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
@@ -0,0 +1,48 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Front Matter Utilities — Read/write YAML front matter fields.
6
+ *
7
+ * All replacements are scoped to the --- delimiters to prevent
8
+ * markdown body corruption. Used across executor, plan-handlers,
9
+ * and state-reconciler.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+
14
+ /**
15
+ * Replace a field value in a raw YAML string (no --- delimiters).
16
+ * If the field does not exist, it is appended.
17
+ */
18
+ export function replaceYamlField(yaml: string, field: string, value: string): string {
19
+ const regex = new RegExp(`^(${field}:\\s*).+$`, 'm');
20
+ if (regex.test(yaml)) {
21
+ return yaml.replace(regex, `$1${value}`);
22
+ }
23
+ return `${yaml}\n${field}: ${value}`;
24
+ }
25
+
26
+ /**
27
+ * Replace a YAML front matter field in a full markdown content string.
28
+ * Only modifies content between the first pair of --- delimiters.
29
+ * If the field does not exist in front matter, it is appended.
30
+ * Returns content unchanged if no front matter block is found.
31
+ */
32
+ export function replaceFrontMatterField(content: string, field: string, value: string): string {
33
+ const fmMatch = content.match(/^(---\n)([\s\S]*?)(\n---)/);
34
+ if (!fmMatch) return content;
35
+
36
+ const yaml = replaceYamlField(fmMatch[2], field, value);
37
+ return `${fmMatch[1]}${yaml}${fmMatch[3]}${content.slice(fmMatch[0].length)}`;
38
+ }
39
+
40
+ /**
41
+ * Read a file, update a front matter field, and write it back.
42
+ * Convenience wrapper for single-field updates.
43
+ */
44
+ export function setFrontMatterField(filePath: string, field: string, value: string): void {
45
+ const content = readFileSync(filePath, 'utf-8');
46
+ const updated = replaceFrontMatterField(content, field, value);
47
+ writeFileSync(filePath, updated, 'utf-8');
48
+ }
@@ -0,0 +1,113 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Output Manager — Resolves output paths, lists existing docs, and publishes outputs.
6
+ *
7
+ * Handles sprint-sandboxed and global output directories with fallback.
8
+ */
9
+
10
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import { defaultPmDir, resolvePmDir } from './parser.js';
13
+ import type { Issue } from './types.js';
14
+
15
+ /** Convert a title to a URL-friendly slug (max 60 chars). */
16
+ export function slugify(text: string): string {
17
+ return text
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-+|-+$/g, '')
21
+ .slice(0, 60);
22
+ }
23
+
24
+ /**
25
+ * Resolve the canonical output path for an issue.
26
+ * Uses sprint sandbox when available, otherwise global .pm/out/.
27
+ */
28
+ export function resolveOutputPath(issue: Issue, workingDir: string, sprintSandboxDir: string | null): string {
29
+ if (sprintSandboxDir) {
30
+ return join(sprintSandboxDir, 'out', `${issue.id}-${slugify(issue.title)}.md`);
31
+ }
32
+ const pmDir = resolvePmDir(workingDir);
33
+ const outDir = pmDir ? join(pmDir, 'out') : join(defaultPmDir(workingDir), 'out');
34
+ return join(outDir, `${issue.id}-${slugify(issue.title)}.md`);
35
+ }
36
+
37
+ /**
38
+ * List existing execution output docs.
39
+ * Searches sprint sandbox first (higher priority), then global out/.
40
+ */
41
+ export function listExistingDocs(workingDir: string, sprintSandboxDir: string | null): string[] {
42
+ const pmDir = resolvePmDir(workingDir);
43
+ if (!pmDir) return [];
44
+
45
+ const dirs: string[] = [];
46
+ if (sprintSandboxDir) dirs.push(join(sprintSandboxDir, 'out'));
47
+ dirs.push(join(pmDir, 'out'));
48
+
49
+ const docs: string[] = [];
50
+ for (const dir of dirs) {
51
+ if (!existsSync(dir)) continue;
52
+ try {
53
+ docs.push(...readdirSync(dir).filter(f => f.endsWith('.md')).map(f => join(dir, f)));
54
+ } catch { /* skip */ }
55
+ }
56
+ return docs;
57
+ }
58
+
59
+ export interface PublishOutputsCallbacks {
60
+ onWarning?: (issueId: string, text: string) => void;
61
+ }
62
+
63
+ /**
64
+ * Copy confirmed-done outputs from .pm/out/ to user-specified output_file paths.
65
+ * Only copies for issues that completed successfully and have output_file set.
66
+ */
67
+ export function publishOutputs(
68
+ issues: Issue[],
69
+ workingDir: string,
70
+ sprintSandboxDir: string | null,
71
+ callbacks?: PublishOutputsCallbacks,
72
+ ): void {
73
+ const pmDir = resolvePmDir(workingDir);
74
+ if (!pmDir) return;
75
+
76
+ for (const issue of issues) {
77
+ publishSingleOutput(issue, pmDir, workingDir, sprintSandboxDir, callbacks);
78
+ }
79
+ }
80
+
81
+ function publishSingleOutput(
82
+ issue: Issue,
83
+ pmDir: string,
84
+ workingDir: string,
85
+ sprintSandboxDir: string | null,
86
+ callbacks?: PublishOutputsCallbacks,
87
+ ): void {
88
+ if (!issue.outputFile) return;
89
+
90
+ // Only publish for confirmed-done issues
91
+ try {
92
+ const content = readFileSync(join(pmDir, issue.path), 'utf-8');
93
+ if (!content.match(/^status:\s*done$/m)) return;
94
+ } catch { return; }
95
+
96
+ const srcPath = resolveOutputPath(issue, workingDir, sprintSandboxDir);
97
+ if (!existsSync(srcPath)) return;
98
+
99
+ // Guard against path traversal — output_file must resolve within workingDir
100
+ const destPath = resolve(workingDir, issue.outputFile);
101
+ if (!destPath.startsWith(`${workingDir}/`) && destPath !== workingDir) {
102
+ callbacks?.onWarning?.(issue.id, `output_file "${issue.outputFile}" escapes project directory — skipping`);
103
+ return;
104
+ }
105
+
106
+ try {
107
+ const destDir = join(destPath, '..');
108
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
109
+ copyFileSync(srcPath, destPath);
110
+ } catch {
111
+ callbacks?.onWarning?.(issue.id, `could not copy output to ${issue.outputFile}`);
112
+ }
113
+ }
@@ -7,10 +7,14 @@
7
7
  * Handles YAML front matter extraction and markdown body parsing.
