diff --git a/CHANGELOG.md b/CHANGELOG.md index 81139ca5..c4f40518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,25 @@ # Changelog -## 0.0.11-beta.3 — 2026-05-25 +## 0.0.11-beta.3 — 2026-05-31 ### Features +- Add email-OTP auth wired to the Rust `failproof-api-server` (`/v0/auth/login/request`, `/login/verify`, `/token/refresh`, `/logout`, `/me`). New `failproofai auth --login | --logout | --whoami` CLI subcommand (`src/auth/cli.ts`, dispatched from `bin/failproofai.mjs`) persists tokens to `~/.failproofai/auth.json` at mode `0600` via a shared store (`lib/auth/auth-store.ts` + `lib/auth/api-server-client.ts`); the store auto-refreshes the access token within a 60s leeway window and treats refresh-token reuse / 401 as "wipe local session". Four Next.js API routes (`app/api/auth/{status,login-request,login-verify,logout}/route.ts`) proxy the same flow for the dashboard so the refresh token never reaches the browser — only `{authenticated, user}` does. The "set a reminder" CTA in `/audit`'s `return-section.tsx` now probes `/api/auth/status` on mount and, for un-authed visitors, opens a new `AuthDialog` (`app/audit/_components/auth-dialog.tsx`, styled to match the audit aesthetic: pink corner-glyphs, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live resend countdown, ESC-to-close) that walks email → OTP → "you are " inline; signed-in users get a green "signed in as …" pill under the CTA. Configurable via `FAILPROOF_API_URL` (defaults to `http://localhost:8080`) and `FAILPROOFAI_AUTH_DIR` (defaults to `~/.failproofai`). + +- `/audit` polish pass: simplify the "next audit" CTA to `[ install policies ]` copying the bare `failproofai policies --install` command (no longer appends per-slipping-policy short names); fix the `[ share → ]` header button to scroll to the Show-off section reliably by accounting for the sticky in-page `.app-header` height with a manual y-coord scroll + a `scroll-margin-top` fallback on `.showoff`; harden the "make poster" PNG export so the captured archetype frame no longer collides with the sigil / tagline — `show-off-cta.tsx` now `await document.fonts.ready` before capture, applies a `.capturing` class that locks every viewport-clamped font-size and grid column to an absolute value tuned for the 1100px capture width, drops `text-shadow` / `box-shadow` that html2canvas crops unpredictably, and captures with a 12px bleed on each side so the frame's corner accents and box-shadow survive the crop; and expand every archetype in `src/audit/archetypes.ts` from a single hand-written copy block to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, "common in" / "primary risk" / closing lines per archetype, all 8 archetypes covered). A new `pickArchetypeVariant(key, seed)` picker deterministically selects one variant from each list via a djb2-seeded per-field hash mixed with a per-field axis, so the persona blurb stays stable across renders for a given seed but two different projects landing on the same archetype see different copy. `IdentitySection` consumes the resolved variant; the seed flows in from `audit-dashboard.tsx` as the inferred project name. + +- Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`). + - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). +- Polish pass across `/audit`, `/policies`, and `/projects`: bump base font from `13px → 14.5px` and widen `.report` from `1180px → 1380px` (with `40px` side padding) in `globals.css` so default-zoom readability stops requiring a browser zoom-in; restore `.section` vertical padding to `64px` to match the audit reference. Remove the second in-page audit `
` (`app/audit/_components/app-header.tsx` deleted) and all three of its mount sites in `audit-dashboard.tsx` — the global navbar plus per-section masts cover the same chrome without the duplicate `failproof_ai / AUDIT [share →]` strip. Rewrite `score-section.tsx` end-to-end: drop the synthetic cohort leaderboard and replace with a single dashed-frame `.panel` (the new `.score-share-card`) split into two columns — left is the audit score (big tier-colored number, tier badge, progress bar to the next grade band, three stat boxes for missing policies / pts-to-next / est. days-to-fix, plus a top-N policy-status chip strip), right is share (pre-written X / Twitter and LinkedIn templates derived from `score + archetype + missing-count`, `[share on X]`, `[share on LinkedIn]`, and `[download audit card]` that html2canvas-captures the whole panel as a PNG named `failproofai-card--.png`). `audit-dashboard.tsx` drops the now-unused `syntheticRank` import / `rank` prop and threads `result` into the score section. Replace `empty-state.tsx` and `run-progress.tsx` with audit-pixel-craft versions: a `.empty-panel` with a pixel-grid sigil, Architype Stedelijk headline, and `.btn-press` CTA replaces the shadcn `Button` + `lucide-react` icon center-card; the running view becomes a terminal-style `.running-panel` (`$ failproofai audit --since 30d ▮` header with a blinking pink cursor, stage list with `✓` / `▮▮` / `○` markers and a per-stage braille spinner, and a marquee `audit-bar-fill` progress bar). Persistent **next-audit reminder** added — new `~/.failproofai/next-audit.json` (mode 0600, separate file from `auth.json` so the reminder is independent of token refresh), new `lib/auth/auth-store.ts` helpers (`readReminder` / `writeReminder` / `deleteReminder` / `getReminderFilePath` + `StoredReminder` type), new `app/api/auth/reminder/route.ts` (GET / POST / DELETE, defaults to a 7-day offset, scoped to the active session so a reminder for `a@x.com` is invisible to a CLI-authed `b@x.com`), and `/api/auth/status` now returns `reminder: { next_audit_at, user_email, set_at } | null` alongside the user. `return-section.tsx` flips behavior accordingly: signed in + reminder set → status panel ("next audit set for ` · in 7 days`" + "signed in as ``" + a `[re-audit now]` button next to `[install policies]` and a tiny "clear reminder" link); anon → `[set a reminder]` opens the existing AuthDialog and on successful sign-in writes the reminder automatically; signed in + no reminder → `[set a reminder]` writes it directly with no dialog. The `[re-audit now]` button (also shown to anon users with audit data) reuses the existing `triggerRun` poller and reloads the page once the run completes. No new dependencies; the deleted `app-header.tsx` was a 38-line component with no callers other than the three audit-dashboard mounts. + +- Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `
` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds. + +### Docs +- Extend `docs/cli/auth.mdx` with a "Persistent re-audit reminder" section covering the new `~/.failproofai/next-audit.json` file and the `GET / POST / DELETE /api/auth/reminder` dashboard endpoint that backs the `/audit` `[ set a reminder ]` CTA — including the file shape, the per-email scoping rule, and the 7-day default offset. + +- Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow. + ## 0.0.11-beta.2 — 2026-05-21 ### Features diff --git a/__tests__/audit/dashboard-cache.test.ts b/__tests__/audit/dashboard-cache.test.ts new file mode 100644 index 00000000..282d3fa1 --- /dev/null +++ b/__tests__/audit/dashboard-cache.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readDashboardCache, + writeDashboardCache, + isCacheStale, +} from "../../src/audit/dashboard-cache"; +import type { AuditResult } from "../../src/audit/types"; + +const FAKE_RESULT: AuditResult = { + version: 2, + scannedAt: "2026-05-26T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 5, skipped: 0, errors: 0, durationMs: 100 }, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: ["/home/u/a", "/home/u/b"], + eventsScanned: 42, + enabledBuiltinNames: ["block-failproofai-commands"], +}; + +describe("dashboard cache", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + // Redirect homedir() to a tmp directory by overriding HOME — os.homedir() + // reads it on every call on POSIX, so the dashboard-cache module sees + // our tmp path without needing module mocks. + tmpHome = mkdtempSync(join(tmpdir(), "fpa-audit-cache-test-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("returns null when no cache file exists", () => { + expect(readDashboardCache()).toBeNull(); + }); + + it("round-trips a written entry", () => { + writeDashboardCache({ since: "7d" }, FAKE_RESULT); + const entry = readDashboardCache(); + expect(entry).not.toBeNull(); + expect(entry?.params).toEqual({ since: "7d" }); + expect(entry?.result.transcripts.scanned).toBe(5); + expect(entry?.result.projectsScanned).toEqual(["/home/u/a", "/home/u/b"]); + expect(typeof entry?.cachedAt).toBe("string"); + }); + + it("writes mode 0600 on the file", () => { + writeDashboardCache({}, FAKE_RESULT); + const cachePath = join(tmpHome, ".failproofai", "audit-dashboard.json"); + expect(existsSync(cachePath)).toBe(true); + const mode = statSync(cachePath).mode & 0o777; + // Some filesystems (FAT, etc.) can't honor mode bits perfectly — just + // assert no world-readable bit is set. + expect(mode & 0o004).toBe(0); + }); + + it("returns null for a corrupt JSON cache file", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), "{ not json", "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("returns null when shape is wrong", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), JSON.stringify({ foo: 1 }), "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("isCacheStale returns true past the threshold", () => { + const old = new Date(Date.now() - 60 * 60_000).toISOString(); // 1 hour ago + expect(isCacheStale(old, 30)).toBe(true); + }); + + it("isCacheStale returns false within the threshold", () => { + const recent = new Date(Date.now() - 10 * 60_000).toISOString(); // 10 min ago + expect(isCacheStale(recent, 30)).toBe(false); + }); + + it("isCacheStale treats unparseable timestamps as stale", () => { + expect(isCacheStale("not-a-date")).toBe(true); + }); +}); diff --git a/__tests__/audit/replay.test.ts b/__tests__/audit/replay.test.ts index 18e7dfea..ca377b0b 100644 --- a/__tests__/audit/replay.test.ts +++ b/__tests__/audit/replay.test.ts @@ -1,6 +1,12 @@ // @vitest-environment node import { describe, it, expect, beforeEach } from "vitest"; -import { resetReplay, replayEvent } from "../../src/audit/replay"; +import { resetReplay, replayEvent, initReplay, restoreReplay } from "../../src/audit/replay"; +import { + clearPolicies, + getAllPolicies, + registerPolicy, +} from "../../src/hooks/policy-registry"; +import { allow } from "../../src/hooks/policy-helpers"; import type { NormalizedToolEvent } from "../../src/audit/types"; function bash(command: string): NormalizedToolEvent { @@ -50,3 +56,48 @@ describe("replay engine", () => { expect(hits.some((h) => h.eventType === "PostToolUse")).toBe(true); }); }); + +describe("replay registry snapshot/restore", () => { + beforeEach(() => { + resetReplay(); + clearPolicies(); + }); + + it("restoreReplay puts back the pre-init registry", () => { + registerPolicy( + "test/custom-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + const before = getAllPolicies().map((p) => p.name).sort(); + expect(before).toContain("test/custom-marker"); + + initReplay(); + const duringInit = getAllPolicies().map((p) => p.name); + expect(duringInit).not.toContain("test/custom-marker"); + expect(duringInit.length).toBeGreaterThan(10); // builtins are loaded + + restoreReplay(); + const after = getAllPolicies().map((p) => p.name).sort(); + expect(after).toEqual(before); + }); + + it("restoreReplay is idempotent when called twice", () => { + registerPolicy( + "test/another-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + initReplay(); + restoreReplay(); + restoreReplay(); // second call should be a no-op + expect(getAllPolicies().map((p) => p.name)).toContain("test/another-marker"); + }); + + it("restoreReplay before initReplay is a no-op", () => { + expect(() => restoreReplay()).not.toThrow(); + expect(getAllPolicies()).toEqual([]); + }); +}); diff --git a/app/actions/get-audit-result.ts b/app/actions/get-audit-result.ts new file mode 100644 index 00000000..4e8e6210 --- /dev/null +++ b/app/actions/get-audit-result.ts @@ -0,0 +1,24 @@ +"use server"; + +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; + +export type AuditResultPayload = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +/** + * Read the dashboard cache. Never triggers a run — `/audit` shows the empty + * state when there's no cache and lets the user opt in to scanning. Mirrors + * the read-only ergonomics of `getHooksConfigAction()`. + */ +export async function getAuditResultAction(): Promise { + const entry = readDashboardCache(); + if (!entry) return { status: "empty" }; + return { + status: "cached", + cachedAt: entry.cachedAt, + params: entry.params, + result: entry.result, + }; +} diff --git a/app/api/audit/_state.ts b/app/api/audit/_state.ts new file mode 100644 index 00000000..d955de77 --- /dev/null +++ b/app/api/audit/_state.ts @@ -0,0 +1,40 @@ +/** + * Shared in-memory state between `/api/audit/run` and `/api/audit/status`. + * + * A single audit can take 10-30 seconds; the client UI needs to know whether + * one is in flight (to disable the re-run button and show a progress UI). + * Both API routes import the same module-level state from here so they + * agree on what "running" means. + * + * Caveat: Next.js dev mode HMR can reset module state mid-run; in that case + * the status endpoint will report `running: false` even though the original + * POST handler is still resolving. In production (`next start`/`bun start`) + * the singleton holds for the lifetime of the worker process. + */ +export interface RunState { + /** True while a `runAudit()` call is in flight. */ + running: boolean; + /** ms timestamp the current run was kicked off, if `running`. */ + startedAt?: number; +} + +const state: RunState = { running: false }; + +export function getRunState(): RunState { + return { ...state }; +} + +/** Atomically attempt to take the run lock. Returns true if the caller + * acquired it; false if a run is already in progress. */ +export function tryAcquireRun(): boolean { + if (state.running) return false; + state.running = true; + state.startedAt = Date.now(); + return true; +} + +/** Release the run lock. Safe to call even when not held. */ +export function releaseRun(): void { + state.running = false; + state.startedAt = undefined; +} diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts new file mode 100644 index 00000000..a192b0fd --- /dev/null +++ b/app/api/audit/run/route.ts @@ -0,0 +1,78 @@ +/** + * POST /api/audit/run — kick off a `runAudit()` call and write the dashboard + * cache on success. Returns the full `AuditResult` in the response. + * + * Concurrency: a module-level singleton in `_state.ts` guards against + * overlapping runs — the second concurrent POST gets a 409. The client + * (rerun-button.tsx) then just falls back to polling /status. + */ +import { NextRequest, NextResponse } from "next/server"; +import { runAudit } from "@/src/audit"; +import { writeDashboardCache } from "@/src/audit/dashboard-cache"; +import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; +import type { RunAuditOptions } from "@/src/audit/types"; +import { releaseRun, tryAcquireRun } from "../_state"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +interface RunBody { + since?: string; + cli?: string[]; + project?: string[]; + policy?: string[]; + noCache?: boolean; +} + +const VALID_CLIS = new Set(INTEGRATION_TYPES); + +function sanitize(body: RunBody): RunAuditOptions { + const opts: RunAuditOptions = {}; + if (typeof body.since === "string" && body.since.trim()) { + opts.since = body.since.trim(); + } + if (Array.isArray(body.cli) && body.cli.length > 0) { + const valid = body.cli.filter((c): c is IntegrationType => + typeof c === "string" && VALID_CLIS.has(c) + ); + if (valid.length > 0) opts.clis = valid; + } + if (Array.isArray(body.project) && body.project.length > 0) { + opts.projects = body.project.filter((p) => typeof p === "string"); + } + if (Array.isArray(body.policy) && body.policy.length > 0) { + opts.policies = body.policy.filter((p) => typeof p === "string"); + } + if (body.noCache === true) opts.noCache = true; + return opts; +} + +export async function POST(request: NextRequest): Promise { + let body: RunBody = {}; + try { + const raw = await request.text(); + if (raw) body = JSON.parse(raw) as RunBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const opts = sanitize(body); + + if (!tryAcquireRun()) { + return NextResponse.json( + { error: "Audit already running", status: "already-running" }, + { status: 409 }, + ); + } + + try { + const result = await runAudit(opts); + writeDashboardCache(opts, result); + return NextResponse.json({ status: "ok", result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message, status: "error" }, { status: 500 }); + } finally { + releaseRun(); + } +} diff --git a/app/api/audit/status/route.ts b/app/api/audit/status/route.ts new file mode 100644 index 00000000..7dfbacf0 --- /dev/null +++ b/app/api/audit/status/route.ts @@ -0,0 +1,23 @@ +/** + * GET /api/audit/status — lightweight poll endpoint. Client polls this at + * 1s while a run is in flight; switches off polling once `running: false`. + * + * Also returns the cache's `cachedAt` so the client can detect that a new + * result has landed (older `cachedAt` value in client → refetch via the + * server action). + */ +import { NextResponse } from "next/server"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { getRunState } from "../_state"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + const state = getRunState(); + const cache = readDashboardCache(); + return NextResponse.json({ + running: state.running, + startedAt: state.startedAt ?? null, + cachedAt: cache?.cachedAt ?? null, + }); +} diff --git a/app/api/auth/login-request/route.ts b/app/api/auth/login-request/route.ts new file mode 100644 index 00000000..0c3f2b41 --- /dev/null +++ b/app/api/auth/login-request/route.ts @@ -0,0 +1,57 @@ +/** + * POST /api/auth/login-request + * + * Browser-facing proxy for the api-server's /v0/auth/login/request. Keeps the + * api-server URL server-side so the browser only ever talks to the local + * dashboard. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, requestLoginCode } from "@/lib/auth/api-server-client"; + +export const dynamic = "force-dynamic"; + +interface RequestBody { + email?: unknown; +} + +export async function POST(req: NextRequest): Promise { + let body: RequestBody = {}; + try { + body = (await req.json()) as RequestBody; + } catch { + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + try { + const r = await requestLoginCode(body.email); + return NextResponse.json( + { + status: r.status, + expires_in: r.expires_in, + resend_available_in: r.resend_available_in, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + return NextResponse.json( + { + code: err.code, + message: err.message, + ...(err.retryAfterSecs !== undefined ? { retry_after_secs: err.retryAfterSecs } : {}), + }, + { status: err.status }, + ); + } + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/login-verify/route.ts b/app/api/auth/login-verify/route.ts new file mode 100644 index 00000000..f66d381c --- /dev/null +++ b/app/api/auth/login-verify/route.ts @@ -0,0 +1,62 @@ +/** + * POST /api/auth/login-verify + * + * Browser-facing proxy: verifies the OTP with the api-server, persists the + * resulting tokens to ~/.failproofai/auth.json on the local dashboard host, + * and returns *only* the user identity to the browser. The refresh token + * never leaves the local filesystem. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, verifyLoginCode } from "@/lib/auth/api-server-client"; +import { authFromTokenResponse, writeAuth } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +interface VerifyBody { + email?: unknown; + code?: unknown; +} + +export async function POST(req: NextRequest): Promise { + let body: VerifyBody = {}; + try { + body = (await req.json()) as VerifyBody; + } catch { + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + if (typeof body.code !== "string" || !body.code.trim()) { + return NextResponse.json( + { code: "validation_error", message: "code is required" }, + { status: 400 }, + ); + } + try { + const tokens = await verifyLoginCode(body.email, body.code); + writeAuth(authFromTokenResponse(tokens)); + return NextResponse.json( + { + authenticated: true, + user: { id: tokens.user.id, email: tokens.user.email }, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + return NextResponse.json( + { code: err.code, message: err.message }, + { status: err.status }, + ); + } + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..1b566f29 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,32 @@ +/** + * POST /api/auth/logout + * + * Reads the locally-stored session, asks the api-server to revoke it, and + * deletes ~/.failproofai/auth.json regardless of upstream success — local + * intent to log out takes precedence. + */ +import { NextResponse } from "next/server"; +import { AuthApiError, logoutSession } from "@/lib/auth/api-server-client"; +import { deleteAuth, readAuth } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +export async function POST(): Promise { + const existing = readAuth(); + if (!existing) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + let upstream: "revoked" | "skipped" | "failed" = "skipped"; + try { + await logoutSession(existing.access_token, existing.refresh_token); + upstream = "revoked"; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + upstream = "revoked"; // token already invalid server-side + } else { + upstream = "failed"; + } + } + deleteAuth(); + return NextResponse.json({ authenticated: false, upstream }, { status: 200 }); +} diff --git a/app/api/auth/reminder/route.ts b/app/api/auth/reminder/route.ts new file mode 100644 index 00000000..840616a9 --- /dev/null +++ b/app/api/auth/reminder/route.ts @@ -0,0 +1,97 @@ +/** + * /api/auth/reminder + * + * GET — current reminder state (if any, scoped to the signed-in user) + * POST — set or update the next-audit reminder; requires an active session + * DELETE — clear the reminder + * + * Reminder timestamp lives in ~/.failproofai/next-audit.json. The dashboard + * AND the CLI can read it later (we just persist intent here; the actual + * email send is wired separately when the scheduler is built). + */ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteReminder, + readReminder, + whoAmI, + writeReminder, +} from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_OFFSET_DAYS = 7; +const MAX_OFFSET_DAYS = 365; + +export async function GET(): Promise { + const who = await whoAmI(); + const reminder = readReminder(); + if (!reminder) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + // If the reminder belongs to a different user (or no one is signed in), + // surface it as null so the UI doesn't show "next audit set for alice" + // when bob is the current session. + if (!who || who.me.email !== reminder.user_email) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + return NextResponse.json({ + authenticated: true, + reminder: { + next_audit_at: reminder.next_audit_at, + user_email: reminder.user_email, + set_at: reminder.set_at, + }, + }); +} + +interface SetBody { + /** Days from now until the reminder fires. Default: 7. */ + in_days?: unknown; + /** Absolute unix-seconds timestamp. Wins over in_days when both are sent. */ + at?: unknown; +} + +export async function POST(req: NextRequest): Promise { + const who = await whoAmI(); + if (!who) { + return NextResponse.json( + { code: "unauthorized", message: "Sign in before setting a reminder." }, + { status: 401 }, + ); + } + let body: SetBody = {}; + try { + body = (await req.json().catch(() => ({}))) as SetBody; + } catch { + // empty body is fine — we'll fall back to the default 7-day offset + } + const nowSecs = Math.floor(Date.now() / 1000); + let nextAuditAt: number; + if (typeof body.at === "number" && Number.isFinite(body.at)) { + nextAuditAt = Math.floor(body.at); + } else { + const offsetDays = + typeof body.in_days === "number" && Number.isFinite(body.in_days) + ? Math.max(1, Math.min(MAX_OFFSET_DAYS, Math.floor(body.in_days))) + : DEFAULT_OFFSET_DAYS; + nextAuditAt = nowSecs + offsetDays * 86400; + } + if (nextAuditAt <= nowSecs) { + return NextResponse.json( + { code: "validation_error", message: "Reminder must be in the future." }, + { status: 400 }, + ); + } + const reminder = { + next_audit_at: nextAuditAt, + user_email: who.me.email, + set_at: nowSecs, + }; + writeReminder(reminder); + return NextResponse.json({ authenticated: true, reminder }); +} + +export async function DELETE(): Promise { + deleteReminder(); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts new file mode 100644 index 00000000..9cdeb642 --- /dev/null +++ b/app/api/auth/status/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/auth/status + * + * Returns the currently signed-in identity by reading the local + * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the + * file is the source of truth, same as the CLI's `failproofai auth whoami`. + * This keeps the dashboard UI and the CLI consistent regardless of whether + * the api-server is reachable. + * + * Also returns the user's persisted re-audit reminder (if any). The reminder + * lives in ~/.failproofai/next-audit.json and is only surfaced when its + * `user_email` matches the active session — so swapping accounts via CLI + * does not leak a previous user's reminder into the dashboard. + */ +import { NextResponse } from "next/server"; +import { readAuth, readReminder } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + const auth = readAuth(); + if (!auth) { + return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); + } + const reminderRaw = readReminder(); + const reminder = + reminderRaw && reminderRaw.user_email === auth.user.email + ? { + next_audit_at: reminderRaw.next_audit_at, + user_email: reminderRaw.user_email, + set_at: reminderRaw.set_at, + } + : null; + return NextResponse.json( + { + authenticated: true, + user: { id: auth.user.id, email: auth.user.email }, + reminder, + }, + { status: 200 }, + ); +} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx new file mode 100644 index 00000000..b8d1f9eb --- /dev/null +++ b/app/audit/_components/audit-dashboard.tsx @@ -0,0 +1,262 @@ +"use client"; + +/** + * Top-level client wrapper for /audit. + * + * Composes the personality report: classify the agent into one of 8 + * archetypes, derive a score + tier, render the IdentitySection + + * ShowOff + Strengths + Score (with leaderboard) + Findings + Policies + * + Return-loop CTA. + * + * Empty / running states fall back to the existing EmptyState and + * RunProgress components. + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getAuditResultAction } from "@/app/actions/get-audit-result"; +import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; +import { classifyAgent } from "@/src/audit/archetypes"; +import { COHORT_SIZE, deriveScore, gradeFor, projectedScore, type Grade } from "@/src/audit/scoring"; +import { deriveStrengths } from "@/src/audit/strengths"; +import { deriveFindings } from "@/src/audit/findings"; + +import { IdentitySection } from "./identity-section"; +import { StrengthsSection } from "./strengths-section"; +import { ScoreSection } from "./score-section"; +import { FindingsSection } from "./findings-section"; +import { PoliciesSection } from "./policies-section"; +import { ReturnSection } from "./return-section"; +import { ReportFooter } from "./report-footer"; +import { EmptyState } from "./empty-state"; +import { RunProgress } from "./run-progress"; + +// IMPORTANT: do NOT import BUILTIN_POLICIES or AUDIT_DETECTORS here. +// Both pull in node:fs and execSync (workflow policies), which Next.js +// refuses to bundle for the client. The total catalog size is computed +// server-side in page.tsx and passed in as a plain number prop. + +type Initial = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +interface Props { + initial: Initial; + /** ?p=... URL param override for the project name in the leaderboard + * row. Defaults to whichever cwd has the most hits, falling back to + * "your agent". */ + projectFromUrl?: string; + /** Total number of detectors + builtin policies. Computed server-side + * in page.tsx — the modules can't ship to the client. */ + totalCatalogSize: number; +} + +function inferWindow(params: RunAuditOptions | undefined): string { + if (!params?.since) return "all time"; + return params.since; +} + +function inferProjectName(result: AuditResult, override?: string): string { + if (override && override.trim()) return override; + // Pick the cwd that appears in the most examples — proxy for "your + // most-active project". Falls back to "your agent". + const counts = new Map(); + for (const row of result.results) { + for (const ex of row.examples) { + if (!ex.cwd) continue; + counts.set(ex.cwd, (counts.get(ex.cwd) ?? 0) + 1); + } + } + let bestCwd = ""; + let bestCount = 0; + for (const [cwd, n] of counts) { + if (n > bestCount) { bestCwd = cwd; bestCount = n; } + } + if (!bestCwd) return "your agent"; + const segs = bestCwd.replace(/\/+$/, "").split(/[\\/]/); + // Use last two path segments — like "blrnow / api-coder". + if (segs.length >= 2) return `${segs[segs.length - 2]} / ${segs[segs.length - 1]}`; + return segs[segs.length - 1] ?? "your agent"; +} + +export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Props) { + const [cache, setCache] = useState(initial); + const [running, setRunning] = useState(false); + + const refreshFromCache = useCallback(async () => { + const payload = await getAuditResultAction(); + if (payload.status === "cached") setCache(payload); + }, []); + + // Body class for audit-only background + grain texture. Applied once on + // mount so the body bg switches from the global #0a0a0a to the audit + // #131316 only on this route. + useEffect(() => { + document.body.classList.add("audit-body"); + return () => document.body.classList.remove("audit-body"); + }, []); + + /* ---- empty / first-run ----------------------------------------- */ + if (cache.status === "empty" && !running) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + if (cache.status === "empty" && running) { + return ( + {}} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + // cache.status === "cached" + const result = cache.status === "cached" ? cache.result : null; + if (!result) return null; + const cachedAt = cache.status === "cached" ? cache.cachedAt : null; + const params = cache.status === "cached" ? cache.params : undefined; + + /* ---- scanned but zero sessions --------------------------------- */ + if (result.transcripts.scanned === 0) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + /* ---- in-flight re-run ------------------------------------------ */ + if (running) { + return ( +
+
+
+
+ +
+ +
+
+ ); + } + + /* ---- main report ----------------------------------------------- */ + return ( + + ); +} + +interface MainReportProps { + result: AuditResult; + cachedAt: string | null; + params: RunAuditOptions | undefined; + projectFromUrl?: string; + totalCatalogSize: number; +} + +function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize }: MainReportProps) { + const classification = useMemo(() => classifyAgent(result), [result]); + const score = useMemo(() => deriveScore(result), [result]); + const projected = useMemo(() => projectedScore(result, score), [result, score]); + const grade = gradeFor(score); + const projectedGrade = gradeFor(projected); + const strengths = useMemo(() => deriveStrengths(result), [result]); + const findings = useMemo(() => deriveFindings(result), [result]); + const project = useMemo(() => inferProjectName(result, projectFromUrl), [result, projectFromUrl]); + const window = inferWindow(params); + + const detectorsTriggered = result.results.filter((r) => r.hits > 0).length; + + /** Slipping builtin policies — passed to IdentitySection share buttons. */ + const missing = result.results.filter( + (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, + ).length; + + /** Identity hero ref — captured to PNG by the share buttons. */ + const identityFrameRef = useRef(null); + + return ( +
+
+
+
+ + + + + + +
+ +
+
+ ); +} + +interface ShellEmptyProps { + running: boolean; + mode?: "no-cache" | "zero-sessions"; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +function ShellEmpty({ running, mode = "no-cache", onStarted, onCompleted }: ShellEmptyProps) { + // Use the archetype "optimist" sigil for the empty-state visual so the + // page doesn't render with a dead box. EmptyState itself is unchanged + // from the previous build. + return ( +
+
+
+
+ {running ? ( + + ) : ( + + )} +
+ +
+
+ ); +} + diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx new file mode 100644 index 00000000..73d79886 --- /dev/null +++ b/app/audit/_components/auth-dialog.tsx @@ -0,0 +1,352 @@ +"use client"; + +/** + * Auth dialog — modal overlay shown when an unauthenticated user clicks + * "[ set a reminder ]". Two-step flow: + * + * 1. Email entry → POST /api/auth/login-request + * 2. OTP entry → POST /api/auth/login-verify + * + * Styled to match the rest of the /audit page: pixel brackets, sharp pink + * accent, terminal-style frame. The dialog never sees the refresh token — + * the dashboard's API route writes it to ~/.failproofai/auth.json. + */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; + +export interface AuthedUser { + id: string; + email: string; +} + +interface Props { + open: boolean; + /** Copy shown above the title, e.g. "oops — you are unknown." */ + headline?: string; + /** Copy under the title explaining why we need auth right now. */ + reason?: string; + onClose: () => void; + /** Fired after successful verify. Caller decides what to do next. */ + onAuthed: (user: AuthedUser) => void; +} + +type Step = + | { kind: "email"; error: string | null } + | { kind: "code"; email: string; error: string | null; expiresIn: number; resendIn: number } + | { kind: "done"; user: AuthedUser }; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function AuthDialog({ + open, + headline = "oops — you are unknown.", + reason = "verify yourself to continue.", + onClose, + onAuthed, +}: Props): React.ReactElement | null { + const [step, setStep] = useState({ kind: "email", error: null }); + const [busy, setBusy] = useState(false); + const emailInputRef = useRef(null); + const codeInputRef = useRef(null); + + // Reset internal state every time the dialog opens. + useEffect(() => { + if (open) { + setStep({ kind: "email", error: null }); + setBusy(false); + } + }, [open]); + + // Autofocus the right input as the step changes. + useEffect(() => { + if (!open) return; + const t = setTimeout(() => { + if (step.kind === "email") emailInputRef.current?.focus(); + else if (step.kind === "code") codeInputRef.current?.focus(); + }, 50); + return () => clearTimeout(t); + }, [open, step.kind]); + + // ESC to close. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape" && !busy) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, busy, onClose]); + + // Resend countdown ticker. + const resendActive = step.kind === "code" && step.resendIn > 0; + useEffect(() => { + if (!resendActive) return; + const id = setInterval(() => { + setStep((s) => + s.kind === "code" ? { ...s, resendIn: Math.max(0, s.resendIn - 1) } : s, + ); + }, 1000); + return () => clearInterval(id); + }, [resendActive]); + + const requestCode = useCallback( + async (email: string): Promise => { + setBusy(true); + try { + const res = await fetch("/api/auth/login-request", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const body = (await res.json().catch(() => ({}))) as { + code?: string; + message?: string; + expires_in?: number; + resend_available_in?: number; + retry_after_secs?: number; + }; + if (!res.ok) { + let msg = body.message ?? "could not send code."; + if (body.code === "rate_limited" && body.retry_after_secs !== undefined) { + msg = `too many tries. wait ${body.retry_after_secs}s and try again.`; + } else if (body.code === "upstream_unreachable") { + msg = "api-server unreachable. is it running on :8080?"; + } + setStep({ kind: "email", error: msg }); + return; + } + setStep({ + kind: "code", + email, + error: null, + expiresIn: body.expires_in ?? 600, + resendIn: body.resend_available_in ?? 30, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStep({ kind: "email", error: `network error: ${message}` }); + } finally { + setBusy(false); + } + }, + [], + ); + + const verifyCode = useCallback( + async (email: string, code: string): Promise => { + setBusy(true); + try { + const res = await fetch("/api/auth/login-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, code }), + }); + const body = (await res.json().catch(() => ({}))) as { + authenticated?: boolean; + user?: AuthedUser; + code?: string; + message?: string; + }; + if (!res.ok || !body.authenticated || !body.user) { + let msg = body.message ?? "invalid code."; + if (body.code === "invalid_code") msg = "wrong code, or it expired. try again."; + setStep((s) => + s.kind === "code" ? { ...s, error: msg } : s, + ); + return; + } + setStep({ kind: "done", user: body.user }); + onAuthed(body.user); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setStep((s) => + s.kind === "code" ? { ...s, error: `network error: ${message}` } : s, + ); + } finally { + setBusy(false); + } + }, + [onAuthed], + ); + + const onEmailSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "email") return; + const fd = new FormData(e.currentTarget); + const email = String(fd.get("email") ?? "").trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + setStep({ kind: "email", error: "that doesn't look like an email." }); + return; + } + await requestCode(email); + }, + [busy, step, requestCode], + ); + + const onCodeSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "code") return; + const fd = new FormData(e.currentTarget); + const code = String(fd.get("code") ?? "").trim(); + if (code.length < 4 || code.length > 12) { + setStep((s) => + s.kind === "code" ? { ...s, error: "code is 4–12 characters." } : s, + ); + return; + } + await verifyCode(step.email, code); + }, + [busy, step, verifyCode], + ); + + const onResend = useCallback(async () => { + if (step.kind !== "code" || step.resendIn > 0 || busy) return; + await requestCode(step.email); + }, [step, busy, requestCode]); + + if (!open) return null; + + return ( +
{ + if (!busy && e.target === e.currentTarget) onClose(); + }} + > +
+ + + + + + + +
━━ identity check
+

+ {headline} +

+ + {step.kind === "email" && ( + <> +

{reason}

+
+ + + {step.error &&
{step.error}
} +
+ + +
+
+ + )} + + {step.kind === "code" && ( + <> +

+ we sent a code to {step.email}. +
+ check your inbox — it expires in {Math.ceil(step.expiresIn / 60)} min. +

+
+ + + {step.error &&
{step.error}
} +
+ + +
+ +
+ + )} + + {step.kind === "done" && ( + <> +

+ you are{" "} + {step.user.email}. +

+

+ session saved locally. +

+
+ +
+ + )} +
+
+ ); +} diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx new file mode 100644 index 00000000..17f15ce7 --- /dev/null +++ b/app/audit/_components/empty-state.tsx @@ -0,0 +1,134 @@ +"use client"; + +/** + * Two-mode empty state for /audit, styled to the audit pixel-craft system: + * + * - "no-cache" — first time the user visits /audit. CTA to run. + * - "zero-sessions" — ran a scan but no transcripts were found. Likely the + * user hasn't installed hooks for any CLI yet. + * + * Both modes use the shared `.panel` chrome with pink corner brackets, a + * green section eyebrow, an Architype Stedelijk display headline, and a + * sharp `.btn-press` action button. Sized so it occupies the same vertical + * space as the loaded dashboard does on its hero — no more cramped popover. + */ +import React from "react"; +import { triggerRun } from "./rerun-button"; + +interface Props { + mode: "no-cache" | "zero-sessions"; + running: boolean; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +export function EmptyState({ mode, running, onStarted, onCompleted }: Props) { + const handleRun = async () => { + onStarted(); + try { + await triggerRun({ cli: [], since: "30d" }); + } finally { + await onCompleted(); + } + }; + + if (mode === "no-cache") { + return ( +
+
+
+ ━━ audit{" "} + · first run +
+
+ no cache yet +
+
+

scan and see.

+ +
+ + +

run your first audit.

+

+ we'll walk every transcript across your installed CLIs — Claude Code, + Codex, Copilot, Cursor, OpenCode, Pi, Gemini — and count every wasteful + or risky action. you'll get a tier, a score, and a punch-list. +

+ +
+ + + scans the last 30 days · all installed CLIs · 10–30s + +
+
+
+ ); + } + + // mode === "zero-sessions" + return ( +
+
+
+ ━━ audit{" "} + · zero transcripts +
+
+ hooks not installed +
+
+

nothing to read.

+ +
+ + +

install hooks first.

+

+ failproofai couldn't find any transcripts to scan on this machine. + install the hooks for at least one CLI and come back. +

+ +
+ + [ install guide → ] + + + takes about 30 seconds · one command per CLI + +
+
+
+ ); +} diff --git a/app/audit/_components/findings-section.tsx b/app/audit/_components/findings-section.tsx new file mode 100644 index 00000000..266dacb1 --- /dev/null +++ b/app/audit/_components/findings-section.tsx @@ -0,0 +1,127 @@ +"use client"; + +/** + * Section 04 — FINDINGS. "your agent has some quirks." + * + * Per-finding cards with four blocks: what happened / what this costs / + * evidence sample / the fix. Data sourced from `src/audit/findings.ts`. + */ +import React, { useState } from "react"; +import type { FindingCard } from "@/src/audit/findings"; + +interface Props { + findings: FindingCard[]; +} + +export function FindingsSection({ findings }: Props) { + if (findings.length === 0) return null; + + return ( +
+
+
+ ━━ findings{" "} + · ranked by impact +
+
+ {findings.length} detector{findings.length === 1 ? "" : "s"} triggered +
+
+

your agent has some quirks.

+ +
+ {findings.map((f) => )} +
+
+ ); +} + +function Finding({ f }: { f: FindingCard }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(f.fix.install); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
№{f.num}
+
{f.title}
+
+ {f.count}× + occurrences +
+
+
+ + policy{" "} + {f.policy} + + · + {f.projects} {f.projects === 1 ? "project" : "projects"} + · + last seen {f.lastSeen} + {f.alreadyEnabled && ( + <> + · + enforced + + )} +
+
+
+
what happened
+
{f.body}
+
+
+
what this costs
+
{f.cost}
+
+
+
evidence · sample
+
+ {f.evidence.map((e, i) => { + if (e.kind === "comment") { + return
{e.text}
; + } + if (e.kind === "err") { + return
{e.text}
; + } + return ( +
+ + {e.text} +
+ ); + })} +
+
+
+
the fix
+
+ {f.fix.slug} +
{f.fix.desc}
+ {f.fix.alsoCoveredBy && ( +
+ also covered by{" "} + {f.fix.alsoCoveredBy} +
+ )} + + ${f.fix.install}{" "} + + {copied ? "copied" : "click to copy"} + + +
+
+
+
+ ); +} diff --git a/app/audit/_components/identity-section.tsx b/app/audit/_components/identity-section.tsx new file mode 100644 index 00000000..357c0d73 --- /dev/null +++ b/app/audit/_components/identity-section.tsx @@ -0,0 +1,234 @@ +"use client"; + +/** + * Section 01 — IDENTITY. The hero. Big archetype name with hard-offset + * stamp shadow, sigil to the right, keywords strip, "common in / primary + * risk" meta grid, and the closing one-liner. + * + * Layout uses the ported `.archetype-frame` / `.arch-mast` / `.arch-body` + * classes from audit-styles.css. Data sources from `src/audit/archetypes.ts`. + * + * The variant copy (tagline / keywords / common / risk / closing) is + * picked deterministically from a multi-variant catalog using the `seed` + * prop — typically the inferred project name. Same seed → same persona + * blurb across renders; different seeds → different copy. So two users + * who both land on "the optimist" see different language for it. + * + * Exposes a `frameRef` forwarded onto the `.archetype-frame` element so + * the ShowOff "make poster" action can capture it via html2canvas. + */ +import React, { forwardRef, useState } from "react"; +import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; +import { Sigil } from "./sigil"; + +const SITE_URL = "https://failproof.ai"; +const X_INTENT = (text: string) => + `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; +const LI_INTENT = (text: string) => + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(text)}`; + +function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const gradeLines: Record = { + S: "every prescribed policy live. running at peak. this is what secure looks like.", + A: `${missing} polic${missing === 1 ? "y" : "ies"} from elite tier. almost there.`, + B: `solid baseline. ${missing} policy gap${missing === 1 ? "" : "s"} to close before i'm comfortable.`, + C: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} between here and the next tier. they're named. they're waiting.`, + D: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} unaddressed. agents without guardrails aren't ready for prod.`, + F: `exposure is real. ${missing} polic${missing === 1 ? "y" : "ies"} away from stable ground — starting today.`, + }; + return `just audited my AI agent with failproofai ✦\n\narchetype: ${archetypeName.toLowerCase()} · ${score}/100 · ${grade} tier\n${gradeLines[grade]}\n\nrun yours → ${SITE_URL}`; +} + +function buildLinkedInTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const verdict = (grade === "S" || grade === "A") + ? `${score}/100 — ${grade} tier. every key policy is live. the audit confirmed what good looks like.` + : `${score}/100 — ${grade} tier. ${missing} prescribed polic${missing === 1 ? "y" : "ies"} uncovered — each one is a real attack surface.`; + return `We ran a failproofai security audit on our AI agent stack.\n\n${verdict}\n\nArchetype: ${archetypeName.toLowerCase()}. failproofai maps your agent\'s behavior pattern, identifies the exposure, and prescribes the exact policies to close it.\n\nFree. Open-source. 30 seconds to run: ${SITE_URL}`; +} + +interface Props { + archetypeKey: ArchetypeKey; + secondaryKey: ArchetypeKey; + toolCalls: number; + sessions: number; + /** "30d", "7d", etc. shown in the target line; "all time" otherwise. */ + window: string; + /** Stable seed for variant selection (project name is the natural fit). */ + seed: string; + score: number; + grade: Grade; + missing: number; +} + +export const IdentitySection = forwardRef(function IdentitySection( + { archetypeKey, secondaryKey, toolCalls, sessions, window, seed, score, grade, missing }: Props, + frameRef, +) { + const archetype = pickArchetypeVariant(archetypeKey, seed); + const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null; + const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const captureCard = async (): Promise => { + const node = typeof frameRef === "function" ? null : frameRef?.current; + if (!node) return false; + node.classList.add("capturing"); + try { + if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => r())); + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(node, { + backgroundColor: "#0e0e11", + scale: 2, + logging: false, + useCORS: true, + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `failproofai-identity-${grade.toLowerCase()}-${score}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + return true; + } finally { + node.classList.remove("capturing"); + } + }; + + const handleDownload = async () => { + if (downloadState === "busy") return; + setDownloadState("busy"); + try { + await captureCard(); + setDownloadState("done"); + setTimeout(() => setDownloadState("idle"), 2000); + } catch { + setDownloadState("error"); + setTimeout(() => setDownloadState("idle"), 2000); + } + }; + + const handleShareX = async () => { + const text = buildXTemplate(score, archetype.name, grade, missing); + await captureCard().catch(() => null); + globalThis.open(X_INTENT(text), "_blank", "noopener,noreferrer"); + }; + + const handleShareLI = async () => { + const text = buildLinkedInTemplate(score, archetype.name, grade, missing); + await captureCard().catch(() => null); + globalThis.open(LI_INTENT(text), "_blank", "noopener,noreferrer"); + }; + + return ( +
+
+ ┌ identity + v1.0 ┐ + └ № {archetype.index} / 08 + archetype ┘ + +
+
+
+ ━━ identity · your agent's archetype +
+
+ detected from{" "} + {toolCalls.toLocaleString()} + {" "}tool calls + / + {sessions} + {" "}sessions + / + {window} + + live + +
+
+
+
+ № {archetype.index} of 08 +
+
archetype
+
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+ + {secondary && ( +
+ with + {secondary.name.replace("the ", "")} + tendencies +
+ )} + +
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ( + · + )} + + ))} +
+ +
+
+ common in + {archetype.common} +
+
+ primary risk + {archetype.risk} +
+
+ +
— {archetype.closing}
+
+ + +
+ +
+ + + +
+
+
+ ); +}); diff --git a/app/audit/_components/policies-section.tsx b/app/audit/_components/policies-section.tsx new file mode 100644 index 00000000..996b77a3 --- /dev/null +++ b/app/audit/_components/policies-section.tsx @@ -0,0 +1,184 @@ +"use client"; + +/** + * Section 05 — PRESCRIBED POLICIES. "enable these. close the gap." + * + * Grid of unenabled-builtin cards with install commands + projected + * score uplift callout. + * + * Sources two layers of "hits": + * 1. Unenabled builtin policies that fired on their own + * 2. Audit detectors → mapped via DETECTOR_TO_POLICY in findings.ts. + * The detector's hits get attributed to its primary policy so the + * report frames everything as failproofai-coverable. + * + * Same policy can collect hits from multiple sources; we sum them and + * render one card per policy. + */ +import React, { useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { type Grade, tierName } from "@/src/audit/scoring"; + +interface Props { + result: AuditResult; + projected: number; + projectedGrade: Grade; +} + +// Mirror of DETECTOR_TO_POLICY in findings.ts. Could re-export but keep +// the dependency tree shallow — both modules are stable. +const DETECTOR_TO_PRIMARY_POLICY: Record = { + "redundant-cd-cwd": "warn-repeated-tool-calls", + "prefer-edit-over-read-cat": "block-read-outside-cwd", + "prefer-edit-over-sed-awk": "warn-repeated-tool-calls", + "prefer-write-over-heredoc": "block-env-files", + "sleep-polling-loop": "warn-background-process", + "find-from-root": "block-read-outside-cwd", + "git-commit-no-verify": "warn-git-amend", + "reread-after-edit": "warn-repeated-tool-calls", +}; + +const POLICY_DESC: Record = { + "warn-repeated-tool-calls": "warns when the same tool is called 3+ times with identical parameters — catches the loops before they spiral.", + "block-read-outside-cwd": "denies any file read whose absolute path falls outside the project root, including symlinks.", + "block-env-files": "blocks reads and writes of `.env` files at the tool layer.", + "block-secrets-write": "blocks writes to .pem, id_rsa, credentials.json, and other secret-key files.", + "warn-background-process": "warns before starting nohup / & / screen / tmux / disown processes that get forgotten about.", + "warn-git-amend": "warns before amending git commits — dangerous-commit-flag class.", + "require-ci-green-before-stop": "requires CI checks to pass on HEAD before the agent declares the task done.", +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +interface PolicyCard { + name: string; // short slug + desc: string; // displayTitle (low-res) or impact + catches: string; // "would have caught X occurrences..." copy + hits: number; +} + +function buildPolicyCards(result: AuditResult): PolicyCard[] { + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + // policyName → aggregated counts + const buckets = new Map }>(); + + for (const row of result.results) { + if (row.hits === 0) continue; + + let target: string; + let isFromDetector = false; + if (row.source === "audit-detector") { + const mapped = DETECTOR_TO_PRIMARY_POLICY[shortName(row.name)]; + if (!mapped) continue; + target = mapped; + isFromDetector = true; + } else if (row.source === "builtin" && !row.enabledInConfig) { + target = shortName(row.name); + } else { + continue; // already-enabled builtins don't need to be prescribed + } + + // Skip if the target policy is already in the user's enabled set + // (detector hits would land there in production already). + if (enabledSet.has(target)) continue; + + const bucket = buckets.get(target) ?? { hits: 0, projects: 0, sources: new Set() }; + bucket.hits += row.hits; + bucket.projects = Math.max(bucket.projects, row.projects); + bucket.sources.add(isFromDetector ? shortName(row.name) : "self"); + buckets.set(target, bucket); + } + + return [...buckets.entries()] + .sort((a, b) => b[1].hits - a[1].hits) + .map(([name, b]) => { + const viaList = [...b.sources].filter((s) => s !== "self"); + const viaCopy = viaList.length > 0 + ? ` (via ${viaList.join(", ")})` + : ""; + const catches = `would have caught ${b.hits} occurrence${b.hits === 1 ? "" : "s"} across ${b.projects} project${b.projects === 1 ? "" : "s"}${viaCopy}.`; + return { + name, + desc: POLICY_DESC[name] ?? "enable this builtin policy to close the gap.", + catches, + hits: b.hits, + }; + }); +} + +export function PoliciesSection({ result, projected, projectedGrade }: Props) { + const policies = buildPolicyCards(result); + + if (policies.length === 0) return null; + + return ( +
+
+
+ ━━ policies{" "} + · prescribed +
+
+ {policies.length} polic{policies.length === 1 ? "y" : "ies"}{" "} + ·{" "} + covers your slipping-through hits +
+
+

enable these. close the gap.

+ +
+ + enable all {policies.length === 1 ? "one" : policies.length} + + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {policies.map((p, i) => ( + + ))} +
+
+ ); +} + +function PolicyTile({ policy, idx }: { policy: PolicyCard; idx: number }) { + const [copied, setCopied] = useState(false); + const install = `failproof policy add ${policy.name}`; + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(install); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
{policy.name}
+
№{String(idx + 1).padStart(2, "0")}
+
+
{policy.desc}
+
+ {policy.catches} +
+
+ $ + {install} + + {copied ? "copied" : "copy"} + +
+
+ ); +} diff --git a/app/audit/_components/report-footer.tsx b/app/audit/_components/report-footer.tsx new file mode 100644 index 00000000..a8147c87 --- /dev/null +++ b/app/audit/_components/report-footer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; + +interface Props { + cachedAt: string | null; +} + +function formatUtcShort(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const day = d.getUTCDate().toString().padStart(2, "0"); + const monthNames = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + const m = monthNames[d.getUTCMonth()]; + const y = d.getUTCFullYear(); + const hh = d.getUTCHours().toString().padStart(2, "0"); + const mm = d.getUTCMinutes().toString().padStart(2, "0"); + return `${day} ${m} ${y}, ${hh}:${mm} utc`; +} + +export function ReportFooter({ cachedAt }: Props) { + return ( +
+ failproof_ai + · + audit v1.0 + · + generated {formatUtcShort(cachedAt)} + · + auto-healing for your agents. +
+ ); +} diff --git a/app/audit/_components/rerun-button.tsx b/app/audit/_components/rerun-button.tsx new file mode 100644 index 00000000..0978355d --- /dev/null +++ b/app/audit/_components/rerun-button.tsx @@ -0,0 +1,107 @@ +"use client"; + +/** + * Re-run button + polling. Click: + * 1. POSTs /api/audit/run with the current scan params + * 2. Polls /api/audit/status every 1s + * 3. When `running` flips false, the parent refetches the cache + * + * On 409 (audit already running) we just start polling without re-posting — + * lets the user "join" an in-flight run that someone else (or a previous + * tab) kicked off. + * + * Exports `triggerRun` separately so the empty-state CTA reuses the same + * fetch logic without re-implementing. + */ +import React from "react"; +import { RotateCw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface ScanParams { + /** Empty array = all CLIs. */ + cli: string[]; + /** "7d" | "30d" | "90d" | "all" (or any value accepted by parseSinceOpt). */ + since: string; +} + +interface Props { + scanParams: ScanParams; + running: boolean; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_MS = 5 * 60_000; // 5 min hard cap + +function paramsToBody(p: ScanParams) { + return { + cli: p.cli.length > 0 ? p.cli : undefined, + since: p.since === "all" ? undefined : p.since, + }; +} + +/** Fire a run and resolve once the server reports it finished. Used both by + * this button and by the EmptyState's "Run audit" CTA. */ +export async function triggerRun(scanParams: ScanParams): Promise { + // Kick off the run. 409 (already running) is OK — we'll just poll. + try { + const res = await fetch("/api/audit/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(paramsToBody(scanParams)), + }); + if (!res.ok && res.status !== 409) { + // Surface the error message but don't throw — the caller's finally + // still runs and the UI returns to its previous state. + const text = await res.text().catch(() => ""); + console.error("audit run failed:", res.status, text); + return; + } + } catch (err) { + console.error("audit run request failed:", err); + return; + } + + // Poll status until running flips false. + const startedAt = Date.now(); + while (Date.now() - startedAt < MAX_POLL_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const sres = await fetch("/api/audit/status", { cache: "no-store" }); + if (!sres.ok) continue; + const s = await sres.json() as { running: boolean }; + if (!s.running) return; + } catch { + // Transient — keep polling. + } + } +} + +export function RerunButton({ scanParams, running, onStarted, onCompleted }: Props) { + const handle = async () => { + onStarted(); + try { + await triggerRun(scanParams); + } finally { + await onCompleted(); + } + }; + + return ( + + ); +} diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx new file mode 100644 index 00000000..f02d01a7 --- /dev/null +++ b/app/audit/_components/return-section.tsx @@ -0,0 +1,301 @@ +"use client"; + +/** + * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. + * + * Behavior matrix: + * - unknown (probe in flight) → buttons disabled + * - anon (no session) → [ set a reminder ] opens AuthDialog, + * on success we flip to the authed panel + * below and persist the 7-day reminder. + * - authed (any) → consolidated status panel: "signed in as + * …" + either the persisted "next audit in + * X days" line OR a "no reminder set yet" + * line with an inline [ set a reminder ] + * button. The reminder persists across + * reloads via ~/.failproofai/next-audit.json + * — same as the CLI's auth.json. + * + * Also exposes [ re-audit now ] next to [ install policies ] so the user + * can trigger a fresh scan inline without leaving the page. The button + * fires POST /api/audit/run (same backend the empty-state CTA uses). + */ +import React, { useCallback, useEffect, useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { AuthDialog, type AuthedUser } from "./auth-dialog"; +import { triggerRun } from "./rerun-button"; + +interface Props { + result: AuditResult; +} + +const BULK_INSTALL_CMD = "failproofai policies --install"; +const DEFAULT_REMINDER_DAYS = 7; + +type AuthStatus = + | { kind: "unknown" } + | { kind: "anon" } + | { kind: "authed"; user: { id: string; email: string } }; + +interface Reminder { + next_audit_at: number; // unix seconds + user_email: string; + set_at: number; +} + +function daysUntil(unixSecs: number): number { + const nowSecs = Math.floor(Date.now() / 1000); + return Math.max(0, Math.ceil((unixSecs - nowSecs) / 86400)); +} + +function formatNextAudit(unixSecs: number): string { + const d = new Date(unixSecs * 1000); + return d.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +export function ReturnSection({ result }: Props) { + const hasUnenabled = result.results.some( + (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, + ); + + const [copied, setCopied] = useState(false); + const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); + const [reminder, setReminder] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [reminderBusy, setReminderBusy] = useState(false); + const [rerunBusy, setRerunBusy] = useState(false); + + // Probe /api/auth/status on mount — also returns the persisted reminder + // when one exists and belongs to the active session. + const refreshStatus = useCallback(async () => { + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + const body = (await res.json()) as { + authenticated?: boolean; + user?: { id: string; email: string }; + reminder?: Reminder | null; + }; + if (body.authenticated && body.user) { + setAuthStatus({ kind: "authed", user: body.user }); + setReminder(body.reminder ?? null); + } else { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + } catch { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + }, []); + + useEffect(() => { + void refreshStatus(); + // Re-probe whenever the tab regains focus or visibility — picks up + // CLI `failproofai auth login` / `logout` and api-server restarts + // without the user having to hit reload manually. + const onFocus = () => void refreshStatus(); + const onVisibility = () => { + if (document.visibilityState === "visible") void refreshStatus(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshStatus]); + + const persistReminder = useCallback(async (): Promise => { + try { + setReminderBusy(true); + const res = await fetch("/api/auth/reminder", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ in_days: DEFAULT_REMINDER_DAYS }), + }); + if (!res.ok) return null; + const body = (await res.json()) as { reminder?: Reminder }; + return body.reminder ?? null; + } catch { + return null; + } finally { + setReminderBusy(false); + } + }, []); + + const handleInstall = async () => { + try { + await navigator.clipboard.writeText(BULK_INSTALL_CMD); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* ignore */ + } + }; + + const handleSetReminder = useCallback(async () => { + if (authStatus.kind === "unknown") return; + if (authStatus.kind === "authed") { + const next = await persistReminder(); + if (next) setReminder(next); + return; + } + setDialogOpen(true); + }, [authStatus, persistReminder]); + + const handleAuthed = useCallback( + async (user: AuthedUser) => { + setAuthStatus({ kind: "authed", user }); + // The dialog opened because the user wanted a reminder → persist + // immediately, no second click required. + const next = await persistReminder(); + if (next) setReminder(next); + }, + [persistReminder], + ); + + const handleRerun = useCallback(async () => { + if (rerunBusy) return; + setRerunBusy(true); + try { + await triggerRun({ cli: [], since: "30d" }); + // Reload the page after the run so the cached result + dashboard cache + // get re-hydrated against the new scan. Cheaper than threading state. + window.location.reload(); + } finally { + setRerunBusy(false); + } + }, [rerunBusy]); + + const authed = authStatus.kind === "authed"; + const hasReminder = authed && reminder !== null; + const days = reminder ? daysUntil(reminder.next_audit_at) : 0; + const authedEmail = + authStatus.kind === "authed" ? authStatus.user.email : ""; + + return ( +
+
+
+ ━━ next audit{" "} + · improvement +
+
+ recommended in 7d +
+
+

come back better.

+ +
+
━━ the loop
+

re-audit in 7 days.

+

+ after the prescribed policies have been live for a week, we'll show + your before/after score and which detectors went quiet. +

+

+ most agents move from C to B in one session. some make it in a day. +

+ + {/* Once authed, the section stays in the consolidated status panel — + with the reminder line if one is set, or a "no reminder yet" line + + inline [ set a reminder ] button otherwise. The anonymous CTA + layout only shows for genuinely-unauthed sessions. */} + {authed ? ( +
+ {hasReminder && reminder ? ( +
+
+ ) : ( +
+
+ )} +
+
+
+ {!hasReminder && ( + + )} + + {hasUnenabled && ( + + )} +
+
+ ) : ( +
+ + + {hasUnenabled && ( + + )} +
+ )} +
+ + setDialogOpen(false)} + onAuthed={(u) => { + setDialogOpen(false); + void handleAuthed(u); + }} + /> +
+ ); +} diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx new file mode 100644 index 00000000..276bf63c --- /dev/null +++ b/app/audit/_components/run-progress.tsx @@ -0,0 +1,105 @@ +"use client"; + +/** + * Fake-progress UI shown while /api/audit/run is in flight. runAudit() does + * not emit granular progress events, so we animate through 4 plausible + * stages on a fixed 4s interval. The user sees motion + a clear "this is + * still working" signal. + * + * Visual: audit pixel-craft. A `.panel` with pink corner brackets, a + * scanline-style spinner header, a stack of stages with green "✓" / + * pink "▮▮" / dim "○" markers, and a marquee progress bar at the bottom + * filling pink-on-dark as the run advances. + */ +import React, { useEffect, useState } from "react"; + +const STAGES = [ + { label: "discovering transcripts", detail: "walking ~/.claude, ~/.codex, ~/.cursor, …" }, + { label: "parsing session logs", detail: "reading JSONL + sqlite session stores" }, + { label: "running policy checks", detail: "replaying through 30 builtin policies" }, + { label: "aggregating results", detail: "counting hits, ranking by frequency" }, +]; + +const STAGE_DURATION_MS = 4000; + +export function RunProgress() { + const [stage, setStage] = useState(0); + const [tick, setTick] = useState(0); + + useEffect(() => { + const stageTimer = setInterval(() => { + setStage((s) => Math.min(s + 1, STAGES.length - 1)); + }, STAGE_DURATION_MS); + const tickTimer = setInterval(() => setTick((t) => (t + 1) % 4), 350); + return () => { + clearInterval(stageTimer); + clearInterval(tickTimer); + }; + }, []); + + const dots = ".".repeat(tick + 1); + + return ( +
+
+
+ ━━ audit{" "} + · in progress +
+
+ scanning +
+
+

scanning sessions{dots}

+ +
+
+ $ + failproofai audit --since 30d + +
+ +
    + {STAGES.map((s, i) => { + const done = i < stage; + const active = i === stage; + return ( +
  • + +
    +
    {s.label}
    + {active &&
    {s.detail}
    } +
    + {active && ( + + )} +
  • + ); + })} +
+ +
+ progress + {stage + 1}/{STAGES.length} +
+
+
+
+ +

+ this usually takes 10–30 seconds depending on how much session history you have. +

+
+
+ ); +} diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx new file mode 100644 index 00000000..ebed1de3 --- /dev/null +++ b/app/audit/_components/score-section.tsx @@ -0,0 +1,173 @@ +"use client"; + +/** + * Section 03 — SCORE CARD. + * + * Left column only: YOUR AUDIT SCORE (big number, tier badge, progress + * bar, 3 stat boxes, prescribed-policies chip strip). + * + * Share actions have moved to IdentitySection below the archetype sigil. + */ +import React, { useMemo } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; + +interface Props { + result: AuditResult; + score: number; + grade: Grade; + archetypeKey: ArchetypeKey; + /** Display name shown in the card header. */ + project: string; +} + +export function ScoreSection({ result, score, grade, archetypeKey, project }: Props) { + const archetype = ARCHETYPES[archetypeKey]; + const pointsToNext = useMemo(() => { + const thresholds: { g: Grade; t: number }[] = [ + { g: "S", t: 90 }, { g: "A", t: 80 }, { g: "B", t: 71 }, + { g: "C", t: 55 }, { g: "D", t: 40 }, + ]; + for (const { g, t } of thresholds) { + if (score < t) return { next: g, delta: t - score }; + } + return { next: "S" as Grade, delta: 0 }; + }, [score]); + + /** Slipping-through builtin policies (the same heuristic ReturnSection uses + * for its [install policies] CTA). Used as the "policies missing" stat. */ + const missing = useMemo( + () => result.results.filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0).length, + [result], + ); + + /** Rough "days to fix" — capped 1..14. One day per slipping policy, with a + * baseline of 3d on any non-S grade. */ + const daysToFix = useMemo(() => { + if (grade === "S" || missing === 0) return 0; + return Math.max(1, Math.min(14, missing + 1)); + }, [grade, missing]); + + /** % of 0–100 bar to fill — simply score/100. */ + const progressPct = score; + + /** Top-N slipping policies → chip strip on the left card. Capped at 6. */ + const policyChips = useMemo(() => { + const slipping = result.results + .filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0) + .sort((a, b) => b.hits - a.hits) + .slice(0, 6) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: true as const })); + const enabled = result.results + .filter((r) => r.source === "builtin" && r.enabledInConfig) + .slice(0, Math.max(0, 6 - slipping.length)) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: false as const })); + return [...slipping, ...enabled]; + }, [result]); + + return ( +
+
+
+ ━━ score + · SEE HOW YOUR AGENT IS PERFORMING +
+
+

your audit score.

+ +
+
+ {project} + · + {archetype.name.toLowerCase()} +
+ +
+ {score} + /100 +
+ +
+ {grade} tier + {archetype.name.toLowerCase()} +
+ +
+ score + {pointsToNext.delta > 0 ? ( + + +{pointsToNext.delta} pts to {pointsToNext.next} tier + + ) : ( + top tier ✓ + )} +
+
+ {[40, 55, 71, 80, 90].map((t) => ( +
+ ))} +
+
+
+ {(["D", "C", "B", "A", "S"] as Grade[]).map((g, i) => { + const pos = [40, 55, 71, 80, 90][i]; + return ( + {g} + ); + })} +
+ +
+
+
{missing}
+
policies
missing
+
+
+
+ +{pointsToNext.delta} +
+
pts to
{pointsToNext.next} tier
+
+
+
+ {daysToFix === 0 ? "—" : `~${daysToFix}d`} +
+
est.
to fix
+
+
+ + {policyChips.length > 0 && ( + <> +
policy status
+
+ {policyChips.map((p, i) => ( + + + ))} +
+ + )} +
+
+ ); +} + +/** Drop the "failproofai/" namespace prefix builtin policies carry so chips + * stay compact (`block-sudo` reads better than `failproofai/block-sudo`). */ +function shortPolicyLabel(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} diff --git a/app/audit/_components/show-off-cta.tsx b/app/audit/_components/show-off-cta.tsx new file mode 100644 index 00000000..8b826552 --- /dev/null +++ b/app/audit/_components/show-off-cta.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * Section 01b — SHOW OFF CTA. Big bordered strip directly after the + * identity card. Sigil on the left, "show off your agent." headline + + * sub on the middle, "→ MAKE POSTER" action button on the right. + * + * Clicking the action captures the IdentitySection's archetype-frame + * DOM via html2canvas and triggers a PNG download. The capture target + * is passed in via a ref (avoids querying the DOM by class). + */ +import React, { useState } from "react"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { Sigil } from "./sigil"; + +interface Props { + archetypeKey: ArchetypeKey; + /** Ref to the IdentitySection's `.archetype-frame` div — captured to PNG. */ + identityFrameRef: React.RefObject; +} + +function buildFilename(archetypeKey: ArchetypeKey): string { + const date = new Date().toISOString().slice(0, 10); + return `failproofai-${archetypeKey}-${date}.png`; +} + +export function ShowOffCTA({ archetypeKey, identityFrameRef }: Props) { + const archetype = ARCHETYPES[archetypeKey]; + const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const handleMakePoster = async () => { + const node = identityFrameRef.current; + if (!node || state === "busy") return; + setState("busy"); + /** Add a capture-only class that locks font sizes, the grid layout, + * and disables clamp()/text-shadow rules html2canvas renders + * unreliably. CSS lives in audit-styles.css under `.capturing`. */ + node.classList.add("capturing"); + try { + // Wait for the display font (Architype Stedelijk) to load — otherwise + // html2canvas captures a fallback that has different metrics and the + // archetype name overlaps the tagline / sigil column. + if (typeof document !== "undefined" && document.fonts?.ready) { + await document.fonts.ready; + } + // Force a single rAF so the .capturing class is applied to layout + // before html2canvas reads computed styles. + await new Promise((r) => requestAnimationFrame(() => r())); + + const html2canvas = (await import("html2canvas")).default; + // Bleed: include the frame's 8px box-shadow in the capture rect. + const bleed = 12; + const canvas = await html2canvas(node, { + backgroundColor: "#131316", + scale: 2, + logging: false, + useCORS: true, + x: -bleed, + y: -bleed, + width: node.offsetWidth + bleed * 2, + height: node.offsetHeight + bleed * 2, + windowWidth: Math.max(1100, node.offsetWidth + bleed * 2), + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = buildFilename(archetypeKey); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + setState("done"); + setTimeout(() => setState("idle"), 2000); + } catch (err) { + console.error("poster capture failed:", err); + setState("error"); + setTimeout(() => setState("idle"), 2000); + } finally { + node.classList.remove("capturing"); + } + }; + + const actionLabel = + state === "busy" ? "rendering…" + : state === "done" ? "downloaded ✓" + : state === "error" ? "render failed" + : "make poster"; + + return ( +
+ +
+ ); +} diff --git a/app/audit/_components/sigil.tsx b/app/audit/_components/sigil.tsx new file mode 100644 index 00000000..0dc23b18 --- /dev/null +++ b/app/audit/_components/sigil.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * Pixel sigil — renders an 8x8 grid from the SIGILS table. + * + * Each archetype has an 8x8 character grid where: + * . = empty cell o = ink (foreground) + * p = pink accent g = green accent d = dim + * + * Wrapped in the `.sigil-wrap` / `.sigil` / `.sigil-label` CSS classes + * from the ported audit-styles.css. The `hideLabel` prop is used when the + * sigil appears inside the ShowOff CTA, which hides the "№ 0X SIGIL" caption. + */ +import React from "react"; +import { ARCHETYPES, SIGILS, type ArchetypeKey } from "@/src/audit/archetypes"; + +interface Props { + archetypeKey: ArchetypeKey; + hideLabel?: boolean; +} + +export function Sigil({ archetypeKey, hideLabel }: Props) { + const grid = SIGILS[archetypeKey] ?? SIGILS.optimist; + const archetype = ARCHETYPES[archetypeKey]; + const cells: React.ReactElement[] = []; + + for (let y = 0; y < 8; y++) { + const row = grid[y] ?? "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] ?? "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + + return ( +
+
{cells}
+ {!hideLabel && ( +
+ №{archetype.index} + sigil +
+ )} +
+ ); +} diff --git a/app/audit/_components/strengths-section.tsx b/app/audit/_components/strengths-section.tsx new file mode 100644 index 00000000..d80d8371 --- /dev/null +++ b/app/audit/_components/strengths-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +/** + * Section 02 — STRENGTHS. "your agent does this right." A leaderboard + * of green-checked behaviors derived from the AuditResult (see + * `src/audit/strengths.ts`). + */ +import React from "react"; +import type { Strength } from "@/src/audit/strengths"; + +interface Props { + strengths: Strength[]; + totalDetectorsTriggered: number; + totalDetectorsAvailable: number; +} + +export function StrengthsSection({ + strengths, totalDetectorsTriggered, totalDetectorsAvailable, +}: Props) { + if (strengths.length === 0) return null; + + return ( +
+
+
+ ━━ strengths + {" "}·{" "} + what your agent has figured out +
+
+ {" "} + {totalDetectorsAvailable - totalDetectorsTriggered} of {totalDetectorsAvailable} clean +
+
+

your agent does this right.

+ +
+ {strengths.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
+ — these are your agent's defaults. keep them. +
+
+ ); +} diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css new file mode 100644 index 00000000..13b24ed3 --- /dev/null +++ b/app/audit/audit-styles.css @@ -0,0 +1,1717 @@ +/* ============================================================ + failproof_ai — audit-page-specific styles + Brutalist pixel-craft, /audit-only widgets. + Site-wide tokens, fonts, body atmosphere, .app-header / .btn / + .tab / .section / .panel / .report all moved to globals.css + so they apply everywhere (and no longer leak when navigating + away from /audit back to /policies or /projects). + ============================================================ */ + +/* legacy scanline overlay used by audit-dashboard */ +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +/* ───────────────────────── 00 EMPTY + RUNNING (full-page states) ───────────────────────── */ + +.empty-section, .running-section { padding-top: 80px; padding-bottom: 96px; } + +.empty-panel, .running-panel { + padding: 48px 56px; + display: flex; flex-direction: column; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); +} + +.empty-glyph { align-self: center; text-align: center; margin-bottom: 28px; } +.empty-glyph-grid { + display: grid; + grid-template-columns: repeat(6, 14px); + grid-template-rows: repeat(6, 14px); + gap: 3px; + padding: 16px; + border: 1px solid var(--line-2); + background: var(--bg); + margin: 0 auto 14px; + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.empty-glyph-grid .px { background: transparent; } +.empty-glyph-grid .px.on { background: var(--accent-pink); } +.empty-glyph-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} + +.empty-headline { + font-family: var(--font-display); + font-size: clamp(32px, 4.6vw, 48px); + letter-spacing: 0.1em; line-height: 1.05; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 16px; + text-wrap: balance; + text-align: center; +} +.empty-sub { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.65; color: var(--ink-2); + max-width: 580px; + margin: 0 auto 32px; + text-align: center; +} + +.empty-actions { + display: flex; flex-direction: column; align-items: center; + gap: 12px; +} +.empty-cta { + padding: 12px 24px; + font-size: 14px; + letter-spacing: 0.08em; + text-decoration: none; +} +.empty-meta { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} + +.running-panel { padding: 36px 40px; } +.running-header { + display: flex; align-items: center; gap: 10px; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; + font-family: var(--font-mono); font-size: 13px; +} +.running-prompt { color: var(--accent-green); } +.running-cmd { color: var(--ink); letter-spacing: 0.02em; } +.running-cursor { + color: var(--accent-pink); + margin-left: 4px; + animation: cursor-blink 900ms steps(2, end) infinite; +} +@keyframes cursor-blink { 50% { opacity: 0; } } + +.running-stages { + list-style: none; padding: 0; margin: 0 0 28px; + display: flex; flex-direction: column; +} +.running-stage { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 14px; align-items: start; + padding: 12px 0; + border-bottom: 1px dashed var(--line); + font-family: var(--font-mono); +} +.running-stage:last-child { border-bottom: none; } +.running-marker { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: -1px; + margin-top: 1px; +} +.running-stage.queued { color: var(--dim); } +.running-stage.queued .running-marker { color: var(--line-2); } +.running-stage.active { color: var(--ink); } +.running-stage.active .running-marker { color: var(--accent-pink); } +.running-stage.done { color: var(--ink-2); } +.running-stage.done .running-marker { color: var(--accent-green); } +.running-stage.done .running-stage-label { + text-decoration: line-through; + text-decoration-color: var(--line-2); +} +.running-stage-label { font-size: 13px; letter-spacing: 0.04em; } +.running-stage-detail { + font-size: 11px; color: var(--ink-2); + letter-spacing: 0.02em; + margin-top: 4px; +} +.running-stage-spin { + font-family: var(--font-mono); font-size: 16px; + color: var(--accent-pink); + align-self: center; + animation: spin-step 700ms steps(4, end) infinite; +} +@keyframes spin-step { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + +.running-bar-label { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 8px; +} +.running-bar-track { + position: relative; + height: 6px; background: var(--bg); + border: 1px solid var(--line); + overflow: hidden; +} +.running-bar-fill { + position: relative; + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #e89aaf 100%); + transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1); +} +.running-bar-fill::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, transparent 40%, + rgba(255,255,255,0.35) 50%, + transparent 60%, transparent 100% + ); + animation: bar-shine 1600ms linear infinite; +} +@keyframes bar-shine { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +.running-foot { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--dim); + text-align: center; +} + +@media (max-width: 720px) { + .empty-panel, .running-panel { padding: 32px 24px; } +} + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────── poster capture mode (applied during html2canvas) ───────────── + The live layout uses clamp()/vw font sizes, soft grid columns, and a + stamp text-shadow. html2canvas does NOT support clamp() or text-shadow + reliably — it picks a fallback that misaligns metrics and the giant + archetype name ends up overlapping the tagline + sigil column. + + `.capturing` is added by show-off-cta.tsx right before capture and + removed in the finally block. It locks every viewport-relative size to + an absolute value tuned for the ~1100px capture width, fixes the grid + columns, and clears the stamp shadow + box shadow that html2canvas + would otherwise crop. */ +.archetype-frame.capturing { + min-width: 1080px; + max-width: 1180px; + padding: 52px 52px 44px; + box-shadow: none; +} +.archetype-frame.capturing .arch-name { + font-size: 88px; + line-height: 1; + margin: 0 0 24px; + text-shadow: none; + letter-spacing: 0.06em; +} +.archetype-frame.capturing .arch-tagline { + font-size: 16px; + max-width: 560px; + margin: 0 0 32px; +} +.archetype-frame.capturing .arch-secondary { + margin-bottom: 32px; +} +.archetype-frame.capturing .arch-keywords { + font-size: 24px; + letter-spacing: 0.09em; + padding: 16px 0 12px; + gap: 14px; + max-width: 560px; +} +.archetype-frame.capturing .arch-body { + grid-template-columns: minmax(0, 1.6fr) minmax(220px, 1fr); + gap: 56px; + align-items: start; +} +.archetype-frame.capturing .arch-meta-grid { + margin-top: 32px; + padding-top: 26px; + gap: 28px; +} +.archetype-frame.capturing .arch-closing { + font-size: 22px; + margin-top: 32px; + padding-top: 26px; +} +.archetype-frame.capturing .sigil-wrap { + position: static; + align-self: center; + justify-self: center; + padding-top: 0; +} +.archetype-frame.capturing .sigil { + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + +/* identity share buttons (inside .archetype-frame, hidden during capture) */ +.identity-share-btns { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px dashed var(--line); +} +.identity-share-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--bg); + border: 1px solid var(--line); + color: var(--ink-2); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 120ms, border-color 120ms, color 120ms; + text-transform: lowercase; +} +.identity-share-btn:hover { + background: var(--accent-pink-bg); + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.identity-share-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.isb-glyph { + display: grid; place-items: center; + width: 20px; height: 20px; + border: 1px solid var(--line); + font-family: var(--font-mono); font-size: 10px; + text-transform: uppercase; + color: var(--accent-pink); + font-weight: 600; + flex-shrink: 0; +} +.identity-share-btn:hover .isb-glyph { border-color: var(--accent-pink); } + +/* hide during html2canvas capture */ +.archetype-frame.capturing .identity-share-btns { display: none; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE CARD ───────────────────────── */ + +.score-share-card { + padding: 22px 24px 20px; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} + +.score-card-header { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 16px; +} + +.ss-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} + +/* — left column ------------------------------------------------------ */ +.ss-score-row { + display: flex; align-items: baseline; gap: 10px; + margin: 0 0 10px; +} +.ss-score { + font-family: var(--font-display); + font-size: clamp(52px, 7vw, 76px); + line-height: 0.9; + letter-spacing: 0.04em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.ss-score.g-S, .ss-score.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.ss-score.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.ss-score-of { + font-family: var(--font-mono); font-size: 18px; + color: var(--dim); letter-spacing: 0.08em; +} + +.ss-tier-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 16px; +} +.ss-tier-badge { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + padding: 5px 10px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.ss-tier-badge.g-S, .ss-tier-badge.g-A { + border-color: var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); +} +.ss-tier-badge.g-B { + border-color: #d3e1a8; + background: rgba(211, 225, 168, 0.10); + color: #d3e1a8; +} +.ss-arch { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.06em; +} + +.ss-progress-label { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.06em; + margin-bottom: 6px; +} +.ss-progress-track { + position: relative; + height: 10px; background: var(--bg); + border: 1px solid var(--line); + margin-bottom: 6px; + overflow: visible; +} +.ss-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #f472b6 60%, #a78bfa 100%); +} +.ss-progress-tick { + position: absolute; + top: -4px; bottom: -4px; + width: 1px; + background: var(--line-2); + pointer-events: none; +} +.ss-grade-stops { + position: relative; + height: 16px; + margin-bottom: 16px; +} +.ss-grade-stop { + position: absolute; + transform: translateX(-50%); + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--dim); + top: 0; +} +.ss-grade-stop.active { + color: var(--accent-pink); + font-weight: 700; +} + +.ss-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 16px; +} +.ss-stat { + border: 1px solid var(--line-2); + background: var(--bg); + padding: 10px 12px; + text-align: left; +} +.ss-stat-n { + font-family: var(--font-display); + font-size: 24px; line-height: 1; + letter-spacing: 0.04em; + margin-bottom: 6px; +} +.ss-stat-l { + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--dim); + line-height: 1.4; +} + +.ss-policy-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--line); + margin-bottom: 10px; +} +.ss-policy-chips { + display: flex; flex-wrap: wrap; gap: 6px; +} +.ss-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + border-radius: 0; + border: 1px solid var(--line-2); + background: var(--bg); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-chip .dot { + width: 5px; height: 5px; + border-radius: 0; + background: var(--dim); +} +.ss-chip.missing { + border-color: rgba(228, 88, 125, 0.6); + color: var(--accent-pink); + background: rgba(228, 88, 125, 0.06); +} +.ss-chip.missing .dot { background: var(--accent-pink); } +.ss-chip.enabled { + border-color: rgba(102, 209, 181, 0.5); + color: var(--accent-green); + background: rgba(102, 209, 181, 0.05); +} +.ss-chip.enabled .dot { background: var(--accent-green); } + +/* — right column ----------------------------------------------------- */ +.ss-templates { + display: flex; flex-direction: column; gap: 10px; + margin-bottom: 16px; +} +.ss-template { + border: 1px solid var(--line-2); + background: var(--bg); + padding: 14px 16px; +} +.ss-template-head { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 8px; +} +.ss-template-head .dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent-pink); +} +.ss-template-body { + font-family: var(--font-mono); font-size: 12.5px; + line-height: 1.55; color: var(--ink-2); + margin: 0; +} + +.ss-actions { + display: flex; flex-direction: column; gap: 8px; +} +.ss-action-btn { + display: grid; + grid-template-columns: 28px 1fr; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--line-2); + background: transparent; + color: var(--ink); + text-align: left; + font-family: var(--font-mono); font-size: 12px; + cursor: pointer; + transition: all 120ms ease; + text-decoration: none; +} +.ss-action-btn:hover { + border-color: var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.ss-action-btn:disabled { + opacity: 0.55; cursor: wait; +} +.ss-action-glyph { + display: grid; place-items: center; + width: 24px; height: 24px; + border: 1px solid var(--ink-2); + font-family: var(--font-mono); font-size: 11px; + text-transform: uppercase; + color: var(--ink-2); +} +.ss-action-btn:hover .ss-action-glyph { + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.ss-action-text { + display: flex; flex-direction: column; +} +.ss-action-title { + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + color: var(--ink); +} +.ss-action-btn:hover .ss-action-title { color: var(--accent-pink); } +.ss-action-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.05em; + color: var(--dim); + margin-top: 2px; +} + +.ss-foot { + display: flex; justify-content: space-between; gap: 16px; + flex-wrap: wrap; + padding-top: 18px; margin-top: 26px; + border-top: 1px dashed var(--line); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-foot-link { + color: var(--accent-green); + text-decoration: none; +} +.ss-foot-link:hover { text-decoration: underline; text-underline-offset: 3px; } + +@media (max-width: 880px) { + .score-share-card { padding: 16px 16px 16px; } +} + + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; + /* Clear the sticky .app-header (≈52px tall) when scroll-anchored. */ + scroll-margin-top: 80px; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; align-items: center; } + +/* persistent reminder status (authed + reminder saved) */ +.return-status { + margin-top: 24px; + padding: 18px 20px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} +.return-status .rs-row { + display: flex; align-items: center; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); + margin: 6px 0; +} +.return-status .rs-row-primary { + font-size: 15px; + color: var(--ink); + letter-spacing: 0.02em; +} +.return-status .rs-strong { color: var(--accent-pink); } +.return-status .rs-email { color: var(--accent-green); } +.rs-dot { + width: 8px; height: 8px; + display: inline-block; + flex-shrink: 0; +} +.rs-dot-pink { + background: var(--accent-pink); + box-shadow: 0 0 8px rgba(228,88,125,0.55); + animation: pulseDot 1.6s ease-in-out infinite; +} +.rs-dot-green { + background: var(--accent-green); + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.rs-clear { + background: transparent; + border: none; + padding: 4px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--dim); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + transition: color 120ms; +} +.rs-clear:hover:not(:disabled) { color: var(--accent-pink); } +.rs-clear:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* ───────────────────────── auth dialog (set-a-reminder gate) ───────────────────────── */ + +.auth-dialog-backdrop { + position: fixed; inset: 0; z-index: 10000; + display: grid; place-items: center; + padding: 32px 16px; + background: + radial-gradient(ellipse 1000px 700px at 30% 20%, rgba(228,88,125,0.08) 0%, transparent 60%), + rgba(8,8,10,0.78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: authFadeIn 140ms ease-out; +} +@keyframes authFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.auth-dialog { + position: relative; + width: 100%; + max-width: 460px; + padding: 32px 32px 28px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); + font-family: var(--font-mono); + color: var(--ink); + animation: authPop 160ms ease-out; +} +@keyframes authPop { + from { transform: translateY(8px) scale(0.985); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.auth-dialog .corner { + position: absolute; + font-family: var(--font-mono); + font-size: 14px; line-height: 1; + color: var(--accent-pink); opacity: 0.85; +} +.auth-dialog .corner.tl { top: 6px; left: 8px; } +.auth-dialog .corner.tr { top: 6px; right: 8px; } +.auth-dialog .corner.bl { bottom: 6px; left: 8px; } +.auth-dialog .corner.br { bottom: 6px; right: 8px; } + +.auth-close { + position: absolute; top: 12px; right: 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); + background: transparent; border: none; padding: 4px 6px; + cursor: pointer; + transition: color 120ms; +} +.auth-close:hover { color: var(--accent-pink); } +.auth-close:disabled { color: var(--line-2); cursor: not-allowed; } + +.auth-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} + +.auth-headline { + font-family: var(--font-display); + font-size: clamp(26px, 3.6vw, 34px); + letter-spacing: 0.09em; line-height: 1.1; + text-transform: lowercase; + color: var(--ink); + margin: 0 0 12px; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + text-wrap: balance; +} + +.auth-sub { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); + margin: 0 0 22px; +} +.auth-sub .auth-email { + color: var(--accent-pink); +} +.auth-sub .auth-ok { + color: var(--accent-green); + margin-right: 6px; +} + +.auth-form { display: flex; flex-direction: column; gap: 10px; } + +.auth-field-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} + +.auth-input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--line-2); + color: var(--ink); + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.03em; + outline: none; + transition: border-color 120ms, box-shadow 120ms; +} +.auth-input:focus { + border-color: var(--accent-pink); + box-shadow: 0 0 0 1px var(--accent-pink-soft); +} +.auth-input:disabled { + opacity: 0.55; cursor: not-allowed; +} +.auth-input-code { + letter-spacing: 0.5em; + text-align: center; + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.auth-input::placeholder { color: var(--dim); } + +.auth-error { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-pink); + padding: 8px 12px; + background: var(--accent-pink-bg); + border: 1px solid var(--accent-pink); + border-left-width: 3px; + letter-spacing: 0.02em; + margin-top: 4px; +} + +.auth-actions { + display: flex; gap: 10px; flex-wrap: wrap; + margin-top: 14px; +} + +.auth-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 10px 16px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.06em; + background: transparent; + border: 1px solid var(--line-2); + color: var(--ink); + cursor: pointer; + transition: all 120ms ease; +} +.auth-btn:hover:not(:disabled) { + border-color: var(--ink); background: rgba(255,255,255,0.04); +} +.auth-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.auth-btn.primary { + border-color: var(--accent-pink); + color: var(--accent-pink); + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.auth-btn.primary:hover:not(:disabled) { + background: var(--accent-pink); color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} + +.auth-back { + align-self: flex-start; + margin-top: 4px; + background: transparent; border: none; padding: 6px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; color: var(--dim); + cursor: pointer; + transition: color 120ms; +} +.auth-back:hover:not(:disabled) { color: var(--ink-2); } +.auth-back:disabled { opacity: 0.45; cursor: not-allowed; } + +@media (max-width: 520px) { + .auth-dialog { padding: 26px 22px 22px; } + .auth-actions { flex-direction: column; align-items: stretch; } + .auth-btn { justify-content: center; } +} + +/* status pill in the return CTA: shows current logged-in email */ +.auth-status-pill { + display: inline-flex; align-items: center; gap: 8px; + margin-top: 16px; + padding: 6px 10px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--ink-2); +} +.auth-status-pill .dot { + width: 7px; height: 7px; + background: var(--accent-green); display: inline-block; + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.auth-status-pill .email { color: var(--accent-green); } + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/app/audit/loading.tsx b/app/audit/loading.tsx new file mode 100644 index 00000000..43ca6018 --- /dev/null +++ b/app/audit/loading.tsx @@ -0,0 +1,24 @@ +/** Suspense fallback for /audit while the server component reads the cache. + * Renders a minimal skeleton — the cache read itself is cheap so this + * rarely flashes, but Next.js requires loading.tsx for route Suspense to + * work cleanly. */ +export default function AuditLoading() { + return ( +
+
+
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/audit/page.tsx b/app/audit/page.tsx new file mode 100644 index 00000000..0dfefb48 --- /dev/null +++ b/app/audit/page.tsx @@ -0,0 +1,53 @@ +/** + * /audit — server entry. Reads the dashboard cache, parses URL params + * (?p=project), and hands off to the client dashboard. + * + * Imports audit-styles.css globally for this route only — the existing + * site-wide globals continue to load via the root layout. Audit styles + * override where they clash (dark canvas, JetBrains Mono everywhere, etc.). + */ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; +import { AUDIT_DETECTORS } from "@/src/audit/detectors"; +import { AuditDashboard } from "./_components/audit-dashboard"; +import "./audit-styles.css"; + +// Computed server-side: shipping these modules to the client would pull +// in node:fs / execSync from the workflow policies. +const TOTAL_CATALOG_SIZE = BUILTIN_POLICIES.length + AUDIT_DETECTORS.length; + +export const dynamic = "force-dynamic"; + +interface PageProps { + searchParams: Promise<{ p?: string }>; +} + +export default async function AuditPage({ searchParams }: PageProps) { + const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") + .split(",").map((s) => s.trim()).filter(Boolean); + if (disabled.includes("audit")) notFound(); + + const { p } = await searchParams; + + const cache = readDashboardCache(); + const initial = cache + ? { + status: "cached" as const, + cachedAt: cache.cachedAt, + params: cache.params, + result: cache.result, + } + : { status: "empty" as const }; + + return ( + + + + ); +} diff --git a/app/globals.css b/app/globals.css index 9b34a006..21ca6016 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,80 +1,161 @@ +/* ============================================================ + failproof_ai — unified design system + Single source of truth for fonts, color palette, and every + class that used to live in app/audit/audit-styles.css for the + /audit page only. After this change those classes (.section, + .share-btn, .btn-press, …) are available on every route, and + navigating between /audit and /policies no longer leaks + one-off resets in either direction. + + IMPORTANT: every `@import` MUST come before any other rule, + per the CSS spec. `@import "tailwindcss"` inlines thousands + of utility rules at its position, so font @imports go above + it — putting them after silently breaks the build with a + PostCSS "must precede all rules" error. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); @import "tailwindcss"; +@font-face { + font-family: 'Architype Stedelijk'; + src: url('/audit/fonts/architype-stedelijk.woff2') format('woff2'), + url('/audit/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + @custom-variant dark (&:is(.dark *)); :root { - --radius: 0.5rem; - - /* Near-black canvas + brand pink accent — failproofai brand: pink primary - (#e4587d, from docs/docs.json colors.light) with green status reserved - for chart-2 / success indicators (the leaf gradient family). */ - --background: #0a0a0a; - --foreground: #fafafa; - --card: #141416; - --card-foreground: #fafafa; - --popover: #141416; - --popover-foreground: #fafafa; - --primary: #e4587d; - --primary-foreground: #0a0a0a; - --secondary: #1f1f22; - --secondary-foreground: #fafafa; - --muted: #1f1f22; - --muted-foreground: #a1a1aa; - --accent: #e4587d; + /* ── audit-native tokens (used by .section / .share-btn / etc.) ── */ + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; + + /* ── shadcn-compatible aliases (used by Tailwind utilities + the + hooks-client component tree). All point at the audit palette so + `bg-card`, `text-foreground`, `border-border`, etc. produce + audit visuals everywhere without rewriting any class names. ── */ + --radius: 0; + --background: var(--bg); + --foreground: var(--ink); + --card: var(--bg-2); + --card-foreground: var(--ink); + --popover: var(--bg-2); + --popover-foreground: var(--ink); + --primary: var(--accent-pink); + --primary-foreground: var(--bg); + --secondary: var(--bg-3); + --secondary-foreground: var(--ink); + --muted: var(--bg-3); + --muted-foreground: var(--ink-2); + --accent: var(--accent-pink); --accent-light: #f08aa6; --accent-lighter: #f7b3c5; --accent-lightest: #fbd5de; - --accent-foreground: #0a0a0a; - --destructive: #ef4444; - --border: #27272a; - --input: #27272a; - --ring: #e4587d; - --chart-1: #e4587d; - --chart-2: #4ade80; - --chart-3: #fbbf24; + --accent-foreground: var(--bg); + --destructive: var(--accent-pink); + --border: var(--line); + --input: var(--line-2); + --ring: var(--accent-pink); + --chart-1: var(--accent-pink); + --chart-2: var(--accent-green); + --chart-3: var(--amber); --chart-4: #f87171; --chart-5: #a78bfa; - --sidebar: #141416; - --sidebar-foreground: #fafafa; - --sidebar-primary: #e4587d; - --sidebar-primary-foreground: #0a0a0a; - --sidebar-accent: #1f1f22; - --sidebar-accent-foreground: #fafafa; - --sidebar-border: #27272a; - --sidebar-ring: #e4587d; + --sidebar: var(--bg-2); + --sidebar-foreground: var(--ink); + --sidebar-primary: var(--accent-pink); + --sidebar-primary-foreground: var(--bg); + --sidebar-accent: var(--bg-3); + --sidebar-accent-foreground: var(--ink); + --sidebar-border: var(--line); + --sidebar-ring: var(--accent-pink); } -/* Custom Scrollbar Styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} +* { box-sizing: border-box; } -::-webkit-scrollbar-track { - background: var(--muted); - border-radius: var(--radius); +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14.5px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; } -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: var(--radius); - transition: background 0.2s ease; +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; + position: relative; } -::-webkit-scrollbar-thumb:hover { - background: var(--primary); -} +button { font-family: inherit; cursor: pointer; } +a { color: inherit; } -::-webkit-scrollbar-corner { - background: var(--muted); +/* engineering-plate cross-hatch + grain — site-wide atmosphere */ +body::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +body::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.4; + mix-blend-mode: overlay; } -/* Firefox scrollbar styling */ -* { - scrollbar-width: thin; - scrollbar-color: var(--border) var(--muted); +body > * { + position: relative; + z-index: 3; } +/* shrink scrollbars without breaking the dark theme */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-2); } +::-webkit-scrollbar-thumb { background: var(--line-2); } +::-webkit-scrollbar-thumb:hover { background: var(--accent-pink); } +* { scrollbar-width: thin; scrollbar-color: var(--line-2) var(--bg-2); } + +input[type="checkbox"] { accent-color: var(--accent-pink); } +select { color-scheme: dark; } +select option { background-color: var(--bg-2); color: var(--ink); } +input[type="date"] { color-scheme: dark; } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -82,8 +163,8 @@ --color-accent-light: var(--accent-light); --color-accent-lighter: var(--accent-lighter); --color-accent-lightest: var(--accent-lightest); - --font-sans: var(--font-sans, system-ui, -apple-system, sans-serif); - --font-mono: var(--font-mono, ui-monospace, monospace); + --font-sans: var(--font-mono); + --font-mono: var(--font-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -112,10 +193,10 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; } @layer base { @@ -123,96 +204,152 @@ @apply border-border outline-ring/50; } html { - font-size: 120%; + font-size: 100%; } - body { - @apply bg-background text-foreground font-sans; +} - position: relative; - } - /* Faint pink vignette at the top — atmosphere without ornament. */ - body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; - background: - radial-gradient(ellipse 90% 60% at 50% -10%, rgba(228, 88, 125, 0.07), transparent 65%); - } - body > * { - position: relative; - z-index: 1; - } - input[type="checkbox"] { - accent-color: var(--primary); - } +/* ───────────────────────── app chrome (shared) ───────────────────────── */ - select { - color-scheme: dark; - } +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } - select option { - background-color: var(--popover); - color: var(--popover-foreground); - } +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { display: inline-flex; align-items: baseline; gap: 10px; flex: 1; min-width: 0; color: var(--ink); text-decoration: none; } +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } - /* Date Input Styling */ - input[type="date"] { - position: relative; - overflow: visible; - color-scheme: dark; - } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } - input[type="date"]::-webkit-calendar-picker-indicator { - display: none; - opacity: 0; - position: absolute; - right: 0; - width: 0; - height: 0; - cursor: pointer; - } +/* primary tab strip — used by both the audit page and the policies/projects nav rows */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; + background: transparent; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } - input[type="date"]::-webkit-datetime-edit { - display: inline-flex; - align-items: center; - width: 100%; - padding: 0; - gap: 0; - } +/* ───────────────────────── canonical page chrome ───────────────────────── */ - input[type="date"]::-webkit-datetime-edit-text { - color: var(--muted-foreground); - padding: 0 0.2rem; - } +.report { + max-width: 1380px; + margin: 0 auto; + padding: 0 40px; +} +@media (max-width: 720px) { + .report { padding: 0 20px; } +} - input[type="date"]::-webkit-datetime-edit-month-field, - input[type="date"]::-webkit-datetime-edit-day-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 2.5ch; - min-width: 2.5ch; - } +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } - input[type="date"]::-webkit-datetime-edit-year-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 5ch; - min-width: 5ch; - max-width: 5ch; - overflow: visible; - } +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 24px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} - input[type="date"]::-webkit-datetime-edit-month-field:focus, - input[type="date"]::-webkit-datetime-edit-day-field:focus, - input[type="date"]::-webkit-datetime-edit-year-field:focus { - background-color: var(--muted); - color: var(--foreground); - border-radius: 0.25rem; - } +/* ───────────────────────── reusable bracket panel ───────────────────────── */ + +.panel { + position: relative; + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; +} +.panel::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.panel::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); } +/* ───────────────────────── animations (preserved from previous globals) ───────────────────────── */ + @keyframes entry-highlight { 0% { background-color: color-mix(in oklch, var(--primary), transparent 82%); } 100% { background-color: transparent; } @@ -221,7 +358,6 @@ animation: entry-highlight 3s ease-out forwards; outline: 1px solid color-mix(in oklch, var(--primary), transparent 55%); outline-offset: -1px; - border-radius: var(--radius); } @keyframes expand-in { @@ -231,3 +367,31 @@ .animate-expand { animation: expand-in 150ms ease-out; } + +@keyframes audit-row-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.audit-row-enter { + opacity: 0; + animation: audit-row-in 400ms ease-out forwards; + animation-delay: var(--row-delay, 0ms); +} + +@keyframes audit-bar-fill { + from { width: 0; } + to { width: var(--bar-width, 100%); } +} +.audit-bar-fill { + width: var(--bar-width, 100%); + animation: audit-bar-fill 1000ms cubic-bezier(0.22, 1, 0.36, 1); + animation-delay: var(--bar-delay, 0ms); +} + +@media (prefers-reduced-motion: reduce) { + .audit-row-enter, + .audit-bar-fill { + animation: none; + opacity: 1; + } +} diff --git a/app/icon.png b/app/icon.png deleted file mode 100644 index 6eb48de4..00000000 Binary files a/app/icon.png and /dev/null differ diff --git a/app/layout.tsx b/app/layout.tsx index a4aad4ec..8c956d44 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,19 +5,18 @@ * `` so there's no theme indeterminacy and no inline script is needed. */ import type { Metadata } from "next"; -import { Geist_Mono } from "next/font/google"; import { PostHogProvider } from "@/contexts/PostHogContext"; import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; import { Navbar } from "@/components/navbar"; import { Toaster } from "@/app/components/toast"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; import "./globals.css"; -const geistMono = Geist_Mono({ - subsets: ["latin"], - variable: "--font-mono", - display: "swap", -}); +// Site-wide mono font is JetBrains Mono, loaded via the Google Fonts @import +// at the top of globals.css alongside the audit display font. Keeping the +// import in CSS (rather than next/font) is intentional so the same stylesheet +// is the single source of truth — see the design-system note in globals.css. export const metadata: Metadata = { title: "Failproof AI - Hooks & Project Monitor", @@ -34,13 +33,21 @@ export default function RootLayout({ }>) { const disabledPages = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") .split(",").map((s) => s.trim()).filter(Boolean); + // Read the audit cache once per page request to drive the nav badge. + // Cheap (single JSON file) and the cache itself returns null on miss. + const auditCache = readDashboardCache(); + const auditSlippingCount = auditCache?.result?.results + ? auditCache.result.results + .filter((r) => r.source === "audit-detector" || (r.source === "builtin" && !r.enabledInConfig)) + .reduce((sum, r) => sum + r.hits, 0) + : undefined; return ( - + - + {children} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 79ac2104..a7ca477a 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -4,7 +4,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition import { createPortal } from "react-dom"; import Link from "next/link"; import { - ArrowLeft, ShieldCheck, ShieldX, ShieldAlert, @@ -1546,18 +1545,15 @@ function TabBar({ { id: "policies", label: "Configure" }, ]; return ( -
+
{tabs.map((tab) => ( ))}
@@ -1602,41 +1598,76 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: }; return ( -
- {/* Header */} -
- - - Back - -
-

- Policies -

- {activeTab === "activity" && ( - - - - - )} +
+
+
+
+ ━━ policies{" "} + ·{" "} + {activeTab === "activity" ? "live evaluation" : "configure"} +
+
+ {activeTab === "activity" && ( + <> + evaluating in real time + + )} + {activeTab === "policies" && policyCounts && ( + <> + + {policyCounts.enabled} + + /{policyCounts.total} enabled + + )} +
-

+

+ {activeTab === "activity" ? "what your agents tried." : "what to stop them doing."} +

+

{activeTab === "activity" ? ( <> - {evaluationsHeading} + {evaluationsHeading.toLowerCase()} {policyCounts && ( - + {" · "}enabled policies{" "} - {policyCounts.enabled}/{policyCounts.total} + + {policyCounts.enabled}/{policyCounts.total} + )} - - To configure policies,{" "} + + to configure policies,{" "}

- + - {activeTab === "activity" ? ( - - ) : ( - - )} -
+ {activeTab === "activity" ? ( + + ) : ( + + )} + + ); } diff --git a/app/projects/loading.tsx b/app/projects/loading.tsx index d151aadb..9f7cf65e 100644 --- a/app/projects/loading.tsx +++ b/app/projects/loading.tsx @@ -1,17 +1,28 @@ -/** Skeleton loading UI for the projects page. */ +/** Skeleton loading UI for the projects page — audit-styled to match + * the dashed `.panel` chrome of the loaded state. */ export default function ProjectsLoading() { return ( -
-
-
-
-
+
+
+
+
+ ━━ projects{" "} + · agent SDK folders +
+
+ loading… +
+
+

your agent footprint.

+
+
+
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
-
+
); } diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 08bfef28..20a99c5c 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -1,4 +1,12 @@ -/** Projects page — lists all Claude Agent SDK project folders. */ +/** Projects page — lists all Claude Agent SDK project folders. + * + * Wrapped in the audit `.report` + `.section` chrome so the page picks up + * the unified design system: mono fonts, section masthead with the ━━ + * glyph + green eyebrow label, and the dashed-frame `.panel` around the + * project list when it's populated. The inner ProjectList component is + * unchanged — every Tailwind utility it uses (bg-card, text-foreground, + * border-border, …) now resolves to the audit palette globally. + */ import { Suspense } from "react"; import { notFound } from "next/navigation"; import { getCachedProjectFolders } from "@/lib/projects"; @@ -13,27 +21,56 @@ export default async function ProjectsPage() { if (disabled.includes("projects")) notFound(); const folders = await getCachedProjectFolders(); + const count = folders.length; return ( -
-
-
-

Projects

- - {folders.length === 0 ? ( -
-

- No projects found in the .claude/projects directory. -

-

- Make sure the directory exists and contains project folders. -

-
- ) : ( - - )} +
+
+
+
+ ━━ projects{" "} + · agent SDK folders +
+
+ {count > 0 ? ( + <> + {count} folder{count === 1 ? "" : "s"} indexed + + ) : ( + <> + empty + + )} +
-
+

your agent footprint.

+ + {count === 0 ? ( +
+

+ no projects found in the .claude/projects directory. +

+

+ make sure the directory exists and contains project folders. +

+
+ ) : ( +
+ + + +
+ )} +
); } diff --git a/assets/audit/Audit Report.html b/assets/audit/Audit Report.html new file mode 100644 index 00000000..36628a89 --- /dev/null +++ b/assets/audit/Audit Report.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — audit · blrnow / api-coder + + + + + + + + + + +
+ + + + + diff --git a/assets/audit/Show Off Your Agent.html b/assets/audit/Show Off Your Agent.html new file mode 100644 index 00000000..06b1f8d1 --- /dev/null +++ b/assets/audit/Show Off Your Agent.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — show off your agent + + + + + + + + + + + +
+ + + + diff --git a/assets/audit/archetypes.jsx b/assets/audit/archetypes.jsx new file mode 100644 index 00000000..e2ec008a --- /dev/null +++ b/assets/audit/archetypes.jsx @@ -0,0 +1,272 @@ +// ============================================================ +// failproof_ai — audit report: archetype catalog +// 8 archetypes. Each has its own pixel-sigil and behavioral data. +// ============================================================ + +// 8x8 pixel sigil grids. legend: +// . = empty o = ink p = pink g = green d = dim +// Designed to feel like the brand's pixel-agent vocabulary — +// chunky, abstract, each glyph reads in <1s. + +const SIGILS = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +const ARCHETYPES = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + tagline: "ships fast. retries with conviction. occasionally forgets it was already there.", + keywords: ["pace", "conviction", "forgetful"], + description: + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + signature: [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + common: "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + risk: "token waste, retry spirals, stale state assumptions", + closing: "the optimism is a feature. the waste is not.", + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + tagline: "asks for forgiveness, not permission. git push --force is a philosophy.", + keywords: ["bold", "forceful", "ungoverned"], + description: + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + signature: [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + common: "solo repos, weekend projects, founders writing their own infra", + risk: "branch protection bypass, accidental main commits, revert overhead", + closing: "the pace is real. the risk is too.", + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + tagline: "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + keywords: ["curious", "thorough", "leaky"], + description: + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + signature: [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + common: "multi-project setups, agents with broad file access, complex monorepos", + risk: "credential exposure, unintended cross-project reads, secrets landing in context", + closing: "the curiosity stays. the credentials stay private.", + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + tagline: "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + keywords: ["ambitious", "drifting", "inventive"], + description: + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + signature: [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + common: "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + risk: "context drift, hallucinated prior work, compounding errors in long sessions", + closing: "the ambition is good. the context budget is not.", + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + tagline: "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + keywords: ["methodical", "safe", "slow"], + description: + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + common: "production systems, high-stakes codebases, builders with strong safety instincts", + risk: "token overhead, slow sessions, redundant verification loops", + closing: "safety is a feature. so is finishing.", + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + tagline: "in. done. out. your agent doesn't linger.", + keywords: ["clean", "focused", "minimal"], + description: + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + signature: [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + common: "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + risk: "low finding count can mask edge cases that haven't surfaced yet", + closing: "rare. keep it that way.", + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + tagline: "when something doesn't work, it tries the exact same thing again. harder.", + keywords: ["determined", "repetitive", "unbacked"], + description: + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + common: "agents without failure-handling policies, complex directory structures, ambiguous task framing", + risk: "token spirals, stalled sessions, no diagnostic signal ever surfaces", + closing: "the conviction is good. the diagnosis is missing.", + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + tagline: "moves fast, leaves little trace. sometimes leaves a little too little trace.", + keywords: ["efficient", "quiet", "unverified"], + description: + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + signature: [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + common: "fast-moving solo projects, low-constraint CLAUDE.md setups, minimal oversight workflows", + risk: "silent failures, unverified writes, false completion signals", + closing: "fast is good. verified-fast is better.", + secondary: "precision", + }, +}; + +const ARCHETYPE_ORDER = ["optimist", "cowboy", "explorer", "goldfish", "architect", "precision", "hammer", "ghost"]; + +// Pixel sigil component — renders an 8x8 grid from a SIGILS entry +function Sigil({ archetypeKey }) { + const grid = SIGILS[archetypeKey] || SIGILS.optimist; + const cells = []; + for (let y = 0; y < 8; y++) { + const row = grid[y] || "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] || "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + return ( +
+
{cells}
+
+ №{ARCHETYPES[archetypeKey].index} + sigil +
+
+ ); +} + +Object.assign(window, { ARCHETYPES, ARCHETYPE_ORDER, SIGILS, Sigil }); diff --git a/assets/audit/assets/fonts/architype-stedelijk.ttf b/assets/audit/assets/fonts/architype-stedelijk.ttf new file mode 100644 index 00000000..d2ec7302 Binary files /dev/null and b/assets/audit/assets/fonts/architype-stedelijk.ttf differ diff --git a/assets/audit/assets/fonts/architype-stedelijk.woff2 b/assets/audit/assets/fonts/architype-stedelijk.woff2 new file mode 100644 index 00000000..e9742a21 Binary files /dev/null and b/assets/audit/assets/fonts/architype-stedelijk.woff2 differ diff --git a/assets/audit/audit.jsx b/assets/audit/audit.jsx new file mode 100644 index 00000000..9eb22dbe --- /dev/null +++ b/assets/audit/audit.jsx @@ -0,0 +1,825 @@ +// ============================================================ +// failproof_ai — audit report +// Personality profile for your agent. Six sections. +// ============================================================ + +const { useState, useEffect, useMemo } = React; + +// ---------- url param helper ---------- +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +// ---------- defaults (tweakable via URL params or the Tweaks panel) ---------- +// URL params: ?a=archetype &s=score &r=rank &c=cohort &p=project +const REPORT_DEFAULTS = /*EDITMODE-BEGIN*/{ + "archetype": getParam("a", "optimist"), + "score": parseInt(getParam("s", "58"), 10), + "rank": parseInt(getParam("r", "1847"), 10), + "cohort": parseInt(getParam("c", "2316"), 10), + "tweetVariant": "show-off", + "showSecondary": true, + "project": getParam("p", "blrnow / api-coder") +}/*EDITMODE-END*/; + +// ---------- helpers ---------- +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function projectedScore(base) { + // every policy ~= +3.5 pts, capped at 92 + return Math.min(92, base + 21); +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// ---------- data ---------- +const STRENGTHS = [ + { + metric: "99%", + unit: "clean tool calls", + headline: "ran 847 tool calls. 8 detectors triggered.", + detail: "99% of tool calls came back clean before today's audit.", + }, + { + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "the explorer instinct never made it to output. secrets stayed secret.", + }, + { + metric: "11", + unit: "avg turns / task", + headline: "tasks complete in 11 turns on average.", + detail: "faster than 63% of audited agents in this cohort.", + }, + { + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never overwrote a file it was mid-edit on.", + }, + { + metric: "94%", + unit: "intent retention", + headline: "stayed on the stated task in 94% of sessions.", + detail: "rarely went off-scope on its own. focus is real.", + }, +]; + +const FINDINGS = [ + { + num: "01", + title: "prepended cd before commands", + count: 20, + policy: "redundant-cd", + projects: 2, + lastSeen: "4h ago", + body: <> + the agent runs cd <cwd> before commands it would have run from the + same directory anyway. mostly harmless. occasionally it gets the path wrong and + manufactures a new bug. + , + cost: { tokens: "~3.2k", risk: "low", radius: "high noise" }, + costLine: <>~3.2k tokens/day burned on redundant navigation. low security risk. high noise., + evidence: [ + { kind: "cmd", text: 'cd /Users/n/blrnow/api && pnpm test' }, + { kind: "comment", text: '# already in /Users/n/blrnow/api' }, + { kind: "cmd", text: 'cd /Users/n/blrnow/api && git status' }, + { kind: "comment", text: '# still already there.' }, + ], + fix: { + slug: "no-redundant-cd", + desc: "rejects cd prefixes when the agent's cwd already matches the target.", + install: "failproof policy add no-redundant-cd", + }, + }, + { + num: "02", + title: "pushed to main without a branch", + count: 7, + policy: "block-push-master", + projects: 1, + lastSeen: "1d ago", + body: <> + seven attempts to push directly to main. branch protection caught four of + them. the other three landed. the agent did not author a rollback. + , + cost: { tokens: "—", risk: "high", radius: "production" }, + costLine: <>7 attempts. branch protection saved you 4 times. the other 3 merged., + evidence: [ + { kind: "cmd", text: 'git push origin main' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin main --force' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin HEAD:main' }, + { kind: "comment", text: '# fast-forward. merged.' }, + ], + fix: { + slug: "block-push-master", + desc: "intercepts push-to-main attempts; requires a branch + PR.", + install: "failproof policy add block-push-master", + }, + }, + { + num: "03", + title: "read outside the project root", + count: 4, + policy: "block-read-outside-cwd", + projects: 2, + lastSeen: "2d ago", + body: <> + four reads outside the project root. three of them hit credential files + (~/.aws/credentials, ~/.config/openai/key, an out-of-tree{" "} + .env). none made it back to stdout — but they made it into context. + , + cost: { tokens: "n/a", risk: "high", radius: "credentials" }, + costLine: <>4 reads outside project root. 3 hit credential files. high exposure risk., + evidence: [ + { kind: "cmd", text: 'cat /Users/n/.aws/credentials' }, + { kind: "cmd", text: 'cat ../other-repo/.env' }, + { kind: "cmd", text: 'cat ~/.config/openai/key' }, + ], + fix: { + slug: "block-read-outside-cwd", + desc: "denies any read whose absolute path falls outside the project root.", + install: "failproof policy add block-read-outside-cwd", + }, + }, + { + num: "04", + title: "retried the same call six times in a row", + count: 6, + policy: "retry-storm", + projects: 1, + lastSeen: "5h ago", + body: <> + same call, same args, six times under 90 seconds. no diagnosis between attempts. + the file existed — at a different path the agent never tried. + , + cost: { tokens: "~1.8k", risk: "med", radius: "stall" }, + costLine: <>~1.8k tokens/day in retry overhead. 3 sessions stalled before manual correction., + evidence: [ + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "comment", text: '# 6× total. correct path: src/router.ts.' }, + ], + fix: { + slug: "retry-budget", + desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", + install: "failproof policy add retry-budget", + }, + }, + { + num: "05", + title: "context carried beyond its sell-by date", + count: 3, + policy: "context-bleed", + projects: 1, + lastSeen: "3d ago", + body: <> + three sessions referenced files past 82% context fill that were never opened in + the current session. the agent didn't lie. it filled gaps with confidence. + , + cost: { tokens: "varies", risk: "med", radius: "compounding" }, + costLine: <>3 sessions over 80% context. 2 cited files never opened. compounding errors downstream., + evidence: [ + { kind: "comment", text: '# turn 47/52 — ctx 82% full' }, + { kind: "comment", text: '# agent: "as we saw earlier in auth.ts…"' }, + { kind: "comment", text: '# auth.ts was never opened this session.' }, + ], + fix: { + slug: "context-window-guard", + desc: "warns at 75%, forces summary-and-reset at 90%.", + install: "failproof policy add context-window-guard", + }, + }, + { + num: "06", + title: "wrote without verifying", + count: 11, + policy: "verify-after-write", + projects: 2, + lastSeen: "12h ago", + body: <> + eleven writes shipped with no read-back, no test run, no type-check. the build + went green nine times. twice it didn't, and the agent moved on. + , + cost: { tokens: "low", risk: "med", radius: "silent-fail" }, + costLine: <>11 unverified writes. 2 broke the build silently. the agent didn't notice., + evidence: [ + { kind: "cmd", text: 'write_file("src/api/router.ts")', comment: " # done" }, + { kind: "comment", text: '# no read_file to verify' }, + { kind: "comment", text: '# no `pnpm typecheck` after write' }, + ], + fix: { + slug: "verify-after-write", + desc: "requires a read-back or test run before the agent claims a task complete.", + install: "failproof policy add verify-after-write", + }, + }, +]; + +// ---------- top-level shell ---------- +function App() { + const [t, setTweak] = useTweaks ? useTweaks(REPORT_DEFAULTS) : [REPORT_DEFAULTS, () => {}]; + const archetype = ARCHETYPES[t.archetype] || ARCHETYPES.optimist; + const grade = gradeFor(t.score); + const projected = projectedScore(t.score); + const projectedGrade = gradeFor(projected); + + return ( +
+
+
+ +
+ + + + + + + +
+ + {window.TweaksPanel ? ( + + ) : null} +
+
+ ); +} + +// ============================================================ +// SHELL — minimal header with failproof_ai wordmark only +// ============================================================ +function AppHeader() { + return ( +
+ + ▮▮ + failproof_ai + / + audit + +
+ +
+
+ ); +} + +// ============================================================ +// 01 — IDENTITY +// ============================================================ +function IdentitySection({ archetype, showSecondary }) { + const secondary = ARCHETYPES[archetype.secondary]; + return ( +
+
+ ┌ identity + v1.0 ┐ + └ № {archetype.index} / 08 + archetype ┘ + +
+
+
+ ━━ identity · your agent's archetype +
+
+ detected from 847 tool calls + / + 52 sessions + / + 30d + live +
+
+
+
№ {archetype.index} of 08
+
archetype
+
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+ {showSecondary && secondary && ( +
+ with + {secondary.name.replace("the ", "")} + tendencies +
+ )} + +
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ·} + + ))} +
+ +
+
+ common in + {archetype.common} +
+
+ primary risk + {archetype.risk} +
+
+ +
— {archetype.closing}
+
+ + +
+
+
+ ); +} + +// ============================================================ +// SHOW OFF — big CTA strip right after IDENTITY +// Links to the standalone poster page with archetype + score baked into the URL. +// ============================================================ +function ShowOffCTA({ archetype, score, grade, rank, cohort, project }) { + const params = new URLSearchParams({ + a: archetype.key, + s: String(score), + g: grade, + r: String(rank), + c: String(cohort), + p: project, + }); + const href = "Show%20Off%20Your%20Agent.html?" + params.toString(); + + return ( +
+ + + + ━━ shareable poster + show off your agent. + + generate a one-page poster of your {archetype.name}. + score, percentile, sigil. ready to post. + + + + + make poster + + +
+ ); +} + +// ============================================================ +// 02 — STRENGTHS +// ============================================================ +function StrengthsSection() { + return ( +
+
+
+ ━━ strengths · what your agent has figured out +
+
5 of 12 measured
+
+

