mstro-app 0.4.4 → 0.4.11

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 (80) hide show
  1. package/dist/server/server-setup.js +1 -1
  2. package/dist/server/server-setup.js.map +1 -1
  3. package/dist/server/services/auth.d.ts.map +1 -1
  4. package/dist/server/services/auth.js +4 -2
  5. package/dist/server/services/auth.js.map +1 -1
  6. package/dist/server/services/instances.js +1 -1
  7. package/dist/server/services/instances.js.map +1 -1
  8. package/dist/server/services/plan/config-installer.d.ts +5 -14
  9. package/dist/server/services/plan/config-installer.d.ts.map +1 -1
  10. package/dist/server/services/plan/config-installer.js +14 -72
  11. package/dist/server/services/plan/config-installer.js.map +1 -1
  12. package/dist/server/services/plan/executor.d.ts +15 -8
  13. package/dist/server/services/plan/executor.d.ts.map +1 -1
  14. package/dist/server/services/plan/executor.js +95 -59
  15. package/dist/server/services/plan/executor.js.map +1 -1
  16. package/dist/server/services/plan/issue-prompt-builder.d.ts +17 -0
  17. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -0
  18. package/dist/server/services/plan/issue-prompt-builder.js +73 -0
  19. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -0
  20. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  21. package/dist/server/services/plan/parser-core.js +9 -0
  22. package/dist/server/services/plan/parser-core.js.map +1 -1
  23. package/dist/server/services/plan/types.d.ts +2 -0
  24. package/dist/server/services/plan/types.d.ts.map +1 -1
  25. package/dist/server/services/platform.d.ts +1 -0
  26. package/dist/server/services/platform.d.ts.map +1 -1
  27. package/dist/server/services/platform.js +3 -7
  28. package/dist/server/services/platform.js.map +1 -1
  29. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  30. package/dist/server/services/websocket/file-explorer-handlers.js +5 -4
  31. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  32. package/dist/server/services/websocket/git-handlers.js +2 -2
  33. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  34. package/dist/server/services/websocket/git-pr-handlers.d.ts +1 -1
  35. package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -1
  36. package/dist/server/services/websocket/git-pr-handlers.js +2 -2
  37. package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
  38. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  39. package/dist/server/services/websocket/handler.js +12 -0
  40. package/dist/server/services/websocket/handler.js.map +1 -1
  41. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  42. package/dist/server/services/websocket/quality-complexity.js +5 -7
  43. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  44. package/dist/server/services/websocket/quality-linting.d.ts +1 -1
  45. package/dist/server/services/websocket/quality-linting.d.ts.map +1 -1
  46. package/dist/server/services/websocket/quality-linting.js +16 -11
  47. package/dist/server/services/websocket/quality-linting.js.map +1 -1
  48. package/dist/server/services/websocket/quality-review-agent.d.ts +14 -3
  49. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  50. package/dist/server/services/websocket/quality-review-agent.js +87 -23
  51. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  52. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  53. package/dist/server/services/websocket/quality-service.js +62 -39
  54. package/dist/server/services/websocket/quality-service.js.map +1 -1
  55. package/dist/server/services/websocket/quality-types.d.ts +2 -0
  56. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  57. package/dist/server/services/websocket/quality-types.js.map +1 -1
  58. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  59. package/dist/server/services/websocket/session-handlers.js +3 -0
  60. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  61. package/package.json +1 -1
  62. package/server/server-setup.ts +1 -1
  63. package/server/services/auth.ts +3 -2
  64. package/server/services/instances.ts +1 -1
  65. package/server/services/plan/config-installer.ts +13 -72
  66. package/server/services/plan/executor.ts +105 -61
  67. package/server/services/plan/issue-prompt-builder.ts +92 -0
  68. package/server/services/plan/parser-core.ts +8 -0
  69. package/server/services/plan/types.ts +2 -0
  70. package/server/services/platform.ts +3 -8
  71. package/server/services/websocket/file-explorer-handlers.ts +7 -3
  72. package/server/services/websocket/git-handlers.ts +3 -3
  73. package/server/services/websocket/git-pr-handlers.ts +3 -3
  74. package/server/services/websocket/handler.ts +12 -0
  75. package/server/services/websocket/quality-complexity.ts +5 -7
  76. package/server/services/websocket/quality-linting.ts +17 -10
  77. package/server/services/websocket/quality-review-agent.ts +95 -23
  78. package/server/services/websocket/quality-service.ts +70 -39
  79. package/server/services/websocket/quality-types.ts +2 -0
  80. package/server/services/websocket/session-handlers.ts +3 -0
@@ -208,6 +208,13 @@ function optionalNumber(val: unknown): number | null {
208
208
  return val != null ? Number(val) : null;
209
209
  }
210
210
 
