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