mstro-app 0.4.34 → 0.4.35

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 (91) hide show
  1. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker-stream.js +63 -0
  3. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  4. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
  5. package/dist/server/cli/headless/haiku-assessments.js +10 -5
  6. package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
  7. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  8. package/dist/server/cli/improvisation-retry.js +17 -2
  9. package/dist/server/cli/improvisation-retry.js.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  11. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  12. package/dist/server/cli/improvisation-session-manager.js +13 -5
  13. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  14. package/dist/server/cli/improvisation-types.d.ts +1 -0
  15. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-types.js.map +1 -1
  17. package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
  18. package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
  19. package/dist/server/services/websocket/git-head-watcher.js +136 -0
  20. package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
  21. package/dist/server/services/websocket/git-worktree-handlers.js +37 -5
  22. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  23. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  24. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  25. package/dist/server/services/websocket/handler.d.ts +3 -1
  26. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  27. package/dist/server/services/websocket/handler.js +18 -6
  28. package/dist/server/services/websocket/handler.js.map +1 -1
  29. package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
  30. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  31. package/dist/server/services/websocket/plan-board-handlers.js +94 -0
  32. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  33. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  34. package/dist/server/services/websocket/plan-handlers.js +3 -1
  35. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  36. package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
  37. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  38. package/dist/server/services/websocket/quality-persistence.js +15 -7
  39. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  40. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  41. package/dist/server/services/websocket/quality-review-agent.js +2 -13
  42. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  43. package/dist/server/services/websocket/quality-service.d.ts +12 -3
  44. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  45. package/dist/server/services/websocket/quality-service.js +101 -81
  46. package/dist/server/services/websocket/quality-service.js.map +1 -1
  47. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  48. package/dist/server/services/websocket/quality-tools.js +6 -1
  49. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  50. package/dist/server/services/websocket/quality-types.d.ts +15 -2
  51. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  52. package/dist/server/services/websocket/quality-types.js.map +1 -1
  53. package/dist/server/services/websocket/session-handlers.js +2 -2
  54. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  55. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  56. package/dist/server/services/websocket/tab-handlers.js +9 -2
  57. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  58. package/dist/server/services/websocket/types.d.ts +2 -2
  59. package/dist/server/services/websocket/types.d.ts.map +1 -1
  60. package/package.json +2 -1
  61. package/server/cli/headless/claude-invoker-stream.ts +63 -0
  62. package/server/cli/headless/haiku-assessments.ts +10 -5
  63. package/server/cli/improvisation-retry.ts +18 -2
  64. package/server/cli/improvisation-session-manager.ts +13 -5
  65. package/server/cli/improvisation-types.ts +1 -0
  66. package/server/services/plan/agents/assess-stall.md +21 -0
  67. package/server/services/plan/agents/check-injection.md +36 -0
  68. package/server/services/plan/agents/classify-error.md +29 -0
  69. package/server/services/plan/agents/detect-context-loss.md +29 -0
  70. package/server/services/plan/agents/execute-issue.md +42 -0
  71. package/server/services/plan/agents/plan-coordinator.md +71 -0
  72. package/server/services/plan/agents/retry-task.md +26 -0
  73. package/server/services/plan/agents/review-code.md +4 -1
  74. package/server/services/plan/agents/review-criteria.md +53 -0
  75. package/server/services/plan/agents/review-custom.md +4 -1
  76. package/server/services/plan/agents/review-quality.md +4 -1
  77. package/server/services/plan/agents/verify-review.md +56 -0
  78. package/server/services/websocket/git-head-watcher.ts +120 -0
  79. package/server/services/websocket/git-worktree-handlers.ts +40 -6
  80. package/server/services/websocket/handler-context.ts +2 -0
  81. package/server/services/websocket/handler.ts +19 -6
  82. package/server/services/websocket/plan-board-handlers.ts +116 -0
  83. package/server/services/websocket/plan-handlers.ts +3 -1
  84. package/server/services/websocket/quality-persistence.ts +23 -7
  85. package/server/services/websocket/quality-review-agent.ts +2 -12
  86. package/server/services/websocket/quality-service.ts +116 -99
  87. package/server/services/websocket/quality-tools.ts +6 -1
  88. package/server/services/websocket/quality-types.ts +17 -2
  89. package/server/services/websocket/session-handlers.ts +2 -2
  90. package/server/services/websocket/tab-handlers.ts +8 -2
  91. package/server/services/websocket/types.ts +7 -2
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: review-criteria
3
+ description: "Help write effective custom review criteria for PM board issue reviews. Use when configuring what the AI reviewer should check for on completed work."
4
+ user-invocable: false
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ You are helping the user write effective review criteria for their PM board. Review criteria tell the AI reviewer what to check when evaluating completed work.
9
+
10
+ ## What Are Review Criteria?
11
+
12
+ Review criteria are custom instructions that the AI reviewer follows when checking completed issues. They supplement the issue's acceptance criteria with board-level quality standards.
13
+
14
+ ## How to Write Good Criteria
15
+
16
+ Good criteria are:
17
+ - **Specific**: "Verify all API endpoints return proper error codes (4xx/5xx)" not "Check for errors"
18
+ - **Observable**: Things the reviewer can verify by reading code/output
19
+ - **Relevant**: Match the type of work on the board (code, writing, research, design)
20
+
21
+ ## Examples by Task Type
22
+
23
+ ### Code Tasks
24
+ - Verify all new functions have TypeScript types (no `any`)
25
+ - Ensure error handling exists for all async operations
26
+ - Check that no hardcoded credentials or secrets are present
27
+ - Verify tests exist for new functionality
28
+ - Ensure all endpoints have input validation
29
+
30
+ ### Writing/Content Tasks
31
+ - Verify the document follows the company style guide
32
+ - Check that all claims have citations or evidence
33
+ - Ensure the tone matches the target audience
34
+ - Verify all sections from the outline are addressed
35
+
36
+ ### Design Tasks
37
+ - Verify designs match the Figma source files
38
+ - Check responsive behavior is documented for mobile/tablet/desktop
39
+ - Ensure accessibility requirements (contrast ratios, ARIA labels) are noted
40
+
41
+ ### Research Tasks
42
+ - Verify at least 3 sources are cited for each major finding
43
+ - Check that methodology is documented
44
+ - Ensure conclusions follow logically from the evidence
45
+
46
+ ## Your Task
47
+
48
+ Help the user craft review criteria for their board. Ask them:
49
+ 1. What type of work does this board contain? (code, writing, research, design, mixed)
50
+ 2. What quality standards matter most?
51
+ 3. Are there specific patterns or anti-patterns to watch for?
52
+
53
+ Then generate 3-7 clear, actionable review criteria they can paste into their board's review criteria field.
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  name: review-custom
3
- description: Reviews work using board-defined custom criteria alongside acceptance criteria — works for code, content, research, planning, and any other task type
3
+ description: "Reviews work using board-defined custom criteria alongside acceptance criteria — works for code, content, research, planning, and any other task type. Use when a PM board has custom review criteria configured."
4
+ user-invocable: false
4
5
  type: review
