mstro-app 0.4.2 → 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 (77) 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/services/plan/composer.d.ts +1 -1
  8. package/dist/server/services/plan/composer.d.ts.map +1 -1
  9. package/dist/server/services/plan/composer.js +116 -31
  10. package/dist/server/services/plan/composer.js.map +1 -1
  11. package/dist/server/services/plan/config-installer.d.ts +25 -0
  12. package/dist/server/services/plan/config-installer.d.ts.map +1 -0
  13. package/dist/server/services/plan/config-installer.js +182 -0
  14. package/dist/server/services/plan/config-installer.js.map +1 -0
  15. package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
  16. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
  17. package/dist/server/services/plan/dependency-resolver.js +4 -1
  18. package/dist/server/services/plan/dependency-resolver.js.map +1 -1
  19. package/dist/server/services/plan/executor.d.ts +38 -74
  20. package/dist/server/services/plan/executor.d.ts.map +1 -1
  21. package/dist/server/services/plan/executor.js +271 -459
  22. package/dist/server/services/plan/executor.js.map +1 -1
  23. package/dist/server/services/plan/front-matter.d.ts +18 -0
  24. package/dist/server/services/plan/front-matter.d.ts.map +1 -0
  25. package/dist/server/services/plan/front-matter.js +44 -0
  26. package/dist/server/services/plan/front-matter.js.map +1 -0
  27. package/dist/server/services/plan/output-manager.d.ts +22 -0
  28. package/dist/server/services/plan/output-manager.d.ts.map +1 -0
  29. package/dist/server/services/plan/output-manager.js +97 -0
  30. package/dist/server/services/plan/output-manager.js.map +1 -0
  31. package/dist/server/services/plan/parser.d.ts +18 -2
  32. package/dist/server/services/plan/parser.d.ts.map +1 -1
  33. package/dist/server/services/plan/parser.js +359 -25
  34. package/dist/server/services/plan/parser.js.map +1 -1
  35. package/dist/server/services/plan/prompt-builder.d.ts +17 -0
  36. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
  37. package/dist/server/services/plan/prompt-builder.js +137 -0
  38. package/dist/server/services/plan/prompt-builder.js.map +1 -0
  39. package/dist/server/services/plan/review-gate.d.ts +26 -0
  40. package/dist/server/services/plan/review-gate.d.ts.map +1 -0
  41. package/dist/server/services/plan/review-gate.js +191 -0
  42. package/dist/server/services/plan/review-gate.js.map +1 -0
  43. package/dist/server/services/plan/state-reconciler.d.ts +1 -1
  44. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  45. package/dist/server/services/plan/state-reconciler.js +59 -7
  46. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  47. package/dist/server/services/plan/types.d.ts +66 -0
  48. package/dist/server/services/plan/types.d.ts.map +1 -1
  49. package/dist/server/services/platform.d.ts.map +1 -1
  50. package/dist/server/services/platform.js +11 -0
  51. package/dist/server/services/platform.js.map +1 -1
  52. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  53. package/dist/server/services/websocket/handler.js +14 -0
  54. package/dist/server/services/websocket/handler.js.map +1 -1
  55. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  56. package/dist/server/services/websocket/plan-handlers.js +518 -40
  57. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  58. package/dist/server/services/websocket/types.d.ts +2 -2
  59. package/dist/server/services/websocket/types.d.ts.map +1 -1
  60. package/package.json +1 -2
  61. package/server/cli/headless/claude-invoker.ts +4 -0
  62. package/server/cli/headless/types.ts +4 -1
  63. package/server/services/plan/composer.ts +138 -34
  64. package/server/services/plan/config-installer.ts +187 -0
  65. package/server/services/plan/dependency-resolver.ts +4 -1
  66. package/server/services/plan/executor.ts +278 -487
  67. package/server/services/plan/front-matter.ts +48 -0
  68. package/server/services/plan/output-manager.ts +113 -0
  69. package/server/services/plan/parser.ts +389 -27
  70. package/server/services/plan/prompt-builder.ts +161 -0
  71. package/server/services/plan/review-gate.ts +210 -0
  72. package/server/services/plan/state-reconciler.ts +68 -7
  73. package/server/services/plan/types.ts +99 -1
  74. package/server/services/platform.ts +11 -0
  75. package/server/services/websocket/handler.ts +14 -0
  76. package/server/services/websocket/plan-handlers.ts +629 -44
  77. package/server/services/websocket/types.ts +29 -2
