mstro-app 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +18 -9
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/headless-logger.d.ts +10 -0
- package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
- package/dist/server/cli/headless/headless-logger.js +66 -0
- package/dist/server/cli/headless/headless-logger.js.map +1 -0
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +6 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +4 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +70 -19
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +22 -9
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +8 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +94 -11
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/mcp/bouncer-cli.d.ts +3 -0
- package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-cli.js +54 -0
- package/dist/server/mcp/bouncer-cli.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts +4 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -0
- package/dist/server/services/plan/composer.js +181 -0
- package/dist/server/services/plan/composer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/dependency-resolver.js +152 -0
- package/dist/server/services/plan/dependency-resolver.js.map +1 -0
- package/dist/server/services/plan/executor.d.ts +91 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -0
- package/dist/server/services/plan/executor.js +545 -0
- package/dist/server/services/plan/executor.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +11 -0
- package/dist/server/services/plan/parser.d.ts.map +1 -0
- package/dist/server/services/plan/parser.js +415 -0
- package/dist/server/services/plan/parser.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +2 -0
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
- package/dist/server/services/plan/state-reconciler.js +105 -0
- package/dist/server/services/plan/state-reconciler.js.map +1 -0
- package/dist/server/services/plan/types.d.ts +120 -0
- package/dist/server/services/plan/types.d.ts.map +1 -0
- package/dist/server/services/plan/types.js +4 -0
- package/dist/server/services/plan/types.js.map +1 -0
- package/dist/server/services/plan/watcher.d.ts +14 -0
- package/dist/server/services/plan/watcher.d.ts.map +1 -0
- package/dist/server/services/plan/watcher.js +69 -0
- package/dist/server/services/plan/watcher.js.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +21 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/plan-handlers.js +494 -0
- package/dist/server/services/websocket/plan-handlers.js.map +1 -0
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +375 -11
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-persistence.js +187 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -0
- package/dist/server/services/websocket/quality-service.d.ts +2 -2
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +62 -12
- package/dist/server/services/websocket/quality-service.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 +2 -2
- package/server/cli/headless/claude-invoker.ts +21 -9
- package/server/cli/headless/headless-logger.ts +78 -0
- package/server/cli/headless/mcp-config.ts +6 -5
- package/server/cli/headless/runner.ts +4 -0
- package/server/cli/headless/stall-assessor.ts +97 -19
- package/server/cli/headless/tool-watchdog.ts +10 -9
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +118 -11
- package/server/mcp/bouncer-cli.ts +73 -0
- package/server/services/plan/composer.ts +199 -0
- package/server/services/plan/dependency-resolver.ts +179 -0
- package/server/services/plan/executor.ts +604 -0
- package/server/services/plan/parser.ts +459 -0
- package/server/services/plan/state-reconciler.ts +132 -0
- package/server/services/plan/types.ts +164 -0
- package/server/services/plan/watcher.ts +73 -0
- package/server/services/websocket/file-explorer-handlers.ts +20 -0
- package/server/services/websocket/handler.ts +21 -0
- package/server/services/websocket/plan-handlers.ts +592 -0
- package/server/services/websocket/quality-handlers.ts +441 -11
- package/server/services/websocket/quality-persistence.ts +250 -0
- package/server/services/websocket/quality-service.ts +65 -12
- package/server/services/websocket/types.ts +48 -2
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PPS Parser — Parses .pm/ (or legacy .plan/) directory files into structured TypeScript objects.
|
|
6
|
+
*
|
|
7
|
+
* Handles YAML front matter extraction and markdown body parsing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type {
|
|
13
|
+
AcceptanceCriterion,
|
|
14
|
+
Issue,
|
|
15
|
+
IssueSummary,
|
|
16
|
+
Milestone,
|
|
17
|
+
MilestoneEpicSummary,
|
|
18
|
+
PlanFullState,
|
|
19
|
+
ProjectConfig,
|
|
20
|
+
ProjectState,
|
|
21
|
+
Sprint,
|
|
22
|
+
SprintIssueSummary,
|
|
23
|
+
Team,
|
|
24
|
+
WorkflowStatus,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Front Matter Extraction
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
interface ParsedFile {
|
|
32
|
+
frontMatter: Record<string, unknown>;
|
|
33
|
+
body: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stripQuotes(v: string): string {
|
|
37
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
38
|
+
return v.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseYamlValue(v: string): unknown {
|
|
44
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
45
|
+
return v.slice(1, -1);
|
|
46
|
+
}
|
|
47
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
48
|
+
return v.slice(1, -1).split(',').map(s => stripQuotes(s.trim())).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
if (v === 'true') return true;
|
|
51
|
+
if (v === 'false') return false;
|
|
52
|
+
if (v === 'null' || v === '~' || v === '') return null;
|
|
53
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
|
54
|
+
return v;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseFrontMatter(content: string): ParsedFile {
|
|
58
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
59
|
+
if (!match) {
|
|
60
|
+
return { frontMatter: {}, body: content };
|
|
61
|
+
}
|
|
62
|
+
const frontMatter: Record<string, unknown> = {};
|
|
63
|
+
|
|
64
|
+
for (const line of match[1].split('\n')) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
67
|
+
const colonIdx = trimmed.indexOf(':');
|
|
68
|
+
if (colonIdx === -1) continue;
|
|
69
|
+
frontMatter[trimmed.slice(0, colonIdx).trim()] = parseYamlValue(trimmed.slice(colonIdx + 1).trim());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { frontMatter, body: match[2] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Section Extraction
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
function extractSections(body: string): Map<string, string> {
|
|
80
|
+
const sections = new Map<string, string>();
|
|
81
|
+
const lines = body.split('\n');
|
|
82
|
+
let currentSection = '';
|
|
83
|
+
let currentContent: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (line.startsWith('## ')) {
|
|
87
|
+
if (currentSection) {
|
|
88
|
+
sections.set(currentSection, currentContent.join('\n').trim());
|
|
89
|
+
}
|
|
90
|
+
currentSection = line.slice(3).trim();
|
|
91
|
+
currentContent = [];
|
|
92
|
+
} else {
|
|
93
|
+
currentContent.push(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (currentSection) {
|
|
97
|
+
sections.set(currentSection, currentContent.join('\n').trim());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return sections;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseCheckboxes(content: string): AcceptanceCriterion[] {
|
|
104
|
+
const items: AcceptanceCriterion[] = [];
|
|
105
|
+
for (const line of content.split('\n')) {
|
|
106
|
+
const match = line.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
|
|
107
|
+
if (match) {
|
|
108
|
+
items.push({ text: match[2].trim(), checked: match[1] !== ' ' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return items;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseListItems(content: string): string[] {
|
|
115
|
+
const items: string[] = [];
|
|
116
|
+
for (const line of content.split('\n')) {
|
|
117
|
+
const match = line.match(/^[-*]\s+(.+)$/);
|
|
118
|
+
if (match) items.push(match[1].trim());
|
|
119
|
+
}
|
|
120
|
+
return items;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseIssueSummaries(content: string): IssueSummary[] {
|
|
124
|
+
const summaries: IssueSummary[] = [];
|
|
125
|
+
for (const line of content.split('\n')) {
|
|
126
|
+
// Match: 1. [IS-003](backlog/IS-003.md) — Title (P1)
|
|
127
|
+
const match = line.match(/\d+\.\s+\[([^\]]+)\]\(([^)]+)\)\s*[—–-]\s*(.+?)(?:\s*\((\w+)\))?\s*$/);
|
|
128
|
+
if (match) {
|
|
129
|
+
summaries.push({
|
|
130
|
+
id: match[1],
|
|
131
|
+
path: match[2],
|
|
132
|
+
title: match[3].trim(),
|
|
133
|
+
priority: match[4] || '',
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Match: - [IS-001](backlog/IS-001.md) — Title
|
|
138
|
+
const match2 = line.match(/^[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[—–-]\s*(.+?)(?:\s*[→→]\s*blocked by\s+\[([^\]]+)\])?\s*$/i);
|
|
139
|
+
if (match2) {
|
|
140
|
+
summaries.push({
|
|
141
|
+
id: match2[1],
|
|
142
|
+
path: match2[2],
|
|
143
|
+
title: match2[3].trim(),
|
|
144
|
+
priority: '',
|
|
145
|
+
blockedBy: match2[4] || undefined,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return summaries;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseCompletedSummaries(content: string): IssueSummary[] {
|
|
153
|
+
const summaries: IssueSummary[] = [];
|
|
154
|
+
for (const line of content.split('\n')) {
|
|
155
|
+
const match = line.match(/^[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*[—–-]\s*(.+?)(?:\s*✓)?\s*$/);
|
|
156
|
+
if (match) {
|
|
157
|
+
summaries.push({
|
|
158
|
+
id: match[1],
|
|
159
|
+
path: match[2],
|
|
160
|
+
title: match[3].trim(),
|
|
161
|
+
priority: '',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return summaries;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// Entity Parsers
|
|
170
|
+
// ============================================================================
|
|
171
|
+
|
|
172
|
+
function parseWorkflows(section: string | undefined): WorkflowStatus[] {
|
|
173
|
+
if (!section) return [];
|
|
174
|
+
const workflows: WorkflowStatus[] = [];
|
|
175
|
+
for (const line of section.split('\n')) {
|
|
176
|
+
const match = line.match(/\|\s*(\w+)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|/);
|
|
177
|
+
if (match && match[1] !== 'Status') {
|
|
178
|
+
workflows.push({
|
|
179
|
+
status: match[1],
|
|
180
|
+
category: match[2] as WorkflowStatus['category'],
|
|
181
|
+
description: match[3].trim(),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return workflows;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseTeams(section: string | undefined): Team[] {
|
|
189
|
+
if (!section) return [];
|
|
190
|
+
const teams: Team[] = [];
|
|
191
|
+
for (const line of section.split('\n')) {
|
|
192
|
+
const match = line.match(/^[-*]\s+(\w+)(?:\s*[—–-]\s*(.+))?$/);
|
|
193
|
+
if (match) teams.push({ name: match[1], description: match[2]?.trim() });
|
|
194
|
+
}
|
|
195
|
+
return teams;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseProjectConfig(content: string): ProjectConfig {
|
|
199
|
+
const { frontMatter, body } = parseFrontMatter(content);
|
|
200
|
+
const sections = extractSections(body);
|
|
201
|
+
|
|
202
|
+
const idPrefixes: Record<string, string> = {};
|
|
203
|
+
const rawPrefixes = frontMatter.id_prefixes;
|
|
204
|
+
if (rawPrefixes && typeof rawPrefixes === 'object') {
|
|
205
|
+
Object.assign(idPrefixes, rawPrefixes);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
name: String(frontMatter.name || ''),
|
|
210
|
+
id: String(frontMatter.id || ''),
|
|
211
|
+
created: String(frontMatter.created || ''),
|
|
212
|
+
status: (frontMatter.status as ProjectConfig['status']) || 'active',
|
|
213
|
+
estimation: (frontMatter.estimation as ProjectConfig['estimation']) || 'none',
|
|
214
|
+
idPrefixes,
|
|
215
|
+
workflows: parseWorkflows(sections.get('Workflows')),
|
|
216
|
+
labels: (Array.isArray(frontMatter.labels) ? frontMatter.labels : []) as string[],
|
|
217
|
+
teams: parseTeams(sections.get('Teams')),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseProjectState(content: string): ProjectState {
|
|
222
|
+
const { frontMatter, body } = parseFrontMatter(content);
|
|
223
|
+
const sections = extractSections(body);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
project: String(frontMatter.project || ''),
|
|
227
|
+
currentSprint: (frontMatter.current_sprint as string) || null,
|
|
228
|
+
activeMilestone: (frontMatter.active_milestone as string) || null,
|
|
229
|
+
paused: frontMatter.paused === true,
|
|
230
|
+
lastSession: (frontMatter.last_session as string) || null,
|
|
231
|
+
readyToWork: parseIssueSummaries(sections.get('Ready to Work') || ''),
|
|
232
|
+
inProgress: parseIssueSummaries(sections.get('In Progress') || ''),
|
|
233
|
+
blocked: parseIssueSummaries(sections.get('Blocked') || ''),
|
|
234
|
+
recentlyCompleted: parseCompletedSummaries(sections.get('Recently Completed') || ''),
|
|
235
|
+
warnings: parseListItems(sections.get('Warnings') || ''),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function toStringArray(val: unknown): string[] {
|
|
240
|
+
return Array.isArray(val) ? val.map(String) : [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function optionalString(val: unknown): string | null {
|
|
244
|
+
return (val as string) || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseIssue(content: string, filePath: string): Issue {
|
|
248
|
+
const { frontMatter: fm, body } = parseFrontMatter(content);
|
|
249
|
+
const sections = extractSections(body);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
id: String(fm.id || ''),
|
|
253
|
+
title: String(fm.title || ''),
|
|
254
|
+
type: (fm.type as Issue['type']) || 'issue',
|
|
255
|
+
status: String(fm.status || 'backlog'),
|
|
256
|
+
priority: String(fm.priority || 'P2'),
|
|
257
|
+
estimate: fm.estimate != null ? fm.estimate as number | string : null,
|
|
258
|
+
labels: toStringArray(fm.labels),
|
|
259
|
+
epic: optionalString(fm.epic),
|
|
260
|
+
sprint: optionalString(fm.sprint),
|
|
261
|
+
milestone: optionalString(fm.milestone),
|
|
262
|
+
assigned: optionalString(fm.assigned),
|
|
263
|
+
created: String(fm.created || ''),
|
|
264
|
+
updated: optionalString(fm.updated),
|
|
265
|
+
due: optionalString(fm.due),
|
|
266
|
+
blockedBy: toStringArray(fm.blocked_by),
|
|
267
|
+
blocks: toStringArray(fm.blocks),
|
|
268
|
+
relatesTo: toStringArray(fm.relates_to),
|
|
269
|
+
children: toStringArray(fm.children),
|
|
270
|
+
progress: optionalString(fm.progress),
|
|
271
|
+
description: sections.get('Description') || '',
|
|
272
|
+
acceptanceCriteria: parseCheckboxes(sections.get('Acceptance Criteria') || ''),
|
|
273
|
+
technicalNotes: sections.get('Technical Notes') || null,
|
|
274
|
+
filesToModify: parseListItems(sections.get('Files to Modify') || ''),
|
|
275
|
+
activity: parseListItems(sections.get('Activity') || ''),
|
|
276
|
+
body,
|
|
277
|
+
path: filePath,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseSprintIssues(section: string | undefined): SprintIssueSummary[] {
|
|
282
|
+
if (!section) return [];
|
|
283
|
+
const issues: SprintIssueSummary[] = [];
|
|
284
|
+
for (const line of section.split('\n')) {
|
|
285
|
+
const match = line.match(/\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|/);
|
|
286
|
+
if (match) {
|
|
287
|
+
issues.push({
|
|
288
|
+
id: match[1],
|
|
289
|
+
path: match[2],
|
|
290
|
+
title: match[3].trim(),
|
|
291
|
+
points: /^\d+$/.test(match[4]) ? Number(match[4]) : match[4],
|
|
292
|
+
status: match[5],
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function optionalNumber(val: unknown): number | null {
|
|
300
|
+
return val != null ? Number(val) : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseSprint(content: string, filePath: string): Sprint {
|
|
304
|
+
const { frontMatter: fm, body } = parseFrontMatter(content);
|
|
305
|
+
const sections = extractSections(body);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
id: String(fm.id || ''),
|
|
309
|
+
title: String(fm.title || ''),
|
|
310
|
+
status: (fm.status as Sprint['status']) || 'planned',
|
|
311
|
+
start: String(fm.start || ''),
|
|
312
|
+
end: String(fm.end || ''),
|
|
313
|
+
goal: String(fm.goal || sections.get('Goal') || ''),
|
|
314
|
+
capacity: optionalNumber(fm.capacity),
|
|
315
|
+
committed: optionalNumber(fm.committed),
|
|
316
|
+
completed: optionalNumber(fm.completed),
|
|
317
|
+
issues: parseSprintIssues(sections.get('Issues')),
|
|
318
|
+
path: filePath,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseMilestone(content: string, filePath: string): Milestone {
|
|
323
|
+
const { frontMatter, body } = parseFrontMatter(content);
|
|
324
|
+
const sections = extractSections(body);
|
|
325
|
+
|
|
326
|
+
const epics: MilestoneEpicSummary[] = [];
|
|
327
|
+
const epicSection = sections.get('Epics');
|
|
328
|
+
if (epicSection) {
|
|
329
|
+
for (const line of epicSection.split('\n')) {
|
|
330
|
+
const match = line.match(/\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*(.+?)\s*\|\s*(\S+)\s*\|/);
|
|
331
|
+
if (match) {
|
|
332
|
+
epics.push({
|
|
333
|
+
id: match[1],
|
|
334
|
+
path: match[2],
|
|
335
|
+
title: match[3].trim(),
|
|
336
|
+
progress: match[4],
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
id: String(frontMatter.id || ''),
|
|
344
|
+
title: String(frontMatter.title || ''),
|
|
345
|
+
status: (frontMatter.status as Milestone['status']) || 'planned',
|
|
346
|
+
targetDate: (frontMatter.target_date as string) || null,
|
|
347
|
+
progress: (frontMatter.progress as string) || null,
|
|
348
|
+
definition: sections.get('Definition of Done') || '',
|
|
349
|
+
epics,
|
|
350
|
+
path: filePath,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ============================================================================
|
|
355
|
+
// Directory Parser
|
|
356
|
+
// ============================================================================
|
|
357
|
+
|
|
358
|
+
/** Resolve the PM directory — prefers .pm/, falls back to legacy .plan/ */
|
|
359
|
+
export function resolvePmDir(workingDir: string): string | null {
|
|
360
|
+
const pmDir = join(workingDir, '.pm');
|
|
361
|
+
if (existsSync(pmDir)) return pmDir;
|
|
362
|
+
const legacyDir = join(workingDir, '.plan');
|
|
363
|
+
if (existsSync(legacyDir)) return legacyDir;
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function planDirExists(workingDir: string): boolean {
|
|
368
|
+
return resolvePmDir(workingDir) !== null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function readFileIfExists(path: string): string | null {
|
|
372
|
+
try {
|
|
373
|
+
if (existsSync(path)) return readFileSync(path, 'utf-8');
|
|
374
|
+
} catch { /* skip */ }
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function readMdFilesInDir(dirPath: string): Array<{ name: string; content: string }> {
|
|
379
|
+
if (!existsSync(dirPath)) return [];
|
|
380
|
+
try {
|
|
381
|
+
return readdirSync(dirPath)
|
|
382
|
+
.filter(f => f.endsWith('.md'))
|
|
383
|
+
.map(name => {
|
|
384
|
+
const content = readFileSync(join(dirPath, name), 'utf-8');
|
|
385
|
+
return { name, content };
|
|
386
|
+
});
|
|
387
|
+
} catch { return []; }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function parsePlanDirectory(workingDir: string): PlanFullState | null {
|
|
391
|
+
const planDir = resolvePmDir(workingDir);
|
|
392
|
+
if (!planDir) return null;
|
|
393
|
+
|
|
394
|
+
// Parse project.md
|
|
395
|
+
const projectContent = readFileIfExists(join(planDir, 'project.md'));
|
|
396
|
+
const project = projectContent
|
|
397
|
+
? parseProjectConfig(projectContent)
|
|
398
|
+
: { name: '', id: '', created: '', status: 'active' as const, estimation: 'none' as const, idPrefixes: {}, workflows: [], labels: [], teams: [] };
|
|
399
|
+
|
|
400
|
+
// Parse STATE.md
|
|
401
|
+
const stateContent = readFileIfExists(join(planDir, 'STATE.md'));
|
|
402
|
+
const state = stateContent
|
|
403
|
+
? parseProjectState(stateContent)
|
|
404
|
+
: { project: '', currentSprint: null, activeMilestone: null, paused: false, lastSession: null, readyToWork: [], inProgress: [], blocked: [], recentlyCompleted: [], warnings: [] };
|
|
405
|
+
|
|
406
|
+
// Parse backlog issues
|
|
407
|
+
const issueFiles = readMdFilesInDir(join(planDir, 'backlog'));
|
|
408
|
+
const issues = issueFiles.map(f => parseIssue(f.content, `backlog/${f.name}`));
|
|
409
|
+
|
|
410
|
+
// Parse sprints
|
|
411
|
+
const sprintFiles = readMdFilesInDir(join(planDir, 'sprints'));
|
|
412
|
+
const sprints = sprintFiles.map(f => parseSprint(f.content, `sprints/${f.name}`));
|
|
413
|
+
|
|
414
|
+
// Parse milestones
|
|
415
|
+
const milestoneFiles = readMdFilesInDir(join(planDir, 'milestones'));
|
|
416
|
+
const milestones = milestoneFiles.map(f => parseMilestone(f.content, `milestones/${f.name}`));
|
|
417
|
+
|
|
418
|
+
return { project, state, issues, sprints, milestones };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function parseSingleIssue(workingDir: string, issuePath: string): Issue | null {
|
|
422
|
+
const pmDir = resolvePmDir(workingDir);
|
|
423
|
+
if (!pmDir) return null;
|
|
424
|
+
const fullPath = join(pmDir, issuePath);
|
|
425
|
+
const content = readFileIfExists(fullPath);
|
|
426
|
+
if (!content) return null;
|
|
427
|
+
return parseIssue(content, issuePath);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function parseSingleSprint(workingDir: string, sprintPath: string): Sprint | null {
|
|
431
|
+
const pmDir = resolvePmDir(workingDir);
|
|
432
|
+
if (!pmDir) return null;
|
|
433
|
+
const fullPath = join(pmDir, sprintPath);
|
|
434
|
+
const content = readFileIfExists(fullPath);
|
|
435
|
+
if (!content) return null;
|
|
436
|
+
return parseSprint(content, sprintPath);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function parseSingleMilestone(workingDir: string, milestonePath: string): Milestone | null {
|
|
440
|
+
const pmDir = resolvePmDir(workingDir);
|
|
441
|
+
if (!pmDir) return null;
|
|
442
|
+
const fullPath = join(pmDir, milestonePath);
|
|
443
|
+
const content = readFileIfExists(fullPath);
|
|
444
|
+
if (!content) return null;
|
|
445
|
+
return parseMilestone(content, milestonePath);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Compute the next available ID for a given prefix (e.g., "IS" → "IS-004") */
|
|
449
|
+
export function getNextId(issues: Issue[], prefix: string): string {
|
|
450
|
+
let max = 0;
|
|
451
|
+
for (const issue of issues) {
|
|
452
|
+
const match = issue.id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
453
|
+
if (match) {
|
|
454
|
+
const num = Number.parseInt(match[1], 10);
|
|
455
|
+
if (num > max) max = num;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return `${prefix}-${String(max + 1).padStart(3, '0')}`;
|
|
459
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* State Reconciler — Recomputes STATE.md from individual issue files.
|
|
6
|
+
*
|
|
7
|
+
* When individual issues change (detected by watcher), this module
|
|
8
|
+
* scans all backlog files and rebuilds the STATE.md sections.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { resolveReadyToWork } from './dependency-resolver.js';
|
|
14
|
+
import { parsePlanDirectory, resolvePmDir } from './parser.js';
|
|
15
|
+
import type { Issue } from './types.js';
|
|
16
|
+
|
|
17
|
+
interface CategorizedIssues {
|
|
18
|
+
inProgress: Issue[];
|
|
19
|
+
blocked: Issue[];
|
|
20
|
+
recentlyCompleted: Issue[];
|
|
21
|
+
readyToWork: Issue[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function categorizeIssues(issues: Issue[], issueByPath: Map<string, Issue>): CategorizedIssues {
|
|
25
|
+
const inProgress: Issue[] = [];
|
|
26
|
+
const blocked: Issue[] = [];
|
|
27
|
+
const recentlyCompleted: Issue[] = [];
|
|
28
|
+
const readyToWork = resolveReadyToWork(issues);
|
|
29
|
+
|
|
30
|
+
for (const issue of issues) {
|
|
31
|
+
if (issue.type === 'epic') continue;
|
|
32
|
+
|
|
33
|
+
if (issue.status === 'in_progress' || issue.status === 'in_review') {
|
|
34
|
+
inProgress.push(issue);
|
|
35
|
+
} else if (issue.blockedBy.length > 0 && issue.status !== 'done' && issue.status !== 'cancelled') {
|
|
36
|
+
const allBlockersDone = issue.blockedBy.every(bp => {
|
|
37
|
+
const blocker = issueByPath.get(bp);
|
|
38
|
+
return blocker && (blocker.status === 'done' || blocker.status === 'cancelled');
|
|
39
|
+
});
|
|
40
|
+
if (!allBlockersDone) {
|
|
41
|
+
blocked.push(issue);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (issue.status === 'done') {
|
|
46
|
+
recentlyCompleted.push(issue);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { inProgress, blocked, recentlyCompleted, readyToWork };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function computeWarnings(issues: Issue[]): string[] {
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
const today = new Date().toISOString().split('T')[0];
|
|
56
|
+
|
|
57
|
+
for (const issue of issues) {
|
|
58
|
+
if (issue.due && issue.due <= today && issue.status !== 'done' && issue.status !== 'cancelled') {
|
|
59
|
+
const daysOverdue = Math.ceil((Date.now() - new Date(issue.due).getTime()) / (1000 * 60 * 60 * 24));
|
|
60
|
+
warnings.push(`${issue.id} due date was ${issue.due} (${daysOverdue} day${daysOverdue !== 1 ? 's' : ''} overdue)`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return warnings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildStateMarkdown(
|
|
68
|
+
frontMatter: string,
|
|
69
|
+
categories: CategorizedIssues,
|
|
70
|
+
warnings: string[],
|
|
71
|
+
issueByPath: Map<string, Issue>,
|
|
72
|
+
): string {
|
|
73
|
+
const formatSummary = (issue: Issue, index: number): string => {
|
|
74
|
+
return `${index + 1}. [${issue.id}](${issue.path}) — ${issue.title} (${issue.priority})`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const formatBlocked = (issue: Issue): string => {
|
|
78
|
+
const blockerIds = issue.blockedBy.map(bp => {
|
|
79
|
+
const blocker = issueByPath.get(bp);
|
|
80
|
+
return blocker ? `[${blocker.id}](${blocker.path})` : bp;
|
|
81
|
+
}).join(', ');
|
|
82
|
+
return `- [${issue.id}](${issue.path}) — ${issue.title} → blocked by ${blockerIds}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const sections = [
|
|
86
|
+
'# Project State',
|
|
87
|
+
'',
|
|
88
|
+
'## Current Focus',
|
|
89
|
+
'',
|
|
90
|
+
'## Ready to Work',
|
|
91
|
+
...categories.readyToWork.map(formatSummary),
|
|
92
|
+
'',
|
|
93
|
+
'## In Progress',
|
|
94
|
+
...categories.inProgress.map(issue => `- [${issue.id}](${issue.path}) — ${issue.title}`),
|
|
95
|
+
'',
|
|
96
|
+
'## Blocked',
|
|
97
|
+
...categories.blocked.map(formatBlocked),
|
|
98
|
+
'',
|
|
99
|
+
'## Recently Completed',
|
|
100
|
+
...categories.recentlyCompleted.slice(0, 10).map(issue => `- [${issue.id}](${issue.path}) — ${issue.title} ✓`),
|
|
101
|
+
'',
|
|
102
|
+
'## Warnings',
|
|
103
|
+
...warnings.map(w => `- ${w}`),
|
|
104
|
+
'',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
return `---\n${frontMatter}\n---\n\n${sections.join('\n')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function reconcileState(workingDir: string): void {
|
|
111
|
+
const pmDir = resolvePmDir(workingDir);
|
|
112
|
+
if (!pmDir) return;
|
|
113
|
+
const statePath = join(pmDir, 'STATE.md');
|
|
114
|
+
if (!existsSync(statePath)) return;
|
|
115
|
+
|
|
116
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
117
|
+
if (!fullState) return;
|
|
118
|
+
|
|
119
|
+
const { issues, project } = fullState;
|
|
120
|
+
|
|
121
|
+
const issueByPath = new Map(issues.map(i => [i.path, i]));
|
|
122
|
+
const categories = categorizeIssues(issues, issueByPath);
|
|
123
|
+
const warnings = computeWarnings(issues);
|
|
124
|
+
|
|
125
|
+
// Read existing front matter
|
|
126
|
+
const content = readFileSync(statePath, 'utf-8');
|
|
127
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
128
|
+
const frontMatter = fmMatch ? fmMatch[1] : `project: "${project.name}"\ncurrent_sprint: null\nactive_milestone: null\npaused: false\nlast_session: null`;
|
|
129
|
+
|
|
130
|
+
const newContent = buildStateMarkdown(frontMatter, categories, warnings, issueByPath);
|
|
131
|
+
writeFileSync(statePath, newContent, 'utf-8');
|
|
132
|
+
}
|