mstro-app 0.4.1 → 0.4.3

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 (80) hide show
  1. package/bin/mstro.js +119 -40
  2. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker.js +3 -0
  4. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  5. package/dist/server/cli/headless/types.d.ts +4 -1
  6. package/dist/server/cli/headless/types.d.ts.map +1 -1
  7. package/dist/server/cli/improvisation-session-manager.js +1 -1
  8. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  9. package/dist/server/services/plan/composer.d.ts +1 -1
  10. package/dist/server/services/plan/composer.d.ts.map +1 -1
  11. package/dist/server/services/plan/composer.js +116 -31
  12. package/dist/server/services/plan/composer.js.map +1 -1
  13. package/dist/server/services/plan/config-installer.d.ts +25 -0
  14. package/dist/server/services/plan/config-installer.d.ts.map +1 -0
  15. package/dist/server/services/plan/config-installer.js +182 -0
  16. package/dist/server/services/plan/config-installer.js.map +1 -0
  17. package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
  18. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
  19. package/dist/server/services/plan/dependency-resolver.js +4 -1
  20. package/dist/server/services/plan/dependency-resolver.js.map +1 -1
  21. package/dist/server/services/plan/executor.d.ts +43 -71
  22. package/dist/server/services/plan/executor.d.ts.map +1 -1
  23. package/dist/server/services/plan/executor.js +314 -438
  24. package/dist/server/services/plan/executor.js.map +1 -1
  25. package/dist/server/services/plan/front-matter.d.ts +18 -0
  26. package/dist/server/services/plan/front-matter.d.ts.map +1 -0
  27. package/dist/server/services/plan/front-matter.js +44 -0
  28. package/dist/server/services/plan/front-matter.js.map +1 -0
  29. package/dist/server/services/plan/output-manager.d.ts +22 -0
  30. package/dist/server/services/plan/output-manager.d.ts.map +1 -0
  31. package/dist/server/services/plan/output-manager.js +97 -0
  32. package/dist/server/services/plan/output-manager.js.map +1 -0
  33. package/dist/server/services/plan/parser.d.ts +18 -2
  34. package/dist/server/services/plan/parser.d.ts.map +1 -1
  35. package/dist/server/services/plan/parser.js +372 -32
  36. package/dist/server/services/plan/parser.js.map +1 -1
  37. package/dist/server/services/plan/prompt-builder.d.ts +17 -0
  38. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
  39. package/dist/server/services/plan/prompt-builder.js +137 -0
  40. package/dist/server/services/plan/prompt-builder.js.map +1 -0
  41. package/dist/server/services/plan/review-gate.d.ts +26 -0
  42. package/dist/server/services/plan/review-gate.d.ts.map +1 -0
  43. package/dist/server/services/plan/review-gate.js +191 -0
  44. package/dist/server/services/plan/review-gate.js.map +1 -0
  45. package/dist/server/services/plan/state-reconciler.d.ts +1 -1
  46. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  47. package/dist/server/services/plan/state-reconciler.js +59 -7
  48. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  49. package/dist/server/services/plan/types.d.ts +66 -0
  50. package/dist/server/services/plan/types.d.ts.map +1 -1
  51. package/dist/server/services/platform.d.ts.map +1 -1
  52. package/dist/server/services/platform.js +11 -0
  53. package/dist/server/services/platform.js.map +1 -1
  54. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  55. package/dist/server/services/websocket/handler.js +14 -0
  56. package/dist/server/services/websocket/handler.js.map +1 -1
  57. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  58. package/dist/server/services/websocket/plan-handlers.js +518 -40
  59. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  60. package/dist/server/services/websocket/types.d.ts +2 -2
  61. package/dist/server/services/websocket/types.d.ts.map +1 -1
  62. package/package.json +1 -2
  63. package/server/cli/headless/claude-invoker.ts +4 -0
  64. package/server/cli/headless/types.ts +4 -1
  65. package/server/cli/improvisation-session-manager.ts +1 -1
  66. package/server/services/plan/composer.ts +138 -34
  67. package/server/services/plan/config-installer.ts +187 -0
  68. package/server/services/plan/dependency-resolver.ts +4 -1
  69. package/server/services/plan/executor.ts +325 -464
  70. package/server/services/plan/front-matter.ts +48 -0
  71. package/server/services/plan/output-manager.ts +113 -0
  72. package/server/services/plan/parser.ts +403 -34
  73. package/server/services/plan/prompt-builder.ts +161 -0
  74. package/server/services/plan/review-gate.ts +210 -0
  75. package/server/services/plan/state-reconciler.ts +68 -7
  76. package/server/services/plan/types.ts +99 -1
  77. package/server/services/platform.ts +11 -0
  78. package/server/services/websocket/handler.ts +14 -0
  79. package/server/services/websocket/plan-handlers.ts +629 -44
  80. package/server/services/websocket/types.ts +29 -2