@@ -9,10 +9,12 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
12
- import { join, resolve } from 'node:path';
12
+ import { basename, join, resolve } from 'node:path';
13
13
  import { handlePlanPrompt } from '../plan/composer.js';
14
14
  import { PlanExecutor } from '../plan/executor.js';
15
- import { getNextId, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
15
+ import { replaceFrontMatterField } from '../plan/front-matter.js';
16
+ import { defaultPmDir, getNextBoardId, getNextBoardNumber, getNextId, getNextSprintId, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, parseSprintArtifacts, planDirExists, resolvePmDir } from '../plan/parser.js';
17
+ import type { Issue, Workspace } from '../plan/types.js';
16
18
  import { PlanWatcher } from '../plan/watcher.js';
17
19
  import type { HandlerContext } from './handler-context.js';
18
20
  import type { WebSocketMessage, WSContext } from './types.js';
@@ -210,11 +212,25 @@ export function handlePlanMessage(
210
212
  planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, permission),
211
213
  planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, permission),
212
214
  planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, permission),
213
- planExecute: () => handleExecute(ctx, ws, workingDir, permission),
215
+ planExecute: () => handleExecute(ctx, ws, msg, workingDir, permission),
214
216
  planExecuteEpic: () => handleExecuteEpic(ctx, ws, msg, workingDir, permission),
215
217
  planPause: () => handlePause(ctx, ws, workingDir, permission),
216
218
  planStop: () => handleStop(ctx, ws, workingDir, permission),
217
219
  planResume: () => handleResume(ctx, ws, workingDir, permission),
220
+ // Board lifecycle
221
+ planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
222
+ planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
223
+ planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
224
+ planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
225
+ planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
226
+ planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
227
+ planSetActiveBoard: () => handleSetActiveBoard(ctx, ws, msg, workingDir, permission),
228
+ planGetBoardArtifacts: () => handleGetBoardArtifacts(ctx, ws, msg, workingDir),
229
+ // Sprint lifecycle (legacy)
230
+ planCreateSprint: () => handleCreateSprint(ctx, ws, msg, workingDir, permission),
231
+ planActivateSprint: () => handleActivateSprint(ctx, ws, msg, workingDir, permission),
232
+ planCompleteSprint: () => handleCompleteSprint(ctx, ws, msg, workingDir, permission),
233
+ planGetSprintArtifacts: () => handleGetSprintArtifacts(ctx, ws, msg, workingDir),
218
234
  };
219
235
 
220
236
  const handler = handlers[msg.type];
