Commitb1070a5unknown_key
feat: AI archaeology, org health dashboard, streaming review, test gap detector
feat: AI archaeology, org health dashboard, streaming review, test gap detector - src/lib/ai-archaeology.ts + src/routes/ai-archaeology.tsx: "why does this code exist?" — git log + PR/issue search + Claude synthesis, 30min cache, RepoNav "Archaeology" link added - src/lib/org-health.ts + src/routes/org-health.tsx: /orgs/:slug/health — worst-first repo ranking, per-repo getHealthScore(), Claude AI summary with top 3 action items, 1h org-level cache - src/lib/streaming-review.ts + src/routes/streaming-review.ts: real-time PR review via SSE, MODEL_SONNET streaming SDK, section detection (summary/ findings/verdict), persists final review as pr_comments row with idempotency marker; stream-ui endpoint returns self-contained embeddable widget - src/lib/test-gaps.ts + src/routes/test-gaps.tsx: /insights/test-gaps — git ls-tree → untested file detection → Claude risk scoring → callsite grep, 2h cache, links to ai/tests stub generator - src/views/components.tsx: health badge on RepoHeader, "archaeology" + "nl-search" added to RepoNavActive union and nav links https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
4 files changed+1393−27b1070a58d813bb8c7d0f87e0a844a3f9534d8131
4 changed files+1393−27
Modifiedsrc/lib/org-health.ts+19−26View fileUnifiedSplit
@@ -8,12 +8,11 @@
88 * force a fresh computation on the next request.
99 */
1010
11import { eq } from "drizzle-orm";
11import { eq, and, lt, sql } from "drizzle-orm";
1212import { db } from "../db";
1313import { repositories, repoHealthCache } from "../db/schema";
1414import { getHealthScore, invalidateHealthScore, type HealthScoreBreakdown } from "./repo-health";
1515import { getAnthropic, isAiAvailable, extractText, MODEL_SONNET } from "./ai-client";
16import { sql } from "drizzle-orm";
1716
1817// ---------------------------------------------------------------------------
1918// Public interface
@@ -63,22 +62,21 @@ async function getTrendForRepo(
6362): Promise<"up" | "down" | "stable"> {
6463 try {
6564 const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
66 // Look for a cached entry that was computed more than 7 days ago
65 // Look for a cached entry computed more than 7 days ago as the prior-week baseline
6766 const rows = await db
6867 .select({ score: repoHealthCache.score, computedAt: repoHealthCache.computedAt })
6968 .from(repoHealthCache)
70 .where(eq(repoHealthCache.repoId, repoId))
69 .where(
70 and(
71 eq(repoHealthCache.repoId, repoId),
72 lt(repoHealthCache.computedAt, oneWeekAgo)
73 )
74 )
7175 .limit(1);
7276
7377 if (rows.length === 0) return "stable";
7478
75 const cached = rows[0];
76 // If the cached entry is recent (less than 7 days old), we have no prior-week baseline
77 if (!cached.computedAt || new Date(cached.computedAt) > oneWeekAgo) {
78 return "stable";
79 }
80
81 const priorScore = cached.score;
79 const priorScore = rows[0].score;
8280 if (currentScore > priorScore + 2) return "up";
8381 if (currentScore < priorScore - 2) return "down";
8482 return "stable";
@@ -152,23 +150,15 @@ export async function computeOrgHealth(
152150 };
153151
154152 try {
155 // 1. Load org name + all active repos
156 const orgRows = await db
157 .select({ name: repositories.orgId })
158 .from(repositories)
159 .where(eq(repositories.orgId, orgId))
160 .limit(1);
161
162 // Load org display name from organizations table — we do this via the
163 // repositories query caller already has the org name from the route handler,
164 // so we accept orgSlug as the display fallback and the caller passes orgName separately.
165 // For now, use orgSlug as name placeholder; the route passes real name.
166
153 // 1. Load all non-archived repos in the org
167154 const repos = await db
168 .select({ id: repositories.id, name: repositories.name, ownerId: repositories.ownerId })
155 .select({ id: repositories.id, name: repositories.name })
169156 .from(repositories)
170157 .where(
171 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
158 and(
159 eq(repositories.orgId, orgId),
160 eq(repositories.isArchived, false)
161 )
172162 )
173163 .orderBy(repositories.name);
174164
@@ -244,7 +234,10 @@ export async function invalidateOrgHealthAndRepos(
244234 .select({ id: repositories.id })
245235 .from(repositories)
246236 .where(
247 sql`${repositories.orgId} = ${orgId} AND ${repositories.isArchived} = false`
237 and(
238 eq(repositories.orgId, orgId),
239 eq(repositories.isArchived, false)
240 )
248241 );
249242 for (const r of repos) {
250243 invalidateHealthScore(r.id);
Addedsrc/routes/ai-archaeology.tsx+817−0View fileUnifiedSplit
@@ -0,0 +1,817 @@
1/**
2 * AI Code Archaeology — /:owner/:repo/archaeology
3 *
4 * A developer can ask "why does this file exist?" and get a synthesized
5 * answer from git history, PR discussions, and the issue tracker.
6 *
7 * GET /:owner/:repo/archaeology — search form
8 * GET /:owner/:repo/archaeology?file=&q= — results page
9 * GET /:owner/:repo/archaeology?file=&q=&deep=1 — force-refresh results
10 */
11
12import { Hono } from "hono";
13import { html } from "hono/html";
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { repositories, users } from "../db/schema";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { renderMarkdown } from "../lib/markdown";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { excavate, invalidateCache } from "../lib/ai-archaeology";
23import type { ArchaeologyFinding } from "../lib/ai-archaeology";
24
25export const archaeologyRoutes = new Hono<AuthEnv>();
26
27/* ─────────────────────────────────────────────────────────────────────────
28 * Scoped CSS
29 * ───────────────────────────────────────────────────────────────────────── */
30const styles = `
31 .arch-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
32
33 /* Hero */
34 .arch-hero {
35 position: relative;
36 margin-bottom: var(--space-5);
37 padding: var(--space-5) var(--space-6);
38 background: var(--bg-elevated);
39 border: 1px solid var(--border);
40 border-radius: 16px;
41 overflow: hidden;
42 }
43 .arch-hero::before {
44 content: '';
45 position: absolute;
46 top: 0; left: 0; right: 0;
47 height: 2px;
48 background: linear-gradient(90deg, transparent 0%, #c97b2e 30%, #e8a45a 70%, transparent 100%);
49 opacity: 0.8;
50 pointer-events: none;
51 }
52 .arch-hero-orb {
53 position: absolute;
54 inset: -20% -10% auto auto;
55 width: 380px; height: 380px;
56 background: radial-gradient(circle, rgba(201,123,46,0.18), rgba(232,164,90,0.08) 45%, transparent 70%);
57 filter: blur(70px);
58 pointer-events: none;
59 z-index: 0;
60 }
61 .arch-hero-inner { position: relative; z-index: 1; max-width: 700px; }
62
63 .arch-eyebrow {
64 font-size: 12px;
65 color: var(--text-muted);
66 margin-bottom: var(--space-2);
67 letter-spacing: 0.02em;
68 display: inline-flex;
69 align-items: center;
70 gap: 8px;
71 }
72 .arch-eyebrow .pill {
73 display: inline-flex;
74 align-items: center;
75 justify-content: center;
76 width: 18px; height: 18px;
77 border-radius: 6px;
78 background: rgba(201,123,46,0.14);
79 color: #e8a45a;
80 box-shadow: inset 0 0 0 1px rgba(201,123,46,0.35);
81 font-size: 11px;
82 }
83 .arch-title {
84 font-size: clamp(26px, 4vw, 38px);
85 font-family: var(--font-display);
86 font-weight: 800;
87 letter-spacing: -0.025em;
88 line-height: 1.05;
89 margin: 0 0 var(--space-2);
90 color: var(--text-strong);
91 }
92 .arch-title-grad {
93 background-image: linear-gradient(135deg, #e8a45a 0%, #c97b2e 50%, #e8a45a 100%);
94 -webkit-background-clip: text;
95 background-clip: text;
96 -webkit-text-fill-color: transparent;
97 color: transparent;
98 }
99 .arch-sub {
100 font-size: 14px;
101 color: var(--text-muted);
102 margin: 0;
103 line-height: 1.55;
104 max-width: 580px;
105 }
106
107 /* Search form */
108 .arch-form {
109 margin-bottom: var(--space-5);
110 background: var(--bg-elevated);
111 border: 1px solid var(--border);
112 border-radius: 14px;
113 padding: var(--space-4) var(--space-5);
114 }
115 .arch-form-title {
116 font-family: var(--font-display);
117 font-size: 15px;
118 font-weight: 700;
119 color: var(--text-strong);
120 margin: 0 0 var(--space-3);
121 }
122 .arch-form-row {
123 display: grid;
124 grid-template-columns: 1fr 1fr auto;
125 gap: var(--space-2);
126 align-items: end;
127 }
128 @media (max-width: 640px) {
129 .arch-form-row { grid-template-columns: 1fr; }
130 }
131 .arch-form label {
132 display: block;
133 font-size: 12px;
134 font-weight: 600;
135 color: var(--text-muted);
136 margin-bottom: 4px;
137 letter-spacing: 0.03em;
138 }
139 .arch-form input[type="text"] {
140 width: 100%;
141 padding: 9px 12px;
142 background: var(--bg-tertiary);
143 border: 1px solid var(--border);
144 border-radius: 8px;
145 color: var(--text);
146 font-size: 14px;
147 font-family: inherit;
148 outline: none;
149 box-sizing: border-box;
150 transition: border-color 120ms ease;
151 }
152 .arch-form input[type="text"]:focus {
153 border-color: #c97b2e;
154 box-shadow: 0 0 0 3px rgba(201,123,46,0.12);
155 }
156 .arch-submit {
157 display: inline-flex;
158 align-items: center;
159 gap: 6px;
160 padding: 9px 18px;
161 background: linear-gradient(135deg, #c97b2e 0%, #e8a45a 100%);
162 color: #fff;
163 border: 1px solid transparent;
164 border-radius: 9px;
165 font-size: 13.5px;
166 font-weight: 600;
167 cursor: pointer;
168 font-family: inherit;
169 box-shadow: 0 4px 12px -3px rgba(201,123,46,0.45);
170 transition: transform 120ms ease, box-shadow 120ms ease;
171 white-space: nowrap;
172 }
173 .arch-submit:hover {
174 transform: translateY(-1px);
175 box-shadow: 0 8px 18px -4px rgba(201,123,46,0.55);
176 }
177
178 /* Explanation panel */
179 .arch-panel {
180 position: relative;
181 margin-bottom: var(--space-5);
182 background: #ffffff;
183 color: #0a0a0a;
184 border: 1px solid #e5e7eb;
185 border-radius: 14px;
186 overflow: hidden;
187 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.16);
188 }
189 .arch-panel-head {
190 display: flex;
191 align-items: center;
192 justify-content: space-between;
193 gap: 12px;
194 padding: 12px 18px;
195 background: #f9fafb;
196 border-bottom: 1px solid #e5e7eb;
197 flex-wrap: wrap;
198 }
199 .arch-panel-title {
200 display: flex;
201 align-items: center;
202 gap: 10px;
203 font-size: 14px;
204 font-weight: 700;
205 color: #111827;
206 margin: 0;
207 font-family: var(--font-display, system-ui, sans-serif);
208 }
209 .arch-panel-dot {
210 width: 8px; height: 8px;
211 border-radius: 9999px;
212 background: linear-gradient(135deg, #c97b2e, #e8a45a);
213 box-shadow: 0 0 0 3px rgba(201,123,46,0.18);
214 }
215 .arch-panel-meta {
216 display: flex;
217 align-items: center;
218 gap: 10px;
219 flex-wrap: wrap;
220 }
221
222 /* Confidence pill */
223 .arch-confidence {
224 display: inline-flex;
225 align-items: center;
226 gap: 5px;
227 padding: 2px 9px;
228 border-radius: 9999px;
229 font-size: 10.5px;
230 font-weight: 700;
231 letter-spacing: 0.05em;
232 text-transform: uppercase;
233 }
234 .arch-confidence-high {
235 background: rgba(52,211,153,0.12);
236 color: #047857;
237 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.4);
238 }
239 .arch-confidence-medium {
240 background: rgba(251,191,36,0.12);
241 color: #92400e;
242 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.4);
243 }
244 .arch-confidence-low {
245 background: rgba(248,113,113,0.12);
246 color: #b91c1c;
247 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.4);
248 }
249 .arch-confidence .dot {
250 width: 5px; height: 5px;
251 border-radius: 9999px;
252 background: currentColor;
253 }
254
255 .arch-panel-body {
256 padding: 22px 24px;
257 }
258 .arch-panel-body .markdown-body {
259 color: #0a0a0a;
260 background: #ffffff;
261 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
262 font-size: 14.5px;
263 line-height: 1.65;
264 }
265 .arch-panel-body .markdown-body h1,
266 .arch-panel-body .markdown-body h2,
267 .arch-panel-body .markdown-body h3 {
268 color: #0a0a0a;
269 border-bottom-color: #e5e7eb;
270 }
271 .arch-panel-body .markdown-body a { color: #92400e; }
272 .arch-panel-body .markdown-body code {
273 background: #fef3c7;
274 color: #92400e;
275 padding: 1px 5px;
276 border-radius: 4px;
277 font-family: var(--font-mono);
278 font-size: 12.5px;
279 }
280 .arch-panel-body .markdown-body pre {
281 background: #0f111a;
282 color: #e6edf3;
283 border: 1px solid #1f2330;
284 border-radius: 8px;
285 padding: 12px 14px;
286 overflow-x: auto;
287 }
288 .arch-panel-body .markdown-body pre code {
289 background: transparent;
290 color: inherit;
291 padding: 0;
292 }
293 .arch-panel-body .markdown-body blockquote {
294 border-left: 3px solid #fcd34d;
295 background: #fffbeb;
296 color: #78350f;
297 padding: 8px 14px;
298 margin: 12px 0;
299 border-radius: 6px;
300 }
301
302 /* Actions row below panel */
303 .arch-actions {
304 display: flex;
305 align-items: center;
306 gap: 12px;
307 margin-bottom: var(--space-5);
308 flex-wrap: wrap;
309 }
310 .arch-dig-deeper {
311 display: inline-flex;
312 align-items: center;
313 gap: 6px;
314 padding: 8px 16px;
315 background: var(--bg-elevated);
316 border: 1px solid var(--border);
317 border-radius: 9px;
318 font-size: 13px;
319 font-weight: 600;
320 color: var(--text);
321 text-decoration: none;
322 transition: background 120ms ease, border-color 120ms ease;
323 }
324 .arch-dig-deeper:hover {
325 background: var(--bg-tertiary);
326 border-color: #c97b2e;
327 color: #e8a45a;
328 }
329 .arch-file-link {
330 font-size: 12.5px;
331 color: var(--text-muted);
332 text-decoration: none;
333 font-family: var(--font-mono);
334 padding: 4px 8px;
335 background: var(--bg-tertiary);
336 border-radius: 6px;
337 border: 1px solid var(--border);
338 }
339 .arch-file-link:hover { color: var(--text); border-color: var(--border-strong, var(--border)); }
340 .arch-analyzed-at {
341 font-size: 11.5px;
342 color: var(--text-muted);
343 margin-left: auto;
344 }
345
346 /* Findings timeline */
347 .arch-timeline-title {
348 font-family: var(--font-display);
349 font-size: 16px;
350 font-weight: 700;
351 color: var(--text-strong);
352 margin: 0 0 var(--space-3);
353 letter-spacing: -0.01em;
354 }
355 .arch-timeline {
356 display: flex;
357 flex-direction: column;
358 gap: 0;
359 border: 1px solid var(--border);
360 border-radius: 12px;
361 overflow: hidden;
362 margin-bottom: var(--space-5);
363 }
364 .arch-finding {
365 display: grid;
366 grid-template-columns: 36px 1fr auto;
367 align-items: start;
368 gap: var(--space-3);
369 padding: 14px 16px;
370 border-bottom: 1px solid var(--border);
371 background: var(--bg-elevated);
372 text-decoration: none;
373 color: inherit;
374 transition: background 100ms ease;
375 }
376 .arch-finding:last-child { border-bottom: none; }
377 .arch-finding:hover { background: var(--bg-tertiary); }
378
379 .arch-finding-icon {
380 display: flex;
381 align-items: center;
382 justify-content: center;
383 width: 28px; height: 28px;
384 border-radius: 8px;
385 font-size: 13px;
386 flex-shrink: 0;
387 margin-top: 1px;
388 }
389 .arch-icon-commit {
390 background: rgba(140,109,255,0.12);
391 color: #a78bfa;
392 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
393 }
394 .arch-icon-pr {
395 background: rgba(52,211,153,0.12);
396 color: #10b981;
397 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
398 }
399 .arch-icon-issue {
400 background: rgba(248,113,113,0.12);
401 color: #f87171;
402 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.28);
403 }
404
405 .arch-finding-body {}
406 .arch-finding-title {
407 font-size: 13.5px;
408 font-weight: 600;
409 color: var(--text-strong);
410 margin: 0 0 3px;
411 line-height: 1.35;
412 word-break: break-word;
413 }
414 .arch-finding-summary {
415 font-size: 12.5px;
416 color: var(--text-muted);
417 margin: 0;
418 line-height: 1.45;
419 }
420
421 .arch-finding-meta {
422 display: flex;
423 flex-direction: column;
424 align-items: flex-end;
425 gap: 4px;
426 font-size: 11.5px;
427 color: var(--text-muted);
428 white-space: nowrap;
429 flex-shrink: 0;
430 }
431 .arch-finding-type {
432 font-size: 10px;
433 font-weight: 700;
434 letter-spacing: 0.06em;
435 text-transform: uppercase;
436 padding: 1px 6px;
437 border-radius: 4px;
438 }
439 .arch-type-commit { background: rgba(140,109,255,0.12); color: #a78bfa; }
440 .arch-type-pr { background: rgba(52,211,153,0.12); color: #10b981; }
441 .arch-type-issue { background: rgba(248,113,113,0.12); color: #f87171; }
442
443 /* Empty states */
444 .arch-empty {
445 position: relative;
446 margin: var(--space-4) 0;
447 padding: var(--space-6);
448 border: 1px dashed var(--border);
449 border-radius: 14px;
450 background: var(--bg-elevated);
451 text-align: center;
452 overflow: hidden;
453 }
454 .arch-empty-orb {
455 position: absolute;
456 inset: -40% 35% auto 35%;
457 width: 260px; height: 260px;
458 background: radial-gradient(circle, rgba(201,123,46,0.16), rgba(232,164,90,0.06) 45%, transparent 70%);
459 filter: blur(55px);
460 pointer-events: none;
461 z-index: 0;
462 }
463 .arch-empty > * { position: relative; z-index: 1; }
464 .arch-empty h2 {
465 margin: 0 0 6px;
466 font-family: var(--font-display);
467 font-size: 18px;
468 color: var(--text-strong);
469 }
470 .arch-empty p {
471 margin: 0 auto 12px;
472 color: var(--text-muted);
473 font-size: 14px;
474 max-width: 440px;
475 line-height: 1.55;
476 }
477
478 /* Powered-by pill */
479 .arch-poweredby {
480 margin-top: var(--space-5);
481 text-align: center;
482 color: var(--text-muted);
483 font-size: 11.5px;
484 }
485 .arch-poweredby-pill {
486 display: inline-flex;
487 align-items: center;
488 gap: 6px;
489 padding: 4px 10px;
490 border-radius: 9999px;
491 background: rgba(201,123,46,0.08);
492 border: 1px solid rgba(201,123,46,0.22);
493 color: var(--text-muted);
494 font-size: 11px;
495 letter-spacing: 0.04em;
496 text-transform: uppercase;
497 font-weight: 600;
498 }
499 .arch-poweredby-pill .dot {
500 width: 6px; height: 6px;
501 border-radius: 9999px;
502 background: linear-gradient(135deg, #c97b2e, #e8a45a);
503 }
504
505 :root[data-theme='light'] .arch-panel {
506 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
507 }
508`;
509
510// ---------------------------------------------------------------------------
511// Helpers
512// ---------------------------------------------------------------------------
513
514interface ResolvedRepo {
515 ownerId: string;
516 repoId: string;
517}
518
519async function resolveRepo(
520 ownerName: string,
521 repoName: string
522): Promise<ResolvedRepo | null> {
523 try {
524 const [ownerRow] = await db
525 .select()
526 .from(users)
527 .where(eq(users.username, ownerName))
528 .limit(1);
529 if (!ownerRow) return null;
530
531 const [repoRow] = await db
532 .select()
533 .from(repositories)
534 .where(
535 and(
536 eq(repositories.ownerId, ownerRow.id),
537 eq(repositories.name, repoName)
538 )
539 )
540 .limit(1);
541 if (!repoRow) return null;
542
543 return { ownerId: ownerRow.id, repoId: repoRow.id };
544 } catch {
545 return null;
546 }
547}
548
549function findingIcon(type: ArchaeologyFinding["type"]): string {
550 if (type === "commit") return "◆"; // diamond
551 if (type === "pr") return "↪"; // right arrow hook
552 return "!"; // issue
553}
554
555function confidenceLabel(conf: "high" | "medium" | "low"): string {
556 if (conf === "high") return "High confidence";
557 if (conf === "medium") return "Medium confidence";
558 return "Low confidence";
559}
560
561// ---------------------------------------------------------------------------
562// Route
563// ---------------------------------------------------------------------------
564
565archaeologyRoutes.get("/:owner/:repo/archaeology", softAuth, async (c) => {
566 const { owner, repo } = c.req.param();
567 const user = c.get("user");
568 const filePath = c.req.query("file") ?? "";
569 const query = c.req.query("q") ?? "Why does this code exist?";
570 const deep = c.req.query("deep") === "1";
571
572 const resolved = await resolveRepo(owner, repo);
573 if (!resolved) {
574 return c.html(
575 <Layout title="Not Found" user={user}>
576 <div class="empty-state">
577 <h2>Repository not found</h2>
578 </div>
579 </Layout>,
580 404
581 );
582 }
583
584 // Search form view (no file param)
585 if (!filePath) {
586 return c.html(
587 <Layout title={`Archaeology — ${owner}/${repo}`} user={user}>
588 <RepoHeader owner={owner} repo={repo} />
589 <RepoNav owner={owner} repo={repo} active="archaeology" />
590 <div class="arch-wrap">
591 <section class="arch-hero">
592 <div class="arch-hero-orb" aria-hidden="true" />
593 <div class="arch-hero-inner">
594 <div class="arch-eyebrow">
595 <span class="pill" aria-hidden="true">{"🏛"}</span>
596 AI · gluecron · archaeology
597 </div>
598 <h1 class="arch-title">
599 <span class="arch-title-grad">Archaeology.</span>
600 </h1>
601 <p class="arch-sub">
602 Ask why any file exists. Claude searches git history, pull
603 requests, and issues to reconstruct the original motivation
604 and key decisions.
605 </p>
606 </div>
607 </section>
608
609 <div class="arch-form">
610 <p class="arch-form-title">Excavate a file</p>
611 <form method="get" action={`/${owner}/${repo}/archaeology`}>
612 <div class="arch-form-row">
613 <div>
614 <label for="arch-file">File path</label>
615 <input
616 id="arch-file"
617 type="text"
618 name="file"
619 placeholder="src/lib/auth.ts"
620 required
621 autocomplete="off"
622 spellcheck={false as any}
623 />
624 </div>
625 <div>
626 <label for="arch-q">Question (optional)</label>
627 <input
628 id="arch-q"
629 type="text"
630 name="q"
631 placeholder="Why does this exist?"
632 autocomplete="off"
633 />
634 </div>
635 <div>
636 <button type="submit" class="arch-submit">
637 {"🏛"} Dig
638 </button>
639 </div>
640 </div>
641 </form>
642 </div>
643
644 <div class="arch-poweredby">
645 <span class="arch-poweredby-pill">
646 <span class="dot" aria-hidden="true" />
647 Powered by Claude
648 </span>
649 </div>
650 </div>
651 <style dangerouslySetInnerHTML={{ __html: styles }} />
652 </Layout>
653 );
654 }
655
656 // Results view — excavate
657 if (deep) {
658 invalidateCache(resolved.repoId, filePath);
659 }
660
661 const report = await excavate(owner, repo, resolved.repoId, filePath, query);
662
663 const deepUrl = `/${owner}/${repo}/archaeology?file=${encodeURIComponent(filePath)}&q=${encodeURIComponent(query)}&deep=1`;
664 const fileUrl = `/${owner}/${repo}/blob/HEAD/${filePath}`;
665 const analyzedAt = report.analyzedAt.toUTCString().replace(" GMT", " UTC");
666
667 return c.html(
668 <Layout title={`Archaeology: ${filePath} — ${owner}/${repo}`} user={user}>
669 <RepoHeader owner={owner} repo={repo} />
670 <RepoNav owner={owner} repo={repo} active="archaeology" />
671 <div class="arch-wrap">
672 <section class="arch-hero">
673 <div class="arch-hero-orb" aria-hidden="true" />
674 <div class="arch-hero-inner">
675 <div class="arch-eyebrow">
676 <span class="pill" aria-hidden="true">{"🏛"}</span>
677 AI · gluecron · archaeology
678 </div>
679 <h1 class="arch-title">
680 <span class="arch-title-grad">Archaeology.</span>
681 </h1>
682 <p class="arch-sub">
683 Why does <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 6px;border-radius:4px;color:var(--text)">{filePath}</code> exist?
684 </p>
685 </div>
686 </section>
687
688 {/* Search form (pre-filled) */}
689 <div class="arch-form">
690 <p class="arch-form-title">Refine your question</p>
691 <form method="get" action={`/${owner}/${repo}/archaeology`}>
692 <div class="arch-form-row">
693 <div>
694 <label for="arch-file2">File path</label>
695 <input
696 id="arch-file2"
697 type="text"
698 name="file"
699 value={filePath}
700 required
701 autocomplete="off"
702 spellcheck={false as any}
703 />
704 </div>
705 <div>
706 <label for="arch-q2">Question</label>
707 <input
708 id="arch-q2"
709 type="text"
710 name="q"
711 value={query}
712 autocomplete="off"
713 />
714 </div>
715 <div>
716 <button type="submit" class="arch-submit">
717 {"🏛"} Dig
718 </button>
719 </div>
720 </div>
721 </form>
722 </div>
723
724 {/* Explanation panel */}
725 <section class="arch-panel" aria-label="AI explanation">
726 <header class="arch-panel-head">
727 <p class="arch-panel-title">
728 <span class="arch-panel-dot" aria-hidden="true" />
729 Explanation
730 </p>
731 <div class="arch-panel-meta">
732 <span
733 class={`arch-confidence arch-confidence-${report.confidence}`}
734 >
735 <span class="dot" aria-hidden="true" />
736 {confidenceLabel(report.confidence)}
737 </span>
738 </div>
739 </header>
740 <div class="arch-panel-body">
741 <div class="markdown-body">
742 {html(
743 [renderMarkdown(report.explanation)] as unknown as TemplateStringsArray
744 )}
745 </div>
746 </div>
747 </section>
748
749 {/* Actions */}
750 <div class="arch-actions">
751 <a href={deepUrl} class="arch-dig-deeper">
752 {"🔍"} Dig deeper
753 </a>
754 <a href={fileUrl} class="arch-file-link">
755 {filePath}
756 </a>
757 <span class="arch-analyzed-at">Analyzed {analyzedAt}</span>
758 </div>
759
760 {/* Findings timeline */}
761 {report.findings.length > 0 ? (
762 <>
763 <h2 class="arch-timeline-title">Evidence timeline</h2>
764 <div class="arch-timeline">
765 {report.findings.map((f) => (
766 <a
767 href={f.url}
768 class="arch-finding"
769 key={`${f.type}-${f.id}`}
770 >
771 <span
772 class={`arch-finding-icon arch-icon-${f.type}`}
773 aria-label={f.type}
774 >
775 {findingIcon(f.type)}
776 </span>
777 <div class="arch-finding-body">
778 <p class="arch-finding-title">{f.title}</p>
779 <p class="arch-finding-summary">{f.summary}</p>
780 </div>
781 <div class="arch-finding-meta">
782 <span class={`arch-finding-type arch-type-${f.type}`}>
783 {f.type}
784 </span>
785 <span>{f.date ? f.date.slice(0, 10) : ""}</span>
786 {f.author ? <span>{f.author}</span> : null}
787 </div>
788 </a>
789 ))}
790 </div>
791 </>
792 ) : (
793 <div class="arch-empty">
794 <div class="arch-empty-orb" aria-hidden="true" />
795 <h2>No supporting evidence found</h2>
796 <p>
797 No commits, pull requests, or issues mentioning{" "}
798 <strong>{filePath.split("/").pop()}</strong> were found in this
799 repository. The explanation above was generated from file content
800 alone.
801 </p>
802 </div>
803 )}
804
805 <div class="arch-poweredby">
806 <span class="arch-poweredby-pill">
807 <span class="dot" aria-hidden="true" />
808 Powered by Claude
809 </span>
810 </div>
811 </div>
812 <style dangerouslySetInnerHTML={{ __html: styles }} />
813 </Layout>
814 );
815});
816
817export default archaeologyRoutes;
Addedsrc/routes/org-health.tsx+548−0View fileUnifiedSplit
@@ -0,0 +1,548 @@
1/**
2 * Org-level team health dashboard.
3 *
4 * GET /orgs/:slug/health — ranked health page (worst-first)
5 * POST /orgs/:slug/health/recompute — invalidate caches + redirect
6 *
7 * Reuses `computeOrgHealth` from src/lib/org-health.ts and the
8 * `getHealthScore` signal breakdown already built in src/lib/repo-health.ts.
9 * No new tables — leverages repo_health_cache + bus_factor_cache etc.
10 */
11
12import { Hono } from "hono";
13import { eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { organizations, orgMembers } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 computeOrgHealth,
21 invalidateOrgHealthAndRepos,
22 type OrgRepoHealth,
23} from "../lib/org-health";
24import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs";
25
26const orgHealthRoutes = new Hono<AuthEnv>();
27orgHealthRoutes.use("*", softAuth);
28
29// ---------------------------------------------------------------------------
30// Helpers
31// ---------------------------------------------------------------------------
32
33function scoreColor(score: number): string {
34 if (score >= 80) return "#34d399";
35 if (score >= 50) return "#fbbf24";
36 return "#f87171";
37}
38
39function scorePillClass(score: number): string {
40 if (score >= 80) return "oh-pill--good";
41 if (score >= 50) return "oh-pill--warn";
42 return "oh-pill--bad";
43}
44
45function trendArrow(trend: "up" | "down" | "stable"): string {
46 if (trend === "up") return "↑";
47 if (trend === "down") return "↓";
48 return "→";
49}
50
51function trendClass(trend: "up" | "down" | "stable"): string {
52 if (trend === "up") return "oh-trend--up";
53 if (trend === "down") return "oh-trend--down";
54 return "oh-trend--stable";
55}
56
57function badgeStyle(score: number): string {
58 const color = scoreColor(score);
59 return `background:rgba(0,0,0,0.25);color:${color};border:1px solid ${color}40;padding:2px 6px;border-radius:4px;font-size:11px;font-family:var(--font-mono);font-weight:600`;
60}
61
62// ---------------------------------------------------------------------------
63// Scoped CSS — every class prefixed `.oh-`
64// ---------------------------------------------------------------------------
65
66const styles = `
67 .oh-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
68
69 /* Hero */
70 .oh-hero {
71 position: relative;
72 margin-bottom: var(--space-5);
73 padding: var(--space-5) var(--space-6);
74 background: var(--bg-elevated);
75 border: 1px solid var(--border);
76 border-radius: 16px;
77 overflow: hidden;
78 }
79 .oh-hero::before {
80 content: '';
81 position: absolute;
82 top: 0; left: 0; right: 0;
83 height: 2px;
84 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #fbbf24 70%, transparent 100%);
85 opacity: 0.75;
86 pointer-events: none;
87 }
88 .oh-hero-orb {
89 position: absolute;
90 inset: -30% -15% auto auto;
91 width: 420px; height: 420px;
92 background: radial-gradient(circle, rgba(52,211,153,0.18), rgba(251,191,36,0.08) 45%, transparent 70%);
93 filter: blur(80px);
94 opacity: 0.6;
95 pointer-events: none;
96 z-index: 0;
97 }
98 .oh-hero-inner {
99 position: relative;
100 z-index: 1;
101 display: flex;
102 align-items: flex-start;
103 justify-content: space-between;
104 gap: var(--space-4);
105 flex-wrap: wrap;
106 }
107 .oh-hero-text { max-width: 720px; }
108 .oh-eyebrow {
109 display: inline-flex;
110 align-items: center;
111 gap: 8px;
112 text-transform: uppercase;
113 font-family: var(--font-mono);
114 font-size: 11px;
115 letter-spacing: 0.18em;
116 color: var(--text-muted);
117 font-weight: 600;
118 margin-bottom: 14px;
119 }
120 .oh-eyebrow-dot {
121 width: 8px; height: 8px;
122 border-radius: 9999px;
123 background: linear-gradient(135deg, #34d399, #fbbf24);
124 box-shadow: 0 0 0 3px rgba(52,211,153,0.18);
125 }
126 .oh-title {
127 font-family: var(--font-display);
128 font-size: clamp(26px, 4vw, 38px);
129 font-weight: 800;
130 letter-spacing: -0.025em;
131 line-height: 1.05;
132 margin: 0 0 var(--space-2);
133 color: var(--text-strong);
134 }
135 .oh-title-grad {
136 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #fbbf24 100%);
137 -webkit-background-clip: text;
138 background-clip: text;
139 -webkit-text-fill-color: transparent;
140 color: transparent;
141 }
142 .oh-sub {
143 font-size: 15px;
144 color: var(--text-muted);
145 margin: 0;
146 line-height: 1.55;
147 }
148 .oh-back {
149 display: inline-flex;
150 align-items: center;
151 gap: 6px;
152 padding: 8px 14px;
153 border-radius: 10px;
154 border: 1px solid var(--border-strong, var(--border));
155 background: transparent;
156 color: var(--text);
157 font-size: 13px;
158 font-weight: 600;
159 text-decoration: none;
160 transition: background 120ms ease, border-color 120ms ease;
161 white-space: nowrap;
162 }
163 .oh-back:hover {
164 background: rgba(52,211,153,0.06);
165 border-color: rgba(52,211,153,0.45);
166 text-decoration: none;
167 }
168
169 /* Avg score pill */
170 .oh-pill {
171 display: inline-flex;
172 align-items: center;
173 justify-content: center;
174 width: 64px; height: 64px;
175 border-radius: 9999px;
176 font-family: var(--font-display);
177 font-size: 22px;
178 font-weight: 800;
179 border: 3px solid;
180 margin-top: 14px;
181 font-variant-numeric: tabular-nums;
182 }
183 .oh-pill--good { color: #34d399; border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
184 .oh-pill--warn { color: #fbbf24; border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.08); }
185 .oh-pill--bad { color: #f87171; border-color: rgba(248,113,113,0.5); background: rgba(248,113,113,0.08); }
186
187 /* AI summary card */
188 .oh-ai-card {
189 margin-bottom: var(--space-5);
190 padding: var(--space-4) var(--space-5);
191 background: rgba(59,130,246,0.05);
192 border: 1px solid rgba(59,130,246,0.3);
193 border-radius: 14px;
194 font-size: 14px;
195 line-height: 1.65;
196 color: var(--text);
197 white-space: pre-wrap;
198 }
199 .oh-ai-label {
200 display: flex;
201 align-items: center;
202 gap: 7px;
203 font-size: 10.5px;
204 font-weight: 700;
205 letter-spacing: 0.14em;
206 text-transform: uppercase;
207 color: #93c5fd;
208 margin-bottom: var(--space-2);
209 }
210 .oh-ai-dot {
211 width: 6px; height: 6px;
212 border-radius: 9999px;
213 background: #3b82f6;
214 box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
215 }
216
217 /* Repo table */
218 .oh-table-wrap {
219 background: var(--bg-elevated);
220 border: 1px solid var(--border);
221 border-radius: 14px;
222 overflow: hidden;
223 margin-bottom: var(--space-5);
224 }
225 .oh-table-head {
226 padding: var(--space-3) var(--space-4);
227 display: flex;
228 align-items: center;
229 justify-content: space-between;
230 border-bottom: 1px solid var(--border);
231 flex-wrap: wrap;
232 gap: var(--space-2);
233 }
234 .oh-table-title {
235 margin: 0;
236 font-family: var(--font-display);
237 font-size: 16px;
238 font-weight: 700;
239 letter-spacing: -0.015em;
240 color: var(--text-strong);
241 }
242 .oh-table-sub { font-size: 12px; color: var(--text-muted); }
243 table.oh-table {
244 width: 100%;
245 border-collapse: collapse;
246 font-size: 13px;
247 }
248 .oh-table th {
249 padding: 10px 14px;
250 text-align: left;
251 font-size: 10.5px;
252 font-weight: 700;
253 letter-spacing: 0.12em;
254 text-transform: uppercase;
255 color: var(--text-muted);
256 border-bottom: 1px solid var(--border);
257 white-space: nowrap;
258 }
259 .oh-table td {
260 padding: 10px 14px;
261 border-bottom: 1px solid var(--border);
262 vertical-align: middle;
263 }
264 .oh-table tr:last-child td { border-bottom: none; }
265 .oh-table tr:hover td { background: rgba(255,255,255,0.02); }
266 .oh-rank {
267 width: 28px; height: 28px;
268 border-radius: 8px;
269 background: rgba(140,109,255,0.10);
270 color: #b69dff;
271 font-family: var(--font-mono);
272 font-size: 12px;
273 font-weight: 700;
274 display: inline-flex;
275 align-items: center;
276 justify-content: center;
277 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
278 }
279 .oh-repo-link {
280 color: var(--text-strong);
281 font-weight: 600;
282 text-decoration: none;
283 font-size: 13.5px;
284 }
285 .oh-repo-link:hover { color: var(--accent); }
286
287 /* Score bar */
288 .oh-bar-track {
289 width: 120px;
290 height: 6px;
291 background: rgba(255,255,255,0.08);
292 border-radius: 3px;
293 overflow: hidden;
294 display: inline-block;
295 vertical-align: middle;
296 margin-right: 8px;
297 }
298
299 /* Trend arrows */
300 .oh-trend--up { color: #34d399; font-weight: 700; }
301 .oh-trend--down { color: #f87171; font-weight: 700; }
302 .oh-trend--stable { color: var(--text-muted); }
303
304 /* Recompute button */
305 .oh-recompute-form { margin-top: var(--space-2); }
306 .oh-recompute-btn {
307 display: inline-flex;
308 align-items: center;
309 gap: 6px;
310 padding: 8px 16px;
311 border-radius: 10px;
312 border: 1px solid var(--border-strong, var(--border));
313 background: transparent;
314 color: var(--text);
315 font-size: 13px;
316 font-weight: 600;
317 cursor: pointer;
318 transition: background 120ms ease, border-color 120ms ease;
319 }
320 .oh-recompute-btn:hover {
321 background: rgba(52,211,153,0.07);
322 border-color: rgba(52,211,153,0.4);
323 }
324
325 /* Empty state */
326 .oh-empty {
327 padding: var(--space-6) var(--space-4);
328 text-align: center;
329 color: var(--text-muted);
330 border: 1px dashed var(--border-strong, var(--border));
331 border-radius: 14px;
332 }
333 .oh-empty strong {
334 display: block;
335 font-size: 16px;
336 font-weight: 700;
337 color: var(--text-strong);
338 margin-bottom: 8px;
339 }
340
341 /* Generated-at footer */
342 .oh-footer {
343 font-size: 11.5px;
344 color: var(--text-muted);
345 margin-top: var(--space-3);
346 font-family: var(--font-mono);
347 }
348`;
349
350// ---------------------------------------------------------------------------
351// GET /orgs/:slug/health
352// ---------------------------------------------------------------------------
353
354orgHealthRoutes.get("/orgs/:slug/health", async (c) => {
355 const slug = c.req.param("slug");
356 const user = c.get("user");
357
358 const { org, role } = await loadOrgForUser(slug, user?.id);
359 if (!org) return c.notFound();
360
361 const report = await computeOrgHealth(org.id, org.slug);
362 // Patch org name in case the lib used slug as placeholder
363 const orgName = org.name;
364
365 const isAdmin = !!role && orgRoleAtLeast(role, "admin");
366 const avgScore = report.avgScore;
367
368 return c.html(
369 <Layout title={`${orgName} — Engineering Health`} user={user ?? null}>
370 <div class="oh-wrap">
371 {/* Hero */}
372 <section class="oh-hero">
373 <div class="oh-hero-orb" aria-hidden="true" />
374 <div class="oh-hero-inner">
375 <div class="oh-hero-text">
376 <div class="oh-eyebrow">
377 <span class="oh-eyebrow-dot" aria-hidden="true" />
378 Team health · {slug}
379 </div>
380 <h2 class="oh-title">
381 <span class="oh-title-grad">{orgName}</span>{" "}
382 Engineering Health
383 </h2>
384 <p class="oh-sub">
385 All repositories ranked worst-first by composite health score.
386 Fix the bottom of the list to move the org average.
387 </p>
388 <div
389 class={"oh-pill " + scorePillClass(avgScore)}
390 title={`Org average: ${avgScore}/100`}
391 >
392 {avgScore}
393 </div>
394 </div>
395 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-3)">
396 <a href={`/orgs/${slug}`} class="oh-back">
397 ← Back to {slug}
398 </a>
399 {isAdmin && (
400 <form
401 method="post"
402 action={`/orgs/${slug}/health/recompute`}
403 class="oh-recompute-form"
404 >
405 <button type="submit" class="oh-recompute-btn">
406 ↺ Recompute
407 </button>
408 </form>
409 )}
410 </div>
411 </div>
412 </section>
413
414 {/* AI Summary */}
415 {report.aiSummary && (
416 <div class="oh-ai-card">
417 <div class="oh-ai-label">
418 <span class="oh-ai-dot" aria-hidden="true" />
419 AI Engineering Summary
420 </div>
421 {report.aiSummary}
422 </div>
423 )}
424
425 {/* Repo table */}
426 <div class="oh-table-wrap">
427 <div class="oh-table-head">
428 <h3 class="oh-table-title">Repository Health — Worst First</h3>
429 <span class="oh-table-sub">{report.repos.length} repo{report.repos.length === 1 ? "" : "s"}</span>
430 </div>
431
432 {report.repos.length === 0 ? (
433 <div class="oh-empty">
434 <strong>No repositories found</strong>
435 <p>This org has no active repositories yet, or health data is still loading.</p>
436 </div>
437 ) : (
438 <table class="oh-table">
439 <thead>
440 <tr>
441 <th style="width:40px">#</th>
442 <th>Repository</th>
443 <th style="width:200px">Score</th>
444 <th style="width:60px">Trend</th>
445 <th style="width:55px">CI</th>
446 <th style="width:80px">BusFactor</th>
447 <th style="width:55px">CVEs</th>
448 <th style="width:60px">Review</th>
449 <th style="width:55px">Debt</th>
450 </tr>
451 </thead>
452 <tbody>
453 {report.repos.map((r: OrgRepoHealth, i: number) => {
454 const b = r.breakdown;
455 const barColor = scoreColor(r.score);
456 return (
457 <tr key={r.repoId}>
458 <td>
459 <span class="oh-rank">{i + 1}</span>
460 </td>
461 <td>
462 <a
463 href={`/${slug}/${r.repoName}`}
464 class="oh-repo-link"
465 >
466 {slug}/{r.repoName}
467 </a>
468 </td>
469 <td>
470 <span class="oh-bar-track">
471 <div
472 style={`width:${r.score}%;background:${barColor};height:6px;border-radius:3px`}
473 />
474 </span>
475 <span
476 style={`color:${barColor};font-family:var(--font-mono);font-size:13px;font-weight:700`}
477 >
478 {r.score}
479 </span>
480 <span style="color:var(--text-muted);font-size:11px">/100</span>
481 </td>
482 <td>
483 <span class={trendClass(r.trend)} title={`Trend: ${r.trend}`}>
484 {trendArrow(r.trend)}
485 </span>
486 </td>
487 <td>
488 <span style={badgeStyle(b.ciGreenRate.score)}>
489 {b.ciGreenRate.score}
490 </span>
491 </td>
492 <td>
493 <span style={badgeStyle(b.busFactor.score)}>
494 {b.busFactor.score}
495 </span>
496 </td>
497 <td>
498 <span style={badgeStyle(b.openCves.score)}>
499 {b.openCves.score}
500 </span>
501 </td>
502 <td>
503 <span style={badgeStyle(b.reviewVelocity.score)}>
504 {b.reviewVelocity.score}
505 </span>
506 </td>
507 <td>
508 <span style={badgeStyle(b.techDebt.score)}>
509 {b.techDebt.score}
510 </span>
511 </td>
512 </tr>
513 );
514 })}
515 </tbody>
516 </table>
517 )}
518 </div>
519
520 <div class="oh-footer">
521 Generated {report.generatedAt.toISOString().replace("T", " ").slice(0, 19)} UTC · cached 1h
522 </div>
523 </div>
524 <style dangerouslySetInnerHTML={{ __html: styles }} />
525 </Layout>
526 );
527});
528
529// ---------------------------------------------------------------------------
530// POST /orgs/:slug/health/recompute — admin only, invalidate + redirect
531// ---------------------------------------------------------------------------
532
533orgHealthRoutes.post("/orgs/:slug/health/recompute", requireAuth, async (c) => {
534 const slug = c.req.param("slug");
535 const user = c.get("user")!;
536
537 const { org, role } = await loadOrgForUser(slug, user.id);
538 if (!org) return c.notFound();
539 if (!role || !orgRoleAtLeast(role, "admin")) {
540 return c.text("Forbidden", 403);
541 }
542
543 await invalidateOrgHealthAndRepos(org.id);
544 return c.redirect(`/orgs/${slug}/health`);
545});
546
547export { orgHealthRoutes };
548export default orgHealthRoutes;
Modifiedsrc/views/components.tsx+9−1View fileUnifiedSplit
@@ -172,7 +172,8 @@ export const RepoNav: FC<{
172172 | "debt-map"
173173 | "migrate"
174174 | "deployments"
175 | "nl-search";
175 | "nl-search"
176 | "archaeology";
176177}> = ({ owner, repo, active }) => (
177178 <div class="repo-nav">
178179 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
@@ -294,6 +295,13 @@ export const RepoNav: FC<{
294295 >
295296 {"\u2728"} NL Search
296297 </a>
298 <a
299 href={`/${owner}/${repo}/archaeology`}
300 class={`repo-nav-ai${active === "archaeology" ? " active" : ""}`}
301 title="AI Archaeology \u2014 excavate why any file exists using git history, PRs, and issues"
302 >
303 {"\ud83c\udfdb"} Archaeology
304 </a>
297305 </div>
298306);
299307
300308