mstro-app 0.4.34 → 0.4.37

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 (104) hide show
  1. package/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
  2. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stream.js +85 -10
  4. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +2 -0
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
  9. package/dist/server/cli/headless/haiku-assessments.js +10 -5
  10. package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
  11. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  12. package/dist/server/cli/improvisation-retry.js +17 -2
  13. package/dist/server/cli/improvisation-retry.js.map +1 -1
  14. package/dist/server/cli/improvisation-session-manager.d.ts +4 -0
  15. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-session-manager.js +61 -42
  17. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  18. package/dist/server/cli/improvisation-types.d.ts +1 -0
  19. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  20. package/dist/server/cli/improvisation-types.js.map +1 -1
  21. package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
  22. package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
  23. package/dist/server/services/websocket/git-head-watcher.js +136 -0
  24. package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
  25. package/dist/server/services/websocket/git-worktree-handlers.js +47 -6
  26. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  27. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  28. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  29. package/dist/server/services/websocket/handler.d.ts +3 -1
  30. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  31. package/dist/server/services/websocket/handler.js +18 -6
  32. package/dist/server/services/websocket/handler.js.map +1 -1
  33. package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
  34. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  35. package/dist/server/services/websocket/plan-board-handlers.js +94 -0
  36. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  37. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  38. package/dist/server/services/websocket/plan-handlers.js +3 -1
  39. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  40. package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
  41. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  42. package/dist/server/services/websocket/quality-persistence.js +15 -7
  43. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  44. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  45. package/dist/server/services/websocket/quality-review-agent.js +2 -13
  46. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  47. package/dist/server/services/websocket/quality-service.d.ts +12 -3
  48. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  49. package/dist/server/services/websocket/quality-service.js +101 -81
  50. package/dist/server/services/websocket/quality-service.js.map +1 -1
  51. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  52. package/dist/server/services/websocket/quality-tools.js +6 -1
  53. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  54. package/dist/server/services/websocket/quality-types.d.ts +15 -2
  55. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  56. package/dist/server/services/websocket/quality-types.js.map +1 -1
  57. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  58. package/dist/server/services/websocket/session-handlers.js +13 -3
  59. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  60. package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
  61. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  62. package/dist/server/services/websocket/skill-handlers.js +244 -3
  63. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  64. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  65. package/dist/server/services/websocket/tab-handlers.js +9 -2
  66. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  67. package/dist/server/services/websocket/types.d.ts +44 -3
  68. package/dist/server/services/websocket/types.d.ts.map +1 -1
  69. package/dist/server/services/websocket/types.js +38 -0
  70. package/dist/server/services/websocket/types.js.map +1 -1
  71. package/package.json +2 -1
  72. package/server/cli/headless/claude-invoker-stream.ts +163 -18
  73. package/server/cli/headless/claude-invoker.ts +2 -0
  74. package/server/cli/headless/haiku-assessments.ts +10 -5
  75. package/server/cli/improvisation-retry.ts +18 -2
  76. package/server/cli/improvisation-session-manager.ts +69 -45
  77. package/server/cli/improvisation-types.ts +1 -0
  78. package/server/services/plan/agents/assess-stall.md +21 -0
  79. package/server/services/plan/agents/check-injection.md +36 -0
  80. package/server/services/plan/agents/classify-error.md +29 -0
  81. package/server/services/plan/agents/detect-context-loss.md +29 -0
  82. package/server/services/plan/agents/execute-issue.md +42 -0
  83. package/server/services/plan/agents/plan-coordinator.md +71 -0
  84. package/server/services/plan/agents/retry-task.md +26 -0
  85. package/server/services/plan/agents/review-code.md +4 -1
  86. package/server/services/plan/agents/review-criteria.md +53 -0
  87. package/server/services/plan/agents/review-custom.md +4 -1
  88. package/server/services/plan/agents/review-quality.md +4 -1
  89. package/server/services/plan/agents/verify-review.md +56 -0
  90. package/server/services/websocket/git-head-watcher.ts +120 -0
  91. package/server/services/websocket/git-worktree-handlers.ts +57 -7
  92. package/server/services/websocket/handler-context.ts +2 -0
  93. package/server/services/websocket/handler.ts +19 -6
  94. package/server/services/websocket/plan-board-handlers.ts +116 -0
  95. package/server/services/websocket/plan-handlers.ts +3 -1
  96. package/server/services/websocket/quality-persistence.ts +23 -7
  97. package/server/services/websocket/quality-review-agent.ts +2 -12
  98. package/server/services/websocket/quality-service.ts +116 -99
  99. package/server/services/websocket/quality-tools.ts +6 -1
  100. package/server/services/websocket/quality-types.ts +17 -2
  101. package/server/services/websocket/session-handlers.ts +19 -3
  102. package/server/services/websocket/skill-handlers.ts +260 -3
  103. package/server/services/websocket/tab-handlers.ts +8 -2
  104. package/server/services/websocket/types.ts +123 -324
