mstro-app 0.4.33 → 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 (117) 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/plan/composer.d.ts +1 -1
  18. package/dist/server/services/plan/composer.d.ts.map +1 -1
  19. package/dist/server/services/plan/composer.js +2 -2
  20. package/dist/server/services/plan/composer.js.map +1 -1
  21. package/dist/server/services/plan/executor.d.ts +3 -1
  22. package/dist/server/services/plan/executor.d.ts.map +1 -1
  23. package/dist/server/services/plan/executor.js +8 -3
  24. package/dist/server/services/plan/executor.js.map +1 -1
  25. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  26. package/dist/server/services/plan/parser-core.js +15 -0
  27. package/dist/server/services/plan/parser-core.js.map +1 -1
  28. package/dist/server/services/plan/types.d.ts +5 -0
  29. package/dist/server/services/plan/types.d.ts.map +1 -1
  30. package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
  31. package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
  32. package/dist/server/services/websocket/git-head-watcher.js +136 -0
  33. package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
  34. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  35. package/dist/server/services/websocket/git-worktree-handlers.js +84 -13
  36. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  37. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  38. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  39. package/dist/server/services/websocket/handler.d.ts +3 -1
  40. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  41. package/dist/server/services/websocket/handler.js +18 -6
  42. package/dist/server/services/websocket/handler.js.map +1 -1
  43. package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
  44. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  45. package/dist/server/services/websocket/plan-board-handlers.js +94 -0
  46. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  47. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  48. package/dist/server/services/websocket/plan-execution-handlers.js +4 -2
  49. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  50. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  51. package/dist/server/services/websocket/plan-handlers.js +3 -1
  52. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  53. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  54. package/dist/server/services/websocket/plan-issue-handlers.js +9 -0
  55. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  56. package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
  57. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  58. package/dist/server/services/websocket/quality-persistence.js +15 -7
  59. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  60. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  61. package/dist/server/services/websocket/quality-review-agent.js +2 -13
  62. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  63. package/dist/server/services/websocket/quality-service.d.ts +12 -3
  64. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  65. package/dist/server/services/websocket/quality-service.js +101 -81
  66. package/dist/server/services/websocket/quality-service.js.map +1 -1
  67. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  68. package/dist/server/services/websocket/quality-tools.js +6 -1
  69. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  70. package/dist/server/services/websocket/quality-types.d.ts +15 -2
  71. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  72. package/dist/server/services/websocket/quality-types.js.map +1 -1
  73. package/dist/server/services/websocket/session-handlers.js +2 -2
  74. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  75. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  76. package/dist/server/services/websocket/tab-handlers.js +9 -2
  77. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  78. package/dist/server/services/websocket/types.d.ts +2 -2
  79. package/dist/server/services/websocket/types.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/server/cli/headless/claude-invoker-stream.ts +63 -0
  82. package/server/cli/headless/haiku-assessments.ts +10 -5
  83. package/server/cli/improvisation-retry.ts +18 -2
  84. package/server/cli/improvisation-session-manager.ts +13 -5
  85. package/server/cli/improvisation-types.ts +1 -0
  86. package/server/services/plan/agents/assess-stall.md +21 -0
  87. package/server/services/plan/agents/check-injection.md +36 -0
  88. package/server/services/plan/agents/classify-error.md +29 -0
  89. package/server/services/plan/agents/detect-context-loss.md +29 -0
  90. package/server/services/plan/agents/execute-issue.md +42 -0
  91. package/server/services/plan/agents/plan-coordinator.md +71 -0
  92. package/server/services/plan/agents/retry-task.md +26 -0
  93. package/server/services/plan/agents/review-code.md +4 -1
  94. package/server/services/plan/agents/review-criteria.md +53 -0
  95. package/server/services/plan/agents/review-custom.md +4 -1
  96. package/server/services/plan/agents/review-quality.md +4 -1
  97. package/server/services/plan/agents/verify-review.md +56 -0
  98. package/server/services/plan/composer.ts +2 -1
  99. package/server/services/plan/executor.ts +8 -3
  100. package/server/services/plan/parser-core.ts +14 -0
  101. package/server/services/plan/types.ts +6 -0
  102. package/server/services/websocket/git-head-watcher.ts +120 -0
  103. package/server/services/websocket/git-worktree-handlers.ts +85 -15
  104. package/server/services/websocket/handler-context.ts +2 -0
  105. package/server/services/websocket/handler.ts +19 -6
  106. package/server/services/websocket/plan-board-handlers.ts +116 -0
  107. package/server/services/websocket/plan-execution-handlers.ts +4 -2
  108. package/server/services/websocket/plan-handlers.ts +3 -1
  109. package/server/services/websocket/plan-issue-handlers.ts +10 -0
  110. package/server/services/websocket/quality-persistence.ts +23 -7
  111. package/server/services/websocket/quality-review-agent.ts +2 -12
  112. package/server/services/websocket/quality-service.ts +116 -99
  113. package/server/services/websocket/quality-tools.ts +6 -1
  114. package/server/services/websocket/quality-types.ts +17 -2
  115. package/server/services/websocket/session-handlers.ts +2 -2
  116. package/server/services/websocket/tab-handlers.ts +8 -2
  117. 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