8
8
  */
9
9
 
10
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import type {
13
13
  AcceptanceCriterion,
14
+ Board,
15
+ BoardArtifacts,
16
+ BoardExecutionSummary,
17
+ BoardFullState,
14
18
  Issue,
15
19
  IssueSummary,
16
20
  Milestone,
@@ -18,10 +22,14 @@ import type {
18
22
  PlanFullState,
19
23
  ProjectConfig,
20
24
  ProjectState,
25
+ ReviewResult,
21
26
  Sprint,
27
+ SprintArtifacts,
28
+ SprintExecutionSummary,
22
29
  SprintIssueSummary,
23
30
  Team,
24
31
  WorkflowStatus,
32
+ Workspace,
25
33
  } from './types.js';
26
34
 
27
35
  // ============================================================================
@@ -298,6 +306,7 @@ function parseIssue(content: string, filePath: string): Issue {
298
306
  technicalNotes: sections.get('Technical Notes') || null,
299
307
  filesToModify: parseListItems(sections.get('Files to Modify') || ''),
300
308
  activity: parseListItems(sections.get('Activity') || ''),
309
+ reviewGate: (['none', 'auto', 'required'].includes(String(fm.review_gate)) ? String(fm.review_gate) : 'auto') as Issue['reviewGate'],
301
310
  outputFile: optionalString(fm.output_file),
302
311
  body,
303
312
  path: filePath,
@@ -341,6 +350,19 @@ function parseSprint(content: string, filePath: string): Sprint {
341
350
  });
342
351
  }