211
+ function clampParallelAgents(val: unknown): number {
212
+ if (val == null) return 3;
213
+ const n = Number(val);
214
+ if (!Number.isFinite(n) || n < 1) return 3;
215
+ return Math.min(Math.round(n), 10);
216
+ }
217
+
211
218
  export function parseProjectConfig(content: string): ProjectConfig {
212
219
  const { frontMatter, body } = parseFrontMatter(content);
213
220
  const sections = extractSections(body);
@@ -389,6 +396,7 @@ export function parseBoard(content: string, filePath: string): Board {
389
396
  completedAt: optionalString(fm.completed_at),
390
397
  goal: String(fm.goal || sections.get('Goal') || ''),
391
398
  executionSummary,
399
+ maxParallelAgents: clampParallelAgents(fm.max_parallel_agents),
392
400
  path: filePath,
393
401
  };
394
402
  }
@@ -119,6 +119,8 @@ export interface Board {
119
119
  completedAt: string | null;
120
120
  goal: string;
121
121
  executionSummary: BoardExecutionSummary | null;
122
+ /** Max parallel headless Claude Code instances per execution wave (default: 3) */
123
+ maxParallelAgents: number;
122
124
  path: string;
123
125
  }
124
126
 
@@ -72,6 +72,7 @@ export class PlatformConnection {
72
72
  private tokenRefreshInterval: ReturnType<typeof setInterval> | null = null
73
73
  private heartbeatInterval: ReturnType<typeof setInterval> | null = null
74
74
  private missedPongs = 0
75
+ private everConnected = false
75
76
  private readonly startedAt: string
76
77
 
77
78
  constructor(
@@ -228,13 +229,6 @@ export class PlatformConnection {
228
229
  }
229
230
  }
230
231
 
231
- let everConnected = false
232
- const originalOnConnected = this.callbacks.onConnected
233
- this.callbacks.onConnected = (connectionId) => {
234
- everConnected = true
235
- originalOnConnected?.(connectionId)
236
- }
237
-
238
232
  this.ws.onclose = (event) => {
239
233
  this.stopHeartbeat()
240
234
  this.isConnected = false
@@ -242,7 +236,7 @@ export class PlatformConnection {
242
236
  if (!this.isIntentionallyClosed) {
243
237
  const isAuthFailure = event.code === 4001 ||
244
238
  event.reason?.includes('Unauthorized') ||
245
- (event.code === 1006 && !everConnected)
239
+ (event.code === 1006 && !this.everConnected)
246
240
 
247
241
  if (isAuthFailure) {
248
242
  console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
@@ -267,6 +261,7 @@ export class PlatformConnection {
267
261
  switch (message.type) {
268
262
  case 'paired':
269
263
  this.isConnected = true
264
+ this.everConnected = true
270
265
  this.connectionId = message.connectionId as string
271
266
  this.startHeartbeat()
272
267
  this.callbacks.onConnected?.(message.connectionId as string)
@@ -93,9 +93,13 @@ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, ms
93
93
  handleDeleteFile(ctx, ws, msg, tabId, workingDir);
94
94
  },
95
95
  renameFile: () => {
96
- if (isSandboxed && msg.data?.filePath) {
97
- const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
98
- if (!validation.valid) { ctx.send(ws, { type: 'fileError', tabId, data: { operation: 'renameFile', path: msg.data.filePath, error: 'Sandboxed: path outside project directory' } }); return; }
96
+ if (isSandboxed) {
97
+ const oldValidation = msg.data?.oldPath ? validatePathWithinWorkingDir(msg.data.oldPath, workingDir) : { valid: false };
98
+ const newValidation = msg.data?.newPath ? validatePathWithinWorkingDir(msg.data.newPath, workingDir) : { valid: false };
99
+ if (!oldValidation.valid || !newValidation.valid) {
100
+ ctx.send(ws, { type: 'fileError', tabId, data: { operation: 'renameFile', path: msg.data?.oldPath || '', error: 'Sandboxed: path outside project directory' } });
101
+ return;
102
+ }
99
103
  }
100
104
  handleRenameFile(ctx, ws, msg, tabId, workingDir);
101
105
  },
@@ -31,7 +31,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
31
31
  const gitDir = ctx.gitDirectories.get(tabId) || workingDir;
32
32
 
33
33
  if (GIT_PR_TYPES.has(msg.type)) {
34
- handleGitPRMessage(ctx, ws, msg, tabId, gitDir, workingDir);
34
+ await handleGitPRMessage(ctx, ws, msg, tabId, gitDir, workingDir);
35
35
  return;
36
36
  }
37
37
  if (GIT_WORKTREE_TYPES.has(msg.type)) {
@@ -39,7 +39,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
39
39
  return;
40
40
  }
41
41
 
42
- const handlers: Record<string, () => void> = {
42
+ const handlers: Record<string, () => Promise<void>> = {
43
43
  gitStatus: () => handleGitStatus(ctx, ws, tabId, gitDir),
44
44
  gitStage: () => handleGitStage(ctx, ws, msg, tabId, gitDir),
45
45
  gitUnstage: () => handleGitUnstage(ctx, ws, msg, tabId, gitDir),
@@ -61,7 +61,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
61
61
  gitCreateTag: () => handleGitCreateTag(ctx, ws, msg, tabId, gitDir),
62
62
  gitPushTag: () => handleGitPushTag(ctx, ws, msg, tabId, gitDir),
63
63
  };
64
- handlers[msg.type]?.();
64
+ await handlers[msg.type]?.();
65
65
  }
66
66
 
67
67
  export async function handleGitStatus(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
@@ -6,13 +6,13 @@ import { detectGitProvider, executeGitCommand, spawnCheck, spawnHaikuWithPrompt,
6
6
  import type { HandlerContext } from './handler-context.js';
7
7
  import type { WebSocketMessage, WSContext } from './types.js';
8
8
 
9
- export function handleGitPRMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, _workingDir: string): void {
10
- const handlers: Record<string, () => void> = {
9
+ export async function handleGitPRMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, _workingDir: string): Promise<void> {
10
+ const handlers: Record<string, () => Promise<void>> = {
11
11
  gitGetRemoteInfo: () => handleGitGetRemoteInfo(ctx, ws, tabId, gitDir),
12
12
  gitCreatePR: () => handleGitCreatePR(ctx, ws, msg, tabId, gitDir),
13
13
  gitGeneratePRDescription: () => handleGitGeneratePRDescription(ctx, ws, msg, tabId, gitDir),
14
14
  };
15
- handlers[msg.type]?.();
15
+ await handlers[msg.type]?.();
16
16
  }
17
17
 
18
18
  async function handleGitGetRemoteInfo(ctx: HandlerContext, ws: WSContext, tabId: string, workingDir: string): Promise<void> {
@@ -215,6 +215,18 @@ export class WebSocketImproviseHandler implements HandlerContext {
215
215
  }
216
216
 
217
217
  handleClose(ws: WSContext): void {
218
+ // Destroy sessions owned by this connection to free interval timers
219
+ const tabMap = this.connections.get(ws);
220
+ if (tabMap) {
221
+ const sessionIds = new Set(tabMap.values());
222
+ for (const sessionId of sessionIds) {
223
+ const session = this.sessions.get(sessionId);
224
+ if (session) {
225
+ session.destroy();
226
+ this.sessions.delete(sessionId);
227
+ }
228
+ }
229
+ }
218
230
  this.connections.delete(ws);
219
231
  this.allConnections.delete(ws);
220
232
  cleanupTerminalSubscribers(this, ws);
@@ -1,8 +1,7 @@
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 } from 'node:fs';
5
- import { extname, join, relative } from 'node:path';
4
+ import { extname, relative } from 'node:path';
6
5
  import { runCommand, type SourceFile } from './quality-tools.js';
7
6
  import { biomeDiagToFinding, type Ecosystem, FUNCTION_LENGTH_THRESHOLD, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
8
7
 
@@ -162,9 +161,6 @@ function computeComplexityScore(findings: QualityFinding[]): number {
162
161
  }
163
162
 
164
163
  async function complexityFromBiome(dirPath: string): Promise<QualityFinding[] | null> {
165
- const hasBiomeConfig = existsSync(join(dirPath, 'biome.json')) || existsSync(join(dirPath, 'biome.jsonc'));
166
- if (!hasBiomeConfig) return null;
167
-
168
164
  const result = await runCommand('npx', ['@biomejs/biome', 'lint', '--reporter=json', '.'], dirPath);
169
165
  if (result.exitCode > 1) return null;
170
166
 
@@ -245,8 +241,10 @@ async function analyzeNodeComplexity(
245
241
  const hasCapableTool = !installed || installed.has('biome') || installed.has('eslint');
246
242
  if (!hasCapableTool) return null;
247
243
 
248
- const hasBiomeConfig = existsSync(join(dirPath, 'biome.json')) || existsSync(join(dirPath, 'biome.jsonc'));
249
- if (hasBiomeConfig) {
244
+ // Use installed tools list instead of config file presence.
245
+ // This fixes monorepo scenarios where biome.json is in a subdirectory.
246
+ const hasBiome = !installed || installed.has('biome');
247
+ if (hasBiome) {
250
248
  const findings = await complexityFromBiome(dirPath);
251
249
  if (findings) return findings;
252
250
  }
@@ -1,8 +1,7 @@
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 } from 'node:fs';
5
- import { join, relative } from 'node:path';
4
+ import { relative } from 'node:path';
6
5
  import { runCommand, type SourceFile } from './quality-tools.js';
7
6
  import { biomeDiagToFinding, type Ecosystem, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
8
7
 
@@ -57,7 +56,7 @@ function processEslintMessage(
57
56
  else acc.warnings++;
58
57
  acc.findings.push({
59
58
  severity: msg.severity === 2 ? 'high' : 'medium',
60
- category: 'linting',
59
+ category: 'lint',
61
60
  file: relative(dirPath, filePath),
62
61
  line: (msg.line as number) ?? null,
63
62
  title: (msg.ruleId as string) || 'Lint issue',
@@ -81,11 +80,17 @@ async function lintWithEslint(dirPath: string, acc: LintAccumulator): Promise<vo
81
80
  }
82
81
  }
83
82
 
84
- async function lintNode(dirPath: string, acc: LintAccumulator): Promise<void> {
85
- const biomeConfig = existsSync(join(dirPath, 'biome.json')) || existsSync(join(dirPath, 'biome.jsonc'));
86
- if (biomeConfig) {
83
+ async function lintNode(dirPath: string, acc: LintAccumulator, installed: Set<string> | null): Promise<void> {
84
+ // Use installed tools list to decide which linter to run, not config file presence.
85
+ // This fixes monorepo scenarios where the config is in a subdirectory.
86
+ const hasBiome = !installed || installed.has('biome');
87
+ const hasEslint = !installed || installed.has('eslint');
88
+
89
+ if (hasBiome) {
87
90
  await lintWithBiome(dirPath, acc);
88
- } else {
91
+ if (acc.ran) return;
92
+ }
93
+ if (hasEslint) {
89
94
  await lintWithEslint(dirPath, acc);
90
95
  }
91
96
  }
@@ -103,7 +108,7 @@ async function lintPython(dirPath: string, acc: LintAccumulator): Promise<void>
103
108
  else acc.warnings++;
104
109
  acc.findings.push({
105
110
  severity: sev,
106
- category: 'linting',
111
+ category: 'lint',
107
112
  file: item.filename ? relative(dirPath, item.filename) : '',
108
113
  line: item.location?.row ?? null,
109
114
  title: item.code || 'Lint issue',
@@ -124,7 +129,7 @@ function processClippyMessage(msg: Record<string, unknown>, acc: LintAccumulator
124
129
  const code = message.code as Record<string, unknown> | undefined;
125
130
  acc.findings.push({
126
131
  severity: level === 'error' ? 'high' : 'medium',
127
- category: 'linting',
132
+ category: 'lint',
128
133
  file: (span?.file_name as string) || '',
129
134
  line: (span?.line_start as number) ?? null,
130
135
  title: (code?.code as string) || 'Clippy',
@@ -164,10 +169,12 @@ export async function analyzeLinting(
164
169
  dirPath: string,
165
170
  ecosystems: Ecosystem[],
166
171
  files: SourceFile[],
172
+ installedToolNames?: string[],
167
173
  ): Promise<{ score: number; findings: QualityFinding[]; available: boolean; issueCount: number }> {
168
174
  const acc = newLintAccumulator();
175
+ const installed = installedToolNames ? new Set(installedToolNames) : null;
169
176
 
170
- if (ecosystems.includes('node')) await lintNode(dirPath, acc);
177
+ if (ecosystems.includes('node')) await lintNode(dirPath, acc, installed);
171
178
  if (ecosystems.includes('python')) await lintPython(dirPath, acc);
172
179
  if (ecosystems.includes('rust')) await lintRust(dirPath, acc);
173
180
 
@@ -29,28 +29,62 @@ export interface CodeReviewFinding {
29
29
 
30
30
  // ── Prompt ────────────────────────────────────────────────────
31
31
 
32
- export function buildCodeReviewPrompt(dirPath: string): string {
33
- return `You are an expert code review agent. Your task is to perform a comprehensive, language-agnostic code review of the project in the current working directory.
32
+ export function buildCodeReviewPrompt(dirPath: string, cliFindings?: Array<{ severity: string; category: string; file: string; line: number | null; title: string; description: string }>): string {
33
+ const cliFindingsSection = cliFindings && cliFindings.length > 0
34
+ ? `\n## CLI Tool Findings (already detected)\n\nThe following issues were found by automated CLI tools (linters, formatters, complexity analyzers). Review these for context — they are already included in the final report. Focus your analysis on DEEPER issues these tools cannot detect.\n\n${cliFindings.slice(0, 50).map((f, i) => `${i + 1}. [${f.severity.toUpperCase()}] ${f.category} — ${f.file}${f.line ? `:${f.line}` : ''} — ${f.title}: ${f.description}`).join('\n')}\n${cliFindings.length > 50 ? `\n...and ${cliFindings.length - 50} more issues from CLI tools.\n` : ''}`
35
+ : '';
34
36
 
35
- IMPORTANT: Your current working directory is "${dirPath}". Only review files within this directory. Do NOT traverse parent directories or review files outside this path.
37
+ return `You are a senior staff engineer performing a rigorous, honest code review. Your job is to surface the most impactful quality bottlenecks — the issues a principal engineer would flag in a code review. Be critical and objective. Do NOT inflate scores.
36
38
 
39
+ IMPORTANT: Your current working directory is "${dirPath}". Only review files within this directory.
40
+ ${cliFindingsSection}
37
41
  ## Review Process
38
42
 
39
- 1. **Discover**: Use Glob to find source files (e.g. "**/*.{ts,tsx,js,py,rs,go,java,rb,php}"). Understand the project structure. Only search within the current directory.
43
+ 1. **Discover**: Use Glob to find source files (e.g. "**/*.{ts,tsx,js,py,rs,go,java,rb,php}"). Understand the project structure.
40
44
  2. **Read**: Read the most important files — entry points, core modules, handlers, services. Prioritize files with recent git changes (\`git diff --name-only HEAD~5\` via Bash if available).
41
- 3. **Analyze**: Look for real, actionable issues across these categories:
42
- - **security**: Injection vulnerabilities (SQL, XSS, command), hardcoded secrets/credentials, auth bypasses, insecure crypto, path traversal, SSRF, unsafe deserialization
43
- - **bugs**: Null/undefined errors, race conditions, logic errors, unhandled edge cases, off-by-one errors, resource leaks, incorrect error handling
44
- - **performance**: N+1 queries, unnecessary re-renders, missing memoization, blocking I/O in hot paths, unbounded data structures, missing pagination
45
- - **maintainability**: God functions (>100 lines), deep nesting (>4 levels), duplicated logic, missing error handling at system boundaries, tight coupling
45
+ 3. **Analyze**: Look for real, actionable issues across ALL of these categories:
46
+
47
+ ### Architecture
48
+ - What is the current architecture (monolith, microservices, layered, etc.)?
49
+ - Are there architectural violations? (e.g., presentation layer directly accessing data layer, circular dependencies between modules)
50
+ - Is there proper separation of concerns?
51
+ - Are there god objects or god modules that do too much?
52
+
53
+ ### SOLID / OOP Principles
54
+ - **SRP**: Classes/modules with multiple unrelated responsibilities
55
+ - **OCP**: Code that requires modification instead of extension for new features
56
+ - **LSP**: Subtypes that don't properly substitute for their base types
57
+ - **ISP**: Interfaces/contracts that force implementations to depend on methods they don't use
58
+ - **DIP**: High-level modules directly depending on low-level modules instead of abstractions
59
+
60
+ ### Security
61
+ - Injection vulnerabilities (SQL, XSS, command), hardcoded secrets/credentials, auth bypasses, insecure crypto, path traversal, SSRF, unsafe deserialization
62
+
63
+ ### Bugs & Logic
64
+ - Null/undefined errors, race conditions, logic errors, unhandled edge cases, off-by-one errors, resource leaks, incorrect error handling, incorrect algorithms
65
+
66
+ ### Performance
67
+ - N+1 queries, unnecessary re-renders, missing memoization, blocking I/O in hot paths, unbounded data structures, missing pagination
46
68
 
47
69
  ## Rules
48
70
 
49
71
  - Only report findings you are >80% confident about. No speculative or low-confidence issues.
50
- - Focus on bugs and security over style. Skip formatting, naming preferences, and minor nits.
72
+ - Focus on architecture, SOLID violations, bugs, and security over style nits.
51
73
  - Each finding MUST reference a specific file and line number. Do not report vague or file-level issues.
52
- - Limit to the 20 most important findings, ranked by severity.
74
+ - Limit to the 25 most important findings, ranked by severity.
53
75
  - Do NOT modify any files. This is a read-only review.
76
+ - Be HONEST about the overall quality. A codebase with serious issues should score low.
77
+
78
+ ## Scoring Guidelines
79
+
80
+ After your analysis, provide an honest overall quality score (0-100) and letter grade:
81
+ - **A (90-100)**: Excellent — clean architecture, minimal issues, well-tested, follows best practices
82
+ - **B (80-89)**: Good — solid code with minor issues, mostly well-structured
83
+ - **C (70-79)**: Adequate — functional but has notable quality issues that should be addressed
84
+ - **D (60-69)**: Below average — significant issues in architecture, testing, or code quality
85
+ - **F (0-59)**: Poor — serious problems: security vulnerabilities, broken architecture, major bugs, or unmaintainable code
86
+
87
+ Consider ALL findings (both CLI tool findings and your own) when determining the score. The score should reflect the overall state of the codebase honestly. A project with 50+ linting errors, formatting issues, complex functions, AND architectural problems should NOT score above 70.
54
88
 
55
89
  ## Output
56
90
 
@@ -58,10 +92,13 @@ After your analysis, output EXACTLY one JSON code block with your findings. No o
58
92
 
59
93
  \`\`\`json
60
94
  {
95
+ "score": 72,
96
+ "grade": "C",
97
+ "scoreRationale": "Brief explanation of why this score was given, referencing key issues",
61
98
  "findings": [
62
99
  {
63
100
  "severity": "critical|high|medium|low",
64
- "category": "security|bugs|performance|maintainability",
101
+ "category": "architecture|oop|security|bugs|performance|logic",
65
102
  "file": "relative/path/to/file.ts",
66
103
  "line": 42,
67
104
  "title": "Short title describing the issue",
@@ -77,7 +114,7 @@ After your analysis, output EXACTLY one JSON code block with your findings. No o
77
114
  // ── Response parsing ──────────────────────────────────────────
78
115
 
79
116
  const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
80
- const VALID_CATEGORIES = new Set(['security', 'bugs', 'performance', 'maintainability']);
117
+ const VALID_CATEGORIES = new Set(['architecture', 'oop', 'security', 'bugs', 'performance', 'logic', 'maintainability']);
81
118
 
82
119
  function normalizeFinding(f: Record<string, unknown>): CodeReviewFinding | null {
83
120
  if (typeof f.file !== 'string' || typeof f.title !== 'string') return null;
@@ -105,7 +142,15 @@ function extractJson(response: string): string {
105
142
  return response.trim();
106
143
  }
107
144
 
108
- export function parseCodeReviewResponse(response: string): { findings: CodeReviewFinding[]; summary: string } {
145
+ export interface CodeReviewResult {
146
+ findings: CodeReviewFinding[];
147
+ summary: string;
148
+ score: number | null;
149
+ grade: string | null;
150
+ scoreRationale: string | null;
151
+ }
152
+
153
+ export function parseCodeReviewResponse(response: string): CodeReviewResult {
109
154
  const jsonStr = extractJson(response);
110
155
 
111
156
  try {
@@ -113,9 +158,12 @@ export function parseCodeReviewResponse(response: string): { findings: CodeRevie
113
158
  const rawFindings: Record<string, unknown>[] = Array.isArray(parsed.findings) ? parsed.findings : [];
114
159
  const findings = rawFindings.map(normalizeFinding).filter((f): f is CodeReviewFinding => f !== null);
115
160
  const summary = typeof parsed.summary === 'string' ? parsed.summary : `Found ${findings.length} issue(s).`;
116
- return { findings, summary };
161
+ const score = typeof parsed.score === 'number' ? Math.max(0, Math.min(100, Math.round(parsed.score))) : null;
162
+ const grade = typeof parsed.grade === 'string' ? parsed.grade : null;
163
+ const scoreRationale = typeof parsed.scoreRationale === 'string' ? parsed.scoreRationale : null;
164
+ return { findings, summary, score, grade, scoreRationale };
117
165
  } catch {
118
- return { findings: [], summary: 'Failed to parse code review results.' };
166
+ return { findings: [], summary: 'Failed to parse code review results.', score: null, grade: null, scoreRationale: null };
119
167
  }
120
168
  }
121
169
 
@@ -180,9 +228,21 @@ export async function handleCodeReview(
180
228
  data: { path: reportPath, message: 'Starting AI code review...' },
181
229
  });
182
230
 
231
+ // Load CLI findings from the existing report to pass to the AI reviewer
232
+ let cliFindings: Array<{ severity: string; category: string; file: string; line: number | null; title: string; description: string }> | undefined;
233
+ try {
234
+ const persistence = getPersistence(workingDir);
235
+ const existingReport = persistence.loadReport(reportPath);
236
+ if (existingReport?.findings) {
237
+ cliFindings = existingReport.findings;
238
+ }
239
+ } catch {
240
+ // Continue without CLI findings if persistence fails
241
+ }
242
+
183
243
  const runner = new HeadlessRunner({
184
244
  workingDir: dirPath,
185
- directPrompt: buildCodeReviewPrompt(dirPath),
245
+ directPrompt: buildCodeReviewPrompt(dirPath, cliFindings),
186
246
  stallWarningMs: 120_000,
187
247
  stallKillMs: 600_000,
188
248
  stallHardCapMs: 900_000,
@@ -213,27 +273,39 @@ export async function handleCodeReview(
213
273
  });
214
274
 
215
275
  const responseText = result.assistantResponse || '';
216
- const { findings, summary } = parseCodeReviewResponse(responseText);
276
+ const reviewResult = parseCodeReviewResponse(responseText);
217
277
 
218
- // Recompute overall score with AI review findings included
278
+ // Use AI-determined score if available, otherwise fall back to recomputation
219
279
  let updatedResults: import('./quality-service.js').QualityResults | null = null;
220
280
  try {
221
281
  const persistence = getPersistence(workingDir);
222
282
  const existingReport = persistence.loadReport(reportPath);
223
283
  if (existingReport) {
224
- updatedResults = recomputeWithAiReview(existingReport, findings);
225
- updatedResults = { ...updatedResults, codeReview: findings as unknown as typeof updatedResults.codeReview };
284
+ if (reviewResult.score !== null && reviewResult.grade !== null) {
285
+ // Use the AI-determined score and grade directly
286
+ updatedResults = {
287
+ ...existingReport,
288
+ overall: reviewResult.score,
289
+ grade: reviewResult.grade,
290
+ codeReview: reviewResult.findings as unknown as typeof existingReport.codeReview,
291
+ scoreRationale: reviewResult.scoreRationale ?? undefined,
292
+ };
293
+ } else {
294
+ // Fallback: recompute with weighted formula
295
+ updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
296
+ updatedResults = { ...updatedResults, codeReview: reviewResult.findings as unknown as typeof updatedResults.codeReview };
297
+ }
226
298
  persistence.saveReport(reportPath, updatedResults);
227
299
  persistence.appendHistory(updatedResults, reportPath);
228
300
  }
229
- persistence.saveCodeReview(reportPath, findings as unknown as Record<string, unknown>[], summary);
301
+ persistence.saveCodeReview(reportPath, reviewResult.findings as unknown as Record<string, unknown>[], reviewResult.summary);
230
302
  } catch {
231
303
  // Persistence failure should not break the review flow
232
304
  }
233
305
 
234
306
  ctx.send(ws, {
235
307
  type: 'qualityCodeReview',
236
- data: { path: reportPath, findings, summary, results: updatedResults },
308
+ data: { path: reportPath, findings: reviewResult.findings, summary: reviewResult.summary, results: updatedResults },
237
309
  });
238
310
  } catch (error) {
239
311
  ctx.send(ws, {
@@ -15,51 +15,81 @@ export type { CategoryScore, QualityFinding, QualityResults, QualityTool, ScanPr
15
15
  // Formatting Analysis
16
16
  // ============================================================================
17
17
 
18
- async function analyzeFormatting(
19
- dirPath: string,
20
- ecosystems: Ecosystem[],
21
- files: SourceFile[],
22
- ): Promise<{ score: number; available: boolean; issueCount: number }> {
23
- let totalFiles = 0;
24
- let passingFiles = 0;
25
- let ran = false;
26
-
27
- if (ecosystems.includes('node')) {
28
- const result = await runCommand('npx', ['prettier', '--check', '.'], dirPath);
29
- ran = true;
30
- const unformatted = result.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('Checking'));
31
- const nodeFiles = files.filter((f) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(f.path)));
32
- totalFiles += nodeFiles.length;
33
- passingFiles += Math.max(0, nodeFiles.length - unformatted.length);
18
+ interface FmtAccumulator {
19
+ totalFiles: number;
20
+ passingFiles: number;
21
+ ran: boolean;
22
+ findings: QualityFinding[];
23
+ }
24
+
25
+ function newFmtAccumulator(): FmtAccumulator {
26
+ return { totalFiles: 0, passingFiles: 0, ran: false, findings: [] };
27
+ }
28
+
29
+ async function fmtNode(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
30
+ const result = await runCommand('npx', ['prettier', '--check', '.'], dirPath);
31
+ acc.ran = true;
32
+ const unformatted = result.stdout.split('\n').filter((l) => l.trim() && !l.startsWith('Checking'));
33
+ const nodeFiles = files.filter((f) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(f.path)));
34
+ acc.totalFiles += nodeFiles.length;
35
+ acc.passingFiles += Math.max(0, nodeFiles.length - unformatted.length);
36
+ for (const filePath of unformatted) {
37
+ if (!filePath.trim()) continue;
38
+ const rel = filePath.startsWith('/') ? filePath.replace(`${dirPath}/`, '') : filePath;
39
+ acc.findings.push({ severity: 'low', category: 'format', file: rel, line: null, title: 'File not formatted', description: 'Does not match Prettier formatting rules.' });
34
40
  }
41
+ }
35
42
 
36
- if (ecosystems.includes('python')) {
37
- const result = await runCommand('black', ['--check', '--quiet', '.'], dirPath);
38
- ran = true;
39
- const pyFiles = files.filter((f) => ['.py', '.pyi'].includes(extname(f.path)));
40
- totalFiles += pyFiles.length;
41
- if (result.exitCode === 0) {
42
- passingFiles += pyFiles.length;
43
- } else {
44
- const wouldReformat = (result.stderr.match(/would reformat/gi) || []).length;
45
- passingFiles += Math.max(0, pyFiles.length - wouldReformat);
46
- }
43
+ async function fmtPython(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
44
+ const result = await runCommand('black', ['--check', '--quiet', '.'], dirPath);
45
+ acc.ran = true;
46
+ const pyFiles = files.filter((f) => ['.py', '.pyi'].includes(extname(f.path)));
47
+ acc.totalFiles += pyFiles.length;
48
+ if (result.exitCode === 0) {
49
+ acc.passingFiles += pyFiles.length;
50
+ return;
47
51
  }
52
+ const reformatLines = result.stderr.split('\n').filter((l) => l.includes('would reformat'));
53
+ acc.passingFiles += Math.max(0, pyFiles.length - reformatLines.length);
54
+ for (const line of reformatLines) {
55
+ const match = line.match(/would reformat (.+)/);
56
+ if (match) acc.findings.push({ severity: 'low', category: 'format', file: match[1].trim(), line: null, title: 'File not formatted', description: 'Does not match Black formatting rules.' });
57
+ }
58
+ }
48
59
 
49
- if (ecosystems.includes('rust')) {
50
- const result = await runCommand('cargo', ['fmt', '--check'], dirPath);
51
- ran = true;
52
- const rsFiles = files.filter((f) => extname(f.path) === '.rs');
53
- totalFiles += rsFiles.length;
54
- if (result.exitCode === 0) passingFiles += rsFiles.length;
60
+ async function fmtRust(dirPath: string, files: SourceFile[], acc: FmtAccumulator): Promise<void> {
61
+ const result = await runCommand('cargo', ['fmt', '--check'], dirPath);
62
+ acc.ran = true;
63
+ const rsFiles = files.filter((f) => extname(f.path) === '.rs');
64
+ acc.totalFiles += rsFiles.length;
65
+ if (result.exitCode === 0) {
66
+ acc.passingFiles += rsFiles.length;
67
+ return;
68
+ }
69
+ const diffLines = result.stdout.split('\n').filter((l) => l.startsWith('Diff in'));
70
+ for (const line of diffLines) {
71
+ const match = line.match(/Diff in (.+?) at/);
72
+ if (match) acc.findings.push({ severity: 'low', category: 'format', file: match[1].trim(), line: null, title: 'File not formatted', description: 'Does not match rustfmt formatting rules.' });
55
73
  }
74
+ }
75
+
76
+ async function analyzeFormatting(
77
+ dirPath: string,
78
+ ecosystems: Ecosystem[],
79
+ files: SourceFile[],
80
+ ): Promise<{ score: number; available: boolean; issueCount: number; findings: QualityFinding[] }> {
81
+ const acc = newFmtAccumulator();
82
+
83
+ if (ecosystems.includes('node')) await fmtNode(dirPath, files, acc);
84
+ if (ecosystems.includes('python')) await fmtPython(dirPath, files, acc);
85
+ if (ecosystems.includes('rust')) await fmtRust(dirPath, files, acc);
56
86
 
57
- if (!ran || totalFiles === 0) {
58
- return { score: 0, available: false, issueCount: 0 };
87
+ if (!acc.ran || acc.totalFiles === 0) {
88
+ return { score: 0, available: false, issueCount: 0, findings: [] };
59
89
  }
60
90
 
61
- const score = Math.round((passingFiles / totalFiles) * 100);
62
- return { score, available: true, issueCount: totalFiles - passingFiles };
91
+ const score = Math.round((acc.passingFiles / acc.totalFiles) * 100);
92
+ return { score, available: true, issueCount: acc.totalFiles - acc.passingFiles, findings: acc.findings.slice(0, 50) };
63
93
  }
64
94
 
65
95
  // ============================================================================
@@ -195,7 +225,7 @@ export async function runQualityScan(
195
225
  progress('Running linters', 2);
196
226
  const hasLinter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'linter');
197
227
  const lintResult = hasLinter
198
- ? await analyzeLinting(dirPath, ecosystems, files)
228
+ ? await analyzeLinting(dirPath, ecosystems, files, installedToolNames)
199
229
  : { score: 0, findings: [], available: false, issueCount: 0 };
200
230
 
201
231
  // Step 3: Check formatting (only if a formatter is installed)
@@ -203,7 +233,7 @@ export async function runQualityScan(
203
233
  const hasFormatter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'formatter');
204
234
  const fmtResult = hasFormatter
205
235
  ? await analyzeFormatting(dirPath, ecosystems, files)
206
- : { score: 0, available: false, issueCount: 0 };
236
+ : { score: 0, available: false, issueCount: 0, findings: [] as QualityFinding[] };
207
237
 
208
238
  // Step 4: Analyze complexity (using real tools: Biome, ESLint, radon)
209
239
  progress('Analyzing complexity', 4);
@@ -274,6 +304,7 @@ export async function runQualityScan(
274
304
  const overall = computeOverallScore(categories);
275
305
  const allFindings = [
276
306
  ...lintResult.findings,
307
+ ...fmtResult.findings,
277
308
  ...complexityResult.findings,
278
309
  ...fileLengthResult.findings,
279
310
  ...funcLengthResult.findings,
@@ -42,6 +42,8 @@ export interface QualityResults {
42
42
  totalLines: number;
43
43
  timestamp: string;
44
44
  ecosystem: string[];
45
+ /** AI-generated rationale for the score */
46
+ scoreRationale?: string;
45
47
  }
46
48
 
47
49
  export interface ScanProgress {
@@ -201,7 +201,10 @@ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: We
201
201
  }
202
202
  case 'new': {
203
203
  const oldSession = requireSession(ctx, ws, tabId);
204
+ const oldSessionId = oldSession.getSessionInfo().sessionId;
204
205
  const newSession = oldSession.startNewSession({ model: getModel() });
206
+ oldSession.destroy();
207
+ ctx.sessions.delete(oldSessionId);
205
208
  setupSessionListeners(ctx, newSession, ws, tabId);
206
209
  const newSessionId = newSession.getSessionInfo().sessionId;
207
210
  ctx.sessions.set(newSessionId, newSession);