@@ -7,10 +7,11 @@
7
7
  * Follows the same pattern as quality-handlers.ts and git-handlers.ts.
8
8
  */
9
9
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
10
- import { join, resolve } from 'node:path';
10
+ import { basename, join, resolve } from 'node:path';
11
11
  import { handlePlanPrompt } from '../plan/composer.js';
12
12
  import { PlanExecutor } from '../plan/executor.js';
13
- import { getNextId, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
13
+ import { replaceFrontMatterField } from '../plan/front-matter.js';
14
+ import { defaultPmDir, getNextBoardId, getNextBoardNumber, getNextId, getNextSprintId, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, parseSprintArtifacts, planDirExists, resolvePmDir } from '../plan/parser.js';
14
15
  import { PlanWatcher } from '../plan/watcher.js';
15
16
  const watcherCache = new Map();
16
17
  const executorCache = new Map();
@@ -189,11 +190,25 @@ export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, permission)
189
190
  planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, permission),
190
191
  planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, permission),
191
192
  planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, permission),
192
- planExecute: () => handleExecute(ctx, ws, workingDir, permission),
193
+ planExecute: () => handleExecute(ctx, ws, msg, workingDir, permission),
193
194
  planExecuteEpic: () => handleExecuteEpic(ctx, ws, msg, workingDir, permission),
194
195
  planPause: () => handlePause(ctx, ws, workingDir, permission),
195
196
  planStop: () => handleStop(ctx, ws, workingDir, permission),
196
197
  planResume: () => handleResume(ctx, ws, workingDir, permission),
198
+ // Board lifecycle
199
+ planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
200
+ planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
201
+ planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
202
+ planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
203
+ planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
204
+ planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
205
+ planSetActiveBoard: () => handleSetActiveBoard(ctx, ws, msg, workingDir, permission),
206
+ planGetBoardArtifacts: () => handleGetBoardArtifacts(ctx, ws, msg, workingDir),
207
+ // Sprint lifecycle (legacy)
208
+ planCreateSprint: () => handleCreateSprint(ctx, ws, msg, workingDir, permission),
209
+ planActivateSprint: () => handleActivateSprint(ctx, ws, msg, workingDir, permission),
210
+ planCompleteSprint: () => handleCompleteSprint(ctx, ws, msg, workingDir, permission),
211
+ planGetSprintArtifacts: () => handleGetSprintArtifacts(ctx, ws, msg, workingDir),
197
212
  };
198
213
  const handler = handlers[msg.type];
199
214
  if (!handler)
@@ -209,10 +224,44 @@ export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, permission)
209
224
  // ============================================================================
210
225
  // Read-only handlers
211
226
  // ============================================================================