343
352
 
353
+ // Parse execution_summary if present (JSON object in front matter)
354
+ let executionSummary: SprintExecutionSummary | null = null;
355
+ if (fm.execution_summary && typeof fm.execution_summary === 'object') {
356
+ const es = fm.execution_summary as Record<string, unknown>;
357
+ executionSummary = {
358
+ totalIssues: Number(es.total_issues ?? 0),
359
+ completedIssues: Number(es.completed_issues ?? 0),
360
+ failedIssues: Number(es.failed_issues ?? 0),
361
+ totalDuration: Number(es.total_duration ?? 0),
362
+ waves: Number(es.waves ?? 0),
363
+ };
364
+ }
365
+
344
366
  return {
345
367
  id: String(fm.id || ''),
346
368
  title: String(fm.title || ''),
@@ -353,6 +375,8 @@ function parseSprint(content: string, filePath: string): Sprint {
353
375
  completed: optionalNumber(fm.completed),
354
376
  issues,
355
377
  path: filePath,
378
+ completedAt: optionalString(fm.completed_at),
379
+ executionSummary,
356
380
  };
357
381
  }
358
382
 
@@ -388,19 +412,196 @@ function parseMilestone(content: string, filePath: string): Milestone {
388
412
  };
389
413
  }
390
414
 
415
+ // ============================================================================
416
+ // Board Parser
417
+ // ============================================================================
418
+
419
+ function parseBoard(content: string, filePath: string): Board {
420
+ const { frontMatter: fm, body } = parseFrontMatter(content);
421
+ const sections = extractSections(body);
422
+
423
+ let executionSummary: BoardExecutionSummary | null = null;
424
+ if (fm.execution_summary && typeof fm.execution_summary === 'object') {
425
+ const es = fm.execution_summary as Record<string, unknown>;
426
+ executionSummary = {
427
+ totalIssues: Number(es.total_issues ?? 0),
428
+ completedIssues: Number(es.completed_issues ?? 0),
429
+ failedIssues: Number(es.failed_issues ?? 0),
430
+ totalDuration: Number(es.total_duration ?? 0),
431
+ waves: Number(es.waves ?? 0),
432
+ };
433
+ }
434
+
435
+ return {
436
+ id: String(fm.id || ''),
437
+ title: String(fm.title || ''),
438
+ status: (fm.status as Board['status']) || 'draft',
439
+ created: String(fm.created || ''),
440
+ completedAt: optionalString(fm.completed_at),
441
+ goal: String(fm.goal || sections.get('Goal') || ''),
442
+ executionSummary,
443
+ path: filePath,
444
+ };
445
+ }
446
+
447
+ function parseWorkspace(content: string): Workspace {
448
+ try {
449
+ const parsed = JSON.parse(content) as Record<string, unknown>;
450
+ return {
451
+ activeBoardId: typeof parsed.activeBoardId === 'string' ? parsed.activeBoardId : null,
452
+ boardOrder: Array.isArray(parsed.boardOrder) ? parsed.boardOrder.map(String) : [],
453
+ };
454
+ } catch {
455
+ return { activeBoardId: null, boardOrder: [] };
456
+ }
457
+ }
458
+
459
+ /** Check whether a .pm/ directory uses the board-centric format (has boards/ subdirectory). */
460
+ export function isBoardCentricFormat(pmDir: string): boolean {
461
+ return existsSync(join(pmDir, 'boards'));
462
+ }
463
+
464
+ /** Check whether a .pm/ directory uses the legacy flat format (has backlog/ at root, no boards/). */
465
+ function isLegacyFormat(pmDir: string): boolean {
466
+ return existsSync(join(pmDir, 'backlog')) && !existsSync(join(pmDir, 'boards'));
467
+ }
468
+
469
+ // ============================================================================
470
+ // Legacy → Board Migration
471
+ // ============================================================================
472
+
473
+ /** Move all files from a legacy directory into a board subdirectory and remove the source. */
474
+ function moveLegacyDir(srcDir: string, destDir: string): void {
475
+ if (!existsSync(srcDir)) return;
476
+ for (const file of readdirSync(srcDir)) {
477
+ renameSync(join(srcDir, file), join(destDir, file));
478
+ }
479
+ rmSync(srcDir, { recursive: true });
480
+ }
481
+
482
+ /** Move a single file if it exists. */
483
+ function moveLegacyFile(src: string, dest: string): void {
484
+ if (existsSync(src)) renameSync(src, dest);
485
+ }
486
+
487
+ /** Copy review files from sprint sandbox directories into the board reviews dir. */
488
+ function copySprintReviews(sprintsDir: string, boardReviewsDir: string): void {
489
+ for (const entry of readdirSync(sprintsDir)) {
490
+ if (entry.endsWith('.md')) continue;
491
+ const reviewsDir = join(sprintsDir, entry, 'reviews');
492
+ if (!existsSync(reviewsDir)) continue;
493
+ for (const reviewFile of readdirSync(reviewsDir)) {
494
+ cpSync(join(reviewsDir, reviewFile), join(boardReviewsDir, reviewFile));
495
+ }
496
+ }
497
+ }
498
+
499
+ /** Find and return the goal from the active sprint .md file. */
500
+ function extractActiveSprintGoal(sprintsDir: string): string {
501
+ for (const entry of readdirSync(sprintsDir).filter(e => e.endsWith('.md'))) {
502
+ const content = readFileIfExists(join(sprintsDir, entry));
503
+ if (!content) continue;
504
+ const fm = parseFrontMatter(content).frontMatter;
505
+ if (fm.status === 'active') return String(fm.goal || '');
506
+ }
507
+ return '';
508
+ }
509
+
510
+ /** Migrate sprint reviews and extract the active sprint's goal. */
511
+ function migrateLegacySprints(sprintsDir: string, boardReviewsDir: string): string {
512
+ if (!existsSync(sprintsDir)) return '';
513
+ copySprintReviews(sprintsDir, boardReviewsDir);
514
+ const goal = extractActiveSprintGoal(sprintsDir);
515
+ rmSync(sprintsDir, { recursive: true });
516
+ return goal;
517
+ }
518
+
519
+ /** Clean up migrated issues: remove sprint fields, detect active issues. */
520
+ function cleanupMigratedIssues(boardBacklogDir: string): boolean {
521
+ if (!existsSync(boardBacklogDir)) return false;
522
+ let hasActive = false;
523
+
524
+ for (const file of readdirSync(boardBacklogDir).filter(f => f.endsWith('.md'))) {
525
+ const content = readFileIfExists(join(boardBacklogDir, file));
526
+ if (!content) continue;
527
+ if (content.match(/^status:\s*(in_progress|in_review|todo)/m)) hasActive = true;
528
+ if (content.match(/^sprint:\s*.+$/m)) {
529
+ writeFileSync(join(boardBacklogDir, file), content.replace(/^sprint:\s*.+\n?/m, ''), 'utf-8');
530
+ }
531
+ }
532
+ return hasActive;
533
+ }
534
+
535
+ /** Write the board metadata files (board.md, workspace.json, STATE.md, progress.md). */
536
+ function writeBoardMetadata(pmDir: string, boardDir: string, boardId: string, sprintGoal: string, hasActive: boolean): void {
537
+ const today = new Date().toISOString().slice(0, 10);
538
+ const boardMd = [
539
+ '---', `id: ${boardId}`, 'title: "Board 1"',
540
+ `status: ${hasActive ? 'active' : 'draft'}`, `created: "${today}"`,
541
+ 'completed_at: null', `goal: "${sprintGoal.replace(/"/g, '\\"')}"`,
542
+ '---', '', '# Board 1', '',
543
+ sprintGoal ? `## Goal\n${sprintGoal}\n` : '',
544
+ ].join('\n');
545
+ writeFileSync(join(boardDir, 'board.md'), boardMd, 'utf-8');
546
+
547
+ const workspace: Workspace = { activeBoardId: boardId, boardOrder: [boardId] };
548
+ writeFileSync(join(pmDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
549
+
550
+ if (!existsSync(join(boardDir, 'STATE.md'))) {
551
+ writeFileSync(join(boardDir, 'STATE.md'), [
552
+ '---', 'project: ../../project.md', 'board: board.md', 'paused: false', '---', '',
553
+ '# Board State', '', '## Ready to Work', '', '## In Progress', '',
554
+ '## Blocked', '', '## Recently Completed', '', '## Warnings', '',
555
+ ].join('\n'), 'utf-8');
556
+ }
557
+ if (!existsSync(join(boardDir, 'progress.md'))) {
558
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Migrate a legacy flat .pm/ directory to board-centric format.
564
+ * Creates BOARD-001 from the existing backlog, state, outputs, and reviews.
565
+ */
566
+ function migrateToBoards(pmDir: string): void {
567
+ const boardId = 'BOARD-001';
568
+ const boardDir = join(pmDir, 'boards', boardId);
569
+
570
+ mkdirSync(boardDir, { recursive: true });
571
+ mkdirSync(join(boardDir, 'backlog'), { recursive: true });
572
+ mkdirSync(join(boardDir, 'out'), { recursive: true });
573
+ mkdirSync(join(boardDir, 'reviews'), { recursive: true });
574
+
575
+ moveLegacyDir(join(pmDir, 'backlog'), join(boardDir, 'backlog'));
576
+ moveLegacyFile(join(pmDir, 'STATE.md'), join(boardDir, 'STATE.md'));
577
+ moveLegacyDir(join(pmDir, 'out'), join(boardDir, 'out'));
578
+ moveLegacyFile(join(pmDir, 'progress.md'), join(boardDir, 'progress.md'));
579
+
580
+ const sprintGoal = migrateLegacySprints(join(pmDir, 'sprints'), join(boardDir, 'reviews'));
581
+ const hasActive = cleanupMigratedIssues(join(boardDir, 'backlog'));
582
+ writeBoardMetadata(pmDir, boardDir, boardId, sprintGoal, hasActive);
583
+ }
584
+
391
585
  // ============================================================================
392
586
  // Directory Parser
393
587
  // ============================================================================
394
588
 
395
- /** Resolve the PM directory — prefers .pm/, falls back to legacy .plan/ */
589
+ /** Resolve the PM directory — prefers .mstro/pm/, falls back to legacy .pm/ and .plan/ */
396
590
  export function resolvePmDir(workingDir: string): string | null {
397
- const pmDir = join(workingDir, '.pm');
398
- if (existsSync(pmDir)) return pmDir;
399
- const legacyDir = join(workingDir, '.plan');
400
- if (existsSync(legacyDir)) return legacyDir;
591
+ const mstroPmDir = join(workingDir, '.mstro', 'pm');
592
+ if (existsSync(mstroPmDir)) return mstroPmDir;
593
+ const legacyPmDir = join(workingDir, '.pm');
594
+ if (existsSync(legacyPmDir)) return legacyPmDir;
595
+ const legacyPlanDir = join(workingDir, '.plan');
596
+ if (existsSync(legacyPlanDir)) return legacyPlanDir;
401
597
  return null;
402
598
  }
403
599
 
600
+ /** Default PM directory path for new projects */
601
+ export function defaultPmDir(workingDir: string): string {
602
+ return join(workingDir, '.mstro', 'pm');
603
+ }
604
+
404
605
  export function planDirExists(workingDir: string): boolean {
405
606
  return resolvePmDir(workingDir) !== null;
406
607
  }
@@ -424,35 +625,89 @@ function readMdFilesInDir(dirPath: string): Array<{ name: string; content: strin
424
625
  } catch { return []; }
425
626
  }
426
627
 
427
- export function parsePlanDirectory(workingDir: string): PlanFullState | null {
428
- const planDir = resolvePmDir(workingDir);
429
- if (!planDir) return null;
628
+ const defaultProject: ProjectConfig = {
629
+ name: '', id: '', created: '', status: 'active', estimation: 'none',
630
+ idPrefixes: {}, workflows: [], labels: [], teams: [],
631
+ };
632
+
633
+ const defaultState: ProjectState = {
634
+ project: '', currentSprint: null, activeMilestone: null, paused: false,
635
+ lastSession: null, readyToWork: [], inProgress: [], blocked: [],
636
+ recentlyCompleted: [], warnings: [],
637
+ };
638
+
639
+ /** Parse a single board's full state (board.md + STATE.md + backlog/). */
640
+ export function parseBoardDirectory(pmDir: string, boardId: string): BoardFullState | null {
641
+ const boardDir = join(pmDir, 'boards', boardId);
642
+ if (!existsSync(boardDir)) return null;
643
+
644
+ const boardContent = readFileIfExists(join(boardDir, 'board.md'));
645
+ if (!boardContent) return null;
646
+ const board = parseBoard(boardContent, `boards/${boardId}/board.md`);
647
+
648
+ const stateContent = readFileIfExists(join(boardDir, 'STATE.md'));
649
+ const state = stateContent ? parseProjectState(stateContent) : { ...defaultState };
650
+
651
+ const issueFiles = readMdFilesInDir(join(boardDir, 'backlog'));
652
+ const boardPrefix = `boards/${boardId}/`;
653
+ const issues = issueFiles.map(f => {
654
+ const issue = parseIssue(f.content, `${boardPrefix}backlog/${f.name}`);
655
+ // Normalize blocked_by/blocks to full board-relative paths so dependency resolver matches
656
+ issue.blockedBy = issue.blockedBy.map(bp => bp.startsWith('boards/') ? bp : `${boardPrefix}${bp}`);
657
+ issue.blocks = issue.blocks.map(bp => bp.startsWith('boards/') ? bp : `${boardPrefix}${bp}`);
658
+ if (issue.epic && !issue.epic.startsWith('boards/')) issue.epic = `${boardPrefix}${issue.epic}`;
659
+ return issue;
660
+ });
661
+
662
+ return { board, state, issues };
663
+ }
430
664
 
431
- // Parse project.md
432
- const projectContent = readFileIfExists(join(planDir, 'project.md'));
433
- const project = projectContent
434
- ? parseProjectConfig(projectContent)
435
- : { name: '', id: '', created: '', status: 'active' as const, estimation: 'none' as const, idPrefixes: {}, workflows: [], labels: [], teams: [] };
665
+ /** Parse all boards from the boards/ directory and resolve the active board. */
666
+ function parseBoardCentricState(planDir: string): { boards: Board[]; workspace: Workspace; activeBoard: BoardFullState | null } {
667
+ const workspaceContent = readFileIfExists(join(planDir, 'workspace.json'));
668
+ const workspace = workspaceContent ? parseWorkspace(workspaceContent) : { activeBoardId: null, boardOrder: [] };
669
+
670
+ const boards: Board[] = [];
671
+ const boardsDir = join(planDir, 'boards');
672
+ if (existsSync(boardsDir)) {
673
+ for (const entry of readdirSync(boardsDir)) {
674
+ const boardMdPath = join(boardsDir, entry, 'board.md');
675
+ if (!existsSync(boardMdPath)) continue;
676
+ boards.push(parseBoard(readFileSync(boardMdPath, 'utf-8'), `boards/${entry}/board.md`));
677
+ }
678
+ }
679
+
680
+ const orderMap = new Map(workspace.boardOrder.map((id, i) => [id, i]));
681
+ boards.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
436
682
 
437
- // Parse STATE.md
438
- const stateContent = readFileIfExists(join(planDir, 'STATE.md'));
439
- const state = stateContent
440
- ? parseProjectState(stateContent)
441
- : { project: '', currentSprint: null, activeMilestone: null, paused: false, lastSession: null, readyToWork: [], inProgress: [], blocked: [], recentlyCompleted: [], warnings: [] };
683
+ const activeBoard = workspace.activeBoardId ? parseBoardDirectory(planDir, workspace.activeBoardId) : null;
684
+ return { boards, workspace, activeBoard };
685
+ }
442
686
 
443
- // Parse backlog issues
444
- const issueFiles = readMdFilesInDir(join(planDir, 'backlog'));
445
- const issues = issueFiles.map(f => parseIssue(f.content, `backlog/${f.name}`));
687
+ export function parsePlanDirectory(workingDir: string): PlanFullState | null {
688
+ const planDir = resolvePmDir(workingDir);
689
+ if (!planDir) return null;
446
690
 
447
- // Parse sprints
448
- const sprintFiles = readMdFilesInDir(join(planDir, 'sprints'));
449
- const sprints = sprintFiles.map(f => parseSprint(f.content, `sprints/${f.name}`));
691
+ if (isLegacyFormat(planDir)) migrateToBoards(planDir);
450
692
 
451
- // Parse milestones
693
+ const projectContent = readFileIfExists(join(planDir, 'project.md'));
694
+ const project = projectContent ? parseProjectConfig(projectContent) : { ...defaultProject };
452
695
  const milestoneFiles = readMdFilesInDir(join(planDir, 'milestones'));
453
696
  const milestones = milestoneFiles.map(f => parseMilestone(f.content, `milestones/${f.name}`));
454
697
 
455
- return { project, state, issues, sprints, milestones };
698
+ if (!isBoardCentricFormat(planDir)) {
699
+ return {
700
+ project, state: { ...defaultState }, boards: [], workspace: { activeBoardId: null, boardOrder: [] },
701
+ activeBoard: null, issues: [], sprints: [], milestones,
702
+ };
703
+ }
704
+
705
+ const { boards, workspace, activeBoard } = parseBoardCentricState(planDir);
706
+ return {
707
+ project, state: activeBoard?.state ?? { ...defaultState },
708
+ boards, workspace, activeBoard, issues: activeBoard?.issues ?? [],
709
+ sprints: [], milestones,
710
+ };
456
711
  }
457
712
 
458
713
  export function parseSingleIssue(workingDir: string, issuePath: string): Issue | null {
@@ -496,3 +751,110 @@ export function getNextId(issues: Issue[], prefix: string): string {
496
751
  }
497
752
  return `${prefix}-${String(max + 1).padStart(3, '0')}`;
498
753
  }
754
+
755
+ /** Compute the next available board ID (e.g., "BOARD-003") */
756
+ export function getNextBoardId(boards: Board[]): string {
757
+ let max = 0;
758
+ for (const board of boards) {
759
+ const match = board.id.match(/^BOARD-(\d+)$/);
760
+ if (match) {
761
+ const num = Number.parseInt(match[1], 10);
762
+ if (num > max) max = num;
763
+ }
764
+ }
765
+ return `BOARD-${String(max + 1).padStart(3, '0')}`;
766
+ }
767
+
768
+ /** Compute the next available board number for display title (e.g., "Board 3") */
769
+ export function getNextBoardNumber(boards: Board[]): number {
770
+ let max = 0;
771
+ for (const board of boards) {
772
+ const match = board.title.match(/^Board (\d+)$/);
773
+ if (match) {
774
+ const num = Number.parseInt(match[1], 10);
775
+ if (num > max) max = num;
776
+ }
777
+ }
778
+ return max + 1;
779
+ }
780
+
781
+ /** Parse board artifacts from boards/BOARD-N/ directory. */
782
+ export function parseBoardArtifacts(workingDir: string, boardId: string): BoardArtifacts | null {
783
+ const pmDir = resolvePmDir(workingDir);
784
+ if (!pmDir) return null;
785
+
786
+ const boardDir = join(pmDir, 'boards', boardId);
787
+ if (!existsSync(boardDir)) return null;
788
+
789
+ const progressLog = readFileIfExists(join(boardDir, 'progress.md')) ?? '';
790
+
791
+ const outDir = join(boardDir, 'out');
792
+ let outputFiles: string[] = [];
793
+ if (existsSync(outDir)) {
794
+ try {
795
+ outputFiles = readdirSync(outDir).filter(f => f.endsWith('.md'));
796
+ } catch { /* skip */ }
797
+ }
798
+
799
+ const reviewsDir = join(boardDir, 'reviews');
800
+ const reviewResults: ReviewResult[] = [];
801
+ if (existsSync(reviewsDir)) {
802
+ try {
803
+ for (const f of readdirSync(reviewsDir).filter(f => f.endsWith('.json'))) {
804
+ const content = readFileIfExists(join(reviewsDir, f));
805
+ if (content) {
806
+ reviewResults.push(JSON.parse(content) as ReviewResult);
807
+ }
808
+ }
809
+ } catch { /* skip */ }
810
+ }
811
+
812
+ return { boardId, progressLog, outputFiles, reviewResults };
813
+ }
814
+
815
+ /** @deprecated Use getNextBoardId — kept for migration compatibility */
816
+ export function getNextSprintId(sprints: Sprint[]): string {
817
+ let max = 0;
818
+ for (const sprint of sprints) {
819
+ const match = sprint.id.match(/^SPRINT-(\d+)$/);
820
+ if (match) {
821
+ const num = Number.parseInt(match[1], 10);
822
+ if (num > max) max = num;
823
+ }
824
+ }
825
+ return `SPRINT-${String(max + 1).padStart(3, '0')}`;
826
+ }
827
+
828
+ /** @deprecated Use parseBoardArtifacts — kept for migration compatibility */
829
+ export function parseSprintArtifacts(workingDir: string, sprintId: string): SprintArtifacts | null {
830
+ const pmDir = resolvePmDir(workingDir);
831
+ if (!pmDir) return null;
832
+
833
+ const sandboxDir = join(pmDir, 'sprints', sprintId);
834
+ if (!existsSync(sandboxDir)) return null;
835
+
836
+ const progressLog = readFileIfExists(join(sandboxDir, 'progress.md')) ?? '';
837
+
838
+ const outDir = join(sandboxDir, 'out');
839
+ let outputFiles: string[] = [];
840
+ if (existsSync(outDir)) {
841
+ try {
842
+ outputFiles = readdirSync(outDir).filter(f => f.endsWith('.md'));
843
+ } catch { /* skip */ }
844
+ }
845
+
846
+ const reviewsDir = join(sandboxDir, 'reviews');
847
+ const reviewResults: ReviewResult[] = [];
848
+ if (existsSync(reviewsDir)) {
849
+ try {
850
+ for (const f of readdirSync(reviewsDir).filter(f => f.endsWith('.json'))) {
851
+ const content = readFileIfExists(join(reviewsDir, f));
852
+ if (content) {
853
+ reviewResults.push(JSON.parse(content) as ReviewResult);
854
+ }
855
+ }
856
+ } catch { /* skip */ }
857
+ }
858
+
859
+ return { sprintId, progressLog, outputFiles, reviewResults };
860
+ }