@@ -0,0 +1,120 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import { existsSync, type FSWatcher, watch } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { executeGitCommand } from './git-utils.js';
7
+ import type { HandlerContext } from './handler-context.js';
8
+
9
+ /**
10
+ * Watches .git/HEAD and .git/worktrees/ for branch changes and
11
+ * broadcasts updates to all connected web clients.
12
+ */
13
+ export class GitHeadWatcher {
14
+ private headWatcher: FSWatcher | null = null;
15
+ private worktreeWatcher: FSWatcher | null = null;
16
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
17
+ private lastKnownBranch = '';
18
+ private lastKnownWorktreeBranches = new Map<string, string>();
19
+ private started = false;
20
+
21
+ constructor(
22
+ private readonly workingDir: string,
23
+ private readonly ctx: HandlerContext,
24
+ ) {}
25
+
26
+ start(): void {
27
+ if (this.started) return;
28
+
29
+ const gitDir = join(this.workingDir, '.git');
30
+ if (!existsSync(gitDir)) return;
31
+
32
+ this.initLastKnownBranches();
33
+
34
+ try {
35
+ this.headWatcher = watch(join(gitDir, 'HEAD'), () => this.debounce());
36
+ } catch { /* not a git repo or permission issue */ }
37
+
38
+ this.startWorktreeWatcher();
39
+ this.started = true;
40
+ }
41
+
42
+ startWorktreeWatcher(): void {
43
+ if (this.worktreeWatcher) return;
44
+ try {
45
+ const worktreesDir = join(this.workingDir, '.git', 'worktrees');
46
+ if (!existsSync(worktreesDir)) return;
47
+ this.worktreeWatcher = watch(worktreesDir, { recursive: true }, (_event, filename) => {
48
+ if (filename?.endsWith('HEAD')) this.debounce();
49
+ });
50
+ } catch { /* recursive watch not supported or dir missing */ }
51
+ }
52
+
53
+ stop(): void {
54
+ if (this.headWatcher) { this.headWatcher.close(); this.headWatcher = null; }
55
+ if (this.worktreeWatcher) { this.worktreeWatcher.close(); this.worktreeWatcher = null; }
56
+ if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; }
57
+ this.started = false;
58
+ }
59
+
60
+ private async initLastKnownBranches(): Promise<void> {
61
+ try {
62
+ const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], this.workingDir);
63
+ if (result.exitCode === 0) this.lastKnownBranch = result.stdout.trim();
64
+ } catch { /* ignore */ }
65
+
66
+ for (const [tabId, wtPath] of this.ctx.gitDirectories) {
67
+ if (wtPath === this.workingDir) continue;
68
+ try {
69
+ const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], wtPath);
70
+ if (result.exitCode === 0) this.lastKnownWorktreeBranches.set(tabId, result.stdout.trim());
71
+ } catch { /* ignore */ }
72
+ }
73
+ }
74
+
75
+ private debounce(): void {
76
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
77
+ this.debounceTimer = setTimeout(() => { this.handleChange(); }, 300);
78
+ }
79
+
80
+ private async handleChange(): Promise<void> {
81
+ try {
82
+ await this.checkMainBranch();
83
+ await this.checkWorktreeBranches();
84
+ } catch { /* ignore errors from concurrent git operations */ }
85
+ }
86
+
87
+ private async checkMainBranch(): Promise<void> {
88
+ const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], this.workingDir);
89
+ if (result.exitCode !== 0) return;
90
+ const branch = result.stdout.trim();
91
+ if (!branch || branch === this.lastKnownBranch) return;
92
+ this.lastKnownBranch = branch;
93
+ this.ctx.broadcastToAll({
94
+ type: 'gitBranchChanged',
95
+ data: { directory: this.workingDir, branch },
96
+ });
97
+ }
98
+
99
+ private async checkWorktreeBranches(): Promise<void> {
100
+ for (const [tabId, wtPath] of this.ctx.gitDirectories) {
101
+ if (wtPath === this.workingDir) continue;
102
+ try {
103
+ const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], wtPath);
104
+ if (result.exitCode !== 0) continue;
105
+ const branch = result.stdout.trim();
106
+ if (!branch) continue;
107
+ const lastBranch = this.lastKnownWorktreeBranches.get(tabId);
108
+ if (branch === lastBranch) continue;
109
+ this.lastKnownWorktreeBranches.set(tabId, branch);
110
+ this.ctx.gitBranches.set(tabId, branch);
111
+ const registry = this.ctx.getRegistry(this.workingDir);
112
+ registry.updateTabWorktree(tabId, wtPath, branch);
113
+ this.ctx.broadcastToAll({
114
+ type: 'gitBranchChanged',
115
+ data: { directory: wtPath, branch },
116
+ });
117
+ } catch { /* ignore */ }
118
+ }
119
+ }
120
+ }
@@ -391,32 +391,66 @@ async function detectMergeConflicts(mainPath: string): Promise<string[]> {
391
391
  return result.stdout.trim().split('\n').filter(f => f.trim());
392
392
  }
