mstro-app 0.4.1 → 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/cli/improvisation-session-manager.js +1 -1
- package/dist/server/cli/improvisation-session-manager.js.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 +43 -71
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +314 -438
- 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 +372 -32
- 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/cli/improvisation-session-manager.ts +1 -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 +325 -464
- package/server/services/plan/front-matter.ts +48 -0
- package/server/services/plan/output-manager.ts +113 -0
- package/server/services/plan/parser.ts +403 -34
- 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
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* Follows the same pattern as quality-handlers.ts and git-handlers.ts.
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { join, resolve } from 'node:path';
|
|
10
|
+
import { basename, join, resolve } from 'node:path';
|
|
11
11
|
import { handlePlanPrompt } from '../plan/composer.js';
|
|
12
12
|
import { PlanExecutor } from '../plan/executor.js';
|
|
13
|
-
import {
|
|
13
|
+
import { replaceFrontMatterField } from '../plan/front-matter.js';
|
|
14
|
+
import { defaultPmDir, getNextBoardId, getNextBoardNumber, getNextId, getNextSprintId, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, parseSprintArtifacts, planDirExists, resolvePmDir } from '../plan/parser.js';
|
|
14
15
|
import { PlanWatcher } from '../plan/watcher.js';
|
|
15
16
|
const watcherCache = new Map();
|
|
16
17
|
const executorCache = new Map();
|
|
@@ -189,11 +190,25 @@ export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, permission)
|
|
|
189
190
|
planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, permission),
|
|
190
191
|
planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, permission),
|
|
191
192
|
planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, permission),
|
|
192
|
-
planExecute: () => handleExecute(ctx, ws, workingDir, permission),
|
|
193
|
+
planExecute: () => handleExecute(ctx, ws, msg, workingDir, permission),
|
|
193
194
|
planExecuteEpic: () => handleExecuteEpic(ctx, ws, msg, workingDir, permission),
|
|
194
195
|
planPause: () => handlePause(ctx, ws, workingDir, permission),
|
|
195
196
|
planStop: () => handleStop(ctx, ws, workingDir, permission),
|
|
196
197
|
planResume: () => handleResume(ctx, ws, workingDir, permission),
|
|
198
|
+
// Board lifecycle
|
|
199
|
+
planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
|
|
200
|
+
planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
|
|
201
|
+
planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
|
|
202
|
+
planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
|
|
203
|
+
planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
|
|
204
|
+
planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
|
|
205
|
+
planSetActiveBoard: () => handleSetActiveBoard(ctx, ws, msg, workingDir, permission),
|
|
206
|
+
planGetBoardArtifacts: () => handleGetBoardArtifacts(ctx, ws, msg, workingDir),
|
|
207
|
+
// Sprint lifecycle (legacy)
|
|
208
|
+
planCreateSprint: () => handleCreateSprint(ctx, ws, msg, workingDir, permission),
|
|
209
|
+
planActivateSprint: () => handleActivateSprint(ctx, ws, msg, workingDir, permission),
|
|
210
|
+
planCompleteSprint: () => handleCompleteSprint(ctx, ws, msg, workingDir, permission),
|
|
211
|
+
planGetSprintArtifacts: () => handleGetSprintArtifacts(ctx, ws, msg, workingDir),
|
|
197
212
|
};
|
|
198
213
|
const handler = handlers[msg.type];
|
|
199
214
|
if (!handler)
|
|
@@ -209,10 +224,44 @@ export function handlePlanMessage(ctx, ws, msg, _tabId, workingDir, permission)
|
|
|
209
224
|
// ============================================================================
|
|
210
225
|
// Read-only handlers
|
|
211
226
|
// ============================================================================
|
|
227
|
+
/** Create the .mstro/pm/ directory structure with a default board. */
|
|
228
|
+
function scaffoldPmDirectory(workingDir, name) {
|
|
229
|
+
const planDir = defaultPmDir(workingDir);
|
|
230
|
+
const boardId = 'BOARD-001';
|
|
231
|
+
const boardDir = join(planDir, 'boards', boardId);
|
|
232
|
+
for (const dir of ['milestones', 'templates']) {
|
|
233
|
+
mkdirSync(join(planDir, dir), { recursive: true });
|
|
234
|
+
}
|
|
235
|
+
for (const dir of ['backlog', 'out', 'reviews']) {
|
|
236
|
+
mkdirSync(join(boardDir, dir), { recursive: true });
|
|
237
|
+
}
|
|
238
|
+
writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
|
|
239
|
+
const workspace = { activeBoardId: boardId, boardOrder: [boardId] };
|
|
240
|
+
writeFileSync(join(planDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
|
|
241
|
+
const today = new Date().toISOString().split('T')[0];
|
|
242
|
+
writeFileSync(join(boardDir, 'board.md'), `---
|
|
243
|
+
id: ${boardId}
|
|
244
|
+
title: "Board 1"
|
|
245
|
+
status: draft
|
|
246
|
+
created: "${today}"
|
|
247
|
+
completed_at: null
|
|
248
|
+
goal: ""
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
# Board 1
|
|
252
|
+
|
|
253
|
+
## Goal
|
|
254
|
+
|
|
255
|
+
## Notes
|
|
256
|
+
`, 'utf-8');
|
|
257
|
+
writeFileSync(join(boardDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
|
|
258
|
+
writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
|
|
259
|
+
}
|
|
212
260
|
function handlePlanInit(ctx, ws, workingDir) {
|
|
261
|
+
// Auto-scaffold if .mstro/pm/ doesn't exist
|
|
213
262
|
if (!planDirExists(workingDir)) {
|
|
214
|
-
|
|
215
|
-
|
|
263
|
+
const projectName = basename(workingDir) || 'My Project';
|
|
264
|
+
scaffoldPmDirectory(workingDir, projectName);
|
|
216
265
|
}
|
|
217
266
|
const fullState = parsePlanDirectory(workingDir);
|
|
218
267
|
if (!fullState) {
|
|
@@ -273,26 +322,41 @@ function handleGetMilestone(ctx, ws, msg, workingDir) {
|
|
|
273
322
|
// ============================================================================
|
|
274
323
|
// Mutation handlers
|
|
275
324
|
// ============================================================================
|
|
325
|
+
/** Resolve backlog directory and existing issues for a board or legacy layout. */
|
|
326
|
+
function resolveBacklogContext(pmDir, workingDir, boardId) {
|
|
327
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
328
|
+
const effectiveBoardId = boardId || fullState?.workspace?.activeBoardId;
|
|
329
|
+
if (effectiveBoardId && existsSync(join(pmDir, 'boards', effectiveBoardId))) {
|
|
330
|
+
const boardState = parseBoardDirectory(pmDir, effectiveBoardId);
|
|
331
|
+
return {
|
|
332
|
+
backlogDir: join(pmDir, 'boards', effectiveBoardId, 'backlog'),
|
|
333
|
+
issues: boardState?.issues ?? [],
|
|
334
|
+
pathPrefix: `boards/${effectiveBoardId}/backlog`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
backlogDir: join(pmDir, 'backlog'),
|
|
339
|
+
issues: fullState?.issues ?? [],
|
|
340
|
+
pathPrefix: 'backlog',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
276
343
|
function handleCreateIssue(ctx, ws, msg, workingDir, permission) {
|
|
277
344
|
if (denyIfViewOnly(ctx, ws, permission))
|
|
278
345
|
return;
|
|
279
|
-
const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '' } = msg.data || {};
|
|
346
|
+
const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '', boardId } = msg.data || {};
|
|
280
347
|
if (!title) {
|
|
281
348
|
ctx.send(ws, { type: 'planError', data: { error: 'Title required' } });
|
|
282
349
|
return;
|
|
283
350
|
}
|
|
284
|
-
const pmDir = resolvePmDir(workingDir) ??
|
|
285
|
-
const backlogDir =
|
|
286
|
-
if (!existsSync(backlogDir))
|
|
351
|
+
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
352
|
+
const { backlogDir, issues, pathPrefix } = resolveBacklogContext(pmDir, workingDir, boardId);
|
|
353
|
+
if (!existsSync(backlogDir))
|
|
287
354
|
mkdirSync(backlogDir, { recursive: true });
|
|
288
|
-
}
|
|
289
|
-
const fullState = parsePlanDirectory(workingDir);
|
|
290
355
|
const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
|
|
291
|
-
const id =
|
|
292
|
-
const content = buildIssueMarkdown(id, title, type, priority, labels, sprint, description);
|
|
356
|
+
const id = getNextId(issues, prefix);
|
|
293
357
|
const fileName = `${id}.md`;
|
|
294
|
-
writeFileSync(join(backlogDir, fileName),
|
|
295
|
-
const issue = parseSingleIssue(workingDir,
|
|
358
|
+
writeFileSync(join(backlogDir, fileName), buildIssueMarkdown(id, title, type, priority, labels, sprint, description), 'utf-8');
|
|
359
|
+
const issue = parseSingleIssue(workingDir, `${pathPrefix}/${fileName}`);
|
|
296
360
|
ctx.broadcastToAll({ type: 'planIssueCreated', data: issue });
|
|
297
361
|
}
|
|
298
362
|
function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
|
|
@@ -308,26 +372,16 @@ function handleUpdateIssue(ctx, ws, msg, workingDir, permission) {
|
|
|
308
372
|
ctx.send(ws, { type: 'planError', data: { error: `File not found: ${path}` } });
|
|
309
373
|
return;
|
|
310
374
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (!match) {
|
|
375
|
+
let content = readFileSync(fullPath, 'utf-8');
|
|
376
|
+
if (!content.match(/^---\n/)) {
|
|
314
377
|
ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
|
|
315
378
|
return;
|
|
316
379
|
}
|
|
317
|
-
let yamlStr = match[1];
|
|
318
|
-
const body = match[2];
|
|
319
380
|
for (const [key, value] of Object.entries(fields)) {
|
|
320
381
|
const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
321
|
-
|
|
322
|
-
const regex = new RegExp(`^${yamlKey}:.*$`, 'm');
|
|
323
|
-
if (regex.test(yamlStr)) {
|
|
324
|
-
yamlStr = yamlStr.replace(regex, `${yamlKey}: ${yamlValue}`);
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
yamlStr += `\n${yamlKey}: ${yamlValue}`;
|
|
328
|
-
}
|
|
382
|
+
content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
|
|
329
383
|
}
|
|
330
|
-
writeFileSync(fullPath,
|
|
384
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
331
385
|
const issue = parseSingleIssue(workingDir, path);
|
|
332
386
|
ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
|
|
333
387
|
}
|
|
@@ -351,13 +405,7 @@ function handleScaffold(ctx, ws, msg, workingDir, permission) {
|
|
|
351
405
|
if (denyIfViewOnly(ctx, ws, permission))
|
|
352
406
|
return;
|
|
353
407
|
const name = msg.data?.name || 'My Project';
|
|
354
|
-
|
|
355
|
-
for (const dir of ['backlog', 'sprints', 'milestones', 'docs', 'docs/decisions']) {
|
|
356
|
-
mkdirSync(join(planDir, dir), { recursive: true });
|
|
357
|
-
}
|
|
358
|
-
writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
|
|
359
|
-
writeFileSync(join(planDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
|
|
360
|
-
writeFileSync(join(planDir, 'progress.md'), '# Progress Log\n', 'utf-8');
|
|
408
|
+
scaffoldPmDirectory(workingDir, name);
|
|
361
409
|
const fullState = parsePlanDirectory(workingDir);
|
|
362
410
|
ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
|
|
363
411
|
}
|
|
@@ -368,11 +416,12 @@ function handlePrompt(ctx, ws, msg, workingDir, permission) {
|
|
|
368
416
|
if (denyIfViewOnly(ctx, ws, permission))
|
|
369
417
|
return;
|
|
370
418
|
const prompt = msg.data?.prompt;
|
|
419
|
+
const boardId = msg.data?.boardId;
|
|
371
420
|
if (!prompt) {
|
|
372
421
|
ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
|
|
373
422
|
return;
|
|
374
423
|
}
|
|
375
|
-
handlePlanPrompt(ctx, ws, prompt, workingDir).catch(error => {
|
|
424
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
|
|
376
425
|
ctx.send(ws, {
|
|
377
426
|
type: 'planError',
|
|
378
427
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -418,6 +467,9 @@ function wireExecutorEvents(executor, ctx, workingDir) {
|
|
|
418
467
|
ctx.broadcastToAll({ type: 'planStateUpdated', data: fullState });
|
|
419
468
|
}
|
|
420
469
|
});
|
|
470
|
+
executor.on('reviewProgress', (data) => {
|
|
471
|
+
ctx.broadcastToAll({ type: 'planReviewProgress', data });
|
|
472
|
+
});
|
|
421
473
|
executor.on('complete', (reason) => {
|
|
422
474
|
ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
|
|
423
475
|
});
|
|
@@ -425,7 +477,7 @@ function wireExecutorEvents(executor, ctx, workingDir) {
|
|
|
425
477
|
ctx.broadcastToAll({ type: 'planExecutionError', data: { error } });
|
|
426
478
|
});
|
|
427
479
|
}
|
|
428
|
-
function handleExecute(ctx, ws, workingDir, permission) {
|
|
480
|
+
function handleExecute(ctx, ws, msg, workingDir, permission) {
|
|
429
481
|
if (denyIfViewOnly(ctx, ws, permission))
|
|
430
482
|
return;
|
|
431
483
|
const executor = getExecutor(workingDir);
|
|
@@ -434,8 +486,11 @@ function handleExecute(ctx, ws, workingDir, permission) {
|
|
|
434
486
|
return;
|
|
435
487
|
}
|
|
436
488
|
wireExecutorEvents(executor, ctx, workingDir);
|
|
437
|
-
|
|
438
|
-
|
|
489
|
+
// Execute the board the user is looking at, falling back to workspace.json activeBoardId
|
|
490
|
+
const boardId = msg.data?.boardId;
|
|
491
|
+
ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
|
|
492
|
+
const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
|
|
493
|
+
startPromise.catch(error => {
|
|
439
494
|
ctx.send(ws, {
|
|
440
495
|
type: 'planExecutionError',
|
|
441
496
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -491,4 +546,427 @@ function handleResume(ctx, ws, workingDir, permission) {
|
|
|
491
546
|
});
|
|
492
547
|
}
|
|
493
548
|
}
|
|
549
|
+
// ============================================================================
|
|
550
|
+
// Board lifecycle handlers
|
|
551
|
+
// ============================================================================
|
|
552
|
+
function handleCreateBoard(ctx, ws, msg, workingDir, permission) {
|
|
553
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
554
|
+
return;
|
|
555
|
+
const pmDir = resolvePmDir(workingDir);
|
|
556
|
+
if (!pmDir) {
|
|
557
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found' } });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
561
|
+
if (!fullState)
|
|
562
|
+
return;
|
|
563
|
+
const boardId = getNextBoardId(fullState.boards);
|
|
564
|
+
const boardNum = getNextBoardNumber(fullState.boards);
|
|
565
|
+
const title = msg.data?.title || `Board ${boardNum}`;
|
|
566
|
+
const goal = msg.data?.goal || '';
|
|
567
|
+
const boardDir = join(pmDir, 'boards', boardId);
|
|
568
|
+
// Create directory structure
|
|
569
|
+
for (const dir of ['backlog', 'out', 'reviews']) {
|
|
570
|
+
mkdirSync(join(boardDir, dir), { recursive: true });
|
|
571
|
+
}
|
|
572
|
+
// Create board.md
|
|
573
|
+
const today = new Date().toISOString().split('T')[0];
|
|
574
|
+
writeFileSync(join(boardDir, 'board.md'), `---
|
|
575
|
+
id: ${boardId}
|
|
576
|
+
title: "${title.replace(/"/g, '\\"')}"
|
|
577
|
+
status: draft
|
|
578
|
+
created: "${today}"
|
|
579
|
+
completed_at: null
|
|
580
|
+
goal: "${goal.replace(/"/g, '\\"')}"
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
# ${title}
|
|
584
|
+
|
|
585
|
+
## Goal
|
|
586
|
+
${goal}
|
|
587
|
+
|
|
588
|
+
## Notes
|
|
589
|
+
`, 'utf-8');
|
|
590
|
+
// Create STATE.md
|
|
591
|
+
writeFileSync(join(boardDir, 'STATE.md'), `---
|
|
592
|
+
project: ../../project.md
|
|
593
|
+
board: board.md
|
|
594
|
+
paused: false
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
# Board State
|
|
598
|
+
|
|
599
|
+
## Ready to Work
|
|
600
|
+
|
|
601
|
+
## In Progress
|
|
602
|
+
|
|
603
|
+
## Blocked
|
|
604
|
+
|
|
605
|
+
## Recently Completed
|
|
606
|
+
|
|
607
|
+
## Warnings
|
|
608
|
+
`, 'utf-8');
|
|
609
|
+
// Create progress.md
|
|
610
|
+
writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
|
|
611
|
+
// Update workspace.json
|
|
612
|
+
const wsPath = join(pmDir, 'workspace.json');
|
|
613
|
+
if (!existsSync(wsPath)) {
|
|
614
|
+
writeFileSync(wsPath, JSON.stringify({ activeBoardId: null, boardOrder: [] }, null, 2), 'utf-8');
|
|
615
|
+
}
|
|
616
|
+
const workspaceContent = readFileSync(wsPath, 'utf-8');
|
|
617
|
+
const workspace = JSON.parse(workspaceContent);
|
|
618
|
+
workspace.boardOrder.push(boardId);
|
|
619
|
+
if (!workspace.activeBoardId) {
|
|
620
|
+
workspace.activeBoardId = boardId;
|
|
621
|
+
}
|
|
622
|
+
writeFileSync(join(pmDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
|
|
623
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
624
|
+
if (boardState) {
|
|
625
|
+
ctx.broadcastToAll({ type: 'planBoardCreated', data: boardState.board });
|
|
626
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function handleUpdateBoard(ctx, ws, msg, workingDir, permission) {
|
|
630
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
631
|
+
return;
|
|
632
|
+
const { boardId, fields } = msg.data || {};
|
|
633
|
+
if (!boardId || !fields) {
|
|
634
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID and fields required' } });
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const pmDir = resolvePmDir(workingDir);
|
|
638
|
+
if (!pmDir)
|
|
639
|
+
return;
|
|
640
|
+
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
641
|
+
if (!existsSync(boardMdPath)) {
|
|
642
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
let content = readFileSync(boardMdPath, 'utf-8');
|
|
646
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
647
|
+
const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
648
|
+
content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
|
|
649
|
+
}
|
|
650
|
+
writeFileSync(boardMdPath, content, 'utf-8');
|
|
651
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
652
|
+
if (boardState) {
|
|
653
|
+
ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function handleArchiveBoard(ctx, ws, msg, workingDir, permission) {
|
|
657
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
658
|
+
return;
|
|
659
|
+
const boardId = msg.data?.boardId;
|
|
660
|
+
if (!boardId) {
|
|
661
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const pmDir = resolvePmDir(workingDir);
|
|
665
|
+
if (!pmDir)
|
|
666
|
+
return;
|
|
667
|
+
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
668
|
+
if (!existsSync(boardMdPath)) {
|
|
669
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
// Set status to archived
|
|
673
|
+
let content = readFileSync(boardMdPath, 'utf-8');
|
|
674
|
+
content = replaceFrontMatterField(content, 'status', 'archived');
|
|
675
|
+
writeFileSync(boardMdPath, content, 'utf-8');
|
|
676
|
+
// Remove from workspace.json boardOrder and update activeBoardId if needed
|
|
677
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
678
|
+
if (existsSync(workspacePath)) {
|
|
679
|
+
const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
680
|
+
workspace.boardOrder = workspace.boardOrder.filter(id => id !== boardId);
|
|
681
|
+
if (workspace.activeBoardId === boardId) {
|
|
682
|
+
workspace.activeBoardId = workspace.boardOrder[0] || null;
|
|
683
|
+
}
|
|
684
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
685
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
686
|
+
}
|
|
687
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
688
|
+
if (boardState) {
|
|
689
|
+
ctx.broadcastToAll({ type: 'planBoardArchived', data: boardState.board });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function handleGetBoard(ctx, ws, msg, workingDir) {
|
|
693
|
+
const boardId = msg.data?.boardId;
|
|
694
|
+
if (!boardId) {
|
|
695
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const pmDir = resolvePmDir(workingDir);
|
|
699
|
+
if (!pmDir)
|
|
700
|
+
return;
|
|
701
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
702
|
+
if (!boardState) {
|
|
703
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
ctx.send(ws, { type: 'planBoardState', data: boardState });
|
|
707
|
+
}
|
|
708
|
+
function handleGetBoardState(ctx, ws, msg, workingDir) {
|
|
709
|
+
handleGetBoard(ctx, ws, msg, workingDir);
|
|
710
|
+
}
|
|
711
|
+
function handleReorderBoards(ctx, ws, msg, workingDir, permission) {
|
|
712
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
713
|
+
return;
|
|
714
|
+
const boardOrder = msg.data?.boardOrder;
|
|
715
|
+
if (!Array.isArray(boardOrder)) {
|
|
716
|
+
ctx.send(ws, { type: 'planError', data: { error: 'boardOrder array required' } });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const pmDir = resolvePmDir(workingDir);
|
|
720
|
+
if (!pmDir)
|
|
721
|
+
return;
|
|
722
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
723
|
+
if (!existsSync(workspacePath))
|
|
724
|
+
return;
|
|
725
|
+
const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
726
|
+
workspace.boardOrder = boardOrder;
|
|
727
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
728
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
729
|
+
}
|
|
730
|
+
function handleSetActiveBoard(ctx, ws, msg, workingDir, permission) {
|
|
731
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
732
|
+
return;
|
|
733
|
+
const boardId = msg.data?.boardId;
|
|
734
|
+
if (!boardId) {
|
|
735
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const pmDir = resolvePmDir(workingDir);
|
|
739
|
+
if (!pmDir)
|
|
740
|
+
return;
|
|
741
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
742
|
+
if (!existsSync(workspacePath))
|
|
743
|
+
return;
|
|
744
|
+
const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
745
|
+
workspace.activeBoardId = boardId;
|
|
746
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
747
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
748
|
+
// Also send the active board's full state
|
|
749
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
750
|
+
if (boardState) {
|
|
751
|
+
ctx.send(ws, { type: 'planBoardState', data: boardState });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function handleGetBoardArtifacts(ctx, ws, msg, workingDir) {
|
|
755
|
+
const boardId = msg.data?.boardId;
|
|
756
|
+
if (!boardId) {
|
|
757
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const artifacts = parseBoardArtifacts(workingDir, boardId);
|
|
761
|
+
if (!artifacts) {
|
|
762
|
+
ctx.send(ws, { type: 'planBoardArtifacts', data: { boardId, progressLog: '', outputFiles: [], reviewResults: [] } });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
ctx.send(ws, { type: 'planBoardArtifacts', data: artifacts });
|
|
766
|
+
}
|
|
767
|
+
// ============================================================================
|
|
768
|
+
// Sprint lifecycle handlers (legacy — kept for backward compatibility)
|
|
769
|
+
// ============================================================================
|
|
770
|
+
function buildSprintMarkdown(id, title, goal, start, end, issueRefs) {
|
|
771
|
+
const issuesYaml = issueRefs.length > 0
|
|
772
|
+
? `\n${issueRefs.map(p => ` - ${p}`).join('\n')}`
|
|
773
|
+
: ' []';
|
|
774
|
+
return `---
|
|
775
|
+
id: ${id}
|
|
776
|
+
title: "${title.replace(/"/g, '\\"')}"
|
|
777
|
+
status: planned
|
|
778
|
+
start: "${start}"
|
|
779
|
+
end: "${end}"
|
|
780
|
+
goal: "${goal.replace(/"/g, '\\"')}"
|
|
781
|
+
capacity: null
|
|
782
|
+
committed: null
|
|
783
|
+
completed: null
|
|
784
|
+
completed_at: null
|
|
785
|
+
issues:${issuesYaml}
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
# ${id}: ${title}
|
|
789
|
+
|
|
790
|
+
## Sprint Goal
|
|
791
|
+
${goal}
|
|
792
|
+
|
|
793
|
+
## Issues
|
|
794
|
+
| Issue | Title | Points | Status |
|
|
795
|
+
|---|---|---|---|
|
|
796
|
+
`;
|
|
797
|
+
}
|
|
798
|
+
/** Assign issues to a sprint by updating their front matter sprint field. */
|
|
799
|
+
function assignIssuesToSprint(workingDir, issues, issueIds, sprintPath) {
|
|
800
|
+
for (const issueId of issueIds) {
|
|
801
|
+
const issue = issues.find(i => i.id === issueId);
|
|
802
|
+
if (!issue)
|
|
803
|
+
continue;
|
|
804
|
+
const fullPath = resolvePlanPath(workingDir, issue.path);
|
|
805
|
+
if (!fullPath || !existsSync(fullPath))
|
|
806
|
+
continue;
|
|
807
|
+
const content = replaceFrontMatterField(readFileSync(fullPath, 'utf-8'), 'sprint', sprintPath);
|
|
808
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function handleCreateSprint(ctx, ws, msg, workingDir, permission) {
|
|
812
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
813
|
+
return;
|
|
814
|
+
const { title, goal = '', start = '', end = '', issueIds = [] } = msg.data || {};
|
|
815
|
+
if (!title) {
|
|
816
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint title required' } });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
820
|
+
const sprintsDir = join(pmDir, 'sprints');
|
|
821
|
+
if (!existsSync(sprintsDir))
|
|
822
|
+
mkdirSync(sprintsDir, { recursive: true });
|
|
823
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
824
|
+
const id = fullState ? getNextSprintId(fullState.sprints) : 'SPRINT-001';
|
|
825
|
+
const issueRefs = issueIds.map((issueId) => {
|
|
826
|
+
const issue = fullState?.issues.find(i => i.id === issueId);
|
|
827
|
+
return issue ? issue.path : `backlog/${issueId}.md`;
|
|
828
|
+
});
|
|
829
|
+
writeFileSync(join(sprintsDir, `${id}.md`), buildSprintMarkdown(id, title, goal, start, end, issueRefs), 'utf-8');
|
|
830
|
+
const sandboxDir = join(sprintsDir, id);
|
|
831
|
+
mkdirSync(join(sandboxDir, 'out'), { recursive: true });
|
|
832
|
+
mkdirSync(join(sandboxDir, 'reviews'), { recursive: true });
|
|
833
|
+
writeFileSync(join(sandboxDir, 'progress.md'), `# ${id}: ${title} — Progress Log\n`, 'utf-8');
|
|
834
|
+
if (issueRefs.length > 0 && fullState) {
|
|
835
|
+
assignIssuesToSprint(workingDir, fullState.issues, issueIds, `sprints/${id}.md`);
|
|
836
|
+
}
|
|
837
|
+
const sprint = parseSingleSprint(workingDir, `sprints/${id}.md`);
|
|
838
|
+
ctx.broadcastToAll({ type: 'planSprintCreated', data: sprint });
|
|
839
|
+
}
|
|
840
|
+
/** Promote sprint issues from 'backlog' to 'todo' status. */
|
|
841
|
+
function promoteSprintIssues(pmDir, sprint, allIssues) {
|
|
842
|
+
for (const issueSummary of sprint.issues) {
|
|
843
|
+
const issue = allIssues.find(i => i.id === issueSummary.id || i.path === issueSummary.path);
|
|
844
|
+
if (!issue || issue.status !== 'backlog')
|
|
845
|
+
continue;
|
|
846
|
+
const issuePath = join(pmDir, issue.path);
|
|
847
|
+
if (!existsSync(issuePath))
|
|
848
|
+
continue;
|
|
849
|
+
writeFileSync(issuePath, replaceFrontMatterField(readFileSync(issuePath, 'utf-8'), 'status', 'todo'), 'utf-8');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** Update a file's front matter field if the file exists. */
|
|
853
|
+
function updateFileField(filePath, field, value) {
|
|
854
|
+
if (!existsSync(filePath))
|
|
855
|
+
return;
|
|
856
|
+
writeFileSync(filePath, replaceFrontMatterField(readFileSync(filePath, 'utf-8'), field, value), 'utf-8');
|
|
857
|
+
}
|
|
858
|
+
function handleActivateSprint(ctx, ws, msg, workingDir, permission) {
|
|
859
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
860
|
+
return;
|
|
861
|
+
const sprintId = msg.data?.sprintId;
|
|
862
|
+
if (!sprintId) {
|
|
863
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
867
|
+
if (!fullState) {
|
|
868
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const currentActive = fullState.sprints.find(s => s.status === 'active');
|
|
872
|
+
if (currentActive && currentActive.id !== sprintId) {
|
|
873
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint ${currentActive.id} is already active. Complete it first.` } });
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const sprint = fullState.sprints.find(s => s.id === sprintId);
|
|
877
|
+
if (!sprint) {
|
|
878
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const pmDir = resolvePmDir(workingDir);
|
|
882
|
+
if (!pmDir)
|
|
883
|
+
return;
|
|
884
|
+
updateFileField(join(pmDir, sprint.path), 'status', 'active');
|
|
885
|
+
updateFileField(join(pmDir, 'STATE.md'), 'current_sprint', `"${sprint.path}"`);
|
|
886
|
+
promoteSprintIssues(pmDir, sprint, fullState.issues);
|
|
887
|
+
const updatedSprint = parseSingleSprint(workingDir, sprint.path);
|
|
888
|
+
ctx.broadcastToAll({ type: 'planSprintUpdated', data: updatedSprint });
|
|
889
|
+
const updatedState = parsePlanDirectory(workingDir);
|
|
890
|
+
if (updatedState) {
|
|
891
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function handleCompleteSprint(ctx, ws, msg, workingDir, permission) {
|
|
895
|
+
if (denyIfViewOnly(ctx, ws, permission))
|
|
896
|
+
return;
|
|
897
|
+
const sprintId = msg.data?.sprintId;
|
|
898
|
+
if (!sprintId) {
|
|
899
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
903
|
+
if (!fullState) {
|
|
904
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const sprint = fullState.sprints.find(s => s.id === sprintId);
|
|
908
|
+
if (!sprint) {
|
|
909
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const pmDir = resolvePmDir(workingDir);
|
|
913
|
+
if (!pmDir)
|
|
914
|
+
return;
|
|
915
|
+
const now = new Date().toISOString();
|
|
916
|
+
// Compute execution summary from sprint issues
|
|
917
|
+
const sprintIssues = fullState.issues.filter(i => i.sprint === sprint.path);
|
|
918
|
+
const completedIssues = sprintIssues.filter(i => i.status === 'done').length;
|
|
919
|
+
const failedIssues = sprintIssues.filter(i => i.status !== 'done' && i.status !== 'cancelled').length;
|
|
920
|
+
// Update sprint file with completion data
|
|
921
|
+
const sprintPath = join(pmDir, sprint.path);
|
|
922
|
+
if (existsSync(sprintPath)) {
|
|
923
|
+
let content = readFileSync(sprintPath, 'utf-8');
|
|
924
|
+
content = replaceFrontMatterField(content, 'status', 'completed');
|
|
925
|
+
content = replaceFrontMatterField(content, 'completed_at', `"${now}"`);
|
|
926
|
+
content = replaceFrontMatterField(content, 'completed', String(completedIssues));
|
|
927
|
+
// Write execution summary if not already present
|
|
928
|
+
if (!content.includes('execution_summary:')) {
|
|
929
|
+
const summaryYaml = [
|
|
930
|
+
'execution_summary:',
|
|
931
|
+
` total_issues: ${sprintIssues.length}`,
|
|
932
|
+
` completed_issues: ${completedIssues}`,
|
|
933
|
+
` failed_issues: ${failedIssues}`,
|
|
934
|
+
].join('\n');
|
|
935
|
+
// Insert before the closing --- of front matter (second occurrence)
|
|
936
|
+
const fmClose = content.indexOf('\n---', content.indexOf('---') + 3);
|
|
937
|
+
if (fmClose !== -1) {
|
|
938
|
+
content = `${content.slice(0, fmClose)}\n${summaryYaml}${content.slice(fmClose)}`;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
writeFileSync(sprintPath, content, 'utf-8');
|
|
942
|
+
}
|
|
943
|
+
// Clear STATE.md current_sprint
|
|
944
|
+
const statePath = join(pmDir, 'STATE.md');
|
|
945
|
+
if (existsSync(statePath)) {
|
|
946
|
+
let stateContent = readFileSync(statePath, 'utf-8');
|
|
947
|
+
stateContent = replaceFrontMatterField(stateContent, 'current_sprint', 'null');
|
|
948
|
+
writeFileSync(statePath, stateContent, 'utf-8');
|
|
949
|
+
}
|
|
950
|
+
const updatedSprint = parseSingleSprint(workingDir, sprint.path);
|
|
951
|
+
ctx.broadcastToAll({ type: 'planSprintCompleted', data: updatedSprint });
|
|
952
|
+
// Refresh full state
|
|
953
|
+
const updatedState = parsePlanDirectory(workingDir);
|
|
954
|
+
if (updatedState) {
|
|
955
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function handleGetSprintArtifacts(ctx, ws, msg, workingDir) {
|
|
959
|
+
const sprintId = msg.data?.sprintId;
|
|
960
|
+
if (!sprintId) {
|
|
961
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const artifacts = parseSprintArtifacts(workingDir, sprintId);
|
|
965
|
+
if (!artifacts) {
|
|
966
|
+
// Fall back to empty artifacts if sandbox dir doesn't exist yet
|
|
967
|
+
ctx.send(ws, { type: 'planSprintArtifacts', data: { sprintId, progressLog: '', outputFiles: [], reviewResults: [] } });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
ctx.send(ws, { type: 'planSprintArtifacts', data: artifacts });
|
|
971
|
+
}
|
|
494
972
|
//# sourceMappingURL=plan-handlers.js.map
|