mstro-app 0.4.2 → 0.4.3

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 (77) hide show
  1. package/bin/mstro.js +119 -40
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +3 -0
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/types.d.ts +4 -1
  6. package/dist/server/cli/headless/types.d.ts.map +1 -1
  7. package/dist/server/services/plan/composer.d.ts +1 -1
  8. package/dist/server/services/plan/composer.d.ts.map +1 -1
  9. package/dist/server/services/plan/composer.js +116 -31
  10. package/dist/server/services/plan/composer.js.map +1 -1
  11. package/dist/server/services/plan/config-installer.d.ts +25 -0
  12. package/dist/server/services/plan/config-installer.d.ts.map +1 -0
  13. package/dist/server/services/plan/config-installer.js +182 -0
  14. package/dist/server/services/plan/config-installer.js.map +1 -0
  15. package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
  16. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
  17. package/dist/server/services/plan/dependency-resolver.js +4 -1
  18. package/dist/server/services/plan/dependency-resolver.js.map +1 -1
  19. package/dist/server/services/plan/executor.d.ts +38 -74
  20. package/dist/server/services/plan/executor.d.ts.map +1 -1
  21. package/dist/server/services/plan/executor.js +271 -459
  22. package/dist/server/services/plan/executor.js.map +1 -1
  23. package/dist/server/services/plan/front-matter.d.ts +18 -0
  24. package/dist/server/services/plan/front-matter.d.ts.map +1 -0
  25. package/dist/server/services/plan/front-matter.js +44 -0
  26. package/dist/server/services/plan/front-matter.js.map +1 -0
  27. package/dist/server/services/plan/output-manager.d.ts +22 -0
  28. package/dist/server/services/plan/output-manager.d.ts.map +1 -0
  29. package/dist/server/services/plan/output-manager.js +97 -0
  30. package/dist/server/services/plan/output-manager.js.map +1 -0
  31. package/dist/server/services/plan/parser.d.ts +18 -2
  32. package/dist/server/services/plan/parser.d.ts.map +1 -1
  33. package/dist/server/services/plan/parser.js +359 -25
  34. package/dist/server/services/plan/parser.js.map +1 -1
  35. package/dist/server/services/plan/prompt-builder.d.ts +17 -0
  36. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
  37. package/dist/server/services/plan/prompt-builder.js +137 -0
  38. package/dist/server/services/plan/prompt-builder.js.map +1 -0
  39. package/dist/server/services/plan/review-gate.d.ts +26 -0
  40. package/dist/server/services/plan/review-gate.d.ts.map +1 -0
  41. package/dist/server/services/plan/review-gate.js +191 -0
  42. package/dist/server/services/plan/review-gate.js.map +1 -0
  43. package/dist/server/services/plan/state-reconciler.d.ts +1 -1
  44. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  45. package/dist/server/services/plan/state-reconciler.js +59 -7
  46. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  47. package/dist/server/services/plan/types.d.ts +66 -0
  48. package/dist/server/services/plan/types.d.ts.map +1 -1
  49. package/dist/server/services/platform.d.ts.map +1 -1
  50. package/dist/server/services/platform.js +11 -0
  51. package/dist/server/services/platform.js.map +1 -1
  52. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  53. package/dist/server/services/websocket/handler.js +14 -0
  54. package/dist/server/services/websocket/handler.js.map +1 -1
  55. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  56. package/dist/server/services/websocket/plan-handlers.js +518 -40
  57. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  58. package/dist/server/services/websocket/types.d.ts +2 -2
  59. package/dist/server/services/websocket/types.d.ts.map +1 -1
  60. package/package.json +1 -2
  61. package/server/cli/headless/claude-invoker.ts +4 -0
  62. package/server/cli/headless/types.ts +4 -1
  63. package/server/services/plan/composer.ts +138 -34
  64. package/server/services/plan/config-installer.ts +187 -0
  65. package/server/services/plan/dependency-resolver.ts +4 -1
  66. package/server/services/plan/executor.ts +278 -487
  67. package/server/services/plan/front-matter.ts +48 -0
  68. package/server/services/plan/output-manager.ts +113 -0
  69. package/server/services/plan/parser.ts +389 -27
  70. package/server/services/plan/prompt-builder.ts +161 -0
  71. package/server/services/plan/review-gate.ts +210 -0
  72. package/server/services/plan/state-reconciler.ts +68 -7
  73. package/server/services/plan/types.ts +99 -1
  74. package/server/services/platform.ts +11 -0
  75. package/server/services/websocket/handler.ts +14 -0
  76. package/server/services/websocket/plan-handlers.ts +629 -44
  77. package/server/services/websocket/types.ts +29 -2
@@ -3,27 +3,51 @@
3
3
  /**
4
4
  * Plan Executor — Wave-based execution with Claude Code Agent Teams.
5
5
  *
6
- * Reads the dependency DAG from .pm/, picks ALL unblocked issues per wave,
7
- * spawns a coordinator Claude session that uses Agent Teams to execute them
8
- * in parallel, then reconciles state and repeats for newly-unblocked issues.
6
+ * Orchestrates the execution loop: picks ready issues, executes waves,
7
+ * runs AI review gate, reconciles state, and repeats.
8
+ *
9
+ * Implementation is split across focused modules:
10
+ * - config-installer.ts — teammate permissions + bouncer MCP install/uninstall
11
+ * - prompt-builder.ts — Agent Teams coordinator prompt construction
12
+ * - output-manager.ts — output path resolution, listing, publishing
13
+ * - review-gate.ts — AI-powered quality gate (review, parse, persist)
14
+ * - front-matter.ts — YAML front matter field editing utility
9
15
  */
10
16
  import { EventEmitter } from 'node:events';
11
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
12
- import { join, resolve } from 'node:path';
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
13
19
  import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
14
20
  import { HeadlessRunner } from '../../cli/headless/index.js';
