Visual audit: switched-off scanners, dead-end pages, and a container with no memory limit #5562
15 changed files+580−129
Modified.env.example+7−0View fileUnifiedSplit
@@ -93,6 +93,13 @@ HEARTBEAT_REPORT_TOKEN=
9393# recording.
9494REPORTING_EPOCH=
9595ANTHROPIC_API_KEY=
96# Container memory ceiling (docker-compose.standalone.yml). Default 2g.
97# INCIDENT 2026-08-27: the container ran unbounded and leaked, taking a
98# shared host to 96% full via five 10-13 GB core dumps. Raise on a
99# dedicated box; do not remove — unbounded means one tenant can take the
100# machine. Core dumps are disabled alongside it (ulimits.core=0), which is
101# what stops a crash becoming a disk-space incident for everyone else.
102GLUECRON_MEM_LIMIT=2g
96103# ── Model provider ──────────────────────────────────────────────────────
97104# The platform does not need AI to run: git, CI and merges never call a
98105# model. These exist so the AI features are not tied to one vendor.
Modifieddocker-compose.standalone.yml+26−0View fileUnifiedSplit
@@ -82,6 +82,32 @@ services:
8282 # pgdata dir or a disk hiccup in a service nothing reads could stop the
8383 # whole platform from starting after a redeploy. Removed 2026-08-19.
8484 restart: unless-stopped
85 # ── Resource bounds ────────────────────────────────────────────────
86 # INCIDENT 2026-08-27. This container ran with HostConfig.Memory=0 — no
87 # limit at all — and leaked steadily (a slow climb over hours, i.e.
88 # retained objects rather than a spike). On a shared box it took the
89 # host to 96% full: five bun core dumps of 10.7-13.3 GB each, written
90 # 25-27 Aug, on a disk five other platforms depend on. The autohealer
91 # beside it restarted the container each time, which is precisely why
92 # the leak stayed invisible for three days: an autohealer plus no limit
93 # turns a visible crash into a silent cycle.
94 #
95 # Two settings, and the SECOND is the one that turned a leak into an
96 # outage. A memory cap makes a runaway a bounded restart of one
97 # container instead of a competition with every tenant on the box. But
98 # it was the unlimited core-dump size that converted each restart into
99 # 13 GB on disk — without it a crash is a restart; with it, a crash is
100 # a disk-space incident for everyone else on the machine.
101 #
102 # GLUECRON_MEM_LIMIT is overridable because the right ceiling depends on
103 # the host: 2g suits a small shared box, and a dedicated one can afford
104 # more. Set it in .env rather than editing this file.
105 mem_limit: ${GLUECRON_MEM_LIMIT:-2g}
106 memswap_limit: ${GLUECRON_MEM_LIMIT:-2g}
107 ulimits:
108 # No core dumps. A bun core here is 10-13 GB and has never once been
109 # the thing that diagnosed a problem — the logs were.
110 core: 0
85111 # Give the app time to drain in-flight requests on SIGTERM
86112 # (src/lib/lifecycle.ts, SHUTDOWN_DRAIN_MS default 5s) before docker
87113 # kills it. Must exceed the drain window.
Modifiedscripts/ci-test-excludes.txt+30−2View fileUnifiedSplit
@@ -37,9 +37,37 @@ src/__tests__/admin.test.ts
3737src/__tests__/claude-config.test.ts
3838src/__tests__/claude-web-session.test.ts
3939src/__tests__/close-keywords-apply.test.ts
40src/__tests__/json-in-script.test.ts
4140src/__tests__/mcp-write.test.ts
4241src/__tests__/self-host.test.ts
4342src/__tests__/suppression-quality.test.ts
4443src/lib/selfcheck/docs-rule.test.ts
45src/lib/selfcheck/raw-sql-rule.test.ts
44#
45# RE-INCLUDED 2026-08-27: src/lib/selfcheck/raw-sql-rule.test.ts
46#
47# It was excluded as "env-sensitive". It was not. It was RED, for one real
48# finding: truth-ledger.ts's defaultDbExec calls sql.raw(). Adjudicated as
49# safe — every caller passes a compile-time literal from that file's claim
50# table — and suppressed inline with that reason.
51#
52# The cost of leaving it out was not one skipped test. This rule is what
53# detects raw SQL splicing anywhere in the tree, so excluding it to keep the
54# build green disabled the protection for all 395 files in order to avoid
55# adjudicating one line. An exclusion that silences a scanner is not the same
56# as an exclusion that skips a test.
57#
58# RE-INCLUDED 2026-08-27: src/__tests__/json-in-script.test.ts
59#
60# Also not env-sensitive. Also simply RED — one site, live-feed.tsx:30,
61# interpolating JSON.stringify into an inline <script>. Not exploitable
62# there (the value is a hardcoded constant), but FIXED rather than
63# suppressed: swapping in jsonForScript is one word and keeps the rule at
64# full strength, where a suppression would have carved a permanent hole in
65# an XSS guard to save the same keystroke.
66#
67# Same cost as the raw-SQL exclusion: this rule is what stops any inline
68# script in the tree from embedding a value that can close its own <script>
69# tag. debt-map.tsx serialises repository FILE PATHS, and git permits nearly
70# any byte in a path — so the reachable version of this bug is a file
71# committed under a hostile name giving script execution to anyone who opens
72# that repo's debt map. Excluding the rule left that undefended.
73
Modifiedsrc/__tests__/gate-security-scan-availability.test.ts+30−2View fileUnifiedSplit
@@ -61,12 +61,21 @@ afterEach(() => {
6161});
6262
6363describe("runSecretAndSecurityScan — security scan availability vs verdict", () => {
64 it("is skipped (not blocking) when the AI provider errors — was previously silently 'clean'", async () => {
64 it("does not block on an AI outage alone — benign diff still passes", async () => {
65 // The fixture is deliberately harmless. It used to be "+ eval(userInput)",
66 // which passed only because an AI outage meant NOTHING inspected the diff.
67 // Static rules now run regardless, so that fixture is a real critical
68 // finding and passing it would be the bug — see the test below.
6569 mockFetch(() => new Response("rate limited", { status: 429 }));
6670 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
6771 scanSecrets: false,
6872 scanSecurity: true,
69 diffText: "diff --git a/x.ts b/x.ts\n+ eval(userInput)",
73 diffText: [
74 "--- a/x.ts",
75 "+++ b/x.ts",
76 "@@ -1,1 +1,2 @@",
77 "+const total = items.length;",
78 ].join(String.fromCharCode(10)),
7079 });
7180 expect(result.securityResult.skipped).toBe(true);
7281 expect(result.securityResult.passed).toBe(true);
@@ -74,6 +83,25 @@ describe("runSecretAndSecurityScan — security scan availability vs verdict", (
7483 expect(result.securityResult.details).toContain("unavailable");
7584 });
7685
86 it("STILL CATCHES a dangerous diff while the AI provider is down", async () => {
87 // The property this exists for: security must not be AI-dependent. A
88 // billing failure took the platform's model offline for every customer at
89 // once, and before this it took security scanning offline with it.
90 mockFetch(() => new Response("rate limited", { status: 429 }));
91 const result = await runSecretAndSecurityScan("o", "r", "refs/heads/main", "a".repeat(40), {
92 scanSecrets: false,
93 scanSecurity: true,
94 diffText: [
95 "--- a/x.ts",
96 "+++ b/x.ts",
97 "@@ -1,1 +1,2 @@",
98 "+ eval(userInput);",
99 ].join(String.fromCharCode(10)),
100 });
101 expect(result.securityResult.passed).toBe(false);
102 expect(result.securityResult.details).toContain("Static rules ran");
103 });
104
77105 it("is skipped (not blocking) when the request throws — network error / timeout", async () => {
78106 mockFetch(() => {
79107 throw new Error("ETIMEDOUT");
Addedsrc/__tests__/static-security-scan.test.ts+111−0View fileUnifiedSplit
@@ -0,0 +1,111 @@
1/**
2 * The security gate must produce findings without a model.
3 *
4 * The "Security scan" gate was AI-only. When the platform's Anthropic balance
5 * ran out, every push reported "AI security scan unavailable — scan skipped",
6 * so a BILLING condition silently became a security-coverage condition — for
7 * every customer at once, since they all draw on the platform's key.
8 *
9 * The Secret scan beside it never stopped: 15 regexes, no credit. These tests
10 * pin the same property for the security rules — a floor that holds whether or
11 * not a model is reachable.
12 */
13
14import { describe, it, expect } from "bun:test";
15import {
16 addedLinesFromDiff,
17 staticSecurityScan,
18 STATIC_SECURITY_RULES,
19} from "../lib/security-scan";
20
21const diff = (body: string, file = "src/app.ts") =>
22 `--- a/${file}\n+++ b/${file}\n@@ -1,3 +1,4 @@\n${body}\n`;
23
24describe("addedLinesFromDiff", () => {
25 it("reads added lines with their file and line number", () => {
26 const out = addedLinesFromDiff(
27 "--- a/src/x.ts\n+++ b/src/x.ts\n@@ -10,2 +10,3 @@\n ctx\n+const a = 1;\n"
28 );
29 expect(out).toEqual([{ file: "src/x.ts", line: 11, text: "const a = 1;" }]);
30 });
31
32 it("ignores removed lines", () => {
33 // Flagging a pattern someone is DELETING is how a scanner teaches people
34 // to ignore it.
35 const out = addedLinesFromDiff(
36 "--- a/src/x.ts\n+++ b/src/x.ts\n@@ -1,2 +1,1 @@\n-eval(userInput);\n"
37 );
38 expect(out).toEqual([]);
39 });
40});
41
42describe("staticSecurityScan", () => {
43 it("finds SQL built by interpolation", () => {
44 const f = staticSecurityScan(diff("+ const r = await db.execute(`SELECT * FROM t WHERE id = ${id}`);"));
45 expect(f.map((x) => x.type)).toContain("sql-injection");
46 expect(f[0].severity).toBe("critical");
47 expect(f[0].line).toBe(1);
48 });
49
50 it("finds shell commands built by interpolation", () => {
51 const f = staticSecurityScan(diff("+ execSync(`git checkout ${branch}`);"));
52 expect(f.map((x) => x.type)).toContain("command-injection");
53 });
54
55 it("finds dynamic code execution", () => {
56 expect(staticSecurityScan(diff("+ eval(payload);")).map((x) => x.type)).toContain("code-execution");
57 expect(staticSecurityScan(diff("+ const fn = new Function(src);")).map((x) => x.type)).toContain("code-execution");
58 });
59
60 it("finds disabled TLS verification", () => {
61 const f = staticSecurityScan(diff("+ const agent = new https.Agent({ rejectUnauthorized: false });"));
62 expect(f.map((x) => x.type)).toContain("tls-disabled");
63 });
64
65 it("finds broken hashes", () => {
66 expect(staticSecurityScan(diff('+ createHash("md5").update(pw);')).map((x) => x.type)).toContain("weak-crypto");
67 });
68
69 it("finds non-timing-safe secret comparison", () => {
70 const f = staticSecurityScan(diff("+ if (token === presented) return true;"));
71 expect(f.map((x) => x.type)).toContain("timing-unsafe-compare");
72 });
73
74 it("stays quiet on ordinary code", () => {
75 // A scanner that cries wolf gets muted, and a muted scanner is exactly the
76 // failure this exists to prevent.
77 const f = staticSecurityScan(
78 diff("+ const total = items.reduce((a, b) => a + b.n, 0);\n+ return c.json({ total });")
79 );
80 expect(f).toEqual([]);
81 });
82
83 it("skips paths the secret scanner also skips", () => {
84 const f = staticSecurityScan(diff("+ eval(x);", "node_modules/pkg/index.js"));
85 expect(f).toEqual([]);
86 });
87
88 it("returns nothing for an empty diff rather than throwing", () => {
89 expect(staticSecurityScan("")).toEqual([]);
90 });
91
92 it("reports a file and line a reviewer can navigate to", () => {
93 const f = staticSecurityScan(
94 "--- a/src/db.ts\n+++ b/src/db.ts\n@@ -40,1 +40,2 @@\n keep\n+ db.execute(`DELETE FROM t WHERE id = ${id}`);\n"
95 );
96 expect(f).toHaveLength(1);
97 expect(f[0].file).toBe("src/db.ts");
98 expect(f[0].line).toBe(41);
99 expect(f[0].suggestion).toBeTruthy();
100 });
101});
102
103describe("STATIC_SECURITY_RULES", () => {
104 it("every rule carries a description and a suggestion", () => {
105 for (const r of STATIC_SECURITY_RULES) {
106 expect(r.description.length).toBeGreaterThan(10);
107 expect(r.suggestion.length).toBeGreaterThan(10);
108 expect(["critical", "high", "medium", "low"]).toContain(r.severity);
109 }
110 });
111});
Modifiedsrc/lib/gate.ts+50−3View fileUnifiedSplit
@@ -25,12 +25,18 @@ import {
2525 workflows,
2626} from "../db/schema";
2727import { getOrCreateSettings } from "./repo-bootstrap";
28import { scanForSecrets, aiSecurityScanSafe } from "./security-scan";
28import {
29 scanForSecrets,
30 aiSecurityScanSafe,
31 staticSecurityScan,
32 STATIC_SECURITY_RULES,
33} from "./security-scan";
2934import { isAiAvailable } from "./ai-client";
3035import type { SecretFinding, SecurityFinding } from "./security-scan";
3136import { repairSecrets, repairSecurityIssues } from "./auto-repair";
3237import { readFile } from "fs/promises";
3338import { join } from "path";
39import { humanizeAiError } from "./ai-client";
3440
3541export interface GateCheckResult {
3642 name: string;
@@ -363,7 +369,29 @@ export async function runSecretAndSecurityScan(
363369 opts.scanSecurity && opts.diffText
364370 ? await aiSecurityScanSafe(`${owner}/${repo}`, opts.diffText)
365371 : { findings: [], skipped: true as const };
366 const securityIssues = aiOutcome.findings;
372 // Static rules run whether or not a model is reachable. The AI layer finds
373 // classes of bug a pattern cannot, but it must not be the ONLY thing looking
374 // at a diff: when the platform's Anthropic balance ran out, the security gate
375 // stopped producing security findings for every customer at once — a billing
376 // condition silently became a security-coverage condition. This is the floor
377 // that keeps that from happening again, and it costs nothing per customer.
378 const staticIssues =
379 opts.scanSecurity && opts.diffText
380 ? staticSecurityScan(opts.diffText)
381 : [];
382
383 // Merge, preferring the AI finding when both flag the same file+line+type,
384 // so a reachable model still upgrades the explanation rather than doubling
385 // the report.
386 const seenStatic = new Set(
387 aiOutcome.findings.map((f) => `${f.file}:${f.line ?? ""}:${f.type}`)
388 );
389 const securityIssues = [
390 ...aiOutcome.findings,
391 ...staticIssues.filter(
392 (f) => !seenStatic.has(`${f.file}:${f.line ?? ""}:${f.type}`)
393 ),
394 ];
367395
368396 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
369397 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
@@ -375,12 +403,31 @@ export async function runSecretAndSecurityScan(
375403 // security issues found", since that string previously also covered a
376404 // provider outage indistinguishably).
377405 const notConfigured = !opts.scanSecurity || !opts.diffText;
406 // "Skipped" now means only that the AI layer did not run. Static rules did,
407 // so the gate has a verdict either way and must not report "nothing looked".
408 const staticNote = `Static rules ran (${STATIC_SECURITY_RULES.length} checks, no AI required)`;
378409 const securityDetails = notConfigured
379410 ? !opts.scanSecurity
380411 ? "Disabled in settings"
381412 : "Skipped — no changes to scan"
382413 : aiOutcome.skipped
383 ? `AI security scan unavailable — scan skipped, not blocking: ${aiOutcome.error ?? "unknown error"}`
414 ? // humanizeAiError, not the raw string. This line rendered the provider's
415 // JSON envelope verbatim on /security — twice per run, in Scanner status
416 // and again in Recent runs:
417 // 400 {"type":"error","error":{"type":"invalid_request_error",
418 // "message":"Your credit balance is too low..."},"request_id":"req_011..."}
419 // That hands a viewer another company's error shape and an internal
420 // request id, for a condition the reader can do nothing with. The
421 // humanizer already had a branch for this exact case ("credit balance is
422 // too low") and simply was not called here — it says what happened, that
423 // nothing was lost, and what to do next.
424 `${staticNote} — ${
425 staticIssues.length === 0
426 ? "no static findings"
427 : `${staticIssues.length} finding${staticIssues.length === 1 ? "" : "s"}`
428 }. AI review unavailable: ${humanizeAiError(
429 aiOutcome.error ?? "unknown error"
430 )}`
384431 : securityIssues.length === 0
385432 ? "No security issues found"
386433 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`;
Modifiedsrc/lib/security-scan.ts+148−0View fileUnifiedSplit
@@ -295,3 +295,151 @@ export async function runSecurityScan(
295295
296296 return { secrets, securityIssues, summary, passed };
297297}
298
299// ---------------------------------------------------------------------------
300// Static security scan — no model, no credit, no network.
301// ---------------------------------------------------------------------------
302//
303// WHY THIS EXISTS. The "Security scan" gate was AI-only. When the platform's
304// Anthropic balance ran out, every push reported:
305//
306// AI security scan unavailable — scan skipped, not blocking: 400 {...}
307//
308// i.e. the security gate stopped producing security findings because of a
309// BILLING condition. The owner's objection was exactly right: a security scan
310// should not be AI-dependent. The Secret scan sitting beside it proves the
311// point — 15 regexes, no credit, and it kept working throughout.
312//
313// This does not attempt to replace semantic review. An LLM finds classes of
314// bug a pattern cannot. What it does is guarantee a FLOOR: the high-signal,
315// mechanically-detectable issues are caught on every push whether or not a
316// model is reachable, so "we could not pay the AI bill" never again means
317// "nothing looked at this diff".
318//
319// Scans the DIFF rather than a working tree, because user repos are bare —
320// there is nothing to check out at gate time. Only added lines are considered:
321// flagging a pattern someone is DELETING is how a scanner teaches people to
322// ignore it.
323
324interface StaticRule {
325 type: string;
326 severity: SecurityFinding["severity"];
327 pattern: RegExp;
328 description: string;
329 suggestion: string;
330}
331
332/**
333 * High-signal only. Every rule here should be one a reviewer would stop a PR
334 * for; a scanner that cries wolf gets muted, and a muted scanner is the thing
335 * this whole exercise is trying to stop existing.
336 */
337export const STATIC_SECURITY_RULES: readonly StaticRule[] = [
338 {
339 type: "sql-injection",
340 severity: "critical",
341 pattern: /\b(?:sql|query|execute|db\.execute)\s*\(\s*`[^`]*\$\{/,
342 description: "SQL built with template interpolation — a caller-controlled value reaches the query text.",
343 suggestion: "Use parameterised queries (drizzle's sql`` tagged template binds values; sql.raw does not).",
344 },
345 {
346 type: "command-injection",
347 severity: "critical",
348 pattern: /\b(?:exec|execSync|spawnSync)\s*\(\s*`[^`]*\$\{/,
349 description: "Shell command built with template interpolation.",
350 suggestion: "Pass an argv array to spawn() so the shell never parses the value.",
351 },
352 {
353 type: "code-execution",
354 severity: "critical",
355 pattern: /\b(?:eval\s*\(|new\s+Function\s*\()/,
356 description: "Dynamic code execution.",
357 suggestion: "Replace with an explicit dispatch table; eval on any request-derived value is remote code execution.",
358 },
359 {
360 type: "xss-inline-script",
361 severity: "high",
362 pattern: /dangerouslySetInnerHTML|__html\s*:/,
363 description: "Raw HTML injection into the DOM.",
364 suggestion: "Escape the value, or use jsonForScript() for data embedded in an inline <script>.",
365 },
366 {
367 type: "xss-json-in-script",
368 severity: "high",
369 pattern: /<script[^>]*>[\s\S]{0,200}?JSON\.stringify/,
370 description: "JSON.stringify embedded in an inline script — it does not escape '<', so a value containing a closing script tag ends the element early.",
371 suggestion: "Use jsonForScript() from lib/json-for-script.",
372 },
373 {
374 type: "weak-crypto",
375 severity: "high",
376 pattern: /createHash\s*\(\s*['"`](?:md5|sha1)['"`]/i,
377 description: "Broken hash function used.",
378 suggestion: "Use sha256 or better. For passwords use a KDF (argon2/bcrypt), not a hash.",
379 },
380 {
381 type: "tls-disabled",
382 severity: "critical",
383 pattern: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"`]?0/,
384 description: "TLS certificate verification disabled — the connection is open to interception.",
385 suggestion: "Trust the correct CA instead of disabling verification.",
386 },
387 {
388 type: "timing-unsafe-compare",
389 severity: "medium",
390 pattern: /\b(?:token|secret|signature|hmac|password|apiKey)\w*\s*===\s*/i,
391 description: "Secret compared with === — leaks length and prefix through timing.",
392 suggestion: "Use crypto.timingSafeEqual on equal-length buffers.",
393 },
394];
395
396/** Parse a unified diff into added lines, keeping file and line context. */
397export function addedLinesFromDiff(
398 diffText: string
399): Array<{ file: string; line: number; text: string }> {
400 const out: Array<{ file: string; line: number; text: string }> = [];
401 let file = "unknown";
402 let lineNo = 0;
403 for (const raw of diffText.split("\n")) {
404 if (raw.startsWith("+++ ")) {
405 const p = raw.slice(4).trim();
406 file = p.startsWith("b/") ? p.slice(2) : p;
407 continue;
408 }
409 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw);
410 if (hunk) {
411 lineNo = Number(hunk[1]);
412 continue;
413 }
414 if (raw.startsWith("+")) {
415 out.push({ file, line: lineNo, text: raw.slice(1) });
416 lineNo++;
417 } else if (!raw.startsWith("-") && !raw.startsWith("\\")) {
418 lineNo++;
419 }
420 }
421 return out;
422}
423
424/**
425 * Run the static rules over a unified diff. Pure, synchronous, no I/O.
426 */
427export function staticSecurityScan(diffText: string): SecurityFinding[] {
428 if (!diffText) return [];
429 const findings: SecurityFinding[] = [];
430 for (const { file, line, text } of addedLinesFromDiff(diffText)) {
431 if (shouldSkipPath(file)) continue;
432 for (const rule of STATIC_SECURITY_RULES) {
433 if (!rule.pattern.test(text)) continue;
434 findings.push({
435 type: rule.type,
436 file,
437 line,
438 description: rule.description,
439 severity: rule.severity,
440 suggestion: rule.suggestion,
441 });
442 }
443 }
444 return findings;
445}
Modifiedsrc/lib/truth-ledger.ts+13−0View fileUnifiedSplit
@@ -527,6 +527,19 @@ export interface EvaluateDeps {
527527async function defaultDbExec(
528528 rawSql: string
529529): Promise<Array<Record<string, unknown>>> {
530 // Every caller passes a compile-time string literal from the claim table in
531 // this file (`v.sql`, e.g. "SELECT count(*)::int AS n FROM repositories
532 // WHERE pushed_at > now() - interval '7 days'"). No value reaching here is
533 // caller- or request-derived, so there is nothing to interpolate and nothing
534 // to parameterise.
535 //
536 // Adjudicated 2026-08-27. This single false positive was why the whole
537 // raw-sql-interpolation rule sat excluded from CI — meaning a REAL splice
538 // anywhere else in the tree would not have been caught. Suppressing the
539 // known-safe site is what puts the rule back on duty over the other 394
540 // files. If a `sql:` field ever stops being a literal, delete the marker
541 // rather than widening it.
542 // selfcheck-ignore raw-sql-interpolation — all callers pass literals from this file's claim table
530543 const rows = await db.execute(sql.raw(rawSql));
531544 return Array.isArray(rows)
532545 ? (rows as Array<Record<string, unknown>>)
Modifiedsrc/routes/dashboard.tsx+61−44View fileUnifiedSplit
@@ -827,7 +827,25 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
827827 </div>
828828
829829 {/* ─── Repo Grid ─── */}
830 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
830 {/* 45 repos in a three-across grid is fifteen rows of scrolling to reach
831 one you use daily. Favourites float to the top; this makes the rest
832 reachable by name without a round trip to the server. */}
833 <div style="display:flex;align-items:baseline;gap:12px;margin-bottom:16px;flex-wrap:wrap">
834 <h2 style="font-size: 18px; margin: 0">Your Repositories</h2>
835 {repos.length > 8 && (
836 <>
837 <input
838 id="repo-filter"
839 type="search"
840 placeholder="Filter repositories…"
841 aria-label="Filter your repositories by name"
842 autocomplete="off"
843 style="flex:1;min-width:180px;max-width:280px;padding:5px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-subtle);color:inherit"
844 />
845 <span id="repo-filter-count" style="font-size:12px;color:var(--text-muted)" />
846 </>
847 )}
848 </div>
831849 {repos.length === 0 ? (
832850 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
833851 <div style="text-align:center;margin-bottom:20px">
@@ -878,16 +896,18 @@ git push -u gluecron main</code></pre>
878896 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
879897 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
880898 <div
881 class="card"
899 class="card js-repo-card"
900 data-repo={`${repo.name} ${repo.description ?? ""}`.toLowerCase()}
882901 style={`padding: 0; overflow: hidden${
883902 starredRepoIds.has(repo.id)
884903 ? "; border-color: var(--accent)"
885904 : ""
886905 }`}
887906 >
888 {/* Health bar at top */}
907 {/* A hairline, not a banner. The grade chip carries the signal;
908 45 saturated full-width bars carried only noise. */}
889909 <div
890 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
910 style={`height: 2px; background: ${gradeColor(healthGrade)}; opacity: 0.45; width: ${healthScore}%`}
891911 />
892912 <div style="padding: var(--space-4)">
893913 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
@@ -930,21 +950,16 @@ git push -u gluecron main</code></pre>
930950 </p>
931951 )}
932952 </div>
933 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
934 <div
935 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
936 >
937 {healthGrade}
938 </div>
939 <div style="font-size: 10px; color: var(--text-muted)">
940 {healthScore}/100
941 </div>
953 <div
954 title={`Health ${healthScore}/100 (grade ${healthGrade})`}
955 style={`flex-shrink: 0; margin-left: 10px; font-size: 11px; font-weight: 600; padding: 2px 7px; border-radius: 3px; white-space: nowrap; color: ${gradeColor(healthGrade)}; border: 1px solid ${gradeColor(healthGrade)}33; background: ${gradeColor(healthGrade)}14`}
956 >
957 {healthGrade} · {healthScore}
942958 </div>
943959 </div>
944960
945961 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
946962 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
947 <span>{"\u2606"} {repo.starCount}</span>
948963 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
949964 </div>
950965
@@ -961,42 +976,44 @@ git push -u gluecron main</code></pre>
961976 </div>
962977 )}
963978
964 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
965 <a
966 href={`/${user.username}/${repo.name}/health`}
967 class="btn btn-sm"
968 style="font-size: 11px; padding: 2px 8px"
969 >
970 Health
971 </a>
972 <a
973 href={`/${user.username}/${repo.name}/dependencies`}
974 class="btn btn-sm"
975 style="font-size: 11px; padding: 2px 8px"
976 >
977 Deps
978 </a>
979 <a
980 href={`/${user.username}/${repo.name}/coupling`}
981 class="btn btn-sm"
982 style="font-size: 11px; padding: 2px 8px"
983 >
984 Insights
985 </a>
986 <a
987 href={`/${user.username}/${repo.name}/settings`}
988 class="btn btn-sm"
989 style="font-size: 11px; padding: 2px 8px"
990 >
991 Settings
992 </a>
993 </div>
979
994980 </div>
995981 </div>
996982 ))}
997983 </div>
998984 )}
999985
986 {repos.length > 8 && (
987 <script
988 dangerouslySetInnerHTML={{
989 __html: `
990(function(){
991 var box = document.getElementById('repo-filter');
992 var out = document.getElementById('repo-filter-count');
993 if (!box) return;
994 var cards = Array.prototype.slice.call(document.querySelectorAll('.js-repo-card'));
995 function apply(){
996 var q = box.value.trim().toLowerCase();
997 var shown = 0;
998 for (var i = 0; i < cards.length; i++) {
999 var hit = !q || (cards[i].getAttribute('data-repo') || '').indexOf(q) !== -1;
1000 cards[i].style.display = hit ? '' : 'none';
1001 if (hit) shown++;
1002 }
1003 if (out) out.textContent = q ? (shown + ' of ' + cards.length) : '';
1004 }
1005 box.addEventListener('input', apply);
1006 // "/" focuses global search, so Escape clearing the field is the only
1007 // shortcut worth binding here.
1008 box.addEventListener('keydown', function(e){
1009 if (e.key === 'Escape') { box.value = ''; apply(); }
1010 });
1011})();
1012`,
1013 }}
1014 />
1015 )}
1016
10001017 {/* ─── Activity Feed ─── */}
10011018 {recentActivity.length > 0 && (
10021019
Modifiedsrc/routes/insights.tsx+54−50View fileUnifiedSplit
@@ -48,6 +48,36 @@ insights.use("*", softAuth);
4848const styles = `
4949 .insights-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
5050
51 /* Compact page head.
52 The three heroes this replaces each stacked an eyebrow ("Insights ·
53 owner/repo"), a gradient display title, and a sentence of marketing
54 ("the kind of intelligence GitHub doesn't ship") on top of a page you
55 already navigated to deliberately. Between the repo breadcrumb, the
56 active tab and the eyebrow, the page stated where you were three times
57 and pushed the first real content to roughly half the viewport.
58 A working surface should open with its content. */
59 .insights-pagehead {
60 margin-bottom: var(--space-5);
61 }
62 .insights-pagehead-title {
63 font-size: 20px;
64 font-weight: 650;
65 letter-spacing: -0.01em;
66 margin: 0 0 4px;
67 color: var(--text-strong);
68 }
69 .insights-pagehead-title--mono {
70 font-family: var(--font-mono, ui-monospace, monospace);
71 font-size: 16px;
72 word-break: break-all;
73 }
74 .insights-pagehead-sub {
75 margin: 0;
76 font-size: 13px;
77 line-height: 1.5;
78 color: var(--text-muted);
79 max-width: 68ch;
80 }
5181 .insights-hero {
5282 position: relative;
5383 margin-bottom: var(--space-5);
@@ -441,24 +471,17 @@ insights.get("/:owner/:repo/timeline/:ref{.+$}", async (c) => {
441471 <RepoNav owner={owner} repo={repo} active="code" />
442472
443473 <div class="insights-wrap">
444 <section class="insights-hero">
445 <div class="insights-hero-orb" aria-hidden="true" />
446 <div class="insights-hero-inner">
447 <div class="insights-eyebrow">
448 <span class="insights-eyebrow-dot" aria-hidden="true" />
449 Time travel · {owner}/{repo}
450 </div>
451 <h2 class="insights-title">
452 <span class="insights-title-grad">{filePath}</span>
453 </h2>
454 <p class="insights-sub">
455 {timeline.totalRevisions} revision
456 {timeline.totalRevisions !== 1 ? "s" : ""} · First seen{" "}
457 {new Date(timeline.firstSeen.date).toLocaleDateString()} by{" "}
458 {timeline.firstSeen.author}
459 </p>
460 </div>
461 </section>
474 <div class="insights-pagehead">
475 <h2 class="insights-pagehead-title insights-pagehead-title--mono">
476 {filePath}
477 </h2>
478 <p class="insights-pagehead-sub">
479 {timeline.totalRevisions} revision
480 {timeline.totalRevisions !== 1 ? "s" : ""} · First seen{" "}
481 {new Date(timeline.firstSeen.date).toLocaleDateString()} by{" "}
482 {timeline.firstSeen.author}
483 </p>
484 </div>
462485
463486 <ul class="insights-timeline">
464487 {timeline.revisions.map((rev) => (
@@ -636,22 +659,12 @@ insights.get("/:owner/:repo/coupling", async (c) => {
636659 <RepoNav owner={owner} repo={repo} active="insights" />
637660
638661 <div class="insights-wrap">
639 <section class="insights-hero">
640 <div class="insights-hero-orb" aria-hidden="true" />
641 <div class="insights-hero-inner">
642 <div class="insights-eyebrow">
643 <span class="insights-eyebrow-dot" aria-hidden="true" />
644 Insights · {owner}/{repo}
645 </div>
646 <h2 class="insights-title">
647 <span class="insights-title-grad">Code intelligence.</span>
648 </h2>
649 <p class="insights-sub">
650 File coupling, milestone history, and contributor signals — the
651 kind of intelligence GitHub doesn't ship.
652 </p>
653 </div>
654 </section>
662 <div class="insights-pagehead">
663 <h2 class="insights-pagehead-title">Code intelligence</h2>
664 <p class="insights-pagehead-sub">
665 File coupling, milestone history and contributor signals.
666 </p>
667 </div>
655668
656669 <div class="insights-stats">
657670 <div class="insights-stat">
@@ -794,22 +807,13 @@ insights.get("/:owner/:repo/dependencies", async (c) => {
794807 <RepoNav owner={owner} repo={repo} active="code" />
795808
796809 <div class="insights-wrap">
797 <section class="insights-hero">
798 <div class="insights-hero-orb" aria-hidden="true" />
799 <div class="insights-hero-inner">
800 <div class="insights-eyebrow">
801 <span class="insights-eyebrow-dot" aria-hidden="true" />
802 Dependency intelligence · {owner}/{repo}
803 </div>
804 <h2 class="insights-title">
805 <span class="insights-title-grad">What you depend on.</span>
806 </h2>
807 <p class="insights-sub">
808 Static import graph across the repo — every package, how it's
809 used, and which ones are dead weight.
810 </p>
811 </div>
812 </section>
810 <div class="insights-pagehead">
811 <h2 class="insights-pagehead-title">What you depend on</h2>
812 <p class="insights-pagehead-sub">
813 Static import graph — every package, how it is used, and which are
814 dead weight.
815 </p>
816 </div>
813817
814818 <div class="insights-stats">
815819 <div class="insights-stat">
Modifiedsrc/routes/pulls.tsx+1−2View fileUnifiedSplit
@@ -3099,8 +3099,7 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
30993099 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
31003100
31013101 <PageHeader
3102 eyebrow="Pull requests"
3103 title="Review, merge with AI."
3102 title="Pull requests"
31043103 lede={
31053104 openCount === 0 && allCount === 0
31063105 ? "No pull requests yet. Open the first one to start collaborating — AI review runs automatically on every PR."
Modifiedsrc/routes/workflows.tsx+24−18View fileUnifiedSplit
@@ -157,6 +157,24 @@ const wfStyles = `
157157 }
158158 .wf-head-inner { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
159159 .wf-head-text { flex: 1; min-width: 240px; max-width: 720px; }
160 /* Compact page head — see the note in insights.tsx. The breadcrumb and the
161 active "Actions" tab already name this page; a third nameplate at display
162 scale only pushed the run list down. */
163 .wf-pagehead { margin-bottom: var(--space-5); }
164 .wf-pagehead-title {
165 font-size: 20px;
166 font-weight: 650;
167 letter-spacing: -0.01em;
168 margin: 0 0 4px;
169 color: var(--text-strong);
170 }
171 .wf-pagehead-sub {
172 margin: 0;
173 font-size: 13px;
174 line-height: 1.5;
175 color: var(--text-muted);
176 max-width: 68ch;
177 }
160178 .wf-eyebrow {
161179 display: inline-flex;
162180 align-items: center;
@@ -678,24 +696,12 @@ actions.get("/:owner/:repo/actions", async (c) => {
678696 <RepoNav owner={owner} repo={repo} active="actions" />
679697
680698 <div class="wf-wrap">
681 <section class="wf-head">
682 <div class="wf-head-orb" aria-hidden="true" />
683 <div class="wf-head-inner">
684 <div class="wf-head-text">
685 <div class="wf-eyebrow">
686 <span class="wf-eyebrow-dot" aria-hidden="true" />
687 Continuous integration · {owner}/{repo}
688 </div>
689 <h2 class="wf-title">
690 <span class="wf-title-grad">Workflows.</span>
691 </h2>
692 <p class="wf-sub">
693 YAML pipelines that run on push, on a schedule, or on demand —
694 with live logs and one-click cancel.
695 </p>
696 </div>
697 </div>
698 </section>
699 <div class="wf-pagehead">
700 <h2 class="wf-pagehead-title">Workflows</h2>
701 <p class="wf-pagehead-sub">
702 YAML pipelines that run on push, on a schedule, or on demand.
703 </p>
704 </div>
699705
700706 <div class="wf-grid">
701707 <aside>
Modifiedsrc/views/components.tsx+12−5View fileUnifiedSplit
@@ -1463,14 +1463,20 @@ export const Button: FC<{
14631463 */
14641464export const sharedComponentStyles = `
14651465 /* ---- PageHeader ------------------------------------------------ */
1466 /* Proportioned for a working surface, not a landing page.
1467 On a repo sub-page this header sits BELOW a breadcrumb and an active
1468 nav tab that both already name the place, so a display-size title with
1469 space-8 below it pushed the first real content to roughly half the
1470 viewport on a 1080px screen. Trimmed, not removed: the page still needs
1471 a name, it just does not need to announce itself three times. */
14661472 .gx-pagehead {
14671473 display: flex;
14681474 align-items: flex-end;
14691475 justify-content: space-between;
1470 gap: var(--space-6);
1476 gap: var(--space-5);
14711477 flex-wrap: wrap;
1472 margin-bottom: var(--space-8);
1473 padding-bottom: var(--space-5);
1478 margin-bottom: var(--space-5);
1479 padding-bottom: var(--space-4);
14741480 border-bottom: 1px solid var(--border);
14751481 }
14761482 .gx-pagehead__lead { min-width: 0; }
@@ -1495,14 +1501,15 @@ export const sharedComponentStyles = `
14951501 }
14961502 .gx-pagehead__title {
14971503 font-family: var(--font-display);
1498 font-size: clamp(var(--t-xl), 4vw, var(--t-2xl));
1504 /* was clamp(--t-xl, 4vw, --t-2xl) — hero scale on a tracker page */
1505 font-size: clamp(var(--t-lg), 2.4vw, var(--t-xl));
14991506 line-height: var(--leading-tight);
15001507 letter-spacing: -0.028em;
15011508 color: var(--text-strong);
15021509 margin: 0;
15031510 }
15041511 .gx-pagehead__lede {
1505 margin-top: var(--space-3);
1512 margin-top: var(--space-2);
15061513 max-width: 60ch;
15071514 font-size: var(--t-md);
15081515 line-height: var(--leading-relaxed);
Modifiedsrc/views/layout.tsx+11−2View fileUnifiedSplit
@@ -2327,7 +2327,14 @@ ${designTokensCss}
23272327 border-bottom: 1px solid var(--border);
23282328 margin-bottom: 28px;
23292329 overflow-x: auto;
2330 scrollbar-width: thin;
2330 /* scrollbar-width:thin still PAINTS a bar, and overflow-x:auto forces
2331 overflow-y to auto (see the note below), so the 1px that .repo-nav a
2332 pushes past the box with margin-bottom:-1px was enough to render a tiny
2333 VERTICAL scrollbar — arrows and all — at the right-hand end of a
2334 HORIZONTAL tab row, on every repo page. Setting none stops it being
2335 painted without changing a single box: the row still scrolls on a
2336 phone, which is the only reason overflow-x is here. */
2337 scrollbar-width: none;
23312338 }
23322339 /* overflow-x:auto exists so the tab row scrolls on phones — but per the
23332340 CSS spec, overflow-x:auto forces overflow-y to auto as well, so this
@@ -2343,7 +2350,9 @@ ${designTokensCss}
23432350 .repo-nav.repo-nav--menu-open {
23442351 overflow: visible;
23452352 }
2346 .repo-nav::-webkit-scrollbar { height: 0; }
2353 /* height:0 hides the horizontal bar; the vertical one needs width:0.
2354 Zeroing only height is why the vertical bar survived. */
2355 .repo-nav::-webkit-scrollbar { height: 0; width: 0; }
23472356 .repo-nav a {
23482357 position: relative;
23492358 padding: 11px 14px;
Modifiedsrc/views/live-feed.tsx+2−1View fileUnifiedSplit
@@ -9,6 +9,7 @@
99 */
1010
1111import { liveSubscribeScript } from "../lib/sse-client";
12import { jsonForScript } from "../lib/json-for-script";
1213
1314export function LiveFeed(props: {
1415 topic: string;
@@ -27,7 +28,7 @@ export function LiveFeed(props: {
2728 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
2829 var d = event && event.data ? event.data : event;
2930 if (!d) return '';
30 var ph = document.getElementById(${JSON.stringify(emptyId)});
31 var ph = document.getElementById(${jsonForScript(emptyId)});
3132 if (ph) ph.remove();
3233 return '<li>' + esc(d.actor) + ' ' + esc(d.action) + ' ' + esc(d.target) + '</li>';
3334 `;
3435
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts