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
@@ -0,0 +1,847 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Board Execution Handler
6
+ *
7
+ * Handles PM Board execution requests from a developer's backend on behalf
8
+ * of end users. Each execution is isolated — no shared context between
9
+ * end users.
10
+ *
11
+ * Flow:
12
+ * 1. Validate boardTemplateId against deployment's allowedBoardTemplateIds
13
+ * 2. Load the referenced board template from .mstro/pm/boards/
14
+ * 3. Create an isolated working directory (git worktree)
15
+ * 4. Use headless Claude Code to customize the board from end-user prompt
16
+ * 5. Trigger PM Board "implement all" execution via PlanExecutor
17
+ * 6. Collect results and return them
18
+ *
19
+ * Board executions are long-running — returns a job ID immediately with
20
+ * polling for status and results.
21
+ *
22
+ * Security: End-user prompts are untrusted input. They are always passed
23
+ * as user messages, never injected into system instructions.
24
+ */
25
+
26
+ import { execSync } from 'node:child_process';
27
+ import { randomUUID } from 'node:crypto';
28
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { HeadlessRunner } from '../../cli/headless/runner.js';
32
+ import { PlanExecutor } from '../plan/executor.js';
33
+ import { parseBoardDirectory, resolvePmDir } from '../plan/parser.js';
34
+ import { readOwnerApiCredential } from './deploy-ai-service.js';
35
+
36
+ // ========== Prompt Sanitization ==========
37
+
38
+ /**
39
+ * Maximum allowed length for an end-user prompt.
40
+ * Prevents memory abuse and ensures reasonable prompt sizes.
41
+ */
42
+ const MAX_END_USER_PROMPT_LENGTH = 100_000;
43
+
44
+ /**
45
+ * Sanitize an end-user prompt before passing it to the AI.
46
+ *
47
+ * SECURITY: End-user prompts are untrusted. This function:
48
+ * 1. Strips system instruction XML delimiters to prevent prompt escape
49
+ * 2. Removes null bytes and zero-width characters used for evasion
50
+ * 3. Truncates to MAX_END_USER_PROMPT_LENGTH
51
+ *
52
+ * Note: This does NOT strip tool-use instructions or path traversal text —
53
+ * those are handled by the Security Bouncer (tool execution level) and
54
+ * the isolated working directory (filesystem level).
55
+ */
56
+ export function sanitizeEndUserPrompt(prompt: string): string {
57
+ let sanitized = prompt;
58
+
59
+ // Strip system instruction XML tags that could break prompt structure
60
+ sanitized = sanitized.replace(/<\/?system-instruction>/gi, '');
61
+
62
+ // Remove null bytes
63
+ sanitized = sanitized.replace(/\x00/g, '');
64
+
65
+ // Remove zero-width characters used for evasion
66
+ // U+200B Zero Width Space, U+200C Zero Width Non-Joiner,
67
+ // U+200D Zero Width Joiner, U+FEFF Byte Order Mark
68
+ sanitized = sanitized.replace(/\u200B|\u200C|\u200D|\uFEFF/g, '');
69
+
70
+ // Truncate to max length
71
+ if (sanitized.length > MAX_END_USER_PROMPT_LENGTH) {
72
+ sanitized = sanitized.slice(0, MAX_END_USER_PROMPT_LENGTH);
73
+ }
74
+
75
+ return sanitized;
76
+ }
77
+
78
+ // ========== Types ==========
79
+
80
+ export interface BoardExecutionRequest {
81
+ /** Board template to execute (must be in deployment's allowedBoardTemplateIds) */
82
+ boardTemplateId: string;
83
+ /** The end user's prompt (untrusted input) */
84
+ endUserPrompt: string;
85
+ /** Unique identifier for the end user (for isolation + rate tracking) */
86
+ endUserId: string;
87
+ /** Deployment that owns this execution */
88
+ deploymentId: string;
89
+ }
90
+
91
+ export interface BoardExecutionConfig {
92
+ deploymentId: string;
93
+ aiEnabled: boolean;
94
+ allowedAiCapabilities: string[];
95
+ /** Board template IDs this deployment is allowed to execute */
96
+ allowedBoardTemplateIds: string[];
97
+ /** Max concurrent board executions per deployment */
98
+ maxConcurrentBoardExecutions: number;
99
+ /** Max board executions per minute (null = unlimited) */
100
+ maxBoardExecutionsPerMinute: number | null;
101
+ defaultModel: string;
102
+ workingDir: string;
103
+ }
104
+
105
+ export type BoardExecutionErrorCode =
106
+ | 'CAPABILITY_DENIED'
107
+ | 'AI_DISABLED'
108
+ | 'INVALID_BOARD_TEMPLATE'
109
+ | 'BOARD_TEMPLATE_NOT_FOUND'
110
+ | 'RATE_LIMIT_EXCEEDED'
111
+ | 'CONCURRENT_LIMIT_EXCEEDED'
112
+ | 'INVALID_REQUEST'
113
+ | 'EXECUTION_FAILED';
114
+
115
+ export interface BoardExecutionError {
116
+ code: BoardExecutionErrorCode;
117
+ message: string;
118
+ }
119
+
120
+ export type BoardExecutionJobStatus =
121
+ | 'customizing'
122
+ | 'executing'
123
+ | 'completed'
124
+ | 'failed'
125
+ | 'cancelled';
126
+
127
+ export interface BoardExecutionProgress {
128
+ phase: 'isolating' | 'customizing' | 'executing' | 'collecting' | 'done';
129
+ issuesTotal: number;
130
+ issuesCompleted: number;
131
+ currentWaveIds: string[];
132
+ }
133
+
134
+ export interface BoardExecutionJobResult {
135
+ completed: boolean;
136
+ issuesTotal: number;
137
+ issuesCompleted: number;
138
+ issuesFailed: number;
139
+ /** Output artifact contents keyed by filename */
140
+ outputs: Record<string, string>;
141
+ durationMs: number;
142
+ }
143
+
144
+ export interface BoardExecutionStatusResult {
145
+ jobId: string;
146
+ status: BoardExecutionJobStatus;
147
+ progress: BoardExecutionProgress;
148
+ result: BoardExecutionJobResult | null;
149
+ error: string | null;
150
+ }
151
+
152
+ export type StartBoardExecutionResult =
153
+ | { ok: true; jobId: string }
154
+ | { ok: false; error: BoardExecutionError };
155
+
156
+ // ========== Internal Job Type ==========
157
+
158
+ interface BoardExecutionJob {
159
+ jobId: string;
160
+ deploymentId: string;
161
+ endUserId: string;
162
+ boardTemplateId: string;
163
+ endUserPrompt: string;
164
+ status: BoardExecutionJobStatus;
165
+ progress: BoardExecutionProgress;
166
+ result: BoardExecutionJobResult | null;
167
+ error: string | null;
168
+ createdAt: number;
169
+ updatedAt: number;
170
+ isolatedDir: string | null;
171
+ }
172
+
173
+ // ========== Rate Limiter ==========
174
+
175
+ interface RateBucket {
176
+ timestamps: number[];
177
+ activeExecutions: number;
178
+ }
179
+
180
+ const rateBuckets = new Map<string, RateBucket>();
181
+
182
+ function getBucket(deploymentId: string): RateBucket {
183
+ let bucket = rateBuckets.get(deploymentId);
184
+ if (!bucket) {
185
+ bucket = { timestamps: [], activeExecutions: 0 };
186
+ rateBuckets.set(deploymentId, bucket);
187
+ }
188
+ return bucket;
189
+ }
190
+
191
+ function pruneTimestamps(bucket: RateBucket): void {
192
+ const oneMinuteAgo = Date.now() - 60_000;
193
+ while (bucket.timestamps.length > 0 && bucket.timestamps[0] < oneMinuteAgo) {
194
+ bucket.timestamps.shift();
195
+ }
196
+ }
197
+
198
+ function checkRateLimit(
199
+ config: BoardExecutionConfig,
200
+ ): BoardExecutionError | null {
201
+ const bucket = getBucket(config.deploymentId);
202
+
203
+ // Check concurrent executions
204
+ if (bucket.activeExecutions >= config.maxConcurrentBoardExecutions) {
205
+ return {
206
+ code: 'CONCURRENT_LIMIT_EXCEEDED',
207
+ message: `Deployment has reached the maximum of ${config.maxConcurrentBoardExecutions} concurrent board executions. Wait for an existing execution to complete.`,
208
+ };
209
+ }
210
+
211
+ // Check executions per minute
212
+ if (config.maxBoardExecutionsPerMinute !== null) {
213
+ pruneTimestamps(bucket);
214
+ if (bucket.timestamps.length >= config.maxBoardExecutionsPerMinute) {
215
+ return {
216
+ code: 'RATE_LIMIT_EXCEEDED',
217
+ message: `Deployment has exceeded the rate limit of ${config.maxBoardExecutionsPerMinute} board executions per minute. Try again shortly.`,
218
+ };
219
+ }
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ function recordExecutionStart(deploymentId: string): void {
226
+ const bucket = getBucket(deploymentId);
227
+ bucket.timestamps.push(Date.now());
228
+ bucket.activeExecutions++;
229
+ }
230
+
231
+ function recordExecutionEnd(deploymentId: string): void {
232
+ const bucket = getBucket(deploymentId);
233
+ bucket.activeExecutions = Math.max(0, bucket.activeExecutions - 1);
234
+ }
235
+
236
+ // ========== Job Store ==========
237
+
238
+ /** Retention window for completed/failed jobs before cleanup (5 minutes) */
239
+ const JOB_RETENTION_MS = 300_000;
240
+
241
+ const jobs = new Map<string, BoardExecutionJob>();
242
+
243
+ function createJob(request: BoardExecutionRequest): BoardExecutionJob {
244
+ const jobId = `board-exec-${request.deploymentId}-${randomUUID()}`;
245
+ const now = Date.now();
246
+
247
+ const job: BoardExecutionJob = {
248
+ jobId,
249
+ deploymentId: request.deploymentId,
250
+ endUserId: request.endUserId,
251
+ boardTemplateId: request.boardTemplateId,
252
+ endUserPrompt: request.endUserPrompt,
253
+ status: 'customizing',
254
+ progress: {
255
+ phase: 'isolating',
256
+ issuesTotal: 0,
257
+ issuesCompleted: 0,
258
+ currentWaveIds: [],
259
+ },
260
+ result: null,
261
+ error: null,
262
+ createdAt: now,
263
+ updatedAt: now,
264
+ isolatedDir: null,
265
+ };
266
+
267
+ jobs.set(jobId, job);
268
+ return job;
269
+ }
270
+
271
+ function updateJob(jobId: string, updates: Partial<BoardExecutionJob>): void {
272
+ const job = jobs.get(jobId);
273
+ if (!job) return;
274
+ Object.assign(job, updates, { updatedAt: Date.now() });
275
+ }
276
+
277
+ function scheduleJobCleanup(jobId: string): void {
278
+ setTimeout(() => {
279
+ const job = jobs.get(jobId);
280
+ if (job && job.status !== 'customizing' && job.status !== 'executing') {
281
+ if (job.isolatedDir) {
282
+ cleanupIsolatedDir(job.isolatedDir);
283
+ }
284
+ jobs.delete(jobId);
285
+ }
286
+ }, JOB_RETENTION_MS);
287
+ }
288
+
289
+ // ========== Isolation ==========
290
+
291
+ function isGitRepo(dir: string): boolean {
292
+ try {
293
+ execSync('git rev-parse --is-inside-work-tree', { cwd: dir, stdio: 'pipe' });
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Create an isolated working directory for a board execution.
302
+ *
303
+ * Uses git worktree for git repos (efficient — shares object store).
304
+ * Falls back to a filtered directory copy for non-git repos.
305
+ *
306
+ * .mstro/pm/ is gitignored, so it is always copied manually.
307
+ */
308
+ function createIsolatedDir(workingDir: string): string {
309
+ const prefix = join(tmpdir(), 'mstro-board-exec-');
310
+
311
+ if (isGitRepo(workingDir)) {
312
+ const isolatedDir = mkdtempSync(prefix);
313
+ // git worktree needs a non-existent path
314
+ rmSync(isolatedDir, { recursive: true, force: true });
315
+ execSync(`git worktree add --detach "${isolatedDir}"`, {
316
+ cwd: workingDir,
317
+ stdio: 'pipe',
318
+ });
319
+
320
+ // .mstro/ is gitignored — copy the PM directory manually
321
+ const pmDir = join(workingDir, '.mstro', 'pm');
322
+ const isolatedPmDir = join(isolatedDir, '.mstro', 'pm');
323
+ if (existsSync(pmDir) && !existsSync(isolatedPmDir)) {
324
+ mkdirSync(join(isolatedDir, '.mstro'), { recursive: true });
325
+ cpSync(pmDir, isolatedPmDir, { recursive: true });
326
+ }
327
+
328
+ return isolatedDir;
329
+ }
330
+
331
+ // Fallback: filtered directory copy for non-git repos
332
+ const isolatedDir = mkdtempSync(prefix);
333
+ cpSync(workingDir, isolatedDir, {
334
+ recursive: true,
335
+ filter: (src) => !src.includes('node_modules') && !src.includes('.git'),
336
+ });
337
+ return isolatedDir;
338
+ }
339
+
340
+ function cleanupIsolatedDir(isolatedDir: string): void {
341
+ try {
342
+ // Try git worktree remove first
343
+ execSync(`git worktree remove --force "${isolatedDir}"`, { stdio: 'pipe' });
344
+ } catch {
345
+ // Fallback: direct removal
346
+ try {
347
+ rmSync(isolatedDir, { recursive: true, force: true });
348
+ } catch { /* best-effort cleanup */ }
349
+ }
350
+ }
351
+
352
+ // ========== Board Template Loading ==========
353
+
354
+ function validateBoardTemplate(
355
+ workingDir: string,
356
+ boardTemplateId: string,
357
+ ): { issueCount: number } | null {
358
+ const pmDir = resolvePmDir(workingDir);
359
+ if (!pmDir) return null;
360
+
361
+ const boardState = parseBoardDirectory(pmDir, boardTemplateId);
362
+ if (!boardState) return null;
363
+
364
+ return {
365
+ issueCount: boardState.issues.filter(i => i.type !== 'epic').length,
366
+ };
367
+ }
368
+
369
+ // ========== Board Customization ==========
370
+
371
+ /**
372
+ * Build the customization prompt. The system instruction explains the task;
373
+ * the end-user prompt is in a separate, clearly delimited section.
374
+ *
375
+ * SECURITY: The end-user prompt is never interpolated into the system
376
+ * instruction. It appears in the user-message section only.
377
+ */
378
+ function buildCustomizationPrompt(
379
+ boardTemplateId: string,
380
+ endUserPrompt: string,
381
+ isolatedDir: string,
382
+ ): string {
383
+ const pmDir = resolvePmDir(isolatedDir);
384
+ const boardDir = pmDir ? join(pmDir, 'boards', boardTemplateId) : '';
385
+ const backlogDir = boardDir ? join(boardDir, 'backlog') : '';
386
+
387
+ const systemInstruction = `You are customizing a PM Board template for an end user's specific needs.
388
+
389
+ ## Board Template
390
+ Board ID: ${boardTemplateId}
391
+ Board directory: ${boardDir}
392
+ Backlog directory: ${backlogDir}
393
+
394
+ ## Task
395
+
396
+ Read all issue files in the board's backlog directory. Then adapt every issue to fulfill the end user's request below. For each issue:
397
+
398
+ 1. Update the description to be specific to the end user's needs
399
+ 2. Update acceptance criteria to match their requirements
400
+ 3. Update technical notes with relevant implementation details
401
+ 4. Update "Files to Modify" if applicable
402
+ 5. Preserve all YAML front matter fields (id, type, status, priority, blocked_by, etc.)
403
+ 6. Do NOT change issue IDs, dependency edges, or the overall board structure
404
+
405
+ If issues need to be added or removed to properly serve the end user's request, you may do so — but preserve the dependency graph's integrity.
406
+
407
+ ## Rules
408
+
409
+ - All changes must be within ${backlogDir}
410
+ - Preserve YAML front matter structure exactly
411
+ - Keep issue scoping appropriate (1-5 story points each)
412
+ - Ensure blocked_by references remain valid
413
+ - Do NOT modify board.md, STATE.md, or any files outside the backlog
414
+ - Respond briefly describing what you changed`;
415
+
416
+ return [
417
+ '<system-instruction>',
418
+ systemInstruction,
419
+ '</system-instruction>',
420
+ '',
421
+ endUserPrompt,
422
+ ].join('\n');
423
+ }
424
+
425
+ /**
426
+ * Customize a board template via headless Claude Code session.
427
+ * Claude reads the board's issues and adapts them for the end user's prompt.
428
+ */
429
+ async function customizeBoard(
430
+ boardTemplateId: string,
431
+ endUserPrompt: string,
432
+ isolatedDir: string,
433
+ apiKey: string,
434
+ ): Promise<{ completed: boolean; error?: string }> {
435
+ const prompt = buildCustomizationPrompt(boardTemplateId, endUserPrompt, isolatedDir);
436
+
437
+ const runner = new HeadlessRunner({
438
+ workingDir: isolatedDir,
439
+ directPrompt: prompt,
440
+ stallWarningMs: 300_000, // 5 min
441
+ stallKillMs: 900_000, // 15 min
442
+ stallHardCapMs: 1_800_000, // 30 min hard cap
443
+ extraEnv: { ANTHROPIC_API_KEY: apiKey },
444
+ verbose: false,
445
+ deployMode: true, // Activate deploy-specific bouncer patterns
446
+ });
447
+
448
+ const result = await runner.run();
449
+ return { completed: result.completed, error: result.error };
450
+ }
451
+
452
+ // ========== Board Execution ==========
453
+
454
+ /**
455
+ * Run the full board execution ("implement all") via PlanExecutor.
456
+ * Listens to executor events to track progress on the parent job.
457
+ */
458
+ async function executeBoard(
459
+ boardTemplateId: string,
460
+ isolatedDir: string,
461
+ job: BoardExecutionJob,
462
+ apiKey: string,
463
+ ): Promise<BoardExecutionJobResult> {
464
+ const startTime = Date.now();
465
+ const executor = new PlanExecutor(isolatedDir, {
466
+ extraEnv: { ANTHROPIC_API_KEY: apiKey },
467
+ });
468
+
469
+ // Track progress via executor events
470
+ executor.on('waveStarted', (data: { issueIds: string[] }) => {
471
+ const current = jobs.get(job.jobId);
472
+ if (current) {
473
+ updateJob(job.jobId, {
474
+ progress: { ...current.progress, currentWaveIds: data.issueIds },
475
+ });
476
+ }
477
+ });
478
+
479
+ executor.on('issueCompleted', () => {
480
+ const current = jobs.get(job.jobId);
481
+ if (current) {
482
+ updateJob(job.jobId, {
483
+ progress: {
484
+ ...current.progress,
485
+ issuesCompleted: current.progress.issuesCompleted + 1,
486
+ currentWaveIds: [],
487
+ },
488
+ });
489
+ }
490
+ });
491
+
492
+ await executor.startBoard(boardTemplateId);
493
+
494
+ // Collect output artifacts
495
+ const outputs = collectOutputs(isolatedDir, boardTemplateId);
496
+ const metrics = executor.getMetrics();
497
+
498
+ return {
499
+ completed: executor.getStatus() === 'complete',
500
+ issuesTotal: metrics.issuesAttempted,
501
+ issuesCompleted: metrics.issuesCompleted,
502
+ issuesFailed: metrics.issuesAttempted - metrics.issuesCompleted,
503
+ outputs,
504
+ durationMs: Date.now() - startTime,
505
+ };
506
+ }
507
+
508
+ // ========== Result Collection ==========
509
+
510
+ function collectOutputs(
511
+ isolatedDir: string,
512
+ boardTemplateId: string,
513
+ ): Record<string, string> {
514
+ const pmDir = resolvePmDir(isolatedDir);
515
+ if (!pmDir) return {};
516
+
517
+ const outDir = join(pmDir, 'boards', boardTemplateId, 'out');
518
+ if (!existsSync(outDir)) return {};
519
+
520
+ const outputs: Record<string, string> = {};
521
+ try {
522
+ for (const file of readdirSync(outDir)) {
523
+ if (file.endsWith('.md')) {
524
+ outputs[file] = readFileSync(join(outDir, file), 'utf-8');
525
+ }
526
+ }
527
+ } catch { /* non-fatal */ }
528
+
529
+ return outputs;
530
+ }
531
+
532
+ // ========== Background Execution ==========
533
+
534
+ /**
535
+ * Orchestrates the full board execution lifecycle. Updates job state
536
+ * as it progresses through isolation, customization, execution, and
537
+ * result collection phases.
538
+ */
539
+ async function runBoardExecution(
540
+ job: BoardExecutionJob,
541
+ config: BoardExecutionConfig,
542
+ apiKey: string,
543
+ ): Promise<void> {
544
+ try {
545
+ // Phase 1: Create isolated working directory
546
+ updateJob(job.jobId, {
547
+ progress: { ...job.progress, phase: 'isolating' },
548
+ });
549
+
550
+ let isolatedDir: string;
551
+ try {
552
+ isolatedDir = createIsolatedDir(config.workingDir);
553
+ } catch (error: unknown) {
554
+ const message = error instanceof Error ? error.message : String(error);
555
+ updateJob(job.jobId, {
556
+ status: 'failed',
557
+ error: `Failed to create isolated directory: ${message}`,
558
+ });
559
+ return;
560
+ }
561
+
562
+ updateJob(job.jobId, { isolatedDir });
563
+
564
+ // Phase 2: Customize board via headless Claude Code
565
+ updateJob(job.jobId, {
566
+ status: 'customizing',
567
+ progress: { ...job.progress, phase: 'customizing' },
568
+ });
569
+
570
+ const customization = await customizeBoard(
571
+ job.boardTemplateId,
572
+ sanitizeEndUserPrompt(job.endUserPrompt),
573
+ isolatedDir,
574
+ apiKey,
575
+ );
576
+
577
+ if (!customization.completed) {
578
+ updateJob(job.jobId, {
579
+ status: 'failed',
580
+ error: `Board customization failed: ${customization.error || 'Unknown error'}`,
581
+ });
582
+ return;
583
+ }
584
+
585
+ // Phase 3: Execute board ("implement all")
586
+ updateJob(job.jobId, {
587
+ status: 'executing',
588
+ progress: { ...job.progress, phase: 'executing' },
589
+ });
590
+
591
+ const result = await executeBoard(
592
+ job.boardTemplateId,
593
+ isolatedDir,
594
+ job,
595
+ apiKey,
596
+ );
597
+
598
+ // Phase 4: Store results
599
+ updateJob(job.jobId, {
600
+ status: result.completed ? 'completed' : 'failed',
601
+ progress: {
602
+ phase: 'done',
603
+ issuesTotal: result.issuesTotal,
604
+ issuesCompleted: result.issuesCompleted,
605
+ currentWaveIds: [],
606
+ },
607
+ result,
608
+ error: result.completed ? null : 'Board execution did not complete all issues',
609
+ });
610
+ } catch (error: unknown) {
611
+ const message = error instanceof Error ? error.message : String(error);
612
+ updateJob(job.jobId, {
613
+ status: 'failed',
614
+ error: message,
615
+ });
616
+ } finally {
617
+ recordExecutionEnd(job.deploymentId);
618
+ scheduleJobCleanup(job.jobId);
619
+ }
620
+ }
621
+
622
+ // ========== Public API ==========
623
+
624
+ /**
625
+ * Start a board execution for an end user. Returns a job ID immediately.
626
+ * The execution runs asynchronously — poll with getBoardExecutionStatus().
627
+ *
628
+ * Validates the deployment config, checks rate limits, verifies the board
629
+ * template exists and is allowed, then launches the background execution.
630
+ *
631
+ * @returns Structured result with either the job ID or an error.
632
+ */
633
+ export function startBoardExecution(
634
+ request: BoardExecutionRequest,
635
+ config: BoardExecutionConfig,
636
+ ): StartBoardExecutionResult {
637
+ // ── Validate request ───────────────────────────────────────
638
+ if (!request.endUserPrompt || request.endUserPrompt.trim().length === 0) {
639
+ return {
640
+ ok: false,
641
+ error: { code: 'INVALID_REQUEST', message: 'endUserPrompt is required and must not be empty.' },
642
+ };
643
+ }
644
+
645
+ if (request.endUserPrompt.length > MAX_END_USER_PROMPT_LENGTH) {
646
+ return {
647
+ ok: false,
648
+ error: {
649
+ code: 'INVALID_REQUEST',
650
+ message: `endUserPrompt exceeds the maximum allowed length of ${MAX_END_USER_PROMPT_LENGTH.toLocaleString()} characters.`,
651
+ },
652
+ };
653
+ }
654
+
655
+ if (!request.endUserId || request.endUserId.trim().length === 0) {
656
+ return {
657
+ ok: false,
658
+ error: { code: 'INVALID_REQUEST', message: 'endUserId is required.' },
659
+ };
660
+ }
661
+
662
+ if (!request.boardTemplateId || request.boardTemplateId.trim().length === 0) {
663
+ return {
664
+ ok: false,
665
+ error: { code: 'INVALID_REQUEST', message: 'boardTemplateId is required.' },
666
+ };
667
+ }
668
+
669
+ // ── Validate boardTemplateId format (path traversal defense) ─
670
+ if (/[/\\]|\.\.|\x00/.test(request.boardTemplateId)) {
671
+ return {
672
+ ok: false,
673
+ error: {
674
+ code: 'INVALID_BOARD_TEMPLATE',
675
+ message: 'boardTemplateId contains invalid characters (/, \\, .., or null bytes).',
676
+ },
677
+ };
678
+ }
679
+
680
+ // ── Validate AI is enabled ─────────────────────────────────
681
+ if (!config.aiEnabled) {
682
+ return {
683
+ ok: false,
684
+ error: { code: 'AI_DISABLED', message: 'AI features are not enabled for this deployment.' },
685
+ };
686
+ }
687
+
688
+ // ── Validate board-execution capability ────────────────────
689
+ if (!config.allowedAiCapabilities.includes('board-execution')) {
690
+ return {
691
+ ok: false,
692
+ error: {
693
+ code: 'CAPABILITY_DENIED',
694
+ message: "This deployment does not have the 'board-execution' AI capability enabled.",
695
+ },
696
+ };
697
+ }
698
+
699
+ // ── Validate board template is allowed ─────────────────────
700
+ if (!config.allowedBoardTemplateIds.includes(request.boardTemplateId)) {
701
+ return {
702
+ ok: false,
703
+ error: {
704
+ code: 'INVALID_BOARD_TEMPLATE',
705
+ message: `Board template '${request.boardTemplateId}' is not allowed for this deployment.`,
706
+ },
707
+ };
708
+ }
709
+
710
+ // ── Validate board template exists ─────────────────────────
711
+ const template = validateBoardTemplate(config.workingDir, request.boardTemplateId);
712
+ if (!template) {
713
+ return {
714
+ ok: false,
715
+ error: {
716
+ code: 'BOARD_TEMPLATE_NOT_FOUND',
717
+ message: `Board template '${request.boardTemplateId}' not found in project.`,
718
+ },
719
+ };
720
+ }
721
+
722
+ // ── Rate limit checks ─────────────────────────────────────
723
+ const rateLimitError = checkRateLimit(config);
724
+ if (rateLimitError) {
725
+ return { ok: false, error: rateLimitError };
726
+ }
727
+
728
+ // ── Verify API key ─────────────────────────────────────────
729
+ const credential = readOwnerApiCredential();
730
+ if (!credential || credential.type !== 'api-key') {
731
+ return {
732
+ ok: false,
733
+ error: {
734
+ code: 'EXECUTION_FAILED',
735
+ message: 'Deploy requires an Anthropic API key. Add your key in Deploy \u2192 AI Config or set ANTHROPIC_API_KEY in your environment.',
736
+ },
737
+ };
738
+ }
739
+
740
+ // ── Create job and launch background execution ─────────────
741
+ const job = createJob(request);
742
+ job.progress.issuesTotal = template.issueCount;
743
+
744
+ recordExecutionStart(config.deploymentId);
745
+
746
+ // Fire-and-forget — execution runs in background, errors captured in job
747
+ runBoardExecution(job, config, credential.key).catch(() => {});
748
+
749
+ return { ok: true, jobId: job.jobId };
750
+ }
751
+
752
+ /**
753
+ * Get the current status of a board execution job.
754
+ *
755
+ * Optionally pass endUserId to enforce isolation — returns null if the
756
+ * job belongs to a different end user.
757
+ *
758
+ * @returns Job status or null if not found / access denied.
759
+ */
760
+ export function getBoardExecutionStatus(
761
+ jobId: string,
762
+ endUserId?: string,
763
+ ): BoardExecutionStatusResult | null {
764
+ const job = jobs.get(jobId);
765
+ if (!job) return null;
766
+
767
+ // Enforce end-user isolation when endUserId is provided
768
+ if (endUserId !== undefined && job.endUserId !== endUserId) {
769
+ return null;
770
+ }
771
+
772
+ return {
773
+ jobId: job.jobId,
774
+ status: job.status,
775
+ progress: { ...job.progress },
776
+ result: job.result,
777
+ error: job.error,
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Get the current rate limit state for a deployment's board executions.
783
+ * Useful for status/monitoring endpoints.
784
+ */
785
+ export function getDeploymentBoardExecutionState(deploymentId: string): {
786
+ executionsInLastMinute: number;
787
+ activeExecutions: number;
788
+ } {
789
+ const bucket = getBucket(deploymentId);
790
+ pruneTimestamps(bucket);
791
+ return {
792
+ executionsInLastMinute: bucket.timestamps.length,
793
+ activeExecutions: bucket.activeExecutions,
794
+ };
795
+ }
796
+
797
+ /**
798
+ * Reset rate limit state for a deployment's board executions.
799
+ * Call when a deployment is deleted.
800
+ */
801
+ export function resetDeploymentBoardExecutionRateLimit(
802
+ deploymentId: string,
803
+ ): void {
804
+ rateBuckets.delete(deploymentId);
805
+ }
806
+
807
+ /**
808
+ * Sweep stale isolated directories left behind by crashed executions.
809
+ *
810
+ * Board execution creates temp dirs prefixed with 'mstro-board-exec-'.
811
+ * If the process crashes before cleanup, these dirs leak. This function
812
+ * removes any that are older than the retention window + buffer.
813
+ *
814
+ * Safe to call on startup or periodically.
815
+ */
816
+ export function sweepStaleIsolatedDirs(): number {
817
+ const prefix = 'mstro-board-exec-';
818
+ const maxAgeMs = JOB_RETENTION_MS * 2; // 10 minutes — generous buffer
819
+ let swept = 0;
820
+
821
+ try {
822
+ const tmpDir = tmpdir();
823
+ const entries = readdirSync(tmpDir);
824
+ const now = Date.now();
825
+
826
+ for (const entry of entries) {
827
+ if (!entry.startsWith(prefix)) continue;
828
+
829
+ const fullPath = join(tmpDir, entry);
830
+ try {
831
+ // Extract timestamp from dir name: mstro-board-exec-<random>
832
+ // Use filesystem stat for age instead of parsing name
833
+ const { mtimeMs } = statSync(fullPath);
834
+ if (now - mtimeMs > maxAgeMs) {
835
+ cleanupIsolatedDir(fullPath);
836
+ swept++;
837
+ }
838
+ } catch {
839
+ // Can't stat or clean — skip
840
+ }
841
+ }
842
+ } catch {
843
+ // Can't read tmpdir — skip
844
+ }
845
+
846
+ return swept;
847
+ }