mstro-app 0.4.33 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  2. package/dist/server/cli/headless/claude-invoker-stream.js +63 -0
  3. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  4. package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
  5. package/dist/server/cli/headless/haiku-assessments.js +10 -5
  6. package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
  7. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  8. package/dist/server/cli/improvisation-retry.js +17 -2
  9. package/dist/server/cli/improvisation-retry.js.map +1 -1
  10. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  11. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  12. package/dist/server/cli/improvisation-session-manager.js +13 -5
  13. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  14. package/dist/server/cli/improvisation-types.d.ts +1 -0
  15. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  16. package/dist/server/cli/improvisation-types.js.map +1 -1
  17. package/dist/server/services/plan/composer.d.ts +1 -1
  18. package/dist/server/services/plan/composer.d.ts.map +1 -1
  19. package/dist/server/services/plan/composer.js +2 -2
  20. package/dist/server/services/plan/composer.js.map +1 -1
  21. package/dist/server/services/plan/executor.d.ts +3 -1
  22. package/dist/server/services/plan/executor.d.ts.map +1 -1
  23. package/dist/server/services/plan/executor.js +8 -3
  24. package/dist/server/services/plan/executor.js.map +1 -1
  25. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  26. package/dist/server/services/plan/parser-core.js +15 -0
  27. package/dist/server/services/plan/parser-core.js.map +1 -1
  28. package/dist/server/services/plan/types.d.ts +5 -0
  29. package/dist/server/services/plan/types.d.ts.map +1 -1
  30. package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
  31. package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
  32. package/dist/server/services/websocket/git-head-watcher.js +136 -0
  33. package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
  34. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  35. package/dist/server/services/websocket/git-worktree-handlers.js +84 -13
  36. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  37. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  38. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  39. package/dist/server/services/websocket/handler.d.ts +3 -1
  40. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  41. package/dist/server/services/websocket/handler.js +18 -6
  42. package/dist/server/services/websocket/handler.js.map +1 -1
  43. package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
  44. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  45. package/dist/server/services/websocket/plan-board-handlers.js +94 -0
  46. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  47. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  48. package/dist/server/services/websocket/plan-execution-handlers.js +4 -2
  49. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  50. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  51. package/dist/server/services/websocket/plan-handlers.js +3 -1
  52. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  53. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  54. package/dist/server/services/websocket/plan-issue-handlers.js +9 -0
  55. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  56. package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
  57. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  58. package/dist/server/services/websocket/quality-persistence.js +15 -7
  59. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  60. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  61. package/dist/server/services/websocket/quality-review-agent.js +2 -13
  62. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  63. package/dist/server/services/websocket/quality-service.d.ts +12 -3
  64. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  65. package/dist/server/services/websocket/quality-service.js +101 -81
  66. package/dist/server/services/websocket/quality-service.js.map +1 -1
  67. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  68. package/dist/server/services/websocket/quality-tools.js +6 -1
  69. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  70. package/dist/server/services/websocket/quality-types.d.ts +15 -2
  71. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  72. package/dist/server/services/websocket/quality-types.js.map +1 -1
  73. package/dist/server/services/websocket/session-handlers.js +2 -2
  74. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  75. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  76. package/dist/server/services/websocket/tab-handlers.js +9 -2
  77. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  78. package/dist/server/services/websocket/types.d.ts +2 -2
  79. package/dist/server/services/websocket/types.d.ts.map +1 -1
  80. package/package.json +2 -1
  81. package/server/cli/headless/claude-invoker-stream.ts +63 -0
  82. package/server/cli/headless/haiku-assessments.ts +10 -5
  83. package/server/cli/improvisation-retry.ts +18 -2
  84. package/server/cli/improvisation-session-manager.ts +13 -5
  85. package/server/cli/improvisation-types.ts +1 -0
  86. package/server/services/plan/agents/assess-stall.md +21 -0
  87. package/server/services/plan/agents/check-injection.md +36 -0
  88. package/server/services/plan/agents/classify-error.md +29 -0
  89. package/server/services/plan/agents/detect-context-loss.md +29 -0
  90. package/server/services/plan/agents/execute-issue.md +42 -0
  91. package/server/services/plan/agents/plan-coordinator.md +71 -0
  92. package/server/services/plan/agents/retry-task.md +26 -0
  93. package/server/services/plan/agents/review-code.md +4 -1
  94. package/server/services/plan/agents/review-criteria.md +53 -0
  95. package/server/services/plan/agents/review-custom.md +4 -1
  96. package/server/services/plan/agents/review-quality.md +4 -1
  97. package/server/services/plan/agents/verify-review.md +56 -0
  98. package/server/services/plan/composer.ts +2 -1
  99. package/server/services/plan/executor.ts +8 -3
  100. package/server/services/plan/parser-core.ts +14 -0
  101. package/server/services/plan/types.ts +6 -0
  102. package/server/services/websocket/git-head-watcher.ts +120 -0
  103. package/server/services/websocket/git-worktree-handlers.ts +85 -15
  104. package/server/services/websocket/handler-context.ts +2 -0
  105. package/server/services/websocket/handler.ts +19 -6
  106. package/server/services/websocket/plan-board-handlers.ts +116 -0
  107. package/server/services/websocket/plan-execution-handlers.ts +4 -2
  108. package/server/services/websocket/plan-handlers.ts +3 -1
  109. package/server/services/websocket/plan-issue-handlers.ts +10 -0
  110. package/server/services/websocket/quality-persistence.ts +23 -7
  111. package/server/services/websocket/quality-review-agent.ts +2 -12
  112. package/server/services/websocket/quality-service.ts +116 -99
  113. package/server/services/websocket/quality-tools.ts +6 -1
  114. package/server/services/websocket/quality-types.ts +17 -2
  115. package/server/services/websocket/session-handlers.ts +2 -2
  116. package/server/services/websocket/tab-handlers.ts +8 -2
  117. package/server/services/websocket/types.ts +7 -2