393
393
 
394
+ async function removeWorktreeWithFallback(
395
+ mainPath: string,
396
+ worktreePath: string,
397
+ ): Promise<{ success: boolean; warning?: string }> {
398
+ const removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
399
+ if (removeResult.exitCode === 0) return { success: true };
400
+ const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
401
+ if (forceResult.exitCode === 0) return { success: true };
402
+ return { success: false, warning: `Failed to remove worktree: ${forceResult.stderr || 'unknown error'}` };
403
+ }
404
+
405
+ async function deleteBranchAfterMerge(
406
+ mainPath: string,
407
+ branchName: string,
408
+ strategy: string,
409
+ ): Promise<string | undefined> {
410
+ const deleteFlag = strategy === 'squash' ? '-D' : '-d';
411
+ const result = await executeGitCommand(['branch', deleteFlag, branchName], mainPath);
412
+ if (result.exitCode !== 0) {
413
+ return `Failed to delete branch: ${result.stderr || 'unknown error'}`;
414
+ }
415
+ return undefined;
416
+ }
417
+
394
418
  async function cleanupAfterMerge(
395
419
  mainPath: string,
396
420
  sourceBranch: string,
397
421
  strategy: string,
398
422
  deleteWorktree: boolean,
399
423
  deleteBranch: boolean,
400
- ): Promise<void> {
424
+ ): Promise<{ warnings: string[]; removedWorktreePath: string | null }> {
425
+ const warnings: string[] = [];
426
+ let removedWorktreePath: string | null = null;
427
+
401
428
  if (deleteWorktree) {
402
429
  const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
403
430
  const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
404
431
  if (worktreePath && worktreePath !== mainPath) {
405
- await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
432
+ const result = await removeWorktreeWithFallback(mainPath, worktreePath);
433
+ if (result.success) {
434
+ removedWorktreePath = worktreePath;
435
+ } else if (result.warning) {
436
+ warnings.push(result.warning);
437
+ }
406
438
  }
407
439
  }
408
440
  if (deleteBranch) {
409
- const deleteFlag = strategy === 'squash' ? '-D' : '-d';
410
- await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
441
+ const warning = await deleteBranchAfterMerge(mainPath, sourceBranch, strategy);
442
+ if (warning) warnings.push(warning);
411
443
  }
412
444
  await executeGitCommand(['worktree', 'prune'], mainPath);
445
+ return { warnings, removedWorktreePath };
413
446
  }
