mstro-app 0.3.8 → 0.4.0

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 (109) hide show
  1. package/LICENSE +191 -21
  2. package/PRIVACY.md +286 -62
  3. package/README.md +81 -58
  4. package/bin/commands/status.js +1 -1
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +22 -12
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/headless-logger.d.ts +10 -0
  9. package/dist/server/cli/headless/headless-logger.d.ts.map +1 -0
  10. package/dist/server/cli/headless/headless-logger.js +66 -0
  11. package/dist/server/cli/headless/headless-logger.js.map +1 -0
  12. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  13. package/dist/server/cli/headless/mcp-config.js +6 -5
  14. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  15. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  16. package/dist/server/cli/headless/runner.js +4 -0
  17. package/dist/server/cli/headless/runner.js.map +1 -1
  18. package/dist/server/cli/headless/stall-assessor.d.ts +21 -0
  19. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  20. package/dist/server/cli/headless/stall-assessor.js +100 -24
  21. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  22. package/dist/server/cli/headless/tool-watchdog.d.ts +0 -12
  23. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.js +22 -9
  25. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  26. package/dist/server/cli/headless/types.d.ts +8 -1
  27. package/dist/server/cli/headless/types.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-session-manager.d.ts +16 -0
  29. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.js +94 -11
  31. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  32. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  33. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  34. package/dist/server/mcp/bouncer-cli.js +54 -0
  35. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  36. package/dist/server/services/plan/composer.d.ts +4 -0
  37. package/dist/server/services/plan/composer.d.ts.map +1 -0
  38. package/dist/server/services/plan/composer.js +181 -0
  39. package/dist/server/services/plan/composer.js.map +1 -0
  40. package/dist/server/services/plan/dependency-resolver.d.ts +28 -0
  41. package/dist/server/services/plan/dependency-resolver.d.ts.map +1 -0
  42. package/dist/server/services/plan/dependency-resolver.js +154 -0
  43. package/dist/server/services/plan/dependency-resolver.js.map +1 -0
  44. package/dist/server/services/plan/executor.d.ts +110 -0
  45. package/dist/server/services/plan/executor.d.ts.map +1 -0
  46. package/dist/server/services/plan/executor.js +641 -0
  47. package/dist/server/services/plan/executor.js.map +1 -0
  48. package/dist/server/services/plan/parser.d.ts +11 -0
  49. package/dist/server/services/plan/parser.d.ts.map +1 -0
  50. package/dist/server/services/plan/parser.js +445 -0
  51. package/dist/server/services/plan/parser.js.map +1 -0
  52. package/dist/server/services/plan/state-reconciler.d.ts +2 -0
  53. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -0
  54. package/dist/server/services/plan/state-reconciler.js +145 -0
  55. package/dist/server/services/plan/state-reconciler.js.map +1 -0
  56. package/dist/server/services/plan/types.d.ts +121 -0
  57. package/dist/server/services/plan/types.d.ts.map +1 -0
  58. package/dist/server/services/plan/types.js +4 -0
  59. package/dist/server/services/plan/types.js.map +1 -0
  60. package/dist/server/services/plan/watcher.d.ts +14 -0
  61. package/dist/server/services/plan/watcher.d.ts.map +1 -0
  62. package/dist/server/services/plan/watcher.js +69 -0
  63. package/dist/server/services/plan/watcher.js.map +1 -0
  64. package/dist/server/services/websocket/file-explorer-handlers.js +20 -0
  65. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  66. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  67. package/dist/server/services/websocket/handler.js +21 -0
  68. package/dist/server/services/websocket/handler.js.map +1 -1
  69. package/dist/server/services/websocket/plan-handlers.d.ts +6 -0
  70. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -0
  71. package/dist/server/services/websocket/plan-handlers.js +494 -0
  72. package/dist/server/services/websocket/plan-handlers.js.map +1 -0
  73. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  74. package/dist/server/services/websocket/quality-handlers.js +384 -12
  75. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  76. package/dist/server/services/websocket/quality-persistence.d.ts +45 -0
  77. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -0
  78. package/dist/server/services/websocket/quality-persistence.js +187 -0
  79. package/dist/server/services/websocket/quality-persistence.js.map +1 -0
  80. package/dist/server/services/websocket/quality-service.d.ts +12 -2
  81. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  82. package/dist/server/services/websocket/quality-service.js +162 -18
  83. package/dist/server/services/websocket/quality-service.js.map +1 -1
  84. package/dist/server/services/websocket/types.d.ts +2 -2
  85. package/dist/server/services/websocket/types.d.ts.map +1 -1
  86. package/package.json +3 -3
  87. package/server/cli/headless/claude-invoker.ts +25 -12
  88. package/server/cli/headless/headless-logger.ts +78 -0
  89. package/server/cli/headless/mcp-config.ts +6 -5
  90. package/server/cli/headless/runner.ts +4 -0
  91. package/server/cli/headless/stall-assessor.ts +131 -24
  92. package/server/cli/headless/tool-watchdog.ts +10 -9
  93. package/server/cli/headless/types.ts +10 -1
  94. package/server/cli/improvisation-session-manager.ts +118 -11
  95. package/server/mcp/bouncer-cli.ts +73 -0
  96. package/server/services/plan/composer.ts +199 -0
  97. package/server/services/plan/dependency-resolver.ts +182 -0
  98. package/server/services/plan/executor.ts +700 -0
  99. package/server/services/plan/parser.ts +491 -0
  100. package/server/services/plan/state-reconciler.ts +174 -0
  101. package/server/services/plan/types.ts +166 -0
  102. package/server/services/plan/watcher.ts +73 -0
  103. package/server/services/websocket/file-explorer-handlers.ts +20 -0
  104. package/server/services/websocket/handler.ts +21 -0
  105. package/server/services/websocket/plan-handlers.ts +592 -0
  106. package/server/services/websocket/quality-handlers.ts +450 -12
  107. package/server/services/websocket/quality-persistence.ts +250 -0
  108. package/server/services/websocket/quality-service.ts +183 -18
  109. package/server/services/websocket/types.ts +48 -2