@@ -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. */
@@ -24,7 +24,8 @@ export function handlePrompt(
24
24
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
25
25
  return;
26
26
  }
27
- handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
27
+ const executionDir = boardId ? ctx.gitDirectories.get(boardId) : undefined;
28
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, executionDir).catch(error => {
28
29
  ctx.send(ws, {
29
30
  type: 'planError',
30
31
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -109,8 +110,9 @@ export function handleExecute(
109
110
  wireExecutorEvents(executor, ctx, workingDir);
110
111
 
111
112
  const boardId = msg.data?.boardId as string | undefined;
113
+ const executionDir = boardId ? ctx.gitDirectories.get(boardId) : undefined;
112
114
  ctx.send(ws, { type: 'planExecutionStarted', data: { status: 'executing', boardId } });
113
- const startPromise = boardId ? executor.startBoard(boardId) : executor.start();
115
+ const startPromise = boardId ? executor.startBoard(boardId, executionDir) : executor.start();
114
116
  startPromise.catch(error => {
115
117
  ctx.send(ws, {
116
118
  type: 'planExecutionError',
@@ -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),
@@ -26,6 +26,16 @@ export function handlePlanInit(ctx: HandlerContext, ws: WSContext, workingDir: s
26
26
  return;
27
27
  }
28
28
 
29
+ // Restore board worktree assignments into runtime maps
30
+ if (fullState.workspace?.boardWorktrees) {
31
+ for (const [boardId, entry] of Object.entries(fullState.workspace.boardWorktrees)) {
32
+ if (!ctx.gitDirectories.has(boardId)) {
33
+ ctx.gitDirectories.set(boardId, entry.path);
34
+ ctx.gitBranches.set(boardId, entry.branch);
35
+ }
36
+ }
37
+ }
38
+
29
39
  ctx.send(ws, { type: 'planState', data: fullState });
30
40
 
31
41
  const watcher = getWatcher(workingDir, ctx);
@@ -34,10 +34,18 @@ export interface HistoryDirectoryEntry {
34
34
  grade: string;
35
35
  }
36
36
 
37
+ export interface HistoryCategoryScore {
38
+ category: string;
39
+ score: number;
40
+ grade: string;
41
+ }
42
+
37
43
  export interface QualityHistoryEntry {
38
44
  timestamp: string;
39
45
  overall: number;
40
46
  grade: string;
47
+ issueDensity?: number;
48
+ categoryScores?: HistoryCategoryScore[];
41
49
  directories: HistoryDirectoryEntry[];
42
50
  }
43
51
 
@@ -187,12 +195,10 @@ export class QualityPersistence {
187
195
  appendHistory(results: QualityResults, dirPath: string): void {
188
196
  const history = this.loadHistory();
189
197
 
190
- // Find or create entry for this timestamp batch
191
- // If the last entry was within 60 seconds, merge into it (for multi-dir scans)
192
198
  const now = new Date();
193
199
  const lastEntry = history[history.length - 1];
194
200
  const lastTime = lastEntry ? new Date(lastEntry.timestamp).getTime() : 0;
195
- const mergeWindow = 60_000; // 60 seconds
201
+ const mergeWindow = 60_000;
196
202
 
197
203
  const dirEntry: HistoryDirectoryEntry = {
198
204
  path: dirPath,
@@ -200,30 +206,40 @@ export class QualityPersistence {
200
206
  grade: results.grade,
201
207
  };
202
208
 
209
+ const categoryScores: HistoryCategoryScore[] | undefined = results.scoreBreakdown
210
+ ? results.scoreBreakdown.categoryPenalties.map((cp) => ({
211
+ category: cp.category,
212
+ score: cp.score,
213
+ grade: cp.grade,
214
+ }))
215
+ : undefined;
216
+
217
+ const issueDensity = results.scoreBreakdown?.issueDensity;
218
+
203
219
  if (lastEntry && now.getTime() - lastTime < mergeWindow) {
204
- // Merge: update or add this directory in the last entry
205
220
  const existing = lastEntry.directories.findIndex((d) => d.path === dirPath);
206
221
  if (existing >= 0) {
207
222
  lastEntry.directories[existing] = dirEntry;
208
223
  } else {
209
224
  lastEntry.directories.push(dirEntry);
210
225
  }
211
- // Recompute overall as average of all directories in this entry
212
226
  const totalScore = lastEntry.directories.reduce((sum, d) => sum + d.score, 0);
213
227
  lastEntry.overall = Math.round(totalScore / lastEntry.directories.length);
214
228
  lastEntry.grade = gradeFromScore(lastEntry.overall);
215
229
  lastEntry.timestamp = now.toISOString();
230
+ if (categoryScores) lastEntry.categoryScores = categoryScores;
231
+ if (issueDensity !== undefined) lastEntry.issueDensity = issueDensity;
216
232
  } else {
217
- // New entry
218
233
  history.push({
219
234
  timestamp: now.toISOString(),
220
235
  overall: results.overall,
221
236
  grade: results.grade,
237
+ issueDensity,
238
+ categoryScores,
222
239
  directories: [dirEntry],
223
240
  });
224
241
  }
225
242
 
226
- // Trim to max entries
227
243
  while (history.length > MAX_HISTORY_ENTRIES) {
228
244
  history.shift();
229
245
  }
@@ -443,18 +443,8 @@ function persistReviewResults(
443
443
  }
444
444
 
445
445
  let updatedResults: import('./quality-service.js').QualityResults;
446
- if (reviewResult.score !== null && reviewResult.grade !== null) {
447
- updatedResults = {
448
- ...existingReport,
449
- overall: reviewResult.score,
450
- grade: reviewResult.grade,
451
- codeReview: findings,
452
- scoreRationale: reviewResult.scoreRationale ?? undefined,
453
- };
454
- } else {
455
- updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
456
- updatedResults = { ...updatedResults, codeReview: findings };
457
- }
446
+ updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
447
+ updatedResults = { ...updatedResults, codeReview: findings };
458
448
 
459
449
  persistence.saveReport(reportPath, updatedResults);
460
450
  persistence.appendHistory(updatedResults, reportPath);
@@ -5,11 +5,11 @@ import { extname } from 'node:path';
5
5
  import { analyzeComplexity, analyzeFunctionLength } from './quality-complexity.js';
6
6
  import { analyzeLinting } from './quality-linting.js';
7
7
  import { collectSourceFiles, detectEcosystem, runCommand, type SourceFile } from './quality-tools.js';
8
- import { type CategoryScore, type Ecosystem, FILE_LENGTH_THRESHOLD, hasInstalledToolInCategory, type QualityFinding, type QualityResults, type ScanProgress, TOTAL_STEPS } from './quality-types.js';
8
+ import { type CategoryPenalty, type CategoryScore, type Ecosystem, FILE_LENGTH_THRESHOLD, hasInstalledToolInCategory, type QualityFinding, type QualityResults, type ScanProgress, type ScoreBreakdown, TOTAL_STEPS } from './quality-types.js';
9
9
 
10
10
  export { detectEcosystem, detectTools, installTools } from './quality-tools.js';
11
11
  // Re-export public API for backward compatibility
12
- export type { CategoryScore, QualityFinding, QualityResults, QualityTool, ScanProgress } from './quality-types.js';
12
+ export type { CategoryPenalty, CategoryScore, QualityFinding, QualityResults, QualityTool, ScanProgress, ScoreBreakdown } from './quality-types.js';
13
13
 
14
14
  // ============================================================================
15
15
  // Formatting Analysis
@@ -124,7 +124,7 @@ function analyzeFileLength(files: SourceFile[]): { score: number; findings: Qual
124
124
  }
125
125
 
126
126
  // ============================================================================
127
- // Scoring
127
+ // Deterministic Scoring — penalty-density exponential decay
128
128
  // ============================================================================
129
129
 
130
130
  function computeGrade(score: number): string {
@@ -135,66 +135,107 @@ function computeGrade(score: number): string {
135
135
  return 'F';
136
136
  }
137
137
 
138
- interface CategoryWeights {
139
- linting: number;
140
- formatting: number;
141
- complexity: number;
142
- fileLength: number;
143
- functionLength: number;
144
- aiReview: number;
145
- }
138
+ const SEVERITY_WEIGHT: Record<string, number> = {
139
+ critical: 10,
140
+ high: 5,
141
+ medium: 2,
142
+ low: 0.5,
143
+ };
146
144
 
147
- const DEFAULT_WEIGHTS: CategoryWeights = {
148
- linting: 0.25,
149
- formatting: 0.10,
150
- complexity: 0.20,
151
- fileLength: 0.12,
152
- functionLength: 0.13,
153
- aiReview: 0.20,
145
+ const CATEGORY_MULTIPLIER: Record<string, number> = {
146
+ security: 2.0,
147
+ bugs: 1.5,
148
+ architecture: 1.2,
149
+ logic: 1.2,
150
+ performance: 1.0,
151
+ oop: 0.8,
152
+ maintainability: 0.8,
153
+ complexity: 0.7,
154
+ lint: 0.5,
155
+ linting: 0.5,
156
+ format: 0.3,
157
+ 'file-length': 0.3,
158
+ 'function-length': 0.3,
154
159
  };
155
160
 
156
- // ============================================================================
157
- // AI Code Review Score
158
- // ============================================================================
161
+ const OVERALL_DECAY = 0.09;
162
+ const CATEGORY_DECAY = 0.20;
159
163
 
160
- const SEVERITY_PENALTY: Record<string, number> = {
161
- critical: 10.0,
162
- high: 5.0,
163
- medium: 2.0,
164
- low: 0.5,
165
- };
164
+ function findingPenalty(f: { severity: string; category: string }): number {
165
+ return (SEVERITY_WEIGHT[f.severity] ?? 2) * (CATEGORY_MULTIPLIER[f.category] ?? 1.0);
166
+ }
167
+
168
+ export function computeFormulaScore(
169
+ allFindings: Array<{ severity: string; category: string }>,
170
+ totalLines: number,
171
+ ): { score: number; breakdown: ScoreBreakdown } {
172
+ const kloc = Math.max(totalLines / 1000, 1.0);
173
+
174
+ if (allFindings.length === 0) {
175
+ return {
176
+ score: 100,
177
+ breakdown: { penaltyDensity: 0, totalPenalty: 0, issueDensity: 0, kloc, categoryPenalties: [] },
178
+ };
179
+ }
180
+
181
+ const byCategory = new Map<string, { penalty: number; count: number }>();
182
+ let totalPenalty = 0;
183
+
184
+ for (const f of allFindings) {
185
+ const p = findingPenalty(f);
186
+ totalPenalty += p;
187
+ const existing = byCategory.get(f.category);
188
+ if (existing) {
189
+ existing.penalty += p;
190
+ existing.count++;
191
+ } else {
192
+ byCategory.set(f.category, { penalty: p, count: 1 });
193
+ }
194
+ }
195
+
196
+ const penaltyDensity = totalPenalty / kloc;
197
+ const score = Math.round(100 * Math.exp(-OVERALL_DECAY * penaltyDensity));
198
+
199
+ const categoryPenalties: CategoryPenalty[] = [];
200
+ for (const [cat, data] of byCategory) {
201
+ const catDensity = data.penalty / kloc;
202
+ const catScore = Math.round(100 * Math.exp(-CATEGORY_DECAY * catDensity));
203
+ categoryPenalties.push({
204
+ category: cat,
205
+ score: catScore,
206
+ grade: computeGrade(catScore),
207
+ penalty: Math.round(data.penalty * 10) / 10,
208
+ findingCount: data.count,
209
+ });
210
+ }
166
211
 
167
- /** Exponential decay constant higher = harsher scoring */
168
- const AI_REVIEW_DECAY = 0.10;
212
+ categoryPenalties.sort((a, b) => a.score - b.score);
169
213
 
214
+ return {
215
+ score,
216
+ breakdown: {
217
+ penaltyDensity: Math.round(penaltyDensity * 100) / 100,
218
+ totalPenalty: Math.round(totalPenalty * 10) / 10,
219
+ issueDensity: Math.round((allFindings.length / kloc) * 100) / 100,
220
+ kloc: Math.round(kloc * 10) / 10,
221
+ categoryPenalties,
222
+ },
223
+ };
224
+ }
225
+
226
+ /** @deprecated — use computeFormulaScore instead */
170
227
  export function computeAiReviewScore(
171
228
  findings: Array<{ severity: string }>,
172
229
  totalLines: number,
173
230
  ): number {
174
231
  if (findings.length === 0) return 100;
175
-
176
232
  const effectiveKloc = Math.max(totalLines / 1000, 1.0);
177
233
  const totalPenalty = findings.reduce(
178
- (sum, f) => sum + (SEVERITY_PENALTY[f.severity] ?? 2.0),
234
+ (sum, f) => sum + (SEVERITY_WEIGHT[f.severity] ?? 2.0),
179
235
  0,
180
236
  );
181
237
  const penaltyDensity = totalPenalty / effectiveKloc;
182
- return Math.round(100 * Math.exp(-AI_REVIEW_DECAY * penaltyDensity));
183
- }
184
-
185
- function computeOverallScore(categories: CategoryScore[]): number {
186
- const available = categories.filter((c) => c.available);
187
- if (available.length === 0) return 0;
188
-
189
- const totalWeight = available.reduce((sum, c) => sum + c.weight, 0);
190
- let weighted = 0;
191
- for (const cat of available) {
192
- const effectiveWeight = cat.weight / totalWeight;
193
- cat.effectiveWeight = effectiveWeight;
194
- weighted += cat.score * effectiveWeight;
195
- }
196
-
197
- return Math.round(Math.max(0, Math.min(100, weighted)));
238
+ return Math.round(100 * Math.exp(-0.10 * penaltyDensity));
198
239
  }
199
240
 
200
241
  // ============================================================================
@@ -250,64 +291,58 @@ export async function runQualityScan(
250
291
  // Step 7: Compute scores
251
292
  progress('Computing scores', 7);
252
293
 
294
+ const allFindings = [
295
+ ...lintResult.findings,
296
+ ...fmtResult.findings,
297
+ ...complexityResult.findings,
298
+ ...fileLengthResult.findings,
299
+ ...funcLengthResult.findings,
300
+ ];
301
+
302
+ const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
303
+ const { score: overall, breakdown } = computeFormulaScore(allFindings, totalLines);
304
+
253
305
  const categories: CategoryScore[] = [
254
306
  {
255
307
  name: 'Linting',
256
308
  score: lintResult.score,
257
- weight: DEFAULT_WEIGHTS.linting,
258
- effectiveWeight: DEFAULT_WEIGHTS.linting,
309
+ weight: 0,
310
+ effectiveWeight: 0,
259
311
  available: lintResult.available,
260
312
  issueCount: lintResult.issueCount,
261
313
  },
262
314
  {
263
315
  name: 'Formatting',
264
316
  score: fmtResult.score,
265
- weight: DEFAULT_WEIGHTS.formatting,
266
- effectiveWeight: DEFAULT_WEIGHTS.formatting,
317
+ weight: 0,
318
+ effectiveWeight: 0,
267
319
  available: fmtResult.available,
268
320
  issueCount: fmtResult.issueCount,
269
321
  },
270
322
  {
271
323
  name: 'Complexity',
272
324
  score: complexityResult.score,
273
- weight: DEFAULT_WEIGHTS.complexity,
274
- effectiveWeight: DEFAULT_WEIGHTS.complexity,
325
+ weight: 0,
326
+ effectiveWeight: 0,
275
327
  available: complexityResult.available,
276
328
  issueCount: complexityResult.issueCount,
277
329
  },
278
330
  {
279
331
  name: 'File Length',
280
332
  score: fileLengthResult.score,
281
- weight: DEFAULT_WEIGHTS.fileLength,
282
- effectiveWeight: DEFAULT_WEIGHTS.fileLength,
333
+ weight: 0,
334
+ effectiveWeight: 0,
283
335
  available: true,
284
336
  issueCount: fileLengthResult.issueCount,
285
337
  },
286
338
  {
287
339
  name: 'Function Length',
288
340
  score: funcLengthResult.score,
289
- weight: DEFAULT_WEIGHTS.functionLength,
290
- effectiveWeight: DEFAULT_WEIGHTS.functionLength,
341
+ weight: 0,
342
+ effectiveWeight: 0,
291
343
  available: true,
292
344
  issueCount: funcLengthResult.issueCount,
293
345
  },
294
- {
295
- name: 'AI Review',
296
- score: 0,
297
- weight: DEFAULT_WEIGHTS.aiReview,
298
- effectiveWeight: DEFAULT_WEIGHTS.aiReview,
299
- available: false,
300
- issueCount: 0,
301
- },
302
- ];
303
-
304
- const overall = computeOverallScore(categories);
305
- const allFindings = [
306
- ...lintResult.findings,
307
- ...fmtResult.findings,
308
- ...complexityResult.findings,
309
- ...fileLengthResult.findings,
310
- ...funcLengthResult.findings,
311
346
  ];
312
347
 
313
348
  return {
@@ -317,9 +352,10 @@ export async function runQualityScan(
317
352
  findings: allFindings.slice(0, 200),
318
353
  codeReview: [],
319
354
  analyzedFiles: files.length,
320
- totalLines: files.reduce((sum, f) => sum + f.lines, 0),
355
+ totalLines,
321
356
  timestamp: new Date().toISOString(),
322
357
  ecosystem: ecosystems,
358
+ scoreBreakdown: breakdown,
323
359
  };
324
360
  }
325
361
 
@@ -329,39 +365,20 @@ export async function runQualityScan(
329
365
 
330
366
  /**
331
367
  * Recompute the overall score after AI code review findings become available.
332
- * Returns a new QualityResults with the AI Review category enabled and score updated.
368
+ * Merges CLI + AI findings and runs the deterministic penalty-density formula.
333
369
  */
334
370
  export function recomputeWithAiReview(
335
371
  results: QualityResults,
336
- aiFindings: Array<{ severity: string }>,
372
+ aiFindings: Array<{ severity: string; category: string }>,
337
373
  ): QualityResults {
338
- const aiScore = computeAiReviewScore(aiFindings, results.totalLines);
339
-
340
- // Update or add the AI Review category
341
- const categories = results.categories.map((cat) => ({ ...cat }));
342
- const aiCatIndex = categories.findIndex((c) => c.name === 'AI Review');
343
- const aiCategory: CategoryScore = {
344
- name: 'AI Review',
345
- score: aiScore,
346
- weight: DEFAULT_WEIGHTS.aiReview,
347
- effectiveWeight: DEFAULT_WEIGHTS.aiReview,
348
- available: true,
349
- issueCount: aiFindings.length,
350
- };
351
-
352
- if (aiCatIndex >= 0) {
353
- categories[aiCatIndex] = aiCategory;
354
- } else {
355
- categories.push(aiCategory);
356
- }
357
-
358
- const overall = computeOverallScore(categories);
374
+ const allFindings = [...results.findings, ...aiFindings];
375
+ const { score: overall, breakdown } = computeFormulaScore(allFindings, results.totalLines);
359
376
 
360
377
  return {
361
378
  ...results,
362
379
  overall,
363
380
  grade: computeGrade(overall),
364
- categories,
365
381
  codeReview: results.codeReview,
382
+ scoreBreakdown: breakdown,
366
383
  };
367
384
  }
@@ -65,6 +65,7 @@ async function checkToolInstalled(check: string[], cwd: string): Promise<boolean
65
65
  return new Promise((resolve) => {
66
66
  const proc = spawn(check[0], check.slice(1), {
67
67
  cwd,
68
+ shell: true,
68
69
  stdio: ['ignore', 'pipe', 'pipe'],
69
70
  timeout: 10000,
70
71
  });
@@ -109,13 +110,16 @@ export async function installTools(
109
110
  if (tool.installCommand.startsWith('(')) continue;
110
111
  const commands = tool.installCommand.split(' || ');
111
112
  let installed = false;
113
+ let lastStderr = '';
112
114
  for (const cmd of commands) {
113
115
  const parts = cmd.trim().split(' ');
114
116
  const result = await runCommand(parts[0], parts.slice(1), dirPath);
115
117
  if (result.exitCode === 0) { installed = true; break; }
118
+ lastStderr = result.stderr;
116
119
  }
117
120
  if (!installed) {
118
- failures.push(`${tool.name}: all install methods failed`);
121
+ const detail = lastStderr ? ` (${lastStderr.trim().split('\n').pop()})` : '';
122
+ failures.push(`${tool.name}: install failed${detail}`);
119
123
  }
120
124
  }
121
125
 
@@ -241,6 +245,7 @@ export function runCommand(cmd: string, args: string[], cwd: string): Promise<{
241
245
  return new Promise((resolve) => {
242
246
  const proc = spawn(cmd, args, {
243
247
  cwd,
248
+ shell: true,
244
249
  stdio: ['ignore', 'pipe', 'pipe'],
245
250
  timeout: 120000,
246
251
  });
@@ -35,6 +35,22 @@ export interface QualityFinding {
35
35
  verificationNote?: string;
36
36
  }
37
37
 
38
+ export interface CategoryPenalty {
39
+ category: string;
40
+ score: number;
41
+ grade: string;
42
+ penalty: number;
43
+ findingCount: number;
44
+ }
45
+
46
+ export interface ScoreBreakdown {
47
+ penaltyDensity: number;
48
+ totalPenalty: number;
49
+ issueDensity: number;
50
+ kloc: number;
51
+ categoryPenalties: CategoryPenalty[];
52
+ }
53
+
38
54
  export interface QualityResults {
39
55
  overall: number;
40
56
  grade: string;
@@ -45,8 +61,7 @@ export interface QualityResults {
45
61
  totalLines: number;
46
62
  timestamp: string;
47
63
  ecosystem: string[];
48
- /** AI-generated rationale for the score */
49
- scoreRationale?: string;
64
+ scoreBreakdown?: ScoreBreakdown;
50
65
  }
51
66
 
52
67
  export interface ScanProgress {
@@ -104,8 +104,8 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
104
104
  ctx.send(ws, { type: 'thinking', tabId, data: { text } });
105
105
  });
106
106
 
107
- session.on('onMovementStart', (sequenceNumber: number, prompt: string) => {
108
- ctx.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp } });
107
+ session.on('onMovementStart', (sequenceNumber: number, prompt: string, isAutoContinue?: boolean) => {
108
+ ctx.send(ws, { type: 'movementStart', tabId, data: { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp, isAutoContinue } });
109
109
  ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
110
110
  });
111
111