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.
- package/bin/mstro.js +119 -40
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +3 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +4 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +1 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +116 -31
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/config-installer.d.ts +25 -0
- package/dist/server/services/plan/config-installer.d.ts.map +1 -0
- package/dist/server/services/plan/config-installer.js +182 -0
- package/dist/server/services/plan/config-installer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
- package/dist/server/services/plan/dependency-resolver.js +4 -1
- package/dist/server/services/plan/dependency-resolver.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +43 -71
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +314 -438
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/front-matter.d.ts +18 -0
- package/dist/server/services/plan/front-matter.d.ts.map +1 -0
- package/dist/server/services/plan/front-matter.js +44 -0
- package/dist/server/services/plan/front-matter.js.map +1 -0
- package/dist/server/services/plan/output-manager.d.ts +22 -0
- package/dist/server/services/plan/output-manager.d.ts.map +1 -0
- package/dist/server/services/plan/output-manager.js +97 -0
- package/dist/server/services/plan/output-manager.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +18 -2
- package/dist/server/services/plan/parser.d.ts.map +1 -1
- package/dist/server/services/plan/parser.js +372 -32
- package/dist/server/services/plan/parser.js.map +1 -1
- package/dist/server/services/plan/prompt-builder.d.ts +17 -0
- package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
- package/dist/server/services/plan/prompt-builder.js +137 -0
- package/dist/server/services/plan/prompt-builder.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts +26 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -0
- package/dist/server/services/plan/review-gate.js +191 -0
- package/dist/server/services/plan/review-gate.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +1 -1
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
- package/dist/server/services/plan/state-reconciler.js +59 -7
- package/dist/server/services/plan/state-reconciler.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +66 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +11 -0
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +14 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +518 -40
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +1 -2
- package/server/cli/headless/claude-invoker.ts +4 -0
- package/server/cli/headless/types.ts +4 -1
- package/server/cli/improvisation-session-manager.ts +1 -1
- package/server/services/plan/composer.ts +138 -34
- package/server/services/plan/config-installer.ts +187 -0
- package/server/services/plan/dependency-resolver.ts +4 -1
- package/server/services/plan/executor.ts +325 -464
- package/server/services/plan/front-matter.ts +48 -0
- package/server/services/plan/output-manager.ts +113 -0
- package/server/services/plan/parser.ts +403 -34
- package/server/services/plan/prompt-builder.ts +161 -0
- package/server/services/plan/review-gate.ts +210 -0
- package/server/services/plan/state-reconciler.ts +68 -7
- package/server/services/plan/types.ts +99 -1
- package/server/services/platform.ts +11 -0
- package/server/services/websocket/handler.ts +14 -0
- package/server/services/websocket/plan-handlers.ts +629 -44
- 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
12
|
-
import { join
|
|
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 {
|
|
21
|
+
import { ConfigInstaller } from './config-installer.js';
|
|
16
22
|
import { resolveReadyToWork } from './dependency-resolver.js';
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
this.
|
|
152
|
-
|
|
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
|
-
*
|
|
166
|
-
*
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
// ──
|
|
317
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
198
318
|
pickReadyIssues() {
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
201
|
-
this.emit('error', 'No
|
|
319
|
+
const pmDir = resolvePmDir(this.workingDir);
|
|
320
|
+
if (!pmDir) {
|
|
321
|
+
this.emit('error', 'No PM directory found');
|
|
202
322
|
return [];
|
|
203
323
|
}
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
419
|
-
|
|
366
|
+
if (fullState.state.paused) {
|
|
367
|
+
this.emit('error', 'Project is paused');
|
|
368
|
+
return null;
|
|
420
369
|
}
|
|
421
|
-
|
|
422
|
-
this.claudeSettingsInstalled = false;
|
|
370
|
+
return fullState.issues;
|
|
423
371
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
442
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|