claude-flow 3.56.7 → 3.56.9

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 (47) hide show
  1. package/.claude/helpers/hook-handler.cjs +109 -23
  2. package/README.md +9 -3
  3. package/node_modules/@claude-flow/codex/dist/cli.js +0 -0
  4. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts +1 -0
  5. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.d.ts.map +1 -1
  6. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js +3 -1
  7. package/node_modules/@claude-flow/codex/dist/dual-mode/orchestrator.js.map +1 -1
  8. package/node_modules/@claude-flow/codex/dist/index.d.ts +3 -3
  9. package/node_modules/@claude-flow/codex/dist/index.js +1 -1
  10. package/node_modules/@claude-flow/codex/package.json +1 -1
  11. package/node_modules/@claude-flow/plugin-agent-federation/dist/bin.js +0 -0
  12. package/node_modules/@claude-flow/security/dist/policy/envelope.js +6 -6
  13. package/node_modules/@claude-flow/security/dist/policy/envelope.js.map +1 -1
  14. package/node_modules/@claude-flow/security/package.json +1 -1
  15. package/package.json +5 -5
  16. package/v3/@claude-flow/cli/dist/src/commands/analyze.js +40 -29
  17. package/v3/@claude-flow/cli/dist/src/commands/coverage-reader.d.ts +29 -0
  18. package/v3/@claude-flow/cli/dist/src/commands/coverage-reader.js +247 -0
  19. package/v3/@claude-flow/cli/dist/src/commands/hooks.js +9 -180
  20. package/v3/@claude-flow/cli/dist/src/commands/tui.js +2 -2
  21. package/v3/@claude-flow/cli/dist/src/init/helpers-generator.js +109 -23
  22. package/v3/@claude-flow/cli/dist/src/parser.js +32 -22
  23. package/v3/@claude-flow/cli/dist/src/production/monitoring.d.ts +1 -0
  24. package/v3/@claude-flow/cli/dist/src/production/monitoring.js +19 -6
  25. package/v3/@claude-flow/cli/dist/src/production/rate-limiter.js +5 -4
  26. package/v3/@claude-flow/cli/dist/src/production/retry.js +3 -0
  27. package/v3/@claude-flow/cli/dist/src/services/config-file-manager.js +3 -3
  28. package/v3/@claude-flow/cli/dist/src/services/policy-ledger-anchor.js +20 -2
  29. package/v3/@claude-flow/cli/package.json +5 -5
  30. package/v3/@claude-flow/shared/dist/events/rvf-event-log.d.ts +9 -0
  31. package/v3/@claude-flow/shared/dist/events/rvf-event-log.js +44 -3
  32. package/v3/@claude-flow/shared/dist/mcp/transport/http.js +11 -1
  33. package/v3/@claude-flow/shared/dist/resilience/rate-limiter.js +3 -3
  34. package/v3/@claude-flow/shared/dist/resilience/retry.js +3 -1
  35. package/v3/@claude-flow/shared/package.json +12 -9
  36. package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.d.ts +0 -2
  37. package/v3/@claude-flow/shared/dist/events/event-store-pagination.test.js +0 -32
  38. package/v3/@claude-flow/shared/dist/events/event-store.test.d.ts +0 -8
  39. package/v3/@claude-flow/shared/dist/events/event-store.test.js +0 -313
  40. package/v3/@claude-flow/shared/dist/hooks/hooks.test.d.ts +0 -9
  41. package/v3/@claude-flow/shared/dist/hooks/hooks.test.js +0 -322
  42. package/v3/@claude-flow/shared/dist/hooks/verify-exports.test.d.ts +0 -9
  43. package/v3/@claude-flow/shared/dist/hooks/verify-exports.test.js +0 -93
  44. package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.d.ts +0 -2
  45. package/v3/@claude-flow/shared/dist/resilience/bulkhead-sync-throw.test.js +0 -33
  46. package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.d.ts +0 -2
  47. package/v3/@claude-flow/shared/dist/resilience/retry-timeout-cleanup.test.js +0 -38