@@ -0,0 +1,250 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Quality Persistence — Persists quality config, reports, and history
6
+ * to .mstro/quality/ in the working directory.
7
+ *
8
+ * Files:
9
+ * .mstro/quality/config.json — Directory list (paths + labels)
10
+ * .mstro/quality/reports/<slug>.json — Latest full report per directory
11
+ * .mstro/quality/history.json — Score history entries for trend tracking
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import type { QualityResults } from './quality-service.js';
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ export interface QualityDirectoryConfig {
23
+ path: string;
24
+ label: string;
25
+ }
26
+
27
+ interface QualityConfig {
28
+ directories: QualityDirectoryConfig[];
29
+ }
30
+
31
+ export interface HistoryDirectoryEntry {
32
+ path: string;
33
+ score: number;
34
+ grade: string;
35
+ }
36
+
37
+ export interface QualityHistoryEntry {
38
+ timestamp: string;
39
+ overall: number;
40
+ grade: string;
41
+ directories: HistoryDirectoryEntry[];
42
+ }
43
+
44
+ interface QualityHistory {
45
+ entries: QualityHistoryEntry[];
46
+ }
47
+
48
+ export interface QualityPersistedState {
49
+ directories: QualityDirectoryConfig[];
50
+ reports: Record<string, QualityResults>;
51
+ history: QualityHistoryEntry[];
52
+ }
53
+
54
+ // ============================================================================
55
+ // Helpers
56
+ // ============================================================================
57
+
58
+ const MAX_HISTORY_ENTRIES = 100;
59
+
60
+ function slugify(dirPath: string): string {
61
+ if (dirPath === '.' || dirPath === './') return '_root';
62
+ return dirPath.replace(/[/\\]/g, '_').replace(/^_+|_+$/g, '') || '_root';
63
+ }
64
+
65
+ function ensureDir(dir: string): void {
66
+ if (!existsSync(dir)) {
67
+ mkdirSync(dir, { recursive: true });
68
+ }
69
+ }
70
+
71
+ function readJson<T>(filePath: string, fallback: T): T {
72
+ try {
73
+ if (existsSync(filePath)) {
74
+ return JSON.parse(readFileSync(filePath, 'utf-8')) as T;
75
+ }
76
+ } catch {
77
+ // Corrupted or unreadable — return fallback
78
+ }
79
+ return fallback;
80
+ }
81
+
82
+ function writeJson(filePath: string, data: unknown): void {
83
+ try {
84
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
85
+ } catch (error) {
86
+ console.error('[QualityPersistence] Error writing:', filePath, error);
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Quality Persistence
92
+ // ============================================================================
93
+
94
+ export class QualityPersistence {
95
+ private qualityDir: string;
96
+ private reportsDir: string;
97
+ private configPath: string;
98
+ private historyPath: string;
99
+
100
+ constructor(workingDir: string) {
101
+ this.qualityDir = join(workingDir, '.mstro', 'quality');
102
+ this.reportsDir = join(this.qualityDir, 'reports');
103
+ this.configPath = join(this.qualityDir, 'config.json');
104
+ this.historyPath = join(this.qualityDir, 'history.json');
105
+ ensureDir(this.reportsDir);
106
+ }
107
+
108
+ // ---- Config (directory list) ----
109
+
110
+ loadConfig(): QualityDirectoryConfig[] {
111
+ const config = readJson<QualityConfig>(this.configPath, { directories: [] });
112
+ return config.directories;
113
+ }
114
+
115
+ saveConfig(directories: QualityDirectoryConfig[]): void {
116
+ writeJson(this.configPath, { directories });
117
+ }
118
+
119
+ addDirectory(path: string, label: string): void {
120
+ const dirs = this.loadConfig();
121
+ if (!dirs.some((d) => d.path === path)) {
122
+ dirs.push({ path, label });
123
+ this.saveConfig(dirs);
124
+ }
125
+ }
126
+
127
+ removeDirectory(path: string): void {
128
+ const dirs = this.loadConfig().filter((d) => d.path !== path);
129
+ this.saveConfig(dirs);
130
+ }
131
+
132
+ // ---- Reports (latest per directory) ----
133
+
134
+ loadReport(dirPath: string): QualityResults | null {
135
+ const slug = slugify(dirPath);
136
+ const reportPath = join(this.reportsDir, `${slug}.json`);
137
+ return readJson<QualityResults | null>(reportPath, null);
138
+ }
139
+
140
+ saveReport(dirPath: string, results: QualityResults): void {
141
+ const slug = slugify(dirPath);
142
+ const reportPath = join(this.reportsDir, `${slug}.json`);
143
+ writeJson(reportPath, results);
144
+ }
145
+
146
+ loadAllReports(directories: QualityDirectoryConfig[]): Record<string, QualityResults> {
147
+ const reports: Record<string, QualityResults> = {};
148
+ for (const dir of directories) {
149
+ const report = this.loadReport(dir.path);
150
+ if (report) {
151
+ reports[dir.path] = report;
152
+ }
153
+ }
154
+ return reports;
155
+ }
156
+
157
+ // ---- History (trend tracking) ----
158
+
159
+ loadHistory(): QualityHistoryEntry[] {
160
+ const history = readJson<QualityHistory>(this.historyPath, { entries: [] });
161
+ return history.entries;
162
+ }
163
+
164
+ appendHistory(results: QualityResults, dirPath: string): void {
165
+ const history = this.loadHistory();
166
+
167
+ // Find or create entry for this timestamp batch
168
+ // If the last entry was within 60 seconds, merge into it (for multi-dir scans)
169
+ const now = new Date();
170
+ const lastEntry = history[history.length - 1];
171
+ const lastTime = lastEntry ? new Date(lastEntry.timestamp).getTime() : 0;
172
+ const mergeWindow = 60_000; // 60 seconds
173
+
174
+ const dirEntry: HistoryDirectoryEntry = {
175
+ path: dirPath,
176
+ score: results.overall,
177
+ grade: results.grade,
178
+ };
179
+
180
+ if (lastEntry && now.getTime() - lastTime < mergeWindow) {
181
+ // Merge: update or add this directory in the last entry
182
+ const existing = lastEntry.directories.findIndex((d) => d.path === dirPath);
183
+ if (existing >= 0) {
184
+ lastEntry.directories[existing] = dirEntry;
185
+ } else {
186
+ lastEntry.directories.push(dirEntry);
187
+ }
188
+ // Recompute overall as average of all directories in this entry
189
+ const totalScore = lastEntry.directories.reduce((sum, d) => sum + d.score, 0);
190
+ lastEntry.overall = Math.round(totalScore / lastEntry.directories.length);
191
+ lastEntry.grade = gradeFromScore(lastEntry.overall);
192
+ lastEntry.timestamp = now.toISOString();
193
+ } else {
194
+ // New entry
195
+ history.push({
196
+ timestamp: now.toISOString(),
197
+ overall: results.overall,
198
+ grade: results.grade,
199
+ directories: [dirEntry],
200
+ });
201
+ }
202
+
203
+ // Trim to max entries
204
+ while (history.length > MAX_HISTORY_ENTRIES) {
205
+ history.shift();
206
+ }
207
+
208
+ writeJson(this.historyPath, { entries: history });
209
+ }
210
+
211
+ // ---- Code Review (persisted per directory) ----
212
+
213
+ loadCodeReview(dirPath: string): { findings: Record<string, unknown>[]; summary: string; timestamp: string } | null {
214
+ const slug = slugify(dirPath);
215
+ const reviewPath = join(this.reportsDir, `${slug}-review.json`);
216
+ return readJson<{ findings: Record<string, unknown>[]; summary: string; timestamp: string } | null>(reviewPath, null);
217
+ }
218
+
219
+ saveCodeReview(dirPath: string, findings: Record<string, unknown>[], summary: string): void {
220
+ const slug = slugify(dirPath);
221
+ const reviewPath = join(this.reportsDir, `${slug}-review.json`);
222
+ writeJson(reviewPath, { findings, summary, timestamp: new Date().toISOString() });
223
+ }
224
+
225
+ // ---- Full state load ----
226
+
227
+ loadState(): QualityPersistedState {
228
+ const directories = this.loadConfig();
229
+ const reports = this.loadAllReports(directories);
230
+ const history = this.loadHistory();
231
+
232
+ // Merge persisted code reviews into reports
233
+ for (const dir of directories) {
234
+ const review = this.loadCodeReview(dir.path);
235
+ if (review && reports[dir.path]) {
236
+ reports[dir.path] = { ...reports[dir.path], codeReview: review.findings as unknown as QualityResults['codeReview'] };
237
+ }
238
+ }
239
+
240
+ return { directories, reports, history };
241
+ }
242
+ }
243
+
244
+ function gradeFromScore(score: number): string {
245
+ if (score >= 90) return 'A';
246
+ if (score >= 80) return 'B';
247
+ if (score >= 70) return 'C';
248
+ if (score >= 60) return 'D';
249
+ return 'F';
250
+ }
@@ -54,7 +54,7 @@ export interface ScanProgress {
54
54
  total: number;
55
55
  }
56
56
 
57
- type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'unknown';
57
+ type Ecosystem = 'node' | 'python' | 'rust' | 'go' | 'swift' | 'kotlin' | 'unknown';
58
58
 
59
59
  interface ToolSpec {
60
60
  name: string;
@@ -75,9 +75,9 @@ const ECOSYSTEM_TOOLS: Record<Ecosystem, ToolSpec[]> = {
75
75
  { name: 'typescript', check: ['npx', 'tsc', '--version'], category: 'general', installCmd: 'npm install -D typescript' },
76
76
  ],
77
77
  python: [
78
- { name: 'ruff', check: ['ruff', '--version'], category: 'linter', installCmd: 'pip install ruff' },
79
- { name: 'black', check: ['black', '--version'], category: 'formatter', installCmd: 'pip install black' },
80
- { name: 'radon', check: ['radon', '--version'], category: 'complexity', installCmd: 'pip install radon' },
78
+ { name: 'ruff', check: ['ruff', '--version'], category: 'linter', installCmd: 'uv tool install ruff || pip install ruff' },
79
+ { name: 'black', check: ['black', '--version'], category: 'formatter', installCmd: 'uv tool install black || pip install black' },
80
+ { name: 'radon', check: ['radon', '--version'], category: 'complexity', installCmd: 'uv tool install radon || pip install radon' },
81
81
  ],
82
82
  rust: [
83
83
  { name: 'clippy', check: ['cargo', 'clippy', '--version'], category: 'linter', installCmd: 'rustup component add clippy' },
@@ -87,6 +87,14 @@ const ECOSYSTEM_TOOLS: Record<Ecosystem, ToolSpec[]> = {
87
87
  { name: 'golangci-lint', check: ['golangci-lint', '--version'], category: 'linter', installCmd: 'go install github.com/golangci-lint/golangci-lint/cmd/golangci-lint@latest' },
88
88
  { name: 'gofmt', check: ['gofmt', '-h'], category: 'formatter', installCmd: '(built-in with Go)' },
89
89
  ],
90
+ swift: [
91
+ { name: 'swiftlint', check: ['swiftlint', '--version'], category: 'linter', installCmd: 'brew install swiftlint' },
92
+ { name: 'swiftformat', check: ['swiftformat', '--version'], category: 'formatter', installCmd: 'brew install swiftformat' },
93
+ ],
94
+ kotlin: [
95
+ { name: 'ktlint', check: ['ktlint', '--version'], category: 'linter', installCmd: 'brew install ktlint' },
96
+ { name: 'ktfmt', check: ['ktfmt', '--version'], category: 'formatter', installCmd: 'brew install ktfmt' },
97
+ ],
90
98
  unknown: [],
91
99
  };
92
100
 
@@ -113,6 +121,20 @@ const FILE_LENGTH_THRESHOLD = 300;
113
121
  const FUNCTION_LENGTH_THRESHOLD = 50;
114
122
  const TOTAL_STEPS = 7;
115
123
 
124
+ function hasInstalledToolInCategory(
125
+ installedSet: Set<string>,
126
+ ecosystems: Ecosystem[],
127
+ category: QualityTool['category'],
128
+ ): boolean {
129
+ for (const eco of ecosystems) {
130
+ const specs = ECOSYSTEM_TOOLS[eco] || [];
131
+ for (const spec of specs) {
132
+ if (spec.category === category && installedSet.has(spec.name)) return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
116
138
  // ============================================================================
117
139
  // Ecosystem Detection
118
140
  // ============================================================================
@@ -125,6 +147,8 @@ export function detectEcosystem(dirPath: string): Ecosystem[] {
125
147
  if (files.includes('pyproject.toml') || files.includes('setup.py') || files.includes('requirements.txt')) ecosystems.push('python');
126
148
  if (files.includes('Cargo.toml')) ecosystems.push('rust');
127
149
  if (files.includes('go.mod')) ecosystems.push('go');
150
+ if (files.includes('Package.swift') || files.some(f => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'))) ecosystems.push('swift');
151
+ if (files.includes('build.gradle') || files.includes('build.gradle.kts')) ecosystems.push('kotlin');
128
152
  } catch {
129
153
  // Directory not readable
130
154
  }
@@ -132,6 +156,29 @@ export function detectEcosystem(dirPath: string): Ecosystem[] {
132
156
  return ecosystems;
133
157
  }
134
158
 
159
+ /** Detect the Node.js package manager from lockfiles */
160
+ function detectNodePackageManager(dirPath: string): 'npm' | 'yarn' | 'pnpm' | 'bun' {
161
+ try {
162
+ const files = readdirSync(dirPath);
163
+ if (files.includes('bun.lockb') || files.includes('bun.lock')) return 'bun';
164
+ if (files.includes('pnpm-lock.yaml')) return 'pnpm';
165
+ if (files.includes('yarn.lock')) return 'yarn';
166
+ } catch {
167
+ // Directory not readable
168
+ }
169
+ return 'npm';
170
+ }
171
+
172
+ /** Build the install command for a Node.js dev dependency */
173
+ function nodeInstallCmd(pm: 'npm' | 'yarn' | 'pnpm' | 'bun', pkg: string): string {
174
+ switch (pm) {
175
+ case 'yarn': return `yarn add -D ${pkg}`;
176
+ case 'pnpm': return `pnpm add -D ${pkg}`;
177
+ case 'bun': return `bun add -d ${pkg}`;
178
+ default: return `npm install -D ${pkg}`;
179
+ }
180
+ }
181
+
135
182
  // ============================================================================
136
183
  // Tool Detection
137
184
  // ============================================================================
@@ -151,15 +198,20 @@ async function checkToolInstalled(check: string[], cwd: string): Promise<boolean
151
198
  export async function detectTools(dirPath: string): Promise<{ tools: QualityTool[]; ecosystem: string[] }> {
152
199
  const ecosystems = detectEcosystem(dirPath);
153
200
  const tools: QualityTool[] = [];
201
+ const nodePm = ecosystems.includes('node') ? detectNodePackageManager(dirPath) : 'npm';
154
202
 
155
203
  for (const eco of ecosystems) {
156
204
  const specs = ECOSYSTEM_TOOLS[eco] || [];
157
205
  for (const spec of specs) {
158
206
  const installed = await checkToolInstalled(spec.check, dirPath);
207
+ // For node tools, resolve install command using the project's package manager
208
+ const installCommand = eco === 'node'
209
+ ? nodeInstallCmd(nodePm, spec.installCmd.replace(/^npm install -D /, ''))
210
+ : spec.installCmd;
159
211
  tools.push({
160
212
  name: spec.name,
161
213
  installed,
162
- installCommand: spec.installCmd,
214
+ installCommand,
163
215
  category: spec.category,
164
216
  });
165
217
  }
@@ -179,14 +231,35 @@ export async function installTools(
179
231
  const { tools } = await detectTools(dirPath);
180
232
  const toInstall = tools.filter((t) => !t.installed && (!toolNames || toolNames.includes(t.name)));
181
233
 
234
+ const failures: string[] = [];
182
235
  for (const tool of toInstall) {
183
236
  if (tool.installCommand.startsWith('(')) continue; // built-in, skip
184
- const parts = tool.installCommand.split(' ');
185
- await runCommand(parts[0], parts.slice(1), dirPath);
237
+ // Support chained commands with || (try first, fallback to second)
238
+ const commands = tool.installCommand.split(' || ');
239
+ let installed = false;
240
+ for (const cmd of commands) {
241
+ const parts = cmd.trim().split(' ');
242
+ const result = await runCommand(parts[0], parts.slice(1), dirPath);
243
+ if (result.exitCode === 0) { installed = true; break; }
244
+ }
245
+ if (!installed) {
246
+ failures.push(`${tool.name}: all install methods failed`);
247
+ }
186
248
  }
187
249
 
188
250
  // Re-detect after install
189
- return detectTools(dirPath);
251
+ const detected = await detectTools(dirPath);
252
+
253
+ // Check if any requested tools are still missing after install
254
+ const requestedNames = new Set(toolNames ?? toInstall.map((t) => t.name));
255
+ const stillMissing = detected.tools.filter((t) => !t.installed && requestedNames.has(t.name)).map((t) => t.name);
256
+
257
+ if (stillMissing.length > 0) {
258
+ const detail = failures.length > 0 ? ` ${failures.join('; ')}` : '';
259
+ throw new Error(`Failed to install: ${stillMissing.join(', ')}.${detail}`);
260
+ }
261
+
262
+ return detected;
190
263
  }
191
264
 
192
265
  // ============================================================================
@@ -371,7 +444,7 @@ async function lintNode(dirPath: string, acc: LintAccumulator): Promise<void> {
371
444
 
372
445
  async function lintPython(dirPath: string, acc: LintAccumulator): Promise<void> {
373
446
  const result = await runCommand('ruff', ['check', '--output-format=json', '.'], dirPath);
374
- if (result.exitCode > 1) return;
447
+ if (result.exitCode !== 0 && !result.stdout.trim().startsWith('[')) return;
375
448
 
376
449
  acc.ran = true;
377
450
  try {
@@ -789,16 +862,47 @@ interface CategoryWeights {
789
862
  complexity: number;
790
863
  fileLength: number;
791
864
  functionLength: number;
865
+ aiReview: number;
792
866
  }
793
867
 
794
868
  const DEFAULT_WEIGHTS: CategoryWeights = {
795
- linting: 0.30,
796
- formatting: 0.15,
797
- complexity: 0.25,
798
- fileLength: 0.15,
799
- functionLength: 0.15,
869
+ linting: 0.25,
870
+ formatting: 0.10,
871
+ complexity: 0.20,
872
+ fileLength: 0.12,
873
+ functionLength: 0.13,
874
+ aiReview: 0.20,
800
875
  };
801
876
 
877
+ // ============================================================================
878
+ // AI Code Review Score
879
+ // ============================================================================
880
+
881
+ const SEVERITY_PENALTY: Record<string, number> = {
882
+ critical: 10.0,
883
+ high: 5.0,
884
+ medium: 2.0,
885
+ low: 0.5,
886
+ };
887
+
888
+ /** Exponential decay constant — higher = harsher scoring */
889
+ const AI_REVIEW_DECAY = 0.10;
890
+
891
+ export function computeAiReviewScore(
892
+ findings: Array<{ severity: string }>,
893
+ totalLines: number,
894
+ ): number {
895
+ if (findings.length === 0) return 100;
896
+
897
+ const effectiveKloc = Math.max(totalLines / 1000, 1.0);
898
+ const totalPenalty = findings.reduce(
899
+ (sum, f) => sum + (SEVERITY_PENALTY[f.severity] ?? 2.0),
900
+ 0,
901
+ );
902
+ const penaltyDensity = totalPenalty / effectiveKloc;
903
+ return Math.round(100 * Math.exp(-AI_REVIEW_DECAY * penaltyDensity));
904
+ }
905
+
802
906
  function computeOverallScore(categories: CategoryScore[]): number {
803
907
  const available = categories.filter((c) => c.available);
804
908
  if (available.length === 0) return 0;
@@ -823,9 +927,13 @@ export type ProgressCallback = (progress: ScanProgress) => void;
823
927
  export async function runQualityScan(
824
928
  dirPath: string,
825
929
  onProgress?: ProgressCallback,
930
+ installedToolNames?: string[],
826
931
  ): Promise<QualityResults> {
827
932
  const ecosystems = detectEcosystem(dirPath);
828
933
 
934
+ // Build set of installed tools for gating analyses
935
+ const installedSet = installedToolNames ? new Set(installedToolNames) : null;
936
+
829
937
  const progress = (step: string, current: number) => {
830
938
  onProgress?.({ step, current, total: TOTAL_STEPS });
831
939
  };
@@ -834,13 +942,19 @@ export async function runQualityScan(
834
942
  progress('Collecting source files', 1);
835
943
  const files = collectSourceFiles(dirPath, dirPath);
836
944
 
837
- // Step 2: Run linting
945
+ // Step 2: Run linting (only if a linter is installed)
838
946
  progress('Running linters', 2);
839
- const lintResult = await analyzeLinting(dirPath, ecosystems, files);
947
+ const hasLinter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'linter');
948
+ const lintResult = hasLinter
949
+ ? await analyzeLinting(dirPath, ecosystems, files)
950
+ : { score: 0, findings: [], available: false, issueCount: 0 };
840
951
 
841
- // Step 3: Check formatting
952
+ // Step 3: Check formatting (only if a formatter is installed)
842
953
  progress('Checking formatting', 3);
843
- const fmtResult = await analyzeFormatting(dirPath, ecosystems, files);
954
+ const hasFormatter = !installedSet || hasInstalledToolInCategory(installedSet, ecosystems, 'formatter');
955
+ const fmtResult = hasFormatter
956
+ ? await analyzeFormatting(dirPath, ecosystems, files)
957
+ : { score: 0, available: false, issueCount: 0 };
844
958
 
845
959
  // Step 4: Analyze complexity
846
960
  progress('Analyzing complexity', 4);
@@ -898,6 +1012,14 @@ export async function runQualityScan(
898
1012
  available: true,
899
1013
  issueCount: funcLengthResult.issueCount,
900
1014
  },
1015
+ {
1016
+ name: 'AI Review',
1017
+ score: 0,
1018
+ weight: DEFAULT_WEIGHTS.aiReview,
1019
+ effectiveWeight: DEFAULT_WEIGHTS.aiReview,
1020
+ available: false,
1021
+ issueCount: 0,
1022
+ },
901
1023
  ];
902
1024
 
903
1025
  const overall = computeOverallScore(categories);
@@ -920,3 +1042,46 @@ export async function runQualityScan(
920
1042
  ecosystem: ecosystems,
921
1043
  };
922
1044
  }
1045
+
1046
+ // ============================================================================
1047
+ // Recompute with AI Review
1048
+ // ============================================================================
1049
+
1050
+ /**
1051
+ * Recompute the overall score after AI code review findings become available.
1052
+ * Returns a new QualityResults with the AI Review category enabled and score updated.
1053
+ */
1054
+ export function recomputeWithAiReview(
1055
+ results: QualityResults,
1056
+ aiFindings: Array<{ severity: string }>,
1057
+ ): QualityResults {
1058
+ const aiScore = computeAiReviewScore(aiFindings, results.totalLines);
1059
+
1060
+ // Update or add the AI Review category
1061
+ const categories = results.categories.map((cat) => ({ ...cat }));
1062
+ const aiCatIndex = categories.findIndex((c) => c.name === 'AI Review');
1063
+ const aiCategory: CategoryScore = {
1064
+ name: 'AI Review',
1065
+ score: aiScore,
1066
+ weight: DEFAULT_WEIGHTS.aiReview,
1067
+ effectiveWeight: DEFAULT_WEIGHTS.aiReview,
1068
+ available: true,
1069
+ issueCount: aiFindings.length,
1070
+ };
1071
+
1072
+ if (aiCatIndex >= 0) {
1073
+ categories[aiCatIndex] = aiCategory;
1074
+ } else {
1075
+ categories.push(aiCategory);
1076
+ }
1077
+
1078
+ const overall = computeOverallScore(categories);
1079
+
1080
+ return {
1081
+ ...results,
1082
+ overall,
1083
+ grade: computeGrade(overall),
1084
+ categories,
1085
+ codeReview: results.codeReview,
1086
+ };
1087
+ }
@@ -112,11 +112,31 @@ export interface WebSocketMessage {
112
112
  | 'qualityScan'
113
113
  | 'qualityInstallTools'
114
114
  | 'qualityCodeReview'
115
+ | 'qualityFixIssues'
116
+ | 'qualityLoadState'
117
+ | 'qualitySaveDirectories'
115
118
  // File upload message types (chunked remote uploads)
116
119
  | 'fileUploadStart'
117
120
  | 'fileUploadChunk'
118
121
  | 'fileUploadComplete'
119
- | 'fileUploadCancel';
122
+ | 'fileUploadCancel'
123
+ // Plan message types
124
+ | 'planInit'
125
+ | 'planGetState'
126
+ | 'planListIssues'
127
+ | 'planGetIssue'
128
+ | 'planGetSprint'
129
+ | 'planGetMilestone'
130
+ | 'planCreateIssue'
131
+ | 'planUpdateIssue'
132
+ | 'planDeleteIssue'
133
+ | 'planScaffold'
134
+ | 'planPrompt'
135
+ | 'planExecute'
136
+ | 'planExecuteEpic'
137
+ | 'planPause'
138
+ | 'planStop'
139
+ | 'planResume';
120
140
  tabId?: string;
121
141
  terminalId?: string;
122
142
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
@@ -231,12 +251,38 @@ export interface WebSocketResponse {
231
251
  | 'qualityInstallProgress'
232
252
  | 'qualityInstallComplete'
233
253
  | 'qualityCodeReview'
254
+ | 'qualityCodeReviewProgress'
234
255
  | 'qualityPostSession'
256
+ | 'qualityFixProgress'
257
+ | 'qualityFixComplete'
235
258
  | 'qualityError'
259
+ | 'qualityStateLoaded'
236
260
  // File upload response types
237
261
  | 'fileUploadAck'
238
262
  | 'fileUploadReady'
239
- | 'fileUploadError';
263
+ | 'fileUploadError'
264
+ // Plan response types
265
+ | 'planState'
266
+ | 'planIssueList'
267
+ | 'planIssue'
268
+ | 'planSprint'
269
+ | 'planMilestone'
270
+ | 'planNotFound'
271
+ | 'planStateUpdated'
272
+ | 'planIssueUpdated'
273
+ | 'planIssueCreated'
274
+ | 'planIssueDeleted'
275
+ | 'planScaffolded'
276
+ | 'planPromptStreaming'
277
+ | 'planPromptProgress'
278
+ | 'planPromptResponse'
279
+ | 'planExecutionStarted'
280
+ | 'planExecutionProgress'
281
+ | 'planExecutionOutput'
282
+ | 'planExecutionMetrics'
283
+ | 'planExecutionComplete'
284
+ | 'planExecutionError'
285
+ | 'planError';
240
286
  tabId?: string;
241
287
  terminalId?: string;
242
288
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads