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.
- package/bin/mstro.js +119 -40
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +3 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +4 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +116 -31
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/config-installer.d.ts +25 -0
- package/dist/server/services/plan/config-installer.d.ts.map +1 -0
- package/dist/server/services/plan/config-installer.js +182 -0
- package/dist/server/services/plan/config-installer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
- package/dist/server/services/plan/dependency-resolver.js +4 -1
- package/dist/server/services/plan/dependency-resolver.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +38 -74
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +271 -459
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/front-matter.d.ts +18 -0
- package/dist/server/services/plan/front-matter.d.ts.map +1 -0
- package/dist/server/services/plan/front-matter.js +44 -0
- package/dist/server/services/plan/front-matter.js.map +1 -0
- package/dist/server/services/plan/output-manager.d.ts +22 -0
- package/dist/server/services/plan/output-manager.d.ts.map +1 -0
- package/dist/server/services/plan/output-manager.js +97 -0
- package/dist/server/services/plan/output-manager.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +18 -2
- package/dist/server/services/plan/parser.d.ts.map +1 -1
- package/dist/server/services/plan/parser.js +359 -25
- package/dist/server/services/plan/parser.js.map +1 -1
- package/dist/server/services/plan/prompt-builder.d.ts +17 -0
- package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
- package/dist/server/services/plan/prompt-builder.js +137 -0
- package/dist/server/services/plan/prompt-builder.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts +26 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -0
- package/dist/server/services/plan/review-gate.js +191 -0
- package/dist/server/services/plan/review-gate.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +1 -1
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
- package/dist/server/services/plan/state-reconciler.js +59 -7
- package/dist/server/services/plan/state-reconciler.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +66 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +11 -0
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +14 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +518 -40
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +1 -2
- package/server/cli/headless/claude-invoker.ts +4 -0
- package/server/cli/headless/types.ts +4 -1
- package/server/services/plan/composer.ts +138 -34
- package/server/services/plan/config-installer.ts +187 -0
- package/server/services/plan/dependency-resolver.ts +4 -1
- package/server/services/plan/executor.ts +278 -487
- package/server/services/plan/front-matter.ts +48 -0
- package/server/services/plan/output-manager.ts +113 -0
- package/server/services/plan/parser.ts +389 -27
- package/server/services/plan/prompt-builder.ts +161 -0
- package/server/services/plan/review-gate.ts +210 -0
- package/server/services/plan/state-reconciler.ts +68 -7
- package/server/services/plan/types.ts +99 -1
- package/server/services/platform.ts +11 -0
- package/server/services/websocket/handler.ts +14 -0
- package/server/services/websocket/plan-handlers.ts +629 -44
- 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
|
|
398
|
-
if (existsSync(
|
|
399
|
-
const
|
|
400
|
-
if (existsSync(
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
687
|
+
export function parsePlanDirectory(workingDir: string): PlanFullState | null {
|
|
688
|
+
const planDir = resolvePmDir(workingDir);
|
|
689
|
+
if (!planDir) return null;
|
|
446
690
|
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|