414
447
 
415
448
  function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
416
449
  let currentWtPath = '';
450
+ const fullRef = `refs/heads/${branchName}`;
417
451
  for (const line of porcelainOutput.split('\n')) {
418
452
  if (line.startsWith('worktree ')) currentWtPath = line.slice(9).trim();
419
- if (line.startsWith('branch ') && line.includes(branchName)) return currentWtPath;
453
+ if (line.startsWith('branch ') && line.slice(7).trim() === fullRef) return currentWtPath;
420
454
  }
421
455
  return null;
422
456
  }
@@ -437,6 +471,8 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
437
471
  return;
438
472
  }
439
473
 
474
+ const headBefore = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
475
+
440
476
  const mergeResult = await executeMergeStrategy(strategy, sourceBranch, commitMessage, mainPath);
441
477
  if (mergeResult.exitCode !== 0) {
442
478
  const conflictFiles = await detectMergeConflicts(mainPath);
@@ -447,10 +483,24 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
447
483
  return;
448
484
  }
449
485
 
486
+ const headAfter = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
487
+ if (headBefore.stdout.trim() === headAfter.stdout.trim()) {
488
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: false, error: `Already up to date — "${sourceBranch}" has no new commits to merge into "${targetBranch}"` } });
489
+ return;
490
+ }
491
+
450
492
  const commitHashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
451
- await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
493
+ const { warnings, removedWorktreePath } = await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
494
+
495
+ if (removedWorktreePath) {
496
+ cleanupWorktreeReferences(ctx, workingDir, removedWorktreePath);
497
+ }
452
498
 
453
- ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: true, mergeCommit: commitHashResult.stdout.trim() } });
499
+ const data: Record<string, unknown> = { success: true, mergeCommit: commitHashResult.stdout.trim() };
500
+ if (warnings.length > 0) {
501
+ data.warnings = warnings;
502
+ }
503
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data });
454
504
  } catch (error: unknown) {
455
505
  ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
456
506
  }
@@ -5,6 +5,7 @@ import type { ChildProcess } from 'node:child_process';
5
5
  import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
6
6
  import type { AutocompleteService } from './autocomplete.js';
7
7
  import type { FileUploadHandler } from './file-upload-handler.js';
8
+ import type { GitHeadWatcher } from './git-head-watcher.js';
8
9
  import type { SessionRegistry } from './session-registry.js';
9
10
  import type { WebSocketResponse, WSContext } from './types.js';
10
11
 
@@ -33,6 +34,7 @@ export interface HandlerContext {
33
34
  autocompleteService: AutocompleteService;
34
35
  usageReporter: UsageReporter | null;
35
36
  fileUploadHandler: FileUploadHandler | null;
37
+ gitHeadWatcher: GitHeadWatcher | null;
36
38
 
37
39
  // Registry access
38
40
  getRegistry(workingDir: string): SessionRegistry;
@@ -19,6 +19,7 @@ import { handleDeployMessage } from './deploy-handlers.js';
19
19
  import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
20
20
  import { FileUploadHandler } from './file-upload-handler.js';
21
21
  import { handleGitMessage } from './git-handlers.js';
22
+ import { GitHeadWatcher } from './git-head-watcher.js';
22
23
  import type { HandlerContext, UsageReporter } from './handler-context.js';
23
24
  import { handlePlanMessage } from './plan-handlers.js';
24
25
  import { handleQualityMessage } from './quality-handlers.js';
@@ -46,6 +47,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
46
47
  terminalListenerCleanups: Map<string, () => void> = new Map();
47
48
  terminalSubscribers: Map<string, Set<WSContext>> = new Map();
48
49
  fileUploadHandler: FileUploadHandler | null = null;
50
+ gitHeadWatcher: GitHeadWatcher | null = null;
49
51
 
50
52
  constructor() {
51
53
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
@@ -103,9 +105,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
103
105
  }
104
106
  }
105
107
 
