mstro-app 0.3.7 → 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.
Files changed (131) hide show
  1. package/README.md +4 -8
  2. package/bin/mstro.js +54 -15
  3. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker.js +18 -9
  5. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  6. package/dist/server/cli/headless/headless-logger.d.ts +10 -0
  7. package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
  8. package/dist/server/cli/headless/headless-logger.js +66 -0
  9. package/dist/server/cli/headless/headless-logger.js.map +1 -0
  10. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  11. package/dist/server/cli/headless/mcp-config.js +6 -5
  12. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  13. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  14. package/dist/server/cli/headless/runner.js +4 -0
  15. package/dist/server/cli/headless/runner.js.map +1 -1
  16. package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
  17. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  18. package/dist/server/cli/headless/stall-assessor.js +74 -20
  19. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  20. package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
  21. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  22. package/dist/server/cli/headless/tool-watchdog.js +30 -9
  23. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  24. package/dist/server/cli/headless/types.d.ts +8 -1
  25. package/dist/server/cli/headless/types.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
  27. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-session-manager.js +94 -11
  29. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  30. package/dist/server/index.js +0 -4
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  33. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  34. package/dist/server/mcp/bouncer-cli.js +54 -0
  35. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  36. package/dist/server/mcp/bouncer-integration.d.ts +2 -0
  37. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  38. package/dist/server/mcp/bouncer-integration.js +55 -39
  39. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  40. package/dist/server/mcp/bouncer-sandbox.d.ts +60 -0
  41. package/dist/server/mcp/bouncer-sandbox.d.ts.map +1 -0
  42. package/dist/server/mcp/bouncer-sandbox.js +182 -0
  43. package/dist/server/mcp/bouncer-sandbox.js.map +1 -0
  44. package/dist/server/mcp/security-patterns.d.ts +6 -12
  45. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  46. package/dist/server/mcp/security-patterns.js +197 -10
  47. package/dist/server/mcp/security-patterns.js.map +1 -1
  48. package/dist/server/services/plan/composer.d.ts +4 -0
  49. package/dist/server/services/plan/composer.d.ts.map +1 -0
  50. package/dist/server/services/plan/composer.js +181 -0
  51. package/dist/server/services/plan/composer.js.map +1 -0
  52. package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
  53. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
  54. package/dist/server/services/plan/dependency-resolver.js +152 -0
  55. package/dist/server/services/plan/dependency-resolver.js.map +1 -0
  56. package/dist/server/services/plan/executor.d.ts +91 -0
  57. package/dist/server/services/plan/executor.d.ts.map +1 -0
  58. package/dist/server/services/plan/executor.js +545 -0
  59. package/dist/server/services/plan/executor.js.map +1 -0
  60. package/dist/server/services/plan/parser.d.ts +11 -0
  61. package/dist/server/services/plan/parser.d.ts.map +1 -0
  62. package/dist/server/services/plan/parser.js +415 -0
  63. package/dist/server/services/plan/parser.js.map +1 -0
  64. package/dist/server/services/plan/state-reconciler.d.ts +2 -0
  65. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
  66. package/dist/server/services/plan/state-reconciler.js +105 -0
  67. package/dist/server/services/plan/state-reconciler.js.map +1 -0
  68. package/dist/server/services/plan/types.d.ts +120 -0
  69. package/dist/server/services/plan/types.d.ts.map +1 -0
  70. package/dist/server/services/plan/types.js +4 -0
  71. package/dist/server/services/plan/types.js.map +1 -0
  72. package/dist/server/services/plan/watcher.d.ts +14 -0
  73. package/dist/server/services/plan/watcher.d.ts.map +1 -0
  74. package/dist/server/services/plan/watcher.js +69 -0
  75. package/dist/server/services/plan/watcher.js.map +1 -0
  76. package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
  77. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  78. package/dist/server/services/websocket/handler.d.ts +0 -1
  79. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  80. package/dist/server/services/websocket/handler.js +28 -2
  81. package/dist/server/services/websocket/handler.js.map +1 -1
  82. package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
  83. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
  84. package/dist/server/services/websocket/plan-handlers.js +494 -0
  85. package/dist/server/services/websocket/plan-handlers.js.map +1 -0
  86. package/dist/server/services/websocket/quality-handlers.d.ts +4 -0
  87. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -0
  88. package/dist/server/services/websocket/quality-handlers.js +470 -0
  89. package/dist/server/services/websocket/quality-handlers.js.map +1 -0
  90. package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
  91. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
  92. package/dist/server/services/websocket/quality-persistence.js +187 -0
  93. package/dist/server/services/websocket/quality-persistence.js.map +1 -0
  94. package/dist/server/services/websocket/quality-service.d.ts +54 -0
  95. package/dist/server/services/websocket/quality-service.d.ts.map +1 -0
  96. package/dist/server/services/websocket/quality-service.js +816 -0
  97. package/dist/server/services/websocket/quality-service.js.map +1 -0
  98. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  99. package/dist/server/services/websocket/session-handlers.js +23 -0
  100. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  101. package/dist/server/services/websocket/types.d.ts +2 -2
  102. package/dist/server/services/websocket/types.d.ts.map +1 -1
  103. package/package.json +3 -2
  104. package/server/cli/headless/claude-invoker.ts +21 -9
  105. package/server/cli/headless/headless-logger.ts +78 -0
  106. package/server/cli/headless/mcp-config.ts +6 -5
  107. package/server/cli/headless/runner.ts +4 -0
  108. package/server/cli/headless/stall-assessor.ts +101 -20
  109. package/server/cli/headless/tool-watchdog.ts +18 -9
  110. package/server/cli/headless/types.ts +10 -1
  111. package/server/cli/improvisation-session-manager.ts +118 -11
  112. package/server/index.ts +0 -4
  113. package/server/mcp/bouncer-cli.ts +73 -0
  114. package/server/mcp/bouncer-integration.ts +66 -44
  115. package/server/mcp/bouncer-sandbox.ts +214 -0
  116. package/server/mcp/security-patterns.ts +206 -10
  117. package/server/services/plan/composer.ts +199 -0
  118. package/server/services/plan/dependency-resolver.ts +179 -0
  119. package/server/services/plan/executor.ts +604 -0
  120. package/server/services/plan/parser.ts +459 -0
  121. package/server/services/plan/state-reconciler.ts +132 -0
  122. package/server/services/plan/types.ts +164 -0
  123. package/server/services/plan/watcher.ts +73 -0
  124. package/server/services/websocket/file-explorer-handlers.ts +20 -0
  125. package/server/services/websocket/handler.ts +28 -2
  126. package/server/services/websocket/plan-handlers.ts +592 -0
  127. package/server/services/websocket/quality-handlers.ts +570 -0
  128. package/server/services/websocket/quality-persistence.ts +250 -0
  129. package/server/services/websocket/quality-service.ts +975 -0
  130. package/server/services/websocket/session-handlers.ts +26 -0
  131. package/server/services/websocket/types.ts +62 -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
+ }