@@ -97,11 +97,19 @@ const diffCommand = {
97
97
  output.printInfo(`Analyzing diff: ${output.highlight(ref)}`);
98
98
  try {
99
99
  // Call MCP tool for diff analysis
100
+ // Shape returned by the analyze_diff MCP tool (ruvector/diff-classifier.ts).
101
+ // Typed from the classifier itself so the renderer cannot drift from it again.
100
102
  const result = await callMCPTool('analyze_diff', {
101
103
  ref,
102
- includeFileRisks: verbose,
104
+ // Always request file risks: the handler computes them anyway, and the
105
+ // high-risk file list below is derived from them.
106
+ includeFileRisks: true,
103
107
  includeReviewers: showReviewers || showAll,
104
108
  });
109
+ if (result.error) {
110
+ output.printError(`Diff analysis failed: ${result.message ?? 'unknown error'}`);
111
+ return { success: false, exitCode: 1 };
112
+ }
105
113
  // JSON output
106
114
  if (formatType === 'json') {
107
115
  output.printJson(result);
@@ -110,13 +118,18 @@ const diffCommand = {
110
118
  output.writeln();
111
119
  // Summary box
112
120
  const files = result.files || [];
113
- const risk = result.risk || { overall: 'unknown', score: 0, breakdown: { fileCount: 0, totalChanges: 0, highRiskFiles: [], securityConcerns: [], breakingChanges: [], testCoverage: 'unknown' } };
114
- const classification = result.classification || { category: 'unknown', confidence: 0, reasoning: '' };
121
+ const fileRisks = result.fileRisks || [];
122
+ const risk = result.risk || { overall: 'low', score: 0, breakdown: { low: 0, medium: 0, high: 0, critical: 0 } };
123
+ const classification = result.classification;
124
+ const category = classification?.primary ?? 'unknown';
125
+ const secondary = classification?.secondary?.length ? classification.secondary.join(', ') : '';
126
+ const totalChanges = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
127
+ const highRiskFiles = fileRisks.filter((r) => r.risk === 'high' || r.risk === 'critical').map((r) => r.file);
115
128
  output.printBox([
116
129
  `Ref: ${result.ref || 'HEAD'}`,
117
130
  `Files: ${files.length}`,
118
131
  `Risk: ${getRiskDisplay(risk.overall)} (${risk.score}/100)`,
119
- `Type: ${classification.category}${classification.subcategory ? ` (${classification.subcategory})` : ''}`,
132
+ `Type: ${category}${secondary ? ` (${secondary})` : ''}`,
120
133
  ``,
121
134
  result.summary || 'No summary available',
122
135
  ].join('\n'), 'Diff Analysis');
@@ -128,33 +141,28 @@ const diffCommand = {
128
141
  output.printTable({
129
142
  columns: [
130
143
  { key: 'metric', header: 'Metric', width: 25 },
131
- { key: 'value', header: 'Value', width: 30 },
144
+ { key: 'value', header: 'Value', width: 42 },
132
145
  ],
133
146
  data: [
134
147
  { metric: 'Overall Risk', value: getRiskDisplay(risk.overall) },
135
148
  { metric: 'Risk Score', value: `${risk.score}/100` },
136
- { metric: 'Files Changed', value: risk.breakdown.fileCount },
137
- { metric: 'Total Lines Changed', value: risk.breakdown.totalChanges },
138
- { metric: 'Test Coverage', value: risk.breakdown.testCoverage },
149
+ { metric: 'Files Changed', value: files.length },
150
+ { metric: 'Total Lines Changed', value: totalChanges },
151
+ { metric: 'Files by Risk', value: `low ${risk.breakdown.low} / medium ${risk.breakdown.medium} / high ${risk.breakdown.high} / critical ${risk.breakdown.critical}` },
139
152
  ],
140
153
  });
141
- // Security concerns
142
- if (risk.breakdown.securityConcerns.length > 0) {
143
- output.writeln();
144
- output.writeln(output.bold(output.warning('Security Concerns')));
145
- output.printList(risk.breakdown.securityConcerns.map(c => output.warning(c)));
146
- }
147
- // Breaking changes
148
- if (risk.breakdown.breakingChanges.length > 0) {
154
+ // Risk factors reported by the classifier
155
+ const riskFactors = classification?.riskFactors ?? [];
156
+ if (riskFactors.length > 0) {
149
157
  output.writeln();
150
- output.writeln(output.bold(output.error('Potential Breaking Changes')));
151
- output.printList(risk.breakdown.breakingChanges.map(c => output.error(c)));
158
+ output.writeln(output.bold(output.warning('Risk Factors')));
159
+ output.printList(riskFactors.map(c => output.warning(c)));
152
160
  }
153
161
  // High risk files
154
- if (risk.breakdown.highRiskFiles.length > 0) {
162
+ if (highRiskFiles.length > 0) {
155
163
  output.writeln();
156
164
  output.writeln(output.bold('High Risk Files'));
157
- output.printList(risk.breakdown.highRiskFiles.map(f => output.warning(f)));
165
+ output.printList(highRiskFiles.map(f => output.warning(f)));
158
166
  }
159
167
  }
160
168
  // Classification
@@ -168,13 +176,16 @@ const diffCommand = {
168
176
  { key: 'value', header: 'Value', width: 40 },
169
177
  ],
170
178
  data: [
171
- { field: 'Category', value: classification.category },
172
- { field: 'Subcategory', value: classification.subcategory || '-' },
173
- { field: 'Confidence', value: `${(classification.confidence * 100).toFixed(0)}%` },
179
+ { field: 'Category', value: category },
180
+ { field: 'Secondary', value: secondary || '-' },
181
+ { field: 'Confidence', value: `${((classification?.confidence ?? 0) * 100).toFixed(0)}%` },
182
+ { field: 'Impact', value: classification?.impactLevel ?? '-' },
174
183
  ],
175
184
  });
176
- output.writeln();
177
- output.writeln(output.dim(`Reasoning: ${classification.reasoning}`));
185
+ if (classification?.testingStrategy?.length) {
186
+ output.writeln();
187
+ output.writeln(output.dim(`Testing strategy: ${classification.testingStrategy.join(', ')}`));
188
+ }
178
189
  }
179
190
  // Reviewers
180
191
  if (showReviewers || showAll) {
@@ -190,13 +201,13 @@ const diffCommand = {
190
201
  }
191
202
  }
192
203
  // Verbose file-level details
193
- if (verbose && result.fileRisks) {
204
+ if (verbose && fileRisks.length > 0) {
194
205
  output.writeln();
195
206
  output.writeln(output.bold('File-Level Analysis'));
196
207
  output.writeln(output.dim('-'.repeat(50)));
197
208
  output.printTable({
198
209
  columns: [
199
- { key: 'path', header: 'File', width: 40 },
210
+ { key: 'file', header: 'File', width: 40 },
200
211
  { key: 'risk', header: 'Risk', width: 12, format: (v) => getRiskDisplay(String(v)) },
201
212
  { key: 'score', header: 'Score', width: 8, align: 'right' },
202
213
  { key: 'reasons', header: 'Reasons', width: 30, format: (v) => {
@@ -204,7 +215,7 @@ const diffCommand = {
204
215
  return reasons.slice(0, 2).join('; ');
205
216
  } },
206
217
  ],
207
- data: result.fileRisks,
218
+ data: fileRisks.map((r) => ({ ...r })),
208
219
  });
209
220
  }
210
221
  // Files changed table
@@ -219,7 +230,7 @@ const diffCommand = {
219
230
  { key: 'additions', header: '+', width: 8, align: 'right', format: (v) => output.success(`+${v}`) },
220
231
  { key: 'deletions', header: '-', width: 8, align: 'right', format: (v) => output.error(`-${v}`) },
221
232
  ],
222
- data: files.slice(0, 20),
233
+ data: files.slice(0, 20).map((f) => ({ ...f })),
223
234
  });
224
235
  if (files.length > 20) {
225
236
  output.writeln(output.dim(` ... and ${files.length - 20} more files`));
@@ -0,0 +1,29 @@
1
+ interface CoverageFileEntry {
2
+ filePath: string;
3
+ lines: number;
4
+ branches: number;
5
+ functions: number;
6
+ statements: number;
7
+ }
8
+ interface CoverageData {
9
+ found: boolean;
10
+ source: string;
11
+ entries: CoverageFileEntry[];
12
+ summary: {
13
+ totalFiles: number;
14
+ overallLineCoverage: number;
15
+ overallBranchCoverage: number;
16
+ overallFunctionCoverage: number;
17
+ overallStatementCoverage: number;
18
+ };
19
+ }
20
+ /**
21
+ * Read coverage data from disk. Checks these locations in order:
22
+ * 1. coverage/coverage-summary.json (Jest/Istanbul)
23
+ * 2. coverage/lcov.info (lcov format)
24
+ * 3. coverage/coverage-final.json (Istanbul counters)
25
+ * 4. .nyc_output/out.json (nyc counters or legacy summary)
26
+ */
27
+ export declare function readCoverageFromDisk(): CoverageData;
28
+ export {};
29
+ //# sourceMappingURL=coverage-reader.d.ts.map
@@ -0,0 +1,247 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Coverage reports are build output the project controls: refuse anything large enough to stall or exhaust the process. */
4
+ const MAX_REPORT_BYTES = 64 * 1024 * 1024;
5
+ /** The file's text, or undefined when it is missing, not a regular file, unreadable, or over the size cap. */
6
+ function readReport(path) {
7
+ try {
8
+ const stat = statSync(path);
9
+ if (!stat.isFile() || stat.size > MAX_REPORT_BYTES)
10
+ return undefined;
11
+ return readFileSync(path, 'utf-8');
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /**
18
+ * Read coverage data from disk. Checks these locations in order:
19
+ * 1. coverage/coverage-summary.json (Jest/Istanbul)
20
+ * 2. coverage/lcov.info (lcov format)
21
+ * 3. coverage/coverage-final.json (Istanbul counters)
22
+ * 4. .nyc_output/out.json (nyc counters or legacy summary)
23
+ */
24
+ export function readCoverageFromDisk() {
25
+ const cwd = process.cwd();
26
+ const noData = {
27
+ found: false,
28
+ source: 'none',
29
+ entries: [],
30
+ summary: { totalFiles: 0, overallLineCoverage: 0, overallBranchCoverage: 0, overallFunctionCoverage: 0, overallStatementCoverage: 0 },
31
+ };
32
+ // 1. Try coverage-summary.json (Jest/Istanbul)
33
+ for (const relPath of ['coverage/coverage-summary.json', 'coverage-summary.json']) {
34
+ const text = readReport(join(cwd, relPath));
35
+ if (text !== undefined) {
36
+ try {
37
+ const raw = JSON.parse(text);
38
+ const report = parseCoverageSummaryJson(raw, relPath);
39
+ if (report.found)
40
+ return report;
41
+ }
42
+ catch {
43
+ // malformed, try next
44
+ }
45
+ }
46
+ }
47
+ // 2. Try lcov.info
48
+ for (const relPath of ['coverage/lcov.info', 'lcov.info']) {
49
+ const raw = readReport(join(cwd, relPath));
50
+ if (raw !== undefined) {
51
+ try {
52
+ const report = parseLcovInfo(raw, relPath);
53
+ if (report.found)
54
+ return report;
55
+ }
56
+ catch {
57
+ // malformed, try next
58
+ }
59
+ }
60
+ }
61
+ // Istanbul's raw counter map (Jest, Vitest, nyc).
62
+ for (const relPath of ['coverage/coverage-final.json', 'coverage-final.json', '.nyc_output/out.json']) {
63
+ const text = readReport(join(cwd, relPath));
64
+ if (text === undefined)
65
+ continue;
66
+ try {
67
+ const raw = JSON.parse(text);
68
+ const report = parseIstanbulCoverage(raw, relPath);
69
+ if (report.found)
70
+ return report;
71
+ const legacy = parseCoverageSummaryJson(raw, relPath);
72
+ if (legacy.found)
73
+ return legacy;
74
+ }
75
+ catch {
76
+ // Missing or malformed, try the next supported report.
77
+ }
78
+ }
79
+ return noData;
80
+ }
81
+ function parseCoverageSummaryJson(data, source) {
82
+ const entries = [];
83
+ const metricsByFile = [];
84
+ for (const [filePath, metrics] of Object.entries(data)) {
85
+ if (filePath === 'total')
86
+ continue;
87
+ const m = metrics;
88
+ if (!m || typeof m !== 'object')
89
+ continue;
90
+ if (!['lines', 'branches', 'functions', 'statements'].some(key => m[key]))
91
+ continue;
92
+ metricsByFile.push(m);
93
+ const linePct = percentage(m.lines) ?? 0;
94
+ const branchPct = (percentage(m.branches) ?? 0);
95
+ const funcPct = (percentage(m.functions) ?? 0);
96
+ const stmtPct = (percentage(m.statements) ?? 0);
97
+ entries.push({ filePath, lines: linePct, branches: branchPct, functions: funcPct, statements: stmtPct });
98
+ }
99
+ // Only aggregate complete counts. A missing denominator is unknown, not empty.
100
+ const total = data['total'];
101
+ const aggregate = (key) => {
102
+ const supplied = percentage(total?.[key]);
103
+ if (supplied !== undefined)
104
+ return supplied;
105
+ const metrics = metricsByFile.map(file => file[key]);
106
+ if (metrics.length === 0 || !metrics.every(hasCounts))
107
+ return 0;
108
+ return ratio(metrics.reduce((sum, m) => sum + m.covered, 0), metrics.reduce((sum, m) => sum + m.total, 0));
109
+ };
110
+ // Sort by lowest line coverage
111
+ entries.sort((a, b) => a.lines - b.lines);
112
+ return {
113
+ found: entries.length > 0,
114
+ source,
115
+ entries,
116
+ summary: {
117
+ totalFiles: entries.length,
118
+ overallLineCoverage: aggregate('lines'),
119
+ overallBranchCoverage: aggregate('branches'),
120
+ overallFunctionCoverage: aggregate('functions'),
121
+ overallStatementCoverage: aggregate('statements'),
122
+ },
123
+ };
124
+ }
125
+ function parseLcovInfo(raw, source) {
126
+ const entries = [];
127
+ let currentFile = '';
128
+ let linesHit = 0, linesFound = 0;
129
+ let branchesHit = 0, branchesFound = 0;
130
+ let functionsHit = 0, functionsFound = 0;
131
+ let totalLH = 0, totalLF = 0, totalBH = 0, totalBF = 0, totalFH = 0, totalFF = 0;
132
+ const flushRecord = () => {
133
+ if (currentFile) {
134
+ // A corrupt record cannot report more hits than lines, or a negative count.
135
+ linesHit = Math.min(linesHit, linesFound);
136
+ branchesHit = Math.min(branchesHit, branchesFound);
137
+ functionsHit = Math.min(functionsHit, functionsFound);
138
+ totalLH += linesHit;
139
+ totalLF += linesFound;
140
+ totalBH += branchesHit;
141
+ totalBF += branchesFound;
142
+ totalFH += functionsHit;
143
+ totalFF += functionsFound;
144
+ entries.push({
145
+ filePath: currentFile,
146
+ lines: linesFound > 0 ? (linesHit / linesFound) * 100 : 100,
147
+ branches: branchesFound > 0 ? (branchesHit / branchesFound) * 100 : 100,
148
+ functions: functionsFound > 0 ? (functionsHit / functionsFound) * 100 : 100,
149
+ statements: linesFound > 0 ? (linesHit / linesFound) * 100 : 100,
150
+ });
151
+ }
152
+ };
153
+ for (const line of raw.split('\n')) {
154
+ const trimmed = line.trim();
155
+ if (trimmed.startsWith('SF:')) {
156
+ flushRecord();
157
+ currentFile = trimmed.slice(3);
158
+ linesHit = 0;
159
+ linesFound = 0;
160
+ branchesHit = 0;
161
+ branchesFound = 0;
162
+ functionsHit = 0;
163
+ functionsFound = 0;
164
+ }
165
+ else if (trimmed.startsWith('LH:')) {
166
+ linesHit = count(trimmed.slice(3));
167
+ }
168
+ else if (trimmed.startsWith('LF:')) {
169
+ linesFound = count(trimmed.slice(3));
170
+ }
171
+ else if (trimmed.startsWith('BRH:')) {
172
+ branchesHit = count(trimmed.slice(4));
173
+ }
174
+ else if (trimmed.startsWith('BRF:')) {
175
+ branchesFound = count(trimmed.slice(4));
176
+ }
177
+ else if (trimmed.startsWith('FNH:')) {
178
+ functionsHit = count(trimmed.slice(4));
179
+ }
180
+ else if (trimmed.startsWith('FNF:')) {
181
+ functionsFound = count(trimmed.slice(4));
182
+ }
183
+ else if (trimmed === 'end_of_record') {
184
+ flushRecord();
185
+ currentFile = '';
186
+ }
187
+ }
188
+ flushRecord();
189
+ entries.sort((a, b) => a.lines - b.lines);
190
+ return {
191
+ found: entries.length > 0,
192
+ source,
193
+ entries,
194
+ summary: {
195
+ totalFiles: entries.length,
196
+ overallLineCoverage: ratio(totalLH, totalLF),
197
+ overallBranchCoverage: ratio(totalBH, totalBF),
198
+ overallFunctionCoverage: ratio(totalFH, totalFF),
199
+ overallStatementCoverage: ratio(totalLH, totalLF),
200
+ },
201
+ };
202
+ }
203
+ /** A non-negative integer count; anything else in a report line is treated as 0. */
204
+ function count(text) {
205
+ const n = parseInt(text, 10);
206
+ return Number.isFinite(n) && n > 0 ? n : 0;
207
+ }
208
+ function ratio(covered, total) {
209
+ return total > 0 ? covered / total * 100 : 100;
210
+ }
211
+ function hasCounts(metric) {
212
+ return typeof metric?.total === 'number' && Number.isFinite(metric.total) && metric.total >= 0
213
+ && typeof metric.covered === 'number' && Number.isFinite(metric.covered)
214
+ && metric.covered >= 0 && metric.covered <= metric.total;
215
+ }
216
+ function percentage(metric) {
217
+ if (typeof metric?.pct === 'number' && Number.isFinite(metric.pct)
218
+ && metric.pct >= 0 && metric.pct <= 100)
219
+ return metric.pct;
220
+ return hasCounts(metric) ? ratio(metric.covered, metric.total) : undefined;
221
+ }
222
+ /** Istanbul line coverage uses the maximum statement hit count on each start line. */
223
+ function parseIstanbulCoverage(data, source) {
224
+ const summary = {};
225
+ for (const [filePath, value] of Object.entries(data)) {
226
+ const cov = value;
227
+ if (!cov || !cov.s || !cov.f || !cov.b || !cov.statementMap)
228
+ continue;
229
+ const lines = new Map();
230
+ for (const [id, hits] of Object.entries(cov.s)) {
231
+ const line = cov.statementMap[id]?.start?.line;
232
+ if (!Number.isInteger(line) || line < 1)
233
+ throw new Error('Invalid Istanbul statement location');
234
+ lines.set(line, Math.max(lines.get(line) ?? 0, hits));
235
+ }
236
+ const metric = (hits) => {
237
+ if (hits.some(n => !Number.isFinite(n) || n < 0))
238
+ throw new Error('Invalid Istanbul counter');
239
+ const covered = hits.filter(n => n > 0).length;
240
+ return { total: hits.length, covered, pct: ratio(covered, hits.length) };
241
+ };
242
+ summary[filePath] = { lines: metric([...lines.values()]), statements: metric(Object.values(cov.s)),
243
+ functions: metric(Object.values(cov.f)), branches: metric(Object.values(cov.b).flat()) };
244
+ }
245
+ return parseCoverageSummaryJson(summary, source);
246
+ }
247
+ //# sourceMappingURL=coverage-reader.js.map
@@ -1,10 +1,14 @@
1
+ /**
2
+ * V3 CLI Hooks Command
3
+ * Self-learning hooks system for intelligent workflow automation
4
+ */
1
5
  import { validateFeedbackPatterns } from '../memory/feedback-patterns.js';
6
+ import { readCoverageFromDisk } from './coverage-reader.js';
2
7
  import { output } from '../output.js';
3
8
  import { confirm } from '../prompt.js';
4
9
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
5
10
  import { storeCommand } from './transfer-store.js';
6
- import { existsSync, readFileSync, statSync } from 'node:fs';
7
- import { join } from 'node:path';
11
+ import { readFileSync, statSync } from 'node:fs';
8
12
  import { getSecurityStatus as sharedGetSecurityStatus, getSwarmStatus as sharedGetSwarmStatus, getGitUncommittedCount as sharedGetGitUncommittedCount, } from '../funnel/local-signals.js';
9
13
  /**
10
14
  * #1686 — `?? 0` only defaults null/undefined; NaN slips through and
@@ -16,181 +20,6 @@ function safeNum(value, fallback = 0) {
16
20
  const n = typeof value === 'number' ? value : Number(value);
17
21
  return Number.isFinite(n) ? n : fallback;
18
22
  }
19
- /**
20
- * Read coverage data from disk. Checks these locations in order:
21
- * 1. coverage/coverage-summary.json (Jest/Istanbul)
22
- * 2. coverage/lcov.info (lcov format)
23
- * 3. .nyc_output/out.json (nyc)
24
- */
25
- function readCoverageFromDisk() {
26
- const cwd = process.cwd();
27
- const noData = {
28
- found: false,
29
- source: 'none',
30
- entries: [],
31
- summary: { totalFiles: 0, overallLineCoverage: 0, overallBranchCoverage: 0, overallFunctionCoverage: 0, overallStatementCoverage: 0 },
32
- };
33
- // 1. Try coverage-summary.json (Jest/Istanbul)
34
- for (const relPath of ['coverage/coverage-summary.json', 'coverage-summary.json']) {
35
- const summaryPath = join(cwd, relPath);
36
- if (existsSync(summaryPath)) {
37
- try {
38
- const raw = JSON.parse(readFileSync(summaryPath, 'utf-8'));
39
- return parseCoverageSummaryJson(raw, relPath);
40
- }
41
- catch {
42
- // malformed, try next
43
- }
44
- }
45
- }
46
- // 2. Try lcov.info
47
- for (const relPath of ['coverage/lcov.info', 'lcov.info']) {
48
- const lcovPath = join(cwd, relPath);
49
- if (existsSync(lcovPath)) {
50
- try {
51
- const raw = readFileSync(lcovPath, 'utf-8');
52
- return parseLcovInfo(raw, relPath);
53
- }
54
- catch {
55
- // malformed, try next
56
- }
57
- }
58
- }
59
- // 3. Try .nyc_output/out.json
60
- const nycPath = join(cwd, '.nyc_output', 'out.json');
61
- if (existsSync(nycPath)) {
62
- try {
63
- const raw = JSON.parse(readFileSync(nycPath, 'utf-8'));
64
- return parseCoverageSummaryJson(raw, '.nyc_output/out.json');
65
- }
66
- catch {
67
- // malformed
68
- }
69
- }
70
- return noData;
71
- }
72
- function parseCoverageSummaryJson(data, source) {
73
- const entries = [];
74
- let totalLines = 0, coveredLines = 0;
75
- let totalBranches = 0, coveredBranches = 0;
76
- let totalFunctions = 0, coveredFunctions = 0;
77
- let totalStatements = 0, coveredStatements = 0;
78
- for (const [filePath, metrics] of Object.entries(data)) {
79
- if (filePath === 'total')
80
- continue;
81
- const m = metrics;
82
- if (!m || typeof m !== 'object')
83
- continue;
84
- const linePct = m.lines?.pct ?? m.lines?.covered != null ? ((m.lines?.covered ?? 0) / Math.max(m.lines?.total ?? 1, 1)) * 100 : 0;
85
- const branchPct = m.branches?.pct ?? (m.branches?.total ? ((m.branches?.covered ?? 0) / m.branches.total) * 100 : 100);
86
- const funcPct = m.functions?.pct ?? (m.functions?.total ? ((m.functions?.covered ?? 0) / m.functions.total) * 100 : 100);
87
- const stmtPct = m.statements?.pct ?? (m.statements?.total ? ((m.statements?.covered ?? 0) / m.statements.total) * 100 : 100);
88
- entries.push({ filePath, lines: linePct, branches: branchPct, functions: funcPct, statements: stmtPct });
89
- totalLines += m.lines?.total ?? 0;
90
- coveredLines += m.lines?.covered ?? 0;
91
- totalBranches += m.branches?.total ?? 0;
92
- coveredBranches += m.branches?.covered ?? 0;
93
- totalFunctions += m.functions?.total ?? 0;
94
- coveredFunctions += m.functions?.covered ?? 0;
95
- totalStatements += m.statements?.total ?? 0;
96
- coveredStatements += m.statements?.covered ?? 0;
97
- }
98
- // Also read the total key if present
99
- const total = data['total'];
100
- const overallLine = total?.lines?.pct ?? (totalLines > 0 ? (coveredLines / totalLines) * 100 : 0);
101
- const overallBranch = total?.branches?.pct ?? (totalBranches > 0 ? (coveredBranches / totalBranches) * 100 : 0);
102
- const overallFunction = total?.functions?.pct ?? (totalFunctions > 0 ? (coveredFunctions / totalFunctions) * 100 : 0);
103
- const overallStatement = total?.statements?.pct ?? (totalStatements > 0 ? (coveredStatements / totalStatements) * 100 : 0);
104
- // Sort by lowest line coverage
105
- entries.sort((a, b) => a.lines - b.lines);
106
- return {
107
- found: true,
108
- source,
109
- entries,
110
- summary: {
111
- totalFiles: entries.length,
112
- overallLineCoverage: overallLine,
113
- overallBranchCoverage: overallBranch,
114
- overallFunctionCoverage: overallFunction,
115
- overallStatementCoverage: overallStatement,
116
- },
117
- };
118
- }
119
- function parseLcovInfo(raw, source) {
120
- const entries = [];
121
- let currentFile = '';
122
- let linesHit = 0, linesFound = 0;
123
- let branchesHit = 0, branchesFound = 0;
124
- let functionsHit = 0, functionsFound = 0;
125
- const flushRecord = () => {
126
- if (currentFile) {
127
- entries.push({
128
- filePath: currentFile,
129
- lines: linesFound > 0 ? (linesHit / linesFound) * 100 : 0,
130
- branches: branchesFound > 0 ? (branchesHit / branchesFound) * 100 : 100,
131
- functions: functionsFound > 0 ? (functionsHit / functionsFound) * 100 : 100,
132
- statements: linesFound > 0 ? (linesHit / linesFound) * 100 : 0,
133
- });
134
- }
135
- };
136
- for (const line of raw.split('\n')) {
137
- const trimmed = line.trim();
138
- if (trimmed.startsWith('SF:')) {
139
- currentFile = trimmed.slice(3);
140
- linesHit = 0;
141
- linesFound = 0;
142
- branchesHit = 0;
143
- branchesFound = 0;
144
- functionsHit = 0;
145
- functionsFound = 0;
146
- }
147
- else if (trimmed.startsWith('LH:')) {
148
- linesHit = parseInt(trimmed.slice(3), 10) || 0;
149
- }
150
- else if (trimmed.startsWith('LF:')) {
151
- linesFound = parseInt(trimmed.slice(3), 10) || 0;
152
- }
153
- else if (trimmed.startsWith('BRH:')) {
154
- branchesHit = parseInt(trimmed.slice(4), 10) || 0;
155
- }
156
- else if (trimmed.startsWith('BRF:')) {
157
- branchesFound = parseInt(trimmed.slice(4), 10) || 0;
158
- }
159
- else if (trimmed.startsWith('FNH:')) {
160
- functionsHit = parseInt(trimmed.slice(4), 10) || 0;
161
- }
162
- else if (trimmed.startsWith('FNF:')) {
163
- functionsFound = parseInt(trimmed.slice(4), 10) || 0;
164
- }
165
- else if (trimmed === 'end_of_record') {
166
- flushRecord();
167
- currentFile = '';
168
- }
169
- }
170
- flushRecord();
171
- entries.sort((a, b) => a.lines - b.lines);
172
- let totalLH = 0, totalLF = 0, totalBH = 0, totalBF = 0;
173
- for (const e of entries) {
174
- // Approximate from percentages (we lost exact counts after flush, but summaries are okay)
175
- totalLH += e.lines;
176
- totalLF += 100;
177
- totalBH += e.branches;
178
- totalBF += 100;
179
- }
180
- const n = entries.length || 1;
181
- return {
182
- found: true,
183
- source,
184
- entries,
185
- summary: {
186
- totalFiles: entries.length,
187
- overallLineCoverage: totalLH / n,
188
- overallBranchCoverage: totalBH / n,
189
- overallFunctionCoverage: 0,
190
- overallStatementCoverage: totalLH / n,
191
- },
192
- };
193
- }
194
23
  /**
195
24
  * Classify a coverage gap by priority type based on coverage percentage and threshold
196
25
  */
@@ -2996,7 +2825,7 @@ const coverageRouteCommand = {
2996
2825
  'c8: npx c8 npm test',
2997
2826
  ]);
2998
2827
  output.writeln();
2999
- output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, or .nyc_output/out.json'));
2828
+ output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, coverage/coverage-final.json, or .nyc_output/out.json'));
3000
2829
  return { success: false, exitCode: 1 };
3001
2830
  }
3002
2831
  }
@@ -3187,7 +3016,7 @@ const coverageSuggestCommand = {
3187
3016
  'c8: npx c8 npm test',
3188
3017
  ]);
3189
3018
  output.writeln();
3190
- output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, or .nyc_output/out.json'));
3019
+ output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, coverage/coverage-final.json, or .nyc_output/out.json'));
3191
3020
  return { success: false, exitCode: 1 };
3192
3021
  }
3193
3022
  }
