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.
- package/dist/server/cli/headless/claude-invoker-stream.d.ts +76 -1
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +85 -10
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +2 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.js +10 -5
- package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +17 -2
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +4 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +61 -42
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +1 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
- package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
- package/dist/server/services/websocket/git-head-watcher.js +136 -0
- package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +47 -6
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +2 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +3 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +18 -6
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js +94 -0
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +3 -1
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +15 -7
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +2 -13
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +12 -3
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +101 -81
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +6 -1
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +15 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +13 -3
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/skill-handlers.d.ts +9 -0
- package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/skill-handlers.js +244 -3
- package/dist/server/services/websocket/skill-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +9 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +44 -3
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +38 -0
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +2 -1
- package/server/cli/headless/claude-invoker-stream.ts +163 -18
- package/server/cli/headless/claude-invoker.ts +2 -0
- package/server/cli/headless/haiku-assessments.ts +10 -5
- package/server/cli/improvisation-retry.ts +18 -2
- package/server/cli/improvisation-session-manager.ts +69 -45
- package/server/cli/improvisation-types.ts +1 -0
- package/server/services/plan/agents/assess-stall.md +21 -0
- package/server/services/plan/agents/check-injection.md +36 -0
- package/server/services/plan/agents/classify-error.md +29 -0
- package/server/services/plan/agents/detect-context-loss.md +29 -0
- package/server/services/plan/agents/execute-issue.md +42 -0
- package/server/services/plan/agents/plan-coordinator.md +71 -0
- package/server/services/plan/agents/retry-task.md +26 -0
- package/server/services/plan/agents/review-code.md +4 -1
- package/server/services/plan/agents/review-criteria.md +53 -0
- package/server/services/plan/agents/review-custom.md +4 -1
- package/server/services/plan/agents/review-quality.md +4 -1
- package/server/services/plan/agents/verify-review.md +56 -0
- package/server/services/websocket/git-head-watcher.ts +120 -0
- package/server/services/websocket/git-worktree-handlers.ts +57 -7
- package/server/services/websocket/handler-context.ts +2 -0
- package/server/services/websocket/handler.ts +19 -6
- package/server/services/websocket/plan-board-handlers.ts +116 -0
- package/server/services/websocket/plan-handlers.ts +3 -1
- package/server/services/websocket/quality-persistence.ts +23 -7
- package/server/services/websocket/quality-review-agent.ts +2 -12
- package/server/services/websocket/quality-service.ts +116 -99
- package/server/services/websocket/quality-tools.ts +6 -1
- package/server/services/websocket/quality-types.ts +17 -2
- package/server/services/websocket/session-handlers.ts +19 -3
- package/server/services/websocket/skill-handlers.ts +260 -3
- package/server/services/websocket/tab-handlers.ts +8 -2
- 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<
|
|
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
|
|
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
|
|
410
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
247
|
-
if (this.allConnections.size === 0
|
|
248
|
-
this.fileUploadHandler
|
|
249
|
-
|
|
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;
|
|
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
|
-
|
|
447
|
-
|
|
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);
|