mstro-app 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mstro.js +119 -40
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +3 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +4 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +116 -31
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/config-installer.d.ts +25 -0
- package/dist/server/services/plan/config-installer.d.ts.map +1 -0
- package/dist/server/services/plan/config-installer.js +182 -0
- package/dist/server/services/plan/config-installer.js.map +1 -0
- package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
- package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -1
- package/dist/server/services/plan/dependency-resolver.js +4 -1
- package/dist/server/services/plan/dependency-resolver.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +38 -74
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +271 -459
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/front-matter.d.ts +18 -0
- package/dist/server/services/plan/front-matter.d.ts.map +1 -0
- package/dist/server/services/plan/front-matter.js +44 -0
- package/dist/server/services/plan/front-matter.js.map +1 -0
- package/dist/server/services/plan/output-manager.d.ts +22 -0
- package/dist/server/services/plan/output-manager.d.ts.map +1 -0
- package/dist/server/services/plan/output-manager.js +97 -0
- package/dist/server/services/plan/output-manager.js.map +1 -0
- package/dist/server/services/plan/parser.d.ts +18 -2
- package/dist/server/services/plan/parser.d.ts.map +1 -1
- package/dist/server/services/plan/parser.js +359 -25
- package/dist/server/services/plan/parser.js.map +1 -1
- package/dist/server/services/plan/prompt-builder.d.ts +17 -0
- package/dist/server/services/plan/prompt-builder.d.ts.map +1 -0
- package/dist/server/services/plan/prompt-builder.js +137 -0
- package/dist/server/services/plan/prompt-builder.js.map +1 -0
- package/dist/server/services/plan/review-gate.d.ts +26 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -0
- package/dist/server/services/plan/review-gate.js +191 -0
- package/dist/server/services/plan/review-gate.js.map +1 -0
- package/dist/server/services/plan/state-reconciler.d.ts +1 -1
- package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
- package/dist/server/services/plan/state-reconciler.js +59 -7
- package/dist/server/services/plan/state-reconciler.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +66 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +11 -0
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +14 -0
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +518 -40
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +1 -2
- package/server/cli/headless/claude-invoker.ts +4 -0
- package/server/cli/headless/types.ts +4 -1
- package/server/services/plan/composer.ts +138 -34
- package/server/services/plan/config-installer.ts +187 -0
- package/server/services/plan/dependency-resolver.ts +4 -1
- package/server/services/plan/executor.ts +278 -487
- package/server/services/plan/front-matter.ts +48 -0
- package/server/services/plan/output-manager.ts +113 -0
- package/server/services/plan/parser.ts +389 -27
- package/server/services/plan/prompt-builder.ts +161 -0
- package/server/services/plan/review-gate.ts +210 -0
- package/server/services/plan/state-reconciler.ts +68 -7
- package/server/services/plan/types.ts +99 -1
- package/server/services/platform.ts +11 -0
- package/server/services/websocket/handler.ts +14 -0
- package/server/services/websocket/plan-handlers.ts +629 -44
- package/server/services/websocket/types.ts +29 -2
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
|
-
import { join, resolve } from 'node:path';
|
|
12
|
+
import { basename, join, resolve } from 'node:path';
|
|
13
13
|
import { handlePlanPrompt } from '../plan/composer.js';
|
|
14
14
|
import { PlanExecutor } from '../plan/executor.js';
|
|
15
|
-
import {
|
|
15
|
+
import { replaceFrontMatterField } from '../plan/front-matter.js';
|
|
16
|
+
import { defaultPmDir, getNextBoardId, getNextBoardNumber, getNextId, getNextSprintId, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, parseSprintArtifacts, planDirExists, resolvePmDir } from '../plan/parser.js';
|
|
17
|
+
import type { Issue, Workspace } from '../plan/types.js';
|
|
16
18
|
import { PlanWatcher } from '../plan/watcher.js';
|
|
17
19
|
import type { HandlerContext } from './handler-context.js';
|
|
18
20
|
import type { WebSocketMessage, WSContext } from './types.js';
|
|
@@ -210,11 +212,25 @@ export function handlePlanMessage(
|
|
|
210
212
|
planDeleteIssue: () => handleDeleteIssue(ctx, ws, msg, workingDir, permission),
|
|
211
213
|
planScaffold: () => handleScaffold(ctx, ws, msg, workingDir, permission),
|
|
212
214
|
planPrompt: () => handlePrompt(ctx, ws, msg, workingDir, permission),
|
|
213
|
-
planExecute: () => handleExecute(ctx, ws, workingDir, permission),
|
|
215
|
+
planExecute: () => handleExecute(ctx, ws, msg, workingDir, permission),
|
|
214
216
|
planExecuteEpic: () => handleExecuteEpic(ctx, ws, msg, workingDir, permission),
|
|
215
217
|
planPause: () => handlePause(ctx, ws, workingDir, permission),
|
|
216
218
|
planStop: () => handleStop(ctx, ws, workingDir, permission),
|
|
217
219
|
planResume: () => handleResume(ctx, ws, workingDir, permission),
|
|
220
|
+
// Board lifecycle
|
|
221
|
+
planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
|
|
222
|
+
planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
|
|
223
|
+
planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
|
|
224
|
+
planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
|
|
225
|
+
planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
|
|
226
|
+
planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
|
|
227
|
+
planSetActiveBoard: () => handleSetActiveBoard(ctx, ws, msg, workingDir, permission),
|
|
228
|
+
planGetBoardArtifacts: () => handleGetBoardArtifacts(ctx, ws, msg, workingDir),
|
|
229
|
+
// Sprint lifecycle (legacy)
|
|
230
|
+
planCreateSprint: () => handleCreateSprint(ctx, ws, msg, workingDir, permission),
|
|
231
|
+
planActivateSprint: () => handleActivateSprint(ctx, ws, msg, workingDir, permission),
|
|
232
|
+
planCompleteSprint: () => handleCompleteSprint(ctx, ws, msg, workingDir, permission),
|
|
233
|
+
planGetSprintArtifacts: () => handleGetSprintArtifacts(ctx, ws, msg, workingDir),
|
|
218
234
|
};
|
|
219
235
|
|
|
220
236
|
const handler = handlers[msg.type];
|
|
@@ -232,10 +248,50 @@ export function handlePlanMessage(
|
|
|
232
248
|
// Read-only handlers
|
|
233
249
|
// ============================================================================
|
|
234
250
|
|
|
251
|
+
/** Create the .mstro/pm/ directory structure with a default board. */
|
|
252
|
+
function scaffoldPmDirectory(workingDir: string, name: string): void {
|
|
253
|
+
const planDir = defaultPmDir(workingDir);
|
|
254
|
+
const boardId = 'BOARD-001';
|
|
255
|
+
const boardDir = join(planDir, 'boards', boardId);
|
|
256
|
+
|
|
257
|
+
for (const dir of ['milestones', 'templates']) {
|
|
258
|
+
mkdirSync(join(planDir, dir), { recursive: true });
|
|
259
|
+
}
|
|
260
|
+
for (const dir of ['backlog', 'out', 'reviews']) {
|
|
261
|
+
mkdirSync(join(boardDir, dir), { recursive: true });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
|
|
265
|
+
|
|
266
|
+
const workspace: Workspace = { activeBoardId: boardId, boardOrder: [boardId] };
|
|
267
|
+
writeFileSync(join(planDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
|
|
268
|
+
|
|
269
|
+
const today = new Date().toISOString().split('T')[0];
|
|
270
|
+
writeFileSync(join(boardDir, 'board.md'), `---
|
|
271
|
+
id: ${boardId}
|
|
272
|
+
title: "Board 1"
|
|
273
|
+
status: draft
|
|
274
|
+
created: "${today}"
|
|
275
|
+
completed_at: null
|
|
276
|
+
goal: ""
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
# Board 1
|
|
280
|
+
|
|
281
|
+
## Goal
|
|
282
|
+
|
|
283
|
+
## Notes
|
|
284
|
+
`, 'utf-8');
|
|
285
|
+
|
|
286
|
+
writeFileSync(join(boardDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
|
|
287
|
+
writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
|
|
288
|
+
}
|
|
289
|
+
|
|
235
290
|
function handlePlanInit(ctx: HandlerContext, ws: WSContext, workingDir: string): void {
|
|
291
|
+
// Auto-scaffold if .mstro/pm/ doesn't exist
|
|
236
292
|
if (!planDirExists(workingDir)) {
|
|
237
|
-
|
|
238
|
-
|
|
293
|
+
const projectName = basename(workingDir) || 'My Project';
|
|
294
|
+
scaffoldPmDirectory(workingDir, projectName);
|
|
239
295
|
}
|
|
240
296
|
|
|
241
297
|
const fullState = parsePlanDirectory(workingDir);
|
|
@@ -305,33 +361,50 @@ function handleGetMilestone(ctx: HandlerContext, ws: WSContext, msg: WebSocketMe
|
|
|
305
361
|
// Mutation handlers
|
|
306
362
|
// ============================================================================
|
|
307
363
|
|
|
364
|
+
/** Resolve backlog directory and existing issues for a board or legacy layout. */
|
|
365
|
+
function resolveBacklogContext(pmDir: string, workingDir: string, boardId?: string) {
|
|
366
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
367
|
+
const effectiveBoardId = boardId || fullState?.workspace?.activeBoardId;
|
|
368
|
+
|
|
369
|
+
if (effectiveBoardId && existsSync(join(pmDir, 'boards', effectiveBoardId))) {
|
|
370
|
+
const boardState = parseBoardDirectory(pmDir, effectiveBoardId);
|
|
371
|
+
return {
|
|
372
|
+
backlogDir: join(pmDir, 'boards', effectiveBoardId, 'backlog'),
|
|
373
|
+
issues: boardState?.issues ?? [],
|
|
374
|
+
pathPrefix: `boards/${effectiveBoardId}/backlog`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
backlogDir: join(pmDir, 'backlog'),
|
|
379
|
+
issues: fullState?.issues ?? [],
|
|
380
|
+
pathPrefix: 'backlog',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
308
384
|
function handleCreateIssue(
|
|
309
385
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
310
386
|
workingDir: string, permission?: 'control' | 'view',
|
|
311
387
|
): void {
|
|
312
388
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
313
389
|
|
|
314
|
-
const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '' } = msg.data || {};
|
|
390
|
+
const { title, type = 'issue', priority = 'P2', labels = [], sprint, description = '', boardId } = msg.data || {};
|
|
315
391
|
if (!title) {
|
|
316
392
|
ctx.send(ws, { type: 'planError', data: { error: 'Title required' } });
|
|
317
393
|
return;
|
|
318
394
|
}
|
|
319
395
|
|
|
320
|
-
const pmDir = resolvePmDir(workingDir) ??
|
|
321
|
-
const backlogDir =
|
|
322
|
-
if (!existsSync(backlogDir)) {
|
|
323
|
-
mkdirSync(backlogDir, { recursive: true });
|
|
324
|
-
}
|
|
396
|
+
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
397
|
+
const { backlogDir, issues, pathPrefix } = resolveBacklogContext(pmDir, workingDir, boardId);
|
|
325
398
|
|
|
326
|
-
|
|
327
|
-
const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
|
|
328
|
-
const id = fullState ? getNextId(fullState.issues, prefix) : `${prefix}-001`;
|
|
399
|
+
if (!existsSync(backlogDir)) mkdirSync(backlogDir, { recursive: true });
|
|
329
400
|
|
|
330
|
-
const
|
|
401
|
+
const prefix = type === 'bug' ? 'BG' : type === 'epic' ? 'EP' : 'IS';
|
|
402
|
+
const id = getNextId(issues, prefix);
|
|
331
403
|
const fileName = `${id}.md`;
|
|
332
|
-
writeFileSync(join(backlogDir, fileName), content, 'utf-8');
|
|
333
404
|
|
|
334
|
-
|
|
405
|
+
writeFileSync(join(backlogDir, fileName), buildIssueMarkdown(id, title, type, priority, labels, sprint, description), 'utf-8');
|
|
406
|
+
|
|
407
|
+
const issue = parseSingleIssue(workingDir, `${pathPrefix}/${fileName}`);
|
|
335
408
|
ctx.broadcastToAll({ type: 'planIssueCreated', data: issue });
|
|
336
409
|
}
|
|
337
410
|
|
|
@@ -353,28 +426,18 @@ function handleUpdateIssue(
|
|
|
353
426
|
return;
|
|
354
427
|
}
|
|
355
428
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (!match) {
|
|
429
|
+
let content = readFileSync(fullPath, 'utf-8');
|
|
430
|
+
if (!content.match(/^---\n/)) {
|
|
359
431
|
ctx.send(ws, { type: 'planError', data: { error: 'Invalid file format' } });
|
|
360
432
|
return;
|
|
361
433
|
}
|
|
362
434
|
|
|
363
|
-
let yamlStr = match[1];
|
|
364
|
-
const body = match[2];
|
|
365
|
-
|
|
366
435
|
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
367
436
|
const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
368
|
-
|
|
369
|
-
const regex = new RegExp(`^${yamlKey}:.*$`, 'm');
|
|
370
|
-
if (regex.test(yamlStr)) {
|
|
371
|
-
yamlStr = yamlStr.replace(regex, `${yamlKey}: ${yamlValue}`);
|
|
372
|
-
} else {
|
|
373
|
-
yamlStr += `\n${yamlKey}: ${yamlValue}`;
|
|
374
|
-
}
|
|
437
|
+
content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
|
|
375
438
|
}
|
|
376
439
|
|
|
377
|
-
writeFileSync(fullPath,
|
|
440
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
378
441
|
|
|
379
442
|
const issue = parseSingleIssue(workingDir, path);
|
|
380
443
|
ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
|
|
@@ -409,15 +472,7 @@ function handleScaffold(
|
|
|
409
472
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
410
473
|
|
|
411
474
|
const name = msg.data?.name || 'My Project';
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
for (const dir of ['backlog', 'sprints', 'milestones', 'docs', 'docs/decisions']) {
|
|
415
|
-
mkdirSync(join(planDir, dir), { recursive: true });
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
writeFileSync(join(planDir, 'project.md'), buildProjectMarkdown(name), 'utf-8');
|
|
419
|
-
writeFileSync(join(planDir, 'STATE.md'), buildStateMarkdown(name), 'utf-8');
|
|
420
|
-
writeFileSync(join(planDir, 'progress.md'), '# Progress Log\n', 'utf-8');
|
|
475
|
+
scaffoldPmDirectory(workingDir, name);
|
|
421
476
|
|
|
422
477
|
const fullState = parsePlanDirectory(workingDir);
|
|
423
478
|
ctx.broadcastToAll({ type: 'planScaffolded', data: fullState });
|
|
@@ -434,11 +489,12 @@ function handlePrompt(
|
|
|
434
489
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
435
490
|
|
|
436
491
|
const prompt = msg.data?.prompt;
|
|
492
|
+
const boardId = msg.data?.boardId as string | undefined;
|
|
437
493
|
if (!prompt) {
|
|
438
494
|
ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
|
|
439
495
|
return;
|
|
440
496
|
}
|
|
441
|
-
handlePlanPrompt(ctx, ws, prompt, workingDir).catch(error => {
|
|
497
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
|
|
442
498
|
ctx.send(ws, {
|
|
443
499
|
type: 'planError',
|
|
444
500
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -494,6 +550,10 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
|
|
|
494
550
|
}
|
|
495
551
|
});
|
|
496
552
|
|
|
553
|
+
executor.on('reviewProgress', (data: { issueId: string; status: string }) => {
|
|
554
|
+
ctx.broadcastToAll({ type: 'planReviewProgress', data });
|
|
555
|
+
});
|
|
556
|
+
|
|
497
557
|
executor.on('complete', (reason: string) => {
|
|
498
558
|
ctx.broadcastToAll({ type: 'planExecutionComplete', data: { reason, metrics: executor.getMetrics() } });
|
|
499
559
|
});
|
|
@@ -504,7 +564,7 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
|
|
|
504
564
|
}
|
|
505
565
|
|
|
506
566
|
function handleExecute(
|
|
507
|
-
ctx: HandlerContext, ws: WSContext,
|
|
567
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
508
568
|
workingDir: string, permission?: 'control' | 'view',
|
|
509
569
|
): void {
|
|
510
570
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
@@ -518,8 +578,11 @@ function handleExecute(
|
|
|
518
578
|
|
|
519
579
|
wireExecutorEvents(executor, ctx, workingDir);
|
|
520
580
|
|
|
521
|
-
|
|
522
|
-
|
|
581
|
+
// Execute the board the user is looking at, falling back to workspace.json activeBoardId
|
|
582
|
+
const boardId = msg.data?.boardId as string | undefined;
|
|
583
|
+
ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
|
|
584
|
+
const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
|
|
585
|
+
startPromise.catch(error => {
|
|
523
586
|
ctx.send(ws, {
|
|
524
587
|
type: 'planExecutionError',
|
|
525
588
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -590,3 +653,525 @@ function handleResume(
|
|
|
590
653
|
});
|
|
591
654
|
}
|
|
592
655
|
}
|
|
656
|
+
|
|
657
|
+
// ============================================================================
|
|
658
|
+
// Board lifecycle handlers
|
|
659
|
+
// ============================================================================
|
|
660
|
+
|
|
661
|
+
function handleCreateBoard(
|
|
662
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
663
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
664
|
+
): void {
|
|
665
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
666
|
+
|
|
667
|
+
const pmDir = resolvePmDir(workingDir);
|
|
668
|
+
if (!pmDir) {
|
|
669
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found' } });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
674
|
+
if (!fullState) return;
|
|
675
|
+
|
|
676
|
+
const boardId = getNextBoardId(fullState.boards);
|
|
677
|
+
const boardNum = getNextBoardNumber(fullState.boards);
|
|
678
|
+
const title = msg.data?.title || `Board ${boardNum}`;
|
|
679
|
+
const goal = msg.data?.goal || '';
|
|
680
|
+
const boardDir = join(pmDir, 'boards', boardId);
|
|
681
|
+
|
|
682
|
+
// Create directory structure
|
|
683
|
+
for (const dir of ['backlog', 'out', 'reviews']) {
|
|
684
|
+
mkdirSync(join(boardDir, dir), { recursive: true });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Create board.md
|
|
688
|
+
const today = new Date().toISOString().split('T')[0];
|
|
689
|
+
writeFileSync(join(boardDir, 'board.md'), `---
|
|
690
|
+
id: ${boardId}
|
|
691
|
+
title: "${title.replace(/"/g, '\\"')}"
|
|
692
|
+
status: draft
|
|
693
|
+
created: "${today}"
|
|
694
|
+
completed_at: null
|
|
695
|
+
goal: "${goal.replace(/"/g, '\\"')}"
|
|
696
|
+
---
|
|
697
|
+
|
|
698
|
+
# ${title}
|
|
699
|
+
|
|
700
|
+
## Goal
|
|
701
|
+
${goal}
|
|
702
|
+
|
|
703
|
+
## Notes
|
|
704
|
+
`, 'utf-8');
|
|
705
|
+
|
|
706
|
+
// Create STATE.md
|
|
707
|
+
writeFileSync(join(boardDir, 'STATE.md'), `---
|
|
708
|
+
project: ../../project.md
|
|
709
|
+
board: board.md
|
|
710
|
+
paused: false
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
# Board State
|
|
714
|
+
|
|
715
|
+
## Ready to Work
|
|
716
|
+
|
|
717
|
+
## In Progress
|
|
718
|
+
|
|
719
|
+
## Blocked
|
|
720
|
+
|
|
721
|
+
## Recently Completed
|
|
722
|
+
|
|
723
|
+
## Warnings
|
|
724
|
+
`, 'utf-8');
|
|
725
|
+
|
|
726
|
+
// Create progress.md
|
|
727
|
+
writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
|
|
728
|
+
|
|
729
|
+
// Update workspace.json
|
|
730
|
+
const wsPath = join(pmDir, 'workspace.json');
|
|
731
|
+
if (!existsSync(wsPath)) {
|
|
732
|
+
writeFileSync(wsPath, JSON.stringify({ activeBoardId: null, boardOrder: [] }, null, 2), 'utf-8');
|
|
733
|
+
}
|
|
734
|
+
const workspaceContent = readFileSync(wsPath, 'utf-8');
|
|
735
|
+
const workspace: Workspace = JSON.parse(workspaceContent);
|
|
736
|
+
workspace.boardOrder.push(boardId);
|
|
737
|
+
if (!workspace.activeBoardId) {
|
|
738
|
+
workspace.activeBoardId = boardId;
|
|
739
|
+
}
|
|
740
|
+
writeFileSync(join(pmDir, 'workspace.json'), JSON.stringify(workspace, null, 2), 'utf-8');
|
|
741
|
+
|
|
742
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
743
|
+
if (boardState) {
|
|
744
|
+
ctx.broadcastToAll({ type: 'planBoardCreated', data: boardState.board });
|
|
745
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function handleUpdateBoard(
|
|
750
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
751
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
752
|
+
): void {
|
|
753
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
754
|
+
|
|
755
|
+
const { boardId, fields } = msg.data || {};
|
|
756
|
+
if (!boardId || !fields) {
|
|
757
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID and fields required' } });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const pmDir = resolvePmDir(workingDir);
|
|
762
|
+
if (!pmDir) return;
|
|
763
|
+
|
|
764
|
+
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
765
|
+
if (!existsSync(boardMdPath)) {
|
|
766
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let content = readFileSync(boardMdPath, 'utf-8');
|
|
771
|
+
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
772
|
+
const yamlKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
|
773
|
+
content = replaceFrontMatterField(content, yamlKey, formatYamlValue(value));
|
|
774
|
+
}
|
|
775
|
+
writeFileSync(boardMdPath, content, 'utf-8');
|
|
776
|
+
|
|
777
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
778
|
+
if (boardState) {
|
|
779
|
+
ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function handleArchiveBoard(
|
|
784
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
785
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
786
|
+
): void {
|
|
787
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
788
|
+
|
|
789
|
+
const boardId = msg.data?.boardId;
|
|
790
|
+
if (!boardId) {
|
|
791
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const pmDir = resolvePmDir(workingDir);
|
|
796
|
+
if (!pmDir) return;
|
|
797
|
+
|
|
798
|
+
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
799
|
+
if (!existsSync(boardMdPath)) {
|
|
800
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Set status to archived
|
|
805
|
+
let content = readFileSync(boardMdPath, 'utf-8');
|
|
806
|
+
content = replaceFrontMatterField(content, 'status', 'archived');
|
|
807
|
+
writeFileSync(boardMdPath, content, 'utf-8');
|
|
808
|
+
|
|
809
|
+
// Remove from workspace.json boardOrder and update activeBoardId if needed
|
|
810
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
811
|
+
if (existsSync(workspacePath)) {
|
|
812
|
+
const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
813
|
+
workspace.boardOrder = workspace.boardOrder.filter(id => id !== boardId);
|
|
814
|
+
if (workspace.activeBoardId === boardId) {
|
|
815
|
+
workspace.activeBoardId = workspace.boardOrder[0] || null;
|
|
816
|
+
}
|
|
817
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
818
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
822
|
+
if (boardState) {
|
|
823
|
+
ctx.broadcastToAll({ type: 'planBoardArchived', data: boardState.board });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function handleGetBoard(
|
|
828
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
829
|
+
workingDir: string,
|
|
830
|
+
): void {
|
|
831
|
+
const boardId = msg.data?.boardId;
|
|
832
|
+
if (!boardId) {
|
|
833
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const pmDir = resolvePmDir(workingDir);
|
|
838
|
+
if (!pmDir) return;
|
|
839
|
+
|
|
840
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
841
|
+
if (!boardState) {
|
|
842
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
ctx.send(ws, { type: 'planBoardState', data: boardState });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function handleGetBoardState(
|
|
850
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
851
|
+
workingDir: string,
|
|
852
|
+
): void {
|
|
853
|
+
handleGetBoard(ctx, ws, msg, workingDir);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function handleReorderBoards(
|
|
857
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
858
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
859
|
+
): void {
|
|
860
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
861
|
+
|
|
862
|
+
const boardOrder = msg.data?.boardOrder;
|
|
863
|
+
if (!Array.isArray(boardOrder)) {
|
|
864
|
+
ctx.send(ws, { type: 'planError', data: { error: 'boardOrder array required' } });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const pmDir = resolvePmDir(workingDir);
|
|
869
|
+
if (!pmDir) return;
|
|
870
|
+
|
|
871
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
872
|
+
if (!existsSync(workspacePath)) return;
|
|
873
|
+
|
|
874
|
+
const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
875
|
+
workspace.boardOrder = boardOrder;
|
|
876
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
877
|
+
|
|
878
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function handleSetActiveBoard(
|
|
882
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
883
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
884
|
+
): void {
|
|
885
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
886
|
+
|
|
887
|
+
const boardId = msg.data?.boardId;
|
|
888
|
+
if (!boardId) {
|
|
889
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const pmDir = resolvePmDir(workingDir);
|
|
894
|
+
if (!pmDir) return;
|
|
895
|
+
|
|
896
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
897
|
+
if (!existsSync(workspacePath)) return;
|
|
898
|
+
|
|
899
|
+
const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
900
|
+
workspace.activeBoardId = boardId;
|
|
901
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
902
|
+
|
|
903
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
904
|
+
|
|
905
|
+
// Also send the active board's full state
|
|
906
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
907
|
+
if (boardState) {
|
|
908
|
+
ctx.send(ws, { type: 'planBoardState', data: boardState });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function handleGetBoardArtifacts(
|
|
913
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
914
|
+
workingDir: string,
|
|
915
|
+
): void {
|
|
916
|
+
const boardId = msg.data?.boardId;
|
|
917
|
+
if (!boardId) {
|
|
918
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const artifacts = parseBoardArtifacts(workingDir, boardId);
|
|
923
|
+
if (!artifacts) {
|
|
924
|
+
ctx.send(ws, { type: 'planBoardArtifacts', data: { boardId, progressLog: '', outputFiles: [], reviewResults: [] } });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
ctx.send(ws, { type: 'planBoardArtifacts', data: artifacts });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ============================================================================
|
|
932
|
+
// Sprint lifecycle handlers (legacy — kept for backward compatibility)
|
|
933
|
+
// ============================================================================
|
|
934
|
+
|
|
935
|
+
function buildSprintMarkdown(
|
|
936
|
+
id: string, title: string, goal: string, start: string, end: string,
|
|
937
|
+
issueRefs: string[],
|
|
938
|
+
): string {
|
|
939
|
+
const issuesYaml = issueRefs.length > 0
|
|
940
|
+
? `\n${issueRefs.map(p => ` - ${p}`).join('\n')}`
|
|
941
|
+
: ' []';
|
|
942
|
+
return `---
|
|
943
|
+
id: ${id}
|
|
944
|
+
title: "${title.replace(/"/g, '\\"')}"
|
|
945
|
+
status: planned
|
|
946
|
+
start: "${start}"
|
|
947
|
+
end: "${end}"
|
|
948
|
+
goal: "${goal.replace(/"/g, '\\"')}"
|
|
949
|
+
capacity: null
|
|
950
|
+
committed: null
|
|
951
|
+
completed: null
|
|
952
|
+
completed_at: null
|
|
953
|
+
issues:${issuesYaml}
|
|
954
|
+
---
|
|
955
|
+
|
|
956
|
+
# ${id}: ${title}
|
|
957
|
+
|
|
958
|
+
## Sprint Goal
|
|
959
|
+
${goal}
|
|
960
|
+
|
|
961
|
+
## Issues
|
|
962
|
+
| Issue | Title | Points | Status |
|
|
963
|
+
|---|---|---|---|
|
|
964
|
+
`;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Assign issues to a sprint by updating their front matter sprint field. */
|
|
968
|
+
function assignIssuesToSprint(workingDir: string, issues: Issue[], issueIds: string[], sprintPath: string): void {
|
|
969
|
+
for (const issueId of issueIds) {
|
|
970
|
+
const issue = issues.find(i => i.id === issueId);
|
|
971
|
+
if (!issue) continue;
|
|
972
|
+
const fullPath = resolvePlanPath(workingDir, issue.path);
|
|
973
|
+
if (!fullPath || !existsSync(fullPath)) continue;
|
|
974
|
+
const content = replaceFrontMatterField(readFileSync(fullPath, 'utf-8'), 'sprint', sprintPath);
|
|
975
|
+
writeFileSync(fullPath, content, 'utf-8');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function handleCreateSprint(
|
|
980
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
981
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
982
|
+
): void {
|
|
983
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
984
|
+
|
|
985
|
+
const { title, goal = '', start = '', end = '', issueIds = [] } = msg.data || {};
|
|
986
|
+
if (!title) {
|
|
987
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint title required' } });
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
992
|
+
const sprintsDir = join(pmDir, 'sprints');
|
|
993
|
+
if (!existsSync(sprintsDir)) mkdirSync(sprintsDir, { recursive: true });
|
|
994
|
+
|
|
995
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
996
|
+
const id = fullState ? getNextSprintId(fullState.sprints) : 'SPRINT-001';
|
|
997
|
+
|
|
998
|
+
const issueRefs = (issueIds as string[]).map((issueId: string) => {
|
|
999
|
+
const issue = fullState?.issues.find(i => i.id === issueId);
|
|
1000
|
+
return issue ? issue.path : `backlog/${issueId}.md`;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
writeFileSync(join(sprintsDir, `${id}.md`), buildSprintMarkdown(id, title, goal, start, end, issueRefs), 'utf-8');
|
|
1004
|
+
|
|
1005
|
+
const sandboxDir = join(sprintsDir, id);
|
|
1006
|
+
mkdirSync(join(sandboxDir, 'out'), { recursive: true });
|
|
1007
|
+
mkdirSync(join(sandboxDir, 'reviews'), { recursive: true });
|
|
1008
|
+
writeFileSync(join(sandboxDir, 'progress.md'), `# ${id}: ${title} — Progress Log\n`, 'utf-8');
|
|
1009
|
+
|
|
1010
|
+
if (issueRefs.length > 0 && fullState) {
|
|
1011
|
+
assignIssuesToSprint(workingDir, fullState.issues, issueIds as string[], `sprints/${id}.md`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const sprint = parseSingleSprint(workingDir, `sprints/${id}.md`);
|
|
1015
|
+
ctx.broadcastToAll({ type: 'planSprintCreated', data: sprint });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Promote sprint issues from 'backlog' to 'todo' status. */
|
|
1019
|
+
function promoteSprintIssues(pmDir: string, sprint: { issues: Array<{ id: string; path: string }> }, allIssues: Issue[]): void {
|
|
1020
|
+
for (const issueSummary of sprint.issues) {
|
|
1021
|
+
const issue = allIssues.find(i => i.id === issueSummary.id || i.path === issueSummary.path);
|
|
1022
|
+
if (!issue || issue.status !== 'backlog') continue;
|
|
1023
|
+
const issuePath = join(pmDir, issue.path);
|
|
1024
|
+
if (!existsSync(issuePath)) continue;
|
|
1025
|
+
writeFileSync(issuePath, replaceFrontMatterField(readFileSync(issuePath, 'utf-8'), 'status', 'todo'), 'utf-8');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Update a file's front matter field if the file exists. */
|
|
1030
|
+
function updateFileField(filePath: string, field: string, value: string): void {
|
|
1031
|
+
if (!existsSync(filePath)) return;
|
|
1032
|
+
writeFileSync(filePath, replaceFrontMatterField(readFileSync(filePath, 'utf-8'), field, value), 'utf-8');
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function handleActivateSprint(
|
|
1036
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
1037
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
1038
|
+
): void {
|
|
1039
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
1040
|
+
|
|
1041
|
+
const sprintId = msg.data?.sprintId;
|
|
1042
|
+
if (!sprintId) {
|
|
1043
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
1048
|
+
if (!fullState) {
|
|
1049
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const currentActive = fullState.sprints.find(s => s.status === 'active');
|
|
1054
|
+
if (currentActive && currentActive.id !== sprintId) {
|
|
1055
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint ${currentActive.id} is already active. Complete it first.` } });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const sprint = fullState.sprints.find(s => s.id === sprintId);
|
|
1060
|
+
if (!sprint) {
|
|
1061
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const pmDir = resolvePmDir(workingDir);
|
|
1066
|
+
if (!pmDir) return;
|
|
1067
|
+
|
|
1068
|
+
updateFileField(join(pmDir, sprint.path), 'status', 'active');
|
|
1069
|
+
updateFileField(join(pmDir, 'STATE.md'), 'current_sprint', `"${sprint.path}"`);
|
|
1070
|
+
promoteSprintIssues(pmDir, sprint, fullState.issues);
|
|
1071
|
+
|
|
1072
|
+
const updatedSprint = parseSingleSprint(workingDir, sprint.path);
|
|
1073
|
+
ctx.broadcastToAll({ type: 'planSprintUpdated', data: updatedSprint });
|
|
1074
|
+
|
|
1075
|
+
const updatedState = parsePlanDirectory(workingDir);
|
|
1076
|
+
if (updatedState) {
|
|
1077
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function handleCompleteSprint(
|
|
1082
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
1083
|
+
workingDir: string, permission?: 'control' | 'view',
|
|
1084
|
+
): void {
|
|
1085
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
1086
|
+
|
|
1087
|
+
const sprintId = msg.data?.sprintId;
|
|
1088
|
+
if (!sprintId) {
|
|
1089
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const fullState = parsePlanDirectory(workingDir);
|
|
1094
|
+
if (!fullState) {
|
|
1095
|
+
ctx.send(ws, { type: 'planError', data: { error: 'No project found' } });
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const sprint = fullState.sprints.find(s => s.id === sprintId);
|
|
1100
|
+
if (!sprint) {
|
|
1101
|
+
ctx.send(ws, { type: 'planError', data: { error: `Sprint not found: ${sprintId}` } });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const pmDir = resolvePmDir(workingDir);
|
|
1106
|
+
if (!pmDir) return;
|
|
1107
|
+
|
|
1108
|
+
const now = new Date().toISOString();
|
|
1109
|
+
|
|
1110
|
+
// Compute execution summary from sprint issues
|
|
1111
|
+
const sprintIssues = fullState.issues.filter(i => i.sprint === sprint.path);
|
|
1112
|
+
const completedIssues = sprintIssues.filter(i => i.status === 'done').length;
|
|
1113
|
+
const failedIssues = sprintIssues.filter(i => i.status !== 'done' && i.status !== 'cancelled').length;
|
|
1114
|
+
|
|
1115
|
+
// Update sprint file with completion data
|
|
1116
|
+
const sprintPath = join(pmDir, sprint.path);
|
|
1117
|
+
if (existsSync(sprintPath)) {
|
|
1118
|
+
let content = readFileSync(sprintPath, 'utf-8');
|
|
1119
|
+
content = replaceFrontMatterField(content, 'status', 'completed');
|
|
1120
|
+
content = replaceFrontMatterField(content, 'completed_at', `"${now}"`);
|
|
1121
|
+
content = replaceFrontMatterField(content, 'completed', String(completedIssues));
|
|
1122
|
+
|
|
1123
|
+
// Write execution summary if not already present
|
|
1124
|
+
if (!content.includes('execution_summary:')) {
|
|
1125
|
+
const summaryYaml = [
|
|
1126
|
+
'execution_summary:',
|
|
1127
|
+
` total_issues: ${sprintIssues.length}`,
|
|
1128
|
+
` completed_issues: ${completedIssues}`,
|
|
1129
|
+
` failed_issues: ${failedIssues}`,
|
|
1130
|
+
].join('\n');
|
|
1131
|
+
// Insert before the closing --- of front matter (second occurrence)
|
|
1132
|
+
const fmClose = content.indexOf('\n---', content.indexOf('---') + 3);
|
|
1133
|
+
if (fmClose !== -1) {
|
|
1134
|
+
content = `${content.slice(0, fmClose)}\n${summaryYaml}${content.slice(fmClose)}`;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
writeFileSync(sprintPath, content, 'utf-8');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Clear STATE.md current_sprint
|
|
1142
|
+
const statePath = join(pmDir, 'STATE.md');
|
|
1143
|
+
if (existsSync(statePath)) {
|
|
1144
|
+
let stateContent = readFileSync(statePath, 'utf-8');
|
|
1145
|
+
stateContent = replaceFrontMatterField(stateContent, 'current_sprint', 'null');
|
|
1146
|
+
writeFileSync(statePath, stateContent, 'utf-8');
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const updatedSprint = parseSingleSprint(workingDir, sprint.path);
|
|
1150
|
+
ctx.broadcastToAll({ type: 'planSprintCompleted', data: updatedSprint });
|
|
1151
|
+
|
|
1152
|
+
// Refresh full state
|
|
1153
|
+
const updatedState = parsePlanDirectory(workingDir);
|
|
1154
|
+
if (updatedState) {
|
|
1155
|
+
ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function handleGetSprintArtifacts(
|
|
1160
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
1161
|
+
workingDir: string,
|
|
1162
|
+
): void {
|
|
1163
|
+
const sprintId = msg.data?.sprintId;
|
|
1164
|
+
if (!sprintId) {
|
|
1165
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Sprint ID required' } });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const artifacts = parseSprintArtifacts(workingDir, sprintId);
|
|
1170
|
+
if (!artifacts) {
|
|
1171
|
+
// Fall back to empty artifacts if sandbox dir doesn't exist yet
|
|
1172
|
+
ctx.send(ws, { type: 'planSprintArtifacts', data: { sprintId, progressLog: '', outputFiles: [], reviewResults: [] } });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
ctx.send(ws, { type: 'planSprintArtifacts', data: artifacts });
|
|
1177
|
+
}
|