6
+ allowed-tools: Read, Grep, Glob, Bash
7
+ context: fork
5
8
  variables: [issue_id, issue_title, context_section, acceptance_criteria, review_criteria, read_instruction]
6
9
  checks: [criteria_met, review_criteria]
7
10
  ---
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  name: review-quality
3
- description: Reviews non-code output (writing, research, plans, designs, analysis) for completeness, accuracy, and quality against acceptance criteria
3
+ description: "Reviews non-code output (writing, research, plans, designs, analysis) for completeness, accuracy, and quality against acceptance criteria. Use when reviewing completed PM board issues that produce documents or deliverables."
4
+ user-invocable: false
4
5
  type: review
6
+ allowed-tools: Read, Grep, Glob, Bash
7
+ context: fork
5
8
  variables: [issue_id, issue_title, output_path, issue_spec_path, acceptance_criteria]
6
9
  checks: [criteria_met, output_quality, completeness]
7
10
  ---
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: verify-review
3
+ description: "Independent verification pass for code review findings — skeptically re-checks each finding against actual code to catch hallucinations and false positives. Use after an AI code review to validate findings."
4
+ user-invocable: false
5
+ allowed-tools: Read, Grep, Glob, Bash
6
+ context: fork
7
+ ---
8
+
9
+ You are an independent code review VERIFIER. A separate reviewer produced the findings below. Your job is to VERIFY each finding against the actual code. You are a skeptic — do NOT trust the original reviewer's claims.
10
+
11
+ IMPORTANT: Your current working directory is "{{dirPath}}". Only read files within this directory.
12
+
13
+ ## Findings to Verify
14
+
15
+ {{findingsJson}}
16
+
17
+ ## Verification Process
18
+
19
+ For EACH finding:
20
+
21
+ 1. **Read the cited file and line** using the Read tool. Read at least 20 lines around the cited line for context.
22
+ 2. **Check the specific claim** in the description. Does the code actually do what the finding claims?
23
+ 3. **Search for counter-evidence**:
24
+ - If the finding claims something is missing (no validation, no cleanup, no guard): search for it with Grep
25
+ - If the finding claims an API is used: verify the actual API call at that line
26
+ - If the finding claims a value is leaked/exposed: check if it's filtered/deleted elsewhere in the same function
27
+ 4. **Verdict**: Mark as "confirmed" or "rejected" with a brief explanation
28
+
29
+ ## Rules
30
+
31
+ - You MUST actually Read each cited file. Do not rely on memory or assumptions.
32
+ - Use Grep to search for patterns the finding claims exist (or don't exist).
33
+ - A finding is "rejected" if:
34
+ - The code does NOT match what the description claims
35
+ - There IS a guard/fix that the finding claims is missing
36
+ - The line number doesn't contain the relevant code
37
+ - The finding is about a different version of the code than what exists now
38
+ - A finding is "confirmed" if you can independently verify the issue exists in the current code.
39
+ - Be thorough but efficient — focus verification effort on high/critical severity findings.
40
+
41
+ ## Output
42
+
43
+ Output EXACTLY one JSON code block. No other text after the JSON block.
44
+
45
+ ```json
46
+ {
47
+ "verifications": [
48
+ {
49
+ "id": 1,
50
+ "verdict": "confirmed|rejected",
51
+ "confidence": 0.95,
52
+ "note": "Brief explanation of what you found when checking the code"
53
+ }
54
+ ]
55
+ }
56
+ ```
@@ -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
+ }
@@ -397,26 +397,44 @@ async function cleanupAfterMerge(
397
397
  strategy: string,
398
398
  deleteWorktree: boolean,
399
399
  deleteBranch: boolean,
400
- ): Promise<void> {
400
+ ): Promise<{ warnings: string[]; removedWorktreePath: string | null }> {
401
+ const warnings: string[] = [];
402
+ let removedWorktreePath: string | null = null;
403
+
401
404
  if (deleteWorktree) {
402
405
  const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
403
406
  const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
404
407
  if (worktreePath && worktreePath !== mainPath) {
405
- await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
408
+ const removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
409
+ if (removeResult.exitCode !== 0) {
410
+ const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
411
+ if (forceResult.exitCode !== 0) {
412
+ warnings.push(`Failed to remove worktree: ${forceResult.stderr || 'unknown error'}`);
413
+ } else {
414
+ removedWorktreePath = worktreePath;
415
+ }
416
+ } else {
417
+ removedWorktreePath = worktreePath;
418
+ }
406
419
  }
407
420
  }
408
421
  if (deleteBranch) {
409
422
  const deleteFlag = strategy === 'squash' ? '-D' : '-d';
410
- await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
423
+ const branchResult = await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
424
+ if (branchResult.exitCode !== 0) {
425
+ warnings.push(`Failed to delete branch: ${branchResult.stderr || 'unknown error'}`);
426
+ }
411
427
  }
412
428
  await executeGitCommand(['worktree', 'prune'], mainPath);
429
+ return { warnings, removedWorktreePath };
413
430
  }