227
+ /** Create the .mstro/pm/ directory structure with a default board. */
228
+ function scaffoldPmDirectory(workingDir, name) {
229
+ const planDir = defaultPmDir(workingDir);
230
+ const boardId = 'BOARD-001';
231
+ const boardDir = join(planDir, 'boards', boardId);
232
+ for (const dir of ['milestones', 'templates']) {
233
+ mkdirSync(join(planDir, dir), { recursive: true });
234
+ }
235
+ for (const dir of ['backlog', 'out', 'reviews']) {
236
+ mkdirSync(join(boardDir, dir), { recursive: true });
237
+ }
238
+ writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
239
+ const workspace = { activeBoardId: boardId, boardOrder: [boardId] };
240
+ writeFileSync(join(planDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
241
+ const today = new Date().toISOString().split('T')[0];
242
+ writeFileSync(join(boardDir, 'board.md'), `---
243
+ id: ${boardId}
244
+ title: "Board 1"
245
+ status: draft
246
+ created: "${today}"
247
+ completed_at: null
248
+ goal: ""
249
+ ---
250
+
251
+ # Board 1
252
+
253
+ ## Goal
254
+
255
+ ## Notes
256
+ `, 'utf-8');
257
+ writeFileSync(join(boardDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
258
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
259
+ }
212
260
  function handlePlanInit(ctx, ws, workingDir) {
261
+ // Auto-scaffold if .mstro/pm/ doesn't exist
213
262
  if (!planDirExists(workingDir)) {
214
- ctx.send(ws, { type: 'planNotFound', data: {} });
215
- return;
263
+ const projectName = basename(workingDir) || 'My Project';
264
+ scaffoldPmDirectory(workingDir, projectName);
216
265
  }
217
266
  const fullState = parsePlanDirectory(workingDir);
218
267
  if (!fullState) {
@@ -273,26 +322,41 @@ function handleGetMilestone(ctx, ws, msg, workingDir) {
273
322
  // ============================================================================
274
323
  // Mutation handlers
275
324
  // ============================================================================
325
+ /** Resolve backlog directory and existing issues for a board or legacy layout. */
326
+ function resolveBacklogContext(pmDir, workingDir, boardId) {
327
+ const fullState = parsePlanDirectory(workingDir);
328
+ const effectiveBoardId = boardId || fullState?.workspace?.activeBoardId;
329
+ if (effectiveBoardId && existsSync(join(pmDir, 'boards', effectiveBoardId))) {
330
+ const boardState = parseBoardDirectory(pmDir, effectiveBoardId);
331
+ return {
332
+ backlogDir: join(pmDir, 'boards', effectiveBoardId, 'backlog'),
333
+ issues: boardState?.issues ?? [],
334
+ pathPrefix: `boards/${effectiveBoardId}/backlog`,
335
+ };
336
+ }
337
+ return {
338
+ backlogDir: join(pmDir, 'backlog'),
339
+ issues: fullState?.issues ?? [],
340
+ pathPrefix: 'backlog',
341
+ };
342
+ }
276
343
  function handleCreateIssue(ctx, ws, msg, workingDir, permission) {
277
344
  if (denyIfViewOnly(ctx, ws, permission))
278
345
  return;
279
- const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '' } = msg.data || {};
346
+ const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '', boardId } = msg.data || {};
280
347
  if (!title) {
281
348
  ctx.send(ws, { type: 'planError', data: { error: 'Title required' } });
282
349
  return;
283
350
  }
284
- const pmDir = resolvePmDir(workingDir) ?? join(workingDir, '.pm');
285
- const backlogDir = join(pmDir, 'backlog');
286
- if (!existsSync(backlogDir)) {
351
+ const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
352
+ const { backlogDir, issues, pathPrefix } = resolveBacklogContext(pmDir, workingDir, boardId);
353
+ if (!existsSync(backlogDir))
287
354
  mkdirSync(backlogDir, { recursive: true });
288
- }
289
- const fullState = parsePlanDirectory(workingDir);
290
355
  const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
291
- const id = fullState ? getNextId(fullState.issues, prefix) : `${prefix}-001`;
292
- const content = buildIssueMarkdown(id, title, type, priority, labels, sprint, description);
356
+ const id = getNextId(issues, prefix);
293
357
  const fileName = `${id}.md`;
294
- writeFileSync(join(backlogDir, fileName), content, 'utf-8');
295
- const issue = parseSingleIssue(workingDir, `backlog/${fileName}`);
358
+ writeFileSync(join(backlogDir, fileName), buildIssueMarkdown(id, title, type, priority, labels, sprint, description), 'utf-8');
359
+ const issue = parseSingleIssue(workingDir, `${pathPrefix}/${fileName}`);
296
360
  ctx.broadcastToAll({ type: 'planIssueCreated', data: issue });
297
361
  }
298
362
  function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
@@ -308,26 +372,16 @@ function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
308
372
  ctx.send(ws, { type: 'planError', data: { error: `File not found: ${path}` } });
309
373
  return;
310
374
  }
311
- const content = readFileSync(fullPath, 'utf-8');
312
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
313
- if (!match) {
375
+ let content = readFileSync(fullPath, 'utf-8');
376
+ if (!content.match(/^---\n/)) {
314
377
  ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
315
378
  return;
316
379
  }
317
- let yamlStr = match[1];
318
- const body = match[2];
319
380
  for (const [key, value] of Object.entries(fields)) {
320
381
  const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
321
- const yamlValue = formatYamlValue(value);
322
- const regex = new RegExp(`^${yamlKey}:.*$`, 'm');
323
- if (regex.test(yamlStr)) {
324
- yamlStr = yamlStr.replace(regex, `${yamlKey}: ${yamlValue}`);
325
- }
326
- else {
327
- yamlStr += `\n${yamlKey}: ${yamlValue}`;
328
- }
382
+ content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
329
383
  }
330
- writeFileSync(fullPath, `---\n${yamlStr}\n---\n${body}`, 'utf-8');
384
+ writeFileSync(fullPath, content, 'utf-8');
331
385
  const issue = parseSingleIssue(workingDir, path);
332
386
  ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
333
387
  }
@@ -351,13 +405,7 @@ function handleScaffold(ctx, ws, msg, workingDir, permission) {
351
405
  if (denyIfViewOnly(ctx, ws, permission))
352
406
  return;
353
407
  const name = msg.data?.name || 'My Project';
354
- const planDir = join(workingDir, '.pm');
355
- for (const dir of ['backlog', 'sprints', 'milestones', 'docs', 'docs/decisions']) {
356
- mkdirSync(join(planDir, dir), { recursive: true });
357
- }
358
- writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
359
- writeFileSync(join(planDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
360
- writeFileSync(join(planDir, 'progress.md'), '# Progress Log\n', 'utf-8');
408
+ scaffoldPmDirectory(workingDir, name);
361
409
  const fullState = parsePlanDirectory(workingDir);
362
410
  ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
363
411
  }
@@ -368,11 +416,12 @@ function handlePrompt(ctx, ws, msg, workingDir, permission) {
368
416
  if (denyIfViewOnly(ctx, ws, permission))
369
417
  return;
370
418
  const prompt = msg.data?.prompt;
419
+ const boardId = msg.data?.boardId;
371
420
  if (!prompt) {
372
421
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
373
422
  return;
374
423
  }
375
- handlePlanPrompt(ctx, ws, prompt, workingDir).catch(error => {
424
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
376
425
  ctx.send(ws, {
377
426
  type: 'planError',
378
427
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -418,6 +467,9 @@ function wireExecutorEvents(executor, ctx, workingDir) {
418
467
  ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
419
468
  }
420
469
  });
470
+ executor.on('reviewProgress', (data) => {
471
+ ctx.broadcastToAll({ type: 'planReviewProgress', data });
472
+ });
421
473
  executor.on('complete', (reason) => {
422
474
  ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
423
475
  });
@@ -425,7 +477,7 @@ function wireExecutorEvents(executor, ctx, workingDir) {
425
477
  ctx.broadcastToAll({ type: 'planExecutionError', data: { error } });
426
478
  });
427
479
  }
428
- function handleExecute(ctx, ws, workingDir, permission) {
480
+ function handleExecute(ctx, ws, msg, workingDir, permission) {
429
481
  if (denyIfViewOnly(ctx, ws, permission))
430
482
  return;
431
483
  const executor = getExecutor(workingDir);
@@ -434,8 +486,11 @@ function handleExecute(ctx, ws, workingDir, permission) {
434
486
  return;
435
487
  }
436
488
  wireExecutorEvents(executor, ctx, workingDir);
437
- ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing' } });
438
- executor.start().catch(error => {
489
+ // Execute the board the user is looking at, falling back to workspace.json activeBoardId
490
+ const boardId = msg.data?.boardId;
491
+ ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
492
+ const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
493
+ startPromise.catch(error => {
439
494
  ctx.send(ws, {
440
495
  type: 'planExecutionError',
441
496
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -491,4 +546,427 @@ function handleResume(ctx, ws, workingDir, permission) {
491
546
  });
492
547
  }
493
548
  }
549
+ // ============================================================================
550
+ // Board lifecycle handlers
551
+ // ============================================================================
552
+ function handleCreateBoard(ctx, ws, msg, workingDir, permission) {
553
+ if (denyIfViewOnly(ctx, ws, permission))
554
+ return;
555
+ const pmDir = resolvePmDir(workingDir);
556
+ if (!pmDir) {
557
+ ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found' } });
558
+ return;
559
+ }
560
+ const fullState = parsePlanDirectory(workingDir);
561
+ if (!fullState)
562
+ return;
563
+ const boardId = getNextBoardId(fullState.boards);
564
+ const boardNum = getNextBoardNumber(fullState.boards);
565
+ const title = msg.data?.title || `Board ${boardNum}`;
566
+ const goal = msg.data?.goal || '';
567
+ const boardDir = join(pmDir, 'boards', boardId);
568
+ // Create directory structure
569
+ for (const dir of ['backlog', 'out', 'reviews']) {
570
+ mkdirSync(join(boardDir, dir), { recursive: true });
571
+ }
572
+ // Create board.md
573
+ const today = new Date().toISOString().split('T')[0];
574
+ writeFileSync(join(boardDir, 'board.md'), `---
575
+ id: ${boardId}
576
+ title: "${title.replace(/"/g, '\\"')}"
577
+ status: draft
578
+ created: "${today}"
579
+ completed_at: null
580
+ goal: "${goal.replace(/"/g, '\\"')}"
581
+ ---
582
+
583
+ # ${title}
584
+
585
+ ## Goal
586
+ ${goal}
587
+
588
+ ## Notes
589
+ `, 'utf-8');
590
+ // Create STATE.md
591
+ writeFileSync(join(boardDir, 'STATE.md'), `---
592
+ project: ../../project.md
593
+ board: board.md
594
+ paused: false
595
+ ---
596
+
597
+ # Board State
598
+
599
+ ## Ready to Work
600
+
601
+ ## In Progress
602
+
603
+ ## Blocked
604
+
605
+ ## Recently Completed
606
+
607
+ ## Warnings
608
+ `, 'utf-8');
609
+ // Create progress.md
610
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
611
+ // Update workspace.json
612
+ const wsPath = join(pmDir, 'workspace.json');
613
+ if (!existsSync(wsPath)) {
614
+ writeFileSync(wsPath, JSON.stringify({ activeBoardId: null, boardOrder: [] }, null, 2), 'utf-8');
615
+ }
616
+ const workspaceContent = readFileSync(wsPath, 'utf-8');
617
+ const workspace = JSON.parse(workspaceContent);
618
+ workspace.boardOrder.push(boardId);
619
+ if (!workspace.activeBoardId) {
620
+ workspace.activeBoardId = boardId;
621
+ }
622
+ writeFileSync(join(pmDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
623
+ const boardState = parseBoardDirectory(pmDir, boardId);
624
+ if (boardState) {
625
+ ctx.broadcastToAll({ type: 'planBoardCreated', data: boardState.board });
626
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
627
+ }
628
+ }
629
+ function handleUpdateBoard(ctx, ws, msg, workingDir, permission) {
630
+ if (denyIfViewOnly(ctx, ws, permission))
631
+ return;
632
+ const { boardId, fields } = msg.data || {};
633
+ if (!boardId || !fields) {
634
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID and fields required' } });
635
+ return;
636
+ }
637
+ const pmDir = resolvePmDir(workingDir);
638
+ if (!pmDir)
639
+ return;
640
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
641
+ if (!existsSync(boardMdPath)) {
642
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
643
+ return;
644
+ }
645
+ let content = readFileSync(boardMdPath, 'utf-8');
646
+ for (const [key, value] of Object.entries(fields)) {
647
+ const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
648
+ content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
649
+ }
650
+ writeFileSync(boardMdPath, content, 'utf-8');
651
+ const boardState = parseBoardDirectory(pmDir, boardId);
652
+ if (boardState) {
653
+ ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
654
+ }
655
+ }
656
+ function handleArchiveBoard(ctx, ws, msg, workingDir, permission) {
657
+ if (denyIfViewOnly(ctx, ws, permission))
658
+ return;
659
+ const boardId = msg.data?.boardId;
660
+ if (!boardId) {
661
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
662
+ return;
663
+ }
664
+ const pmDir = resolvePmDir(workingDir);
665
+ if (!pmDir)
666
+ return;
667
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
668
+ if (!existsSync(boardMdPath)) {
669
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
670
+ return;
671
+ }
672
+ // Set status to archived
673
+ let content = readFileSync(boardMdPath, 'utf-8');
674
+ content = replaceFrontMatterField(content, 'status', 'archived');
675
+ writeFileSync(boardMdPath, content, 'utf-8');
676
+ // Remove from workspace.json boardOrder and update activeBoardId if needed
677
+ const workspacePath = join(pmDir, 'workspace.json');
678
+ if (existsSync(workspacePath)) {
679
+ const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
680
+ workspace.boardOrder = workspace.boardOrder.filter(id => id !== boardId);
681
+ if (workspace.activeBoardId === boardId) {
682
+ workspace.activeBoardId = workspace.boardOrder[0] || null;
683
+ }
684
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
685
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
686
+ }
687
+ const boardState = parseBoardDirectory(pmDir, boardId);
688
+ if (boardState) {
689
+ ctx.broadcastToAll({ type: 'planBoardArchived', data: boardState.board });
690
+ }
691
+ }
692
+ function handleGetBoard(ctx, ws, msg, workingDir) {
693
+ const boardId = msg.data?.boardId;
694
+ if (!boardId) {
695
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
696
+ return;
697
+ }
698
+ const pmDir = resolvePmDir(workingDir);
699
+ if (!pmDir)
700
+ return;
701
+ const boardState = parseBoardDirectory(pmDir, boardId);
702
+ if (!boardState) {
703
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
704
+ return;
705
+ }
706
+ ctx.send(ws, { type: 'planBoardState', data: boardState });
707
+ }
708
+ function handleGetBoardState(ctx, ws, msg, workingDir) {
709
+ handleGetBoard(ctx, ws, msg, workingDir);
710
+ }
711
+ function handleReorderBoards(ctx, ws, msg, workingDir, permission) {
712
+ if (denyIfViewOnly(ctx, ws, permission))
713
+ return;
714
+ const boardOrder = msg.data?.boardOrder;
715
+ if (!Array.isArray(boardOrder)) {
716
+ ctx.send(ws, { type: 'planError', data: { error: 'boardOrder array required' } });
717
+ return;
718
+ }
719
+ const pmDir = resolvePmDir(workingDir);
720
+ if (!pmDir)
721
+ return;
722
+ const workspacePath = join(pmDir, 'workspace.json');
723
+ if (!existsSync(workspacePath))
724
+ return;
725
+ const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
726
+ workspace.boardOrder = boardOrder;
727
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
728
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
729
+ }
730
+ function handleSetActiveBoard(ctx, ws, msg, workingDir, permission) {
731
+ if (denyIfViewOnly(ctx, ws, permission))
732
+ return;
733
+ const boardId = msg.data?.boardId;
734
+ if (!boardId) {
735
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
736
+ return;
737
+ }
738
+ const pmDir = resolvePmDir(workingDir);
739
+ if (!pmDir)
740
+ return;
741
+ const workspacePath = join(pmDir, 'workspace.json');
742
+ if (!existsSync(workspacePath))
743
+ return;
744
+ const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
745
+ workspace.activeBoardId = boardId;
746
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
747
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
748
+ // Also send the active board's full state
749
+ const boardState = parseBoardDirectory(pmDir, boardId);
750
+ if (boardState) {
751
+ ctx.send(ws, { type: 'planBoardState', data: boardState });
752
+ }
753
+ }
754
+ function handleGetBoardArtifacts(ctx, ws, msg, workingDir) {
755
+ const boardId = msg.data?.boardId;
756
+ if (!boardId) {
757
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
758
+ return;
759
+ }
760
+ const artifacts = parseBoardArtifacts(workingDir, boardId);
761
+ if (!artifacts) {
762
+ ctx.send(ws, { type: 'planBoardArtifacts', data: { boardId, progressLog: '', outputFiles: [], reviewResults: [] } });
763
+ return;
764
+ }
765
+ ctx.send(ws, { type: 'planBoardArtifacts', data: artifacts });
766
+ }
767
+ // ============================================================================
768
+ // Sprint lifecycle handlers (legacy — kept for backward compatibility)
769
+ // ============================================================================
770
+ function buildSprintMarkdown(id, title, goal, start, end, issueRefs) {
771
+ const issuesYaml = issueRefs.length > 0
772
+ ? `\n${issueRefs.map(p => ` - ${p}`).join('\n')}`
773
+ : ' []';
774
+ return `---
775
+ id: ${id}
776
+ title: "${title.replace(/"/g, '\\"')}"
777
+ status: planned
778
+ start: "${start}"
779
+ end: "${end}"
780
+ goal: "${goal.replace(/"/g, '\\"')}"
781
+ capacity: null
782
+ committed: null
783
+ completed: null
784
+ completed_at: null
785
+ issues:${issuesYaml}
786
+ ---
787
+
788
+ # ${id}: ${title}
789
+
790
+ ## Sprint Goal
791
+ ${goal}
792
+
793
+ ## Issues
794
+ | Issue | Title | Points | Status |
795
+ |---|---|---|---|
796
+ `;
797
+ }
798
+ /** Assign issues to a sprint by updating their front matter sprint field. */
799
+ function assignIssuesToSprint(workingDir, issues, issueIds, sprintPath) {
800
+ for (const issueId of issueIds) {
801
+ const issue = issues.find(i => i.id === issueId);
802
+ if (!issue)
803
+ continue;
804
+ const fullPath = resolvePlanPath(workingDir, issue.path);
805
+ if (!fullPath || !existsSync(fullPath))
806
+ continue;
807
+ const content = replaceFrontMatterField(readFileSync(fullPath, 'utf-8'), 'sprint', sprintPath);
808
+ writeFileSync(fullPath, content, 'utf-8');
809
+ }
810
+ }
811
+ function handleCreateSprint(ctx, ws, msg, workingDir, permission) {
812
+ if (denyIfViewOnly(ctx, ws, permission))
813
+ return;
814
+ const { title, goal = '', start = '', end = '', issueIds = [] } = msg.data || {};
815
+ if (!title) {
816
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint title required' } });
817
+ return;
818
+ }
819
+ const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
820
+ const sprintsDir = join(pmDir, 'sprints');
821
+ if (!existsSync(sprintsDir))
822
+ mkdirSync(sprintsDir, { recursive: true });
823
+ const fullState = parsePlanDirectory(workingDir);
824
+ const id = fullState ? getNextSprintId(fullState.sprints) : 'SPRINT-001';
825
+ const issueRefs = issueIds.map((issueId) => {
826
+ const issue = fullState?.issues.find(i => i.id === issueId);
827
+ return issue ? issue.path : `backlog/${issueId}.md`;
828
+ });
829
+ writeFileSync(join(sprintsDir, `${id}.md`), buildSprintMarkdown(id, title, goal, start, end, issueRefs), 'utf-8');
830
+ const sandboxDir = join(sprintsDir, id);
831
+ mkdirSync(join(sandboxDir, 'out'), { recursive: true });
832
+ mkdirSync(join(sandboxDir, 'reviews'), { recursive: true });
833
+ writeFileSync(join(sandboxDir, 'progress.md'), `# ${id}: ${title} — Progress Log\n`, 'utf-8');
834
+ if (issueRefs.length > 0 && fullState) {
835
+ assignIssuesToSprint(workingDir, fullState.issues, issueIds, `sprints/${id}.md`);
836
+ }
837
+ const sprint = parseSingleSprint(workingDir, `sprints/${id}.md`);
838
+ ctx.broadcastToAll({ type: 'planSprintCreated', data: sprint });
839
+ }
840
+ /** Promote sprint issues from 'backlog' to 'todo' status. */
841
+ function promoteSprintIssues(pmDir, sprint, allIssues) {
842
+ for (const issueSummary of sprint.issues) {
843
+ const issue = allIssues.find(i => i.id === issueSummary.id || i.path === issueSummary.path);
844
+ if (!issue || issue.status !== 'backlog')
845
+ continue;
846
+ const issuePath = join(pmDir, issue.path);
847
+ if (!existsSync(issuePath))
848
+ continue;
849
+ writeFileSync(issuePath, replaceFrontMatterField(readFileSync(issuePath, 'utf-8'), 'status', 'todo'), 'utf-8');
850
+ }
851
+ }
852
+ /** Update a file's front matter field if the file exists. */
853
+ function updateFileField(filePath, field, value) {
854
+ if (!existsSync(filePath))
855
+ return;
856
+ writeFileSync(filePath, replaceFrontMatterField(readFileSync(filePath, 'utf-8'), field, value), 'utf-8');
857
+ }
858
+ function handleActivateSprint(ctx, ws, msg, workingDir, permission) {
859
+ if (denyIfViewOnly(ctx, ws, permission))
860
+ return;
861
+ const sprintId = msg.data?.sprintId;
862
+ if (!sprintId) {
863
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
864
+ return;
865
+ }
866
+ const fullState = parsePlanDirectory(workingDir);
867
+ if (!fullState) {
868
+ ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
869
+ return;
870
+ }
871
+ const currentActive = fullState.sprints.find(s => s.status === 'active');
872
+ if (currentActive && currentActive.id !== sprintId) {
873
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint ${currentActive.id} is already active. Complete it first.` } });
874
+ return;
875
+ }
876
+ const sprint = fullState.sprints.find(s => s.id === sprintId);
877
+ if (!sprint) {
878
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
879
+ return;
880
+ }
881
+ const pmDir = resolvePmDir(workingDir);
882
+ if (!pmDir)
883
+ return;
884
+ updateFileField(join(pmDir, sprint.path), 'status', 'active');
885
+ updateFileField(join(pmDir, 'STATE.md'), 'current_sprint', `"${sprint.path}"`);
886
+ promoteSprintIssues(pmDir, sprint, fullState.issues);
887
+ const updatedSprint = parseSingleSprint(workingDir, sprint.path);
888
+ ctx.broadcastToAll({ type: 'planSprintUpdated', data: updatedSprint });
889
+ const updatedState = parsePlanDirectory(workingDir);
890
+ if (updatedState) {
891
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
892
+ }
893
+ }
894
+ function handleCompleteSprint(ctx, ws, msg, workingDir, permission) {
895
+ if (denyIfViewOnly(ctx, ws, permission))
896
+ return;
897
+ const sprintId = msg.data?.sprintId;
898
+ if (!sprintId) {
899
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
900
+ return;
901
+ }
902
+ const fullState = parsePlanDirectory(workingDir);
903
+ if (!fullState) {
904
+ ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
905
+ return;
906
+ }
907
+ const sprint = fullState.sprints.find(s => s.id === sprintId);
908
+ if (!sprint) {
909
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
910
+ return;
911
+ }
912
+ const pmDir = resolvePmDir(workingDir);
913
+ if (!pmDir)
914
+ return;
915
+ const now = new Date().toISOString();
916
+ // Compute execution summary from sprint issues
917
+ const sprintIssues = fullState.issues.filter(i => i.sprint === sprint.path);
918
+ const completedIssues = sprintIssues.filter(i => i.status === 'done').length;
919
+ const failedIssues = sprintIssues.filter(i => i.status !== 'done' && i.status !== 'cancelled').length;
920
+ // Update sprint file with completion data
921
+ const sprintPath = join(pmDir, sprint.path);
922
+ if (existsSync(sprintPath)) {
923
+ let content = readFileSync(sprintPath, 'utf-8');
924
+ content = replaceFrontMatterField(content, 'status', 'completed');
925
+ content = replaceFrontMatterField(content, 'completed_at', `"${now}"`);
926
+ content = replaceFrontMatterField(content, 'completed', String(completedIssues));
927
+ // Write execution summary if not already present
928
+ if (!content.includes('execution_summary:')) {
929
+ const summaryYaml = [
930
+ 'execution_summary:',
931
+ ` total_issues: ${sprintIssues.length}`,
932
+ ` completed_issues: ${completedIssues}`,
933
+ ` failed_issues: ${failedIssues}`,
934
+ ].join('\n');
935
+ // Insert before the closing --- of front matter (second occurrence)
936
+ const fmClose = content.indexOf('\n---', content.indexOf('---') + 3);
937
+ if (fmClose !== -1) {
938
+ content = `${content.slice(0, fmClose)}\n${summaryYaml}${content.slice(fmClose)}`;
939
+ }
940
+ }
941
+ writeFileSync(sprintPath, content, 'utf-8');
942
+ }
943
+ // Clear STATE.md current_sprint
944
+ const statePath = join(pmDir, 'STATE.md');
945
+ if (existsSync(statePath)) {
946
+ let stateContent = readFileSync(statePath, 'utf-8');
947
+ stateContent = replaceFrontMatterField(stateContent, 'current_sprint', 'null');
948
+ writeFileSync(statePath, stateContent, 'utf-8');
949
+ }
950
+ const updatedSprint = parseSingleSprint(workingDir, sprint.path);
951
+ ctx.broadcastToAll({ type: 'planSprintCompleted', data: updatedSprint });
952
+ // Refresh full state
953
+ const updatedState = parsePlanDirectory(workingDir);
954
+ if (updatedState) {
955
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
956
+ }
957
+ }
958
+ function handleGetSprintArtifacts(ctx, ws, msg, workingDir) {
959
+ const sprintId = msg.data?.sprintId;
960
+ if (!sprintId) {
961
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
962
+ return;
963
+ }
964
+ const artifacts = parseSprintArtifacts(workingDir, sprintId);
965
+ if (!artifacts) {
966
+ // Fall back to empty artifacts if sandbox dir doesn't exist yet
967
+ ctx.send(ws, { type: 'planSprintArtifacts', data: { sprintId, progressLog: '', outputFiles: [], reviewResults: [] } });
968
+ return;
969
+ }
970
+ ctx.send(ws, { type: 'planSprintArtifacts', data: artifacts });
971
+ }
494
972
  //# sourceMappingURL=plan-handlers.js.map