15
- import { generateMcpConfig } from '../../cli/headless/mcp-config.js';
21
+ import { ConfigInstaller } from './config-installer.js';
16
22
  import { resolveReadyToWork } from './dependency-resolver.js';
17
- import { parsePlanDirectory, resolvePmDir } from './parser.js';
23
+ import { replaceFrontMatterField, setFrontMatterField } from './front-matter.js';
24
+ import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
25
+ import { parseBoardDirectory, parsePlanDirectory, resolvePmDir } from './parser.js';
26
+ import { buildCoordinatorPrompt } from './prompt-builder.js';
27
+ import { appendReviewFeedback, getReviewAttemptCount, MAX_REVIEW_ATTEMPTS, persistReviewResult, reviewIssue } from './review-gate.js';
18
28
  import { reconcileState } from './state-reconciler.js';
19
29
  /** Max teammates per wave. Agent Teams docs recommend 3-5; beyond 5-6 returns diminish. */
20
30
  const MAX_WAVE_SIZE = 5;
31
+ /** Stop after this many consecutive waves with zero completions. */
32
+ const MAX_CONSECUTIVE_EMPTY_WAVES = 3;
33
+ /** Wave execution stall timeouts (ms) */
34
+ const WAVE_STALL_WARNING_MS = 1_800_000; // 30 min — Agent Teams leads are silent while teammates work
35
+ const WAVE_STALL_KILL_MS = 3_600_000; // 60 min — waves run longer
36
+ const WAVE_STALL_HARD_CAP_MS = 7_200_000; // 2 hr hard cap
37
+ const WAVE_STALL_MAX_EXTENSIONS = 10;
21
38
  export class PlanExecutor extends EventEmitter {
22
39
  status = 'idle';
23
40
  workingDir;
24
41
  shouldStop = false;
25
42
  shouldPause = false;
26
43
  epicScope = null;
44
+ /** Board directory path (e.g. /path/.pm/boards/BOARD-001). Used for outputs, reviews, progress. */
45
+ boardDir = null;
46
+ /** Board ID being executed (e.g. "BOARD-001") */
47
+ boardId = null;
48
+ configInstaller;
49
+ /** Flag to prevent start() from clearing scope set by startBoard/startEpic */
50
+ _scopeSetByCall = false;
27
51
  metrics = {
28
52
  issuesCompleted: 0,
29
53
  issuesAttempted: 0,
@@ -34,15 +58,19 @@ export class PlanExecutor extends EventEmitter {
34
58
  constructor(workingDir) {
35
59
  super();
36
60
  this.workingDir = workingDir;
61
+ this.configInstaller = new ConfigInstaller(workingDir);
37
62
  }
38
- getStatus() {
39
- return this.status;
40
- }
41
- getMetrics() {
42
- return { ...this.metrics };
43
- }
63
+ getStatus() { return this.status; }
64
+ getMetrics() { return { ...this.metrics }; }
44
65
  async startEpic(epicPath) {
45
66
  this.epicScope = epicPath;
67
+ this._scopeSetByCall = true;
68
+ return this.start();
69
+ }
70
+ /** Start execution, optionally scoped to a specific board. */
71
+ async startBoard(boardId) {
72
+ this.boardId = boardId;
73
+ this._scopeSetByCall = true;
46
74
  return this.start();
47
75
  }
48
76
  async start() {
@@ -50,37 +78,25 @@ export class PlanExecutor extends EventEmitter {
50
78
  return;
51
79
  this.shouldStop = false;
52
80
  this.shouldPause = false;
81
+ // Reset scoping from previous runs unless explicitly set by startBoard/startEpic
82
+ if (!this._scopeSetByCall) {
83
+ this.epicScope = null;
84
+ this.boardId = null;
85
+ }
86
+ this._scopeSetByCall = false;
53
87
  this.status = 'starting';
54
88
  this.emit('statusChanged', this.status);
55
89
  const startTime = Date.now();
56
90
  this.status = 'executing';
57
91
  this.emit('statusChanged', this.status);
58
- let consecutiveZeroCompletions = 0;
59
- while (!this.shouldStop && !this.shouldPause) {
60
- const readyIssues = this.pickReadyIssues();
61
- if (readyIssues.length === 0)
62
- break;
63
- // Cap wave size per Agent Teams best practices (3-5 teammates optimal).
64
- // Remaining issues will be picked up in subsequent waves.
65
- const waveIssues = readyIssues.slice(0, MAX_WAVE_SIZE);
66
- const completedCount = await this.executeWave(waveIssues);
67
- if (completedCount > 0) {
68
- consecutiveZeroCompletions = 0;
69
- }
70
- else {
71
- consecutiveZeroCompletions++;
72
- // Stop after 3 consecutive waves with zero completions to avoid
73
- // retrying the same failing issues indefinitely.
74
- if (consecutiveZeroCompletions >= 3) {
75
- this.metrics.totalDuration = Date.now() - startTime;
76
- this.status = 'error';
77
- this.emit('statusChanged', this.status);
78
- return;
79
- }
80
- }
81
- }
92
+ this.boardDir = this.resolveBoardDir();
93
+ const stallResult = await this.runWaveLoop();
82
94
  this.metrics.totalDuration = Date.now() - startTime;
83
- if (this.shouldPause) {
95
+ if (stallResult === 'stalled') {
96
+ this.status = 'error';
97
+ this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
98
+ }
99
+ else if (this.shouldPause) {
84
100
  this.status = 'paused';
85
101
  }
86
102
  else if (this.shouldStop) {
@@ -91,19 +107,35 @@ export class PlanExecutor extends EventEmitter {
91
107
  }
92
108
  this.emit('statusChanged', this.status);
93
109
  }
94
- pause() {
95
- this.shouldPause = true;
96
- }
97
- stop() {
98
- this.shouldStop = true;
110
+ /** Run waves until done, paused, stopped, or stalled. Returns 'stalled' if zero-completion cap hit. */
111
+ async runWaveLoop() {
112
+ let consecutiveZeroCompletions = 0;
113
+ while (!this.shouldStop && !this.shouldPause) {
114
+ const readyIssues = this.pickReadyIssues();
115
+ if (readyIssues.length === 0)
116
+ break;
117
+ const completedCount = await this.executeWave(readyIssues.slice(0, MAX_WAVE_SIZE));
118
+ if (completedCount > 0) {
119
+ consecutiveZeroCompletions = 0;
120
+ continue;
121
+ }
122
+ consecutiveZeroCompletions++;
123
+ if (consecutiveZeroCompletions >= MAX_CONSECUTIVE_EMPTY_WAVES)
124
+ return 'stalled';
125
+ }
126
+ return 'done';
99
127
  }
128
+ pause() { this.shouldPause = true; }
129
+ stop() { this.shouldStop = true; }
100
130
  resume() {
101
131
  if (this.status !== 'paused')
102
132
  return Promise.resolve();
103
133
  this.shouldPause = false;
134
+ // Preserve board/epic scope across resume by marking as a scoped call
135
+ this._scopeSetByCall = true;
104
136
  return this.start();
105
137
  }
106
- // ── Wave execution (Agent Teams) ──────────────────────────────
138
+ // ── Wave execution ───────────────────────────────────────────
107
139
  async executeWave(issues) {
108
140
  const waveStart = Date.now();
109
141
  const waveIds = issues.map(i => i.id);
@@ -111,35 +143,34 @@ export class PlanExecutor extends EventEmitter {
111
143
  this.metrics.currentWaveIds = waveIds;
112
144
  this.metrics.issuesAttempted += issues.length;
113
145
  this.emit('waveStarted', { issueIds: waveIds });
114
- // Ensure .pm/out/ exists for execution output
115
- const pmDir = resolvePmDir(this.workingDir);
116
- if (pmDir) {
117
- const outDir = join(pmDir, 'out');
118
- if (!existsSync(outDir))
119
- mkdirSync(outDir, { recursive: true });
120
- }
121
- // Pre-approve tools so teammates don't hit interactive permission prompts
122
- this.installTeammatePermissions();
123
- // Install bouncer .mcp.json so Agent Teams teammates discover it
124
- this.installBouncerForSubagents();
125
- // Mark all wave issues as in_progress
146
+ this.ensureOutputDirs();
147
+ this.configInstaller.installTeammatePermissions();
148
+ this.configInstaller.installBouncerForSubagents();
126
149
  for (const issue of issues) {
127
150
  this.updateIssueFrontMatter(issue.path, 'in_progress');
128
151
  }
129
- const prompt = this.buildCoordinatorPrompt(issues);
152
+ const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
153
+ const pmDir = resolvePmDir(this.workingDir);
154
+ const prompt = buildCoordinatorPrompt({
155
+ issues,
156
+ workingDir: this.workingDir,
157
+ pmDir,
158
+ boardDir: this.boardDir,
159
+ existingDocs,
160
+ resolveOutputPath: (issue) => resolveOutputPath(issue, this.workingDir, this.boardDir),
161
+ });
130
162
  let completedCount = 0;
131
163
  try {
132
164
  const runner = new HeadlessRunner({
133
165
  workingDir: this.workingDir,
134
166
  directPrompt: prompt,
135
- stallWarningMs: 1_800_000, // 30 min — Agent Teams leads are silent while teammates work
136
- stallKillMs: 3_600_000, // 60 min — waves run longer
137
- stallHardCapMs: 7_200_000, // 2 hr hard cap
138
- stallMaxExtensions: 10, // Agent Teams waves need many extensions
167
+ stallWarningMs: WAVE_STALL_WARNING_MS,
168
+ stallKillMs: WAVE_STALL_KILL_MS,
169
+ stallHardCapMs: WAVE_STALL_HARD_CAP_MS,
170
+ stallMaxExtensions: WAVE_STALL_MAX_EXTENSIONS,
139
171
  verbose: process.env.MSTRO_VERBOSE === '1',
140
- extraEnv: {
141
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
142
- },
172
+ disallowedTools: ['TeamCreate', 'TeamDelete', 'TaskCreate', 'TaskUpdate', 'TaskList'],
173
+ extraEnv: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' },
143
174
  outputCallback: (text) => {
144
175
  this.emit('output', { issueId: waveLabel, text });
145
176
  },
@@ -151,8 +182,7 @@ export class PlanExecutor extends EventEmitter {
151
182
  error: result.error || 'Wave did not complete successfully',
152
183
  });
153
184
  }
154
- // Check which issues the agents actually completed by reading disk
155
- completedCount = this.reconcileWaveResults(issues);
185
+ completedCount = await this.reconcileWaveResults(issues);
156
186
  }
157
187
  catch (error) {
158
188
  this.emit('waveError', {
@@ -162,9 +192,8 @@ export class PlanExecutor extends EventEmitter {
162
192
  this.revertIncompleteIssues(issues);
163
193
  }
164
194
  finally {
165
- // Clean up temporary configs — must run even if wave throws
166
- this.uninstallBouncerForSubagents();
167
- this.uninstallTeammatePermissions();
195
+ this.configInstaller.uninstallBouncerForSubagents();
196
+ this.configInstaller.uninstallTeammatePermissions();
168
197
  }
169
198
  this.finalizeWave(issues, waveStart, waveLabel);
170
199
  this.metrics.currentWaveIds = [];
@@ -172,12 +201,11 @@ export class PlanExecutor extends EventEmitter {
172
201
  }
173
202
  /**
174
203
  * Post-wave operations wrapped individually so a failure in one
175
- * (e.g. reconcileState hitting a concurrent write from PlanWatcher)
176
204
  * doesn't prevent the others or kill the while loop in start().
177
205
  */
178
206
  finalizeWave(issues, waveStart, waveLabel) {
179
207
  try {
180
- reconcileState(this.workingDir);
208
+ reconcileState(this.workingDir, this.boardId ?? undefined);
181
209
  this.emit('stateUpdated');
182
210
  }
183
211
  catch (err) {
@@ -187,7 +215,9 @@ export class PlanExecutor extends EventEmitter {
187
215
  });
188
216
  }
189
217
  try {
190
- this.publishOutputs(issues);
218
+ publishOutputs(issues, this.workingDir, this.boardDir, {
219
+ onWarning: (issueId, text) => this.emit('output', { issueId, text: `Warning: ${text}` }),
220
+ });
191
221
  }
192
222
  catch (err) {
193
223
  this.emit('output', {
@@ -205,13 +235,13 @@ export class PlanExecutor extends EventEmitter {
205
235
  });
206
236
  }
207
237
  }
238
+ // ── Review gate orchestration ────────────────────────────────
208
239
  /**
209
- * After a wave, check each issue's status on disk.
210
- * `status: done` in issue front matter is the single completion signal.
211
- * Output doc existence is NOT used as a proxy — code-focused issues
212
- * (bug fixes, refactors) don't produce docs but are still valid completions.
240
+ * After a wave, check each issue's status on disk and run the AI review gate.
241
+ * Issues that agents marked as `done` are moved to `in_review`, reviewed,
242
+ * and either confirmed `done` (passed) or reverted to `todo` (failed).
213
243
  */
214
- reconcileWaveResults(issues) {
244
+ async reconcileWaveResults(issues) {
215
245
  const pmDir = resolvePmDir(this.workingDir);
216
246
  if (!pmDir)
217
247
  return 0;
@@ -223,12 +253,17 @@ export class PlanExecutor extends EventEmitter {
223
253
  const statusMatch = content.match(/^status:\s*(\S+)/m);
224
254
  const currentStatus = statusMatch?.[1] ?? 'unknown';
225
255
  if (currentStatus === 'done') {
226
- this.metrics.issuesCompleted++;
227
- completed++;
228
- this.emit('issueCompleted', issue);
256
+ if (issue.reviewGate === 'none') {
257
+ // Skip review gate — accept agent's done status directly
258
+ this.metrics.issuesCompleted++;
259
+ this.emit('issueCompleted', issue);
260
+ completed++;
261
+ }
262
+ else {
263
+ completed += await this.runReviewGate(issue, pmDir);
264
+ }
229
265
  }
230
266
  else {
231
- // Not done — revert to prior status
232
267
  this.updateIssueFrontMatter(issue.path, issue.status);
233
268
  this.emit('issueError', {
234
269
  issueId: issue.id,
@@ -242,429 +277,189 @@ export class PlanExecutor extends EventEmitter {
242
277
  }
243
278
  return completed;
244
279
  }
245
- // ── Issue picking ─────────────────────────────────────────────
280
+ /** Run the review gate for a single issue that agents marked as done. Returns 1 if passed, 0 otherwise. */
281
+ async runReviewGate(issue, pmDir) {
282
+ const reviewDir = this.boardDir ?? pmDir;
283
+ const attempts = getReviewAttemptCount(reviewDir, issue);
284
+ if (attempts >= MAX_REVIEW_ATTEMPTS) {
285
+ this.updateIssueFrontMatter(issue.path, 'in_review');
286
+ this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
287
+ this.emit('output', { issueId: issue.id, text: 'Review: max attempts reached, keeping in review' });
288
+ return 0;
289
+ }
290
+ this.updateIssueFrontMatter(issue.path, 'in_review');
291
+ this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
292
+ const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
293
+ const result = await reviewIssue({
294
+ workingDir: this.workingDir,
295
+ issue,
296
+ pmDir,
297
+ outputPath,
298
+ onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
299
+ });
300
+ persistReviewResult(reviewDir, issue, result);
301
+ if (result.passed) {
302
+ this.updateIssueFrontMatter(issue.path, 'done');
303
+ this.metrics.issuesCompleted++;
304
+ this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
305
+ this.emit('issueCompleted', issue);
306
+ return 1;
307
+ }
308
+ this.updateIssueFrontMatter(issue.path, 'todo');
309
+ appendReviewFeedback(pmDir, issue, result);
310
+ this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
311
+ this.emit('issueError', {
312
+ issueId: issue.id,
313
+ error: `Review failed: ${result.checks.filter(c => !c.passed).map(c => c.name).join(', ')}`,
314
+ });
315
+ return 0;
316
+ }
317
+ // ── Helpers ──────────────────────────────────────────────────
246
318
  pickReadyIssues() {
247
- const fullState = parsePlanDirectory(this.workingDir);
248
- if (!fullState) {
249
- this.emit('error', 'No .pm/ directory found');
319
+ const pmDir = resolvePmDir(this.workingDir);
320
+ if (!pmDir) {
321
+ this.emit('error', 'No PM directory found');
250
322
  return [];
251
323
  }
252
- if (fullState.state.paused) {
253
- this.emit('error', 'Project is paused');
324
+ const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
325
+ const issues = effectiveBoardId
326
+ ? this.loadBoardIssues(pmDir, effectiveBoardId)
327
+ : this.loadProjectIssues();
328
+ if (!issues)
254
329
  return [];
255
- }
256
- const readyIssues = resolveReadyToWork(fullState.issues, this.epicScope ?? undefined);
330
+ const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
257
331
  if (readyIssues.length === 0) {
258
332
  this.emit('complete', this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked');
333
+ if (effectiveBoardId) {
334
+ this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
335
+ }
259
336
  }
260
337
  return readyIssues;
261
338
  }
262
- // ── Prompt building ───────────────────────────────────────────
263
- /**
264
- * Build the team lead prompt for a wave of issues.
265
- * Uses Agent Teams for true parallel execution as separate processes —
266
- * each teammate gets its own context window and sends idle notifications
267
- * when done. The team is created implicitly by the first Agent(team_name=...) call.
268
- */
269
- buildCoordinatorPrompt(issues) {
270
- const pmDir = resolvePmDir(this.workingDir);
271
- const outDir = pmDir ? join(pmDir, 'out') : join(this.workingDir, '.pm', 'out');
272
- // Collect existing output docs that issues may need as input
273
- const existingDocs = this.listExistingDocs();
274
- const issueBlocks = issues.map(issue => {
275
- const criteria = issue.acceptanceCriteria
276
- .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
277
- .join('\n');
278
- const files = issue.filesToModify.length > 0
279
- ? `\nFiles to modify:\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}`
280
- : '';
281
- // Find predecessor output docs this issue should read
282
- const predecessorDocs = issue.blockedBy
283
- .map(bp => {
284
- const blockerId = bp.replace(/^backlog\//, '').replace(/\.md$/, '');
285
- return existingDocs.find(d => d.toLowerCase().includes(blockerId.toLowerCase()));
286
- })
287
- .filter(Boolean);
288
- const predecessorSection = predecessorDocs.length > 0
289
- ? `\nPredecessor outputs to read:\n${predecessorDocs.map(d => `- ${d}`).join('\n')}`
290
- : '';
291
- return `### ${issue.id}: ${issue.title}
292
-
293
- **Type**: ${issue.type} | **Priority**: ${issue.priority} | **Estimate**: ${issue.estimate ?? 'unestimated'}
294
-
295
- **Description**:
296
- ${issue.description}
297
-
298
- **Acceptance Criteria**:
299
- ${criteria || 'No specific criteria defined.'}
300
-
301
- **Technical Notes**:
302
- ${issue.technicalNotes || 'None'}
303
- ${files}${predecessorSection}
304
-
305
- **Output file**: ${this.resolveOutputPath(issue)}`;
306
- }).join('\n\n---\n\n');
307
- const teamName = `pm-wave-${Date.now()}`;
308
- const teammateSpawns = issues.map(issue => {
309
- const predecessorDocs = issue.blockedBy
310
- .map(bp => {
311
- const blockerId = bp.replace(/^backlog\//, '').replace(/\.md$/, '');
312
- return existingDocs.find(d => d.toLowerCase().includes(blockerId.toLowerCase()));
313
- })
314
- .filter(Boolean);
315
- const predInstr = predecessorDocs.length > 0
316
- ? `Read these predecessor output docs before starting: ${predecessorDocs.join(', ')}. `
317
- : '';
318
- const outputFile = this.resolveOutputPath(issue);
319
- const fileOwnership = issue.filesToModify.length > 0
320
- ? `\n> FILE OWNERSHIP: You may ONLY modify these files: ${issue.filesToModify.join(', ')}. Do not touch files owned by other teammates.`
321
- : '';
322
- return `Spawn teammate **${issue.id.toLowerCase()}** using the **Agent** tool with \`team_name: "${teamName}"\` and \`name: "${issue.id.toLowerCase()}"\`:
323
- > ${predInstr}Work on issue ${issue.id}: ${issue.title}.
324
- > Read the full spec at ${pmDir ? join(pmDir, issue.path) : issue.path}.
325
- > Execute all acceptance criteria.
326
- > CRITICAL: Write ALL output/results to ${outputFile} — this is the handoff artifact for downstream issues.
327
- > After writing output, update the issue front matter: change \`status: in_progress\` to \`status: done\`.
328
- > Do not modify STATE.md. Do not work on anything outside this issue's scope.${fileOwnership}`;
329
- }).join('\n\n');
330
- return `You are the team lead coordinating ${issues.length} issue${issues.length > 1 ? 's' : ''} using Agent Teams.
331
-
332
- ## Project Directory
333
- Working directory: ${this.workingDir}
334
- Plan directory: ${pmDir || '.pm/'}
335
-
336
- ## Issues to Execute
337
-
338
- ${issueBlocks}
339
-
340
- ## Execution Protocol — Agent Teams
341
-
342
- ### Step 1: Spawn teammates
343
-
344
- Spawn all ${issues.length} teammates in parallel by sending a single message with ${issues.length} **Agent** tool calls. Each call must include \`team_name: "${teamName}"\` and a unique \`name\`. The team is created automatically when you spawn the first teammate with \`team_name\` — no separate setup step is needed.
345
-
346
- ${teammateSpawns}
347
-
348
- ### Step 2: Wait for ALL teammates to complete
349
-
350
- CRITICAL: After spawning, you MUST remain active and wait for every single teammate to finish. Each teammate automatically sends you an **idle notification** when they complete their work.
351
-
352
- Track completion against this checklist — ALL must report idle before you proceed:
353
- ${issues.map(i => `- [ ] ${i.id.toLowerCase()}`).join('\n')}
354
-
355
- **Exact teammate names for SendMessage** (use these EXACTLY — messages to wrong names are silently lost):
356
- ${issues.map(i => `- \`${i.id.toLowerCase()}\``).join('\n')}
357
-
358
- While waiting:
359
- - As each teammate goes idle, verify their output file exists on disk using the **Read** tool
360
- - If a teammate has not gone idle after 15 minutes, send them a message using **SendMessage** with \`recipient: "{exact name from list above}"\` to check on their progress
361
- - If a teammate does not respond within 5 more minutes after your SendMessage, assume they stalled: check their output file and issue status on disk. If the output exists and status is done, mark them complete. If not, update the issue status yourself based on whatever partial work exists on disk, then proceed.
362
- - Do NOT proceed to Step 3 until all ${issues.length} teammates have either gone idle or been confirmed stalled and handled
363
-
364
- WARNING: The #1 failure mode is exiting before all teammates finish. If you exit early, all teammate processes are killed and their work is permanently lost. When in doubt, keep waiting. Err on the side of waiting too long rather than exiting too early.
365
-
366
- ### Step 3: Verify outputs
367
-
368
- Once every teammate has gone idle or been handled:
369
- 1. Verify each output file exists in ${outDir}/ using **Read** or **Glob**
370
- 2. Verify each issue's front matter status is \`done\`
371
- 3. If any teammate failed to write output or update status, do it yourself
372
- 4. Do NOT modify STATE.md — the orchestrator handles that
373
-
374
- ### Step 4: Clean up
375
-
376
- After all outputs are verified, tell the team to shut down:
377
- - Use **SendMessage** to send each remaining active teammate a shutdown message
378
- - Then exit — the orchestrator will handle the next wave
379
-
380
- ## Critical Rules
381
-
382
- - The team is created implicitly when you spawn the first teammate with \`team_name\`, and cleaned up when all teammates exit or the lead exits.
383
- - You MUST wait for idle notifications from ALL ${issues.length} teammates before exiting. Exiting early kills all teammate processes and permanently loses their work.
384
- - Each teammate MUST write its output to disk — research only in conversation is LOST.
385
- - Each teammate MUST update the issue front matter status to \`done\`.
386
- - One issue per teammate — no cross-issue work.
387
- - NEVER send a SendMessage to a teammate name that is not in the exact list above — misaddressed messages are silently dropped.`;
388
- }
389
- /**
390
- * Revert issues that stayed in_progress after a failed wave.
391
- */
392
- revertIncompleteIssues(issues) {
393
- const pmDir = resolvePmDir(this.workingDir);
394
- if (!pmDir)
395
- return;
396
- for (const issue of issues) {
397
- const fullPath = join(pmDir, issue.path);
398
- try {
399
- const content = readFileSync(fullPath, 'utf-8');
400
- if (content.match(/^status:\s*in_progress$/m)) {
401
- this.updateIssueFrontMatter(issue.path, issue.status);
402
- }
403
- }
404
- catch { /* file may be gone */ }
339
+ /** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
340
+ loadBoardIssues(pmDir, boardId) {
341
+ const boardState = parseBoardDirectory(pmDir, boardId);
342
+ if (!boardState) {
343
+ this.emit('error', `Board not found: ${boardId}`);
344
+ return null;
405
345
  }
406
- }
407
- // ── Teammate permissions ─────────────────────────────────────
408
- /** Saved content of any pre-existing .claude/settings.json so we can restore it */
409
- savedClaudeSettings = null;
410
- claudeSettingsInstalled = false;
411
- /**
412
- * Pre-approve tools in project .claude/settings.json so Agent Teams
413
- * teammates can work without interactive permission prompts.
414
- * Teammates are separate processes that inherit the lead's permission
415
- * settings. Without pre-approved tools, they hit interactive prompts
416
- * that can't be answered in headless/background mode (known bug #25254).
417
- */
418
- installTeammatePermissions() {
419
- const claudeDir = join(this.workingDir, '.claude');
420
- const settingsPath = join(claudeDir, 'settings.json');
421
- if (!existsSync(claudeDir)) {
422
- mkdirSync(claudeDir, { recursive: true });
423
- }
424
- // Tools that teammates may need during execution
425
- const requiredPermissions = [
426
- 'Bash',
427
- 'Read',
428
- 'Edit',
429
- 'Write',
430
- 'Glob',
431
- 'Grep',
432
- 'WebFetch',
433
- 'WebSearch',
434
- 'Agent',
435
- ];
436
- try {
437
- // Save existing settings
438
- if (existsSync(settingsPath)) {
439
- this.savedClaudeSettings = readFileSync(settingsPath, 'utf-8');
440
- const existing = JSON.parse(this.savedClaudeSettings);
441
- // Merge permissions into existing settings
442
- if (!existing.permissions)
443
- existing.permissions = {};
444
- if (!existing.permissions.allow)
445
- existing.permissions.allow = [];
446
- for (const tool of requiredPermissions) {
447
- if (!existing.permissions.allow.includes(tool)) {
448
- existing.permissions.allow.push(tool);
449
- }
450
- }
451
- writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
452
- }
453
- else {
454
- this.savedClaudeSettings = null;
455
- writeFileSync(settingsPath, JSON.stringify({
456
- permissions: { allow: requiredPermissions },
457
- }, null, 2));
458
- }
459
- this.claudeSettingsInstalled = true;
346
+ if (boardState.state.paused) {
347
+ this.emit('error', 'Board is paused');
348
+ return null;
460
349
  }
461
- catch {
462
- // Non-fatal — teammates may hit permission prompts
350
+ if (boardState.board.status === 'draft') {
351
+ this.activateBoard(pmDir, boardId);
463
352
  }
353
+ else if (boardState.board.status !== 'active') {
354
+ this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
355
+ return null;
356
+ }
357
+ return boardState.issues;
464
358
  }
465
- /**
466
- * Restore original .claude/settings.json after wave execution.
467
- */
468
- uninstallTeammatePermissions() {
469
- if (!this.claudeSettingsInstalled)
470
- return;
471
- const settingsPath = join(this.workingDir, '.claude', 'settings.json');
472
- try {
473
- if (this.savedClaudeSettings !== null) {
474
- writeFileSync(settingsPath, this.savedClaudeSettings);
475
- }
476
- else {
477
- unlinkSync(settingsPath);
478
- }
359
+ /** Load project-level issues (legacy or no boards). Returns null on error. */
360
+ loadProjectIssues() {
361
+ const fullState = parsePlanDirectory(this.workingDir);
362
+ if (!fullState) {
363
+ this.emit('error', 'No PM directory found');
364
+ return null;
479
365
  }
480
- catch {
481
- // Best effort
366
+ if (fullState.state.paused) {
367
+ this.emit('error', 'Project is paused');
368
+ return null;
482
369
  }
483
- this.savedClaudeSettings = null;
484
- this.claudeSettingsInstalled = false;
370
+ return fullState.issues;
485
371
  }
486
- // ── Bouncer propagation for sub-agents ─────────────────────
487
- /** Saved content of any pre-existing .mcp.json so we can restore it */
488
- savedMcpJson = null;
489
- mcpJsonInstalled = false;
490
- /**
491
- * Write .mcp.json in the working directory so Agent Teams teammates
492
- * (separate processes) auto-discover the bouncer MCP server.
493
- * This is essential — teammates don't inherit --mcp-config or
494
- * --permission-prompt-tool from the team lead. .mcp.json project-level
495
- * discovery + global PreToolUse hooks are the two bouncer paths for teammates.
496
- *
497
- * Also generates ~/.mstro/mcp-config.json for the team lead (--mcp-config).
498
- */
499
- installBouncerForSubagents() {
500
- const mcpJsonPath = join(this.workingDir, '.mcp.json');
501
- // Generate the standard MCP config (for parent --mcp-config) and reuse for sub-agents
372
+ /** Activate a draft board by updating its status in board.md. */
373
+ activateBoard(pmDir, boardId) {
374
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
375
+ if (!existsSync(boardMdPath))
376
+ return;
502
377
  try {
503
- const generatedPath = generateMcpConfig(this.workingDir);
504
- if (!generatedPath)
505
- return;
506
- const mcpConfig = readFileSync(generatedPath, 'utf-8');
507
- // Save any existing .mcp.json
508
- if (existsSync(mcpJsonPath)) {
509
- this.savedMcpJson = readFileSync(mcpJsonPath, 'utf-8');
510
- // Merge: add bouncer to existing config
511
- const existing = JSON.parse(this.savedMcpJson);
512
- const generated = JSON.parse(mcpConfig);
513
- existing.mcpServers = {
514
- ...existing.mcpServers,
515
- 'mstro-bouncer': generated.mcpServers['mstro-bouncer'],
516
- };
517
- writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2));
518
- }
519
- else {
520
- writeFileSync(mcpJsonPath, mcpConfig);
521
- }
522
- this.mcpJsonInstalled = true;
523
- }
524
- catch {
525
- // Non-fatal: parent has MCP via --mcp-config, teammates fall back to PreToolUse hooks
378
+ const content = readFileSync(boardMdPath, 'utf-8');
379
+ writeFileSync(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
526
380
  }
381
+ catch { /* non-fatal — pickReadyIssues will re-check */ }
527
382
  }
528
- /**
529
- * Restore or remove .mcp.json after execution.
530
- */
531
- uninstallBouncerForSubagents() {
532
- if (!this.mcpJsonInstalled)
383
+ /** Check if all issues in a board are done and mark board as completed. */
384
+ tryCompleteBoardIfDone(pmDir, boardId, issues) {
385
+ const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
386
+ if (!allDone)
387
+ return;
388
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
389
+ if (!existsSync(boardMdPath))
533
390
  return;
534
- const mcpJsonPath = join(this.workingDir, '.mcp.json');
535
391
  try {
536
- if (this.savedMcpJson !== null) {
537
- // Restore the original
538
- writeFileSync(mcpJsonPath, this.savedMcpJson);
539
- }
540
- else {
541
- // We created it — remove it
542
- unlinkSync(mcpJsonPath);
543
- }
392
+ let content = readFileSync(boardMdPath, 'utf-8');
393
+ content = replaceFrontMatterField(content, 'status', 'completed');
394
+ content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
395
+ writeFileSync(boardMdPath, content, 'utf-8');
544
396
  }
545
- catch {
546
- // Best effort cleanup
547
- }
548
- this.savedMcpJson = null;
549
- this.mcpJsonInstalled = false;
550
- }
551
- // ── Helpers ───────────────────────────────────────────────────
552
- /**
553
- * Resolve the canonical output path for an issue in .pm/out/.
554
- * This is the PM system's internal execution artifact — always under
555
- * PM control. User-facing delivery to output_file happens via publishOutputs().
556
- */
557
- resolveOutputPath(issue) {
558
- const pmDir = resolvePmDir(this.workingDir);
559
- const outDir = pmDir ? join(pmDir, 'out') : join(this.workingDir, '.pm', 'out');
560
- return join(outDir, `${issue.id}-${this.slugify(issue.title)}.md`);
397
+ catch { /* non-fatal */ }
561
398
  }
562
- /**
563
- * List existing execution output docs in .pm/out/.
564
- * Single canonical location — no split-brain lookup.
565
- */
566
- listExistingDocs() {
399
+ resolveActiveBoardId() {
567
400
  const pmDir = resolvePmDir(this.workingDir);
568
401
  if (!pmDir)
569
- return [];
570
- const outDir = join(pmDir, 'out');
571
- if (!existsSync(outDir))
572
- return [];
402
+ return null;
573
403
  try {
574
- return readdirSync(outDir)
575
- .filter(f => f.endsWith('.md'))
576
- .map(f => join(outDir, f));
404
+ const workspacePath = join(pmDir, 'workspace.json');
405
+ if (!existsSync(workspacePath))
406
+ return null;
407
+ const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
408
+ return workspace.activeBoardId ?? null;
577
409
  }
578
410
  catch {
579
- return [];
411
+ return null;
580
412
  }
581
413
  }
582
- /**
583
- * Copy confirmed-done outputs from .pm/out/ to user-specified output_file paths.
584
- * Only copies for issues that completed successfully and have output_file set.
585
- * Failures are non-fatal — the canonical artifact in .pm/out/ is always safe.
586
- */
587
- publishOutputs(issues) {
414
+ revertIncompleteIssues(issues) {
588
415
  const pmDir = resolvePmDir(this.workingDir);
589
416
  if (!pmDir)
590
417
  return;
591
418
  for (const issue of issues) {
592
- this.publishSingleOutput(issue, pmDir);
593
- }
594
- }
595
- /** Copy a single confirmed-done output to the user-specified output_file path. */
596
- publishSingleOutput(issue, pmDir) {
597
- if (!issue.outputFile)
598
- return;
599
- // Only publish for confirmed-done issues
600
- try {
601
- const content = readFileSync(join(pmDir, issue.path), 'utf-8');
602
- if (!content.match(/^status:\s*done$/m))
603
- return;
604
- }
605
- catch {
606
- return;
607
- }
608
- const srcPath = this.resolveOutputPath(issue);
609
- if (!existsSync(srcPath))
610
- return;
611
- // Guard against path traversal — output_file must resolve within workingDir
612
- const destPath = resolve(this.workingDir, issue.outputFile);
613
- if (!destPath.startsWith(`${this.workingDir}/`) && destPath !== this.workingDir) {
614
- this.emit('output', {
615
- issueId: issue.id,
616
- text: `Warning: output_file "${issue.outputFile}" escapes project directory — skipping`,
617
- });
618
- return;
619
- }
620
- try {
621
- const destDir = join(destPath, '..');
622
- if (!existsSync(destDir))
623
- mkdirSync(destDir, { recursive: true });
624
- copyFileSync(srcPath, destPath);
625
- }
626
- catch {
627
- this.emit('output', {
628
- issueId: issue.id,
629
- text: `Warning: could not copy output to ${issue.outputFile}`,
630
- });
419
+ const fullPath = join(pmDir, issue.path);
420
+ try {
421
+ const content = readFileSync(fullPath, 'utf-8');
422
+ if (content.match(/^status:\s*in_progress$/m)) {
423
+ this.updateIssueFrontMatter(issue.path, issue.status);
424
+ }
425
+ }
426
+ catch { /* file may be gone */ }
631
427
  }
632
428
  }
633
- slugify(text) {
634
- return text
635
- .toLowerCase()
636
- .replace(/[^a-z0-9]+/g, '-')
637
- .replace(/^-+|-+$/g, '')
638
- .slice(0, 60);
639
- }
640
429
  updateIssueFrontMatter(issuePath, newStatus) {
641
430
  const pmDir = resolvePmDir(this.workingDir);
642
431
  if (!pmDir)
643
432
  return;
644
- const fullPath = join(pmDir, issuePath);
645
433
  try {
646
- let content = readFileSync(fullPath, 'utf-8');
647
- content = content.replace(/^(status:\s*).+$/m, `$1${newStatus}`);
648
- writeFileSync(fullPath, content, 'utf-8');
434
+ setFrontMatterField(join(pmDir, issuePath), 'status', newStatus);
649
435
  }
650
- catch {
651
- // Ignore errors — file may have been moved
436
+ catch { /* file may have been moved */ }
437
+ }
438
+ ensureOutputDirs() {
439
+ if (this.boardDir) {
440
+ const boardOutDir = join(this.boardDir, 'out');
441
+ if (!existsSync(boardOutDir))
442
+ mkdirSync(boardOutDir, { recursive: true });
443
+ }
444
+ else {
445
+ const pmDir = resolvePmDir(this.workingDir);
446
+ if (pmDir) {
447
+ const outDir = join(pmDir, 'out');
448
+ if (!existsSync(outDir))
449
+ mkdirSync(outDir, { recursive: true });
450
+ }
652
451
  }
653
452
  }
654
- /**
655
- * Append a progress log entry after a wave completes.
656
- * Re-reads issue files from disk to determine which actually completed.
657
- */
658
453
  appendProgressEntry(issues, waveStart) {
659
454
  const pmDir = resolvePmDir(this.workingDir);
660
455
  if (!pmDir)
661
456
  return;
662
- const progressPath = join(pmDir, 'progress.md');
663
- if (!existsSync(progressPath))
664
- return;
457
+ // Board-scoped progress log
458
+ const progressPath = this.boardDir
459
+ ? join(this.boardDir, 'progress.md')
460
+ : join(pmDir, 'progress.md');
665
461
  const durationMin = Math.round((Date.now() - waveStart) / 60_000);
666
462
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
667
- // Re-read issue statuses from disk to get accurate completion count
668
463
  const completed = [];
669
464
  const failed = [];
670
465
  for (const issue of issues) {
@@ -693,13 +488,30 @@ After all outputs are verified, tell the team to shut down:
693
488
  lines.push(`- **Failed**: ${failed.join(', ')}`);
694
489
  }
695
490
  lines.push('');
491
+ this.writeProgressLines(progressPath, lines);
492
+ }
493
+ writeProgressLines(filePath, lines) {
696
494
  try {
697
- const existing = readFileSync(progressPath, 'utf-8');
698
- writeFileSync(progressPath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
699
- }
700
- catch {
701
- // Non-fatal
495
+ if (existsSync(filePath)) {
496
+ const existing = readFileSync(filePath, 'utf-8');
497
+ writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
498
+ }
499
+ else {
500
+ writeFileSync(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
501
+ }
702
502
  }
503
+ catch { /* non-fatal */ }
504
+ }
505
+ /** Resolve the active board's directory path for outputs, reviews, and progress. */
506
+ resolveBoardDir() {
507
+ const pmDir = resolvePmDir(this.workingDir);
508
+ if (!pmDir)
509
+ return null;
510
+ const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
511
+ if (!effectiveBoardId)
512
+ return null;
513
+ const boardDir = join(pmDir, 'boards', effectiveBoardId);
514
+ return existsSync(boardDir) ? boardDir : null;
703
515
  }
704
516
  }
705
517
  //# sourceMappingURL=executor.js.map