414
431
 
415
432
  function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
416
433
  let currentWtPath = '';
434
+ const fullRef = `refs/heads/${branchName}`;
417
435
  for (const line of porcelainOutput.split('\n')) {
418
436
  if (line.startsWith('worktree ')) currentWtPath = line.slice(9).trim();
419
- if (line.startsWith('branch ') && line.includes(branchName)) return currentWtPath;
437
+ if (line.startsWith('branch ') && line.slice(7).trim() === fullRef) return currentWtPath;
420
438
  }
421
439
  return null;
422
440
  }
@@ -437,6 +455,8 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
437
455
  return;
438
456
  }
439
457
 
458
+ const headBefore = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
459
+
440
460
  const mergeResult = await executeMergeStrategy(strategy, sourceBranch, commitMessage, mainPath);
441
461
  if (mergeResult.exitCode !== 0) {
442
462
  const conflictFiles = await detectMergeConflicts(mainPath);
@@ -447,10 +467,24 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
447
467
  return;
448
468
  }
449
469
 
470
+ const headAfter = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
471
+ if (headBefore.stdout.trim() === headAfter.stdout.trim()) {
472
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: false, error: `Already up to date — "${sourceBranch}" has no new commits to merge into "${targetBranch}"` } });
473
+ return;
474
+ }
475
+
450
476
  const commitHashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
451
- await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
477
+ const { warnings, removedWorktreePath } = await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
452
478
 
453
- ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: true, mergeCommit: commitHashResult.stdout.trim() } });
479
+ if (removedWorktreePath) {
480
+ cleanupWorktreeReferences(ctx, workingDir, removedWorktreePath);
481
+ }
482
+
483
+ const data: Record<string, unknown> = { success: true, mergeCommit: commitHashResult.stdout.trim() };
484
+ if (warnings.length > 0) {
485
+ data.warnings = warnings;
486
+ }
487
+ ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data });
454
488
  } catch (error: unknown) {
455
489
  ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
456
490
  }
@@ -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);