@viren/claude-code-dashboard 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/generate-dashboard.mjs +248 -625
- package/package.json +1 -1
- package/src/analysis.mjs +19 -12
- package/src/constants.mjs +1 -1
- package/src/demo.mjs +191 -248
- package/src/pipeline.mjs +500 -0
package/src/pipeline.mjs
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure processing pipeline — transforms raw scan data into the shape
|
|
3
|
+
* that generateDashboardHtml() expects.
|
|
4
|
+
*
|
|
5
|
+
* NO filesystem I/O, NO git commands, NO process.exit.
|
|
6
|
+
* All data comes in via the `raw` parameter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SIMILARITY_THRESHOLD } from "./constants.mjs";
|
|
10
|
+
import { relativeTime, freshnessClass } from "./freshness.mjs";
|
|
11
|
+
import {
|
|
12
|
+
computeHealthScore,
|
|
13
|
+
classifyDrift,
|
|
14
|
+
detectConfigPattern,
|
|
15
|
+
findExemplar,
|
|
16
|
+
generateSuggestions,
|
|
17
|
+
computeConfigSimilarity,
|
|
18
|
+
matchSkillsToRepo,
|
|
19
|
+
} from "./analysis.mjs";
|
|
20
|
+
import { findPromotionCandidates, classifyHistoricalServers } from "./mcp.mjs";
|
|
21
|
+
import { aggregateSessionMeta } from "./usage.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build the complete dashboard data object from raw scan inputs.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} raw - All I/O data collected before this function is called.
|
|
27
|
+
* @returns {object} Data object ready for generateDashboardHtml().
|
|
28
|
+
*/
|
|
29
|
+
export function buildDashboardData(raw) {
|
|
30
|
+
// ── 1. Repo Classification ──────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const configured = [];
|
|
33
|
+
const unconfigured = [];
|
|
34
|
+
const seenNames = new Map();
|
|
35
|
+
|
|
36
|
+
for (const repo of raw.repos) {
|
|
37
|
+
// Collision-safe display key
|
|
38
|
+
const count = (seenNames.get(repo.name) || 0) + 1;
|
|
39
|
+
seenNames.set(repo.name, count);
|
|
40
|
+
const key = count > 1 ? `${repo.name}__${count}` : repo.name;
|
|
41
|
+
|
|
42
|
+
const entry = {
|
|
43
|
+
key,
|
|
44
|
+
name: repo.name,
|
|
45
|
+
path: repo.path,
|
|
46
|
+
shortPath: repo.shortPath,
|
|
47
|
+
commands: repo.commands || [],
|
|
48
|
+
rules: repo.rules || [],
|
|
49
|
+
desc: repo.desc || [],
|
|
50
|
+
sections: repo.sections || [],
|
|
51
|
+
freshness: repo.freshness || 0,
|
|
52
|
+
freshnessText: "",
|
|
53
|
+
freshnessClass: "stale",
|
|
54
|
+
techStack: repo.techStack || [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const hasConfig = entry.commands.length > 0 || entry.rules.length > 0 || repo.agentsFile;
|
|
58
|
+
|
|
59
|
+
if (hasConfig) {
|
|
60
|
+
entry.freshnessText = relativeTime(entry.freshness);
|
|
61
|
+
entry.freshnessClass = freshnessClass(entry.freshness);
|
|
62
|
+
|
|
63
|
+
// Health score
|
|
64
|
+
const health = computeHealthScore({
|
|
65
|
+
hasAgentsFile: !!repo.agentsFile,
|
|
66
|
+
desc: entry.desc,
|
|
67
|
+
commandCount: entry.commands.length,
|
|
68
|
+
ruleCount: entry.rules.length,
|
|
69
|
+
sectionCount: entry.sections.length,
|
|
70
|
+
freshnessClass: entry.freshnessClass,
|
|
71
|
+
});
|
|
72
|
+
entry.healthScore = health.score;
|
|
73
|
+
entry.healthReasons = health.reasons;
|
|
74
|
+
entry.hasAgentsFile = !!repo.agentsFile;
|
|
75
|
+
entry.configPattern = detectConfigPattern(entry);
|
|
76
|
+
|
|
77
|
+
// Drift classification from pre-computed gitRevCount (no git I/O)
|
|
78
|
+
entry.drift = classifyDrift(repo.gitRevCount);
|
|
79
|
+
|
|
80
|
+
configured.push(entry);
|
|
81
|
+
} else {
|
|
82
|
+
unconfigured.push(entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sort configured by richness (most config first)
|
|
87
|
+
configured.sort((a, b) => {
|
|
88
|
+
const score = (r) =>
|
|
89
|
+
r.commands.length * 3 + r.rules.length * 2 + r.sections.length + (r.desc.length > 0 ? 1 : 0);
|
|
90
|
+
return score(b) - score(a);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
unconfigured.sort((a, b) => a.name.localeCompare(b.name));
|
|
94
|
+
|
|
95
|
+
// ── 2. Cross-Repo Analysis ────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
// Suggestions for unconfigured repos
|
|
98
|
+
for (const repo of unconfigured) {
|
|
99
|
+
const exemplar = findExemplar(repo.techStack, configured);
|
|
100
|
+
if (exemplar) {
|
|
101
|
+
repo.suggestions = generateSuggestions(exemplar);
|
|
102
|
+
repo.exemplarName = exemplar.name;
|
|
103
|
+
} else {
|
|
104
|
+
repo.suggestions = [];
|
|
105
|
+
repo.exemplarName = "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Similar repos + matched skills for configured repos
|
|
110
|
+
for (const repo of configured) {
|
|
111
|
+
const similar = configured
|
|
112
|
+
.filter((r) => r !== repo)
|
|
113
|
+
.map((r) => ({
|
|
114
|
+
name: r.name,
|
|
115
|
+
similarity: computeConfigSimilarity(repo, r),
|
|
116
|
+
}))
|
|
117
|
+
.filter((r) => r.similarity >= SIMILARITY_THRESHOLD)
|
|
118
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
119
|
+
.slice(0, 2);
|
|
120
|
+
repo.similarRepos = similar;
|
|
121
|
+
repo.matchedSkills = matchSkillsToRepo(repo, raw.globalSkills);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Consolidation groups
|
|
125
|
+
const consolidationGroups = [];
|
|
126
|
+
const byStack = {};
|
|
127
|
+
for (const repo of configured) {
|
|
128
|
+
for (const s of repo.techStack || []) {
|
|
129
|
+
if (!byStack[s]) byStack[s] = [];
|
|
130
|
+
byStack[s].push(repo);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [stack, repos] of Object.entries(byStack)) {
|
|
134
|
+
if (repos.length >= 3) {
|
|
135
|
+
let pairCount = 0;
|
|
136
|
+
let simSum = 0;
|
|
137
|
+
for (let i = 0; i < repos.length; i++) {
|
|
138
|
+
for (let j = i + 1; j < repos.length; j++) {
|
|
139
|
+
simSum += computeConfigSimilarity(repos[i], repos[j]);
|
|
140
|
+
pairCount++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const avgSimilarity = pairCount > 0 ? Math.round(simSum / pairCount) : 0;
|
|
144
|
+
if (avgSimilarity >= 30) {
|
|
145
|
+
consolidationGroups.push({
|
|
146
|
+
stack,
|
|
147
|
+
repos: repos.map((r) => r.name),
|
|
148
|
+
avgSimilarity,
|
|
149
|
+
suggestion: `${repos.length} ${stack} repos with ${avgSimilarity}% avg similarity — consider shared global rules`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 3. MCP Aggregation ────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const allMcpServers = [...(raw.userMcpServers || [])];
|
|
158
|
+
|
|
159
|
+
// Add project MCP servers and attach to matching repos
|
|
160
|
+
for (const [repoPath, servers] of Object.entries(raw.projectMcpByRepo || {})) {
|
|
161
|
+
allMcpServers.push(...servers);
|
|
162
|
+
const repo =
|
|
163
|
+
configured.find((r) => r.path === repoPath) || unconfigured.find((r) => r.path === repoPath);
|
|
164
|
+
if (repo) repo.mcpServers = servers;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const mcpPromotions = findPromotionCandidates(allMcpServers);
|
|
168
|
+
|
|
169
|
+
// Build disabled-by-server counts
|
|
170
|
+
const disabledByServer = {};
|
|
171
|
+
for (const [, names] of Object.entries(raw.disabledMcpByRepo || {})) {
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
disabledByServer[name] = (disabledByServer[name] || 0) + 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Build mcpByName map
|
|
178
|
+
const mcpByName = {};
|
|
179
|
+
for (const s of allMcpServers) {
|
|
180
|
+
if (!mcpByName[s.name])
|
|
181
|
+
mcpByName[s.name] = {
|
|
182
|
+
name: s.name,
|
|
183
|
+
type: s.type,
|
|
184
|
+
projects: [],
|
|
185
|
+
userLevel: false,
|
|
186
|
+
disabledIn: 0,
|
|
187
|
+
};
|
|
188
|
+
if (s.scope === "user") mcpByName[s.name].userLevel = true;
|
|
189
|
+
if (s.scope === "project") mcpByName[s.name].projects.push(s.source);
|
|
190
|
+
}
|
|
191
|
+
for (const entry of Object.values(mcpByName)) {
|
|
192
|
+
entry.disabledIn = disabledByServer[entry.name] || 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Historical MCP servers
|
|
196
|
+
const currentMcpNames = new Set(allMcpServers.map((s) => s.name));
|
|
197
|
+
const { recent: recentMcpServers, former: formerMcpServers } = classifyHistoricalServers(
|
|
198
|
+
raw.historicalMcpMap || new Map(),
|
|
199
|
+
currentMcpNames,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Historical project paths are already normalized by the caller (collectRawInputs
|
|
203
|
+
// applies shortPath on the I/O side, demo data uses short paths directly).
|
|
204
|
+
|
|
205
|
+
// Merge recently-seen servers into mcpByName
|
|
206
|
+
for (const server of recentMcpServers) {
|
|
207
|
+
if (!mcpByName[server.name]) {
|
|
208
|
+
mcpByName[server.name] = {
|
|
209
|
+
name: server.name,
|
|
210
|
+
type: "unknown",
|
|
211
|
+
projects: server.projects,
|
|
212
|
+
userLevel: false,
|
|
213
|
+
disabledIn: disabledByServer[server.name] || 0,
|
|
214
|
+
recentlyActive: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const mcpSummary = Object.values(mcpByName).sort((a, b) => {
|
|
220
|
+
if (a.userLevel !== b.userLevel) return a.userLevel ? -1 : 1;
|
|
221
|
+
return a.name.localeCompare(b.name);
|
|
222
|
+
});
|
|
223
|
+
const mcpCount = mcpSummary.length;
|
|
224
|
+
|
|
225
|
+
// ── 4. Usage Analytics ────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const usageAnalytics = aggregateSessionMeta(raw.sessionMetaFiles || []);
|
|
228
|
+
|
|
229
|
+
// ── 5. Insights Report Parsing ────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
let insightsReport = null;
|
|
232
|
+
if (raw.insightsReportHtml) {
|
|
233
|
+
try {
|
|
234
|
+
const reportHtml = raw.insightsReportHtml;
|
|
235
|
+
|
|
236
|
+
// Extract subtitle — reformat ISO dates to readable format
|
|
237
|
+
const subtitleMatch = reportHtml.match(/<p class="subtitle">([^<]+)<\/p>/);
|
|
238
|
+
let subtitle = subtitleMatch ? subtitleMatch[1] : null;
|
|
239
|
+
if (subtitle) {
|
|
240
|
+
subtitle = subtitle.replace(/(\d{4})-(\d{2})-(\d{2})/g, (_, y, m2, d) => {
|
|
241
|
+
const dt = new Date(`${y}-${m2}-${d}T00:00:00Z`);
|
|
242
|
+
return dt.toLocaleDateString("en-US", {
|
|
243
|
+
month: "short",
|
|
244
|
+
day: "numeric",
|
|
245
|
+
year: "numeric",
|
|
246
|
+
timeZone: "UTC",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Extract glance sections
|
|
252
|
+
const glanceSections = [];
|
|
253
|
+
const glanceRe =
|
|
254
|
+
/<div class="glance-section"><strong>([^<]+)<\/strong>\s*([\s\S]*?)<a[^>]*class="see-more"/g;
|
|
255
|
+
let m;
|
|
256
|
+
while ((m = glanceRe.exec(reportHtml)) !== null) {
|
|
257
|
+
const text = m[2].replace(/<[^>]+>/g, "").trim();
|
|
258
|
+
glanceSections.push({ label: m[1].replace(/:$/, ""), text });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Extract stats
|
|
262
|
+
const statsRe =
|
|
263
|
+
/<div class="stat-value">([^<]+)<\/div><div class="stat-label">([^<]+)<\/div>/g;
|
|
264
|
+
const reportStats = [];
|
|
265
|
+
while ((m = statsRe.exec(reportHtml)) !== null) {
|
|
266
|
+
const value = m[1];
|
|
267
|
+
const label = m[2];
|
|
268
|
+
const isDiff = /^[+-]/.test(value) && value.includes("/");
|
|
269
|
+
reportStats.push({ value, label, isDiff });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Extract friction categories
|
|
273
|
+
const frictionRe =
|
|
274
|
+
/<div class="friction-title">([^<]+)<\/div>\s*<div class="friction-desc">([^<]+)<\/div>/g;
|
|
275
|
+
const frictionPoints = [];
|
|
276
|
+
while ((m = frictionRe.exec(reportHtml)) !== null) {
|
|
277
|
+
frictionPoints.push({ title: m[1], desc: m[2] });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (glanceSections.length > 0 || reportStats.length > 0) {
|
|
281
|
+
insightsReport = {
|
|
282
|
+
subtitle,
|
|
283
|
+
glance: glanceSections,
|
|
284
|
+
stats: reportStats,
|
|
285
|
+
friction: frictionPoints.slice(0, 3),
|
|
286
|
+
filePath: raw.insightsReportPath || null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// skip if parsing fails
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 6. Stats Supplementation ──────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
// Make a copy so we don't mutate raw.statsCache
|
|
297
|
+
const statsCache = structuredClone(raw.statsCache || {});
|
|
298
|
+
|
|
299
|
+
// Supplement dailyActivity with session-meta data
|
|
300
|
+
const sessionMetaFiles = raw.sessionMetaFiles || [];
|
|
301
|
+
if (sessionMetaFiles.length > 0) {
|
|
302
|
+
const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
|
|
303
|
+
const sessionDayCounts = {};
|
|
304
|
+
for (const s of sessionMetaFiles) {
|
|
305
|
+
const date = (s.start_time || "").slice(0, 10);
|
|
306
|
+
if (!date || existingDates.has(date)) continue;
|
|
307
|
+
sessionDayCounts[date] =
|
|
308
|
+
(sessionDayCounts[date] || 0) +
|
|
309
|
+
(s.user_message_count || 0) +
|
|
310
|
+
(s.assistant_message_count || 0);
|
|
311
|
+
}
|
|
312
|
+
const supplemental = Object.entries(sessionDayCounts).map(([date, messageCount]) => ({
|
|
313
|
+
date,
|
|
314
|
+
messageCount,
|
|
315
|
+
}));
|
|
316
|
+
if (supplemental.length > 0) {
|
|
317
|
+
statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...supplemental].sort(
|
|
318
|
+
(a, b) => a.date.localeCompare(b.date),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Supplement dailyActivity with ccusage data
|
|
324
|
+
const ccusageData = raw.ccusageData;
|
|
325
|
+
if (ccusageData && ccusageData.daily) {
|
|
326
|
+
const existingDates = new Set((statsCache.dailyActivity || []).map((d) => d.date));
|
|
327
|
+
const ccusageSupplemental = ccusageData.daily
|
|
328
|
+
.filter((d) => d.date && !existingDates.has(d.date) && d.totalTokens > 0)
|
|
329
|
+
.map((d) => ({
|
|
330
|
+
date: d.date,
|
|
331
|
+
messageCount: Math.max(1, Math.round(d.totalTokens / 10000)),
|
|
332
|
+
}));
|
|
333
|
+
if (ccusageSupplemental.length > 0) {
|
|
334
|
+
statsCache.dailyActivity = [...(statsCache.dailyActivity || []), ...ccusageSupplemental].sort(
|
|
335
|
+
(a, b) => a.date.localeCompare(b.date),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── 7. Summary Stats ──────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const totalRepos = raw.repos.length;
|
|
343
|
+
const configuredCount = configured.length;
|
|
344
|
+
const unconfiguredCount = unconfigured.length;
|
|
345
|
+
const coveragePct = totalRepos > 0 ? Math.round((configuredCount / totalRepos) * 100) : 0;
|
|
346
|
+
const totalRepoCmds = configured.reduce((sum, r) => sum + r.commands.length, 0);
|
|
347
|
+
const avgHealth =
|
|
348
|
+
configured.length > 0
|
|
349
|
+
? Math.round(configured.reduce((sum, r) => sum + (r.healthScore || 0), 0) / configured.length)
|
|
350
|
+
: 0;
|
|
351
|
+
const driftCount = configured.filter(
|
|
352
|
+
(r) => r.drift && (r.drift.level === "medium" || r.drift.level === "high"),
|
|
353
|
+
).length;
|
|
354
|
+
|
|
355
|
+
// ── 8. Insight Generation ─────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
const insights = [];
|
|
358
|
+
|
|
359
|
+
// Drift alerts
|
|
360
|
+
const highDriftRepos = configured.filter((r) => r.drift?.level === "high");
|
|
361
|
+
if (highDriftRepos.length > 0) {
|
|
362
|
+
insights.push({
|
|
363
|
+
type: "warning",
|
|
364
|
+
title: `${highDriftRepos.length} repo${highDriftRepos.length > 1 ? "s have" : " has"} high config drift`,
|
|
365
|
+
detail: highDriftRepos
|
|
366
|
+
.map((r) => `${r.name} (${r.drift.commitsSince} commits since config update)`)
|
|
367
|
+
.join(", "),
|
|
368
|
+
action: "Review and update CLAUDE.md in these repos",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Coverage
|
|
373
|
+
if (unconfigured.length > 0 && totalRepos > 0) {
|
|
374
|
+
const pct = Math.round((unconfigured.length / totalRepos) * 100);
|
|
375
|
+
if (pct >= 40) {
|
|
376
|
+
const withStack = unconfigured.filter((r) => r.techStack?.length > 0).slice(0, 3);
|
|
377
|
+
insights.push({
|
|
378
|
+
type: "info",
|
|
379
|
+
title: `${unconfigured.length} repos unconfigured (${pct}%)`,
|
|
380
|
+
detail: withStack.length
|
|
381
|
+
? `Top candidates: ${withStack.map((r) => `${r.name} (${r.techStack.join(", ")})`).join(", ")}`
|
|
382
|
+
: "",
|
|
383
|
+
action: "Run claude-code-dashboard init --template <stack> in these repos",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// MCP promotions
|
|
389
|
+
if (mcpPromotions.length > 0) {
|
|
390
|
+
insights.push({
|
|
391
|
+
type: "promote",
|
|
392
|
+
title: `${mcpPromotions.length} MCP server${mcpPromotions.length > 1 ? "s" : ""} could be promoted to global`,
|
|
393
|
+
detail: mcpPromotions.map((p) => `${p.name} (in ${p.projects.length} projects)`).join(", "),
|
|
394
|
+
action: "Add to ~/.claude/mcp_config.json for all projects",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Redundant project-scope MCP configs
|
|
399
|
+
const redundantMcp = Object.values(mcpByName).filter((s) => s.userLevel && s.projects.length > 0);
|
|
400
|
+
if (redundantMcp.length > 0) {
|
|
401
|
+
insights.push({
|
|
402
|
+
type: "tip",
|
|
403
|
+
title: `${redundantMcp.length} MCP server${redundantMcp.length > 1 ? "s are" : " is"} global but also in project .mcp.json`,
|
|
404
|
+
detail: redundantMcp.map((s) => `${s.name} (${s.projects.join(", ")})`).join("; "),
|
|
405
|
+
action: "Remove from project .mcp.json — global config already covers all projects",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Skill sharing opportunities
|
|
410
|
+
const skillMatchCounts = {};
|
|
411
|
+
for (const r of configured) {
|
|
412
|
+
for (const sk of r.matchedSkills || []) {
|
|
413
|
+
const skName = typeof sk === "string" ? sk : sk.name;
|
|
414
|
+
if (!skillMatchCounts[skName]) skillMatchCounts[skName] = [];
|
|
415
|
+
skillMatchCounts[skName].push(r.name);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const widelyRelevant = Object.entries(skillMatchCounts)
|
|
419
|
+
.filter(([, repos]) => repos.length >= 3)
|
|
420
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
421
|
+
if (widelyRelevant.length > 0) {
|
|
422
|
+
const top = widelyRelevant.slice(0, 3);
|
|
423
|
+
insights.push({
|
|
424
|
+
type: "info",
|
|
425
|
+
title: `${widelyRelevant.length} skill${widelyRelevant.length > 1 ? "s" : ""} relevant across 3+ repos`,
|
|
426
|
+
detail: top.map(([name, repos]) => `${name} (${repos.length} repos)`).join(", "),
|
|
427
|
+
action: "Consider adding these skills to your global config",
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Health quick wins
|
|
432
|
+
const quickWinRepos = configured
|
|
433
|
+
.filter((r) => r.healthScore > 0 && r.healthScore < 80 && r.healthReasons?.length > 0)
|
|
434
|
+
.sort((a, b) => b.healthScore - a.healthScore)
|
|
435
|
+
.slice(0, 3);
|
|
436
|
+
if (quickWinRepos.length > 0) {
|
|
437
|
+
insights.push({
|
|
438
|
+
type: "tip",
|
|
439
|
+
title: "Quick wins to improve config health",
|
|
440
|
+
detail: quickWinRepos
|
|
441
|
+
.map((r) => `${r.name} (${r.healthScore}/100): ${r.healthReasons[0]}`)
|
|
442
|
+
.join("; "),
|
|
443
|
+
action: "Small changes for measurable improvement",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Insights report nudge
|
|
448
|
+
if (!insightsReport) {
|
|
449
|
+
insights.push({
|
|
450
|
+
type: "info",
|
|
451
|
+
title: "Generate your Claude Code Insights report",
|
|
452
|
+
detail: "Get personalized usage patterns, friction points, and feature suggestions",
|
|
453
|
+
action: "Run /insights in Claude Code",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── 9. Timestamp ──────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
const now = new Date();
|
|
460
|
+
const timestamp =
|
|
461
|
+
now
|
|
462
|
+
.toLocaleDateString("en-US", {
|
|
463
|
+
month: "short",
|
|
464
|
+
day: "numeric",
|
|
465
|
+
year: "numeric",
|
|
466
|
+
})
|
|
467
|
+
.toLowerCase() +
|
|
468
|
+
" at " +
|
|
469
|
+
now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase();
|
|
470
|
+
|
|
471
|
+
// ── Return ────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
configured,
|
|
475
|
+
unconfigured,
|
|
476
|
+
globalCmds: raw.globalCmds,
|
|
477
|
+
globalRules: raw.globalRules,
|
|
478
|
+
globalSkills: raw.globalSkills,
|
|
479
|
+
chains: raw.chains,
|
|
480
|
+
mcpSummary,
|
|
481
|
+
mcpPromotions,
|
|
482
|
+
formerMcpServers,
|
|
483
|
+
consolidationGroups,
|
|
484
|
+
usageAnalytics,
|
|
485
|
+
ccusageData,
|
|
486
|
+
statsCache,
|
|
487
|
+
timestamp,
|
|
488
|
+
coveragePct,
|
|
489
|
+
totalRepos,
|
|
490
|
+
configuredCount,
|
|
491
|
+
unconfiguredCount,
|
|
492
|
+
totalRepoCmds,
|
|
493
|
+
avgHealth,
|
|
494
|
+
driftCount,
|
|
495
|
+
mcpCount,
|
|
496
|
+
scanScope: raw.scanScope,
|
|
497
|
+
insights,
|
|
498
|
+
insightsReport,
|
|
499
|
+
};
|
|
500
|
+
}
|