@@ -232,10 +248,50 @@ export function handlePlanMessage(
232
248
  // Read-only handlers
233
249
  // ============================================================================
234
250
 
251
+ /** Create the .mstro/pm/ directory structure with a default board. */
252
+ function scaffoldPmDirectory(workingDir: string, name: string): void {
253
+ const planDir = defaultPmDir(workingDir);
254
+ const boardId = 'BOARD-001';
255
+ const boardDir = join(planDir, 'boards', boardId);
256
+
257
+ for (const dir of ['milestones', 'templates']) {
258
+ mkdirSync(join(planDir, dir), { recursive: true });
259
+ }
260
+ for (const dir of ['backlog', 'out', 'reviews']) {
261
+ mkdirSync(join(boardDir, dir), { recursive: true });
262
+ }
263
+
264
+ writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
265
+
266
+ const workspace: Workspace = { activeBoardId: boardId, boardOrder: [boardId] };
267
+ writeFileSync(join(planDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
268
+
269
+ const today = new Date().toISOString().split('T')[0];
270
+ writeFileSync(join(boardDir, 'board.md'), `---
271
+ id: ${boardId}
272
+ title: "Board 1"
273
+ status: draft
274
+ created: "${today}"
275
+ completed_at: null
276
+ goal: ""
277
+ ---
278
+
279
+ # Board 1
280
+
281
+ ## Goal
282
+
283
+ ## Notes
284
+ `, 'utf-8');
285
+
286
+ writeFileSync(join(boardDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
287
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
288
+ }
289
+
235
290
  function handlePlanInit(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
291
+ // Auto-scaffold if .mstro/pm/ doesn't exist
236
292
  if (!planDirExists(workingDir)) {
237
- ctx.send(ws, { type: 'planNotFound', data: {} });
238
- return;
293
+ const projectName = basename(workingDir) || 'My Project';
294
+ scaffoldPmDirectory(workingDir, projectName);
239
295
  }
240
296
 
241
297
  const fullState = parsePlanDirectory(workingDir);
@@ -305,33 +361,50 @@ function handleGetMilestone(ctx: HandlerContext, ws: WSContext, msg: WebSocketMe
305
361
  // Mutation handlers
306
362
  // ============================================================================
307
363
 
364
+ /** Resolve backlog directory and existing issues for a board or legacy layout. */
365
+ function resolveBacklogContext(pmDir: string, workingDir: string, boardId?: string) {
366
+ const fullState = parsePlanDirectory(workingDir);
367
+ const effectiveBoardId = boardId || fullState?.workspace?.activeBoardId;
368
+
369
+ if (effectiveBoardId && existsSync(join(pmDir, 'boards', effectiveBoardId))) {
370
+ const boardState = parseBoardDirectory(pmDir, effectiveBoardId);
371
+ return {
372
+ backlogDir: join(pmDir, 'boards', effectiveBoardId, 'backlog'),
373
+ issues: boardState?.issues ?? [],
374
+ pathPrefix: `boards/${effectiveBoardId}/backlog`,
375
+ };
376
+ }
377
+ return {
378
+ backlogDir: join(pmDir, 'backlog'),
379
+ issues: fullState?.issues ?? [],
380
+ pathPrefix: 'backlog',
381
+ };
382
+ }
383
+
308
384
  function handleCreateIssue(
309
385
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
310
386
  workingDir: string, permission?: 'control' | 'view',
311
387
  ): void {
312
388
  if (denyIfViewOnly(ctx, ws, permission)) return;
313
389
 
314
- const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '' } = msg.data || {};
390
+ const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '', boardId } = msg.data || {};
315
391
  if (!title) {
316
392
  ctx.send(ws, { type: 'planError', data: { error: 'Title required' } });
317
393
  return;
318
394
  }
319
395
 
320
- const pmDir = resolvePmDir(workingDir) ?? join(workingDir, '.pm');
321
- const backlogDir = join(pmDir, 'backlog');
322
- if (!existsSync(backlogDir)) {
323
- mkdirSync(backlogDir, { recursive: true });
324
- }
396
+ const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
397
+ const { backlogDir, issues, pathPrefix } = resolveBacklogContext(pmDir, workingDir, boardId);
325
398
 
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`;
399
+ if (!existsSync(backlogDir)) mkdirSync(backlogDir, { recursive: true });
329
400
 
330
- const content = buildIssueMarkdown(id, title, type, priority, labels, sprint, description);
401
+ const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
402
+ const id = getNextId(issues, prefix);
331
403
  const fileName = `${id}.md`;
332
- writeFileSync(join(backlogDir, fileName), content, 'utf-8');
333
404
 
334
- const issue = parseSingleIssue(workingDir, `backlog/${fileName}`);
405
+ writeFileSync(join(backlogDir, fileName), buildIssueMarkdown(id, title, type, priority, labels, sprint, description), 'utf-8');
406
+
407
+ const issue = parseSingleIssue(workingDir, `${pathPrefix}/${fileName}`);
335
408
  ctx.broadcastToAll({ type: 'planIssueCreated', data: issue });
336
409
  }
337
410
 
@@ -353,28 +426,18 @@ function handleUpdateIssue(
353
426
  return;
354
427
  }
355
428
 
356
- const content = readFileSync(fullPath, 'utf-8');
357
- const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
358
- if (!match) {
429
+ let content = readFileSync(fullPath, 'utf-8');
430
+ if (!content.match(/^---\n/)) {
359
431
  ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
360
432
  return;
361
433
  }
362
434
 
363
- let yamlStr = match[1];
364
- const body = match[2];
365
-
366
435
  for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
367
436
  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
- }
437
+ content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
375
438
  }
376
439
 
377
- writeFileSync(fullPath, `---\n${yamlStr}\n---\n${body}`, 'utf-8');
440
+ writeFileSync(fullPath, content, 'utf-8');
378
441
 
379
442
  const issue = parseSingleIssue(workingDir, path);
380
443
  ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
@@ -409,15 +472,7 @@ function handleScaffold(
409
472
  if (denyIfViewOnly(ctx, ws, permission)) return;
410
473
 
411
474
  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');
475
+ scaffoldPmDirectory(workingDir, name);
421
476
 
422
477
  const fullState = parsePlanDirectory(workingDir);
423
478
  ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
@@ -434,11 +489,12 @@ function handlePrompt(
434
489
  if (denyIfViewOnly(ctx, ws, permission)) return;
435
490
 
436
491
  const prompt = msg.data?.prompt;
492
+ const boardId = msg.data?.boardId as string | undefined;
437
493
  if (!prompt) {
438
494
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
439
495
  return;
440
496
  }
441
- handlePlanPrompt(ctx, ws, prompt, workingDir).catch(error => {
497
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
442
498
  ctx.send(ws, {
443
499
  type: 'planError',
444
500
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -494,6 +550,10 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
494
550
  }
495
551
  });
496
552
 
553
+ executor.on('reviewProgress', (data: { issueId: string; status: string }) => {
554
+ ctx.broadcastToAll({ type: 'planReviewProgress', data });
555
+ });
556
+
497
557
  executor.on('complete', (reason: string) => {
498
558
  ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
499
559
  });
@@ -504,7 +564,7 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
504
564
  }
505
565
 
506
566
  function handleExecute(
507
- ctx: HandlerContext, ws: WSContext,
567
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
508
568
  workingDir: string, permission?: 'control' | 'view',
509
569
  ): void {
510
570
  if (denyIfViewOnly(ctx, ws, permission)) return;
@@ -518,8 +578,11 @@ function handleExecute(
518
578
 
519
579
  wireExecutorEvents(executor, ctx, workingDir);
520
580
 
521
- ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing' } });
522
- executor.start().catch(error => {
581
+ // Execute the board the user is looking at, falling back to workspace.json activeBoardId
582
+ const boardId = msg.data?.boardId as string | undefined;
583
+ ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
584
+ const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
585
+ startPromise.catch(error => {
523
586
  ctx.send(ws, {
524
587
  type: 'planExecutionError',
525
588
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -590,3 +653,525 @@ function handleResume(
590
653
  });
591
654
  }
592
655
  }
656
+
657
+ // ============================================================================
658
+ // Board lifecycle handlers
659
+ // ============================================================================
660
+
661
+ function handleCreateBoard(
662
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
663
+ workingDir: string, permission?: 'control' | 'view',
664
+ ): void {
665
+ if (denyIfViewOnly(ctx, ws, permission)) return;
666
+
667
+ const pmDir = resolvePmDir(workingDir);
668
+ if (!pmDir) {
669
+ ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found' } });
670
+ return;
671
+ }
672
+
673
+ const fullState = parsePlanDirectory(workingDir);
674
+ if (!fullState) return;
675
+
676
+ const boardId = getNextBoardId(fullState.boards);
677
+ const boardNum = getNextBoardNumber(fullState.boards);
678
+ const title = msg.data?.title || `Board ${boardNum}`;
679
+ const goal = msg.data?.goal || '';
680
+ const boardDir = join(pmDir, 'boards', boardId);
681
+
682
+ // Create directory structure
683
+ for (const dir of ['backlog', 'out', 'reviews']) {
684
+ mkdirSync(join(boardDir, dir), { recursive: true });
685
+ }
686
+
687
+ // Create board.md
688
+ const today = new Date().toISOString().split('T')[0];
689
+ writeFileSync(join(boardDir, 'board.md'), `---
690
+ id: ${boardId}
691
+ title: "${title.replace(/"/g, '\\"')}"
692
+ status: draft
693
+ created: "${today}"
694
+ completed_at: null
695
+ goal: "${goal.replace(/"/g, '\\"')}"
696
+ ---
697
+
698
+ # ${title}
699
+
700
+ ## Goal
701
+ ${goal}
702
+
703
+ ## Notes
704
+ `, 'utf-8');
705
+
706
+ // Create STATE.md
707
+ writeFileSync(join(boardDir, 'STATE.md'), `---
708
+ project: ../../project.md
709
+ board: board.md
710
+ paused: false
711
+ ---
712
+
713
+ # Board State
714
+
715
+ ## Ready to Work
716
+
717
+ ## In Progress
718
+
719
+ ## Blocked
720
+
721
+ ## Recently Completed
722
+
723
+ ## Warnings
724
+ `, 'utf-8');
725
+
726
+ // Create progress.md
727
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
728
+
729
+ // Update workspace.json
730
+ const wsPath = join(pmDir, 'workspace.json');
731
+ if (!existsSync(wsPath)) {
732
+ writeFileSync(wsPath, JSON.stringify({ activeBoardId: null, boardOrder: [] }, null, 2), 'utf-8');
733
+ }
734
+ const workspaceContent = readFileSync(wsPath, 'utf-8');
735
+ const workspace: Workspace = JSON.parse(workspaceContent);
736
+ workspace.boardOrder.push(boardId);
737
+ if (!workspace.activeBoardId) {
738
+ workspace.activeBoardId = boardId;
739
+ }
740
+ writeFileSync(join(pmDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
741
+
742
+ const boardState = parseBoardDirectory(pmDir, boardId);
743
+ if (boardState) {
744
+ ctx.broadcastToAll({ type: 'planBoardCreated', data: boardState.board });
745
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
746
+ }
747
+ }
748
+
749
+ function handleUpdateBoard(
750
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
751
+ workingDir: string, permission?: 'control' | 'view',
752
+ ): void {
753
+ if (denyIfViewOnly(ctx, ws, permission)) return;
754
+
755
+ const { boardId, fields } = msg.data || {};
756
+ if (!boardId || !fields) {
757
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID and fields required' } });
758
+ return;
759
+ }
760
+
761
+ const pmDir = resolvePmDir(workingDir);
762
+ if (!pmDir) return;
763
+
764
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
765
+ if (!existsSync(boardMdPath)) {
766
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
767
+ return;
768
+ }
769
+
770
+ let content = readFileSync(boardMdPath, 'utf-8');
771
+ for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
772
+ const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
773
+ content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
774
+ }
775
+ writeFileSync(boardMdPath, content, 'utf-8');
776
+
777
+ const boardState = parseBoardDirectory(pmDir, boardId);
778
+ if (boardState) {
779
+ ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
780
+ }
781
+ }
782
+
783
+ function handleArchiveBoard(
784
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
785
+ workingDir: string, permission?: 'control' | 'view',
786
+ ): void {
787
+ if (denyIfViewOnly(ctx, ws, permission)) return;
788
+
789
+ const boardId = msg.data?.boardId;
790
+ if (!boardId) {
791
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
792
+ return;
793
+ }
794
+
795
+ const pmDir = resolvePmDir(workingDir);
796
+ if (!pmDir) return;
797
+
798
+ const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
799
+ if (!existsSync(boardMdPath)) {
800
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
801
+ return;
802
+ }
803
+
804
+ // Set status to archived
805
+ let content = readFileSync(boardMdPath, 'utf-8');
806
+ content = replaceFrontMatterField(content, 'status', 'archived');
807
+ writeFileSync(boardMdPath, content, 'utf-8');
808
+
809
+ // Remove from workspace.json boardOrder and update activeBoardId if needed
810
+ const workspacePath = join(pmDir, 'workspace.json');
811
+ if (existsSync(workspacePath)) {
812
+ const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
813
+ workspace.boardOrder = workspace.boardOrder.filter(id => id !== boardId);
814
+ if (workspace.activeBoardId === boardId) {
815
+ workspace.activeBoardId = workspace.boardOrder[0] || null;
816
+ }
817
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
818
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
819
+ }
820
+
821
+ const boardState = parseBoardDirectory(pmDir, boardId);
822
+ if (boardState) {
823
+ ctx.broadcastToAll({ type: 'planBoardArchived', data: boardState.board });
824
+ }
825
+ }
826
+
827
+ function handleGetBoard(
828
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
829
+ workingDir: string,
830
+ ): void {
831
+ const boardId = msg.data?.boardId;
832
+ if (!boardId) {
833
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
834
+ return;
835
+ }
836
+
837
+ const pmDir = resolvePmDir(workingDir);
838
+ if (!pmDir) return;
839
+
840
+ const boardState = parseBoardDirectory(pmDir, boardId);
841
+ if (!boardState) {
842
+ ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
843
+ return;
844
+ }
845
+
846
+ ctx.send(ws, { type: 'planBoardState', data: boardState });
847
+ }
848
+
849
+ function handleGetBoardState(
850
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
851
+ workingDir: string,
852
+ ): void {
853
+ handleGetBoard(ctx, ws, msg, workingDir);
854
+ }
855
+
856
+ function handleReorderBoards(
857
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
858
+ workingDir: string, permission?: 'control' | 'view',
859
+ ): void {
860
+ if (denyIfViewOnly(ctx, ws, permission)) return;
861
+
862
+ const boardOrder = msg.data?.boardOrder;
863
+ if (!Array.isArray(boardOrder)) {
864
+ ctx.send(ws, { type: 'planError', data: { error: 'boardOrder array required' } });
865
+ return;
866
+ }
867
+
868
+ const pmDir = resolvePmDir(workingDir);
869
+ if (!pmDir) return;
870
+
871
+ const workspacePath = join(pmDir, 'workspace.json');
872
+ if (!existsSync(workspacePath)) return;
873
+
874
+ const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
875
+ workspace.boardOrder = boardOrder;
876
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
877
+
878
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
879
+ }
880
+
881
+ function handleSetActiveBoard(
882
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
883
+ workingDir: string, permission?: 'control' | 'view',
884
+ ): void {
885
+ if (denyIfViewOnly(ctx, ws, permission)) return;
886
+
887
+ const boardId = msg.data?.boardId;
888
+ if (!boardId) {
889
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
890
+ return;
891
+ }
892
+
893
+ const pmDir = resolvePmDir(workingDir);
894
+ if (!pmDir) return;
895
+
896
+ const workspacePath = join(pmDir, 'workspace.json');
897
+ if (!existsSync(workspacePath)) return;
898
+
899
+ const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
900
+ workspace.activeBoardId = boardId;
901
+ writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
902
+
903
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
904
+
905
+ // Also send the active board's full state
906
+ const boardState = parseBoardDirectory(pmDir, boardId);
907
+ if (boardState) {
908
+ ctx.send(ws, { type: 'planBoardState', data: boardState });
909
+ }
910
+ }
911
+
912
+ function handleGetBoardArtifacts(
913
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
914
+ workingDir: string,
915
+ ): void {
916
+ const boardId = msg.data?.boardId;
917
+ if (!boardId) {
918
+ ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
919
+ return;
920
+ }
921
+
922
+ const artifacts = parseBoardArtifacts(workingDir, boardId);
923
+ if (!artifacts) {
924
+ ctx.send(ws, { type: 'planBoardArtifacts', data: { boardId, progressLog: '', outputFiles: [], reviewResults: [] } });
925
+ return;
926
+ }
927
+
928
+ ctx.send(ws, { type: 'planBoardArtifacts', data: artifacts });
929
+ }
930
+
931
+ // ============================================================================
932
+ // Sprint lifecycle handlers (legacy — kept for backward compatibility)
933
+ // ============================================================================
934
+
935
+ function buildSprintMarkdown(
936
+ id: string, title: string, goal: string, start: string, end: string,
937
+ issueRefs: string[],
938
+ ): string {
939
+ const issuesYaml = issueRefs.length > 0
940
+ ? `\n${issueRefs.map(p => ` - ${p}`).join('\n')}`
941
+ : ' []';
942
+ return `---
943
+ id: ${id}
944
+ title: "${title.replace(/"/g, '\\"')}"
945
+ status: planned
946
+ start: "${start}"
947
+ end: "${end}"
948
+ goal: "${goal.replace(/"/g, '\\"')}"
949
+ capacity: null
950
+ committed: null
951
+ completed: null
952
+ completed_at: null
953
+ issues:${issuesYaml}
954
+ ---
955
+
956
+ # ${id}: ${title}
957
+
958
+ ## Sprint Goal
959
+ ${goal}
960
+
961
+ ## Issues
962
+ | Issue | Title | Points | Status |
963
+ |---|---|---|---|
964
+ `;
965
+ }
966
+
967
+ /** Assign issues to a sprint by updating their front matter sprint field. */
968
+ function assignIssuesToSprint(workingDir: string, issues: Issue[], issueIds: string[], sprintPath: string): void {
969
+ for (const issueId of issueIds) {
970
+ const issue = issues.find(i => i.id === issueId);
971
+ if (!issue) continue;
972
+ const fullPath = resolvePlanPath(workingDir, issue.path);
973
+ if (!fullPath || !existsSync(fullPath)) continue;
974
+ const content = replaceFrontMatterField(readFileSync(fullPath, 'utf-8'), 'sprint', sprintPath);
975
+ writeFileSync(fullPath, content, 'utf-8');
976
+ }
977
+ }
978
+
979
+ function handleCreateSprint(
980
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
981
+ workingDir: string, permission?: 'control' | 'view',
982
+ ): void {
983
+ if (denyIfViewOnly(ctx, ws, permission)) return;
984
+
985
+ const { title, goal = '', start = '', end = '', issueIds = [] } = msg.data || {};
986
+ if (!title) {
987
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint title required' } });
988
+ return;
989
+ }
990
+
991
+ const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
992
+ const sprintsDir = join(pmDir, 'sprints');
993
+ if (!existsSync(sprintsDir)) mkdirSync(sprintsDir, { recursive: true });
994
+
995
+ const fullState = parsePlanDirectory(workingDir);
996
+ const id = fullState ? getNextSprintId(fullState.sprints) : 'SPRINT-001';
997
+
998
+ const issueRefs = (issueIds as string[]).map((issueId: string) => {
999
+ const issue = fullState?.issues.find(i => i.id === issueId);
1000
+ return issue ? issue.path : `backlog/${issueId}.md`;
1001
+ });
1002
+
1003
+ writeFileSync(join(sprintsDir, `${id}.md`), buildSprintMarkdown(id, title, goal, start, end, issueRefs), 'utf-8');
1004
+
1005
+ const sandboxDir = join(sprintsDir, id);
1006
+ mkdirSync(join(sandboxDir, 'out'), { recursive: true });
1007
+ mkdirSync(join(sandboxDir, 'reviews'), { recursive: true });
1008
+ writeFileSync(join(sandboxDir, 'progress.md'), `# ${id}: ${title} — Progress Log\n`, 'utf-8');
1009
+
1010
+ if (issueRefs.length > 0 && fullState) {
1011
+ assignIssuesToSprint(workingDir, fullState.issues, issueIds as string[], `sprints/${id}.md`);
1012
+ }
1013
+
1014
+ const sprint = parseSingleSprint(workingDir, `sprints/${id}.md`);
1015
+ ctx.broadcastToAll({ type: 'planSprintCreated', data: sprint });
1016
+ }
1017
+
1018
+ /** Promote sprint issues from 'backlog' to 'todo' status. */
1019
+ function promoteSprintIssues(pmDir: string, sprint: { issues: Array<{ id: string; path: string }> }, allIssues: Issue[]): void {
1020
+ for (const issueSummary of sprint.issues) {
1021
+ const issue = allIssues.find(i => i.id === issueSummary.id || i.path === issueSummary.path);
1022
+ if (!issue || issue.status !== 'backlog') continue;
1023
+ const issuePath = join(pmDir, issue.path);
1024
+ if (!existsSync(issuePath)) continue;
1025
+ writeFileSync(issuePath, replaceFrontMatterField(readFileSync(issuePath, 'utf-8'), 'status', 'todo'), 'utf-8');
1026
+ }
1027
+ }
1028
+
1029
+ /** Update a file's front matter field if the file exists. */
1030
+ function updateFileField(filePath: string, field: string, value: string): void {
1031
+ if (!existsSync(filePath)) return;
1032
+ writeFileSync(filePath, replaceFrontMatterField(readFileSync(filePath, 'utf-8'), field, value), 'utf-8');
1033
+ }
1034
+
1035
+ function handleActivateSprint(
1036
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
1037
+ workingDir: string, permission?: 'control' | 'view',
1038
+ ): void {
1039
+ if (denyIfViewOnly(ctx, ws, permission)) return;
1040
+
1041
+ const sprintId = msg.data?.sprintId;
1042
+ if (!sprintId) {
1043
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
1044
+ return;
1045
+ }
1046
+
1047
+ const fullState = parsePlanDirectory(workingDir);
1048
+ if (!fullState) {
1049
+ ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
1050
+ return;
1051
+ }
1052
+
1053
+ const currentActive = fullState.sprints.find(s => s.status === 'active');
1054
+ if (currentActive && currentActive.id !== sprintId) {
1055
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint ${currentActive.id} is already active. Complete it first.` } });
1056
+ return;
1057
+ }
1058
+
1059
+ const sprint = fullState.sprints.find(s => s.id === sprintId);
1060
+ if (!sprint) {
1061
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
1062
+ return;
1063
+ }
1064
+
1065
+ const pmDir = resolvePmDir(workingDir);
1066
+ if (!pmDir) return;
1067
+
1068
+ updateFileField(join(pmDir, sprint.path), 'status', 'active');
1069
+ updateFileField(join(pmDir, 'STATE.md'), 'current_sprint', `"${sprint.path}"`);
1070
+ promoteSprintIssues(pmDir, sprint, fullState.issues);
1071
+
1072
+ const updatedSprint = parseSingleSprint(workingDir, sprint.path);
1073
+ ctx.broadcastToAll({ type: 'planSprintUpdated', data: updatedSprint });
1074
+
1075
+ const updatedState = parsePlanDirectory(workingDir);
1076
+ if (updatedState) {
1077
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
1078
+ }
1079
+ }
1080
+
1081
+ function handleCompleteSprint(
1082
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
1083
+ workingDir: string, permission?: 'control' | 'view',
1084
+ ): void {
1085
+ if (denyIfViewOnly(ctx, ws, permission)) return;
1086
+
1087
+ const sprintId = msg.data?.sprintId;
1088
+ if (!sprintId) {
1089
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
1090
+ return;
1091
+ }
1092
+
1093
+ const fullState = parsePlanDirectory(workingDir);
1094
+ if (!fullState) {
1095
+ ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
1096
+ return;
1097
+ }
1098
+
1099
+ const sprint = fullState.sprints.find(s => s.id === sprintId);
1100
+ if (!sprint) {
1101
+ ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
1102
+ return;
1103
+ }
1104
+
1105
+ const pmDir = resolvePmDir(workingDir);
1106
+ if (!pmDir) return;
1107
+
1108
+ const now = new Date().toISOString();
1109
+
1110
+ // Compute execution summary from sprint issues
1111
+ const sprintIssues = fullState.issues.filter(i => i.sprint === sprint.path);
1112
+ const completedIssues = sprintIssues.filter(i => i.status === 'done').length;
1113
+ const failedIssues = sprintIssues.filter(i => i.status !== 'done' && i.status !== 'cancelled').length;
1114
+
1115
+ // Update sprint file with completion data
1116
+ const sprintPath = join(pmDir, sprint.path);
1117
+ if (existsSync(sprintPath)) {
1118
+ let content = readFileSync(sprintPath, 'utf-8');
1119
+ content = replaceFrontMatterField(content, 'status', 'completed');
1120
+ content = replaceFrontMatterField(content, 'completed_at', `"${now}"`);
1121
+ content = replaceFrontMatterField(content, 'completed', String(completedIssues));
1122
+
1123
+ // Write execution summary if not already present
1124
+ if (!content.includes('execution_summary:')) {
1125
+ const summaryYaml = [
1126
+ 'execution_summary:',
1127
+ ` total_issues: ${sprintIssues.length}`,
1128
+ ` completed_issues: ${completedIssues}`,
1129
+ ` failed_issues: ${failedIssues}`,
1130
+ ].join('\n');
1131
+ // Insert before the closing --- of front matter (second occurrence)
1132
+ const fmClose = content.indexOf('\n---', content.indexOf('---') + 3);
1133
+ if (fmClose !== -1) {
1134
+ content = `${content.slice(0, fmClose)}\n${summaryYaml}${content.slice(fmClose)}`;
1135
+ }
1136
+ }
1137
+
1138
+ writeFileSync(sprintPath, content, 'utf-8');
1139
+ }
1140
+
1141
+ // Clear STATE.md current_sprint
1142
+ const statePath = join(pmDir, 'STATE.md');
1143
+ if (existsSync(statePath)) {
1144
+ let stateContent = readFileSync(statePath, 'utf-8');
1145
+ stateContent = replaceFrontMatterField(stateContent, 'current_sprint', 'null');
1146
+ writeFileSync(statePath, stateContent, 'utf-8');
1147
+ }
1148
+
1149
+ const updatedSprint = parseSingleSprint(workingDir, sprint.path);
1150
+ ctx.broadcastToAll({ type: 'planSprintCompleted', data: updatedSprint });
1151
+
1152
+ // Refresh full state
1153
+ const updatedState = parsePlanDirectory(workingDir);
1154
+ if (updatedState) {
1155
+ ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
1156
+ }
1157
+ }
1158
+
1159
+ function handleGetSprintArtifacts(
1160
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
1161
+ workingDir: string,
1162
+ ): void {
1163
+ const sprintId = msg.data?.sprintId;
1164
+ if (!sprintId) {
1165
+ ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
1166
+ return;
1167
+ }
1168
+
1169
+ const artifacts = parseSprintArtifacts(workingDir, sprintId);
1170
+ if (!artifacts) {
1171
+ // Fall back to empty artifacts if sandbox dir doesn't exist yet
1172
+ ctx.send(ws, { type: 'planSprintArtifacts', data: { sprintId, progressLog: '', outputFiles: [], reviewResults: [] } });
1173
+ return;
1174
+ }
1175
+
1176
+ ctx.send(ws, { type: 'planSprintArtifacts', data: artifacts });
1177
+ }