+ ```
@@ -129,6 +129,7 @@ export async function handlePlanPrompt(
129
129
  userPrompt: string,
130
130
  workingDir: string,
131
131
  boardId?: string,
132
+ executionDir?: string,
132
133
  ): Promise<void> {
133
134
  const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
134
135
  const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
@@ -246,7 +247,7 @@ User request: ${userPrompt}`;
246
247
  });
247
248
 
248
249
  const runner = new HeadlessRunner({
249
- workingDir,
250
+ workingDir: executionDir || workingDir,
250
251
  directPrompt: enrichedPrompt,
251
252
  stallWarningMs: 300_000, // 5 min — compose usually finishes quickly
252
253
  stallKillMs: 900_000, // 15 min
@@ -73,6 +73,8 @@ export class PlanExecutor extends EventEmitter {
73
73
  private _scopeSetByCall = false;
74
74
  /** Extra environment variables forwarded to HeadlessRunner child processes (e.g. API keys) */
75
75
  private extraEnv?: Record<string, string>;
76
+ /** Optional worktree directory for running AI agents. PM data is always read from workingDir. */
77
+ private executionDir: string | null = null;
76
78
  private metrics: ExecutionMetrics = {
77
79
  issuesCompleted: 0,
78
80
  issuesAttempted: 0,
@@ -98,8 +100,9 @@ export class PlanExecutor extends EventEmitter {
98
100
  }
99
101
 
100
102
  /** Start execution, optionally scoped to a specific board. */
101
- async startBoard(boardId: string): Promise<void> {
103
+ async startBoard(boardId: string, executionDir?: string): Promise<void> {
102
104
  this.boardId = boardId;
105
+ this.executionDir = executionDir ?? null;
103
106
  this._scopeSetByCall = true;
104
107
  return this.start();
105
108
  }
@@ -113,6 +116,7 @@ export class PlanExecutor extends EventEmitter {
113
116
  if (!this._scopeSetByCall) {
114
117
  this.epicScope = null;
115
118
  this.boardId = null;
119
+ this.executionDir = null;
116
120
  }
117
121
  this._scopeSetByCall = false;
118
122
  this.status = 'starting';
@@ -247,10 +251,11 @@ export class PlanExecutor extends EventEmitter {
247
251
  waveLabel: string,
248
252
  abortSignal?: AbortSignal,
249
253
  ): Promise<void> {
254
+ const effectiveDir = this.executionDir || this.workingDir;
250
255
  const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
251
256
  const prompt = buildIssuePrompt({
252
257
  issue,
253
- workingDir: this.workingDir,
258
+ workingDir: effectiveDir,
254
259
  pmDir,
255
260
  boardDir: this.boardDir,
256
261
  existingDocs,
@@ -259,7 +264,7 @@ export class PlanExecutor extends EventEmitter {
259
264
 
260
265
  const boardLogDir = this.boardDir ? join(this.boardDir, 'logs') : undefined;
261
266
  const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runIssueWithRetry({
262
- workingDir: this.workingDir,
267
+ workingDir: effectiveDir,
263
268
  prompt,
264
269
  stallWarningMs: ISSUE_STALL_WARNING_MS,
265
270
  stallKillMs: ISSUE_STALL_KILL_MS,
@@ -402,12 +402,26 @@ export function parseBoard(content: string, filePath: string): Board {
402
402
  };
403
403
  }
404
404
 
405
+ function parseWorktreeEntry(v: unknown): { path: string; branch: string } | null {
406
+ if (!v || typeof v !== 'object' || !('path' in v) || !('branch' in v)) return null;
407
+ const e = v as { path: unknown; branch: unknown };
408
+ return typeof e.path === 'string' && typeof e.branch === 'string' ? { path: e.path, branch: e.branch } : null;
409
+ }
410
+
405
411
  export function parseWorkspace(content: string): Workspace {
406
412
  try {
407
413
  const parsed = JSON.parse(content) as Record<string, unknown>;
414
+ const boardWorktrees: Record<string, { path: string; branch: string }> = {};
415
+ if (parsed.boardWorktrees && typeof parsed.boardWorktrees === 'object') {
416
+ for (const [k, v] of Object.entries(parsed.boardWorktrees as Record<string, unknown>)) {
417
+ const entry = parseWorktreeEntry(v);
418
+ if (entry) boardWorktrees[k] = entry;
419
+ }
420
+ }
408
421
  return {
409
422
  activeBoardId: typeof parsed.activeBoardId === 'string' ? parsed.activeBoardId : null,
410
423
  boardOrder: Array.isArray(parsed.boardOrder) ? parsed.boardOrder.map(String) : [],
424
+ ...(Object.keys(boardWorktrees).length > 0 ? { boardWorktrees } : {}),
411
425
  };
412
426
  } catch {
413
427
  return { activeBoardId: null, boardOrder: [] };
@@ -138,9 +138,15 @@ export interface BoardExecutionSummary {
138
138
  // Workspace (workspace.json)
139
139
  // ============================================================================
140
140
 
141
+ export interface BoardWorktreeEntry {
142
+ path: string;
143
+ branch: string;
144
+ }
145
+
141
146
  export interface Workspace {
142
147
  activeBoardId: string | null;
143
148
  boardOrder: string[];
149
+ boardWorktrees?: Record<string, BoardWorktreeEntry>;
144
150
  }
145
151
 
146
152
  // ============================================================================
@@ -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
+ }
@@ -1,11 +1,35 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
5
  import { dirname, join } from 'node:path';
6
+ import { resolvePmDir } from '../plan/parser.js';
7
+ import type { Workspace } from '../plan/types.js';
5
8
  import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
6
9
  import type { HandlerContext } from './handler-context.js';
7
10
  import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
8
11
 
12
+ function persistBoardWorktree(workingDir: string, boardId: string, worktreePath: string | null, branch: string | null): void {
13
+ const pmDir = resolvePmDir(workingDir);
14
+ if (!pmDir) return;
15
+ const wsPath = join(pmDir, 'workspace.json');
16
+ if (!existsSync(wsPath)) return;
17
+ try {
18
+ const workspace: Workspace = JSON.parse(readFileSync(wsPath, 'utf-8'));
19
+ if (!workspace.boardWorktrees) workspace.boardWorktrees = {};
20
+ if (worktreePath && branch) {
21
+ workspace.boardWorktrees[boardId] = { path: worktreePath, branch };
22
+ } else {
23
+ delete workspace.boardWorktrees[boardId];
24
+ }
25
+ writeFileSync(wsPath, JSON.stringify(workspace, null, 2), 'utf-8');
26
+ } catch { /* non-fatal */ }
27
+ }
28
+
29
+ function isBoardId(id: string): boolean {
30
+ return id.startsWith('BOARD-');
31
+ }
32
+
9
33
  export async function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
10
34
  const handlers: Record<string, () => Promise<void>> = {
11
35
  gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
@@ -116,6 +140,9 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
116
140
  ctx.gitBranches.set(tabId, branchName);
117
141
  const registry = ctx.getRegistry(workingDir);
118
142
  registry.updateTabWorktree(tabId, wtPath, branchName);
143
+ if (isBoardId(tabId)) {
144
+ persistBoardWorktree(workingDir, tabId, wtPath, branchName);
145
+ }
119
146
 
120
147
  ctx.send(ws, {
121
148
  type: 'gitWorktreeCreatedAndAssigned',
@@ -129,6 +156,17 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
129
156
  }
130
157
  }
131
158
 
159
+ function cleanupWorktreeReferences(ctx: HandlerContext, workingDir: string, wtPath: string): void {
160
+ const resolvedWtPath = join(wtPath);
161
+ for (const [tid, dir] of ctx.gitDirectories) {
162
+ if (dir === resolvedWtPath || dir === wtPath) {
163
+ ctx.gitDirectories.delete(tid);
164
+ ctx.gitBranches.delete(tid);
165
+ if (isBoardId(tid)) persistBoardWorktree(workingDir, tid, null, null);
166
+ }
167
+ }
168
+ }
169
+
132
170
  async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
133
171
  try {
134
172
  const { path: wtPath, force, deleteBranch } = msg.data || {};
@@ -155,15 +193,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
155
193
  }
156
194
 
157
195
  await executeGitCommand(['worktree', 'prune'], workingDir);
158
-
159
- // Clean up gitDirectories entries for any tabs referencing the removed worktree
160
- const resolvedWtPath = join(wtPath); // normalize
161
- for (const [tid, dir] of ctx.gitDirectories) {
162
- if (dir === resolvedWtPath || dir === wtPath) {
163
- ctx.gitDirectories.delete(tid);
164
- ctx.gitBranches.delete(tid);
165
- }
166
- }
196
+ cleanupWorktreeReferences(ctx, workingDir, wtPath);
167
197
 
168
198
  ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
169
199
  } catch (error: unknown) {
@@ -180,6 +210,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
180
210
  ctx.gitDirectories.delete(resolvedTabId);
181
211
  ctx.gitBranches.delete(resolvedTabId);
182
212
  registry.updateTabWorktree(resolvedTabId, null, null);
213
+ if (isBoardId(resolvedTabId)) {
214
+ persistBoardWorktree(workingDir, resolvedTabId, null, null);
215
+ }
183
216
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
184
217
  handleGitStatus(ctx, ws, resolvedTabId, workingDir);
185
218
  return;
@@ -191,6 +224,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
191
224
  const branch = branchResult.stdout.trim();
192
225
  ctx.gitBranches.set(resolvedTabId, branch);
193
226
  registry.updateTabWorktree(resolvedTabId, worktreePath, branch);
227
+ if (isBoardId(resolvedTabId)) {
228
+ persistBoardWorktree(workingDir, resolvedTabId, worktreePath, branch);
229
+ }
194
230
 
195
231
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
196
232
  handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
@@ -361,26 +397,44 @@ async function cleanupAfterMerge(
361
397
  strategy: string,
362
398
  deleteWorktree: boolean,
363
399
  deleteBranch: boolean,
364
- ): Promise<void> {
400
+ ): Promise<{ warnings: string[]; removedWorktreePath: string | null }> {
401
+ const warnings: string[] = [];
402
+ let removedWorktreePath: string | null = null;
403
+
365
404
  if (deleteWorktree) {
366
405
  const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
367
406
  const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
368
407
  if (worktreePath && worktreePath !== mainPath) {
369
- 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
+ }
370
419
  }
371
420
  }
372
421
  if (deleteBranch) {
373
422
  const deleteFlag = strategy === 'squash' ? '-D' : '-d';
374
- 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
+ }
375
427
  }
376
428
  await executeGitCommand(['worktree', 'prune'], mainPath);
429
+ return { warnings, removedWorktreePath };
377
430
  }
378
431
 
379
432
  function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
380
433
  let currentWtPath = '';
434
+ const fullRef = `refs/heads/${branchName}`;
381
435
  for (const line of porcelainOutput.split('\n')) {
382
436
  if (line.startsWith('worktree ')) currentWtPath = line.slice(9).trim();
383
- if (line.startsWith('branch ') && line.includes(branchName)) return currentWtPath;
437
+ if (line.startsWith('branch ') && line.slice(7).trim() === fullRef) return currentWtPath;
384
438
  }
385
439
  return null;
386
440
  }
@@ -401,6 +455,8 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
401
455
  return;
402
456
  }
403
457
 
458
+ const headBefore = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
459
+
404
460
  const mergeResult = await executeMergeStrategy(strategy, sourceBranch, commitMessage, mainPath);
405
461
  if (mergeResult.exitCode !== 0) {
406
462
  const conflictFiles = await detectMergeConflicts(mainPath);
@@ -411,10 +467,24 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
411
467
  return;
412
468
  }
413
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
+
414
476
  const commitHashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
415
- await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
477
+ const { warnings, removedWorktreePath } = await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
416
478
 
417
- 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 });
418
488
  } catch (error: unknown) {
419
489
  ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
420
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