mstro-app 0.4.52 → 0.5.0
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/README.md +10 -5
- package/bin/mstro.js +1 -1
- package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
- package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +1 -1
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +63 -67
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +9 -4
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts +16 -0
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
- package/dist/server/cli/improvisation-history-store.js +52 -0
- package/dist/server/cli/improvisation-history-store.js.map +1 -0
- package/dist/server/cli/improvisation-movements.d.ts +31 -0
- package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
- package/dist/server/cli/improvisation-movements.js +93 -0
- package/dist/server/cli/improvisation-movements.js.map +1 -0
- package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
- package/dist/server/cli/improvisation-output-queue.js +40 -0
- package/dist/server/cli/improvisation-output-queue.js.map +1 -0
- package/dist/server/cli/improvisation-retry.d.ts +21 -51
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +18 -433
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +53 -148
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
- package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-best-result.js +61 -0
- package/dist/server/cli/retry/retry-best-result.js.map +1 -0
- package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
- package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-context-loss.js +68 -0
- package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
- package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
- package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-premature-completion.js +81 -0
- package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
- package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
- package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
- package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
- package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
- package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
- package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
- package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
- package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-runner-factory.js +60 -0
- package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
- package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
- package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-tool-results.js +24 -0
- package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
- package/dist/server/cli/retry/retry-types.d.ts +30 -0
- package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
- package/dist/server/cli/retry/retry-types.js +4 -0
- package/dist/server/cli/retry/retry-types.js.map +1 -0
- package/dist/server/index.js +21 -109
- package/dist/server/index.js.map +1 -1
- package/dist/server/server-setup.d.ts +16 -1
- package/dist/server/server-setup.d.ts.map +1 -1
- package/dist/server/server-setup.js +107 -0
- package/dist/server/server-setup.js.map +1 -1
- package/dist/server/services/plan/board-config.d.ts +21 -0
- package/dist/server/services/plan/board-config.d.ts.map +1 -0
- package/dist/server/services/plan/board-config.js +112 -0
- package/dist/server/services/plan/board-config.js.map +1 -0
- 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 +7 -5
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +48 -48
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +157 -455
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-loader.d.ts +16 -0
- package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
- package/dist/server/services/plan/issue-loader.js +46 -0
- package/dist/server/services/plan/issue-loader.js.map +1 -0
- package/dist/server/services/plan/issue-writer.d.ts +34 -0
- package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
- package/dist/server/services/plan/issue-writer.js +110 -0
- package/dist/server/services/plan/issue-writer.js.map +1 -0
- package/dist/server/services/plan/output-manager.d.ts.map +1 -1
- package/dist/server/services/plan/output-manager.js +2 -1
- package/dist/server/services/plan/output-manager.js.map +1 -1
- package/dist/server/services/plan/progress-log.d.ts +11 -0
- package/dist/server/services/plan/progress-log.d.ts.map +1 -0
- package/dist/server/services/plan/progress-log.js +81 -0
- package/dist/server/services/plan/progress-log.js.map +1 -0
- package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/prompt-builder.js +48 -31
- package/dist/server/services/plan/prompt-builder.js.map +1 -1
- package/dist/server/services/plan/readiness-planner.d.ts +15 -0
- package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
- package/dist/server/services/plan/readiness-planner.js +41 -0
- package/dist/server/services/plan/readiness-planner.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts +31 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +52 -2
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/platform.d.ts +56 -0
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +154 -52
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
- package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
- package/dist/server/services/websocket/file-download-handler.js +165 -0
- package/dist/server/services/websocket/file-download-handler.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +28 -2
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +15 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +7 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +73 -11
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
- package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
- package/dist/server/services/websocket/msg-id-tracker.js +77 -0
- package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
- package/dist/server/services/websocket/quality-handlers.js +15 -3
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +2 -2
- package/dist/server/services/websocket/session-handlers.d.ts +48 -2
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +204 -65
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts +2 -2
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +75 -17
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +29 -1
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +53 -4
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-broadcast.js +13 -0
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
- package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-event-buffer.js +107 -0
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
- package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-event-replay.js +21 -0
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +2 -9
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +15 -6
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +6 -4
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/README.md +1 -1
- package/server/cli/headless/claude-invoker-stall.ts +7 -2
- package/server/cli/headless/claude-invoker.ts +1 -1
- package/server/cli/headless/runner.ts +67 -72
- package/server/cli/headless/stall-assessor.ts +9 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-history-store.ts +62 -0
- package/server/cli/improvisation-movements.ts +120 -0
- package/server/cli/improvisation-output-queue.ts +42 -0
- package/server/cli/improvisation-retry.ts +25 -600
- package/server/cli/improvisation-session-manager.ts +74 -160
- package/server/cli/retry/retry-best-result.ts +70 -0
- package/server/cli/retry/retry-context-loss.ts +87 -0
- package/server/cli/retry/retry-premature-completion.ts +113 -0
- package/server/cli/retry/retry-recovery-strategies.ts +247 -0
- package/server/cli/retry/retry-resume-strategy.ts +33 -0
- package/server/cli/retry/retry-runner-factory.ts +70 -0
- package/server/cli/retry/retry-tool-results.ts +31 -0
- package/server/cli/retry/retry-types.ts +32 -0
- package/server/index.ts +37 -123
- package/server/server-setup.ts +126 -1
- package/server/services/plan/agents/assess-stall.md +11 -4
- package/server/services/plan/board-config.ts +122 -0
- package/server/services/plan/composer.ts +7 -5
- package/server/services/plan/executor.ts +214 -467
- package/server/services/plan/issue-loader.ts +64 -0
- package/server/services/plan/issue-writer.ts +137 -0
- package/server/services/plan/output-manager.ts +2 -1
- package/server/services/plan/progress-log.ts +92 -0
- package/server/services/plan/prompt-builder.ts +73 -35
- package/server/services/plan/readiness-planner.ts +50 -0
- package/server/services/plan/review-gate.ts +102 -2
- package/server/services/platform.ts +163 -58
- package/server/services/websocket/file-download-handler.ts +191 -0
- package/server/services/websocket/git-worktree-handlers.ts +29 -2
- package/server/services/websocket/handler-context.ts +15 -0
- package/server/services/websocket/handler.ts +76 -12
- package/server/services/websocket/msg-id-tracker.ts +84 -0
- package/server/services/websocket/quality-handlers.ts +16 -3
- package/server/services/websocket/quality-review-agent.ts +2 -2
- package/server/services/websocket/session-handlers.ts +213 -68
- package/server/services/websocket/session-initialization.ts +83 -19
- package/server/services/websocket/session-registry.ts +61 -4
- package/server/services/websocket/tab-broadcast.ts +38 -0
- package/server/services/websocket/tab-event-buffer.ts +159 -0
- package/server/services/websocket/tab-event-replay.ts +42 -0
- package/server/services/websocket/tab-handlers.ts +2 -9
- package/server/services/websocket/types.ts +17 -4
|
@@ -8,56 +8,51 @@
|
|
|
8
8
|
* reconciles state, and repeats.
|
|
9
9
|
*
|
|
10
10
|
* Implementation is split across focused modules:
|
|
11
|
+
* - board-config.ts — board.md metadata reads, workspace.json active board resolution
|
|
11
12
|
* - config-installer.ts — tool permissions install/uninstall
|
|
12
13
|
* - issue-prompt-builder.ts — per-issue prompt construction
|
|
14
|
+
* - issue-writer.ts — issue front-matter updates, recovery, revert, cancellation notes
|
|
13
15
|
* - output-manager.ts — output path resolution, listing, publishing
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
+
* - progress-log.ts — progress.md writer + output dir creation
|
|
17
|
+
* - review-gate.ts — AI-powered quality gate (review, parse, persist, full pipeline)
|
|
16
18
|
*/
|
|
17
19
|
import { EventEmitter } from 'node:events';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
20
|
+
import { readFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
21
22
|
import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
|
|
23
|
+
import { DEFAULT_MAX_PARALLEL_AGENTS, getBoardMaxParallelAgents, resolveActiveBoardId, resolveBoardDir, tryCompleteBoardIfDone, } from './board-config.js';
|
|
22
24
|
import { ConfigInstaller } from './config-installer.js';
|
|
23
25
|
import { resolveReadyToWork } from './dependency-resolver.js';
|
|
24
|
-
import {
|
|
26
|
+
import { loadBoardIssues, loadProjectIssues } from './issue-loader.js';
|
|
25
27
|
import { buildIssuePrompt } from './issue-prompt-builder.js';
|
|
26
28
|
import { runIssueWithRetry } from './issue-retry.js';
|
|
29
|
+
import { extractIssueStatus, recoverStaleIssues, revertIncompleteIssues, updateIssueFrontMatter, validateIssuePath, } from './issue-writer.js';
|
|
27
30
|
import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
31
|
+
import { resolvePmDir } from './parser.js';
|
|
32
|
+
import { appendProgressEntry, ensureOutputDirs } from './progress-log.js';
|
|
33
|
+
import { buildCompletionReason, detectDeadState, hasBlockedIssues } from './readiness-planner.js';
|
|
34
|
+
import { runReviewPipeline } from './review-gate.js';
|
|
30
35
|
import { reconcileState } from './state-reconciler.js';
|
|
31
|
-
/** Default max parallel agents when board doesn't specify. */
|
|
32
|
-
const DEFAULT_MAX_PARALLEL_AGENTS = 3;
|
|
33
36
|
/** Stop after this many consecutive waves with zero completions. */
|
|
34
37
|
const MAX_CONSECUTIVE_EMPTY_WAVES = 3;
|
|
35
38
|
/** Per-issue stall timeouts (ms) — shorter than Agent Teams wave timeouts */
|
|
36
39
|
const ISSUE_STALL_WARNING_MS = 900_000; // 15 min
|
|
37
40
|
const ISSUE_STALL_KILL_MS = 1_800_000; // 30 min
|
|
38
|
-
const ISSUE_STALL_HARD_CAP_MS =
|
|
41
|
+
const ISSUE_STALL_HARD_CAP_MS = 14_400_000; // 4 hr backstop — only fires after stall signals flag the run
|
|
39
42
|
const ISSUE_STALL_MAX_EXTENSIONS = 10;
|
|
40
43
|
export class PlanExecutor extends EventEmitter {
|
|
41
44
|
status = 'idle';
|
|
42
45
|
workingDir;
|
|
46
|
+
extraEnv;
|
|
43
47
|
shouldStop = false;
|
|
44
48
|
shouldPause = false;
|
|
45
49
|
/** AbortController for killing running HeadlessRunner processes on stop. */
|
|
46
50
|
waveAbortController = null;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
boardDir = null;
|
|
52
|
-
/** Board ID being executed (e.g. "BOARD-001") */
|
|
53
|
-
boardId = null;
|
|
51
|
+
/** Resolved context for the current/last run — rebuilt each runStart(). */
|
|
52
|
+
context;
|
|
53
|
+
/** Options from the last run; replayed on resume() to preserve scope. */
|
|
54
|
+
lastStartOptions = {};
|
|
54
55
|
configInstaller;
|
|
55
|
-
/** Flag to prevent start() from clearing scope set by startBoard/startEpic */
|
|
56
|
-
_scopeSetByCall = false;
|
|
57
|
-
/** Extra environment variables forwarded to HeadlessRunner child processes (e.g. API keys) */
|
|
58
|
-
extraEnv;
|
|
59
|
-
/** Optional worktree directory for running AI agents. PM data is always read from workingDir. */
|
|
60
|
-
executionDir = null;
|
|
61
56
|
metrics = {
|
|
62
57
|
issuesCompleted: 0,
|
|
63
58
|
issuesAttempted: 0,
|
|
@@ -70,50 +65,49 @@ export class PlanExecutor extends EventEmitter {
|
|
|
70
65
|
this.workingDir = workingDir;
|
|
71
66
|
this.extraEnv = options?.extraEnv;
|
|
72
67
|
this.configInstaller = new ConfigInstaller(workingDir);
|
|
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;
|
|
68
|
+
this.context = this.buildContext({});
|
|
82
69
|
}
|
|
83
70
|
getStatus() { return this.status; }
|
|
84
71
|
getMetrics() { return { ...this.metrics }; }
|
|
85
|
-
|
|
86
|
-
this.
|
|
87
|
-
this._scopeSetByCall = true;
|
|
88
|
-
return this.start();
|
|
72
|
+
startEpic(epicPath) {
|
|
73
|
+
return this.runStart({ epic: epicPath });
|
|
89
74
|
}
|
|
90
75
|
/** Start execution, optionally scoped to a specific board. */
|
|
91
|
-
|
|
92
|
-
this.boardId
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return this.
|
|
76
|
+
startBoard(boardId, executionDir) {
|
|
77
|
+
return this.runStart({ board: boardId, executionDir });
|
|
78
|
+
}
|
|
79
|
+
start(options = {}) {
|
|
80
|
+
return this.runStart(options);
|
|
81
|
+
}
|
|
82
|
+
pause() { this.shouldPause = true; }
|
|
83
|
+
stop() {
|
|
84
|
+
this.shouldStop = true;
|
|
85
|
+
this.status = 'stopping';
|
|
86
|
+
this.emit('statusChanged', this.status);
|
|
87
|
+
// Kill all running HeadlessRunner processes in the current wave
|
|
88
|
+
this.waveAbortController?.abort();
|
|
89
|
+
}
|
|
90
|
+
resume() {
|
|
91
|
+
if (this.status !== 'paused')
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
this.shouldPause = false;
|
|
94
|
+
// Replay the options from the previous run to preserve epic/board scope.
|
|
95
|
+
return this.runStart(this.lastStartOptions);
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
// ── Run orchestration ────────────────────────────────────────
|
|
98
|
+
async runStart(options) {
|
|
98
99
|
if (this.status === 'executing' || this.status === 'starting')
|
|
99
100
|
return;
|
|
101
|
+
this.lastStartOptions = options;
|
|
100
102
|
this.shouldStop = false;
|
|
101
103
|
this.shouldPause = false;
|
|
102
|
-
// Reset scoping from previous runs unless explicitly set by startBoard/startEpic
|
|
103
|
-
if (!this._scopeSetByCall) {
|
|
104
|
-
this.epicScope = null;
|
|
105
|
-
this.boardId = null;
|
|
106
|
-
this.executionDir = null;
|
|
107
|
-
}
|
|
108
|
-
this._scopeSetByCall = false;
|
|
109
104
|
this.status = 'starting';
|
|
110
105
|
this.emit('statusChanged', this.status);
|
|
106
|
+
this.context = this.buildContext(options);
|
|
111
107
|
const startTime = Date.now();
|
|
112
108
|
this.status = 'executing';
|
|
113
109
|
this.emit('statusChanged', this.status);
|
|
114
|
-
this.
|
|
115
|
-
this.boardDir = this.resolveBoardDir();
|
|
116
|
-
await this.recoverStaleIssues();
|
|
110
|
+
await this.runStaleRecovery();
|
|
117
111
|
const stallResult = await this.runWaveLoop();
|
|
118
112
|
this.metrics.totalDuration = Date.now() - startTime;
|
|
119
113
|
if (stallResult === 'stalled' || stallResult === 'dead') {
|
|
@@ -135,10 +129,39 @@ export class PlanExecutor extends EventEmitter {
|
|
|
135
129
|
}
|
|
136
130
|
this.emit('statusChanged', this.status);
|
|
137
131
|
}
|
|
132
|
+
/** Build an immutable execution context from start options. */
|
|
133
|
+
buildContext(options) {
|
|
134
|
+
const pmDir = resolvePmDir(this.workingDir);
|
|
135
|
+
const boardId = options.board ?? null;
|
|
136
|
+
return {
|
|
137
|
+
workingDir: this.workingDir,
|
|
138
|
+
extraEnv: this.extraEnv,
|
|
139
|
+
epicScope: options.epic ?? null,
|
|
140
|
+
boardId,
|
|
141
|
+
executionDir: options.executionDir ?? null,
|
|
142
|
+
pmDir,
|
|
143
|
+
boardDir: resolveBoardDir(pmDir, boardId),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ── Warning / update helpers bound to the executor's event stream ──
|
|
147
|
+
/**
|
|
148
|
+
* Forward module-emitted warnings as executor 'output' events so they flow
|
|
149
|
+
* through to the WebSocket broadcast like inline warnings always have.
|
|
150
|
+
*/
|
|
151
|
+
emitWarn = (message, issueId) => {
|
|
152
|
+
this.emit('output', { issueId: issueId ?? 'system', text: message, boardId: this.context.boardId ?? null });
|
|
153
|
+
};
|
|
154
|
+
async setIssueStatus(issuePath, newStatus) {
|
|
155
|
+
const { pmDir } = this.context;
|
|
156
|
+
if (!pmDir)
|
|
157
|
+
return;
|
|
158
|
+
await updateIssueFrontMatter(pmDir, issuePath, newStatus, this.emitWarn);
|
|
159
|
+
}
|
|
160
|
+
// ── Wave loop ────────────────────────────────────────────────
|
|
138
161
|
/** Run waves until done, paused, stopped, or stalled. */
|
|
139
162
|
async runWaveLoop() {
|
|
140
163
|
let consecutiveZeroCompletions = 0;
|
|
141
|
-
const maxParallel = await this.
|
|
164
|
+
const maxParallel = await getBoardMaxParallelAgents(this.context.pmDir, this.effectiveBoardId(), this.emitWarn);
|
|
142
165
|
while (!this.shouldStop && !this.shouldPause) {
|
|
143
166
|
const readyIssues = await this.pickReadyIssues();
|
|
144
167
|
if (readyIssues.length === 0) {
|
|
@@ -156,34 +179,15 @@ export class PlanExecutor extends EventEmitter {
|
|
|
156
179
|
}
|
|
157
180
|
return 'done';
|
|
158
181
|
}
|
|
182
|
+
effectiveBoardId() {
|
|
183
|
+
return this.context.boardId ?? resolveActiveBoardId(this.context.pmDir);
|
|
184
|
+
}
|
|
159
185
|
async hasDeadIssues() {
|
|
160
|
-
const pmDir = this.
|
|
186
|
+
const { pmDir } = this.context;
|
|
161
187
|
if (!pmDir)
|
|
162
188
|
return false;
|
|
163
|
-
const
|
|
164
|
-
|
|
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
|
-
}
|
|
172
|
-
pause() { this.shouldPause = true; }
|
|
173
|
-
stop() {
|
|
174
|
-
this.shouldStop = true;
|
|
175
|
-
this.status = 'stopping';
|
|
176
|
-
this.emit('statusChanged', this.status);
|
|
177
|
-
// Kill all running HeadlessRunner processes in the current wave
|
|
178
|
-
this.waveAbortController?.abort();
|
|
179
|
-
}
|
|
180
|
-
resume() {
|
|
181
|
-
if (this.status !== 'paused')
|
|
182
|
-
return Promise.resolve();
|
|
183
|
-
this.shouldPause = false;
|
|
184
|
-
// Preserve board/epic scope across resume by marking as a scoped call
|
|
185
|
-
this._scopeSetByCall = true;
|
|
186
|
-
return this.start();
|
|
189
|
+
const { issues } = await this.loadScopedIssues(pmDir);
|
|
190
|
+
return issues ? hasBlockedIssues(issues) : false;
|
|
187
191
|
}
|
|
188
192
|
// ── Wave execution ───────────────────────────────────────────
|
|
189
193
|
async executeWave(issues) {
|
|
@@ -195,13 +199,13 @@ export class PlanExecutor extends EventEmitter {
|
|
|
195
199
|
this.emit('waveStarted', { issueIds: waveIds });
|
|
196
200
|
// Create abort controller for this wave — stop() will abort it
|
|
197
201
|
this.waveAbortController = new AbortController();
|
|
198
|
-
await this.
|
|
202
|
+
await ensureOutputDirs(this.context.pmDir, this.context.boardDir);
|
|
199
203
|
this.configInstaller.installPermissions();
|
|
200
204
|
for (const issue of issues) {
|
|
201
|
-
await this.
|
|
205
|
+
await this.setIssueStatus(issue.path, 'in_progress');
|
|
202
206
|
}
|
|
203
|
-
const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
|
|
204
|
-
const pmDir = this.
|
|
207
|
+
const existingDocs = listExistingDocs(this.workingDir, this.context.boardDir);
|
|
208
|
+
const { pmDir } = this.context;
|
|
205
209
|
let completedCount = 0;
|
|
206
210
|
try {
|
|
207
211
|
// Spawn one HeadlessRunner per issue in parallel
|
|
@@ -225,7 +229,8 @@ export class PlanExecutor extends EventEmitter {
|
|
|
225
229
|
issueIds: waveIds,
|
|
226
230
|
error: error instanceof Error ? error.message : String(error),
|
|
227
231
|
});
|
|
228
|
-
|
|
232
|
+
if (pmDir)
|
|
233
|
+
await revertIncompleteIssues(pmDir, issues, this.emitWarn);
|
|
229
234
|
}
|
|
230
235
|
finally {
|
|
231
236
|
this.configInstaller.uninstallPermissions();
|
|
@@ -237,17 +242,18 @@ export class PlanExecutor extends EventEmitter {
|
|
|
237
242
|
}
|
|
238
243
|
/** Run a single issue via its own headless Claude Code instance with retry logic. */
|
|
239
244
|
async runSingleIssue(issue, pmDir, existingDocs, waveLabel, abortSignal) {
|
|
240
|
-
const
|
|
241
|
-
const
|
|
245
|
+
const { executionDir, boardDir, workingDir } = this.context;
|
|
246
|
+
const effectiveDir = executionDir || workingDir;
|
|
247
|
+
const outputPath = resolveOutputPath(issue, workingDir, boardDir);
|
|
242
248
|
const prompt = buildIssuePrompt({
|
|
243
249
|
issue,
|
|
244
250
|
workingDir: effectiveDir,
|
|
245
251
|
pmDir,
|
|
246
|
-
boardDir
|
|
252
|
+
boardDir,
|
|
247
253
|
existingDocs,
|
|
248
254
|
outputPath,
|
|
249
255
|
});
|
|
250
|
-
const boardLogDir =
|
|
256
|
+
const boardLogDir = boardDir ? join(boardDir, 'logs') : undefined;
|
|
251
257
|
const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runIssueWithRetry({
|
|
252
258
|
workingDir: effectiveDir,
|
|
253
259
|
prompt,
|
|
@@ -270,8 +276,9 @@ export class PlanExecutor extends EventEmitter {
|
|
|
270
276
|
* doesn't prevent the others or kill the while loop in start().
|
|
271
277
|
*/
|
|
272
278
|
async finalizeWave(issues, waveStart, waveLabel) {
|
|
279
|
+
const { pmDir, boardDir, boardId } = this.context;
|
|
273
280
|
try {
|
|
274
|
-
reconcileState(this.workingDir,
|
|
281
|
+
reconcileState(this.workingDir, boardId ?? undefined);
|
|
275
282
|
this.emit('stateUpdated');
|
|
276
283
|
}
|
|
277
284
|
catch (err) {
|
|
@@ -281,7 +288,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
281
288
|
});
|
|
282
289
|
}
|
|
283
290
|
try {
|
|
284
|
-
publishOutputs(issues, this.workingDir,
|
|
291
|
+
publishOutputs(issues, this.workingDir, boardDir, {
|
|
285
292
|
onWarning: (issueId, text) => this.emit('output', { issueId, text: `Warning: ${text}` }),
|
|
286
293
|
});
|
|
287
294
|
}
|
|
@@ -292,7 +299,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
292
299
|
});
|
|
293
300
|
}
|
|
294
301
|
try {
|
|
295
|
-
await
|
|
302
|
+
await appendProgressEntry(pmDir, boardDir, issues, waveStart, this.emitWarn);
|
|
296
303
|
}
|
|
297
304
|
catch (err) {
|
|
298
305
|
this.emit('output', {
|
|
@@ -308,30 +315,21 @@ export class PlanExecutor extends EventEmitter {
|
|
|
308
315
|
* and either confirmed `done` (passed) or reverted to `todo` (failed).
|
|
309
316
|
*/
|
|
310
317
|
async reconcileWaveResults(issues) {
|
|
311
|
-
const pmDir = this.
|
|
318
|
+
const { pmDir } = this.context;
|
|
312
319
|
if (!pmDir)
|
|
313
320
|
return 0;
|
|
314
321
|
let completed = 0;
|
|
315
322
|
for (const issue of issues) {
|
|
316
|
-
const fullPath =
|
|
323
|
+
const fullPath = validateIssuePath(issue.path, pmDir);
|
|
317
324
|
try {
|
|
318
325
|
const content = await readFile(fullPath, 'utf-8');
|
|
319
|
-
const
|
|
320
|
-
const currentStatus = statusMatch?.[1] ?? 'unknown';
|
|
326
|
+
const currentStatus = extractIssueStatus(content) ?? 'unknown';
|
|
321
327
|
if (currentStatus === 'in_review' || currentStatus === 'done') {
|
|
322
|
-
if (issue
|
|
323
|
-
// Skip review gate — mark done directly
|
|
324
|
-
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
325
|
-
this.metrics.issuesCompleted++;
|
|
326
|
-
this.emit('issueCompleted', issue);
|
|
328
|
+
if (await this.finalizeCompletedIssue(issue, pmDir))
|
|
327
329
|
completed++;
|
|
328
|
-
}
|
|
329
|
-
else {
|
|
330
|
-
completed += await this.runReviewGate(issue, pmDir);
|
|
331
|
-
}
|
|
332
330
|
}
|
|
333
331
|
else {
|
|
334
|
-
await this.
|
|
332
|
+
await this.setIssueStatus(issue.path, issue.status);
|
|
335
333
|
this.emit('issueError', {
|
|
336
334
|
issueId: issue.id,
|
|
337
335
|
error: 'Issue did not complete during wave execution',
|
|
@@ -344,52 +342,38 @@ export class PlanExecutor extends EventEmitter {
|
|
|
344
342
|
}
|
|
345
343
|
return completed;
|
|
346
344
|
}
|
|
347
|
-
/**
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
this.
|
|
355
|
-
this.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
attempts,
|
|
359
|
-
});
|
|
360
|
-
this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
|
|
361
|
-
return 0;
|
|
345
|
+
/**
|
|
346
|
+
* Finalize a single issue whose status reached `in_review`/`done`. Runs the
|
|
347
|
+
* review pipeline unless the issue opted out via `reviewGate: 'none'`.
|
|
348
|
+
* Returns true when the issue is confirmed done (counted toward completions).
|
|
349
|
+
*/
|
|
350
|
+
async finalizeCompletedIssue(issue, pmDir) {
|
|
351
|
+
if (issue.reviewGate === 'none') {
|
|
352
|
+
await this.setIssueStatus(issue.path, 'done');
|
|
353
|
+
this.metrics.issuesCompleted++;
|
|
354
|
+
this.emit('issueCompleted', issue);
|
|
355
|
+
return true;
|
|
362
356
|
}
|
|
363
|
-
await
|
|
364
|
-
this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
|
|
365
|
-
const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
|
|
366
|
-
const result = await reviewIssue({
|
|
367
|
-
workingDir: this.executionDir || this.workingDir,
|
|
357
|
+
const passed = await runReviewPipeline({
|
|
368
358
|
issue,
|
|
369
359
|
pmDir,
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
boardDir: this.boardDir,
|
|
360
|
+
workingDir: this.workingDir,
|
|
361
|
+
executionDir: this.context.executionDir,
|
|
362
|
+
boardDir: this.context.boardDir,
|
|
363
|
+
boardId: this.context.boardId,
|
|
375
364
|
extraEnv: this.extraEnv,
|
|
365
|
+
}, {
|
|
366
|
+
setStatus: (path, status) => this.setIssueStatus(path, status),
|
|
367
|
+
onOutput: (issueId, text) => this.emit('output', { issueId, text }),
|
|
368
|
+
onReviewProgress: (issueId, status) => this.emit('reviewProgress', { issueId, status }),
|
|
369
|
+
onIssueAbandoned: (issueId, reason, attempts) => this.emit('issueAbandoned', { issueId, reason, attempts }),
|
|
370
|
+
onIssueCompleted: (completedIssue) => this.emit('issueCompleted', completedIssue),
|
|
371
|
+
onIssueError: (issueId, error) => this.emit('issueError', { issueId, error }),
|
|
372
|
+
warn: this.emitWarn,
|
|
376
373
|
});
|
|
377
|
-
|
|
378
|
-
if (result.passed) {
|
|
379
|
-
await this.updateIssueFrontMatter(issue.path, 'done');
|
|
374
|
+
if (passed)
|
|
380
375
|
this.metrics.issuesCompleted++;
|
|
381
|
-
|
|
382
|
-
this.emit('issueCompleted', issue);
|
|
383
|
-
return 1;
|
|
384
|
-
}
|
|
385
|
-
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
386
|
-
appendReviewFeedback(pmDir, issue, result);
|
|
387
|
-
this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
|
|
388
|
-
this.emit('issueError', {
|
|
389
|
-
issueId: issue.id,
|
|
390
|
-
error: `Review failed: ${result.checks.filter(c => !c.passed).map(c => c.name).join(', ')}`,
|
|
391
|
-
});
|
|
392
|
-
return 0;
|
|
376
|
+
return passed;
|
|
393
377
|
}
|
|
394
378
|
// ── Recovery ─────────────────────────────────────────────────
|
|
395
379
|
/**
|
|
@@ -398,26 +382,14 @@ export class PlanExecutor extends EventEmitter {
|
|
|
398
382
|
* these issues block the dependency graph and cause the executor to
|
|
399
383
|
* find zero ready issues, making "Implement" appear to do nothing.
|
|
400
384
|
*/
|
|
401
|
-
async
|
|
402
|
-
const pmDir = this.
|
|
385
|
+
async runStaleRecovery() {
|
|
386
|
+
const { pmDir } = this.context;
|
|
403
387
|
if (!pmDir)
|
|
404
388
|
return;
|
|
405
|
-
const
|
|
406
|
-
const issues = effectiveBoardId
|
|
407
|
-
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
408
|
-
: this.loadProjectIssues();
|
|
389
|
+
const { issues } = await this.loadScopedIssues(pmDir);
|
|
409
390
|
if (!issues)
|
|
410
391
|
return;
|
|
411
|
-
const
|
|
412
|
-
const recovered = [];
|
|
413
|
-
for (const issue of issues) {
|
|
414
|
-
if (issue.type === 'epic')
|
|
415
|
-
continue;
|
|
416
|
-
if (staleStatuses.has(issue.status)) {
|
|
417
|
-
await this.updateIssueFrontMatter(issue.path, 'todo');
|
|
418
|
-
recovered.push(`${issue.id} (${issue.status} → todo)`);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
392
|
+
const recovered = await recoverStaleIssues(pmDir, issues, this.emitWarn);
|
|
421
393
|
if (recovered.length > 0) {
|
|
422
394
|
this.emit('output', {
|
|
423
395
|
issueId: 'recovery',
|
|
@@ -426,317 +398,47 @@ export class PlanExecutor extends EventEmitter {
|
|
|
426
398
|
this.emit('stateUpdated');
|
|
427
399
|
}
|
|
428
400
|
}
|
|
429
|
-
// ──
|
|
430
|
-
/** Read the board's maxParallelAgents setting, falling back to default. */
|
|
431
|
-
async getBoardMaxParallelAgents() {
|
|
432
|
-
const pmDir = this.pmDir;
|
|
433
|
-
if (!pmDir)
|
|
434
|
-
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
435
|
-
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
436
|
-
if (!effectiveBoardId)
|
|
437
|
-
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
438
|
-
const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
|
|
439
|
-
if (!existsSync(boardMdPath))
|
|
440
|
-
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
441
|
-
try {
|
|
442
|
-
const content = await readFile(boardMdPath, 'utf-8');
|
|
443
|
-
const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
|
|
444
|
-
return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
|
|
445
|
-
}
|
|
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 });
|
|
448
|
-
return DEFAULT_MAX_PARALLEL_AGENTS;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
/** Read the board's custom review criteria, if set. */
|
|
452
|
-
async getBoardReviewCriteria() {
|
|
453
|
-
const pmDir = this.pmDir;
|
|
454
|
-
if (!pmDir)
|
|
455
|
-
return undefined;
|
|
456
|
-
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
457
|
-
if (!effectiveBoardId)
|
|
458
|
-
return undefined;
|
|
459
|
-
const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
|
|
460
|
-
if (!existsSync(boardMdPath))
|
|
461
|
-
return undefined;
|
|
462
|
-
try {
|
|
463
|
-
const content = await readFile(boardMdPath, 'utf-8');
|
|
464
|
-
const match = content.match(/^review_criteria:\s*"(.+)"/m);
|
|
465
|
-
if (!match)
|
|
466
|
-
return undefined;
|
|
467
|
-
const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
|
|
468
|
-
return raw || undefined;
|
|
469
|
-
}
|
|
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 });
|
|
472
|
-
return undefined;
|
|
473
|
-
}
|
|
474
|
-
}
|
|
401
|
+
// ── Issue loading & readiness ────────────────────────────────
|
|
475
402
|
async pickReadyIssues() {
|
|
476
|
-
const pmDir = this.
|
|
403
|
+
const { pmDir, epicScope } = this.context;
|
|
477
404
|
if (!pmDir) {
|
|
478
405
|
this.emit('error', 'No PM directory found');
|
|
479
406
|
return [];
|
|
480
407
|
}
|
|
481
|
-
const
|
|
482
|
-
const issues = effectiveBoardId
|
|
483
|
-
? await this.loadBoardIssues(pmDir, effectiveBoardId)
|
|
484
|
-
: this.loadProjectIssues();
|
|
408
|
+
const { issues, boardId } = await this.loadScopedIssues(pmDir);
|
|
485
409
|
if (!issues)
|
|
486
410
|
return [];
|
|
487
|
-
const readyIssues = resolveReadyToWork(issues,
|
|
411
|
+
const readyIssues = resolveReadyToWork(issues, epicScope ?? undefined);
|
|
488
412
|
if (readyIssues.length === 0) {
|
|
489
|
-
const deadState =
|
|
413
|
+
const deadState = detectDeadState(issues);
|
|
490
414
|
if (deadState) {
|
|
491
415
|
this.emit('error', deadState);
|
|
492
416
|
}
|
|
493
417
|
else {
|
|
494
|
-
this.emit('complete',
|
|
495
|
-
if (
|
|
496
|
-
await
|
|
418
|
+
this.emit('complete', buildCompletionReason(issues, epicScope));
|
|
419
|
+
if (boardId) {
|
|
420
|
+
await tryCompleteBoardIfDone(pmDir, boardId, issues, this.emitWarn);
|
|
497
421
|
}
|
|
498
422
|
}
|
|
499
423
|
}
|
|
500
424
|
return readyIssues;
|
|
501
425
|
}
|
|
502
|
-
/**
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
else if (boardState.board.status !== 'active') {
|
|
517
|
-
this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
|
|
518
|
-
return null;
|
|
519
|
-
}
|
|
520
|
-
return boardState.issues;
|
|
521
|
-
}
|
|
522
|
-
/** Load project-level issues (legacy or no boards). Returns null on error. */
|
|
523
|
-
loadProjectIssues() {
|
|
524
|
-
const fullState = parsePlanDirectory(this.workingDir);
|
|
525
|
-
if (!fullState) {
|
|
526
|
-
this.emit('error', 'No PM directory found');
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
|
-
if (fullState.state.paused) {
|
|
530
|
-
this.emit('error', 'Project is paused');
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
return fullState.issues;
|
|
534
|
-
}
|
|
535
|
-
/** Activate a draft board by updating its status in board.md. */
|
|
536
|
-
async activateBoard(pmDir, boardId) {
|
|
537
|
-
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
538
|
-
if (!existsSync(boardMdPath))
|
|
539
|
-
return;
|
|
540
|
-
try {
|
|
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 });
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
/** Check if all issues in a board are done and mark board as completed. */
|
|
549
|
-
async tryCompleteBoardIfDone(pmDir, boardId, issues) {
|
|
550
|
-
const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
|
|
551
|
-
if (!allDone)
|
|
552
|
-
return;
|
|
553
|
-
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
554
|
-
if (!existsSync(boardMdPath))
|
|
555
|
-
return;
|
|
556
|
-
try {
|
|
557
|
-
let content = await readFile(boardMdPath, 'utf-8');
|
|
558
|
-
content = replaceFrontMatterField(content, 'status', 'completed');
|
|
559
|
-
content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
|
|
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 });
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
resolveActiveBoardId() {
|
|
567
|
-
const pmDir = this.pmDir;
|
|
568
|
-
if (!pmDir)
|
|
569
|
-
return null;
|
|
570
|
-
try {
|
|
571
|
-
const workspacePath = join(pmDir, 'workspace.json');
|
|
572
|
-
if (!existsSync(workspacePath))
|
|
573
|
-
return null;
|
|
574
|
-
const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
575
|
-
return workspace.activeBoardId ?? null;
|
|
576
|
-
}
|
|
577
|
-
catch {
|
|
578
|
-
return null;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
buildCompletionReason(issues) {
|
|
582
|
-
const nonEpic = issues.filter(i => i.type !== 'epic');
|
|
583
|
-
const done = nonEpic.filter(i => i.status === 'done' || i.status === 'cancelled').length;
|
|
584
|
-
const blocked = nonEpic.filter(i => i.status === 'todo').length;
|
|
585
|
-
if (done === nonEpic.length)
|
|
586
|
-
return this.epicScope ? 'All epic issues are done' : 'All issues are done';
|
|
587
|
-
if (blocked > 0)
|
|
588
|
-
return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
|
|
589
|
-
return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
|
|
590
|
-
}
|
|
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) {
|
|
612
|
-
const pmDir = this.pmDir;
|
|
613
|
-
if (!pmDir)
|
|
614
|
-
return;
|
|
615
|
-
for (const issue of issues) {
|
|
616
|
-
const fullPath = this.validateIssuePath(issue.path, pmDir);
|
|
617
|
-
try {
|
|
618
|
-
const content = await readFile(fullPath, 'utf-8');
|
|
619
|
-
if (content.match(/^status:\s*in_progress$/m)) {
|
|
620
|
-
await this.updateIssueFrontMatter(issue.path, issue.status);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
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 });
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
async updateIssueFrontMatter(issuePath, newStatus) {
|
|
646
|
-
const pmDir = this.pmDir;
|
|
647
|
-
if (!pmDir)
|
|
648
|
-
return;
|
|
649
|
-
try {
|
|
650
|
-
const fullPath = this.validateIssuePath(issuePath, pmDir);
|
|
651
|
-
await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
|
|
652
|
-
if (newStatus === 'done') {
|
|
653
|
-
const content = await readFile(fullPath, 'utf-8');
|
|
654
|
-
const updated = checkAllAcceptanceCriteria(content);
|
|
655
|
-
if (updated !== content)
|
|
656
|
-
await writeFile(fullPath, updated, 'utf-8');
|
|
657
|
-
}
|
|
658
|
-
}
|
|
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
|
-
}
|
|
662
|
-
}
|
|
663
|
-
async ensureOutputDirs() {
|
|
664
|
-
if (this.boardDir) {
|
|
665
|
-
const boardOutDir = join(this.boardDir, 'out');
|
|
666
|
-
if (!existsSync(boardOutDir))
|
|
667
|
-
await mkdir(boardOutDir, { recursive: true });
|
|
668
|
-
}
|
|
669
|
-
else {
|
|
670
|
-
const pmDir = this.pmDir;
|
|
671
|
-
if (pmDir) {
|
|
672
|
-
const outDir = join(pmDir, 'out');
|
|
673
|
-
if (!existsSync(outDir))
|
|
674
|
-
await mkdir(outDir, { recursive: true });
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
async appendProgressEntry(issues, waveStart) {
|
|
679
|
-
const pmDir = this.pmDir;
|
|
680
|
-
if (!pmDir)
|
|
681
|
-
return;
|
|
682
|
-
const progressPath = this.boardDir
|
|
683
|
-
? join(this.boardDir, 'progress.md')
|
|
684
|
-
: join(pmDir, 'progress.md');
|
|
685
|
-
const durationMin = Math.round((Date.now() - waveStart) / 60_000);
|
|
686
|
-
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
687
|
-
const completed = [];
|
|
688
|
-
const failed = [];
|
|
689
|
-
for (const issue of issues) {
|
|
690
|
-
try {
|
|
691
|
-
const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
|
|
692
|
-
const statusMatch = content.match(/^status:\s*(\S+)/m);
|
|
693
|
-
if (statusMatch?.[1] === 'done') {
|
|
694
|
-
completed.push(issue.id);
|
|
695
|
-
}
|
|
696
|
-
else {
|
|
697
|
-
failed.push(issue.id);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
catch {
|
|
701
|
-
failed.push(issue.id);
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
const lines = [
|
|
705
|
-
'',
|
|
706
|
-
`## ${timestamp} — Wave [${issues.map(i => i.id).join(', ')}]`,
|
|
707
|
-
'',
|
|
708
|
-
`- **Duration**: ${durationMin} min`,
|
|
709
|
-
`- **Completed**: ${completed.length}/${issues.length}${completed.length > 0 ? ` (${completed.join(', ')})` : ''}`,
|
|
710
|
-
];
|
|
711
|
-
if (failed.length > 0) {
|
|
712
|
-
lines.push(`- **Failed**: ${failed.join(', ')}`);
|
|
713
|
-
}
|
|
714
|
-
lines.push('');
|
|
715
|
-
await this.writeProgressLines(progressPath, lines);
|
|
716
|
-
}
|
|
717
|
-
async writeProgressLines(filePath, lines) {
|
|
718
|
-
try {
|
|
719
|
-
if (existsSync(filePath)) {
|
|
720
|
-
await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
|
|
724
|
-
}
|
|
725
|
-
}
|
|
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
|
-
}
|
|
729
|
-
}
|
|
730
|
-
/** Resolve the active board's directory path for outputs, reviews, and progress. */
|
|
731
|
-
resolveBoardDir() {
|
|
732
|
-
const pmDir = this.pmDir;
|
|
733
|
-
if (!pmDir)
|
|
734
|
-
return null;
|
|
735
|
-
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
736
|
-
if (!effectiveBoardId)
|
|
737
|
-
return null;
|
|
738
|
-
const boardDir = join(pmDir, 'boards', effectiveBoardId);
|
|
739
|
-
return existsSync(boardDir) ? boardDir : null;
|
|
426
|
+
/**
|
|
427
|
+
* Load issues for the active execution scope. Returns the resolved boardId
|
|
428
|
+
* alongside the issues so callers can branch on board-specific logic without
|
|
429
|
+
* re-resolving the scope.
|
|
430
|
+
*/
|
|
431
|
+
async loadScopedIssues(pmDir) {
|
|
432
|
+
const boardId = this.effectiveBoardId();
|
|
433
|
+
const issues = boardId
|
|
434
|
+
? await loadBoardIssues(pmDir, boardId, {
|
|
435
|
+
onError: msg => this.emit('error', msg),
|
|
436
|
+
warn: this.emitWarn,
|
|
437
|
+
})
|
|
438
|
+
: loadProjectIssues(this.workingDir, { onError: msg => this.emit('error', msg) });
|
|
439
|
+
return { issues, boardId };
|
|
740
440
|
}
|
|
741
441
|
}
|
|
442
|
+
// Re-export for backwards compatibility with modules that imported the constant from here.
|
|
443
|
+
export { DEFAULT_MAX_PARALLEL_AGENTS };
|
|
742
444
|
//# sourceMappingURL=executor.js.map
|