mstro-app 0.3.8 → 0.4.0

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