@@ -3407,7 +3236,7 @@ const coverageGapsCommand = {
3407
3236
  'c8: npx c8 npm test',
3408
3237
  ]);
3409
3238
  output.writeln();
3410
- output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, or .nyc_output/out.json'));
3239
+ output.writeln(output.dim('Expected files: coverage/coverage-summary.json, coverage/lcov.info, coverage/coverage-final.json, or .nyc_output/out.json'));
3411
3240
  return { success: false, exitCode: 1 };
3412
3241
  }
3413
3242
  }
@@ -9,7 +9,7 @@
9
9
  * a one-line hint and exit 2, unless --plain asks for one frame as plain text and exit 0 (ADR-489 decision E).
10
10
  *
11
11
  * npx ruflo tui # interactive, in a terminal
12
- * npx ruflo tui --view swarm # start on the Swarm page (keys 1-5 or o c s e m switch pages)
12
+ * npx ruflo tui --view swarm # start on the Swarm page (keys 1-6 or o c s e m a switch pages)
13
13
  * npx ruflo tui --plain | head # one plain frame, exit 0 (scripts, CI)
14
14
  * npx ruflo tui | cat # not a terminal and no --plain: a hint on stderr, exit 2
15
15
  */
@@ -55,7 +55,7 @@ export const tuiCommand = {
55
55
  ],
56
56
  examples: [
57
57
  { command: 'npx ruflo tui', description: 'Open the console in this terminal' },
58
- { command: 'npx ruflo tui --view swarm', description: 'Start on the Swarm page (keys 1-5 switch pages, tab cycles, q quits)' },
58
+ { command: 'npx ruflo tui --view swarm', description: 'Start on the Swarm page (keys 1-6 switch pages, tab cycles, q quits)' },
59
59
  { command: 'npx ruflo tui --view ai --model openai/gpt-4o-mini --max-spend-usd 0.1', description: 'The AI page on a cheap OpenRouter model, stopping at $0.10 reported (key in $OPENROUTER_API_KEY, never shown)' },
60
60
  { command: 'npx ruflo tui --plain | head -40', description: 'One plain frame, exit 0: for scripts and CI (no terminal needed)' },
61
61
  ],