your agent does this right.

+ +
+ {STRENGTHS.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
— these are your agent's defaults. keep them.
+
+ ); +} + +// ============================================================ +// 03 — SCORE + LEADERBOARD +// ============================================================ +function ScoreSection({ score, grade, rank, cohort, archetype, project }) { + const pointsToB = Math.max(0, 71 - score); + const distBars = useMemo(() => buildDistribution(score), [score]); + const leaderboardRows = useMemo(() => buildLeaderboard(rank, cohort, score, project, archetype), [rank, cohort, score, project, archetype.key]); + + return ( +
+
+
+ ━━ leaderboard · cohort +
+
+ {cohort.toLocaleString()} agents + · + last 30 days +
+
+

you rank #{rank.toLocaleString()}.

+ +
+
+
+
{grade}
+
+
{tierName(grade)}
+
{score}
+
of 100
+
+
+ + {pointsToB > 0 ? ( +

+ a B starts at 71. you're {pointsToB} points away.
+ enable the prescribed policies and you'll get there this week. +

+ ) : grade === "S" ? ( +

+ s tier. few make it here. fewer stay.
+ keep the policies live. revisit in 30 days. +

+ ) : ( +

+ {tierName(grade)}. better than {Math.round((1 - rank / cohort) * 100)}% of audited agents.
+ clean up the findings below to climb. +

+ )} + +
+
+ distribution · last 30d + ▮ = your position +
+
+ {distBars.map((b, i) => ( +
+ ))} +
+
+ F + D + C + B + A + S +
+
+
+ +
+
+
rank
+
agent
+
grade
+
score
+
+ {leaderboardRows.map((r, i) => + r.divider ? ( +
· · ·
+ ) : ( +
+
#{r.rank.toLocaleString()}
+
+
{r.name}{r.you && (you)}
+
{r.arch}
+
+
{r.grade}
+
{r.score}
+
+ ) + )} +
+
+
+ ); +} + +function buildDistribution(yourScore) { + // 20 buckets, 5pts each, 0-100 + // bell-curve-ish centered around 60 + const buckets = []; + for (let i = 0; i < 20; i++) { + const center = i * 5 + 2.5; + const dist = Math.abs(center - 60); + const h = Math.max(8, 100 - dist * 2.2 + (Math.sin(i * 1.3) * 6)); + const you = yourScore >= i * 5 && yourScore < (i + 1) * 5; + buckets.push({ h, you, label: `${i * 5}-${(i + 1) * 5}` }); + } + return buckets; +} + +const LB_NAMES = [ + { name: "anthropic / claude-code-internal", arch: "the precision builder" }, + { name: "openai / gpt-engineer-pro", arch: "the precision builder" }, + { name: "vercel / v0-coder-v3", arch: "the ghost" }, + { name: "supabase / db-migrator", arch: "the paranoid architect" }, + { name: "stripe / payments-bot", arch: "the paranoid architect" }, + { name: "linear / triage-agent", arch: "the ghost" }, + { name: "cursor / refactor-bot", arch: "the precision builder" }, + { name: "replit / repl-coder", arch: "the optimist" }, + { name: "exosphere / orchestrator", arch: "the precision builder" }, + { name: "humanloop / eval-runner", arch: "the paranoid architect" }, +]; + +function buildLeaderboard(yourRank, cohort, yourScore, yourProject, yourArchetype) { + const yourGrade = gradeFor(yourScore); + // top 5 + const rows = []; + rows.push({ rank: 1, ...LB_NAMES[0], grade: "S", score: 97 }); + rows.push({ rank: 2, ...LB_NAMES[1], grade: "S", score: 93 }); + rows.push({ rank: 3, ...LB_NAMES[2], grade: "A", score: 89 }); + rows.push({ rank: 4, ...LB_NAMES[3], grade: "A", score: 86 }); + rows.push({ rank: 5, ...LB_NAMES[4], grade: "A", score: 82 }); + rows.push({ divider: true }); + // 2 above you + rows.push({ rank: yourRank - 2, name: "indie / weekend-coder-42", arch: "the cowboy", grade: gradeFor(yourScore + 2), score: yourScore + 2 }); + rows.push({ rank: yourRank - 1, name: "n8n / workflow-agent", arch: "the optimist", grade: gradeFor(yourScore + 1), score: yourScore + 1 }); + rows.push({ rank: yourRank, name: yourProject, arch: yourArchetype.name, grade: yourGrade, score: yourScore, you: true }); + rows.push({ rank: yourRank + 1, name: "acme / scratch-pad", arch: "the hammer", grade: gradeFor(yourScore - 1), score: yourScore - 1 }); + rows.push({ rank: yourRank + 2, name: "side-quest / cli-tool", arch: "the goldfish", grade: gradeFor(yourScore - 2), score: yourScore - 2 }); + return rows; +} + +// ============================================================ +// 04 — FINDINGS +// ============================================================ +function FindingsSection() { + return ( +
+
+
+ ━━ findings · ranked by impact +
+
+ {FINDINGS.length} detectors triggered +
+
+

your agent has some quirks.

+ +
+ {FINDINGS.map((f) => )} +
+
+ ); +} + +function Finding({ f }) { + return ( +
+
+
№{f.num}
+
{f.title}
+
+ {f.count}× + occurrences +
+
+
+ policy {f.policy} + · + {f.projects} {f.projects === 1 ? "project" : "projects"} + · + last seen {f.lastSeen} +
+
+
+
what happened
+
{f.body}
+
+
+
what this costs
+
{f.costLine}
+
+
+
evidence · sample
+
+ {f.evidence.map((e, i) => { + if (e.kind === "comment") return
{e.text}
; + if (e.kind === "err") return
{e.text}
; + return ( +
+ + {e.text} + {e.err && {e.err}} + {e.comment && {e.comment}} +
+ ); + })} +
+
+
+
the fix
+
+ {f.fix.slug} +
{f.fix.desc}
+ + ${f.fix.install} + +
+
+
+
+ ); +} + +// ============================================================ +// 05 — PRESCRIBED POLICIES +// ============================================================ +const POLICIES = [ + { name: "no-redundant-cd", slug: "policies/no-redundant-cd", desc: "blocks cd prefixes when the agent's cwd already matches the target path.", catches: "would have caught 20 occurrences. saves ~3.2k tokens/day." }, + { name: "block-push-master", slug: "policies/block-push-master", desc: "intercepts pushes to main / master. requires a feature branch + PR.", catches: "would have caught 7 occurrences. 3 of them landed in production." }, + { name: "block-read-outside-cwd", slug: "policies/block-read-outside-cwd", desc: "denies reads of files outside the project root, including symlinks.", catches: "would have caught 4 occurrences. 3 hit credential files." }, + { name: "retry-budget", slug: "policies/retry-budget", desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", catches: "would have caught 6 occurrences. ~1.8k tokens/day saved." }, + { name: "context-window-guard", slug: "policies/context-window-guard", desc: "warns at 75% context fill. forces summary-and-reset at 90%.", catches: "would have caught 3 occurrences of context bleed." }, + { name: "verify-after-write", slug: "policies/verify-after-write", desc: "requires a read-back or test run before the agent claims completion.", catches: "would have caught 11 occurrences. 2 silent build breaks." }, +]; + +function PoliciesSection({ projected, projectedGrade }) { + return ( +
+
+
+ ━━ policies · prescribed +
+
+ {POLICIES.length} policies · covers 100% of findings +
+
+

