mstro-app 0.4.38 → 0.4.43
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/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 +158 -76
- 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 +10 -5
- 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/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/execute-issue.md +10 -1
- 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 +160 -76
- 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 +11 -5
- 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/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
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import type { ToolUseEvent } from '../../cli/headless/index.js';
|
|
15
|
+
import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
|
|
16
16
|
import type { HandlerContext } from '../websocket/handler-context.js';
|
|
17
17
|
import type { WSContext } from '../websocket/types.js';
|
|
18
18
|
import { defaultPmDir, getNextId, parseBoardDirectory, parsePlanDirectory, resolvePmDir } from './parser.js';
|
|
@@ -196,6 +196,7 @@ created: "YYYY-MM-DD"
|
|
|
196
196
|
blocked_by: [] # Use backlog-relative paths: backlog/IS-NNN.md
|
|
197
197
|
blocks: [] # Use backlog-relative paths: backlog/IS-NNN.md
|
|
198
198
|
review_gate: auto
|
|
199
|
+
output_type: auto # code = modify source files, document = produce written artifact, auto = infer
|
|
199
200
|
output_file: null
|
|
200
201
|
---
|
|
201
202
|
|
|
@@ -228,6 +229,14 @@ Implementation guidance.
|
|
|
228
229
|
- If an issue requires work across multiple subsystems, split it into one issue per subsystem with blocked_by edges between them
|
|
229
230
|
- Research/investigation issues should be separate from implementation issues
|
|
230
231
|
|
|
232
|
+
## output_type rules (critical — determines how the AI executes and reviews each issue)
|
|
233
|
+
|
|
234
|
+
- Set \`output_type: document\` for research, design, analysis, writing, planning, learning, or educational issues — anything that produces a written artifact rather than code changes
|
|
235
|
+
- Set \`output_type: code\` for issues that MUST modify source code files (implementation, bug fixes, refactoring)
|
|
236
|
+
- Set \`output_type: auto\` when unsure — the system will infer from "Files to Modify" (if the section lists real source paths it's treated as code, otherwise as document)
|
|
237
|
+
- When output_type is \`document\`, "Files to Modify" entries are treated as references, not files to edit. The AI produces a document artifact and is reviewed on document quality.
|
|
238
|
+
- When output_type is \`code\`, "Files to Modify" lists actual source files the AI must edit. The review gate verifies source files were changed.
|
|
239
|
+
|
|
231
240
|
## Epic creation rules
|
|
232
241
|
|
|
233
242
|
- Create an EP-*.md file in ${cc.backlogPath} with type: epic and a children: [] field in front matter
|
|
@@ -246,12 +255,13 @@ User request: ${userPrompt}`;
|
|
|
246
255
|
data: { message: 'Starting project planning...' },
|
|
247
256
|
});
|
|
248
257
|
|
|
249
|
-
const runner = new
|
|
258
|
+
const runner = new ResilientRunner({
|
|
250
259
|
workingDir: executionDir || workingDir,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
prompt: enrichedPrompt,
|
|
261
|
+
policy: 'STANDARD',
|
|
262
|
+
stallWarningMs: 300_000,
|
|
263
|
+
stallKillMs: 900_000,
|
|
264
|
+
stallHardCapMs: 1_800_000,
|
|
255
265
|
verbose: true,
|
|
256
266
|
outputCallback: (text: string) => {
|
|
257
267
|
ctx.send(ws, {
|
|
@@ -271,6 +281,8 @@ User request: ${userPrompt}`;
|
|
|
271
281
|
}
|
|
272
282
|
};
|
|
273
283
|
})(),
|
|
284
|
+
logLabel: 'pm-compose',
|
|
285
|
+
logDir: cc.effectiveBoardId ? join(pmDir, 'boards', cc.effectiveBoardId, 'logs') : undefined,
|
|
274
286
|
});
|
|
275
287
|
|
|
276
288
|
ctx.broadcastToAll({
|
|
@@ -278,8 +290,7 @@ User request: ${userPrompt}`;
|
|
|
278
290
|
data: { message: 'Claude is planning your project...' },
|
|
279
291
|
});
|
|
280
292
|
|
|
281
|
-
const
|
|
282
|
-
const result = await runWithFileLogger('pm-compose', () => runner.run(), boardLogDir);
|
|
293
|
+
const result = await runner.run();
|
|
283
294
|
|
|
284
295
|
ctx.broadcastToAll({
|
|
285
296
|
type: 'planPromptProgress',
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { EventEmitter } from 'node:events';
|
|
20
|
-
import { existsSync,
|
|
21
|
-
import {
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
22
23
|
import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
|
|
23
24
|
import { ConfigInstaller } from './config-installer.js';
|
|
24
25
|
import { resolveReadyToWork } from './dependency-resolver.js';
|
|
25
|
-
import { checkAllAcceptanceCriteria, replaceFrontMatterField,
|
|
26
|
+
import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterFieldAsync } from './front-matter.js';
|
|
26
27
|
import { buildIssuePrompt } from './issue-prompt-builder.js';
|
|
27
28
|
import { runIssueWithRetry } from './issue-retry.js';
|
|
28
29
|
import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
|
|
@@ -90,6 +91,14 @@ export class PlanExecutor extends EventEmitter {
|
|
|
90
91
|
this.configInstaller = new ConfigInstaller(workingDir);
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
private validateIssuePath(issuePath: string, baseDir: string): string {
|
|
95
|
+
const fullPath = join(baseDir, issuePath);
|
|
96
|
+
if (!resolve(fullPath).startsWith(resolve(baseDir))) {
|
|
97
|
+
throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
|
|
98
|
+
}
|
|
99
|
+
return fullPath;
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
getStatus(): ExecutionStatus { return this.status; }
|
|
94
103
|
getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
|
|
95
104
|
|
|
@@ -129,15 +138,17 @@ export class PlanExecutor extends EventEmitter {
|
|
|
129
138
|
this.pmDir = resolvePmDir(this.workingDir);
|
|
130
139
|
this.boardDir = this.resolveBoardDir();
|
|
131
140
|
|
|
132
|
-
this.recoverStaleIssues();
|
|
141
|
+
await this.recoverStaleIssues();
|
|
133
142
|
|
|
134
143
|
const stallResult = await this.runWaveLoop();
|
|
135
144
|
|
|
136
145
|
this.metrics.totalDuration = Date.now() - startTime;
|
|
137
146
|
|
|
138
|
-
if (stallResult === 'stalled') {
|
|
147
|
+
if (stallResult === 'stalled' || stallResult === 'dead') {
|
|
139
148
|
this.status = 'error';
|
|
140
|
-
|
|
149
|
+
if (stallResult === 'stalled') {
|
|
150
|
+
this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
|
|
151
|
+
}
|
|
141
152
|
} else if (this.shouldPause) {
|
|
142
153
|
this.status = 'paused';
|
|
143
154
|
} else if (this.shouldStop) {
|
|
@@ -148,14 +159,17 @@ export class PlanExecutor extends EventEmitter {
|
|
|
148
159
|
this.emit('statusChanged', this.status);
|
|
149
160
|
}
|
|
150
161
|
|
|
151
|
-
/** Run waves until done, paused, stopped, or stalled.
|
|
152
|
-
private async runWaveLoop(): Promise<'done' | 'stalled'> {
|
|
162
|
+
/** Run waves until done, paused, stopped, or stalled. */
|
|
163
|
+
private async runWaveLoop(): Promise<'done' | 'stalled' | 'dead'> {
|
|
153
164
|
let consecutiveZeroCompletions = 0;
|
|
154
|
-
const maxParallel = this.getBoardMaxParallelAgents();
|
|
165
|
+
const maxParallel = await this.getBoardMaxParallelAgents();
|
|
155
166
|
|
|
156
167
|
while (!this.shouldStop && !this.shouldPause) {
|
|
157
|
-
const readyIssues = this.pickReadyIssues();
|
|
158
|
-
if (readyIssues.length === 0)
|
|
168
|
+
const readyIssues = await this.pickReadyIssues();
|
|
169
|
+
if (readyIssues.length === 0) {
|
|
170
|
+
// pickReadyIssues emits 'error' for dead state, 'complete' otherwise — check if dead
|
|
171
|
+
return await this.hasDeadIssues() ? 'dead' : 'done';
|
|
172
|
+
}
|
|
159
173
|
|
|
160
174
|
const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
|
|
161
175
|
|
|
@@ -169,6 +183,18 @@ export class PlanExecutor extends EventEmitter {
|
|
|
169
183
|
return 'done';
|
|
170
184
|
}
|
|
171
185
|
|
|
186
|
+
private async hasDeadIssues(): Promise<boolean> {
|
|
187
|
+
const pmDir = this.pmDir;
|
|
188
|
+
if (!pmDir) return false;
|
|
189
|
+
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
190
|
+
const issues = effectiveBoardId
|
|
191
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
192
|
+
: this.loadProjectIssues();
|
|
193
|
+
if (!issues) return false;
|
|
194
|
+
const terminalStatuses = new Set(['done', 'cancelled']);
|
|
195
|
+
return issues.some(i => i.type !== 'epic' && !terminalStatuses.has(i.status) && i.status !== 'todo');
|
|
196
|
+
}
|
|
197
|
+
|
|
172
198
|
pause(): void { this.shouldPause = true; }
|
|
173
199
|
stop(): void {
|
|
174
200
|
this.shouldStop = true;
|
|
@@ -199,11 +225,11 @@ export class PlanExecutor extends EventEmitter {
|
|
|
199
225
|
// Create abort controller for this wave — stop() will abort it
|
|
200
226
|
this.waveAbortController = new AbortController();
|
|
201
227
|
|
|
202
|
-
this.ensureOutputDirs();
|
|
228
|
+
await this.ensureOutputDirs();
|
|
203
229
|
this.configInstaller.installPermissions();
|
|
204
230
|
|
|
205
231
|
for (const issue of issues) {
|
|
206
|
-
this.updateIssueFrontMatter(issue.path, 'in_progress');
|
|
232
|
+
await this.updateIssueFrontMatter(issue.path, 'in_progress');
|
|
207
233
|
}
|
|
208
234
|
|
|
209
235
|
const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
|
|
@@ -234,13 +260,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
234
260
|
issueIds: waveIds,
|
|
235
261
|
error: error instanceof Error ? error.message : String(error),
|
|
236
262
|
});
|
|
237
|
-
this.revertIncompleteIssues(issues);
|
|
263
|
+
await this.revertIncompleteIssues(issues);
|
|
238
264
|
} finally {
|
|
239
265
|
this.configInstaller.uninstallPermissions();
|
|
240
266
|
}
|
|
241
267
|
|
|
242
268
|
this.waveAbortController = null;
|
|
243
|
-
this.finalizeWave(issues, waveStart, waveLabel);
|
|
269
|
+
await this.finalizeWave(issues, waveStart, waveLabel);
|
|
244
270
|
this.metrics.currentWaveIds = [];
|
|
245
271
|
return completedCount;
|
|
246
272
|
}
|
|
@@ -288,7 +314,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
288
314
|
* Post-wave operations wrapped individually so a failure in one
|
|
289
315
|
* doesn't prevent the others or kill the while loop in start().
|
|
290
316
|
*/
|
|
291
|
-
private finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): void {
|
|
317
|
+
private async finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): Promise<void> {
|
|
292
318
|
try {
|
|
293
319
|
reconcileState(this.workingDir, this.boardId ?? undefined);
|
|
294
320
|
this.emit('stateUpdated');
|
|
@@ -311,7 +337,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
311
337
|
}
|
|
312
338
|
|
|
313
339
|
try {
|
|
314
|
-
this.appendProgressEntry(issues, waveStart);
|
|
340
|
+
await this.appendProgressEntry(issues, waveStart);
|
|
315
341
|
} catch (err) {
|
|
316
342
|
this.emit('output', {
|
|
317
343
|
issueId: waveLabel,
|
|
@@ -334,16 +360,16 @@ export class PlanExecutor extends EventEmitter {
|
|
|
334
360
|
let completed = 0;
|
|
335
361
|
|
|
336
362
|
for (const issue of issues) {
|
|
337
|
-
const fullPath =
|
|
363
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
338
364
|
try {
|
|
339
|
-
const content =
|
|
365
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
340
366
|
const statusMatch = content.match(/^status:\s*(\S+)/m);
|
|
341
367
|
const currentStatus = statusMatch?.[1] ?? 'unknown';
|
|
342
368
|
|
|
343
369
|
if (currentStatus === 'in_review' || currentStatus === 'done') {
|
|
344
370
|
if (issue.reviewGate === 'none') {
|
|
345
371
|
// Skip review gate — mark done directly
|
|
346
|
-
this.updateIssueFrontMatter(issue.path, 'done');
|
|
372
|
+
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
347
373
|
this.metrics.issuesCompleted++;
|
|
348
374
|
this.emit('issueCompleted', issue);
|
|
349
375
|
completed++;
|
|
@@ -351,7 +377,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
351
377
|
completed += await this.runReviewGate(issue, pmDir);
|
|
352
378
|
}
|
|
353
379
|
} else {
|
|
354
|
-
this.updateIssueFrontMatter(issue.path, issue.status);
|
|
380
|
+
await this.updateIssueFrontMatter(issue.path, issue.status);
|
|
355
381
|
this.emit('issueError', {
|
|
356
382
|
issueId: issue.id,
|
|
357
383
|
error: 'Issue did not complete during wave execution',
|
|
@@ -370,13 +396,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
370
396
|
const reviewDir = this.boardDir ?? pmDir;
|
|
371
397
|
const attempts = getReviewAttemptCount(reviewDir, issue);
|
|
372
398
|
if (attempts >= MAX_REVIEW_ATTEMPTS) {
|
|
373
|
-
this.updateIssueFrontMatter(issue.path, '
|
|
399
|
+
await this.updateIssueFrontMatter(issue.path, 'cancelled');
|
|
400
|
+
await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
|
|
374
401
|
this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
|
|
375
|
-
this.emit('
|
|
402
|
+
this.emit('issueAbandoned', {
|
|
403
|
+
issueId: issue.id,
|
|
404
|
+
reason: `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
|
|
405
|
+
attempts,
|
|
406
|
+
});
|
|
407
|
+
this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
|
|
376
408
|
return 0;
|
|
377
409
|
}
|
|
378
410
|
|
|
379
|
-
this.updateIssueFrontMatter(issue.path, 'in_review');
|
|
411
|
+
await this.updateIssueFrontMatter(issue.path, 'in_review');
|
|
380
412
|
this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
|
|
381
413
|
|
|
382
414
|
const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
|
|
@@ -387,21 +419,21 @@ export class PlanExecutor extends EventEmitter {
|
|
|
387
419
|
outputPath,
|
|
388
420
|
onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
|
|
389
421
|
logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
|
|
390
|
-
reviewCriteria: this.getBoardReviewCriteria(),
|
|
422
|
+
reviewCriteria: await this.getBoardReviewCriteria(),
|
|
391
423
|
boardDir: this.boardDir,
|
|
392
424
|
extraEnv: this.extraEnv,
|
|
393
425
|
});
|
|
394
426
|
persistReviewResult(reviewDir, issue, result);
|
|
395
427
|
|
|
396
428
|
if (result.passed) {
|
|
397
|
-
this.updateIssueFrontMatter(issue.path, 'done');
|
|
429
|
+
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
398
430
|
this.metrics.issuesCompleted++;
|
|
399
431
|
this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
|
|
400
432
|
this.emit('issueCompleted', issue);
|
|
401
433
|
return 1;
|
|
402
434
|
}
|
|
403
435
|
|
|
404
|
-
this.updateIssueFrontMatter(issue.path, 'todo');
|
|
436
|
+
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
405
437
|
appendReviewFeedback(pmDir, issue, result);
|
|
406
438
|
this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
|
|
407
439
|
this.emit('issueError', {
|
|
@@ -419,13 +451,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
419
451
|
* these issues block the dependency graph and cause the executor to
|
|
420
452
|
* find zero ready issues, making "Implement" appear to do nothing.
|
|
421
453
|
*/
|
|
422
|
-
private recoverStaleIssues(): void {
|
|
454
|
+
private async recoverStaleIssues(): Promise<void> {
|
|
423
455
|
const pmDir = this.pmDir;
|
|
424
456
|
if (!pmDir) return;
|
|
425
457
|
|
|
426
458
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
427
459
|
const issues = effectiveBoardId
|
|
428
|
-
? this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
460
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
429
461
|
: this.loadProjectIssues();
|
|
430
462
|
|
|
431
463
|
if (!issues) return;
|
|
@@ -436,7 +468,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
436
468
|
for (const issue of issues) {
|
|
437
469
|
if (issue.type === 'epic') continue;
|
|
438
470
|
if (staleStatuses.has(issue.status)) {
|
|
439
|
-
this.updateIssueFrontMatter(issue.path, 'todo');
|
|
471
|
+
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
440
472
|
recovered.push(`${issue.id} (${issue.status} → todo)`);
|
|
441
473
|
}
|
|
442
474
|
}
|
|
@@ -453,28 +485,28 @@ export class PlanExecutor extends EventEmitter {
|
|
|
453
485
|
// ── Helpers ──────────────────────────────────────────────────
|
|
454
486
|
|
|
455
487
|
/** Read the board's maxParallelAgents setting, falling back to default. */
|
|
456
|
-
private getBoardMaxParallelAgents(): number {
|
|
488
|
+
private async getBoardMaxParallelAgents(): Promise<number> {
|
|
457
489
|
const pmDir = this.pmDir;
|
|
458
490
|
if (!pmDir) return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
459
491
|
|
|
460
492
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
461
493
|
if (!effectiveBoardId) return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
462
494
|
|
|
463
|
-
// Read only board.md — avoids parsing STATE.md and all backlog issues just for one setting
|
|
464
495
|
const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
|
|
465
496
|
if (!existsSync(boardMdPath)) return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
466
497
|
|
|
467
498
|
try {
|
|
468
|
-
const content =
|
|
499
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
469
500
|
const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
|
|
470
501
|
return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
|
|
471
|
-
} catch {
|
|
502
|
+
} catch (err) {
|
|
503
|
+
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 });
|
|
472
504
|
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
473
505
|
}
|
|
474
506
|
}
|
|
475
507
|
|
|
476
508
|
/** Read the board's custom review criteria, if set. */
|
|
477
|
-
private getBoardReviewCriteria(): string | undefined {
|
|
509
|
+
private async getBoardReviewCriteria(): Promise<string | undefined> {
|
|
478
510
|
const pmDir = this.pmDir;
|
|
479
511
|
if (!pmDir) return undefined;
|
|
480
512
|
|
|
@@ -485,17 +517,18 @@ export class PlanExecutor extends EventEmitter {
|
|
|
485
517
|
if (!existsSync(boardMdPath)) return undefined;
|
|
486
518
|
|
|
487
519
|
try {
|
|
488
|
-
const content =
|
|
520
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
489
521
|
const match = content.match(/^review_criteria:\s*"(.+)"/m);
|
|
490
522
|
if (!match) return undefined;
|
|
491
523
|
const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
|
|
492
524
|
return raw || undefined;
|
|
493
|
-
} catch {
|
|
525
|
+
} catch (err) {
|
|
526
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to read board review criteria: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
494
527
|
return undefined;
|
|
495
528
|
}
|
|
496
529
|
}
|
|
497
530
|
|
|
498
|
-
private pickReadyIssues(): Issue[] {
|
|
531
|
+
private async pickReadyIssues(): Promise<Issue[]> {
|
|
499
532
|
const pmDir = this.pmDir;
|
|
500
533
|
if (!pmDir) {
|
|
501
534
|
this.emit('error', 'No PM directory found');
|
|
@@ -504,23 +537,28 @@ export class PlanExecutor extends EventEmitter {
|
|
|
504
537
|
|
|
505
538
|
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
506
539
|
const issues = effectiveBoardId
|
|
507
|
-
? this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
540
|
+
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
508
541
|
: this.loadProjectIssues();
|
|
509
542
|
|
|
510
543
|
if (!issues) return [];
|
|
511
544
|
|
|
512
545
|
const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
|
|
513
546
|
if (readyIssues.length === 0) {
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
this.
|
|
547
|
+
const deadState = this.detectDeadState(issues);
|
|
548
|
+
if (deadState) {
|
|
549
|
+
this.emit('error', deadState);
|
|
550
|
+
} else {
|
|
551
|
+
this.emit('complete', this.buildCompletionReason(issues));
|
|
552
|
+
if (effectiveBoardId) {
|
|
553
|
+
await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
|
|
554
|
+
}
|
|
517
555
|
}
|
|
518
556
|
}
|
|
519
557
|
return readyIssues;
|
|
520
558
|
}
|
|
521
559
|
|
|
522
560
|
/** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
|
|
523
|
-
private loadBoardIssues(pmDir: string, boardId: string): Issue[] | null {
|
|
561
|
+
private async loadBoardIssues(pmDir: string, boardId: string): Promise<Issue[] | null> {
|
|
524
562
|
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
525
563
|
if (!boardState) {
|
|
526
564
|
this.emit('error', `Board not found: ${boardId}`);
|
|
@@ -531,7 +569,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
531
569
|
return null;
|
|
532
570
|
}
|
|
533
571
|
if (boardState.board.status === 'draft') {
|
|
534
|
-
this.activateBoard(pmDir, boardId);
|
|
572
|
+
await this.activateBoard(pmDir, boardId);
|
|
535
573
|
} else if (boardState.board.status !== 'active') {
|
|
536
574
|
this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
|
|
537
575
|
return null;
|
|
@@ -554,17 +592,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
554
592
|
}
|
|
555
593
|
|
|
556
594
|
/** Activate a draft board by updating its status in board.md. */
|
|
557
|
-
private activateBoard(pmDir: string, boardId: string): void {
|
|
595
|
+
private async activateBoard(pmDir: string, boardId: string): Promise<void> {
|
|
558
596
|
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
559
597
|
if (!existsSync(boardMdPath)) return;
|
|
560
598
|
try {
|
|
561
|
-
const content =
|
|
562
|
-
|
|
563
|
-
} catch {
|
|
599
|
+
const content = await readFile(boardMdPath, 'utf-8');
|
|
600
|
+
await writeFile(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to activate board ${boardId}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
603
|
+
}
|
|
564
604
|
}
|
|
565
605
|
|
|
566
606
|
/** Check if all issues in a board are done and mark board as completed. */
|
|
567
|
-
private tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): void {
|
|
607
|
+
private async tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): Promise<void> {
|
|
568
608
|
const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
|
|
569
609
|
if (!allDone) return;
|
|
570
610
|
|
|
@@ -572,11 +612,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
572
612
|
if (!existsSync(boardMdPath)) return;
|
|
573
613
|
|
|
574
614
|
try {
|
|
575
|
-
let content =
|
|
615
|
+
let content = await readFile(boardMdPath, 'utf-8');
|
|
576
616
|
content = replaceFrontMatterField(content, 'status', 'completed');
|
|
577
617
|
content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
|
|
578
|
-
|
|
579
|
-
} catch {
|
|
618
|
+
await writeFile(boardMdPath, content, 'utf-8');
|
|
619
|
+
} catch (err) {
|
|
620
|
+
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 });
|
|
621
|
+
}
|
|
580
622
|
}
|
|
581
623
|
|
|
582
624
|
private resolveActiveBoardId(): string | null {
|
|
@@ -601,54 +643,95 @@ export class PlanExecutor extends EventEmitter {
|
|
|
601
643
|
return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
|
|
602
644
|
}
|
|
603
645
|
|
|
604
|
-
|
|
646
|
+
/** Detect issues stuck in non-terminal states with no path to completion. */
|
|
647
|
+
private detectDeadState(issues: Issue[]): string | null {
|
|
648
|
+
const nonEpic = issues.filter(i => i.type !== 'epic');
|
|
649
|
+
const terminalStatuses = new Set(['done', 'cancelled']);
|
|
650
|
+
const stuck = nonEpic.filter(i => !terminalStatuses.has(i.status) && i.status !== 'todo');
|
|
651
|
+
|
|
652
|
+
if (stuck.length === 0) return null;
|
|
653
|
+
|
|
654
|
+
const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
|
|
655
|
+
|
|
656
|
+
const issueByPath = new Map(issues.map(i => [i.path, i]));
|
|
657
|
+
const blockedByStuck = nonEpic.filter(i => {
|
|
658
|
+
if (i.status !== 'todo') return false;
|
|
659
|
+
return i.blockedBy.some(bp => {
|
|
660
|
+
const blocker = issueByPath.get(bp);
|
|
661
|
+
return blocker && !terminalStatuses.has(blocker.status);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
const blockedIds = blockedByStuck.map(i => i.id).join(', ');
|
|
665
|
+
|
|
666
|
+
return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private async revertIncompleteIssues(issues: Issue[]): Promise<void> {
|
|
605
670
|
const pmDir = this.pmDir;
|
|
606
671
|
if (!pmDir) return;
|
|
607
672
|
for (const issue of issues) {
|
|
608
|
-
const fullPath =
|
|
673
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
609
674
|
try {
|
|
610
|
-
const content =
|
|
675
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
611
676
|
if (content.match(/^status:\s*in_progress$/m)) {
|
|
612
|
-
this.updateIssueFrontMatter(issue.path, issue.status);
|
|
677
|
+
await this.updateIssueFrontMatter(issue.path, issue.status);
|
|
613
678
|
}
|
|
614
|
-
} catch {
|
|
679
|
+
} catch (err) {
|
|
680
|
+
this.emit('output', { issueId: issue.id, text: `Warning: failed to revert issue status: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
681
|
+
}
|
|
615
682
|
}
|
|
616
683
|
}
|
|
617
684
|
|
|
618
|
-
private
|
|
685
|
+
private async appendCancellationNote(issue: Issue, pmDir: string, reason: string): Promise<void> {
|
|
686
|
+
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
687
|
+
try {
|
|
688
|
+
let content = await readFile(fullPath, 'utf-8');
|
|
689
|
+
const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
|
|
690
|
+
if (content.includes('## Activity')) {
|
|
691
|
+
content = content.replace(/## Activity/, `## Activity\n${entry}`);
|
|
692
|
+
} else {
|
|
693
|
+
content += `\n\n## Activity\n${entry}`;
|
|
694
|
+
}
|
|
695
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
696
|
+
} catch (err) {
|
|
697
|
+
this.emit('output', { issueId: issue.id, text: `Warning: failed to append cancellation note: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private async updateIssueFrontMatter(issuePath: string, newStatus: string): Promise<void> {
|
|
619
702
|
const pmDir = this.pmDir;
|
|
620
703
|
if (!pmDir) return;
|
|
621
704
|
try {
|
|
622
|
-
const fullPath =
|
|
623
|
-
|
|
705
|
+
const fullPath = this.validateIssuePath(issuePath, pmDir);
|
|
706
|
+
await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
|
|
624
707
|
|
|
625
|
-
// Check off all acceptance criteria when marking done
|
|
626
708
|
if (newStatus === 'done') {
|
|
627
|
-
const content =
|
|
709
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
628
710
|
const updated = checkAllAcceptanceCriteria(content);
|
|
629
|
-
if (updated !== content)
|
|
711
|
+
if (updated !== content) await writeFile(fullPath, updated, 'utf-8');
|
|
630
712
|
}
|
|
631
|
-
} catch {
|
|
713
|
+
} catch (err) {
|
|
714
|
+
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 });
|
|
715
|
+
}
|
|
632
716
|
}
|
|
633
717
|
|
|
634
|
-
private ensureOutputDirs(): void {
|
|
718
|
+
private async ensureOutputDirs(): Promise<void> {
|
|
635
719
|
if (this.boardDir) {
|
|
636
720
|
const boardOutDir = join(this.boardDir, 'out');
|
|
637
|
-
if (!existsSync(boardOutDir))
|
|
721
|
+
if (!existsSync(boardOutDir)) await mkdir(boardOutDir, { recursive: true });
|
|
638
722
|
} else {
|
|
639
723
|
const pmDir = this.pmDir;
|
|
640
724
|
if (pmDir) {
|
|
641
725
|
const outDir = join(pmDir, 'out');
|
|
642
|
-
if (!existsSync(outDir))
|
|
726
|
+
if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
|
|
643
727
|
}
|
|
644
728
|
}
|
|
645
729
|
}
|
|
646
730
|
|
|
647
|
-
private appendProgressEntry(issues: Issue[], waveStart: number): void {
|
|
731
|
+
private async appendProgressEntry(issues: Issue[], waveStart: number): Promise<void> {
|
|
648
732
|
const pmDir = this.pmDir;
|
|
649
733
|
if (!pmDir) return;
|
|
650
734
|
|
|
651
|
-
// Board-scoped progress log
|
|
652
735
|
const progressPath = this.boardDir
|
|
653
736
|
? join(this.boardDir, 'progress.md')
|
|
654
737
|
: join(pmDir, 'progress.md');
|
|
@@ -660,7 +743,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
660
743
|
const failed: string[] = [];
|
|
661
744
|
for (const issue of issues) {
|
|
662
745
|
try {
|
|
663
|
-
const content =
|
|
746
|
+
const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
|
|
664
747
|
const statusMatch = content.match(/^status:\s*(\S+)/m);
|
|
665
748
|
if (statusMatch?.[1] === 'done') {
|
|
666
749
|
completed.push(issue.id);
|
|
@@ -684,18 +767,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
684
767
|
}
|
|
685
768
|
lines.push('');
|
|
686
769
|
|
|
687
|
-
this.writeProgressLines(progressPath, lines);
|
|
770
|
+
await this.writeProgressLines(progressPath, lines);
|
|
688
771
|
}
|
|
689
772
|
|
|
690
|
-
private writeProgressLines(filePath: string, lines: string[]): void {
|
|
773
|
+
private async writeProgressLines(filePath: string, lines: string[]): Promise<void> {
|
|
691
774
|
try {
|
|
692
775
|
if (existsSync(filePath)) {
|
|
693
|
-
|
|
694
|
-
writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
|
|
776
|
+
await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
|
|
695
777
|
} else {
|
|
696
|
-
|
|
778
|
+
await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
|
|
697
779
|
}
|
|
698
|
-
} catch {
|
|
780
|
+
} catch (err) {
|
|
781
|
+
this.emit('output', { issueId: 'system', text: `Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
|
|
782
|
+
}
|
|
699
783
|
}
|
|
700
784
|
|
|
701
785
|
/** Resolve the active board's directory path for outputs, reviews, and progress. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Replace a field value in a raw YAML string (no --- delimiters).
|
|
@@ -47,6 +48,12 @@ export function setFrontMatterField(filePath: string, field: string, value: stri
|
|
|
47
48
|
writeFileSync(filePath, updated, 'utf-8');
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
export async function setFrontMatterFieldAsync(filePath: string, field: string, value: string): Promise<void> {
|
|
52
|
+
const content = await readFile(filePath, 'utf-8');
|
|
53
|
+
const updated = replaceFrontMatterField(content, field, value);
|
|
54
|
+
await writeFile(filePath, updated, 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
/**
|
|
51
58
|
* Check off all unchecked acceptance criteria checkboxes in a markdown string.
|
|
52
59
|
* Only modifies checkboxes within the "## Acceptance Criteria" section.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import type { Issue } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Determine whether an issue is a code task (modifies source files) or a
|
|
8
|
+
* document task (produces written output like research, design, plans).
|
|
9
|
+
*
|
|
10
|
+
* Uses the issue's `outputType` field when explicitly set. Falls back to
|
|
11
|
+
* inferring from `filesToModify` — entries prefixed with "Output:" are
|
|
12
|
+
* output path hints, not source files to edit.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveIsCodeTask(issue: Issue): boolean {
|
|
15
|
+
if (issue.outputType === 'code') return true;
|
|
16
|
+
if (issue.outputType === 'document') return false;
|
|
17
|
+
|
|
18
|
+
// auto: infer from filesToModify, filtering out Output:-prefixed entries
|
|
19
|
+
const codeFiles = issue.filesToModify.filter(f => !f.match(/^Output:/i));
|
|
20
|
+
return codeFiles.length > 0;
|
|
21
|
+
}
|