mstro-app 0.4.39 → 0.4.44
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/PRIVACY.md +1 -3
- package/bin/commands/login.js +17 -7
- package/bin/commands/logout.js +14 -6
- package/bin/commands/status.js +9 -3
- package/bin/commands/whoami.js +10 -4
- package/bin/mstro.js +11 -1
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +1 -0
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
- package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
- package/dist/server/cli/headless/resilient-runner.js +234 -0
- package/dist/server/cli/headless/resilient-runner.js.map +1 -0
- package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
- package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
- package/dist/server/cli/headless/retry-strategies.js +262 -0
- package/dist/server/cli/headless/retry-strategies.js.map +1 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +5 -0
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +31 -4
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +1 -30
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +16 -3
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/prompt-builders.d.ts.map +1 -1
- package/dist/server/cli/prompt-builders.js +31 -13
- package/dist/server/cli/prompt-builders.js.map +1 -1
- package/dist/server/index.js +1 -9
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.js +5 -4
- package/dist/server/mcp/bouncer-cli.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +1 -1
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +14 -8
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-patterns.js +1 -1
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +19 -9
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +6 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +163 -77
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/front-matter.d.ts +1 -0
- package/dist/server/services/plan/front-matter.d.ts.map +1 -1
- package/dist/server/services/plan/front-matter.js +6 -0
- package/dist/server/services/plan/front-matter.js.map +1 -1
- package/dist/server/services/plan/issue-classification.d.ts +11 -0
- package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
- package/dist/server/services/plan/issue-classification.js +20 -0
- package/dist/server/services/plan/issue-classification.js.map +1 -0
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +7 -4
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/issue-retry.d.ts +0 -5
- package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
- package/dist/server/services/plan/issue-retry.js +12 -241
- package/dist/server/services/plan/issue-retry.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +9 -6
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform-credentials.d.ts.map +1 -1
- package/dist/server/services/platform-credentials.js +11 -4
- package/dist/server/services/platform-credentials.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +7 -1
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-search-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/file-search-handlers.js +4 -0
- package/dist/server/services/websocket/file-search-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +2 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +2 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +18 -7
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-fix-agent.js +90 -42
- package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +48 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +48 -1
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +74 -32
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +18 -18
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/skill-handlers.js +52 -41
- package/dist/server/services/websocket/skill-handlers.js.map +1 -1
- package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
- package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
- package/dist/server/services/websocket/skill-watcher.js +85 -0
- package/dist/server/services/websocket/skill-watcher.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +2 -268
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +0 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-stream.ts +1 -0
- package/server/cli/headless/index.ts +2 -0
- package/server/cli/headless/resilient-runner.ts +354 -0
- package/server/cli/headless/retry-strategies.ts +330 -0
- package/server/cli/headless/stall-assessor.ts +5 -0
- package/server/cli/headless/tool-watchdog.ts +40 -4
- package/server/cli/improvisation-retry.ts +1 -32
- package/server/cli/improvisation-session-manager.ts +17 -3
- package/server/cli/prompt-builders.ts +33 -12
- package/server/index.ts +1 -9
- package/server/mcp/bouncer-cli.ts +5 -4
- package/server/mcp/bouncer-haiku.ts +1 -1
- package/server/mcp/bouncer-integration.ts +15 -8
- package/server/mcp/security-patterns.ts +1 -1
- package/server/services/plan/agents/code-review.md +109 -0
- package/server/services/plan/agents/commit-message.md +26 -0
- package/server/services/plan/agents/fix-quality.md +24 -0
- package/server/services/plan/agents/pr-description.md +28 -0
- package/server/services/plan/composer.ts +20 -9
- package/server/services/plan/executor.ts +165 -77
- package/server/services/plan/front-matter.ts +7 -0
- package/server/services/plan/issue-classification.ts +21 -0
- package/server/services/plan/issue-prompt-builder.ts +8 -4
- package/server/services/plan/issue-retry.ts +15 -330
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/review-gate.ts +9 -6
- package/server/services/plan/types.ts +3 -0
- package/server/services/platform-credentials.ts +10 -4
- package/server/services/terminal/pty-manager.ts +7 -1
- package/server/services/websocket/file-search-handlers.ts +2 -0
- package/server/services/websocket/handler-context.ts +2 -0
- package/server/services/websocket/handler.ts +18 -8
- package/server/services/websocket/plan-execution-handlers.ts +7 -7
- package/server/services/websocket/quality-fix-agent.ts +86 -44
- package/server/services/websocket/quality-handlers.ts +48 -7
- package/server/services/websocket/quality-persistence.ts +75 -1
- package/server/services/websocket/quality-review-agent.ts +70 -31
- package/server/services/websocket/quality-tools.ts +16 -14
- package/server/services/websocket/skill-handlers.ts +50 -40
- package/server/services/websocket/skill-watcher.ts +79 -0
- package/server/services/websocket/types.ts +0 -311
- package/dist/server/services/deploy/ai-broker.d.ts +0 -63
- package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
- package/dist/server/services/deploy/ai-broker.js +0 -360
- package/dist/server/services/deploy/ai-broker.js.map +0 -1
- package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
- package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
- package/dist/server/services/deploy/board-execution-handler.js +0 -621
- package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
- package/dist/server/services/deploy/credentials.d.ts +0 -35
- package/dist/server/services/deploy/credentials.d.ts.map +0 -1
- package/dist/server/services/deploy/credentials.js +0 -177
- package/dist/server/services/deploy/credentials.js.map +0 -1
- package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
- package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
- package/dist/server/services/deploy/deploy-ai-service.js +0 -294
- package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
- package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
- package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
- package/dist/server/services/deploy/headless-session-handler.js +0 -266
- package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
- package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
- package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
- package/dist/server/services/websocket/deploy-handlers.js +0 -409
- package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
- package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
- package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
- package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
- package/server/cli/headless/RESEARCH.md +0 -627
- package/server/services/deploy/ai-broker.ts +0 -512
- package/server/services/deploy/board-execution-handler.ts +0 -847
- package/server/services/deploy/credentials.ts +0 -200
- package/server/services/deploy/deploy-ai-service.ts +0 -401
- package/server/services/deploy/headless-session-handler.ts +0 -414
- package/server/services/websocket/deploy-handlers.ts +0 -544
- package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
* - front-matter.ts — YAML front matter field editing utility
|
|
16
16
|
*/
|
|
17
17
|
import { EventEmitter } from 'node:events';
|
|
18
|
-
import { existsSync,
|
|
19
|
-
import {
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
20
21
|
import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
|
|
21
22
|
import { ConfigInstaller } from './config-installer.js';
|
|
22
23
|
import { resolveReadyToWork } from './dependency-resolver.js';
|
|
23
|
-
import { checkAllAcceptanceCriteria, replaceFrontMatterField,
|
|
24
|
+
import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterFieldAsync } from './front-matter.js';
|
|
24
25
|
import { buildIssuePrompt } from './issue-prompt-builder.js';
|
|
25
26
|
import { runIssueWithRetry } from './issue-retry.js';
|
|
26
27
|
import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
|
|
@@ -70,6 +71,15 @@ export class PlanExecutor extends EventEmitter {
|
|
|
70
71
|
this.extraEnv = options?.extraEnv;
|
|
71
72
|
this.configInstaller = new ConfigInstaller(workingDir);
|
|
72
73
|
}
|
|
74
|
+
validateIssuePath(issuePath, baseDir) {
|
|
75
|
+
const resolvedBase = resolve(baseDir);
|
|
76
|
+
const resolvedFull = resolve(resolvedBase, issuePath);
|
|
77
|
+
const rel = relative(resolvedBase, resolvedFull);
|
|
78
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
79
|
+
throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
|
|
80
|
+
}
|
|
81
|
+
return resolvedFull;
|
|
82
|
+
}
|
|
73
83
|
getStatus() { return this.status; }
|
|
74
84
|
getMetrics() { return { ...this.metrics }; }
|
|
75
85
|
async startEpic(epicPath) {
|
|
@@ -103,32 +113,38 @@ export class PlanExecutor extends EventEmitter {
|
|
|
103
113
|
this.emit('statusChanged', this.status);
|
|
104
114
|
this.pmDir = resolvePmDir(this.workingDir);
|
|
105
115
|
this.boardDir = this.resolveBoardDir();
|
|
106
|
-
this.recoverStaleIssues();
|
|
116
|
+
await this.recoverStaleIssues();
|
|
107
117
|
const stallResult = await this.runWaveLoop();
|
|
108
118
|
this.metrics.totalDuration = Date.now() - startTime;
|
|
109
|
-
if (stallResult === 'stalled') {
|
|
119
|
+
if (stallResult === 'stalled' || stallResult === 'dead') {
|
|
110
120
|
this.status = 'error';
|
|
111
|
-
|
|
121
|
+
if (stallResult === 'stalled') {
|
|
122
|
+
this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
|
|
123
|
+
}
|
|
112
124
|
}
|
|
113
125
|
else if (this.shouldPause) {
|
|
114
126
|
this.status = 'paused';
|
|
115
127
|
}
|
|
116
128
|
else if (this.shouldStop) {
|
|
117
129
|
this.status = 'idle';
|
|
130
|
+
// Emit complete so clients can transition out of 'stopping' — metrics are broadcast by the handler.
|
|
131
|
+
this.emit('complete', 'Stopped by user');
|
|
118
132
|
}
|
|
119
133
|
else {
|
|
120
134
|
this.status = 'complete';
|
|
121
135
|
}
|
|
122
136
|
this.emit('statusChanged', this.status);
|
|
123
137
|
}
|
|
124
|
-
/** Run waves until done, paused, stopped, or stalled.
|
|
138
|
+
/** Run waves until done, paused, stopped, or stalled. */
|
|
125
139
|
async runWaveLoop() {
|
|
126
140
|
let consecutiveZeroCompletions = 0;
|
|
127
|
-
const maxParallel = this.getBoardMaxParallelAgents();
|
|
141
|
+
const maxParallel = await this.getBoardMaxParallelAgents();
|
|
128
142
|
while (!this.shouldStop && !this.shouldPause) {
|
|
129
|
-
const readyIssues = this.pickReadyIssues();
|
|
130
|
-
if (readyIssues.length === 0)
|
|
131
|
-
|
|
143
|
+
const readyIssues = await this.pickReadyIssues();
|
|
144
|
+
if (readyIssues.length === 0) {
|
|
145
|
+
// pickReadyIssues emits 'error' for dead state, 'complete' otherwise — check if dead
|
|
146
|
+
return await this.hasDeadIssues() ? 'dead' : 'done';
|
|
147
|
+
}
|
|
132
148
|
const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
|
|
133
149
|
if (completedCount > 0) {
|
|
134
150
|
consecutiveZeroCompletions = 0;
|
|
@@ -140,6 +156,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
140
156
|
}
|
|
141
157
|
return 'done';
|
|
142
158
|
}
|
|
159
|
+
async hasDeadIssues() {
|
|
160
|
+
const pmDir = this.pmDir;
|
|
161
|
+
if (!pmDir)
|
|
162
|
+
return false;
|
|
163
|
+
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
164
|
+
const issues = effectiveBoardId
|
|
165
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
166
|
+
: this.loadProjectIssues();
|
|
167
|
+
if (!issues)
|
|
168
|
+
return false;
|
|
169
|
+
const terminalStatuses = new Set(['done', 'cancelled']);
|
|
170
|
+
return issues.some(i => i.type !== 'epic' && !terminalStatuses.has(i.status) && i.status !== 'todo');
|
|
171
|
+
}
|
|
143
172
|
pause() { this.shouldPause = true; }
|
|
144
173
|
stop() {
|
|
145
174
|
this.shouldStop = true;
|
|
@@ -166,10 +195,10 @@ export class PlanExecutor extends EventEmitter {
|
|
|
166
195
|
this.emit('waveStarted', { issueIds: waveIds });
|
|
167
196
|
// Create abort controller for this wave — stop() will abort it
|
|
168
197
|
this.waveAbortController = new AbortController();
|
|
169
|
-
this.ensureOutputDirs();
|
|
198
|
+
await this.ensureOutputDirs();
|
|
170
199
|
this.configInstaller.installPermissions();
|
|
171
200
|
for (const issue of issues) {
|
|
172
|
-
this.updateIssueFrontMatter(issue.path, 'in_progress');
|
|
201
|
+
await this.updateIssueFrontMatter(issue.path, 'in_progress');
|
|
173
202
|
}
|
|
174
203
|
const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
|
|
175
204
|
const pmDir = this.pmDir;
|
|
@@ -196,13 +225,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
196
225
|
issueIds: waveIds,
|
|
197
226
|
error: error instanceof Error ? error.message : String(error),
|
|
198
227
|
});
|
|
199
|
-
this.revertIncompleteIssues(issues);
|
|
228
|
+
await this.revertIncompleteIssues(issues);
|
|
200
229
|
}
|
|
201
230
|
finally {
|
|
202
231
|
this.configInstaller.uninstallPermissions();
|
|
203
232
|
}
|
|
204
233
|
this.waveAbortController = null;
|
|
205
|
-
this.finalizeWave(issues, waveStart, waveLabel);
|
|
234
|
+
await this.finalizeWave(issues, waveStart, waveLabel);
|
|
206
235
|
this.metrics.currentWaveIds = [];
|
|
207
236
|
return completedCount;
|
|
208
237
|
}
|
|
@@ -240,7 +269,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
240
269
|
* Post-wave operations wrapped individually so a failure in one
|
|
241
270
|
* doesn't prevent the others or kill the while loop in start().
|
|
242
271
|
*/
|
|
243
|
-
finalizeWave(issues, waveStart, waveLabel) {
|
|
272
|
+
async finalizeWave(issues, waveStart, waveLabel) {
|
|
244
273
|
try {
|
|
245
274
|
reconcileState(this.workingDir, this.boardId ?? undefined);
|
|
246
275
|
this.emit('stateUpdated');
|
|
@@ -263,7 +292,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
263
292
|
});
|
|
264
293
|
}
|
|
265
294
|
try {
|
|
266
|
-
this.appendProgressEntry(issues, waveStart);
|
|
295
|
+
await this.appendProgressEntry(issues, waveStart);
|
|
267
296
|
}
|
|
268
297
|
catch (err) {
|
|
269
298
|
this.emit('output', {
|
|
@@ -284,15 +313,15 @@ export class PlanExecutor extends EventEmitter {
|
|
|
284
313
|
return 0;
|
|
285
314
|
let completed = 0;
|
|
286
315
|
for (const issue of issues) {
|
|
287
|
-
const fullPath =
|
|
316
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
288
317
|
try {
|
|
289
|
-
const content =
|
|
318
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
290
319
|
const statusMatch = content.match(/^status:\s*(\S+)/m);
|
|
291
320
|
const currentStatus = statusMatch?.[1] ?? 'unknown';
|
|
292
321
|
if (currentStatus === 'in_review' || currentStatus === 'done') {
|
|
293
322
|
if (issue.reviewGate === 'none') {
|
|
294
323
|
// Skip review gate — mark done directly
|
|
295
|
-
this.updateIssueFrontMatter(issue.path, 'done');
|
|
324
|
+
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
296
325
|
this.metrics.issuesCompleted++;
|
|
297
326
|
this.emit('issueCompleted', issue);
|
|
298
327
|
completed++;
|
|
@@ -302,7 +331,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
302
331
|
}
|
|
303
332
|
}
|
|
304
333
|
else {
|
|
305
|
-
this.updateIssueFrontMatter(issue.path, issue.status);
|
|
334
|
+
await this.updateIssueFrontMatter(issue.path, issue.status);
|
|
306
335
|
this.emit('issueError', {
|
|
307
336
|
issueId: issue.id,
|
|
308
337
|
error: 'Issue did not complete during wave execution',
|
|
@@ -320,34 +349,40 @@ export class PlanExecutor extends EventEmitter {
|
|
|
320
349
|
const reviewDir = this.boardDir ?? pmDir;
|
|
321
350
|
const attempts = getReviewAttemptCount(reviewDir, issue);
|
|
322
351
|
if (attempts >= MAX_REVIEW_ATTEMPTS) {
|
|
323
|
-
this.updateIssueFrontMatter(issue.path, '
|
|
352
|
+
await this.updateIssueFrontMatter(issue.path, 'cancelled');
|
|
353
|
+
await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
|
|
324
354
|
this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
|
|
325
|
-
this.emit('
|
|
355
|
+
this.emit('issueAbandoned', {
|
|
356
|
+
issueId: issue.id,
|
|
357
|
+
reason: `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
|
|
358
|
+
attempts,
|
|
359
|
+
});
|
|
360
|
+
this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
|
|
326
361
|
return 0;
|
|
327
362
|
}
|
|
328
|
-
this.updateIssueFrontMatter(issue.path, 'in_review');
|
|
363
|
+
await this.updateIssueFrontMatter(issue.path, 'in_review');
|
|
329
364
|
this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
|
|
330
365
|
const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
|
|
331
366
|
const result = await reviewIssue({
|
|
332
|
-
workingDir: this.workingDir,
|
|
367
|
+
workingDir: this.executionDir || this.workingDir,
|
|
333
368
|
issue,
|
|
334
369
|
pmDir,
|
|
335
370
|
outputPath,
|
|
336
371
|
onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
|
|
337
372
|
logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
|
|
338
|
-
reviewCriteria: this.getBoardReviewCriteria(),
|
|
373
|
+
reviewCriteria: await this.getBoardReviewCriteria(),
|
|
339
374
|
boardDir: this.boardDir,
|
|
340
375
|
extraEnv: this.extraEnv,
|
|
341
376
|
});
|
|
342
377
|
persistReviewResult(reviewDir, issue, result);
|
|
343
378
|
if (result.passed) {
|
|
344
|
-
this.updateIssueFrontMatter(issue.path, 'done');
|
|
379
|
+
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
345
380
|
this.metrics.issuesCompleted++;
|
|
346
381
|
this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
|
|
347
382
|
this.emit('issueCompleted', issue);
|
|
348
383
|
return 1;
|
|
349
384
|
}
|
|
350
|
-
this.updateIssueFrontMatter(issue.path, 'todo');
|
|
385
|
+
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
351
386
|
appendReviewFeedback(pmDir, issue, result);
|
|
352
387
|
this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
|
|
353
388
|
this.emit('issueError', {
|
|
@@ -363,13 +398,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
363
398
|
* these issues block the dependency graph and cause the executor to
|
|
364
399
|
* find zero ready issues, making "Implement" appear to do nothing.
|
|
365
400
|
*/
|
|
366
|
-
recoverStaleIssues() {
|
|
401
|
+
async recoverStaleIssues() {
|
|
367
402
|
const pmDir = this.pmDir;
|
|
368
403
|
if (!pmDir)
|
|
369
404
|
return;
|
|
370
405
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
371
406
|
const issues = effectiveBoardId
|
|
372
|
-
? this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
407
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
373
408
|
: this.loadProjectIssues();
|
|
374
409
|
if (!issues)
|
|
375
410
|
return;
|
|
@@ -379,7 +414,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
379
414
|
if (issue.type === 'epic')
|
|
380
415
|
continue;
|
|
381
416
|
if (staleStatuses.has(issue.status)) {
|
|
382
|
-
this.updateIssueFrontMatter(issue.path, 'todo');
|
|
417
|
+
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
383
418
|
recovered.push(`${issue.id} (${issue.status} → todo)`);
|
|
384
419
|
}
|
|
385
420
|
}
|
|
@@ -393,28 +428,28 @@ export class PlanExecutor extends EventEmitter {
|
|
|
393
428
|
}
|
|
394
429
|
// ── Helpers ──────────────────────────────────────────────────
|
|
395
430
|
/** Read the board's maxParallelAgents setting, falling back to default. */
|
|
396
|
-
getBoardMaxParallelAgents() {
|
|
431
|
+
async getBoardMaxParallelAgents() {
|
|
397
432
|
const pmDir = this.pmDir;
|
|
398
433
|
if (!pmDir)
|
|
399
434
|
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
400
435
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
401
436
|
if (!effectiveBoardId)
|
|
402
437
|
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
403
|
-
// Read only board.md — avoids parsing STATE.md and all backlog issues just for one setting
|
|
404
438
|
const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
|
|
405
439
|
if (!existsSync(boardMdPath))
|
|
406
440
|
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
407
441
|
try {
|
|
408
|
-
const content =
|
|
442
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
409
443
|
const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
|
|
410
444
|
return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
|
|
411
445
|
}
|
|
412
|
-
catch {
|
|
446
|
+
catch (err) {
|
|
447
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to read board max_parallel_agents: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
413
448
|
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
414
449
|
}
|
|
415
450
|
}
|
|
416
451
|
/** Read the board's custom review criteria, if set. */
|
|
417
|
-
getBoardReviewCriteria() {
|
|
452
|
+
async getBoardReviewCriteria() {
|
|
418
453
|
const pmDir = this.pmDir;
|
|
419
454
|
if (!pmDir)
|
|
420
455
|
return undefined;
|
|
@@ -425,18 +460,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
425
460
|
if (!existsSync(boardMdPath))
|
|
426
461
|
return undefined;
|
|
427
462
|
try {
|
|
428
|
-
const content =
|
|
463
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
429
464
|
const match = content.match(/^review_criteria:\s*"(.+)"/m);
|
|
430
465
|
if (!match)
|
|
431
466
|
return undefined;
|
|
432
467
|
const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
|
|
433
468
|
return raw || undefined;
|
|
434
469
|
}
|
|
435
|
-
catch {
|
|
470
|
+
catch (err) {
|
|
471
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to read board review criteria: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
436
472
|
return undefined;
|
|
437
473
|
}
|
|
438
474
|
}
|
|
439
|
-
pickReadyIssues() {
|
|
475
|
+
async pickReadyIssues() {
|
|
440
476
|
const pmDir = this.pmDir;
|
|
441
477
|
if (!pmDir) {
|
|
442
478
|
this.emit('error', 'No PM directory found');
|
|
@@ -444,21 +480,27 @@ export class PlanExecutor extends EventEmitter {
|
|
|
444
480
|
}
|
|
445
481
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
446
482
|
const issues = effectiveBoardId
|
|
447
|
-
? this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
483
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
448
484
|
: this.loadProjectIssues();
|
|
449
485
|
if (!issues)
|
|
450
486
|
return [];
|
|
451
487
|
const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
|
|
452
488
|
if (readyIssues.length === 0) {
|
|
453
|
-
|
|
454
|
-
if (
|
|
455
|
-
this.
|
|
489
|
+
const deadState = this.detectDeadState(issues);
|
|
490
|
+
if (deadState) {
|
|
491
|
+
this.emit('error', deadState);
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
this.emit('complete', this.buildCompletionReason(issues));
|
|
495
|
+
if (effectiveBoardId) {
|
|
496
|
+
await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
|
|
497
|
+
}
|
|
456
498
|
}
|
|
457
499
|
}
|
|
458
500
|
return readyIssues;
|
|
459
501
|
}
|
|
460
502
|
/** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
|
|
461
|
-
loadBoardIssues(pmDir, boardId) {
|
|
503
|
+
async loadBoardIssues(pmDir, boardId) {
|
|
462
504
|
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
463
505
|
if (!boardState) {
|
|
464
506
|
this.emit('error', `Board not found: ${boardId}`);
|
|
@@ -469,7 +511,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
469
511
|
return null;
|
|
470
512
|
}
|
|
471
513
|
if (boardState.board.status === 'draft') {
|
|
472
|
-
this.activateBoard(pmDir, boardId);
|
|
514
|
+
await this.activateBoard(pmDir, boardId);
|
|
473
515
|
}
|
|
474
516
|
else if (boardState.board.status !== 'active') {
|
|
475
517
|
this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
|
|
@@ -491,18 +533,20 @@ export class PlanExecutor extends EventEmitter {
|
|
|
491
533
|
return fullState.issues;
|
|
492
534
|
}
|
|
493
535
|
/** Activate a draft board by updating its status in board.md. */
|
|
494
|
-
activateBoard(pmDir, boardId) {
|
|
536
|
+
async activateBoard(pmDir, boardId) {
|
|
495
537
|
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
496
538
|
if (!existsSync(boardMdPath))
|
|
497
539
|
return;
|
|
498
540
|
try {
|
|
499
|
-
const content =
|
|
500
|
-
|
|
541
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
542
|
+
await writeFile(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to activate board ${boardId}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
501
546
|
}
|
|
502
|
-
catch { /* non-fatal — pickReadyIssues will re-check */ }
|
|
503
547
|
}
|
|
504
548
|
/** Check if all issues in a board are done and mark board as completed. */
|
|
505
|
-
tryCompleteBoardIfDone(pmDir, boardId, issues) {
|
|
549
|
+
async tryCompleteBoardIfDone(pmDir, boardId, issues) {
|
|
506
550
|
const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
|
|
507
551
|
if (!allDone)
|
|
508
552
|
return;
|
|
@@ -510,12 +554,14 @@ export class PlanExecutor extends EventEmitter {
|
|
|
510
554
|
if (!existsSync(boardMdPath))
|
|
511
555
|
return;
|
|
512
556
|
try {
|
|
513
|
-
let content =
|
|
557
|
+
let content = await readFile(boardMdPath, 'utf-8');
|
|
514
558
|
content = replaceFrontMatterField(content, 'status', 'completed');
|
|
515
559
|
content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
|
|
516
|
-
|
|
560
|
+
await writeFile(boardMdPath, content, 'utf-8');
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to mark board ${boardId} as completed: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
517
564
|
}
|
|
518
|
-
catch { /* non-fatal */ }
|
|
519
565
|
}
|
|
520
566
|
resolveActiveBoardId() {
|
|
521
567
|
const pmDir = this.pmDir;
|
|
@@ -542,58 +588,97 @@ export class PlanExecutor extends EventEmitter {
|
|
|
542
588
|
return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
|
|
543
589
|
return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
|
|
544
590
|
}
|
|
545
|
-
|
|
591
|
+
/** Detect issues stuck in non-terminal states with no path to completion. */
|
|
592
|
+
detectDeadState(issues) {
|
|
593
|
+
const nonEpic = issues.filter(i => i.type !== 'epic');
|
|
594
|
+
const terminalStatuses = new Set(['done', 'cancelled']);
|
|
595
|
+
const stuck = nonEpic.filter(i => !terminalStatuses.has(i.status) && i.status !== 'todo');
|
|
596
|
+
if (stuck.length === 0)
|
|
597
|
+
return null;
|
|
598
|
+
const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
|
|
599
|
+
const issueByPath = new Map(issues.map(i => [i.path, i]));
|
|
600
|
+
const blockedByStuck = nonEpic.filter(i => {
|
|
601
|
+
if (i.status !== 'todo')
|
|
602
|
+
return false;
|
|
603
|
+
return i.blockedBy.some(bp => {
|
|
604
|
+
const blocker = issueByPath.get(bp);
|
|
605
|
+
return blocker && !terminalStatuses.has(blocker.status);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
const blockedIds = blockedByStuck.map(i => i.id).join(', ');
|
|
609
|
+
return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
|
|
610
|
+
}
|
|
611
|
+
async revertIncompleteIssues(issues) {
|
|
546
612
|
const pmDir = this.pmDir;
|
|
547
613
|
if (!pmDir)
|
|
548
614
|
return;
|
|
549
615
|
for (const issue of issues) {
|
|
550
|
-
const fullPath =
|
|
616
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
551
617
|
try {
|
|
552
|
-
const content =
|
|
618
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
553
619
|
if (content.match(/^status:\s*in_progress$/m)) {
|
|
554
|
-
this.updateIssueFrontMatter(issue.path, issue.status);
|
|
620
|
+
await this.updateIssueFrontMatter(issue.path, issue.status);
|
|
555
621
|
}
|
|
556
622
|
}
|
|
557
|
-
catch {
|
|
623
|
+
catch (err) {
|
|
624
|
+
this.emit('output', { issueId: issue.id, text: `Warning: failed to revert issue status: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async appendCancellationNote(issue, pmDir, reason) {
|
|
629
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
630
|
+
try {
|
|
631
|
+
let content = await readFile(fullPath, 'utf-8');
|
|
632
|
+
const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
|
|
633
|
+
if (content.includes('## Activity')) {
|
|
634
|
+
content = content.replace(/## Activity/, `## Activity\n${entry}`);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
content += `\n\n## Activity\n${entry}`;
|
|
638
|
+
}
|
|
639
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
this.emit('output', { issueId: issue.id, text: `Warning: failed to append cancellation note: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
558
643
|
}
|
|
559
644
|
}
|
|
560
|
-
updateIssueFrontMatter(issuePath, newStatus) {
|
|
645
|
+
async updateIssueFrontMatter(issuePath, newStatus) {
|
|
561
646
|
const pmDir = this.pmDir;
|
|
562
647
|
if (!pmDir)
|
|
563
648
|
return;
|
|
564
649
|
try {
|
|
565
|
-
const fullPath =
|
|
566
|
-
|
|
567
|
-
// Check off all acceptance criteria when marking done
|
|
650
|
+
const fullPath = this.validateIssuePath(issuePath, pmDir);
|
|
651
|
+
await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
|
|
568
652
|
if (newStatus === 'done') {
|
|
569
|
-
const content =
|
|
653
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
570
654
|
const updated = checkAllAcceptanceCriteria(content);
|
|
571
655
|
if (updated !== content)
|
|
572
|
-
|
|
656
|
+
await writeFile(fullPath, updated, 'utf-8');
|
|
573
657
|
}
|
|
574
658
|
}
|
|
575
|
-
catch {
|
|
659
|
+
catch (err) {
|
|
660
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to update issue front matter for ${issuePath}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
661
|
+
}
|
|
576
662
|
}
|
|
577
|
-
ensureOutputDirs() {
|
|
663
|
+
async ensureOutputDirs() {
|
|
578
664
|
if (this.boardDir) {
|
|
579
665
|
const boardOutDir = join(this.boardDir, 'out');
|
|
580
666
|
if (!existsSync(boardOutDir))
|
|
581
|
-
|
|
667
|
+
await mkdir(boardOutDir, { recursive: true });
|
|
582
668
|
}
|
|
583
669
|
else {
|
|
584
670
|
const pmDir = this.pmDir;
|
|
585
671
|
if (pmDir) {
|
|
586
672
|
const outDir = join(pmDir, 'out');
|
|
587
673
|
if (!existsSync(outDir))
|
|
588
|
-
|
|
674
|
+
await mkdir(outDir, { recursive: true });
|
|
589
675
|
}
|
|
590
676
|
}
|
|
591
677
|
}
|
|
592
|
-
appendProgressEntry(issues, waveStart) {
|
|
678
|
+
async appendProgressEntry(issues, waveStart) {
|
|
593
679
|
const pmDir = this.pmDir;
|
|
594
680
|
if (!pmDir)
|
|
595
681
|
return;
|
|
596
|
-
// Board-scoped progress log
|
|
597
682
|
const progressPath = this.boardDir
|
|
598
683
|
? join(this.boardDir, 'progress.md')
|
|
599
684
|
: join(pmDir, 'progress.md');
|
|
@@ -603,7 +688,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
603
688
|
const failed = [];
|
|
604
689
|
for (const issue of issues) {
|
|
605
690
|
try {
|
|
606
|
-
const content =
|
|
691
|
+
const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
|
|
607
692
|
const statusMatch = content.match(/^status:\s*(\S+)/m);
|
|
608
693
|
if (statusMatch?.[1] === 'done') {
|
|
609
694
|
completed.push(issue.id);
|
|
@@ -627,19 +712,20 @@ export class PlanExecutor extends EventEmitter {
|
|
|
627
712
|
lines.push(`- **Failed**: ${failed.join(', ')}`);
|
|
628
713
|
}
|
|
629
714
|
lines.push('');
|
|
630
|
-
this.writeProgressLines(progressPath, lines);
|
|
715
|
+
await this.writeProgressLines(progressPath, lines);
|
|
631
716
|
}
|
|
632
|
-
writeProgressLines(filePath, lines) {
|
|
717
|
+
async writeProgressLines(filePath, lines) {
|
|
633
718
|
try {
|
|
634
719
|
if (existsSync(filePath)) {
|
|
635
|
-
|
|
636
|
-
writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
|
|
720
|
+
await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
|
|
637
721
|
}
|
|
638
722
|
else {
|
|
639
|
-
|
|
723
|
+
await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
|
|
640
724
|
}
|
|
641
725
|
}
|
|
642
|
-
catch {
|
|
726
|
+
catch (err) {
|
|
727
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
728
|
+
}
|
|
643
729
|
}
|
|
644
730
|
/** Resolve the active board's directory path for outputs, reviews, and progress. */
|
|
645
731
|
resolveBoardDir() {
|