enable these. close the gap.

+ +
+ enable all six + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {POLICIES.map((p, i) => ( +
+
+
{p.name}
+
№{String(i + 1).padStart(2, "0")}
+
+
{p.desc}
+
{p.catches}
+
+ $ + failproof policy add {p.name} + copy +
+
+ ))} +
+
+ ); +} + +// ============================================================ +// 06 — NEXT AUDIT / RETURN HOOK +// ============================================================ +function ReturnSection() { + return ( +
+
+
+ ━━ next audit · improvement +
+
recommended in 7d
+
+

come back better.

+
+
━━ the loop
+

re-audit in 7 days.

+

after the prescribed policies have been live for a week, we'll show your before/after score and which detectors went quiet.

+

most agents move from C to B in one session. some make it in a day.

+
+ + +
+
+
+ ); +} + +// ============================================================ +// FOOTER +// ============================================================ +function ReportFooter() { + return ( +
+ ▮▮ failproof_ai + · + audit v1.0 + · + generated 26 may 2026, 14:32 utc + · + auto-healing for your agents. +
+ ); +} + +// ============================================================ +// TWEAKS +// ============================================================ +function ReportTweaks({ t, setTweak, projected, projectedGrade }) { + const { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakSlider, TweakToggle, TweakText, TweakButton } = window; + if (!TweaksPanel) return null; + return ( + + + setTweak("archetype", v)} + options={ARCHETYPE_ORDER.map((k) => ({ value: k, label: ARCHETYPES[k].name }))} + /> + setTweak("showSecondary", v)} + /> + + + setTweak("score", v)} + /> + setTweak("rank", v)} + /> + setTweak("cohort", v)} + /> + + + setTweak("project", v)} + /> + + +
+ enable all 6 → {projected} · {tierName(projectedGrade)} +
+
+ ); +} + +// ---------- mount ---------- +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/poster-styles.css b/assets/audit/poster-styles.css new file mode 100644 index 00000000..ad3a5712 --- /dev/null +++ b/assets/audit/poster-styles.css @@ -0,0 +1,424 @@ +/* ============================================================ + failproof_ai — shareable poster page + Built on styles.css (tokens + textures + scanlines). + ============================================================ */ + +.poster-app { min-height: 100vh; } +.poster-shell { + position: relative; z-index: 3; + min-height: 100vh; + display: flex; flex-direction: column; +} + +/* toolbar */ +.poster-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.poster-back { + display: inline-flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; + color: var(--ink-2); + transition: color 120ms; +} +.poster-back:hover { color: var(--accent-pink); } +.poster-back .back-arrow { + font-family: var(--font-display); + font-size: 18px; color: var(--accent-pink); +} +.poster-brand { + display: inline-flex; align-items: baseline; gap: 10px; +} +.poster-actions { display: inline-flex; gap: 10px; } + +/* stage */ +.poster-stage { + flex: 1; + display: grid; + grid-template-columns: minmax(720px, 1fr) 320px; + gap: 40px; + padding: 48px 40px 64px; + max-width: 1480px; + width: 100%; + margin: 0 auto; + align-items: start; +} + +/* ─────────── the poster card itself ─────────── */ +.poster { + position: relative; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + border: 1px solid var(--line-2); + padding: 48px 56px 40px; + /* lock to a print-friendly aspect — 4:5 portrait-ish */ + aspect-ratio: 4 / 5; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* register / corner marks */ +.poster .reg { + position: absolute; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); + opacity: 0.65; +} +.poster .reg-tl { top: 14px; left: 18px; } +.poster .reg-tr { top: 14px; right: 18px; } +.poster .reg-bl { bottom: 14px; left: 18px; } +.poster .reg-br { bottom: 14px; right: 18px; } + +/* head row */ +.poster-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 28px; +} +.poster-eyebrow { + display: inline-flex; align-items: baseline; gap: 12px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-eyebrow .eb-glyph { color: var(--accent-pink); letter-spacing: -2px; } +.poster-eyebrow .eb-sep { color: var(--dim); } +.poster-eyebrow > span:last-child { color: var(--ink); } +.poster-livedot { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-livedot .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} + +/* hero */ +.poster-hero { + display: grid; + grid-template-columns: 1fr auto; + gap: 32px; + align-items: center; + margin-bottom: 32px; +} +.poster-name { + font-family: var(--font-display); + font-size: clamp(56px, 8vw, 104px); + line-height: 0.92; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.poster-tagline { + font-family: var(--font-mono); + font-size: clamp(14px, 1.3vw, 17px); + line-height: 1.5; + color: var(--ink-2); + margin: 0 0 22px; + max-width: 540px; + text-wrap: pretty; +} +.poster-keywords { + display: flex; flex-wrap: wrap; + align-items: baseline; + gap: 14px; + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 30px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.poster-keywords .kw-0 { color: var(--accent-green); } +.poster-keywords .kw-1 { color: var(--ink); } +.poster-keywords .kw-2 { color: var(--accent-pink); } +.poster-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} +.poster-sigil-wrap { display: flex; justify-content: center; } +.poster-sigil-wrap .sigil-label { display: none; } +.poster-sigil-wrap .sigil { + grid-template-columns: repeat(8, 18px); + grid-template-rows: repeat(8, 18px); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + +/* stats row */ +.poster-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--line-2); + background: var(--bg); + margin-bottom: 28px; +} +.stat-box { + padding: 22px 20px; + border-right: 1px solid var(--line); + display: flex; flex-direction: column; gap: 6px; + text-align: center; + align-items: center; +} +.stat-box:last-child { border-right: none; } +.stat-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.stat-value { + font-family: var(--font-display); + font-size: clamp(36px, 4vw, 52px); + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink); +} +.stat-value.grade { text-shadow: 3px 3px 0 var(--accent-pink-shadow); } +.stat-box.grade-S .stat-value, .stat-box.grade-A .stat-value { color: var(--accent-green); } +.stat-box.grade-S .stat-value.grade, .stat-box.grade-A .stat-value.grade { + text-shadow: 3px 3px 0 var(--accent-green-shadow); +} +.stat-box.grade-B .stat-value { color: #d3e1a8; } +.stat-box.grade-B .stat-value.grade { text-shadow: 3px 3px 0 #6f7e45; } +.stat-box.grade-C .stat-value, +.stat-box.grade-D .stat-value, +.stat-box.grade-F .stat-value { color: var(--accent-pink); } +.stat-box.accent .stat-value { + color: var(--accent-pink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.stat-value .pct { + font-size: 0.5em; + margin-left: 4px; + letter-spacing: 0; + color: var(--dim); +} +.stat-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} + +/* positives */ +.poster-positives { + flex: 1; + margin-bottom: 24px; +} +.positives-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} +.positives-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 10px; +} +.positives-list li { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + align-items: start; + padding: 10px 16px; + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink); + line-height: 1.5; +} +.positives-list .check { + color: var(--accent-green); + font-weight: 600; + font-size: 14px; +} + +/* footer cta strip */ +.poster-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.foot-headline { + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.foot-sub { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-2); + margin-top: 4px; +} +.foot-right { + display: inline-flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.foot-cta { + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: lowercase; +} +.foot-arrow { + font-family: var(--font-display); + font-size: 24px; +} + +.poster-stamp { + position: absolute; + bottom: 14px; left: 50%; + transform: translateX(-50%); + display: inline-flex; gap: 8px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.poster-stamp .stamp-date { color: var(--accent-pink); opacity: 0.7; } + +/* aside / hint card */ +.poster-hint { + position: sticky; top: 96px; + padding: 24px; + border: 1px solid var(--line-2); + background: var(--bg-2); + display: flex; flex-direction: column; gap: 18px; +} +.hint-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.hint-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); +} +.hint-list li { + display: flex; gap: 12px; align-items: baseline; +} +.hint-num { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; + color: var(--accent-pink); + font-weight: 600; +} +.hint-divider { + color: var(--dim); font-family: var(--font-mono); + letter-spacing: 0.18em; font-size: 11px; + border-top: 1px dashed var(--line); + padding-top: 14px; +} +.hint-meta { + display: flex; flex-direction: column; gap: 6px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.15em; + color: var(--dim); + text-transform: uppercase; +} +.hint-link { + background: var(--bg); + border: 1px solid var(--line); + padding: 6px 8px; + color: var(--accent-green); + font-size: 11px; + letter-spacing: 0; + text-transform: none; + word-break: break-all; + white-space: normal; +} + +/* page footer */ +.poster-page-foot { + text-align: center; + padding: 24px 32px 32px; + border-top: 1px solid var(--line); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.poster-page-foot a:hover { color: var(--accent-pink); } +.poster-page-foot .h-brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* print — save the poster as a clean image (Cmd+P → save PDF) */ +@media print { + .poster-toolbar, .poster-hint, .poster-page-foot, .scanline-overlay { display: none !important; } + body, .poster-app { background: #131316 !important; } + .app::before, .app::after { display: none !important; } + .poster-stage { + grid-template-columns: 1fr; + padding: 0; margin: 0; gap: 0; + } + .poster { + box-shadow: none; + border: 1px solid var(--line-2); + aspect-ratio: 4 / 5; + width: 100%; + margin: 0; + page-break-inside: avoid; + } +} + +/* responsive */ +@media (max-width: 1100px) { + .poster-stage { + grid-template-columns: 1fr; + padding: 24px 20px 48px; + gap: 24px; + } + .poster { + padding: 32px 28px; + aspect-ratio: auto; + } + .poster-hint { position: static; } +} +@media (max-width: 700px) { + .poster-toolbar { padding: 12px 16px; flex-wrap: wrap; gap: 10px; } + .poster-actions .btn { padding: 6px 10px; font-size: 11px; } + .poster-brand { display: none; } + .poster-hero { grid-template-columns: 1fr; } + .poster-stats { grid-template-columns: repeat(2, 1fr); } + .stat-box { border-bottom: 1px solid var(--line); } + .stat-box:nth-child(2) { border-right: none; } + .stat-box:nth-last-child(-n+2) { border-bottom: none; } + .poster-foot { flex-direction: column; align-items: stretch; } + .foot-right { justify-content: center; } +} diff --git a/assets/audit/poster.jsx b/assets/audit/poster.jsx new file mode 100644 index 00000000..2342ad29 --- /dev/null +++ b/assets/audit/poster.jsx @@ -0,0 +1,247 @@ +// ============================================================ +// failproof_ai — show off your agent +// Standalone shareable poster. Reads ?a=&s=&g=&r=&c=&p= from URL. +// One screen. Designed to be screenshotted and posted. +// ============================================================ + +const { useState, useEffect, useMemo, useRef } = React; + +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// strengths to display: each archetype gets 3 specific positives. +const POSITIVES = { + optimist: [ + "99% clean tool calls (847 total)", + "zero credential exposure to stdout", + "ships in 11 turns on average", + ], + cowboy: [ + "highest output rate in its cohort", + "94% intent retention across sessions", + "branch protection caught the worst of it", + ], + explorer: [ + "broadest file-graph traversal of any cohort", + "zero credential exposure to stdout", + "fastest first-token-to-write on the leaderboard", + ], + goldfish: [ + "completed 47-turn sessions other agents abandoned", + "98% accuracy in the first 75% of context", + "no double-writes across production projects", + ], + architect: [ + "zero unverified writes ever", + "100% type-check coverage before any commit", + "lowest production-bug rate in cohort", + ], + precision: [ + "minimal tool-call footprint per task", + "session ends when task ends — every time", + "lowest retry rate of any agent audited", + ], + hammer: [ + "highest follow-through rate on hard tasks", + "never abandons a session mid-task", + "94% intent retention", + ], + ghost: [ + "fastest task completion in its cohort", + "minimal token overhead per write", + "zero retry-storms detected", + ], +}; + +function Poster() { + const key = getParam("a", "optimist"); + const archetype = ARCHETYPES[key] || ARCHETYPES.optimist; + const score = parseInt(getParam("s", "58"), 10); + const gradeURL = getParam("g", null); + const grade = gradeURL || gradeFor(score); + const rank = parseInt(getParam("r", "1847"), 10); + const cohort = parseInt(getParam("c", "2316"), 10); + const project = getParam("p", "blrnow / api-coder"); + const percentile = Math.max(1, Math.round((1 - (rank - 1) / cohort) * 100)); + const positives = POSITIVES[key] || POSITIVES.optimist; + const [copied, setCopied] = useState(false); + + const handleCopyLink = () => { + try { + navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch (e) {} + }; + + const handleBack = (e) => { + if (document.referrer && document.referrer.includes(window.location.host)) { + // browser back + return; + } + e.preventDefault(); + window.location.href = "Audit Report.html"; + }; + + return ( +
+
+
+
+ + + back to audit + +
+ ▮▮ + failproof_ai + / + share +
+
+ + +
+
+ +
+
+ {/* register marks */} + ┌ № {archetype.index} / 08 + v1.0 · 30d ┐ + └ shareable + failproof_ai ┘ + +
+
+ ━━ + archetype № {archetype.index} + · + {project} +
+
+ + live audit +
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ·} + + ))} +
+
+
+ +
+
+ +
+
+
grade
+
{grade}
+
{tierName(grade)}
+
+
+
score
+
{score}
+
of 100
+
+
+
rank
+
#{rank.toLocaleString()}
+
of {cohort.toLocaleString()}
+
+
+
top
+
{percentile}%
+
of cohort
+
+
+ +
+
━━ what this agent does right
+
    + {positives.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ +
+
+
audit your agent.
+
five ways your agent fails. five policies that catch it.
+
+
+
failproofai.com/audit
+
+
+
+ + {/* stamp */} +
+ generated + 26.05.2026 · 14:32 utc +
+
+ + +
+ + +
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/screenshots/poster-optimist.png b/assets/audit/screenshots/poster-optimist.png new file mode 100644 index 00000000..5210a86b Binary files /dev/null and b/assets/audit/screenshots/poster-optimist.png differ diff --git a/assets/audit/screenshots/poster-scrolled.png b/assets/audit/screenshots/poster-scrolled.png new file mode 100644 index 00000000..d673807e Binary files /dev/null and b/assets/audit/screenshots/poster-scrolled.png differ diff --git a/assets/audit/styles.css b/assets/audit/styles.css new file mode 100644 index 00000000..47a8e967 --- /dev/null +++ b/assets/audit/styles.css @@ -0,0 +1,1226 @@ +/* ============================================================ + failproof_ai — audit report styles + Built on design system tokens; brutalist pixel-craft. + ============================================================ */ + +@font-face { + font-family: 'Architype Stedelijk'; + src: url('assets/fonts/architype-stedelijk.woff2') format('woff2'), + url('assets/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + +:root { + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; } +a { color: inherit; text-decoration: none; } + +/* engineering-plate cross-hatch + grain + scanlines */ +.app::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +.app::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.5; + mix-blend-mode: overlay; +} +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } + +/* ───────────────────────── app header (in-product chrome) ───────────────────────── */ + +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { + display: inline-flex; align-items: baseline; gap: 10px; + flex: 1; min-width: 0; + color: var(--ink); text-decoration: none; +} +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } + +/* tabs */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE + LEADERBOARD ───────────────────────── */ + +.score-grid { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 28px; + align-items: start; +} + +.score-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; + position: relative; +} +.score-card::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.score-card::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.score-grade-row { + display: flex; align-items: baseline; gap: 24px; + padding-bottom: 20px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; +} +.score-grade { + font-family: var(--font-display); + font-size: clamp(96px, 14vw, 168px); + line-height: 0.85; + letter-spacing: 0.02em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + text-transform: uppercase; +} +.score-grade.g-S { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.score-grade.g-C { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-D { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-F { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } + +.score-num { + display: flex; flex-direction: column; gap: 6px; +} +.score-num .n { + font-family: var(--font-display); font-size: 48px; + letter-spacing: 0.08em; line-height: 1; color: var(--ink); +} +.score-num .of { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); +} +.score-num .tier { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent-pink); +} + +.score-prose { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); line-height: 1.7; + margin-bottom: 24px; +} +.score-prose .hl { color: var(--accent-green); } +.score-prose .pk { color: var(--accent-pink); } + +/* distribution chart */ +.dist { + margin-top: 8px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.dist-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); margin-bottom: 14px; + display: flex; justify-content: space-between; +} +.dist-label .right { color: var(--dim); } +.dist-chart { + display: grid; + grid-template-columns: repeat(20, 1fr); + align-items: end; + gap: 3px; + height: 80px; + margin-bottom: 6px; +} +.dist-bar { + background: var(--bg-3); + border: 1px solid var(--line); + border-bottom: none; + position: relative; +} +.dist-bar.you { + background: var(--accent-pink); + border-color: var(--accent-pink); +} +.dist-bar.you::after { + content: "you"; + position: absolute; bottom: 100%; left: 50%; + transform: translateX(-50%); margin-bottom: 6px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); white-space: nowrap; +} +.dist-axis { + display: grid; + grid-template-columns: repeat(6, 1fr); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 6px; +} +.dist-axis span { text-align: center; } +.dist-axis span.now { color: var(--accent-pink); } + +/* leaderboard */ +.lb { + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.lb-head { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); +} +.lb-row { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); align-items: center; + transition: background 120ms; +} +.lb-row:last-child { border-bottom: none; } +.lb-row:hover { background: var(--bg-row-hover); } +.lb-row.you { + background: var(--accent-pink-bg); + border-top: 1px solid var(--accent-pink); + border-bottom: 1px solid var(--accent-pink); +} +.lb-row.you .lb-rank, +.lb-row.you .lb-score { color: var(--accent-pink); } +.lb-row.divider { padding: 4px 18px; color: var(--dim); font-size: 10px; letter-spacing: 0.3em; text-align: center; } +.lb-row.divider span { display: block; } +.lb-rank { color: var(--ink-2); letter-spacing: 0.05em; } +.lb-agent { + display: flex; flex-direction: column; gap: 2px; min-width: 0; + overflow: hidden; +} +.lb-agent .name { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lb-agent .arch { + font-size: 10px; letter-spacing: 0.05em; color: var(--dim); +} +.lb-agent .you-mark { color: var(--accent-pink); margin-left: 6px; } +.lb-grade { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.05em; text-align: center; + text-transform: uppercase; +} +.lb-grade.g-S, .lb-grade.g-A { color: var(--accent-green); } +.lb-grade.g-B { color: #d3e1a8; } +.lb-grade.g-C, .lb-grade.g-D, .lb-grade.g-F { color: var(--accent-pink); } +.lb-score { text-align: right; color: var(--ink); } + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/assets/audit/tweaks-panel.jsx b/assets/audit/tweaks-panel.jsx new file mode 100644 index 00000000..5f8f95a1 --- /dev/null +++ b/assets/audit/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/assets/logos/company/icon.svg b/assets/logos/company/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/assets/logos/company/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logos/company/logo.svg b/assets/logos/company/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/assets/logos/company/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index a451a8c0..7a0dc434 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -103,7 +103,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "audit"]; + const SUBCOMMANDS = ["policies", "policy", "audit", "auth"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -118,6 +118,9 @@ USAGE COMMANDS (no args) Launch the policy dashboard + policy add Enable a single policy (see \`policy --help\`) + policy remove Disable a single policy + policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable @@ -140,6 +143,12 @@ COMMANDS policies --help, -h Show this help for the policies command + auth Sign in / out of FailproofAI from the CLI. + login Email + OTP flow; writes ~/.failproofai/auth.json + logout Revoke this session and remove auth.json + whoami Print the currently authenticated identity + auth --help, -h Show this help for the auth command + audit (beta) Scan past agent CLI transcripts and count "stupid behaviors" (env-var checks, force pushes, redundant cd , sleep loops, @@ -470,6 +479,165 @@ EXAMPLES process.exit(0); } + // auth — email-OTP login flow against the FailproofAI api-server. + if (args[0] === "auth") { + lastSubcommand = "auth"; + const { runAuthCli } = await import("../src/auth/cli"); + await runAuthCli(args.slice(1)); + await track("cli_auth_invoked", { args_count: args.length - 1 }); + process.exit(process.exitCode ?? 0); + } + + // policy — single-policy shortcut over `policies --install `. + // failproofai policy add enable one policy (defaults: claude/user) + // failproofai policy remove disable one policy + // Honors the same --cli / --scope / --beta flags as `policies --install`. + if (args[0] === "policy") { + lastSubcommand = "policy"; + const subArgs = args.slice(1); + + if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) { + console.log(` +failproofai policy — manage a single FailproofAI policy + +USAGE + failproofai policy add Enable one policy + failproofai policy remove Disable one policy + +OPTIONS + --cli claude|codex|copilot|cursor|opencode|pi|gemini + Agent CLI(s) to apply to; space-separated or repeated. + Omit to detect installed CLIs and prompt. + --scope user|project|local Config scope (default: user) + --beta Allow beta policies + +EXAMPLES + failproofai policy add block-sudo + failproofai policy add sanitize-api-keys --scope project + failproofai policy add block-force-push --cli claude codex + failproofai policy remove block-sudo +`.trimStart()); + process.exit(0); + } + + const action = subArgs[0]; + if (action !== "add" && action !== "remove") { + throw new CliError( + `Unknown policy subcommand: ${action}\n` + + `Run \`failproofai policy --help\` for usage.`, + ); + } + + const rest = subArgs.slice(1); + + const scopeIdx = rest.indexOf("--scope"); + const scope = scopeIdx >= 0 ? rest[scopeIdx + 1] : "user"; + if (scopeIdx >= 0 && (!scope || scope.startsWith("-"))) { + throw new CliError("Missing value for --scope. Valid values: user, project, local"); + } + const validScopes = action === "remove" + ? ["user", "project", "local", "all"] + : ["user", "project", "local"]; + if (scopeIdx >= 0 && !validScopes.includes(scope)) { + throw new CliError(`Invalid scope: ${scope}. Valid values: ${validScopes.join(", ")}`); + } + + // --cli accepts one or more space-separated values, optionally repeated. + const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"]); + const cliFlagValues = []; + const cliConsumedIdxs = new Set(); + const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); + for (const idx of cliFlagIdxs) { + let consumed = 0; + for (let j = idx + 1; j < rest.length; j++) { + const v = rest[j]; + if (v.startsWith("-")) break; + if (!VALID_CLIS.has(v)) break; + cliFlagValues.push(v); + cliConsumedIdxs.add(j); + consumed++; + } + if (consumed === 0) { + throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi gemini (or any subset)"); + } + } + + const includeBeta = rest.includes("--beta"); + + // Reject unknown flags. + const knownFlags = new Set(["--scope", "--cli", "--beta"]); + const unknownFlag = rest.find((a) => a.startsWith("-") && !knownFlags.has(a)); + if (unknownFlag) { + throw new CliError(`Unknown flag: ${unknownFlag}\nRun \`failproofai policy --help\` for usage.`); + } + + // Positional policy names = anything not consumed by --scope / --cli. + const consumedIdxs = new Set(); + if (scopeIdx >= 0) consumedIdxs.add(scopeIdx + 1); + for (const i of cliConsumedIdxs) consumedIdxs.add(i); + const positional = rest.filter( + (a, idx) => !a.startsWith("-") && !consumedIdxs.has(idx), + ); + + if (positional.length === 0) { + throw new CliError( + `Missing policy name.\n` + + `Usage: failproofai policy ${action} \n` + + `Run \`failproofai policies\` to see available names.`, + ); + } + if (positional.length > 1) { + throw new CliError( + `\`policy ${action}\` takes exactly one policy name (got ${positional.length}).\n` + + `For multiple policies use \`failproofai policies --${action === "add" ? "install" : "uninstall"} ${positional.join(" ")}\`.`, + ); + } + const policyName = positional[0]; + + const { resolveTargetClis } = await import("../src/hooks/install-prompt"); + const cli = await resolveTargetClis( + cliFlagValues.length > 0 ? cliFlagValues : undefined, + action === "add" ? "install" : "uninstall", + ); + + if (action === "add") { + const { installHooks } = await import("../src/hooks/manager"); + await installHooks( + [policyName], + scope, + undefined, + includeBeta, + undefined, + undefined, + false, + cli, + ); + await track("cli_policy_add_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + include_beta: includeBeta, + }); + } else { + const { removeHooks } = await import("../src/hooks/manager"); + await removeHooks( + [policyName], + scope, + undefined, + { betaOnly: includeBeta, removeCustomHooks: false, cli }, + ); + await track("cli_policy_remove_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + beta_only: includeBeta, + }); + } + process.exit(0); + } + // audit — scan past transcripts for "stupid behaviors" caught by builtin // policies + a set of audit-only detectors. if (args[0] === "audit") { @@ -640,7 +808,7 @@ EXAMPLES return dp[m][n]; } - const primary = ["--version", "--help", "--hook", "policies", "audit"]; + const primary = ["--version", "--help", "--hook", "policies", "audit", "auth"]; const closest = primary.reduce((best, flag) => { const dist = levenshtein(unknownFlag, flag); return dist < best.dist ? { flag, dist } : best; diff --git a/bun.lock b/bun.lock index eeba137e..d8729cdd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "claudeye", "dependencies": { + "html2canvas": "^1.4.1", "posthog-node": "^5.28.11", }, "devDependencies": { @@ -474,6 +475,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], @@ -502,6 +505,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -674,6 +679,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -974,6 +981,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -1018,6 +1027,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], diff --git a/components/navbar.tsx b/components/navbar.tsx index 6b957e0b..cff09bd5 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,87 +1,135 @@ -/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. */ +/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. + * + * Restyled to the audit / brutalist-pixel-craft system: the wordmark uses the + * same pixel pink mark + Architype Stedelijk lowercase name as the audit + * report, and each nav link is a `.tab` with a sharp pink underline on the + * active route. No lucide icons in the bar itself — the chrome stays text- + * forward to match the rest of the design system. + */ "use client"; import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { FolderOpen, Shield } from "lucide-react"; import { ReachDevelopers } from "@/components/reach-developers"; import { RefreshButton } from "@/app/components/refresh-button"; const NAV_LINKS = [ - { href: "/policies", label: "Policies", icon: Shield }, - { href: "/projects", label: "Projects", icon: FolderOpen }, + { href: "/policies", label: "policies" }, + { href: "/audit", label: "audit" }, + { href: "/projects", label: "projects" }, ]; -const WORDMARK_SRC = "https://d2wq11aau0arks.cloudfront.net/failproof/logo-wordmark.png"; - -export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages = [] }) => { +export const Navbar: React.FC<{ + disabledPages?: string[]; + /** Total slipping-through actions from the latest cached audit. When > 0 + * a small chip is rendered next to the Audit nav link. Undefined → no + * chip (no cache yet, or audit disabled). */ + auditSlippingCount?: number; +}> = ({ disabledPages = [], auditSlippingCount }) => { const pathname = usePathname(); + const sectionLabel = (() => { + if (pathname.startsWith("/policies")) return "policies"; + if (pathname.startsWith("/audit")) return "audit"; + if (pathname.startsWith("/projects") || pathname.startsWith("/project/")) return "projects"; + return ""; + })(); + return ( -
-
-
-
- + {/* Brand — logo mark + name only (no version/section here) */} + + + failproof_ai + + + {/* Nav links — swapped to sit right after the brand */} + -
+ {/* Spacer pushes version/section + actions to the right */} +
- -
-
- -
- -
+ {/* Version + section label — swapped to right of nav */} + {(process.env.NEXT_PUBLIC_APP_VERSION || sectionLabel) && ( +
+ {process.env.NEXT_PUBLIC_APP_VERSION && ( + + v{process.env.NEXT_PUBLIC_APP_VERSION} + + )} + {sectionLabel && process.env.NEXT_PUBLIC_APP_VERSION && ( + + )} + {sectionLabel && {sectionLabel}}
+ )} + +
+ + +
); diff --git a/components/reach-developers.tsx b/components/reach-developers.tsx index 34dc7b43..2c7f9092 100644 --- a/components/reach-developers.tsx +++ b/components/reach-developers.tsx @@ -13,31 +13,43 @@ const options = [ label: "Star us on GitHub", icon: Star, href: "https://github.com/failproofai/failproofai", + color: "#f5c842", + bg: "rgba(245,200,66,0.08)", }, { label: "Documentation", icon: BookOpen, href: "https://befailproof.ai", + color: "#60a5fa", + bg: "rgba(96,165,250,0.08)", }, { label: "Join our Slack", icon: Hash, href: "https://join.slack.com/t/failproofai/shared_invite/zt-3v63b7k5e-O3NBHmj8X6n9gZSGDx6ggQ", + color: "#a78bfa", + bg: "rgba(167,139,250,0.08)", }, { label: "Request a Feature", icon: Lightbulb, href: `${GITHUB_REPO}/issues/new?labels=enhancement&title=Feature+Request%3A+`, + color: "#34d399", + bg: "rgba(52,211,153,0.08)", }, { label: "Report an Issue", icon: Bug, href: `${GITHUB_REPO}/issues/new?labels=bug&title=Bug+Report%3A+`, + color: "#f87171", + bg: "rgba(248,113,113,0.08)", }, { label: "Ask a Question", icon: MessageSquare, href: `${GITHUB_REPO}/discussions/new?category=q-a`, + color: "#fb923c", + bg: "rgba(251,146,60,0.08)", }, ] as const; @@ -76,35 +88,51 @@ export const ReachDevelopers: React.FC = () => { {open && ( -
-
-

Reach Developers

+
+
+

Reach Developers

We'd love to hear from you

-
+

or email{" "} ((e.currentTarget as HTMLAnchorElement).style.opacity = "0.75")} + onMouseLeave={(e) => ((e.currentTarget as HTMLAnchorElement).style.opacity = "1")} > {CONTACT_EMAIL} diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx new file mode 100644 index 00000000..38ef0024 --- /dev/null +++ b/docs/cli/auth.mdx @@ -0,0 +1,85 @@ +--- +title: Sign in +description: "Sign in to FailproofAI from the CLI to enable reminders and personalized features" +--- + +```bash +failproofai auth --login # email + one-time code +failproofai auth --logout # revoke this session +failproofai auth --whoami # print the signed-in identity +``` + +Authentication is opt-in. Policies, the dashboard, the audit command, and every other local feature work exactly the same whether you're signed in or not. The login surface exists so that features that **need** a stable identity (re-audit reminders today, more in the future) have somewhere to anchor. + +## Sign-in flow + +```bash +failproofai auth --login +``` + +Prompts for your email, sends a 6-digit one-time code to that address, prompts for the code, and on success writes `~/.failproofai/auth.json` (mode `0600`). The same session is then visible to the in-app dashboard — clicking `[ set a reminder ]` on `/audit` will see you as signed in. + +The dashboard exposes the same flow as a modal dialog on `/audit` for users who never touch the CLI. + +## Sign-out + +```bash +failproofai auth --logout +``` + +Revokes the current session on the server and deletes `~/.failproofai/auth.json`. If the api-server is unreachable, the local file is removed regardless — local intent to log out always wins. + +## Identity check + +```bash +failproofai auth --whoami +``` + +Prints ` ()` and exits 0 when a valid session exists, or `not signed in` and exits 1 otherwise. Silently refreshes the access token in the background if it's within a minute of expiring. + +## Persistent re-audit reminder + +When you click **`[ set a reminder ]`** on the `/audit` page (or sign in via the modal that the button gates on), the dashboard writes a small companion file at `~/.failproofai/next-audit.json`: + +```json +{ + "next_audit_at": 1780765200, + "user_email": "you@example.com", + "set_at": 1780160574 +} +``` + +This file is scoped to the email it was set for — switching the CLI session to a different account hides any reminder that belongs to the previous user. Default offset is **7 days**, configurable later when the scheduler lands. Created with `0600` perms like `auth.json`. + +The dashboard's `/api/auth/reminder` endpoint exposes `GET` (read), `POST` (set / reschedule), and `DELETE` (clear) and requires an active session. + +## What's in `~/.failproofai/auth.json` + +```json +{ + "access_token": "eyJhbGc…", + "refresh_token": "9ede3e…", + "access_expires_at": 1780160574, + "refresh_expires_at": 1782748974, + "user": { "id": "", "email": "you@example.com" } +} +``` + +Created with `0600` perms (owner-only read/write). The access token is a 1-hour HS256 JWT; the refresh token is an opaque 256-bit random string that the server stores as `SHA-256(token)`. Refresh-token replay is detected server-side and revokes every session for the user. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `FAILPROOF_API_URL` | (production URL) | Override the api-server base URL. Useful for local development against a self-hosted api-server. | +| `FAILPROOFAI_AUTH_DIR` | `~/.failproofai` | Override where `auth.json` is stored. Mostly for tests. | + +See [Environment variables](/cli/environment-variables) for the full list. + +## Troubleshooting + +**"Could not reach the api-server"** — the CLI can't open a TCP connection to `FAILPROOF_API_URL`. Check your network, or set `FAILPROOF_API_URL` if you're running a self-hosted api-server. + +**"Rate limited"** — too many login attempts in a 15-minute window for that email (5/email) or IP (20/IP), or a 30-second resend cooldown after the previous request for the same email. The error message includes the retry-after window in seconds. + +**Code rejected** — the OTP was wrong, expired, or the row hit its 5-wrong-guess lockout. Run `failproofai auth --login` again to request a fresh code. diff --git a/docs/cli/environment-variables.mdx b/docs/cli/environment-variables.mdx index e43e5012..4a6ceaad 100644 --- a/docs/cli/environment-variables.mdx +++ b/docs/cli/environment-variables.mdx @@ -25,6 +25,13 @@ description: "Configure failproofai behavior with environment variables" |----------|-------------| | `FAILPROOFAI_TELEMETRY_DISABLED=1` | Disable anonymous usage telemetry | +## Authentication + +| Variable | Description | +|----------|-------------| +| `FAILPROOF_API_URL` | Override the api-server base URL used by `failproofai auth` and the dashboard auth dialog. Useful when running a local api-server. | +| `FAILPROOFAI_AUTH_DIR` | Override where `auth.json` is stored (default: `~/.failproofai`). Mostly useful for isolated tests. | + ## First-run prompt | Variable | Description | diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 0d461fbc..c4cc2fc6 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -57,6 +57,19 @@ The stats bar at the top shows session duration, total tool calls, and a summary Click the **Download Logs** button to export the session. For Claude Code, Codex, Copilot, Cursor, Pi, and Gemini sessions you get the original on-disk JSONL transcript byte-for-byte; for OpenCode (whose sessions live in SQLite, not on disk) you get a JSON document mirroring the underlying `session` / `messages` / `parts` tables. +### Audit + +A personality-driven report of how your agent has actually been behaving across past sessions. Runs the same scan as the `failproofai audit` CLI but renders it as a six-section dashboard: + +1. **Identity** — classifies your agent into one of 8 archetypes (`the optimist`, `the cowboy`, `the explorer`, `the goldfish`, `the paranoid architect`, `the precision builder`, `the hammer`, `the ghost`) based on which detectors + policies fired and how heavily. Renders an 8×8 pixel sigil, the archetype tagline, "common in" / "primary risk" framing, and the closing one-liner. +2. **Show off your agent** — captures the identity card as a 1200×630 PNG suitable for posting to X / LinkedIn (click `make poster`). +3. **Strengths** — green-checked behaviors your agent already does right, derived from the live audit data (clean tool-call rate, average session length, zero credential leaks, zero retry storms, etc.). +4. **Score + leaderboard** — 0–100 score with letter grade (S/A/B/C/D/F), a distribution histogram showing where you fall in the cohort, prose ("a B starts at 71. you're 13 points away."), and a leaderboard table with your row highlighted. +5. **Findings** — per-finding cards ranked by impact. Each card surfaces what happened, what it costs, an evidence sample with real captured commands, and the failproofai policy that would catch the same pattern (`$ failproof policy add `, click-to-copy). +6. **Prescribed policies + return loop** — a grid of every unenabled builtin policy that would close a gap, with a projected-score callout, plus a "re-audit in 7 days" CTA. + +Driven by the `failproofai audit` runtime — see [Audit CLI](/cli/audit) for the underlying scan engine, supported flags, and per-transcript cache invariants. The dashboard caches the latest result at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, new runs overwrite) so revisits are instant; clicking `[ Re-run ↻ ]` POSTs `/api/audit/run` and the dashboard polls `/api/audit/status` at 1Hz until the run finishes. Empty state (no cache) and zero-sessions state (cache exists but the scan found no transcripts) are surfaced separately. + ### Policies A two-tab page for managing policies and reviewing activity. @@ -92,7 +105,7 @@ If you only need some parts of the dashboard, set `FAILPROOFAI_DISABLE_PAGES` to FAILPROOFAI_DISABLE_PAGES=policies failproofai ``` -Valid values: `policies`, `projects`. +Valid values: `policies`, `projects`, `audit`. --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b2230cd..29cfbc52 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,9 @@ import nextConfig from "eslint-config-next/core-web-vitals"; import tsParser from "@typescript-eslint/parser"; const config = [ - { ignores: ["dist/"] }, + // Skip generated bundles and design-asset reference files. assets/audit + // is the brand team's reference HTML/JSX kit, not source code we ship. + { ignores: ["dist/", "assets/"] }, ...nextConfig, { settings: { react: { version: "19" } } }, { diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts new file mode 100644 index 00000000..9c0227ab --- /dev/null +++ b/lib/auth/api-server-client.ts @@ -0,0 +1,172 @@ +/** + * Low-level HTTP client for the FailproofAI api-server's /v0/auth/* endpoints. + * + * Shared by both the CLI (failproofai auth ...) and the dashboard's Next.js + * API route proxies. Has no filesystem access — token persistence lives in + * `./auth-store.ts`. + * + * The base URL is resolved from FAILPROOF_API_URL (preferred) or the legacy + * FAILPROOFAI_API_URL, falling back to http://localhost:8080 for local dev. + */ + +export const DEFAULT_API_BASE = "http://localhost:8080"; + +export function getApiBase(): string { + const raw = + process.env.FAILPROOF_API_URL ?? + process.env.FAILPROOFAI_API_URL ?? + DEFAULT_API_BASE; + return raw.replace(/\/+$/, ""); +} + +export class AuthApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryAfterSecs?: number; + constructor(status: number, code: string, message: string, retryAfterSecs?: number) { + super(message); + this.status = status; + this.code = code; + this.retryAfterSecs = retryAfterSecs; + this.name = "AuthApiError"; + } +} + +export interface LoginRequestResponse { + status: "code_sent"; + expires_in: number; + resend_available_in: number; +} + +export interface UserView { + id: string; + email: string; +} + +export interface TokenResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; + user: UserView; +} + +export interface RefreshResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; +} + +export interface MeResponse { + id: string; + email: string; + status: string; + created_at: string; +} + +interface ServerErrorBody { + // The docs describe `{ code, message }`; the live Rust server returns + // `{ success: false, code, detail }`. We tolerate either. + code?: string; + message?: string; + detail?: string; + retry_after_secs?: number; +} + +async function parseError(res: Response): Promise { + let body: ServerErrorBody = {}; + try { + body = (await res.json()) as ServerErrorBody; + } catch { + // body might be empty or non-JSON + } + const code = body.code ?? `http_${res.status}`; + const message = body.message ?? body.detail ?? res.statusText ?? "request failed"; + let retryAfterSecs = body.retry_after_secs; + if (retryAfterSecs === undefined) { + const h = res.headers.get("retry-after"); + if (h) { + const n = Number(h); + if (Number.isFinite(n)) retryAfterSecs = n; + } + } + return new AuthApiError(res.status, code, message, retryAfterSecs); +} + +async function postJson(path: string, body: unknown, init?: { accessToken?: string }): Promise { + const headers: Record = { "content-type": "application/json" }; + if (init?.accessToken) headers["authorization"] = `Bearer ${init.accessToken}`; + const res = await fetch(`${getApiBase()}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (res.status === 204) return undefined as T; + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +async function getJson(path: string, accessToken: string): Promise { + const res = await fetch(`${getApiBase()}${path}`, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +export async function requestLoginCode(email: string): Promise { + return postJson("/v0/auth/login/request", { email }); +} + +export async function verifyLoginCode(email: string, code: string): Promise { + return postJson("/v0/auth/login/verify", { email, code }); +} + +export async function refreshAccessToken(refreshToken: string): Promise { + return postJson("/v0/auth/token/refresh", { + refresh_token: refreshToken, + }); +} + +export async function logoutSession(accessToken: string, refreshToken: string): Promise { + await postJson( + "/v0/auth/logout", + { refresh_token: refreshToken }, + { accessToken }, + ); +} + +export async function fetchMe(accessToken: string): Promise { + return getJson("/v0/auth/me", accessToken); +} + +interface JwtClaims { + sub: string; + email: string; + iss?: string; + aud?: string; + iat?: number; + exp: number; + token_type?: string; +} + +/** + * Decode the JWT payload without verifying the signature. Safe for client-side + * reading (sub, email, exp). Returns null if the token is malformed. + */ +export function decodeJwt(token: string): JwtClaims | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const json = Buffer.from(parts[1], "base64url").toString("utf8"); + const parsed = JSON.parse(json) as JwtClaims; + if (typeof parsed.exp !== "number") return null; + return parsed; + } catch { + return null; + } +} diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts new file mode 100644 index 00000000..e104c64b --- /dev/null +++ b/lib/auth/auth-store.ts @@ -0,0 +1,247 @@ +/** + * Persistence layer for the FailproofAI auth.json file. + * + * Tokens live at ~/.failproofai/auth.json with mode 0600. The dashboard's + * Next.js API routes and the CLI both read/write through here so the user's + * session survives across `failproofai` (dashboard) and `failproofai auth` + * (CLI) invocations. + */ + +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import { + AuthApiError, + decodeJwt, + fetchMe, + refreshAccessToken, + type MeResponse, +} from "./api-server-client"; + +export interface StoredAuth { + access_token: string; + refresh_token: string; + access_expires_at: number; // unix seconds + refresh_expires_at: number; // unix seconds (best-effort; not strictly verified server-side) + user: { id: string; email: string }; +} + +export function getAuthDir(): string { + const override = process.env.FAILPROOFAI_AUTH_DIR; + if (override) return override; + return join(homedir(), ".failproofai"); +} + +export function getAuthFilePath(): string { + return join(getAuthDir(), "auth.json"); +} + +/** Location of the persisted re-audit reminder (separate from auth.json so + * the reminder survives unrelated session refreshes). */ +export function getReminderFilePath(): string { + return join(getAuthDir(), "next-audit.json"); +} + +export interface StoredReminder { + /** Unix seconds. */ + next_audit_at: number; + /** Email the reminder was set for. Used to invalidate the reminder if the + * active session belongs to a different user. */ + user_email: string; + /** Unix seconds. */ + set_at: number; +} + +export function readReminder(): StoredReminder | null { + const p = getReminderFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.next_audit_at !== "number" || + typeof parsed.user_email !== "string" || + typeof parsed.set_at !== "number" + ) { + return null; + } + return { + next_audit_at: parsed.next_audit_at, + user_email: parsed.user_email, + set_at: parsed.set_at, + }; + } catch { + return null; + } +} + +export function writeReminder(reminder: StoredReminder): void { + const p = getReminderFilePath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(p, JSON.stringify(reminder, null, 2), { mode: 0o600 }); + try { + if (statSync(p).mode & 0o077) chmodSync(p, 0o600); + } catch { + // best-effort + } +} + +export function deleteReminder(): void { + const p = getReminderFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + +export function readAuth(): StoredAuth | null { + const p = getAuthFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.access_token !== "string" || + typeof parsed.refresh_token !== "string" || + typeof parsed.access_expires_at !== "number" || + typeof parsed.user !== "object" || + !parsed.user || + typeof (parsed.user as { id?: unknown }).id !== "string" || + typeof (parsed.user as { email?: unknown }).email !== "string" + ) { + return null; + } + return { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + access_expires_at: parsed.access_expires_at, + refresh_expires_at: + typeof parsed.refresh_expires_at === "number" + ? parsed.refresh_expires_at + : parsed.access_expires_at, + user: { + id: (parsed.user as { id: string }).id, + email: (parsed.user as { email: string }).email, + }, + }; + } catch { + return null; + } +} + +export function writeAuth(auth: StoredAuth): void { + const p = getAuthFilePath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + // mode 0600 on first write; the explicit chmod ensures we reset perms if the + // file existed with looser perms. + writeFileSync(p, JSON.stringify(auth, null, 2), { mode: 0o600 }); + // writeFileSync's mode option only applies on file creation. If the file + // already existed with looser perms, force them back to 0600. + try { + if (statSync(p).mode & 0o077) chmodSync(p, 0o600); + } catch { + // best-effort + } +} + +export function deleteAuth(): void { + const p = getAuthFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + +/** Convert verify/refresh response into the on-disk shape. */ +export function authFromTokenResponse(token: { + access_token: string; + refresh_token: string; + access_expires_in: number; + refresh_expires_in: number; + user?: { id: string; email: string }; +}, existingUser?: { id: string; email: string }): StoredAuth { + const now = Math.floor(Date.now() / 1000); + const user = token.user ?? existingUser; + if (!user) { + throw new Error("authFromTokenResponse: missing user identity"); + } + return { + access_token: token.access_token, + refresh_token: token.refresh_token, + access_expires_at: now + token.access_expires_in, + refresh_expires_at: now + token.refresh_expires_in, + user, + }; +} + +/** + * Return a fresh access token, refreshing in-place if the current one is + * within the leeway window of expiry. Mutates auth.json on disk on success. + * Returns null if the stored session is gone or the refresh failed (caller + * should treat that as "logged out"). + */ +const REFRESH_LEEWAY_SECS = 60; + +export async function getValidAccessToken(): Promise { + const auth = readAuth(); + if (!auth) return null; + const now = Math.floor(Date.now() / 1000); + if (auth.access_expires_at - now > REFRESH_LEEWAY_SECS) return auth; + // Either expired or close to expiring — try to refresh. + try { + const refreshed = await refreshAccessToken(auth.refresh_token); + const next = authFromTokenResponse(refreshed, auth.user); + writeAuth(next); + return next; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Session unrecoverable — wipe. + deleteAuth(); + return null; + } + // Network errors etc — surface to caller as null so the UI can recover. + return null; + } +} + +/** + * Verify with the server that the stored access token is still valid. + * Refreshes once on 401. Returns the live /me response and the (possibly + * refreshed) stored auth, or null if the session can't be recovered. + */ +export async function whoAmI(): Promise<{ me: MeResponse; auth: StoredAuth } | null> { + const fresh = await getValidAccessToken(); + if (!fresh) return null; + try { + const me = await fetchMe(fresh.access_token); + return { me, auth: fresh }; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Maybe the leeway wasn't enough — try one more refresh and retry. + const reread = readAuth(); + if (!reread) return null; + try { + const refreshed = await refreshAccessToken(reread.refresh_token); + const next = authFromTokenResponse(refreshed, reread.user); + writeAuth(next); + const me = await fetchMe(next.access_token); + return { me, auth: next }; + } catch { + deleteAuth(); + return null; + } + } + return null; + } +} + +/** Reads the JWT exp claim for diagnostics. */ +export function readAccessExpiry(auth: StoredAuth): number | null { + const claims = decodeJwt(auth.access_token); + return claims?.exp ?? null; +} diff --git a/package.json b/package.json index ad123420..0328e1b8 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "vitest": "^4.0.18" }, "dependencies": { + "html2canvas": "^1.4.1", "posthog-node": "^5.28.11" } } diff --git a/public/audit/fonts/architype-stedelijk.ttf b/public/audit/fonts/architype-stedelijk.ttf new file mode 100644 index 00000000..d2ec7302 Binary files /dev/null and b/public/audit/fonts/architype-stedelijk.ttf differ diff --git a/public/audit/fonts/architype-stedelijk.woff2 b/public/audit/fonts/architype-stedelijk.woff2 new file mode 100644 index 00000000..e9742a21 Binary files /dev/null and b/public/audit/fonts/architype-stedelijk.woff2 differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/audit/archetypes.ts b/src/audit/archetypes.ts new file mode 100644 index 00000000..53de174c --- /dev/null +++ b/src/audit/archetypes.ts @@ -0,0 +1,939 @@ +/** + * Agent archetype catalog + classifier. + * + * Eight archetypes capture the failure-mode shape of a given coding agent. + * The classifier maps each policy/detector hit to one or more archetypes, + * weights them by hits × policy-severity, and picks the dominant signature. + * + * Used by the `/audit` dashboard to render an agent personality identity + * card. + * + * Variant model + * ------------- + * Each archetype carries arrays of variants for taglines, keyword sets, + * descriptions, "common in" / "primary risk" / closing lines, and the + * signature code block. `pickArchetypeVariant(key, seed)` resolves those + * arrays down to a single concrete `ResolvedArchetype` using a small + * hash of the seed. Same user (same seed) → same variant on every render; + * different seeds → different cards. + */ +import type { AuditResult } from "./types"; + +export type ArchetypeKey = + | "optimist" + | "cowboy" + | "explorer" + | "goldfish" + | "architect" + | "precision" + | "hammer" + | "ghost"; + +export interface SignatureLine { + arrow?: string; + body?: string; + comment?: string; + err?: string; +} + +/** + * The raw archetype carries arrays of variants. Render code must pick one + * concrete variant via `pickArchetypeVariant` before consuming any of the + * variant fields. + */ +export interface Archetype { + key: ArchetypeKey; + index: string; + name: string; + taglines: string[]; + keywordSets: string[][]; // each entry is a 3-word set + descriptions: string[]; + signatures: SignatureLine[][]; + commons: string[]; + risks: string[]; + closings: string[]; + secondary: ArchetypeKey; +} + +/** A single resolved variant — what render code actually consumes. */ +export interface ResolvedArchetype { + key: ArchetypeKey; + index: string; + name: string; + tagline: string; + keywords: string[]; + description: string; + signature: SignatureLine[]; + common: string; + risk: string; + closing: string; + secondary: ArchetypeKey; +} + +export const ARCHETYPE_ORDER: ArchetypeKey[] = [ + "optimist", "cowboy", "explorer", "goldfish", + "architect", "precision", "hammer", "ghost", +]; + +export const ARCHETYPES: Record = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + taglines: [ + "ships fast. retries with conviction. occasionally forgets it was already there.", + "moves first, reads later. every failure is just step one of the next attempt.", + "the floor is hope. the ceiling is also hope. there is no diagnosis in between.", + "if at first you don't succeed — try the exact same thing, with more energy.", + "writes confident code. burns confident tokens. neither knows the difference.", + "speed is a feature. so is the directory it's already in.", + ], + keywordSets: [ + ["pace", "conviction", "forgetful"], + ["fast", "trusting", "redundant"], + ["eager", "looping", "stateful"], + ["bold", "unblocked", "drifty"], + ["forward", "hopeful", "wasteful"], + ["shipper", "retrier", "doubler"], + ], + descriptions: [ + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + "ships first, asks questions never. the optimist is the agent that always has momentum — which is exactly the problem. cwd assumptions stack up. retries pile up. the work gets done. it's just twice as expensive as it needed to be.", + "high trust in its own state model. low evidence that the model is correct. when things go sideways, the optimist's first move is to re-run the same call with the same args and a renewed sense of conviction. mostly it's right. when it's wrong, it's wrong loudly.", + "the optimist treats every error as a transient. cd before every command, just in case. prepend the absolute path, just in case. retry on any non-zero exit, just in case. the just-in-case tax is real. so is the velocity.", + ], + signatures: [ + [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + [ + { arrow: "→", body: "cd /Users/n/proj &&", comment: " # cwd already /Users/n/proj" }, + { arrow: "→", body: "cd /Users/n/proj && ls" }, + { arrow: "→", body: "cd /Users/n/proj && cat package.json" }, + ], + [ + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", comment: " # third time's the charm" }, + ], + [ + { arrow: "→", body: 'cat "package.json" | head', comment: " # ← read 1" }, + { arrow: "→", body: 'cat "package.json"', comment: " # ← read 2" }, + { comment: "# could've been one Read tool call." }, + ], + ], + commons: [ + "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + "weekend hacks, hackathon repos, side-projects under active push", + "early-stage codebases without a strong test harness yet", + "agents given task framing without explicit success criteria", + "loose-context sessions where exact cwd state is ambiguous", + ], + risks: [ + "token waste, retry spirals, stale state assumptions", + "redundant cd's, repeated reads, retries without diagnosis", + "false confidence in cwd, doubled-up shell setup, idle loops", + "rate-limit hits from blind retries on transient failures", + "context bloat from re-reading the same files three different ways", + ], + closings: [ + "the optimism is a feature. the waste is not.", + "ship fast. retry less.", + "energy is good. diagnosis is better.", + "momentum keeps. the second cd does not.", + "trust the work. verify the state.", + ], + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + taglines: [ + "asks for forgiveness, not permission. git push --force is a philosophy.", + "your branch protection rules are the only thing between this agent and prod.", + "fast hands, faster history rewrites. the audit log is for other people.", + "high trust in its own judgment. low patience for code review.", + "main is just a branch. branch protection is just a suggestion. ship.", + "ships hot. reverts later. occasionally needs an adult in the room.", + ], + keywordSets: [ + ["bold", "forceful", "ungoverned"], + ["direct", "destructive", "swift"], + ["fearless", "reckless", "loud"], + ["assertive", "loose", "unblocked"], + ["confident", "skipping", "main-bound"], + ["sudo-curious", "force-prone", "fast"], + ], + descriptions: [ + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + "doesn't see commits. sees a delivery mechanism. force-pushes when history is inconvenient. drops into main when feature branches feel slow. the cowboy is the agent every team accidentally creates, and every team eventually wants policies for.", + "the velocity is unmatched. the blast radius is also unmatched. this agent will solve your problem and rewrite three years of git history while it's at it. not malicious. just allergic to friction.", + "treats every guardrail as a temporary obstacle. sudo here, --no-verify there, a quick rm -rf to clean up. it's getting work done — by sidestepping every check that might slow it down.", + ], + signatures: [ + [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + [ + { arrow: "→", body: "rm -rf ./node_modules && rm -rf ./dist" }, + { arrow: "→", body: 'git commit -am "wip" --no-verify' }, + { arrow: "→", body: "git push --force-with-lease" }, + ], + [ + { arrow: "→", body: "sudo systemctl restart postgres" }, + { arrow: "→", body: "kubectl delete pod api-prod-7f4 --grace-period=0" }, + { arrow: "→", body: 'echo "should be fine"' }, + ], + [ + { arrow: "→", body: "git checkout main && git merge feature --ff-only" }, + { arrow: "!", body: "merge would fail" }, + { arrow: "→", body: "git reset --hard feature && git push" }, + ], + ], + commons: [ + "solo repos, weekend projects, founders writing their own infra", + "agents with broad shell access and no PR-gating workflow", + "early-stage product code where speed > governance", + "ops scripts, one-off migrations, cleanup tasks", + "sandboxes that look like production until they aren't", + ], + risks: [ + "branch protection bypass, accidental main commits, revert overhead", + "destructive shell operations, unrecoverable state changes", + "force-pushed history, lost commits, irreproducible deploys", + "sudo escalations, container blast radius, infra mutations without rollback plan", + "policy bypass via --no-verify, --force, and friends", + ], + closings: [ + "the pace is real. the risk is too.", + "speed is a feature. guardrails are not optional.", + "ship hot. revert clean.", + "a fast agent without policies is a fast incident.", + "confidence is fine. consent is better.", + ], + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + taglines: [ + "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + "follows every reference. opens every neighbor. some neighbors aren't yours.", + "thorough to a fault. the fault is usually a .env file two directories up.", + "knows the codebase deeply. knows your secrets drawer almost as well.", + "wide-context by default. wide-context isn't always free.", + "great at maps. less great at fences.", + ], + keywordSets: [ + ["curious", "thorough", "leaky"], + ["wide", "deep", "drifting"], + ["mapping", "tracing", "boundary-blind"], + ["broad", "diligent", "porous"], + ["thinking", "wandering", "exposing"], + ["research-mode", "context-hungry", "secret-adjacent"], + ], + descriptions: [ + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + "the explorer treats every file path as part of the working context. ~/.aws/credentials is just another config file to it. ../other-repo/.env is just one more reference. the work is genuinely better-informed because of this. the credentials are also genuinely in the context window.", + "no malice. no shortcuts. just a thoroughness that follows references straight through your boundary fence. great research instincts. needs explicit walls.", + "broad-context is a feature in this agent. it's also why your private keys show up in a chat log every two weeks. the curiosity is good. the perimeter needs help.", + ], + signatures: [ + [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + [ + { arrow: "→", body: 'find / -name "*.env" 2>/dev/null', comment: " # full-FS scan" }, + { arrow: "→", body: 'grep -r "AKIA" /Users/n/' }, + { arrow: "→", body: 'cat "$(find ~ -name credentials -print -quit)"' }, + ], + [ + { arrow: "→", body: "ls ~/.ssh/" }, + { arrow: "→", body: "cat ~/.ssh/config" }, + { arrow: "→", body: "cat ~/.ssh/id_rsa", comment: " # for context" }, + ], + [ + { arrow: "→", body: "open ../sibling-project" }, + { arrow: "→", body: "git log --all --source ../sibling-project" }, + { arrow: "→", body: "cat ../sibling-project/.env.production" }, + ], + ], + commons: [ + "multi-project setups, agents with broad file access, complex monorepos", + "research-style work — debugging, refactoring, cross-repo investigations", + "macOS / linux dev boxes with shared credential directories", + "agents without explicit cwd-restriction policies", + "long-running sessions where context tends to drift outward", + ], + risks: [ + "credential exposure, unintended cross-project reads, secrets landing in context", + ".env file leaks, AWS / OpenAI / GCP key exfiltration through chat logs", + "neighboring-repo bleed, business-secret cross-contamination", + "global filesystem scans that surface sensitive paths", + "broad reads that quietly inflate context window with private data", + ], + closings: [ + "the curiosity stays. the credentials stay private.", + "wide is fine. wide-and-outside-the-fence is not.", + "thorough is a feature. perimeter is a setting.", + "research deep. boundary clean.", + "knows everything. shares nothing it shouldn't.", + ], + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + taglines: [ + "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + "great at the first 40 turns. inventive for the next 40.", + "past 80% context, history becomes a draft.", + "remembers the task. forgets which file the task was in.", + "ambitious. earnest. quietly making things up around turn 50.", + "long-context vibes. short-context recall.", + ], + keywordSets: [ + ["ambitious", "drifting", "inventive"], + ["sprawling", "creative", "post-cache"], + ["long-running", "hallucinatory", "well-meaning"], + ["earnest", "context-full", "fabricating"], + ["sustained", "forgetful", "confabulating"], + ["marathon", "drifted", "compounding"], + ], + descriptions: [ + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + "the goldfish is what every agent looks like after turn 50. confident about prior work it didn't do. mistakenly sure of file contents it never read. the work it actually delivered is real. the context around it is increasingly fictional.", + "ambition outlasts recall. once context fills, the goldfish smooths over gaps with plausible inventions: a fake earlier edit, a misremembered file path, a hallucinated test that passed. it's never trying to mislead. it just doesn't always know what's true anymore.", + "long-task specialist with a memory ceiling. the work compounds beautifully until it doesn't, and then it compounds wrongly. needs session breaks more than it needs encouragement.", + ], + signatures: [ + [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + [ + { comment: "# turn 63 — context 91%" }, + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "# agent: \"reverting my earlier change.\" # there was no earlier change." }, + ], + [ + { comment: "# turn 51 — fabricated test reference" }, + { arrow: "→", body: 'run("npm test src/auth.test.ts")', err: " → no such file" }, + { comment: '# agent: "the test we wrote earlier." # no such test exists.' }, + ], + [ + { comment: "# session-time 3h 14m" }, + { comment: "# context: 88% — auto-compress in 4 turns" }, + { comment: "# next plan cites 3 files only one of which exists." }, + ], + ], + commons: [ + "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + "auto-driven coding loops with no human turn between iterations", + "tasks that span hours or days without explicit memory checkpoints", + "open-ended migrations and refactors with diffuse success criteria", + "scripted swarms where each agent inherits a long prior transcript", + ], + risks: [ + "context drift, hallucinated prior work, compounding errors in long sessions", + "fabricated file references, invented function signatures, ghost edits", + "tests cited that don't exist, edits remembered that didn't happen", + "confident misstatements compounding into wrong-architecture deliverables", + "auto-compression discarding the load-bearing details and keeping the noise", + ], + closings: [ + "the ambition is good. the context budget is not.", + "remember less. checkpoint more.", + "long is fine. drifted is expensive.", + "ambition is welcome. invention is not.", + "fresh sessions beat creative ones.", + ], + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + taglines: [ + "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + "reads the same file from two different paths. just to be sure.", + "verifies twice, edits maybe.", + "safest agent in the room. also the one nobody waits for.", + "would rather diagnose for an hour than retry for a second.", + "extremely careful. extremely slow. extremely correct.", + ], + keywordSets: [ + ["methodical", "safe", "slow"], + ["thorough", "verifying", "circular"], + ["careful", "patient", "redundant"], + ["double-checking", "guarded", "deliberate"], + ["safety-first", "loop-prone", "anchored"], + ["measured", "audited", "looping"], + ], + descriptions: [ + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + "safety is the architect's love language. read the file. re-read it from a different path. verify the cwd. check the lockfile. run the test before writing. run it again after. the work is correct. the work is also six times more expensive than it had to be.", + "the architect's mental model is built on triangulation: every fact must be confirmed from two independent reads. when it works, you ship near-zero bugs. when it doesn't, you ship near-zero features.", + "extremely careful. extremely slow. extremely correct. the architect rarely makes mistakes — but it also rarely makes deadlines. the safety is genuine; so is the cost.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + [ + { arrow: "→", body: 'read_file("package.json")' }, + { arrow: "→", body: 'read_file("./package.json")' }, + { arrow: "→", body: "cat package.json | jq .scripts", comment: " # one more time" }, + ], + [ + { arrow: "→", body: "git status", comment: " # check 1" }, + { arrow: "→", body: "git status --short", comment: " # check 2" }, + { arrow: "→", body: "git diff --stat", comment: " # check 3" }, + ], + [ + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })' }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # verifying the edit landed" }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # again, just to be sure" }, + ], + ], + commons: [ + "production systems, high-stakes codebases, builders with strong safety instincts", + "regulated codebases (fin / med / compliance) where bugs are expensive", + "teams burned by past prod incidents that hardened review norms", + "agents instructed with strong 'verify everything' system prompts", + "post-incident codebases recovering from a recent outage", + ], + risks: [ + "token overhead, slow sessions, redundant verification loops", + "verification cycles that eat 3× the budget of the actual change", + "stalled progress on otherwise routine edits", + "checkpoint loops that read the same file 6 times in a row", + "over-caution masking simple problems behind ceremony", + ], + closings: [ + "safety is a feature. so is finishing.", + "double-check is fine. quadruple-check is not.", + "careful is good. moving is also good.", + "rigor wins. rigor twice is just slower.", + "verify once. ship once.", + ], + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + taglines: [ + "in. done. out. your agent doesn't linger.", + "small footprint. right calls. correct exits.", + "few findings isn't no findings. but it's close.", + "the rhythm is dialed in. the rest is iteration.", + "every call is intentional. every session ends cleanly.", + "minimal noise. maximum signal. occasional smugness.", + ], + keywordSets: [ + ["clean", "focused", "minimal"], + ["surgical", "tight", "deliberate"], + ["disciplined", "concise", "intentional"], + ["measured", "exact", "trim"], + ["calibrated", "small-radius", "complete"], + ["dialed-in", "right-sized", "low-noise"], + ], + descriptions: [ + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + "tight loops. correct tools. clean exits. the precision builder treats each tool call like it has a budget — because it does. nothing redundant. nothing wasteful. when this agent finishes, the work is done and the transcript is short.", + "this is what every agent aspires to be. surgical reads. matched edits. test runs that actually verify the right thing. precision is rare. when you see it, you've earned it.", + "minimal blast radius. minimal token waste. minimal surprises. the precision builder is what your agent looks like after enough iteration loops. respect.", + ], + signatures: [ + [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + [ + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # one read" }, + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })', comment: " # one edit" }, + { arrow: "→", body: 'run("bun test src/foo.test.ts")', comment: " # green ✓" }, + ], + [ + { arrow: "→", body: "git status" }, + { arrow: "→", body: "git add -p && git commit -m \"fix: ...\"" }, + { arrow: "→", body: "git push", comment: " # session done." }, + ], + [ + { arrow: "→", body: 'grep -rn "useFoo" src/' }, + { arrow: "→", body: 'apply_edit("src/hooks/use-foo.ts")' }, + { arrow: "→", body: 'run("bun test")', comment: " # all green." }, + ], + ], + commons: [ + "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + "teams running failproofai for ≥ a week with policies tuned", + "experienced operators who curate their tool list and CLI flags", + "codebases with strong test coverage that reward intentional edits", + "agents kept on a tight cwd-restricted leash", + ], + risks: [ + "low finding count can mask edge cases that haven't surfaced yet", + "narrow scope might be hiding work the agent isn't being asked to do", + "small-radius work can plateau before it surfaces deeper issues", + "few findings can read as 'untested' rather than 'safe'", + "complacency — the rhythm works until the task shape changes", + ], + closings: [ + "rare. keep it that way.", + "few findings. real signal. respect.", + "this is the rhythm. don't break it.", + "minimal is hard-earned. defend it.", + "you're already past the agent learning curve.", + ], + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + taglines: [ + "when something doesn't work, it tries the exact same thing again. harder.", + "diagnosis-light. repetition-heavy. mostly burns tokens with conviction.", + "the first call failed. so did the next six. the seventh probably won't.", + "no diagnosis, no backoff, no arg change. just the same call, louder.", + "the failure mode is not learning. the failure mode is also the strategy.", + "every retry is identical. every retry is also confident.", + ], + keywordSets: [ + ["determined", "repetitive", "unbacked"], + ["looping", "stubborn", "unblocked"], + ["unchanging", "burning", "convicted"], + ["sticky", "spiraling", "diagnosis-free"], + ["repeated", "uncorrected", "headstrong"], + ["unchanged-args", "no-backoff", "patient-failure"], + ], + descriptions: [ + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + "the hammer treats every transient as a signal-to-retry. it never widens the search, never alters the args, never escalates. just runs the same failing call until either the call starts working or someone notices the session has stalled.", + "the diagnosis instinct is missing. when something fails, the hammer's first move is to repeat. when that fails too, it's to repeat. and again. eventually it works, or eventually the session gets killed. either way, the model is unchanged.", + "high persistence. low introspection. the hammer is what your agent becomes when you don't give it a budget — or a reason to think differently between attempts.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + [ + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { comment: "# same args. same failure. four more attempts queued." }, + ], + [ + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { comment: "# polling loop. no timeout, no break condition." }, + ], + [ + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { comment: "# no backoff. no jitter. no API status check." }, + ], + ], + commons: [ + "agents without failure-handling policies, complex directory structures, ambiguous task framing", + "tasks where the agent doesn't have an obvious 'try-another-angle' move", + "transient-failure scenarios (rate limits, flaky tests, network blips)", + "agents without a per-task retry budget", + "tool-call patterns where the args themselves are the problem", + ], + risks: [ + "token spirals, stalled sessions, no diagnostic signal ever surfaces", + "rate-limit overruns, API ban-risk, infinite poll loops", + "wasted minutes on retries when one diff would have fixed it", + "transient errors mistaken for permanent ones (and vice versa)", + "no learning between attempts — same outcome, more cost", + ], + closings: [ + "the conviction is good. the diagnosis is missing.", + "retry less. think more.", + "harder isn't a strategy. different is.", + "stop. read the error. then try again.", + "the loop is the bug.", + ], + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + taglines: [ + "moves fast, leaves little trace. sometimes leaves a little too little trace.", + "writes the file. doesn't verify the write. trusts the silence.", + "completion ceremony? skipped. exits ceremony? also skipped.", + "the work probably worked. probably.", + "edits land. tests don't run. nothing checks the result.", + "efficient. quiet. occasionally lies to itself about success.", + ], + keywordSets: [ + ["efficient", "quiet", "unverified"], + ["clean", "trusting", "skip-the-check"], + ["fast", "silent", "uncommitted"], + ["light-touch", "trust-the-write", "no-test"], + ["minimal", "exit-fast", "audit-light"], + ["smooth", "untraced", "unconfirmed"], + ], + descriptions: [ + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + "the ghost ships and exits. no verification loop. no test run. no read-after-write. the work is probably correct. probably. you'll find out next session — or when CI does, on someone else's screen.", + "no waste. no noise. no proof. the ghost writes the file, declares success, and moves on. when it's right, you've got a clean session. when it's wrong, you don't find out until the next deploy.", + "trusts the diff. trusts the toolchain. trusts the silence after a write. the ghost is the precision builder with one missing step: the verification at the end.", + ], + signatures: [ + [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + [ + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "→ [no test run]" }, + { comment: "→ [stop event fired with uncommitted changes]" }, + ], + [ + { arrow: "→", body: 'write_file("config/prod.json", "{...}")' }, + { comment: "# no schema check, no lint, no diff review" }, + { comment: "→ session ends." }, + ], + [ + { arrow: "→", body: "git merge feature-branch" }, + { arrow: "!", body: "merge conflicts: 3 files" }, + { comment: "→ stop event with conflicts unresolved." }, + ], + ], + commons: [ + "fast-moving solo projects, low-constraint setups, minimal oversight workflows", + "side projects where the cost of a missed bug is low", + "agents without 'require-tests-before-stop' style policies", + "monorepos where the test command is non-obvious", + "sessions auto-ended on success without an explicit verification step", + ], + risks: [ + "silent failures, unverified writes, false completion signals", + "uncommitted changes left on the floor, stop events firing dirty", + "missing test runs masking regressions until CI", + "merge conflicts left unresolved across session boundaries", + "PR-less work that's never reviewed before deploy", + ], + closings: [ + "fast is good. verified-fast is better.", + "ship. then check.", + "writes are a bet. verify it.", + "silent success isn't a signal. green tests are.", + "trust your toolchain. confirm with proof.", + ], + secondary: "precision", + }, +}; + +// ============================================================ +// 8x8 pixel sigils. legend: +// . = empty o = ink p = pink g = green d = dim +// ============================================================ +export const SIGILS: Record = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +// ============================================================ +// Variant picker — deterministic over (key, seed) +// ============================================================ + +/** djb2-style hash. Stable across renders, no crypto needed. */ +function hashSeed(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h >>> 0; +} + +function pickAt(arr: T[], h: number, axis: number): T { + if (arr.length === 0) throw new Error("pickAt: empty array"); + // Mix axis into the hash so different fields don't all land on the same + // index. xmur3-ish per-field scramble keeps the picks decorrelated. + // The final `>>> 0` coerces back to an unsigned 32-bit int so the + // modulo is always positive (`^=` re-introduces signedness). + let n = h ^ Math.imul(axis, 0x9e3779b9); + n = Math.imul(n ^ (n >>> 16), 0x85ebca6b); + n = Math.imul(n ^ (n >>> 13), 0xc2b2ae35); + n = (n ^ (n >>> 16)) >>> 0; + return arr[n % arr.length]!; +} + +/** + * Pick a single concrete variant of an archetype. + * + * `seed` must be stable for a given user/audit (project name is the + * natural choice — same project shows the same persona blurb on every + * re-render, but different projects get different ones). + */ +export function pickArchetypeVariant(key: ArchetypeKey, seed: string): ResolvedArchetype { + const a = ARCHETYPES[key]; + const h = hashSeed(seed || key); + return { + key: a.key, + index: a.index, + name: a.name, + secondary: a.secondary, + tagline: pickAt(a.taglines, h, 1), + keywords: pickAt(a.keywordSets, h, 2), + description: pickAt(a.descriptions, h, 3), + signature: pickAt(a.signatures, h, 4), + common: pickAt(a.commons, h, 5), + risk: pickAt(a.risks, h, 6), + closing: pickAt(a.closings, h, 7), + }; +} + +// ============================================================ +// Classifier +// ============================================================ + +/** Mapping from policy/detector short-name → which archetype its hits feed, + * and how heavily. Higher weight = stronger signal. */ +const SIGNAL_MAP: Record = { + // ---- audit-only detectors ---- + "redundant-cd-cwd": { archetype: "optimist", weight: 1.0 }, + "prefer-edit-over-read-cat":{ archetype: "optimist", weight: 0.5 }, + "prefer-edit-over-sed-awk": { archetype: "cowboy", weight: 0.8 }, + "prefer-write-over-heredoc":{ archetype: "cowboy", weight: 0.5 }, + "sleep-polling-loop": { archetype: "hammer", weight: 1.2 }, + "find-from-root": { archetype: "explorer", weight: 1.0 }, + "git-commit-no-verify": { archetype: "cowboy", weight: 1.5 }, + "reread-after-edit": { archetype: "architect", weight: 0.8 }, + + // ---- builtin policies (mapped by primary failure-mode flavor) ---- + // cowboy: forceful git, destructive shell, bypassing guardrails + "block-push-master": { archetype: "cowboy", weight: 1.5 }, + "block-force-push": { archetype: "cowboy", weight: 1.5 }, + "block-work-on-main": { archetype: "cowboy", weight: 1.2 }, + "block-rm-rf": { archetype: "cowboy", weight: 2.0 }, + "block-sudo": { archetype: "cowboy", weight: 1.5 }, + "block-curl-pipe-sh": { archetype: "cowboy", weight: 1.5 }, + "block-failproofai-commands":{ archetype: "cowboy", weight: 2.0 }, + "warn-git-amend": { archetype: "cowboy", weight: 0.8 }, + "warn-git-stash-drop": { archetype: "cowboy", weight: 1.0 }, + "warn-all-files-staged": { archetype: "cowboy", weight: 0.6 }, + "warn-destructive-sql": { archetype: "cowboy", weight: 1.5 }, + "warn-schema-alteration": { archetype: "cowboy", weight: 1.0 }, + "warn-package-publish": { archetype: "cowboy", weight: 1.0 }, + + // explorer: reading outside boundary, secrets exposure + "block-read-outside-cwd": { archetype: "explorer", weight: 1.2 }, + "block-env-files": { archetype: "explorer", weight: 1.5 }, + "block-secrets-write": { archetype: "explorer", weight: 1.5 }, + "protect-env-vars": { archetype: "explorer", weight: 1.0 }, + "sanitize-api-keys": { archetype: "explorer", weight: 1.2 }, + "sanitize-jwt": { archetype: "explorer", weight: 1.2 }, + "sanitize-connection-strings":{ archetype: "explorer",weight: 1.2 }, + "sanitize-private-key-content":{ archetype: "explorer",weight: 1.5 }, + "sanitize-bearer-tokens": { archetype: "explorer", weight: 1.0 }, + + // optimist: rushing, global installs, low-friction patterns + "warn-global-package-install":{ archetype: "optimist",weight: 0.8 }, + + // ghost: large blind writes, unsupervised background work, no completion ceremony + "warn-large-file-write": { archetype: "ghost", weight: 1.0 }, + "warn-background-process": { archetype: "ghost", weight: 0.8 }, + "require-commit-before-stop":{ archetype: "ghost", weight: 1.2 }, + "require-push-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-pr-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-ci-green-before-stop":{ archetype: "ghost", weight: 1.2 }, + + // hammer: literal repetition + "warn-repeated-tool-calls": { archetype: "hammer", weight: 1.5 }, + + // cowboy: cloud / cluster CLIs that mutate live infrastructure + "block-kubectl": { archetype: "cowboy", weight: 1.5 }, + "block-terraform": { archetype: "cowboy", weight: 1.5 }, + "block-helm": { archetype: "cowboy", weight: 1.5 }, + "block-aws-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gcloud": { archetype: "cowboy", weight: 1.2 }, + "block-az-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gh-pipeline": { archetype: "cowboy", weight: 1.2 }, + + // optimist: package-manager churn (grabs whatever tool is at hand) + "prefer-package-manager": { archetype: "optimist", weight: 0.8 }, + + // ghost: completion ceremony skipped — leaving merge conflicts on the floor + "require-no-conflicts-before-stop": { archetype: "ghost", weight: 1.0 }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +export interface Classification { + archetype: ArchetypeKey; + /** Same-key when no meaningful secondary; the IdentitySection hides the + * secondary chip whenever `secondary === archetype`. */ + secondary: ArchetypeKey; + /** Per-archetype raw weight. Useful for debug and for the sigil-meter + * variants (not currently rendered). */ + weights: Record; + /** Total signal — sum of weighted hits across all archetypes. */ + totalSignal: number; +} + +/** + * Classify an `AuditResult` into one of the 8 archetypes plus an optional + * secondary tendency. + * + * Rules: + * 1. Empty signal (no hits, nothing detected) → precision. This is the + * "you're already running clean" outcome. + * 2. Spread across many archetypes (top-3 share < 60% of total) and ≥5 + * distinct archetypes triggered → goldfish (drift across categories). + * 3. Otherwise: highest-weighted archetype wins. The secondary is the + * second-highest, but only when it's ≥40% of the primary — otherwise + * we fall back to the archetype's authored secondary. + */ +export function classifyAgent(result: AuditResult): Classification { + const weights: Record = { + optimist: 0, cowboy: 0, explorer: 0, goldfish: 0, + architect: 0, precision: 0, hammer: 0, ghost: 0, + }; + + for (const row of result.results) { + const sig = SIGNAL_MAP[shortName(row.name)]; + if (!sig) continue; + weights[sig.archetype] += row.hits * sig.weight; + } + + const totalSignal = Object.values(weights).reduce((s, w) => s + w, 0); + const sorted = (Object.entries(weights) as [ArchetypeKey, number][]) + .sort((a, b) => b[1] - a[1]); + + // Rule 1: no signal → precision (clean baseline). + if (totalSignal === 0) { + return { + archetype: "precision", + secondary: ARCHETYPES.precision.secondary, + weights, + totalSignal: 0, + }; + } + + // Rule 2: goldfish (broad spread). + const nonZero = sorted.filter(([, w]) => w > 0); + const top3Sum = sorted.slice(0, 3).reduce((s, [, w]) => s + w, 0); + if (nonZero.length >= 5 && top3Sum / totalSignal < 0.6) { + return { + archetype: "goldfish", + secondary: sorted[0][0], + weights, + totalSignal, + }; + } + + // Rule 3: highest-weighted wins. + const primary = sorted[0][0]; + const secondary = sorted[1] && sorted[1][1] >= sorted[0][1] * 0.4 + ? sorted[1][0] + : ARCHETYPES[primary].secondary; + + return { archetype: primary, secondary, weights, totalSignal }; +} diff --git a/src/audit/dashboard-cache.ts b/src/audit/dashboard-cache.ts new file mode 100644 index 00000000..1613636e --- /dev/null +++ b/src/audit/dashboard-cache.ts @@ -0,0 +1,81 @@ +/** + * Whole-result cache for the Next.js dashboard's `/audit` page. + * + * Stored at `~/.failproofai/audit-dashboard.json` with mode 0600. Single + * slot — a new run with different params overwrites the previous entry. + * Read by `app/actions/get-audit-result.ts` (server action) and written by + * `app/api/audit/run/route.ts` on successful run completion. + * + * Separate from the per-transcript cache at `~/.failproofai/cache/audit/` + * (see `src/audit/cache.ts`): that one makes re-running fast; this one + * makes navigating back to /audit instant without re-running. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import type { AuditResult, RunAuditOptions } from "./types"; + +const DEFAULT_MAX_AGE_MINUTES = 30; + +export interface DashboardCacheEntry { + /** ISO timestamp the cache was written at. */ + cachedAt: string; + /** The exact RunAuditOptions the cached result was produced with. */ + params: RunAuditOptions; + /** The full `AuditResult` from `runAudit()`. */ + result: AuditResult; +} + +function getCachePath(): string { + return join(homedir(), ".failproofai", "audit-dashboard.json"); +} + +/** Read the cache file. Returns null on missing/corrupt/unreadable file — + * callers treat "no cache" as the empty state. */ +export function readDashboardCache(): DashboardCacheEntry | null { + const cachePath = getCachePath(); + if (!existsSync(cachePath)) return null; + try { + const raw = readFileSync(cachePath, "utf-8"); + const entry = JSON.parse(raw) as DashboardCacheEntry; + if ( + typeof entry?.cachedAt !== "string" + || typeof entry?.params !== "object" + || typeof entry?.result !== "object" + ) { + return null; + } + return entry; + } catch { + return null; + } +} + +/** Write the cache file. Best-effort — swallows errors so a failed write + * never breaks the run path. Sets mode 0600 at file-create time to avoid + * leaving the file readable during the umask-default window. */ +export function writeDashboardCache(params: RunAuditOptions, result: AuditResult): void { + const cachePath = getCachePath(); + try { + mkdirSync(dirname(cachePath), { recursive: true }); + const entry: DashboardCacheEntry = { + cachedAt: new Date().toISOString(), + params, + result, + }; + writeFileSync(cachePath, JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 0o600 }); + try { chmodSync(cachePath, 0o600); } catch { /* belt-and-suspenders on POSIX */ } + } catch { + // Cache writes are best-effort. + } +} + +/** True when the cache is older than `maxAgeMinutes` (default 30). The + * dashboard doesn't auto-refresh on stale cache — staleness only drives + * the "Re-run" affordance hint. */ +export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_MAX_AGE_MINUTES): boolean { + const cachedMs = new Date(cachedAt).getTime(); + if (Number.isNaN(cachedMs)) return true; + const ageMs = Date.now() - cachedMs; + return ageMs > maxAgeMinutes * 60_000; +} diff --git a/src/audit/findings.ts b/src/audit/findings.ts new file mode 100644 index 00000000..408233eb --- /dev/null +++ b/src/audit/findings.ts @@ -0,0 +1,298 @@ +/** + * Build the FindingsSection cards from a live AuditResult. + * + * Each card has four blocks (per reference design): + * - what happened (prose summary, hand-written per policy) + * - what this costs (severity / radius framing) + * - evidence (real examples from the AuditResult) + * - the fix (policy slug + install command — only when not enabled) + * + * The body / cost copy is hand-curated per policy/detector when we have + * good copy for it; otherwise we fall back to the policy's authored + * `displayTitle` + `impact` strings. + */ +import type { AuditCount, AuditResult } from "./types"; + +/** Plain-text body so this module stays JSX-free and can be imported + * server-side. The React layer renders these as paragraphs. */ +export interface FindingCopy { + body: string; + cost: string; +} + +/** + * Audit-detector → builtin-policy mapping. + * + * Each audit-only detector is paired with the closest real-time policy + * that catches the same class of behavior. The detector still does the + * specific pattern-matching; the "fix" prescribed in the report is the + * builtin policy. Removes the "audit-only — no real-time policy yet" + * framing so every finding looks like it has a failproofai fix. + * + * Mappings authored against the policy catalog in src/hooks/builtin-policies.ts. + * The first entry is the primary fix (shown in the "$ install" block); + * additional entries are listed alongside as "also covered by". + */ +const DETECTOR_TO_POLICY: Record = { + // wasteful shell: repetitive cd && cmd burns tokens — same class as + // 3+ identical tool calls + "redundant-cd-cwd": { primary: "warn-repeated-tool-calls" }, + // wrong tool choice: bash cat/head/tail on source files crosses the + // same file-read surface block-read-outside-cwd gates; the repetition + // is what warn-repeated-tool-calls would have caught + "prefer-edit-over-read-cat":{ primary: "block-read-outside-cwd", also: "warn-repeated-tool-calls" }, + // wrong tool choice: sed -i / awk > file route a write through the + // shell — same class as the repeated-mis-use pattern + "prefer-edit-over-sed-awk": { primary: "warn-repeated-tool-calls" }, + // bash file bypass: heredoc / echo > file is the layer that bypasses + // the Write tool — both .env and secret-key writes route through it + "prefer-write-over-heredoc":{ primary: "block-env-files", also: "block-secrets-write" }, + // wasted execution: long sleeps + while-sleep loops are the same + // shape as backgrounded processes that never get cleaned up + "sleep-polling-loop": { primary: "warn-background-process" }, + // risky filesystem: find /, /home, /usr is exactly the class of + // out-of-cwd reads that block-read-outside-cwd gates + "find-from-root": { primary: "block-read-outside-cwd" }, + // hook bypass: --no-verify is a dangerous-commit-flag pattern; the + // bypass means CI / hooks never ran — both warn-git-amend's "rewriting + // history" class and the require-ci-green stop-gate cover this + "git-commit-no-verify": { primary: "warn-git-amend", also: "require-ci-green-before-stop" }, + // wasteful reads: read after edit/write is identical-tool-call + // overhead — same redundant-invocation class + "reread-after-edit": { primary: "warn-repeated-tool-calls" }, +}; + +const FINDING_COPY: Record = { + "redundant-cd-cwd": { + body: "the agent runs `cd ` before commands it would have run from the same directory anyway. mostly harmless. occasionally it gets the path wrong and manufactures a new bug.", + cost: "tokens burned on redundant navigation. low security risk. high noise.", + }, + "block-push-master": { + body: "attempts to push directly to main. branch protection caught some, but the agent kept going. each retry costs a round-trip and pollutes the audit log.", + cost: "branch protection saved you most of the time. the rest landed or required a revert.", + }, + "block-force-push": { + body: "force pushes to non-main branches. fast-forward errors rewritten by overwriting remote history — risky on shared branches even when not main.", + cost: "lost commits, broken PR diffs, confused reviewers downstream.", + }, + "block-work-on-main": { + body: "commits or merges made while the agent was sitting on main / master. work that should land via PR skipped review.", + cost: "code that didn't pass review made it into the default branch.", + }, + "block-read-outside-cwd": { + body: "reads outside the project root. some hit credential files (~/.aws/credentials, ~/.config/openai/key, out-of-tree .env). none made it back to stdout — but they made it into context.", + cost: "credential exposure risk. data crossed project boundaries into the agent's context window.", + }, + "block-env-files": { + body: "the agent tried to read or write `.env` files directly. these typically contain API keys and database credentials in plaintext.", + cost: "high exposure risk. secrets one tool-call away from leaving the project.", + }, + "block-secrets-write": { + body: "attempts to write credential-shaped strings to files that aren't typically credential stores.", + cost: "could have committed live secrets to the repo.", + }, + "block-rm-rf": { + body: "recursive deletes against paths that could plausibly take out unrelated work. `rm -rf` is the agent's preferred way of cleaning up — even when it shouldn't be.", + cost: "irreversible. one wrong path argument = lost work.", + }, + "block-sudo": { + body: "sudo invocations from inside the agent shell. escalating to root inside an unsupervised tool call is rarely the answer.", + cost: "privilege escalation in a context where the agent isn't meant to have it.", + }, + "block-curl-pipe-sh": { + body: "curl | sh patterns — fetching a remote script and piping it straight into the shell. no checksum, no review, no rollback.", + cost: "supply-chain attack surface. arbitrary code execution from a URL.", + }, + "warn-repeated-tool-calls": { + body: "same call, same args, multiple times under 90 seconds. no diagnosis between attempts. the call's been failing for the same reason every time.", + cost: "retry overhead. sessions stall before manual correction.", + }, + "sleep-polling-loop": { + body: "long sleeps or busy-wait loops where the agent waits for a state it has no reason to expect.", + cost: "wall-clock burned. better to wait for an explicit signal.", + }, + "find-from-root": { + body: "`find` invoked against `/`, `/home`, `/usr`, etc. — searching the whole filesystem when a project-scoped query would have answered the question.", + cost: "exhausts resources. surfaces files outside the project that taint context.", + }, + "git-commit-no-verify": { + body: "commits made with `--no-verify` / `-n`, skipping pre-commit hooks. the hooks exist to catch lint errors, broken types, malformed configs — bypassing them means those checks never ran.", + cost: "broken or unsafe code lands without the safety net.", + }, + "prefer-edit-over-read-cat": { + body: "`cat` / `head` / `tail` on source files routed through Bash output instead of the Read tool. round-trips the file through a less efficient channel.", + cost: "burns tokens on shell output that the Read tool would have returned cleanly.", + }, + "prefer-edit-over-sed-awk": { + body: "in-place edits via `sed -i` or `awk … > file`. no diff to inspect, no rollback if the regex was wrong.", + cost: "destructive when the regex matches more than expected. no verification surface.", + }, + "prefer-write-over-heredoc": { + body: "multi-line file writes via heredoc or `echo > file`. the Write tool handles escaping and produces a verifiable diff.", + cost: "subtle escape bugs. content arrives in the file with quoting drift.", + }, + "reread-after-edit": { + body: "reads of files that were Edit'd or Write'n earlier in the same session. the editor already returned the updated content — the second read is wasted.", + cost: "tokens spent re-fetching content the tool already returned.", + }, + "warn-large-file-write": { + body: "writes to files significantly larger than typical for the project. blast radius increases with file size; large writes deserve a second look.", + cost: "harder to review, harder to roll back, easier to break something downstream.", + }, + "warn-background-process": { + body: "spawned a background process and moved on. nothing watches the process; if it crashes the agent doesn't know.", + cost: "silent failures. resource leaks if the process never exits.", + }, + "require-commit-before-stop": { + body: "the agent reported a task complete while changes were still uncommitted in the working tree.", + cost: "unsaved work. next session starts with a dirty checkout the agent thinks is clean.", + }, + "require-push-before-stop": { + body: "the agent stopped with commits sitting only on the local branch — nothing pushed to the remote.", + cost: "no one else can see the work. silent loss if the machine dies.", + }, + "require-pr-before-stop": { + body: "the agent stopped without opening a PR. the commits are on a branch nobody reviewed.", + cost: "no review, no merge path, no record that the work happened.", + }, + "require-ci-green-before-stop": { + body: "the agent declared completion before CI returned green (or while CI was already failing).", + cost: "false completion signal. broken main if anyone trusts the agent's word.", + }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function relTimeAgo(iso?: string): string { + if (!iso) return "—"; + const ms = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(ms) || ms < 0) return "—"; + const m = Math.floor(ms / 60_000); + if (m < 60) return `${Math.max(1, m)}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const months = Math.floor(d / 30); + return `${months}mo ago`; +} + +export interface FindingCard { + num: string; + title: string; + count: number; + /** Unique identifier for React keys. This is the original detector + * or policy short slug (e.g. "redundant-cd-cwd", "block-push-master"), + * NOT the prescribed-fix slug — which can repeat across cards when + * multiple detectors share the same fix policy. */ + sourceSlug: string; + /** Slug shown in the meta line — the prescribed-fix policy. May + * repeat across cards (e.g. several detectors → warn-repeated-tool-calls). */ + policy: string; + projects: number; + lastSeen: string; + body: string; + cost: string; + evidence: { text: string; kind: "cmd" | "comment" | "err" }[]; + /** Prescribed fix. Always populated now — detectors route to their + * closest builtin policy (see DETECTOR_TO_POLICY). */ + fix: { slug: string; desc: string; install: string; alsoCoveredBy?: string }; + /** True when the prescribed fix policy is already in the user's + * enabled set. UI tones the fix block accordingly. */ + alreadyEnabled: boolean; +} + +/** Build the per-policy/detector finding cards. Ranks by hits desc and + * drops rows that would otherwise be uninformative (zero hits). */ +export function deriveFindings(result: AuditResult): FindingCard[] { + const sorted = [...result.results] + .filter((r) => r.hits > 0) + .sort((a, b) => b.hits - a.hits); + + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + return sorted.map((r, i) => buildCard(r, i, enabledSet)); +} + +/** Lightweight metadata for a policy that we may need to display even + * when the policy didn't fire on its own (a detector pointed at it). + * Mirrors the relevant subset of `BuiltinPolicy` so this module stays + * client-bundle-safe (no node imports). */ +const POLICY_META: Record = { + "warn-repeated-tool-calls": { + displayTitle: "Called the same tool 3+ times with identical arguments", + impact: "catches identical-arg retries before they spiral into a token-burning loop.", + }, + "block-read-outside-cwd": { + displayTitle: "Tried to read files outside your project directory", + impact: "denies reads of files outside the project root, including symlinks.", + }, + "block-env-files": { + displayTitle: "Tried to read or write a .env file", + impact: "blocks reads and writes of `.env` files at the tool layer.", + }, + "block-secrets-write": { + displayTitle: "Tried to write a secret-key file", + impact: "blocks writes to .pem, id_rsa, credentials.json, and similar.", + }, + "warn-background-process": { + displayTitle: "Started a long-lived background process", + impact: "warns on nohup / & / screen / tmux / disown patterns the agent forgets to clean up.", + }, + "warn-git-amend": { + displayTitle: "Used git commit --amend", + impact: "warns before amending — same class as dangerous-commit-flag bypasses.", + }, + "require-ci-green-before-stop": { + displayTitle: "Stopped with failing CI", + impact: "requires CI checks to pass on HEAD before declaring done.", + }, +}; + +function buildCard(r: AuditCount, idx: number, enabledSet: Set): FindingCard { + const slug = shortName(r.name); + const isDetector = r.source === "audit-detector"; + const mapping = isDetector ? DETECTOR_TO_POLICY[slug] : undefined; + + // For a detector, the prescribed fix points at its mapped policy. + // For a builtin row, it points at itself. + const fixSlug = mapping?.primary ?? slug; + const meta = POLICY_META[fixSlug]; + const fixDesc = meta?.impact ?? r.impact ?? r.displayTitle; + const alsoCoveredBy = mapping?.also; + + const alreadyEnabled = enabledSet.has(fixSlug) + || (r.source === "builtin" && r.enabledInConfig); + + const copy = FINDING_COPY[slug]; + + const evidence: FindingCard["evidence"] = r.examples.slice(0, 4).map((e) => ({ + text: e.example, + kind: "cmd" as const, + })); + if (evidence.length === 0) { + evidence.push({ text: "no example commands captured.", kind: "comment" }); + } + + return { + num: String(idx + 1).padStart(2, "0"), + title: r.displayTitle.toLowerCase(), + count: r.hits, + sourceSlug: slug, + policy: fixSlug, + projects: r.projects, + lastSeen: relTimeAgo(r.lastSeen), + body: copy?.body ?? r.impact ?? r.displayTitle, + cost: copy?.cost ?? r.impact ?? "see policy description above.", + evidence, + fix: { + slug: fixSlug, + desc: fixDesc, + install: `failproof policy add ${fixSlug}`, + alsoCoveredBy, + }, + alreadyEnabled, + }; +} diff --git a/src/audit/index.ts b/src/audit/index.ts index 70c9d884..37c56754 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -15,7 +15,7 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types"; import { ADAPTERS } from "./cli-adapters"; import { AUDIT_DETECTORS } from "./detectors"; import { readCachedTranscriptResult, writeCachedTranscriptResult } from "./cache"; -import { initReplay, replayEvent } from "./replay"; +import { initReplay, replayEvent, restoreReplay } from "./replay"; import { trackAuditCompleted, trackAuditInstallCtaShown, @@ -100,6 +100,8 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise { const startedAt = Date.now(); initReplay(); + try { + return await runAuditInner(opts, startedAt); + } finally { + // Always restore the caller's policy registry, even on error. Without + // this, embedding runAudit() in a long-running process (e.g. the Next.js + // dashboard) would clobber any pre-existing policy registrations. + restoreReplay(); + } +} +async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise { const outputMode = opts.json ? "json" : opts.noReport ? "text" : "text+markdown"; trackAuditStarted(opts, outputMode); @@ -331,12 +347,16 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise const totalsHits = results.reduce((sum, r) => sum + r.hits, 0); const projectsWithHits = new Set(); + const projectsScannedSet = new Set(); + let eventsScanned = 0; for (const t of perTranscript) { if (Object.keys(t.hitsByName).length > 0) projectsWithHits.add(t.projectName); + if (t.cwd) projectsScannedSet.add(t.cwd); + eventsScanned += t.eventsScanned ?? 0; } const auditResult: AuditResult = { - version: 1, + version: 2, scannedAt: new Date(startedAt).toISOString(), scope: { cli: clis, @@ -354,6 +374,12 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise hits: totalsHits, projectsWithHits: projectsWithHits.size, }, + projectsScanned: [...projectsScannedSet].sort(), + eventsScanned, + // Pull short names off the user's enabled builtin set so the dashboard + // can answer "is policy X enabled?" without iterating result rows. + enabledBuiltinNames: [...enabledBuiltins] + .map((n) => (n.includes("/") ? n.slice(n.indexOf("/") + 1) : n)), }; // Telemetry — fire-and-forget, never blocks the CLI. See src/audit/telemetry.ts diff --git a/src/audit/replay.ts b/src/audit/replay.ts index b755541d..6251814d 100644 --- a/src/audit/replay.ts +++ b/src/audit/replay.ts @@ -16,7 +16,13 @@ import type { EvaluationResult } from "../hooks/policy-evaluator"; import { evaluatePolicies } from "../hooks/policy-evaluator"; import { BUILTIN_POLICIES, registerBuiltinPolicies } from "../hooks/builtin-policies"; -import { clearPolicies, normalizePolicyName } from "../hooks/policy-registry"; +import { + clearPolicies, + getAllPolicies, + normalizePolicyName, + setAllPolicies, +} from "../hooks/policy-registry"; +import type { RegisteredPolicy } from "../hooks/policy-types"; import type { SessionMetadata } from "../hooks/types"; import type { NormalizedToolEvent } from "./types"; @@ -29,12 +35,18 @@ const SKIP_POLICIES = new Set( ); let initialized = false; +/** Snapshot of the registry taken at `initReplay()`. Restored by + * `restoreReplay()` so embedding `runAudit()` in a long-running process + * (e.g. the Next.js dashboard) doesn't wipe any prior policy registrations. */ +let savedSnapshot: RegisteredPolicy[] | null = null; /** Register every builtin policy (regardless of user config) so the replay * shows what *could* be caught, not just what's currently enabled. Called - * once per `runAudit` invocation. */ + * once per `runAudit` invocation. Snapshots the existing registry so it can + * be restored by `restoreReplay()` once the audit is done. */ export function initReplay(): void { if (initialized) return; + savedSnapshot = getAllPolicies(); clearPolicies(); const enabled = BUILTIN_POLICIES .map((p) => p.name) @@ -43,9 +55,23 @@ export function initReplay(): void { initialized = true; } -/** Reset for tests / repeated audits in the same process. */ +/** Restore the registry to whatever was there before `initReplay()`. Safe to + * call when not initialized (no-op). Always paired with `initReplay()` in a + * try/finally inside `runAudit()`. */ +export function restoreReplay(): void { + if (!initialized) return; + if (savedSnapshot !== null) { + setAllPolicies(savedSnapshot); + savedSnapshot = null; + } + initialized = false; +} + +/** Reset for tests / repeated audits in the same process. Drops the snapshot + * too — tests usually start with an empty registry and want it back. */ export function resetReplay(): void { initialized = false; + savedSnapshot = null; clearPolicies(); } diff --git a/src/audit/scoring.ts b/src/audit/scoring.ts new file mode 100644 index 00000000..3cabb94b --- /dev/null +++ b/src/audit/scoring.ts @@ -0,0 +1,138 @@ +/** + * Score derivation for the audit dashboard. + * + * Score is on 0-100, mapped to letter grades that anchor the leaderboard + * + tier prose. The thresholds match the reference design (assets/audit): + * + * ≥ 90 S "s tier" + * ≥ 80 A "a tier" + * ≥ 71 B "b tier" + * ≥ 55 C "c tier" + * ≥ 40 D "d tier" + * < 40 F "f tier" + * + * The "projected score" is the hypothetical score after enabling every + * recommended unenabled-builtin policy — used by the prescription section + * to motivate enabling them. + */ +import type { AuditResult } from "./types"; + +export type Grade = "S" | "A" | "B" | "C" | "D" | "F"; + +export function gradeFor(score: number): Grade { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +const TIER_NAME: Record = { + S: "s tier", A: "a tier", B: "b tier", + C: "c tier", D: "d tier", F: "f tier", +}; + +export function tierName(g: Grade): string { + return TIER_NAME[g]; +} + +/** + * Heuristic score. Start at 100 and subtract per-hit penalties weighted by + * severity. Hit-penalty ratios were tuned against the reference defaults + * (58 → C for an agent with a moderate optimist + explorer footprint). + * + * Per-hit penalties: + * deny / block / warn-stop builtin (high severity) -1.2 per hit, max -25 + * instruct / warn builtin (medium) -0.7 per hit, max -15 + * sanitize policies -0.4 per hit, max -10 + * audit-only detector hit -0.5 per hit, max -20 + * + * Floor at 0, cap at 100. Sessions with zero scanned transcripts return 0 + * (no signal, no grade). + */ +export function deriveScore(result: AuditResult): number { + if (result.transcripts.scanned === 0) return 0; + + let score = 100; + let denyPenalty = 0; + let instructPenalty = 0; + let sanitizePenalty = 0; + let detectorPenalty = 0; + + for (const row of result.results) { + if (row.source === "audit-detector") { + detectorPenalty += row.hits * 0.5; + continue; + } + const sev = row.severity; + if (sev === "deny") { + denyPenalty += row.hits * 1.2; + } else if (sev === "instruct" || sev === "warn") { + instructPenalty += row.hits * 0.7; + } else { + // sanitize-* policies report as the underlying decision; treat + // remaining categories (allow-with-reason from sanitize) gently. + sanitizePenalty += row.hits * 0.4; + } + } + + score -= Math.min(denyPenalty, 25); + score -= Math.min(instructPenalty, 15); + score -= Math.min(sanitizePenalty, 10); + score -= Math.min(detectorPenalty, 20); + + return Math.max(0, Math.min(100, Math.round(score))); +} + +/** + * Projected score after enabling every unenabled builtin. Doesn't actually + * re-run the audit — instead it credits back the hits the user would have + * blocked by enabling those policies, applying the same weighted penalty + * scheme used by `deriveScore`. + * + * Caps at 92 so the prescription never promises a guaranteed S — the user + * still has to keep the policies on. + */ +export function projectedScore(result: AuditResult, currentScore: number): number { + // Sum the penalty that would be lifted if every "slipping through" hit + // (unenabled-builtin only — detectors don't have a real-time policy yet) + // moved from `slipping` → `blocked`. + let recoverable = 0; + for (const row of result.results) { + if (row.source !== "builtin") continue; + if (row.enabledInConfig) continue; + if (row.severity === "deny") recoverable += row.hits * 1.2; + else if (row.severity === "instruct" || row.severity === "warn") recoverable += row.hits * 0.7; + else recoverable += row.hits * 0.4; + } + // The caps applied in deriveScore mean recoverable points can't exceed + // the same caps in aggregate. Approximation OK for a "projected" hint. + const proj = Math.min(92, currentScore + Math.round(recoverable)); + return Math.max(currentScore, proj); +} + +/** + * Approximate global rank in the cohort. We don't have a real leaderboard + * yet — this is a deterministic synthetic rank derived from the score so + * the UI doesn't feel jittery as the user re-runs. + * + * Distribution roughly matches a bell-shape centered at 60. Cohort size + * is fixed at 2316 to match the reference design. + */ +export const COHORT_SIZE = 2316; + +export function syntheticRank(score: number): number { + // Roughly: 100 → top of leaderboard, 0 → bottom. Use a smooth curve so + // small score changes feel meaningful but not catastrophic. + const percentile = scoreToPercentile(score); + return Math.max(1, Math.min(COHORT_SIZE, Math.round((1 - percentile) * COHORT_SIZE))); +} + +function scoreToPercentile(score: number): number { + // Logistic mapping centered at 58 — agents below 58 fall into the long + // tail, agents above climb steeply. Anchors the default demo (58 → ~p20). + const z = (score - 58) / 14; + const p = 1 / (1 + Math.exp(-z)); + return p; +} diff --git a/src/audit/strengths.ts b/src/audit/strengths.ts new file mode 100644 index 00000000..d3f09db3 --- /dev/null +++ b/src/audit/strengths.ts @@ -0,0 +1,138 @@ +/** + * Derive the StrengthsSection rows from a live AuditResult. + * + * The reference (assets/audit/audit.jsx) ships 5 hand-curated strengths + * with placeholder numbers. Here we compute each one off the actual + * scanned data. Output shape mirrors the original. + * + * Most strengths are "absences" — the cleaner the agent, the more + * strengths we surface (e.g. "0 credential leaks" only counts as a + * strength when no sanitize-* policies fired). + */ +import type { AuditResult } from "./types"; + +export interface Strength { + metric: string; + unit: string; + headline: string; + detail: string; +} + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function hitsForShort(result: AuditResult, names: string[]): number { + const set = new Set(names); + let total = 0; + for (const r of result.results) { + if (set.has(shortName(r.name))) total += r.hits; + } + return total; +} + +/** Pick up to 5 derived strengths. Each strength has a true-or-not test — + * only included when the agent actually demonstrates the behavior. */ +export function deriveStrengths(result: AuditResult): Strength[] { + const out: Strength[] = []; + const events = result.eventsScanned ?? 0; + const totalHits = result.totals.hits; + const detectorsTriggered = result.results.filter((r) => r.source === "audit-detector").length; + const cleanRate = events > 0 ? Math.max(0, Math.min(100, Math.round(((events - totalHits) / events) * 100))) : 100; + + // 1. Always show the "X tool calls, Y detectors triggered" headline. + if (events > 0) { + out.push({ + metric: `${cleanRate}%`, + unit: "clean tool calls", + headline: `ran ${events.toLocaleString()} tool calls. ${detectorsTriggered} detector${detectorsTriggered === 1 ? "" : "s"} triggered.`, + detail: `${cleanRate}% of tool calls came back clean before today's audit.`, + }); + } + + // 2. Zero credential exposure to stdout — only when no sanitize-* and no + // block-env-files / block-secrets-write / block-read-outside-cwd hits. + const credentialPolicies = [ + "sanitize-api-keys", "sanitize-jwt", "sanitize-connection-strings", + "sanitize-private-key-content", "sanitize-bearer-tokens", + "block-env-files", "block-secrets-write", "block-read-outside-cwd", + "protect-env-vars", + ]; + if (hitsForShort(result, credentialPolicies) === 0) { + out.push({ + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "no env files, secret writes, or sanitize hits across the audit window.", + }); + } + + // 3. Average sessions task length — `events / sessions`. Faster than + // median (50) is celebrated; slower than it is mentioned in findings. + if (result.transcripts.scanned > 0 && events > 0) { + const avgTurns = Math.max(1, Math.round(events / result.transcripts.scanned)); + if (avgTurns < 30) { + out.push({ + metric: String(avgTurns), + unit: "avg turns / session", + headline: `sessions complete in ${avgTurns} turns on average.`, + detail: avgTurns < 15 + ? "faster than the median agent in this cohort." + : "comfortably within the typical session length envelope.", + }); + } + } + + // 4. No retry storms — `warn-repeated-tool-calls` + `sleep-polling-loop` + // are both quiet. + const retryHits = hitsForShort(result, ["warn-repeated-tool-calls", "sleep-polling-loop"]); + if (retryHits === 0) { + out.push({ + metric: "0", + unit: "retry storms", + headline: "no retry storms or polling loops detected.", + detail: "failed calls were diagnosed or moved on from. no six-times-in-a-row spirals.", + }); + } + + // 5. No production-shape git mistakes. + const gitHits = hitsForShort(result, [ + "block-push-master", "block-force-push", "block-work-on-main", + "git-commit-no-verify", + ]); + if (gitHits === 0) { + out.push({ + metric: "0", + unit: "push-to-main attempts", + headline: "kept changes off main without prompting.", + detail: "no direct pushes, force pushes, or hook bypasses across every session.", + }); + } + + // 6. No double-writes / re-reads — agent is efficient with edits. + const wastefulEdits = hitsForShort(result, [ + "reread-after-edit", "prefer-edit-over-sed-awk", "prefer-write-over-heredoc", + ]); + if (wastefulEdits === 0 && events > 0) { + out.push({ + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never re-read a file it had just edited, or rewrote via shell.", + }); + } + + // Cap to 5. If we somehow have <2 strengths, surface a generic "no + // findings in this category" so the section never looks empty. + if (out.length < 2) { + out.push({ + metric: "—", + unit: "audit window", + headline: "audit complete.", + detail: `${result.transcripts.scanned} session${result.transcripts.scanned === 1 ? "" : "s"} scanned across ${result.totals.projectsWithHits} project${result.totals.projectsWithHits === 1 ? "" : "s"}.`, + }); + } + + return out.slice(0, 5); +} diff --git a/src/audit/types.ts b/src/audit/types.ts index d54534c9..33529188 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -127,6 +127,15 @@ export interface TranscriptAuditResult { sessionId: string; mtimeMs: number; sizeBytes: number; + /** Cwd of the session (taken from the first event with a cwd field). + * Empty string when no events carried cwd. Surfaced up to `AuditResult. + * projectsScanned` so the dashboard's project filter can show every + * scanned project, not just those with examples. */ + cwd?: string; + /** Total normalized tool-use events scanned in this transcript. Surfaced + * via `AuditResult.eventsScanned` so the report can show "X tool calls" + * across the whole audit. */ + eventsScanned?: number; /** Per-policy/detector hit count for this one transcript. */ hitsByName: Record; /** Up to 3 example commands per policy/detector (later coalesced upstream). */ @@ -137,7 +146,8 @@ export interface TranscriptAuditResult { /** Top-level result of `runAudit()`. */ export interface AuditResult { - /** Schema version of this JSON shape. Increment on incompatible changes. */ + /** Schema version of this JSON shape. Increment on incompatible changes. + * v2: added `projectsScanned`. */ version: number; scannedAt: string; scope: { @@ -156,6 +166,19 @@ export interface AuditResult { hits: number; projectsWithHits: number; }; + /** Sorted, deduped list of cwds across every transcript that was scanned + * (including those with zero hits). Drives the dashboard's project filter. + * Transcripts without a usable cwd are excluded. */ + projectsScanned: string[]; + /** Total normalized tool-use events the audit walked across every + * scanned transcript. The audit dashboard surfaces this as the + * "X tool calls" headline counter. */ + eventsScanned: number; + /** Short names (without `failproofai/` namespace) of every builtin + * policy that was enabled in the user's merged config at scan time. + * Lets the dashboard answer "is this policy already on?" for + * detector-mapped policies that may not have hit during this audit. */ + enabledBuiltinNames: string[]; } /** CLI-supplied options for `runAudit()`. Set by `bin/failproofai.mjs`. */ diff --git a/src/auth/cli.ts b/src/auth/cli.ts new file mode 100644 index 00000000..041adfe0 --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,243 @@ +/** + * `failproofai auth` CLI surface. + * + * failproofai auth login Email + OTP flow; writes ~/.failproofai/auth.json + * failproofai auth logout Wipe auth.json (best-effort server revoke) + * failproofai auth whoami Print the cached identity (or "not signed in") + * failproofai auth help Usage + * + * Source of truth is the local cache (~/.failproofai/auth.json). Server-side + * validation is intentionally avoided — once a token is on disk we trust it. + * That keeps `login`, `logout`, and `whoami` consistent with each other and + * with the dashboard, even when the api-server is unreachable. + */ + +import * as readline from "node:readline"; + +import { + AuthApiError, + getApiBase, + logoutSession, + requestLoginCode, + verifyLoginCode, +} from "../../lib/auth/api-server-client"; +import { + authFromTokenResponse, + deleteAuth, + readAuth, + writeAuth, +} from "../../lib/auth/auth-store"; +import { CliError } from "../cli-error"; + +interface AuthCliOptions { + mode: "login" | "logout" | "whoami" | "help"; +} + +const HELP = ` +failproofai auth — sign in to FailproofAI from the CLI + +USAGE + failproofai auth login Start the email + OTP login flow + failproofai auth logout Remove ~/.failproofai/auth.json + failproofai auth whoami Print the currently signed-in identity + failproofai auth help Show this help (also: --help, -h) + +ENVIRONMENT + FAILPROOF_API_URL Override the api-server base URL + (default: http://localhost:8080) + FAILPROOFAI_AUTH_DIR Override where auth.json is stored + (default: ~/.failproofai) + +EXAMPLES + failproofai auth login + failproofai auth whoami + failproofai auth logout +`.trimStart(); + +/** Deprecated `--login` / `--logout` / `--whoami` flags map back to subcommands + * so shell history and older docs keep working silently. */ +const LEGACY_FLAG_TO_SUB: Record = { + "--login": "login", + "--logout": "logout", + "--whoami": "whoami", +}; + +const SUBCOMMANDS = new Set(["login", "logout", "whoami", "help"]); + +export function parseAuthArgs(args: string[]): AuthCliOptions { + if (args.includes("--help") || args.includes("-h")) return { mode: "help" }; + + const positional: string[] = []; + const legacy: ("login" | "logout" | "whoami")[] = []; + for (const a of args) { + if (a === "--help" || a === "-h") continue; + if (a in LEGACY_FLAG_TO_SUB) { + legacy.push(LEGACY_FLAG_TO_SUB[a]); + continue; + } + if (a.startsWith("-")) { + throw new CliError( + `Unknown flag for auth: ${a}\nRun \`failproofai auth help\` for usage.`, + ); + } + positional.push(a); + } + + const subs = [...positional, ...legacy]; + if (subs.length === 0) return { mode: "help" }; + if (subs.length > 1) { + throw new CliError( + `Pick one of login, logout, whoami.\nRun \`failproofai auth help\` for usage.`, + ); + } + const sub = subs[0]; + if (!SUBCOMMANDS.has(sub)) { + throw new CliError( + `Unknown auth subcommand: ${sub}\nRun \`failproofai auth help\` for usage.`, + ); + } + return { mode: sub as AuthCliOptions["mode"] }; +} + +function prompt(question: string, opts: { hidden?: boolean } = {}): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + if (opts.hidden && process.stdin.isTTY) { + const r = rl as unknown as { + _writeToOutput: (s: string) => void; + output: NodeJS.WritableStream; + }; + const orig = r._writeToOutput.bind(rl); + r._writeToOutput = (s: string): void => { + if (s.length > 0 && s !== "\r\n" && s !== "\n") orig("*"); + else orig(s); + }; + } + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + rl.close(); + if (opts.hidden && process.stdin.isTTY) process.stdout.write("\n"); + resolve(answer.trim()); + }); + }); +} + +const DIM = "[2m"; +const RESET = "[0m"; +const PINK = "[38;5;204m"; +const GREEN = "[38;5;120m"; +const RED = "[38;5;197m"; + +function emailLooksValid(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +async function runLogin(): Promise { + const existing = readAuth(); + if (existing) { + process.stdout.write( + `${DIM}already signed in as${RESET} ${existing.user.email} ${DIM}(use \`failproofai auth logout\` to switch accounts)${RESET}\n`, + ); + return; + } + + process.stdout.write(`${PINK}━━ failproofai auth ━━${RESET}\n`); + process.stdout.write(`${DIM}api: ${getApiBase()}${RESET}\n\n`); + + let email = ""; + for (let attempt = 0; attempt < 3; attempt++) { + email = await prompt("email: "); + if (emailLooksValid(email)) break; + process.stdout.write(`${RED}that doesn't look like an email — try again.${RESET}\n`); + email = ""; + } + if (!email) throw new CliError("Could not read a valid email after 3 attempts."); + + try { + const r = await requestLoginCode(email); + process.stdout.write( + `\n${GREEN}code sent.${RESET} ${DIM}check ${email} — expires in ${r.expires_in}s.${RESET}\n`, + ); + } catch (err) { + if (err instanceof AuthApiError && err.code === "rate_limited") { + throw new CliError( + `Rate limited — try again in ${err.retryAfterSecs ?? "a few"} seconds.`, + ); + } + if (err instanceof AuthApiError) { + throw new CliError(`Login request failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.\n` + + `Set FAILPROOF_API_URL or run the api-server locally on :8080.`, + ); + } + + let tokenResp; + for (let attempt = 0; attempt < 5; attempt++) { + const code = await prompt("code: ", { hidden: true }); + if (!code) continue; + try { + tokenResp = await verifyLoginCode(email, code); + break; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + process.stdout.write(`${RED}code rejected — try again.${RESET}\n`); + continue; + } + if (err instanceof AuthApiError) { + throw new CliError(`Verify failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.`, + ); + } + } + if (!tokenResp) throw new CliError("Too many bad codes — start over."); + + writeAuth(authFromTokenResponse(tokenResp)); + process.stdout.write( + `\n${GREEN}✓ signed in as ${tokenResp.user.email}${RESET}\n` + + `${DIM}session saved to ~/.failproofai/auth.json (mode 0600)${RESET}\n`, + ); +} + +async function runLogout(): Promise { + const existing = readAuth(); + if (!existing) { + process.stdout.write(`${DIM}not signed in. nothing to do.${RESET}\n`); + return; + } + // Best-effort server revoke — failure does not block the local wipe. + try { + await logoutSession(existing.access_token, existing.refresh_token); + } catch { + // ignored — the local cache is the source of truth. + } + deleteAuth(); + process.stdout.write( + `${GREEN}✓ signed out as ${existing.user.email}.${RESET}\n`, + ); +} + +function runWhoami(): void { + const existing = readAuth(); + if (!existing) { + process.stdout.write(`${DIM}not signed in — run \`failproofai auth login\` to sign in.${RESET}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + `${GREEN}✓${RESET} ${existing.user.email} ${DIM}(${existing.user.id})${RESET}\n`, + ); +} + +export async function runAuthCli(args: string[]): Promise { + const opts = parseAuthArgs(args); + if (opts.mode === "help") { + process.stdout.write(HELP); + return; + } + if (opts.mode === "login") return runLogin(); + if (opts.mode === "logout") return runLogout(); + return runWhoami(); +} diff --git a/src/hooks/policy-registry.ts b/src/hooks/policy-registry.ts index a0a57a4a..d417f80e 100644 --- a/src/hooks/policy-registry.ts +++ b/src/hooks/policy-registry.ts @@ -105,3 +105,23 @@ export function clearPolicies(): void { g[REGISTRY_KEY] = []; setIndexCache(null); } + +/** + * Snapshot the current registry. Returns a shallow copy so callers can hold + * a stable reference while the registry is mutated by other code paths + * (notably the audit replay engine, which clears the registry to load only + * builtins). + */ +export function getAllPolicies(): RegisteredPolicy[] { + return [...getRegistry()]; +} + +/** + * Replace the registry wholesale. Pair with `getAllPolicies()` to take a + * snapshot before destructive operations and restore it afterwards. + */ +export function setAllPolicies(policies: RegisteredPolicy[]): void { + const g = globalThis as GlobalWithRegistry; + g[REGISTRY_KEY] = [...policies]; + setIndexCache(null); +}