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