106
- handleConnection(ws: WSContext, _workingDir: string): void {
108
+ handleConnection(ws: WSContext, workingDir: string): void {
107
109
  this.connections.set(ws, new Map());
108
110
  this.allConnections.add(ws);
111
+
112
+ if (!this.gitHeadWatcher && workingDir) {
113
+ this.gitHeadWatcher = new GitHeadWatcher(workingDir, this);
114
+ this.gitHeadWatcher.start();
115
+ }
109
116
  }
110
117
 
111
118
  async handleMessage(
@@ -147,7 +154,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
147
154
  // Quality
148
155
  qualityDetectTools: 'quality', qualityScan: 'quality', qualityInstallTools: 'quality', qualityCodeReview: 'quality', qualityFixIssues: 'quality', qualityLoadState: 'quality', qualitySaveDirectories: 'quality',
149
156
  // Plan + boards + sprints
150
- planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan',
157
+ planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan',
151
158
  // File upload
152
159
  fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
153
160
  // Deploy management + HTTP relay
@@ -243,10 +250,16 @@ export class WebSocketImproviseHandler implements HandlerContext {
243
250
  this.allConnections.delete(ws);
244
251
  cleanupTerminalSubscribers(this, ws);
245
252
 
246
- // Clean up file upload handler when no connections remain
247
- if (this.allConnections.size === 0 && this.fileUploadHandler) {
248
- this.fileUploadHandler.destroy();
249
- this.fileUploadHandler = null;
253
+ // Clean up resources when no connections remain
254
+ if (this.allConnections.size === 0) {
255
+ if (this.fileUploadHandler) {
256
+ this.fileUploadHandler.destroy();
257
+ this.fileUploadHandler = null;
258
+ }
259
+ if (this.gitHeadWatcher) {
260
+ this.gitHeadWatcher.stop();
261
+ this.gitHeadWatcher = null;
262
+ }
250
263
  }
251
264
  }
252
265
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
+ import { handlePlanPrompt } from '../plan/composer.js';
6
7
  import { replaceFrontMatterField } from '../plan/front-matter.js';
7
8
  import { getNextBoardId, getNextBoardNumber, parseBoardArtifacts, parseBoardDirectory, parsePlanDirectory, resolvePmDir } from '../plan/parser.js';
8
9
  import type { Workspace } from '../plan/types.js';
@@ -309,6 +310,121 @@ export function handleGetBoardArtifacts(
309
310
  ctx.send(ws, { type: 'planBoardArtifacts', data: artifacts });
310
311
  }
311
312
 
313
+ // ============================================================================
314
+ // Chat-to-board: create board from conversation and run prompt
315
+ // ============================================================================
316
+
317
+ export function handleChatToBoard(
318
+ ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
319
+ workingDir: string, permission?: 'view',
320
+ ): void {
321
+ if (denyIfViewOnly(ctx, ws, permission)) return;
322
+
323
+ const { conversation, autoImplement, focusHint } = (msg.data || {}) as {
324
+ conversation?: string;
325
+ autoImplement?: boolean;
326
+ focusHint?: string;
327
+ };
328
+
329
+ if (!conversation) {
330
+ ctx.send(ws, { type: 'planError', data: { error: 'Conversation text is required' } });
331
+ return;
332
+ }
333
+
334
+ const pmDir = resolvePmDir(workingDir);
335
+ if (!pmDir) {
336
+ ctx.send(ws, { type: 'planError', data: { error: 'No PM directory found. Run planScaffold first.' } });
337
+ return;
338
+ }
339
+
340
+ const fullState = parsePlanDirectory(workingDir);
341
+ if (!fullState) {
342
+ ctx.send(ws, { type: 'planError', data: { error: 'Failed to parse PM directory' } });
343
+ return;
344
+ }
345
+
346
+ const boardId = getNextBoardId(fullState.boards);
347
+ const boardNum = getNextBoardNumber(fullState.boards);
348
+ const title = `Board ${boardNum}`;
349
+ const boardDir = join(pmDir, 'boards', boardId);
350
+
351
+ for (const dir of ['backlog', 'out', 'reviews', 'logs']) {
352
+ mkdirSync(join(boardDir, dir), { recursive: true });
353
+ }
354
+
355
+ const today = new Date().toISOString().split('T')[0];
356
+ const goalLine = focusHint || 'Generated from chat conversation';
357
+
358
+ writeFileSync(join(boardDir, 'board.md'), `---
359
+ id: ${boardId}
360
+ title: "${title}"
361
+ status: draft
362
+ created: "${today}"
363
+ completed_at: null
364
+ goal: "${goalLine.replace(/"/g, '\\"')}"
365
+ ---
366
+
367
+ # ${title}
368
+
369
+ ## Goal
370
+ ${goalLine}
371
+
372
+ ## Notes
373
+ `, 'utf-8');
374
+
375
+ writeFileSync(join(boardDir, 'STATE.md'), `---
376
+ project: ../../project.md
377
+ board: board.md
378
+ paused: false
379
+ ---
380
+
381
+ # Board State
382
+
383
+ ## Ready to Work
384
+
385
+ ## In Progress
386
+
387
+ ## Blocked
388
+
389
+ ## Recently Completed
390
+
391
+ ## Warnings
392
+ `, 'utf-8');
393
+
394
+ writeFileSync(join(boardDir, 'progress.md'), '# Board Progress\n', 'utf-8');
395
+
396
+ const wsPath = join(pmDir, 'workspace.json');
397
+ if (!existsSync(wsPath)) {
398
+ writeFileSync(wsPath, JSON.stringify({ activeBoardId: null, boardOrder: [] }, null, 2), 'utf-8');
399
+ }
400
+ const workspace: Workspace = JSON.parse(readFileSync(wsPath, 'utf-8'));
401
+ workspace.boardOrder.push(boardId);
402
+ workspace.activeBoardId = boardId;
403
+ writeFileSync(wsPath, JSON.stringify(workspace, null, 2), 'utf-8');
404
+
405
+ const boardState = parseBoardDirectory(pmDir, boardId);
406
+ if (boardState) {
407
+ ctx.broadcastToAll({ type: 'planBoardCreated', data: boardState.board });
408
+ ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
409
+ }
410
+
411
+ ctx.send(ws, {
412
+ type: 'chatToBoardCreated',
413
+ data: { boardId, autoImplement: !!autoImplement },
414
+ });
415
+
416
+ let prompt = conversation;
417
+ if (focusHint) {
418
+ prompt = `Focus on: ${focusHint}\n\n${conversation}`;
419
+ }
420
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
421
+ ctx.send(ws, {
422
+ type: 'planError',
423
+ data: { error: error instanceof Error ? error.message : String(error) },
424
+ });
425
+ });
426
+ }
427
+
312
428
  // ── Private helpers ──────────────────────────────────────────────────
313
429
 
314
430
  /** Build a board-level review-custom agent file from user-provided criteria. */
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { HandlerContext } from './handler-context.js';
12
- import { handleArchiveBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
12
+ import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
13
13
  import { handleExecute, handleExecuteEpic, handlePause, handlePrompt, handleResume, handleStop } from './plan-execution-handlers.js';
14
14
  import { handleCreateIssue, handleDeleteIssue, handleGetIssue, handleGetMilestone, handleGetSprint, handleListIssues, handlePlanInit, handleScaffold, handleUpdateIssue } from './plan-issue-handlers.js';
15
15
  import { handleActivateSprint, handleCompleteSprint, handleCreateSprint, handleGetSprintArtifacts } from './plan-sprint-handlers.js';
@@ -52,6 +52,8 @@ export function handlePlanMessage(
52
52
  planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
53
53
  planSetActiveBoard: () => handleSetActiveBoard(ctx, ws, msg, workingDir, permission),
54
54
  planGetBoardArtifacts: () => handleGetBoardArtifacts(ctx, ws, msg, workingDir),
55
+ // Chat-to-board (from /board and /ship skills)
56
+ chatToBoard: () => handleChatToBoard(ctx, ws, msg, workingDir, permission),
55
57
  // Sprint lifecycle (legacy)
56
58
  planCreateSprint: () => handleCreateSprint(ctx, ws, msg, workingDir, permission),
57
59
  planActivateSprint: () => handleActivateSprint(ctx, ws, msg, workingDir, permission),
@@ -34,10 +34,18 @@ export interface HistoryDirectoryEntry {
34
34
  grade: string;
35
35
  }
36
36
 
37
+ export interface HistoryCategoryScore {
38
+ category: string;
39
+ score: number;
40
+ grade: string;
41
+ }
42
+
37
43
  export interface QualityHistoryEntry {
38
44
  timestamp: string;
39
45
  overall: number;
40
46
  grade: string;
47
+ issueDensity?: number;
48
+ categoryScores?: HistoryCategoryScore[];
41
49
  directories: HistoryDirectoryEntry[];
42
50
  }
43
51
 
@@ -187,12 +195,10 @@ export class QualityPersistence {
187
195
  appendHistory(results: QualityResults, dirPath: string): void {
188
196
  const history = this.loadHistory();
189
197
 
190
- // Find or create entry for this timestamp batch
191
- // If the last entry was within 60 seconds, merge into it (for multi-dir scans)
192
198
  const now = new Date();
193
199
  const lastEntry = history[history.length - 1];
194
200
  const lastTime = lastEntry ? new Date(lastEntry.timestamp).getTime() : 0;
195
- const mergeWindow = 60_000; // 60 seconds
201
+ const mergeWindow = 60_000;
196
202
 
197
203
  const dirEntry: HistoryDirectoryEntry = {
198
204
  path: dirPath,
@@ -200,30 +206,40 @@ export class QualityPersistence {
200
206
  grade: results.grade,
201
207
  };
202
208
 
209
+ const categoryScores: HistoryCategoryScore[] | undefined = results.scoreBreakdown
210
+ ? results.scoreBreakdown.categoryPenalties.map((cp) => ({
211
+ category: cp.category,
212
+ score: cp.score,
213
+ grade: cp.grade,
214
+ }))
215
+ : undefined;
216
+
217
+ const issueDensity = results.scoreBreakdown?.issueDensity;
218
+
203
219
  if (lastEntry && now.getTime() - lastTime < mergeWindow) {
204
- // Merge: update or add this directory in the last entry
205
220
  const existing = lastEntry.directories.findIndex((d) => d.path === dirPath);
206
221
  if (existing >= 0) {
207
222
  lastEntry.directories[existing] = dirEntry;
208
223
  } else {
209
224
  lastEntry.directories.push(dirEntry);
210
225
  }
211
- // Recompute overall as average of all directories in this entry
212
226
  const totalScore = lastEntry.directories.reduce((sum, d) => sum + d.score, 0);
213
227
  lastEntry.overall = Math.round(totalScore / lastEntry.directories.length);
214
228
  lastEntry.grade = gradeFromScore(lastEntry.overall);
215
229
  lastEntry.timestamp = now.toISOString();
230
+ if (categoryScores) lastEntry.categoryScores = categoryScores;
231
+ if (issueDensity !== undefined) lastEntry.issueDensity = issueDensity;
216
232
  } else {
217
- // New entry
218
233
  history.push({
219
234
  timestamp: now.toISOString(),
220
235
  overall: results.overall,
221
236
  grade: results.grade,
237
+ issueDensity,
238
+ categoryScores,
222
239
  directories: [dirEntry],
223
240
  });
224
241
  }
225
242
 
226
- // Trim to max entries
227
243
  while (history.length > MAX_HISTORY_ENTRIES) {
228
244
  history.shift();
229
245
  }
@@ -443,18 +443,8 @@ function persistReviewResults(
443
443
  }
444
444
 
445
445
  let updatedResults: import('./quality-service.js').QualityResults;
446
- if (reviewResult.score !== null && reviewResult.grade !== null) {
447
- updatedResults = {
448
- ...existingReport,
449
- overall: reviewResult.score,
450
- grade: reviewResult.grade,
451
- codeReview: findings,
452
- scoreRationale: reviewResult.scoreRationale ?? undefined,
453
- };
454
- } else {
455
- updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
456
- updatedResults = { ...updatedResults, codeReview: findings };
457
- }
446
+ updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
447
+ updatedResults = { ...updatedResults, codeReview: findings };
458
448
 
459
449
  persistence.saveReport(reportPath, updatedResults);
460
450
  persistence.appendHistory(updatedResults, reportPath);