mstro-app 0.3.8 → 0.3.9
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/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +18 -9
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/headless-logger.d.ts +10 -0
- package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
- package/dist/server/cli/headless/headless-logger.js +66 -0
- package/dist/server/cli/headless/headless-logger.js.map +1 -0
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +6 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +4 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +70 -19
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +22 -9
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +8 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +94 -11
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.d.ts +3 -0
- package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-cli.js +54 -0
- package/dist/server/mcp/bouncer-cli.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts +4 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -0
- package/dist/server/services/plan/composer.js +181 -0
- package/dist/server/services/plan/composer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/dependency-resolver.js +152 -0
- package/dist/server/services/plan/dependency-resolver.js.map +1 -0
- package/dist/server/services/plan/executor.d.ts +91 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -0
- package/dist/server/services/plan/executor.js +545 -0
- package/dist/server/services/plan/executor.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +11 -0
- package/dist/server/services/plan/parser.d.ts.map +1 -0
- package/dist/server/services/plan/parser.js +415 -0
- package/dist/server/services/plan/parser.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +2 -0
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
- package/dist/server/services/plan/state-reconciler.js +105 -0
- package/dist/server/services/plan/state-reconciler.js.map +1 -0
- package/dist/server/services/plan/types.d.ts +120 -0
- package/dist/server/services/plan/types.d.ts.map +1 -0
- package/dist/server/services/plan/types.js +4 -0
- package/dist/server/services/plan/types.js.map +1 -0
- package/dist/server/services/plan/watcher.d.ts +14 -0
- package/dist/server/services/plan/watcher.d.ts.map +1 -0
- package/dist/server/services/plan/watcher.js +69 -0
- package/dist/server/services/plan/watcher.js.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +21 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/plan-handlers.js +494 -0
- package/dist/server/services/websocket/plan-handlers.js.map +1 -0
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +375 -11
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-persistence.js +187 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -0
- package/dist/server/services/websocket/quality-service.d.ts +2 -2
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +62 -12
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/server/cli/headless/claude-invoker.ts +21 -9
- package/server/cli/headless/headless-logger.ts +78 -0
- package/server/cli/headless/mcp-config.ts +6 -5
- package/server/cli/headless/runner.ts +4 -0
- package/server/cli/headless/stall-assessor.ts +97 -19
- package/server/cli/headless/tool-watchdog.ts +10 -9
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +118 -11
- package/server/mcp/bouncer-cli.ts +73 -0
- package/server/services/plan/composer.ts +199 -0
- package/server/services/plan/dependency-resolver.ts +179 -0
- package/server/services/plan/executor.ts +604 -0
- package/server/services/plan/parser.ts +459 -0
- package/server/services/plan/state-reconciler.ts +132 -0
- package/server/services/plan/types.ts +164 -0
- package/server/services/plan/watcher.ts +73 -0
- package/server/services/websocket/file-explorer-handlers.ts +20 -0
- package/server/services/websocket/handler.ts +21 -0
- package/server/services/websocket/plan-handlers.ts +592 -0
- package/server/services/websocket/quality-handlers.ts +441 -11
- package/server/services/websocket/quality-persistence.ts +250 -0
- package/server/services/websocket/quality-service.ts +65 -12
- package/server/services/websocket/types.ts +48 -2
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan Handlers — WebSocket message handlers for Plan view
|
|
6
|
+
*
|
|
7
|
+
* Routes plan* messages to the PPS parser and file operations.
|
|
8
|
+
* Follows the same pattern as quality-handlers.ts and git-handlers.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { handlePlanPrompt } from '../plan/composer.js';
|
|
14
|
+
import { PlanExecutor } from '../plan/executor.js';
|
|
15
|
+
import { getNextId, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
|
|
16
|
+
import { PlanWatcher } from '../plan/watcher.js';
|
|
17
|
+
import type { HandlerContext } from './handler-context.js';
|
|
18
|
+
import type { WebSocketMessage, WSContext } from './types.js';
|
|
19
|
+
|
|
20
|
+
const watcherCache = new Map<string, PlanWatcher>();
|
|
21
|
+
const executorCache = new Map<string, PlanExecutor>();
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Helpers
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Validate that a user-supplied path resolves within the .pm/ (or legacy .plan/) directory. */
|
|
28
|
+
function resolvePlanPath(workingDir: string, relativePath: string): string | null {
|
|
29
|
+
const pmDir = resolvePmDir(workingDir);
|
|
30
|
+
if (!pmDir) return null;
|
|
31
|
+
const resolved = resolve(pmDir, relativePath);
|
|
32
|
+
if (!resolved.startsWith(`${pmDir}/`) && resolved !== pmDir) return null;
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Guard for write operations — returns true if denied. */
|
|
37
|
+
function denyIfViewOnly(ctx: HandlerContext, ws: WSContext, permission?: 'control' | 'view'): boolean {
|
|
38
|
+
if (permission === 'view') {
|
|
39
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Permission denied' } });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatYamlValue(value: unknown): string {
|
|
46
|
+
if (value === null || value === undefined) return 'null';
|
|
47
|
+
if (typeof value === 'boolean') return String(value);
|
|
48
|
+
if (typeof value === 'number') return String(value);
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
if (value.length === 0) return '[]';
|
|
51
|
+
return `[${value.map(v => typeof v === 'string' ? v : String(v)).join(', ')}]`;
|
|
52
|
+
}
|
|
53
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildIssueMarkdown(
|
|
57
|
+
id: string, title: string, type: string, priority: string,
|
|
58
|
+
labels: string[], sprint: string | null, description: string,
|
|
59
|
+
): string {
|
|
60
|
+
const labelsYaml = labels.length > 0 ? `[${labels.join(', ')}]` : '[]';
|
|
61
|
+
const today = new Date().toISOString().split('T')[0];
|
|
62
|
+
return `---
|
|
63
|
+
id: ${id}
|
|
64
|
+
title: "${title.replace(/"/g, '\\"')}"
|
|
65
|
+
type: ${type}
|
|
66
|
+
status: backlog
|
|
67
|
+
priority: ${priority}
|
|
68
|
+
estimate: null
|
|
69
|
+
labels: ${labelsYaml}
|
|
70
|
+
epic: null
|
|
71
|
+
sprint: ${sprint || 'null'}
|
|
72
|
+
milestone: null
|
|
73
|
+
assigned: null
|
|
74
|
+
created: "${today}"
|
|
75
|
+
due: null
|
|
76
|
+
blocked_by: []
|
|
77
|
+
blocks: []
|
|
78
|
+
relates_to: []
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
# ${id}: ${title}
|
|
82
|
+
|
|
83
|
+
## Description
|
|
84
|
+
${description}
|
|
85
|
+
|
|
86
|
+
## Acceptance Criteria
|
|
87
|
+
|
|
88
|
+
## Technical Notes
|
|
89
|
+
|
|
90
|
+
## Files to Modify
|
|
91
|
+
|
|
92
|
+
## Activity
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildProjectMarkdown(name: string): string {
|
|
97
|
+
const today = new Date().toISOString().split('T')[0];
|
|
98
|
+
const projectId = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
99
|
+
return `---
|
|
100
|
+
name: "${name}"
|
|
101
|
+
id: ${projectId}
|
|
102
|
+
created: "${today}"
|
|
103
|
+
status: active
|
|
104
|
+
estimation: fibonacci
|
|
105
|
+
id_prefixes:
|
|
106
|
+
epic: EP
|
|
107
|
+
issue: IS
|
|
108
|
+
bug: BG
|
|
109
|
+
labels: []
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
# ${name}
|
|
113
|
+
|
|
114
|
+
## Goals
|
|
115
|
+
|
|
116
|
+
## Teams
|
|
117
|
+
|
|
118
|
+
## Labels
|
|
119
|
+
|
|
120
|
+
## Workflows
|
|
121
|
+
| Status | Category | Description |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| backlog | unstarted | Accepted, not yet scheduled |
|
|
124
|
+
| todo | unstarted | Scheduled for current sprint |
|
|
125
|
+
| in_progress | started | Actively being worked on |
|
|
126
|
+
| in_review | started | PR open, awaiting review |
|
|
127
|
+
| done | completed | Merged and verified |
|
|
128
|
+
| cancelled | cancelled | Will not be done |
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildStateMarkdown(name: string): string {
|
|
133
|
+
return `---
|
|
134
|
+
project: "${name}"
|
|
135
|
+
current_sprint: null
|
|
136
|
+
active_milestone: null
|
|
137
|
+
paused: false
|
|
138
|
+
last_session: null
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
# Project State
|
|
142
|
+
|
|
143
|
+
## Current Focus
|
|
144
|
+
|
|
145
|
+
## Ready to Work
|
|
146
|
+
|
|
147
|
+
## In Progress
|
|
148
|
+
|
|
149
|
+
## Blocked
|
|
150
|
+
|
|
151
|
+
## Recently Completed
|
|
152
|
+
|
|
153
|
+
## Warnings
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getWatcher(workingDir: string, ctx: HandlerContext): PlanWatcher {
|
|
158
|
+
let watcher = watcherCache.get(workingDir);
|
|
159
|
+
if (!watcher) {
|
|
160
|
+
watcher = new PlanWatcher(workingDir, ctx);
|
|
161
|
+
watcherCache.set(workingDir, watcher);
|
|
162
|
+
}
|
|
163
|
+
return watcher;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getExecutor(workingDir: string): PlanExecutor {
|
|
167
|
+
let executor = executorCache.get(workingDir);
|
|
168
|
+
if (!executor) {
|
|
169
|
+
executor = new PlanExecutor(workingDir);
|
|
170
|
+
executorCache.set(workingDir, executor);
|
|
171
|
+
}
|
|
172
|
+
return executor;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Cleanup watchers and executors for a working directory. */
|
|
176
|
+
export function cleanupPlanResources(workingDir: string): void {
|
|
177
|
+
const watcher = watcherCache.get(workingDir);
|
|
178
|
+
if (watcher) {
|
|
179
|
+
watcher.stop();
|
|
180
|
+
watcherCache.delete(workingDir);
|
|
181
|
+
}
|
|
182
|
+
const executor = executorCache.get(workingDir);
|
|
183
|
+
if (executor) {
|
|
184
|
+
executor.stop();
|
|
185
|
+
executorCache.delete(workingDir);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// Main dispatcher
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
export function handlePlanMessage(
|
|
194
|
+
ctx: HandlerContext,
|
|
195
|
+
ws: WSContext,
|
|
196
|
+
msg: WebSocketMessage,
|
|
197
|
+
_tabId: string,
|
|
198
|
+
workingDir: string,
|
|
199
|
+
permission?: 'control' | 'view',
|
|
200
|
+
): void {
|
|
201
|
+
const handlers: Record<string, () => void> = {
|
|
202
|
+
planInit: () => handlePlanInit(ctx, ws, workingDir),
|
|
203
|
+
planGetState: () => handlePlanInit(ctx, ws, workingDir),
|
|
204
|
+
planListIssues: () => handleListIssues(ctx, ws, workingDir),
|
|
205
|
+
planGetIssue: () => handleGetIssue(ctx, ws, msg, workingDir),
|
|
206
|
+
planGetSprint: () => handleGetSprint(ctx, ws, msg, workingDir),
|
|
207
|
+
planGetMilestone: () => handleGetMilestone(ctx, ws, msg, workingDir),
|
|
208
|
+
planCreateIssue: () => handleCreateIssue(ctx, ws, msg, workingDir, permission),
|
|
209
|
+
planUpdateIssue: () => handleUpdateIssue(ctx, ws, msg, workingDir, permission),
|
|
210
|
+
planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, permission),
|
|
211
|
+
planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, permission),
|
|
212
|
+
planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, permission),
|
|
213
|
+
planExecute: () => handleExecute(ctx, ws, workingDir, permission),
|
|
214
|
+
planExecuteEpic: () => handleExecuteEpic(ctx, ws, msg, workingDir, permission),
|
|
215
|
+
planPause: () => handlePause(ctx, ws, workingDir, permission),
|
|
216
|
+
planStop: () => handleStop(ctx, ws, workingDir, permission),
|
|
217
|
+
planResume: () => handleResume(ctx, ws, workingDir, permission),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handler = handlers[msg.type];
|
|
221
|
+
if (!handler) return;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
handler();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
227
|
+
ctx.send(ws, { type: 'planError', data: { error: errMsg } });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================================
|
|
232
|
+
// Read-only handlers
|
|
233
|
+
// ============================================================================
|
|
234
|
+
|
|
235
|
+
function handlePlanInit(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
|
|
236
|
+
if (!planDirExists(workingDir)) {
|
|
237
|
+
ctx.send(ws, { type: 'planNotFound', data: {} });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
242
|
+
if (!fullState) {
|
|
243
|
+
ctx.send(ws, { type: 'planNotFound', data: {} });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ctx.send(ws, { type: 'planState', data: fullState });
|
|
248
|
+
|
|
249
|
+
const watcher = getWatcher(workingDir, ctx);
|
|
250
|
+
watcher.start();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function handleListIssues(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
|
|
254
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
255
|
+
if (!fullState) {
|
|
256
|
+
ctx.send(ws, { type: 'planNotFound', data: {} });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
ctx.send(ws, { type: 'planIssueList', data: { issues: fullState.issues } });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function handleGetIssue(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
|
|
263
|
+
const path = msg.data?.path;
|
|
264
|
+
if (!path || !resolvePlanPath(workingDir, path)) {
|
|
265
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Invalid issue path' } });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const issue = parseSingleIssue(workingDir, path);
|
|
269
|
+
if (!issue) {
|
|
270
|
+
ctx.send(ws, { type: 'planError', data: { error: `Issue not found: ${path}` } });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
ctx.send(ws, { type: 'planIssue', data: issue });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function handleGetSprint(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
|
|
277
|
+
const path = msg.data?.path;
|
|
278
|
+
if (!path || !resolvePlanPath(workingDir, path)) {
|
|
279
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Invalid sprint path' } });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const sprint = parseSingleSprint(workingDir, path);
|
|
283
|
+
if (!sprint) {
|
|
284
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${path}` } });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
ctx.send(ws, { type: 'planSprint', data: sprint });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function handleGetMilestone(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
|
|
291
|
+
const path = msg.data?.path;
|
|
292
|
+
if (!path || !resolvePlanPath(workingDir, path)) {
|
|
293
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Invalid milestone path' } });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const milestone = parseSingleMilestone(workingDir, path);
|
|
297
|
+
if (!milestone) {
|
|
298
|
+
ctx.send(ws, { type: 'planError', data: { error: `Milestone not found: ${path}` } });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
ctx.send(ws, { type: 'planMilestone', data: milestone });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// Mutation handlers
|
|
306
|
+
// ============================================================================
|
|
307
|
+
|
|
308
|
+
function handleCreateIssue(
|
|
309
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
310
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
311
|
+
): void {
|
|
312
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
313
|
+
|
|
314
|
+
const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '' } = msg.data || {};
|
|
315
|
+
if (!title) {
|
|
316
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Title required' } });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const pmDir = resolvePmDir(workingDir) ?? join(workingDir, '.pm');
|
|
321
|
+
const backlogDir = join(pmDir, 'backlog');
|
|
322
|
+
if (!existsSync(backlogDir)) {
|
|
323
|
+
mkdirSync(backlogDir, { recursive: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
327
|
+
const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
|
|
328
|
+
const id = fullState ? getNextId(fullState.issues, prefix) : `${prefix}-001`;
|
|
329
|
+
|
|
330
|
+
const content = buildIssueMarkdown(id, title, type, priority, labels, sprint, description);
|
|
331
|
+
const fileName = `${id}.md`;
|
|
332
|
+
writeFileSync(join(backlogDir, fileName), content, 'utf-8');
|
|
333
|
+
|
|
334
|
+
const issue = parseSingleIssue(workingDir, `backlog/${fileName}`);
|
|
335
|
+
ctx.broadcastToAll({ type: 'planIssueCreated', data: issue });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function handleUpdateIssue(
|
|
339
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
340
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
341
|
+
): void {
|
|
342
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
343
|
+
|
|
344
|
+
const { path, fields } = msg.data || {};
|
|
345
|
+
if (!path || !fields) {
|
|
346
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Path and fields required' } });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const fullPath = resolvePlanPath(workingDir, path);
|
|
351
|
+
if (!fullPath || !existsSync(fullPath)) {
|
|
352
|
+
ctx.send(ws, { type: 'planError', data: { error: `File not found: ${path}` } });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
357
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
358
|
+
if (!match) {
|
|
359
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let yamlStr = match[1];
|
|
364
|
+
const body = match[2];
|
|
365
|
+
|
|
366
|
+
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
367
|
+
const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
368
|
+
const yamlValue = formatYamlValue(value);
|
|
369
|
+
const regex = new RegExp(`^${yamlKey}:.*$`, 'm');
|
|
370
|
+
if (regex.test(yamlStr)) {
|
|
371
|
+
yamlStr = yamlStr.replace(regex, `${yamlKey}: ${yamlValue}`);
|
|
372
|
+
} else {
|
|
373
|
+
yamlStr += `\n${yamlKey}: ${yamlValue}`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
writeFileSync(fullPath, `---\n${yamlStr}\n---\n${body}`, 'utf-8');
|
|
378
|
+
|
|
379
|
+
const issue = parseSingleIssue(workingDir, path);
|
|
380
|
+
ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function handleDeleteIssue(
|
|
384
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
385
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
386
|
+
): void {
|
|
387
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
388
|
+
|
|
389
|
+
const path = msg.data?.path;
|
|
390
|
+
if (!path) {
|
|
391
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Path required' } });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const fullPath = resolvePlanPath(workingDir, path);
|
|
396
|
+
if (!fullPath || !existsSync(fullPath)) {
|
|
397
|
+
ctx.send(ws, { type: 'planError', data: { error: `File not found: ${path}` } });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
unlinkSync(fullPath);
|
|
402
|
+
ctx.broadcastToAll({ type: 'planIssueDeleted', data: { path } });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function handleScaffold(
|
|
406
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
407
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
408
|
+
): void {
|
|
409
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
410
|
+
|
|
411
|
+
const name = msg.data?.name || 'My Project';
|
|
412
|
+
const planDir = join(workingDir, '.pm');
|
|
413
|
+
|
|
414
|
+
for (const dir of ['backlog', 'sprints', 'milestones', 'docs', 'docs/decisions']) {
|
|
415
|
+
mkdirSync(join(planDir, dir), { recursive: true });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
|
|
419
|
+
writeFileSync(join(planDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
|
|
420
|
+
writeFileSync(join(planDir, 'progress.md'), '# Progress Log\n', 'utf-8');
|
|
421
|
+
|
|
422
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
423
|
+
ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// Composer + Execution handlers
|
|
428
|
+
// ============================================================================
|
|
429
|
+
|
|
430
|
+
function handlePrompt(
|
|
431
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
432
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
433
|
+
): void {
|
|
434
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
435
|
+
|
|
436
|
+
const prompt = msg.data?.prompt;
|
|
437
|
+
if (!prompt) {
|
|
438
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir).catch(error => {
|
|
442
|
+
ctx.send(ws, {
|
|
443
|
+
type: 'planError',
|
|
444
|
+
data: { error: error instanceof Error ? error.message : String(error) },
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, workingDir: string): void {
|
|
450
|
+
executor.removeAllListeners();
|
|
451
|
+
|
|
452
|
+
executor.on('statusChanged', (status: string) => {
|
|
453
|
+
ctx.broadcastToAll({ type: 'planExecutionProgress', data: { status } });
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
executor.on('issueStarted', (issue: { id: string; title: string }) => {
|
|
457
|
+
ctx.broadcastToAll({
|
|
458
|
+
type: 'planExecutionProgress',
|
|
459
|
+
data: { issueId: issue.id, status: 'executing', title: issue.title },
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
executor.on('output', (data: { issueId: string; text: string }) => {
|
|
464
|
+
ctx.broadcastToAll({ type: 'planExecutionOutput', data });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
executor.on('issueCompleted', () => {
|
|
468
|
+
ctx.broadcastToAll({ type: 'planExecutionMetrics', data: executor.getMetrics() });
|
|
469
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
470
|
+
if (fullState) {
|
|
471
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
executor.on('issueError', (data: { issueId: string; error: string }) => {
|
|
476
|
+
ctx.broadcastToAll({ type: 'planExecutionError', data });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
executor.on('waveStarted', (data: { issueIds: string[] }) => {
|
|
480
|
+
ctx.broadcastToAll({
|
|
481
|
+
type: 'planExecutionProgress',
|
|
482
|
+
data: { status: 'wave', issueIds: data.issueIds },
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
executor.on('waveError', (data: { issueIds: string[]; error: string }) => {
|
|
487
|
+
ctx.broadcastToAll({ type: 'planExecutionError', data });
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
executor.on('stateUpdated', () => {
|
|
491
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
492
|
+
if (fullState) {
|
|
493
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
executor.on('complete', (reason: string) => {
|
|
498
|
+
ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
executor.on('error', (error: string) => {
|
|
502
|
+
ctx.broadcastToAll({ type: 'planExecutionError', data: { error } });
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function handleExecute(
|
|
507
|
+
ctx: HandlerContext, ws: WSContext,
|
|
508
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
509
|
+
): void {
|
|
510
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
511
|
+
|
|
512
|
+
const executor = getExecutor(workingDir);
|
|
513
|
+
|
|
514
|
+
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
515
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
wireExecutorEvents(executor, ctx, workingDir);
|
|
520
|
+
|
|
521
|
+
ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing' } });
|
|
522
|
+
executor.start().catch(error => {
|
|
523
|
+
ctx.send(ws, {
|
|
524
|
+
type: 'planExecutionError',
|
|
525
|
+
data: { error: error instanceof Error ? error.message : String(error) },
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function handleExecuteEpic(
|
|
531
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
532
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
533
|
+
): void {
|
|
534
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
535
|
+
|
|
536
|
+
const epicPath = msg.data?.epicPath;
|
|
537
|
+
if (!epicPath) {
|
|
538
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Epic path required' } });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const executor = getExecutor(workingDir);
|
|
543
|
+
|
|
544
|
+
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
545
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
wireExecutorEvents(executor, ctx, workingDir);
|
|
550
|
+
|
|
551
|
+
ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', epicPath } });
|
|
552
|
+
executor.startEpic(epicPath).catch(error => {
|
|
553
|
+
ctx.send(ws, {
|
|
554
|
+
type: 'planExecutionError',
|
|
555
|
+
data: { error: error instanceof Error ? error.message : String(error) },
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function handlePause(
|
|
561
|
+
ctx: HandlerContext, ws: WSContext,
|
|
562
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
563
|
+
): void {
|
|
564
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
565
|
+
const executor = executorCache.get(workingDir);
|
|
566
|
+
if (executor) executor.pause();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function handleStop(
|
|
570
|
+
ctx: HandlerContext, ws: WSContext,
|
|
571
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
572
|
+
): void {
|
|
573
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
574
|
+
const executor = executorCache.get(workingDir);
|
|
575
|
+
if (executor) executor.stop();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function handleResume(
|
|
579
|
+
ctx: HandlerContext, ws: WSContext,
|
|
580
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
581
|
+
): void {
|
|
582
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
583
|
+
const executor = executorCache.get(workingDir);
|
|
584
|
+
if (executor) {
|
|
585
|
+
executor.resume().catch(error => {
|
|
586
|
+
ctx.send(ws, {
|
|
587
|
+
type: 'planExecutionError',
|
|
588
|
+
data: { error: error instanceof Error ? error.message : String(error) },
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|