diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index d35be46..e413e9e 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,4 +1,9 @@ -name: Deploy Ghost UI to GitHub Pages +name: Deploy to GitHub Pages + +# Builds apps/docs with DEPLOY_BASE=/ghost/ and uploads the dist as the +# Pages artifact. apps/docs is the one deployed site — it owns the home, +# the design language catalogue (/ui/*), and the drift tooling docs +# (/tools/drift/*) under a single aesthetic. on: push: @@ -26,17 +31,24 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Build registry + + - name: Build @ghost/ui library + run: pnpm --filter @ghost/ui build:lib + + - name: Build shadcn registry run: pnpm --filter @ghost/ui build:registry - - name: Build Ghost UI - run: pnpm --filter @ghost/ui build + + - name: Build docs site (base=/ghost/) env: - VITE_BASE_PATH: /ghost/ - - name: Copy index.html to 404.html for SPA routing - run: cp packages/ghost-ui/dist/index.html packages/ghost-ui/dist/404.html + DEPLOY_BASE: /ghost/ + run: pnpm --filter @ghost/docs build + + - name: SPA fallback + run: cp apps/docs/dist/index.html apps/docs/dist/404.html + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: - path: packages/ghost-ui/dist + path: apps/docs/dist deploy: needs: build diff --git a/.gitignore b/.gitignore index e92f818..cb9055c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-lib *.tsbuildinfo .DS_Store .claude/settings.local.json @@ -7,3 +8,10 @@ packages/ghost-ui/public/r/ .env .env.local packages/ghost-ui/.ghost/ + +# Claude Code local harness state +.claude/scheduled_tasks.lock +.claude/worktrees/ + +# Emitted skill bundle — re-generate with `ghost emit skill` +.claude/skills/ diff --git a/.npmrc b/.npmrc index bacea71..37792d4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ +registry=https://registry.npmjs.org/ shamefully-hoist=false strict-peer-dependencies=true @anthropic-ai:registry=https://registry.npmjs.org diff --git a/CLAUDE.md b/CLAUDE.md index e491ef6..77a18e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,10 @@ pnpm --filter ghost-cli exec ghost ## Environment Variables -- `ANTHROPIC_API_KEY` — required for AI-powered profiling (`--ai` flag) and LLM agents -- `OPENAI_API_KEY` — alternative LLM provider -- `GITHUB_TOKEN` — optional, for GitHub target resolution and discovery (avoids rate limits) +Ghost's CLI is deterministic — no API key required for any verb. + +- `OPENAI_API_KEY` / `VOYAGE_API_KEY` — optional, consumed only by `computeSemanticEmbedding` (library function; used when a host writes a fingerprint.md and wants an enriched 49-dim vector for paraphrase-robust comparison). +- `GITHUB_TOKEN` — optional, for `resolveParent` fetching a parent fingerprint from GitHub (avoids rate limits). The CLI auto-loads `.env` and `.env.local` from the working directory. @@ -38,43 +39,52 @@ Pre-push hook: `just check`, `just test`, `just build` (parallel). ## Justfile -Run `just` to list all recipes. Key ones: `setup`, `build`, `check`, `fmt`, `test`, `dev` (ghost-ui catalogue), `build-ui`, `build-registry`, `clean`, `ci`. +Run `just` to list all recipes. Key ones: `setup`, `build`, `check`, `fmt`, `test`, `dev` (docs site at apps/docs), `build-ui` (docs build), `build-lib` (@ghost/ui library), `build-registry`, `build-pages`, `clean`, `ci`. ## Architecture -**Director** (`packages/ghost-core/src/agents/director.ts`) orchestrates the pipeline: +Ghost is **BYOA (bring-your-own-agent)**. The CLI is a set of **deterministic primitives**. It never calls an LLM. Judgement work (profile, review, verify, generate, discover) belongs to the host agent harness (Claude Code, Codex, Cursor, Goose, etc.), which drives the primitives via an [agentskills.io](https://agentskills.io)-compatible skill bundle. Ghost ships that bundle via `ghost emit skill`. + +Core library layout: -- **Stages** (`packages/ghost-core/src/stages/`) — deterministic async functions: `extract`, `compare`, `comply` -- **Agents** (`packages/ghost-core/src/agents/`) — LLM-powered steps: `FingerprintAgent`, `DiscoveryAgent`, `ComparisonAgent`, `ComplianceAgent`, `ExtractionAgent` +- `packages/ghost-core/src/compare.ts` — embedding-based comparison (pairwise + fleet) +- `packages/ghost-core/src/embedding/` — 49-dim vector computation, optional semantic embedding via OpenAI/Voyage +- `packages/ghost-core/src/fingerprint/` — parse/compose/diff/lint `fingerprint.md` +- `packages/ghost-core/src/evolution/` — history, ack manifest, fleet analysis, parent resolution +- `packages/ghost-core/src/context/` — artifact generators (review-command, context-bundle, tokens.css) +- `packages/ghost-core/src/reporters/` — output formatters for compare/fleet/temporal/fingerprint -Typical pipeline: `target → extract (stage) → fingerprint (agent) → compare/comply (stage)` +What was removed in the BYOA migration: the Claude Agent SDK profiling loop (`src/agents/`), the LLM-driven review pipeline (`src/review/`), the LLM generate/verify loops (`src/generate/`, `src/verify/`), the Anthropic/OpenAI provider plumbing (`src/llm/`), and the GitHub Action that wrapped them. Profile, review, verify, generate, and discover are now skill recipes the host agent executes. ## Packages | Package | Description | |---------|-------------| -| `packages/ghost-core` | Core library: agents, stages, fingerprinting, scanners, extractors, evolution, LLM providers, reporters | -| `packages/ghost-cli` | CLI (citty-based), 12 subcommands | -| `packages/ghost-ui` | Reference design language — 97 shadcn-compatible components, design tokens, live catalogue | -| `packages/ghost-mcp` | MCP server exposing Ghost UI registry to AI assistants (6 tools, 2 resources) | -| `action/` | GitHub Action for automated PR design review | +| `packages/ghost-core` | Core library: deterministic primitives — compare, embedding, fingerprint parse/lint, evolution, reporters | +| `packages/ghost-cli` | CLI (cac-based) + the shipped `ghost-drift` agentskills.io skill bundle under `src/skill-bundle/` | +| `packages/ghost-ui` | Reference component library — 49 UI primitives + 48 AI elements + theme + hooks, shipped via `dist-lib/` + shadcn `registry.json` | +| `packages/ghost-mcp` | MCP server exposing the Ghost UI component registry to AI assistants (5 tools, 2 resources) — registry lookups only | +| `apps/docs` | The deployed docs site (`@ghost/docs`) — home, drift tooling docs, design language foundations, live component catalogue. Consumes `@ghost/ui`. | ## CLI Commands +Six deterministic primitives. Everything else (profile, review, verify, generate, discover) is a skill recipe the host agent executes. + | Command | Description | |---------|-------------| -| `ghost review [files]` | Review files for visual language drift against a fingerprint (zero-config) | -| `ghost scan` | Scan for design drift (requires `ghost.config.ts`) | -| `ghost profile [target]` | Generate a fingerprint — accepts paths, `github:owner/repo`, `npm:package`, URLs | -| `ghost compare ` | Compare two fingerprint JSON files | -| `ghost diff [component]` | Compare local components against registry | -| `ghost comply [target]` | Check compliance; `--against parent.json` for drift checking | -| `ghost discover [query]` | Find public design systems | -| `ghost fleet ...` | Ecosystem-level comparison (2+ fingerprint files) | -| `ghost ack` | Acknowledge drift, record stance (aligned/accepted/diverging) | -| `ghost adopt ` | Adopt a new parent baseline | -| `ghost diverge ` | Declare intentional divergence with reasoning | -| `ghost viz ...` | 3D fingerprint visualization (Three.js) | +| `ghost compare [...fingerprints]` | Pairwise (N=2) or fleet (N≥3) comparison over fingerprint embeddings. `--semantic`, `--temporal`. | +| `ghost lint [fingerprint.md]` | Validate schema + body/frontmatter coherence | +| `ghost ack` | Acknowledge drift; records stance in `.ghost-sync.json` (reads local `fingerprint.md`) | +| `ghost adopt ` | Adopt a new parent baseline | +| `ghost diverge ` | Declare intentional divergence on a dimension | +| `ghost emit ` | Derive artifacts from `fingerprint.md` — `review-command`, `context-bundle`, or `skill` (the agentskills.io bundle). Run `ghost emit skill` to install the `ghost-drift` skill into your host agent. | + +**Workflows the CLI does not do** — these are recipes the host agent follows: +- **Profile** (write `fingerprint.md` from a project) — `src/skill-bundle/references/profile.md` +- **Review** (flag drift in PR changes) — `src/skill-bundle/references/review.md` +- **Verify** (generate → review loop) — `src/skill-bundle/references/verify.md` +- **Generate** (produce UI from fingerprint) — `src/skill-bundle/references/generate.md` +- **Discover** (find public design systems) — `src/skill-bundle/references/discover.md` ## Target Types @@ -85,28 +95,17 @@ The `resolveTarget()` function in `packages/ghost-core/src/config.ts` accepts: - `figma:file-url` — Figma file - `./path` or `/absolute/path` — local directory - `https://...` — URL -- `.` — current directory (default for `profile` and `comply`) - -Use explicit prefixes when the input is ambiguous. - -## Review Pipeline +- `.` — current directory -The `review` module (`packages/ghost-core/src/review/`) provides fingerprint-informed design review: +Used by `resolveParent` (parent fingerprint resolution) and legacy library consumers. The profile flow itself no longer consumes targets — the host agent explores whatever directory is relevant. -- **matcher.ts** — deterministic scan: match hardcoded values against fingerprint palette/spacing/typography/surfaces -- **deep-review.ts** — LLM-powered nuanced drift detection (optional, `--deep` flag) -- **file-collector.ts** — git diff parsing to resolve changed files and line numbers -- **pipeline.ts** — orchestrates: resolve fingerprint → collect files → match → (optional) deep review → report +## Fingerprint format -Zero-config: `ghost review` looks for `.ghost-fingerprint.json` in cwd. Generate with `ghost profile . --emit`. +The canonical fingerprint artifact is **`fingerprint.md`** — a human-readable, LLM-editable Markdown file with YAML frontmatter (machine layer) and a three-layer prose body (Character → Signature → Decisions → Values). See `docs/fingerprint-format.md` for the full spec; a condensed reference ships inside the skill bundle at `packages/ghost-cli/src/skill-bundle/references/schema.md`. ## Key Conventions -- Fingerprints are 64-dimensional vectors stored as JSON (`DesignFingerprint` type) -- `compare`, `fleet`, and `viz` commands take **file paths** to fingerprint JSON, not target strings -- `profile` outputs fingerprints; pipe to `--output ` to save for later comparison -- `--against` on `comply` takes a **file path** to a parent fingerprint JSON -- `--ai` enables LLM-powered enrichment on `profile`; `--verbose` shows agent reasoning -- `review` reads `.ghost-fingerprint.json` by default; `--fingerprint ` overrides -- `review --deep` requires `ANTHROPIC_API_KEY` for LLM-powered nuanced analysis -- `review --staged` checks only staged changes; `--base main` diffs against a branch +- Each fingerprint carries a 49-dimensional embedding vector (palette [0–20], spacing [21–30], typography [31–40], surfaces [41–48]; see `packages/ghost-core/src/embedding/embedding.ts`). The canonical on-disk form is `fingerprint.md`. +- `compare` takes **file paths** to `fingerprint.md`, not target strings. Mode auto-detects from N and flags: `--semantic` / `--temporal` require N=2; N≥3 runs fleet. +- `ack` / `adopt` / `diverge` read the local `fingerprint.md`. The host agent is responsible for regenerating `fingerprint.md` (via the profile recipe) before acknowledging drift. +- `lint` takes a single fingerprint.md and reports schema/partition violations. Use as the success gate when writing a fingerprint. diff --git a/README.md b/README.md index 01f9717..3a02b03 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # Ghost -**Autonomous perception of organic drift across decentralized design consumers.** +**Autonomous perception of organic drift across a decentralized fleet of fingerprint consumers.** -Ghost makes design systems legible. It continuously detects divergence between a parent design language and its consumers, generates quantitative fingerprints for comparison, tracks how systems evolve over time, and ships a reference design language as a shadcn-compatible component registry. +Ghost makes fingerprint legible. It profiles a system's identity into a human-readable `fingerprint.md`, perceives drift across a decentralized fleet of consumers, tracks the stance each consumer takes toward its parent (acknowledge, adopt, diverge), and surfaces fleet-wide signal the parent can heal from. Current scope: visual/UI fingerprint. The reference fingerprint, Ghost UI, ships as a shadcn-compatible component registry. The format and the perception architecture are identity-agnostic; visual is the first instantiation. ## Why Ghost? -Design languages drift — and drift degrades trust. When interfaces lose coherence, the experience suffers regardless of how good the underlying capabilities are. Ghost perceives this drift across an ecosystem so teams can reason about it and act with intent. +Fingerprint drifts. When a system's identity spreads across consumers — each evolving, each adapting — coherence degrades and trust follows. Ghost perceives this drift across a decentralized fleet so the parent can reason about what's happening and heal proactively. No central gatekeeper; observation and recorded intent instead. -- **Continuous scanning** — Detect token overrides, hardcoded values, structural divergence, and pixel-level visual regressions across every consumer -- **Design fingerprinting** — Generate a 64-dimensional profile of any design system — a continuous signal, not a binary check -- **Intent tracking** — Acknowledge, adopt, or intentionally diverge from a parent system. Every stance is published with reasoning and full lineage -- **Fleet observability** — Compare fingerprints across an ecosystem to see the full picture: clusters, outliers, and how consumers relate to each other and the source +- **Human-readable fingerprints** — Every system is captured as a `fingerprint.md`: YAML frontmatter (machine layer) plus a three-layer prose body (Character, Signature / Observation, Decisions, Values). Humans read it, LLMs consume it, deterministic tools diff it +- **Continuous perception** — Profile each consumer over time. Surface drift at the values (hardcoded colors, token overrides, missing tokens), structural (component divergence), and visual (pixel-level regressions) levels +- **Grounded generation** — Use fingerprints as grounding for AI-driven generation. `ghost emit context-bundle` writes prompt/skill material; any generator produces; `ghost review` surfaces drift in the output; `ghost verify` aggregates drift across a standard prompt suite to classify dimensions as tight, leaky, or uncaptured +- **Intent tracking** — Acknowledge, adopt, or intentionally diverge from a parent fingerprint. Every stance is published with reasoning and full lineage. Drift without intent is noise; drift with intent is signal +- **Fleet intelligence** — Compare fingerprints across an ecosystem to see clusters, outliers, and drift trajectories. The fleet view is the input to proactive healing: when consumers collectively drift toward something, the parent has reason to update itself - **LLM-aided interpretation** — Optionally use Claude or OpenAI for richer fingerprint generation and drift analysis - **3D visualization** — Explore fingerprint similarity space in an interactive Three.js viewer -- **Composable design language** — A full shadcn-compatible registry of atomic components, design tokens, and a live catalogue — building blocks that interfaces compose from +- **Reference fingerprint (Ghost UI)** — A shadcn-compatible registry of atomic components, design tokens, and a live catalogue. Serves as the canonical baseline Ghost profiles and tests itself against in its current visual scope ## Getting Started @@ -41,71 +42,97 @@ ghost profile . # Profile a GitHub repo ghost profile github:shadcn-ui/ui -# Profile with AI enrichment (requires ANTHROPIC_API_KEY or OPENAI_API_KEY) -ghost profile github:shadcn-ui/ui --ai --verbose +# Verbose mode shows the agent's reasoning (requires ANTHROPIC_API_KEY or OPENAI_API_KEY) +ghost profile github:shadcn-ui/ui --verbose # Profile a shadcn registry directly ghost profile --registry https://ui.shadcn.com/registry.json -# Save fingerprint to a file -ghost profile . --output my-system.json +# Save a fingerprint to disk (recommended) +ghost profile . --emit # writes ./fingerprint.md +ghost profile . --output my-system.md # or write to a specific path ``` **Compare two fingerprints:** ```bash # Profile two systems, then compare -ghost profile github:shadcn-ui/ui --output shadcn.json -ghost profile npm:@chakra-ui/react --output chakra.json +ghost profile github:shadcn-ui/ui --output shadcn.fingerprint.md +ghost profile npm:@chakra-ui/react --output chakra.fingerprint.md +ghost compare shadcn.fingerprint.md chakra.fingerprint.md + +# Legacy JSON still works ghost compare shadcn.json chakra.json ``` -**Check compliance against a parent:** +**Review drift — one verb, three scopes:** ```bash -ghost comply . --against parent-fingerprint.json -ghost comply . --against parent-fingerprint.json --format sarif +# files scope (default): zero-config PR review against ./fingerprint.md +ghost review +ghost review --staged --format github + +# project scope: target-level coherence against a parent (CLI/JSON/SARIF) +ghost review project . --against parent.fingerprint.md +ghost review project . --against parent.fingerprint.md --format sarif + +# verify: drive the generate→review loop across a bundled prompt suite +ghost verify ``` -**Scan for drift (config-based):** +**Local components vs registry:** ```bash -ghost scan --config ghost.config.ts +# Diff the local component tree against the configured registry +ghost drift +ghost drift --component Button +``` + +**Generation loop — ground, generate, observe:** + +```bash +# Emit a Claude Code / MCP skill bundle from a fingerprint +ghost emit context-bundle --out skills/my-design + +# Reference generator with built-in self-review retries +ghost generate "pricing page with three tiers" --out pricing.html ``` **Fleet observability and visualization:** ```bash -ghost fleet system-a.json system-b.json system-c.json --cluster -ghost viz system-a.json system-b.json system-c.json +ghost compare system-a.fingerprint.md system-b.fingerprint.md system-c.fingerprint.md --cluster +ghost viz system-a.fingerprint.md system-b.fingerprint.md system-c.fingerprint.md ``` -**Run the ghost-ui catalogue:** +**Run the docs site (design language + drift tooling + component catalogue):** ```bash just dev -# or: cd packages/ghost-ui && pnpm dev +# or: pnpm -F @ghost/docs dev ``` ## CLI Commands | Command | Description | | ---------------- | -------------------------------------------------------------------------------- | -| `ghost scan` | Scan for design drift against a registry | | `ghost profile` | Generate a fingerprint for any target (directory, URL, npm package, GitHub repo) | -| `ghost compare` | Compare two fingerprint JSON files with optional temporal analysis | -| `ghost diff` | Compare local components against registry with drift analysis | -| `ghost comply` | Check design system compliance against rules and a parent fingerprint | +| `ghost compare` | Compare 2+ fingerprints (pairwise, fleet, semantic, or temporal via flags) | +| `ghost review` | Unified drift perception. Scopes: `files` (default, PR drift check), `project [target] --against parent.md` (target coherence against a parent) | +| `ghost drift` | Diff local components against the registry — reports breaking changes | +| `ghost verify` | Run the bundled prompt suite against a fingerprint (classifies each dimension as tight/leaky/uncaptured) | | `ghost discover` | Find public design systems matching a query | +| `ghost emit` | Derive artifacts from fingerprint.md — `review-command` (slash command) or `context-bundle` (SKILL.md + tokens.css + prompt.md) | +| `ghost generate` | Reference generator — LLM → HTML with self-review retries against a fingerprint | +| `ghost lint` | Lint fingerprint.md schema and body/frontmatter drift | | `ghost ack` | Acknowledge current drift — record intentional stance toward parent | -| `ghost adopt` | Shift parent baseline to a new fingerprint | +| `ghost adopt` | Shift parent baseline to a new fingerprint | | `ghost diverge` | Declare intentional divergence on a dimension with reasoning | -| `ghost fleet` | Compare N fingerprint files for ecosystem-level observability | -| `ghost viz` | Launch interactive 3D fingerprint visualization | +| `ghost viz` | Launch interactive 3D fingerprint visualization | ### Target Types -`ghost profile` and `ghost comply` accept universal targets: +`ghost profile` and `ghost review project` accept universal targets: ```bash ghost profile . # current directory @@ -126,28 +153,20 @@ Optionally create a `ghost.config.ts` in your project root to configure scanning import { defineConfig } from "@ghost/core"; export default defineConfig({ - // Parent design system to check drift against + // Parent design system to compare components against parent: { type: "github", value: "shadcn-ui/ui" }, - // Targets to scan + // Targets for `ghost drift` targets: [ { type: "path", value: "./packages/my-ui" }, ], - scan: { - values: true, - structure: true, - visual: false, - analysis: false, - }, - rules: { "hardcoded-color": "error", "token-override": "warn", "missing-token": "warn", "structural-divergence": "error", "missing-component": "warn", - "visual-regression": "warn", }, ignore: [], @@ -174,6 +193,27 @@ export default defineConfig({ ## How It Works +### The Fingerprint + +Ghost's canonical artifact is **`fingerprint.md`** — a Markdown document with YAML frontmatter (machine layer) plus a three-layer prose body. It's human-readable, LLM-consumable, and diff-friendly: + +- **Frontmatter** — 49-dimensional embedding, palette, spacing, typography, surfaces, provenance. What deterministic tools read +- **`# Character`** — the opening atmosphere read: evocative, not technical. What an agent quotes to stay on-brand +- **`# Signature`** — 3–7 distinctive traits that make _this_ system unlike its peers. The drift-sensitive moves +- **`# Observation`** — prose paired with the frontmatter data, dimension by dimension +- **`# Decisions`** — abstract, implementation-agnostic choices with evidence. Each decision is embedded so `compare` can match semantically + +Generate one with `ghost profile . --emit`. See [`docs/fingerprint-format.md`](./docs/fingerprint-format.md) for the full spec. + +The 49-dim machine vector splits like this: + +| Dimensions | Category | What it captures | +| ---------- | ---------- | -------------------------------------------------------------- | +| 0-20 | Palette | Dominant colors (OKLCH), neutrals, semantic coverage, contrast | +| 21-30 | Spacing | Scale values, regularity, base unit, distribution | +| 31-40 | Typography | Font families, size ramp, weight distribution, line heights | +| 41-48 | Surfaces | Border radii, shadow complexity, border usage | + ### Scanning Ghost perceives drift at three levels: @@ -182,24 +222,31 @@ Ghost perceives drift at three levels: 2. **Structure** — Diffs component files between a consumer implementation and the registry source 3. **Visual** — Renders components with Playwright and performs pixel-level comparison using pixelmatch -### Fingerprinting +### Generation Loop -A fingerprint is a 64-dimensional vector — a continuous representation of a system's design characteristics: +Ghost doubles as pipeline infrastructure for AI-driven generation — the fingerprint grounds the generator, and `ghost review` surfaces drift in the output so humans can decide whether to acknowledge, adopt, or diverge: -| Dimensions | Category | What it captures | -| ---------- | ------------ | -------------------------------------------------------------- | -| 0-20 | Palette | Dominant colors (OKLCH), neutrals, semantic coverage, contrast | -| 21-30 | Spacing | Scale values, regularity, base unit, distribution | -| 31-40 | Typography | Font families, size ramp, weight distribution, line heights | -| 41-48 | Surfaces | Border radii, shadow complexity, border usage | -| 49-63 | Architecture | Tokenization ratio, methodology, component count, naming | +``` +fingerprint.md ──► [ghost emit context-bundle] ──► SKILL.md / tokens.css / prompt.md + │ + ▼ + any generator + (ghost generate, Cursor, + v0, in-house tool) + │ + ▼ HTML / JSX + [ghost review] ──► drift signal + (annotate / acknowledge / + adopt / diverge) +``` -Fingerprints can be generated deterministically from extracted material, from a shadcn-compatible registry, or with LLM assistance for richer interpretation. +Run `ghost verify` to drive the loop across a versioned prompt suite and classify each dimension as _tight_, _leaky_, or _uncaptured_ — the mechanism that tells the fingerprint where it needs to say more. See [`docs/generation-loop.md`](./docs/generation-loop.md) for details. ### Intent Tracking Ghost tracks design lineage and published intent through: +- **`fingerprint.md`** — The canonical fingerprint artifact - **`.ghost-sync.json`** — Per-dimension stances toward the parent: aligned, accepted, or diverging — each with recorded reasoning - **`.ghost/history.jsonl`** — Append-only fingerprint history for temporal analysis - **Temporal comparison** — Velocity and trajectory classification to understand where a system is heading, not just where it is @@ -219,7 +266,7 @@ Ghost UI (`@ghost/ui`) is the project's reference design language — atomic, co - **Design tokens** — A full token system (colors, spacing, typography, radii, shadows) defined as CSS custom properties with light and dark mode support - **Theme system** — Runtime theme switching with presets, a live theme panel for editing tokens, and CSS variable export - **HK Grotesk typeface** — Self-hosted display font (300–900 weights) paired with system sans-serif for body text -- **Live catalogue** — An interactive documentation site (React + Vite) with component demos, foundations pages, and a bento showcase +- **Docs site** — Interactive documentation (React + Vite) with drift tooling docs, design-language foundations, a live component catalogue, and a bento showcase — one visual language, one deploy ### Registry @@ -229,14 +276,14 @@ Ghost UI publishes a `registry.json` conforming to the [shadcn registry schema]( npx shadcn@latest add --registry https://your-ghost-ui-host/registry.json button card dialog ``` -Ghost itself can profile the registry to generate a fingerprint, then scan downstream consumers against it to detect drift: +Ghost itself can profile the registry to generate a fingerprint, then check downstream consumers against it to detect drift: ```bash -ghost profile --registry ./packages/ghost-ui/registry.json -ghost scan --config ghost.config.ts +ghost profile --registry ./packages/ghost-ui/registry.json --emit +ghost review project ./consumer-app --against fingerprint.md ``` -### Catalogue development +### Docs site development ```bash # dev server with hot reload @@ -268,43 +315,59 @@ node packages/ghost-mcp/dist/bin.js packages/ ghost-core/ Core library src/ - agents/ Director, FingerprintAgent, DiscoveryAgent, ComparisonAgent, ComplianceAgent + agents/ Director, ExpressionAgent, DiscoveryAgent, ComparisonAgent, ComplianceAgent stages/ Deterministic pipeline stages (extract, compare, comply) - fingerprint/ Fingerprinting engine (embedding, comparison, extraction) + embedding/ Embedding engine (vectors, comparison, extraction) evolution/ Evolution tracking (sync, temporal, fleet, history) - scanners/ Drift scanners (values, structure, visual) + scanners/ Component scanners (values, structure) extractors/ Material extraction (CSS, Tailwind) resolvers/ Registry and CSS resolution llm/ LLM providers (Anthropic, OpenAI) reporters/ Output formatting (CLI, JSON, fingerprint, fleet) - ghost-cli/ CLI interface (citty) + ghost-cli/ CLI interface (cac) — 11 unified verbs src/ - bin.ts Command definitions (scan, profile, compare, diff, comply, discover, etc.) - evolution-commands.ts ack, adopt, diverge, fleet commands - viz/ 3D visualization (Three.js, PCA projection) + bin.ts profile, compare, discover + review-command.ts review (files | project | suite scopes) + emit-command.ts emit (review-command | context-bundle kinds) + generate-command.ts generate (reference LLM generator with self-review) + evolution-commands.ts ack, adopt, diverge + viz/ 3D visualization (Three.js, PCA projection) + compare-mode.ts Pure compare-mode dispatch (testable) ghost-mcp/ MCP server for Ghost UI registry src/ tools.ts 5 MCP tools (search, get, install, categories, theme) resources.ts 2 MCP resources (registry, skills) - ghost-ui/ Reference design language (@ghost/ui) + ghost-ui/ Reference component library (@ghost/ui) src/ + index.ts Public API — all primitives, theme, hooks components/ - ui/ Primitive components (Radix + Tailwind) - ai-elements/ AI-native components (chat, code, agents) - theme/ ThemeProvider and theme toggle - theme-panel/ Live token editor panel - docs/ Catalogue pages, demos, and bento showcase - contexts/ Theme and theme-panel context providers + ui/ 49 primitive components (Radix + Tailwind) + ai-elements/ 48 AI-native components (chat, code, agents) + theme/ ThemeProvider, ThemeToggle hooks/ Shared React hooks - lib/ Utilities, registry helpers, theme presets - styles/ Design tokens and global CSS + lib/ cn + theme presets/defaults/utils + styles/ Design tokens, global CSS fonts/ HK Grotesk woff2 files registry.json shadcn-compatible component registry +apps/ + docs/ Deployed site (@ghost/docs) — one aesthetic, all content + src/ + app/ Routes: /, /tools, /tools/drift/*, /ui/* + components/ + docs/ Page layout, demos, bento showcase + theme-panel/ Live token editor panel + contexts/ Theme and theme-panel context + lib/ component-registry, theme metadata + vite.config.ts base = DEPLOY_BASE env skills/ Claude Code skill definitions - ghost-fingerprint/ Profile any design system + ghost-profile/ Profile any design system ghost-compare/ Compare two design systems ghost-drift-check/ Check design compliance ghost-discover/ Find public design systems + ghost-review/ Review files for drift against a fingerprint +docs/ + fingerprint-format.md The fingerprint.md spec + generation-loop.md Emit → generate → review pipeline ``` ## Development diff --git a/action/action.yml b/action/action.yml deleted file mode 100644 index 890844c..0000000 --- a/action/action.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "Ghost Design Review" -description: "Review pull requests for visual language drift against a design fingerprint" -author: "Ghost" - -inputs: - github-token: - description: "GitHub token for posting review comments" - required: true - default: ${{ github.token }} - fingerprint: - description: "Path to fingerprint JSON file" - required: false - default: ".ghost-fingerprint.json" - anthropic-api-key: - description: "Anthropic API key (or set ANTHROPIC_API_KEY env var)" - required: false - dimensions: - description: "Comma-separated dimensions to check: palette,spacing,typography,surfaces" - required: false - base: - description: "Base ref for diff" - required: false - default: ${{ github.event.pull_request.base.sha }} - -outputs: - issues-found: - description: "Number of issues found" - has-errors: - description: "Whether any errors were found (true/false)" - -runs: - using: "node20" - main: "dist/index.js" - -branding: - icon: "eye" - color: "purple" diff --git a/action/index.ts b/action/index.ts deleted file mode 100644 index 9b46f96..0000000 --- a/action/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Ghost Design Review — GitHub Action entrypoint. - * - * Runs fingerprint-informed review on PR changed files and posts - * inline suggestions as a GitHub PR review. - * - * Usage in workflow: - * - * - uses: block/ghost@v1 - * with: - * github-token: ${{ secrets.GITHUB_TOKEN }} - * - * Requires .ghost-fingerprint.json in the repo (run `ghost profile . --emit`). - */ - -import * as core from "@actions/core"; -import * as github from "@actions/github"; -import { - formatGitHubPRComments, - formatReviewSummary, - review, -} from "@ghost/core"; - -async function run() { - try { - const token = core.getInput("github-token", { required: true }); - const fingerprintPath = - core.getInput("fingerprint") || ".ghost-fingerprint.json"; - const anthropicApiKey = - core.getInput("anthropic-api-key") || process.env.ANTHROPIC_API_KEY; - const dimensionsInput = core.getInput("dimensions") || undefined; - const base = core.getInput("base") || undefined; - - // Parse dimensions - let dimensions: Record | undefined; - if (dimensionsInput) { - dimensions = {}; - for (const d of dimensionsInput.split(",")) { - const dim = d.trim(); - if (["palette", "spacing", "typography", "surfaces"].includes(dim)) { - dimensions[dim] = true; - } - } - for (const d of ["palette", "spacing", "typography", "surfaces"]) { - if (!dimensions[d]) dimensions[d] = false; - } - } - - const report = await review({ - diff: { base }, - fingerprintPath, - config: { - dimensions, - changedLinesOnly: true, - }, - llmConfig: anthropicApiKey - ? { provider: "anthropic", apiKey: anthropicApiKey } - : undefined, - }); - - // Set outputs - core.setOutput("issues-found", report.summary.totalIssues.toString()); - core.setOutput("has-errors", (report.summary.errors > 0).toString()); - - // Post PR review if we have issues and a PR context - const context = github.context; - if (context.payload.pull_request && report.summary.totalIssues > 0) { - const octokit = github.getOctokit(token); - const comments = formatGitHubPRComments(report); - const summaryBody = formatReviewSummary(report); - - await octokit.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - event: "COMMENT", - body: summaryBody, - comments: comments.map((c) => ({ - path: c.path, - line: c.line, - side: c.side, - body: c.body, - })), - }); - - core.info(`Posted review with ${comments.length} inline comments.`); - } else if (report.summary.totalIssues === 0) { - core.info("No design drift detected."); - } - - // Fail the action if errors found - if (report.summary.errors > 0) { - core.setFailed( - `Ghost found ${report.summary.errors} design drift errors.`, - ); - } - } catch (error) { - core.setFailed(error instanceof Error ? error.message : String(error)); - } -} - -run(); diff --git a/action/package.json b/action/package.json deleted file mode 100644 index cb4528b..0000000 --- a/action/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "ghost-action", - "version": "0.1.0", - "private": true, - "description": "Ghost Design Review GitHub Action", - "main": "dist/index.js", - "scripts": { - "build": "ncc build index.ts -o dist" - }, - "dependencies": { - "@actions/core": "^1.10.1", - "@actions/github": "^6.0.0", - "@ghost/core": "workspace:*" - }, - "devDependencies": { - "@vercel/ncc": "^0.38.0", - "typescript": "^5.7.0" - } -} diff --git a/packages/ghost-ui/index.html b/apps/docs/index.html similarity index 100% rename from packages/ghost-ui/index.html rename to apps/docs/index.html diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..0f8b21c --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,93 @@ +{ + "name": "@ghost/docs", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ghost/ui": "workspace:*", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@rive-app/react-webgl2": "^4.27.3", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tanstack/react-table": "^8.21.3", + "@xyflow/react": "^12.10.2", + "ai": "^6.0.141", + "ansi-to-react": "^6.2.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "gsap": "^3.14.2", + "input-otp": "^1.4.2", + "lucide-react": "^1.7.0", + "media-chrome": "^4.18.3", + "motion": "^12.38.0", + "nanoid": "^5.1.7", + "react-day-picker": "9.14.0", + "react-hook-form": "^7.72.0", + "react-jsx-parser": "^2.4.1", + "react-resizable-panels": "^4.8.0", + "react-router": "^7.5.0", + "recharts": "^3.8.1", + "shiki": "^4.0.2", + "sonner": "^2.0.7", + "split-type": "^0.3.4", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tokenlens": "^1.3.1", + "tw-animate-css": "^1.4.0", + "use-stick-to-bottom": "^1.1.3", + "vaul": "^1.1.2", + "zod": "^4.3.6", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^22.0.0", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "postcss": "^8.5.8", + "react": "19.1.0", + "react-dom": "19.1.0", + "tailwindcss": "^4.2.2", + "typescript": "^5.7.0", + "vite": "^6.3.0" + } +} diff --git a/packages/ghost-ui/public/placeholder.svg b/apps/docs/public/placeholder.svg similarity index 100% rename from packages/ghost-ui/public/placeholder.svg rename to apps/docs/public/placeholder.svg diff --git a/packages/ghost-ui/src/App.tsx b/apps/docs/src/App.tsx similarity index 96% rename from packages/ghost-ui/src/App.tsx rename to apps/docs/src/App.tsx index 3726098..b41c745 100644 --- a/packages/ghost-ui/src/App.tsx +++ b/apps/docs/src/App.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@ghost/ui"; import { Navigate, Route, Routes, useParams } from "react-router"; import ComponentPage from "@/app/components/[name]/page"; import ComponentsIndex from "@/app/components/page"; @@ -13,7 +14,6 @@ import HomePage from "@/app/page"; import ToolsIndex from "@/app/tools/page"; import DesignLanguageIndex from "@/app/ui/page"; import { Dock } from "@/components/docs/dock"; -import { ThemeProvider } from "@/components/theme/ThemeProvider"; function ComponentRedirect() { const { name } = useParams<{ name: string }>(); @@ -58,7 +58,7 @@ export function App() { } /> } /> - {/* Redirects from old URLs */} + {/* Redirects from old /docs/* and /foundations/* URLs */} } /> +

+ ghost {name} +

+

{description}

+
+        {usage}
+      
+ {flags.length > 0 && ( + + + + + + + + + {flags.map((f) => ( + + + + + ))} + +
FlagDescription
+ {f.flag} + {f.description}
+ )} + {example && ( +
+          {example}
+        
+ )} + + ); +} + +export default function CLIReferencePage() { + return ( + + + + + +

+ Ghost's canonical artifact is fingerprint.md — a + Markdown file with YAML frontmatter (machine layer) and a + three-layer prose body. Most commands accept a path to an{" "} + fingerprint.md. +

+

+ Commands are zero-config and default to{" "} + ./fingerprint.md in the current directory.{" "} + drift is the one exception — it still reads a{" "} + ghost.config.ts for the registry target. +

+
+ + + ", + description: "Path to ghost config file", + }, + { + flag: "-r, --registry ", + description: + "Path or URL to a registry.json (profiles registry directly)", + }, + { + flag: "-o, --output ", + description: "Write fingerprint to a file (must end in .md)", + }, + { + flag: "--emit", + description: + "Write fingerprint.md to project root (publishable artifact)", + }, + { + flag: "--max-iterations ", + description: "Cap agent exploration iterations (default: 99)", + }, + { + flag: "-v, --verbose", + description: "Show agent reasoning, confidence, and warnings", + }, + { + flag: "--format ", + description: 'Output format: "cli" (default) or "json"', + }, + ]} + example={`# Profile the current directory, save fingerprint.md +ghost profile . --emit + +# Profile a GitHub repo (verbose shows the agent's reasoning) +ghost profile github:shadcn-ui/ui --verbose + +# Profile multiple sources into a single fingerprint +ghost profile github:anthropics/claude-code https://claude.ai --output claude.fingerprint.md + +# Profile a remote shadcn registry directly +ghost profile -r https://ui.shadcn.com/registry.json`} + /> + + + + ", + description: + "Directory containing .ghost/history.jsonl (default: cwd)", + }, + { + flag: "--semantic", + description: + "Semantic diff of decisions/values/palette (N=2 only)", + }, + { + flag: "--cluster", + description: "Include cluster analysis (N≥3)", + }, + { + flag: "--components", + description: + "Compare local components against registry (reads ghost.config.ts; ignores fingerprint args)", + }, + { + flag: "--component ", + description: "Limit --components to one component", + }, + { + flag: "-c, --config ", + description: "Path to ghost config file (for --components)", + }, + { + flag: "--format", + description: 'Output format: "cli" (default) or "json"', + }, + ]} + example={`# Pairwise (N=2) +ghost compare parent.fingerprint.md consumer.fingerprint.md + +# With temporal drift analysis +ghost compare parent.fingerprint.md consumer.fingerprint.md --temporal + +# Semantic diff (decisions / values / palette) +ghost compare a.fingerprint.md b.fingerprint.md --semantic + +# Fleet (N≥3) with clustering +ghost compare *.fingerprint.md --cluster + +# Local components vs registry +ghost drift + +# Single component diff +ghost drift --component button`} + /> + + + + + +

+ Ghost sits as pipeline infrastructure for AI-driven UI generation.{" "} + ghost emit context-bundle produces a grounding bundle, + any generator (including ghost generate) produces,{" "} + ghost review gates the output, and{" "} + ghost verify runs the whole loop over a standard prompt + suite to aggregate drift. See{" "} + + Core Concepts + {" "} + for the shape of the loop. +

+ + ", + description: + "Source fingerprint file (default: ./fingerprint.md)", + }, + { + flag: "-o, --out ", + description: + "Output path (review-command → .claude/commands/design-review.md; context-bundle → ./ghost-context/)", + }, + { + flag: "--stdout", + description: + "Write to stdout instead of a file (review-command only)", + }, + { + flag: "--no-tokens", + description: "Skip tokens.css output (context-bundle)", + }, + { + flag: "--readme", + description: "Include README.md (context-bundle)", + }, + { + flag: "--prompt-only", + description: + "Emit only prompt.md — skips SKILL.md / fingerprint.md / tokens.css (context-bundle)", + }, + { + flag: "--name ", + description: + "Override the skill name — default: fingerprint id (context-bundle)", + }, + ]} + example={`# Emit a per-project design-review slash command +ghost emit review-command + +# Emit a Claude Code / MCP skill bundle +ghost emit context-bundle + +# Single prompt.md for plain-text LLM context +ghost emit context-bundle --prompt-only + +# Custom output directory +ghost emit context-bundle --out dist/context`} + /> + + ", + description: + "Path to fingerprint.md (default: ./fingerprint.md)", + }, + { + flag: "-o, --out ", + description: "Write artifact to file (default: stdout)", + }, + { + flag: "--format ", + description: 'Output format: "html" (default)', + }, + { + flag: "--retries ", + description: + "Max self-review retries after initial attempt (default: 2, cap 3)", + }, + { + flag: "--no-review", + description: "Skip self-review gate (faster, drift-blind)", + }, + { + flag: "--json", + description: + "Emit structured JSON {artifact, attempts, passed}", + }, + ]} + example={`# Generate a pricing page against the current fingerprint +ghost generate "pricing page with three tiers" --out pricing.html + +# Fast path: skip the self-review loop +ghost generate "hero section" --no-review --out hero.html + +# Machine-readable: per-attempt drift counts +ghost generate "dashboard" --json`} + /> + + ", + description: + "[files] Path to fingerprint (default: ./fingerprint.md)", + }, + { + flag: "--staged", + description: "[files] Review staged changes only", + }, + { + flag: "-b, --base ", + description: "[files] Base ref for git diff (default: HEAD)", + }, + { + flag: "--dimensions ", + description: + "[files] Comma-separated: palette, spacing, typography, surfaces", + }, + { + flag: "--all", + description: + "[files] Report issues on all lines, not just changed lines", + }, + // project + { + flag: "--against ", + description: + "[project] Parent fingerprint path to check drift against", + }, + { + flag: "--max-drift ", + description: + "[project] Maximum overall drift distance (default: 0.3)", + }, + { + flag: "-c, --config ", + description: "[project] Path to ghost config file", + }, + // suite + { + flag: "--suite ", + description: + "[suite] Path to a prompt suite JSON (default: bundled v0.1)", + }, + { + flag: "-n, --n ", + description: + "[suite] Subsample first N prompts (default: run all)", + }, + { + flag: "--concurrency ", + description: + "[suite] Max in-flight generate+review calls (default: 3)", + }, + { + flag: "--retries ", + description: + "[suite] Self-review retries per prompt (default: 1)", + }, + { + flag: "-o, --out ", + description: "[suite] Write JSON report to file", + }, + // shared + { + flag: "--format ", + description: + 'Output format: "cli" (default), "json", "github" (files only), "sarif" (project only)', + }, + { + flag: "-v, --verbose", + description: "Verbose output", + }, + ]} + example={`# files scope (default) — review uncommitted changes +ghost review +ghost review --staged --format github +ghost review src/components/hero.tsx -f design.fingerprint.md + +# project scope — target-level compliance against a parent +ghost review project . --against parent.fingerprint.md +ghost review project . --against parent.fingerprint.md --format sarif + +# suite scope — drive generate→review across a prompt suite +ghost verify +ghost verify -n 5 +ghost verify --out suite-report.json`} + /> +
+ + + ", + description: "Path to ghost config file", + }, + { + flag: "-d, --dimension", + description: + "Specific dimension to acknowledge (e.g. palette, spacing)", + }, + { + flag: "--stance", + description: '"aligned", "accepted", or "diverging"', + }, + { + flag: "--reason", + description: "Explanation for this acknowledgment", + }, + { + flag: "--format", + description: 'Output format: "cli" (default) or "json"', + }, + ]} + example={`# Acknowledge all dimensions as aligned +ghost ack --stance aligned --reason "Initial baseline" + +# Mark typography as intentionally diverging +ghost ack -d typography --stance diverging --reason "Brand refresh requires different type scale"`} + /> + + ", + description: "Path to ghost config file", + }, + { + flag: "-d, --dimension", + description: "Only adopt a specific dimension", + }, + { + flag: "--format", + description: 'Output format: "cli" (default) or "json"', + }, + ]} + example={`# Adopt a new parent fingerprint +ghost adopt new-parent.fingerprint.md`} + /> + + ", + description: "Path to ghost config file", + }, + { + flag: "-r, --reason", + description: "Why this dimension is diverging", + }, + { + flag: "--format", + description: 'Output format: "cli" (default) or "json"', + }, + ]} + example={`ghost diverge palette --reason "Dark-mode-first palette for this product"`} + /> + + + + + +
+ +

+ See{" "} + + Core Concepts + {" "} + for the ideas behind these commands, or{" "} + + Getting Started + {" "} + for a guided walkthrough. +

+
+
+
+ ); +} diff --git a/packages/ghost-ui/src/app/docs/concepts/page.tsx b/apps/docs/src/app/docs/concepts/page.tsx similarity index 79% rename from packages/ghost-ui/src/app/docs/concepts/page.tsx rename to apps/docs/src/app/docs/concepts/page.tsx index 5d7f389..71efa20 100644 --- a/packages/ghost-ui/src/app/docs/concepts/page.tsx +++ b/apps/docs/src/app/docs/concepts/page.tsx @@ -1,11 +1,11 @@ "use client"; +import { cn } from "@ghost/ui"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useEffect, useRef, useState } from "react"; import { AnimatedPageHeader } from "@/components/docs/animated-page-header"; import { SectionWrapper } from "@/components/docs/wrappers"; -import { cn } from "@/lib/utils"; gsap.registerPlugin(ScrollTrigger); @@ -283,7 +283,7 @@ function RadarChart({ ); } -function FingerprintSection() { +function ExpressionSection() { const [animated, setAnimated] = useState(false); const ref = useRef(null); @@ -352,23 +352,23 @@ function FingerprintSection() { ); } -/* ──────────────────── 3. The Scan — Three lenses ─────────────────────── */ +/* ──────────────────── 3. The Review — Three scopes ───────────────────── */ -type ScanLens = "values" | "structure" | "visual"; +type ReviewScope = "files" | "project" | "suite"; -const SCAN_LENSES: { - id: ScanLens; +const REVIEW_SCOPES: { + id: ReviewScope; name: string; what: string; catches: string; visual: React.ReactNode; }[] = [ { - id: "values", - name: "Values", - what: "Compares design tokens against the parent registry.", + id: "files", + name: "Files", + what: "Scans code changes against the fingerprint. Zero-config: reads ./fingerprint.md; flags changed lines by default.", catches: - "Hardcoded colors, overridden tokens, missing tokens — the invisible constants that quietly diverge.", + "Hardcoded colors outside the palette, off-scale spacing, type choices that violate the fingerprint's decisions.", visual: (
@@ -382,56 +382,62 @@ const SCAN_LENSES: {
- #ef4444 ← hardcoded, should be a token + #ef4444 ← hardcoded, not in palette
- --accent: missing + padding: 13px ← off scale
), }, { - id: "structure", - name: "Structure", - what: "Diffs component files between your code and the parent.", + id: "project", + name: "Project", + what: "Profiles a whole target and compares its fingerprint against a parent. CI gate with CLI, JSON, or SARIF output.", catches: - "Added props, removed variants, altered APIs — changes to what components can do.", + "Cumulative drift across an entire system — per-dimension deltas and a threshold gate you can fail builds on.", visual: (
-
Button.tsx
-
- + variant: "ghost" | "outline" |{" "} - "brand" +
+ Overall drift: 0.17 / 0.3 + threshold
-
-   size: "sm" | "md" | "lg" +
+ palette{" "} + = 0.05   aligned
-
- - loading?: boolean +
+ spacing{" "} + ~ 0.22   drifting +
+
+ surfaces{" "} + ≠ 0.41   diverged
), }, { - id: "visual", - name: "Visual", - what: "Renders components and performs pixel-level comparison.", + id: "suite", + name: "Suite", + what: "Drives the generate→review loop across a bundled prompt suite. Classifies each dimension as tight, leaky, or uncaptured.", catches: - "Subtle rendering differences from CSS specificity, font rendering, or layout shifts — things code diffs miss.", + "Gaps in the fingerprint — dimensions the generator drifts on because the Decisions under-specify them.", visual: ( -
-
- Expected +
+
18 prompts · 14 passed
+
+ palette tight (0.4) +
+
+ spacing leaky (2.1)
-
-
- Actual -
-
+
+ motion uncaptured (3.7)
), @@ -439,14 +445,14 @@ const SCAN_LENSES: { ]; function ScanSection() { - const [active, setActive] = useState("values"); - const lens = SCAN_LENSES.find((l) => l.id === active)!; + const [active, setActive] = useState("files"); + const lens = REVIEW_SCOPES.find((l) => l.id === active)!; return (
{/* Tab bar */}
- {SCAN_LENSES.map((l) => ( + {REVIEW_SCOPES.map((l) => ( `, props: [ @@ -553,7 +553,7 @@ const docs: Record = { CardTitle, CardDescription, CardContent, -} from "@/components/ui/card"; +} from "@ghost/ui"; @@ -583,7 +583,7 @@ const docs: Record = { DialogHeader, DialogTitle, DialogDescription, -} from "@/components/ui/dialog"; +} from "@ghost/ui"; @@ -614,7 +614,7 @@ const docs: Record = { input: { description: "A styled text input with focus ring, validation states, and file input support.", - usage: `import { Input } from "@/components/ui/input"; + usage: `import { Input } from "@ghost/ui"; `, props: [ @@ -636,7 +636,7 @@ const docs: Record = { tabs: { description: "A tabbed interface for switching between panels of related content.", - usage: `import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + usage: `import { Tabs, TabsList, TabsTrigger, TabsContent } from "@ghost/ui"; diff --git a/packages/ghost-ui/src/lib/component-registry.ts b/apps/docs/src/lib/component-registry.ts similarity index 98% rename from packages/ghost-ui/src/lib/component-registry.ts rename to apps/docs/src/lib/component-registry.ts index df380a5..9907582 100644 --- a/packages/ghost-ui/src/lib/component-registry.ts +++ b/apps/docs/src/lib/component-registry.ts @@ -1,4 +1,4 @@ -import registryData from "../../registry.json"; +import registryData from "@ghost/ui/registry.json"; // ── Category metadata ── // Display order and names for the functional categories in registry.json. diff --git a/packages/ghost-ui/src/lib/component-source.ts b/apps/docs/src/lib/component-source.ts similarity index 98% rename from packages/ghost-ui/src/lib/component-source.ts rename to apps/docs/src/lib/component-source.ts index 31f075e..3557235 100644 --- a/packages/ghost-ui/src/lib/component-source.ts +++ b/apps/docs/src/lib/component-source.ts @@ -1,4 +1,4 @@ -import registryData from "../../registry.json"; +import registryData from "@ghost/ui/registry.json"; // ── Types ── diff --git a/packages/ghost-ui/src/main.tsx b/apps/docs/src/main.tsx similarity index 76% rename from packages/ghost-ui/src/main.tsx rename to apps/docs/src/main.tsx index a833076..484f111 100644 --- a/packages/ghost-ui/src/main.tsx +++ b/apps/docs/src/main.tsx @@ -1,13 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router"; +import "@ghost/ui/styles.css"; import { App } from "./App"; -import "./styles/main.css"; import "./styles/dev-fonts.css"; createRoot(document.getElementById("root")!).render( - + , diff --git a/packages/ghost-ui/src/store/preferences-store.ts b/apps/docs/src/store/preferences-store.ts similarity index 100% rename from packages/ghost-ui/src/store/preferences-store.ts rename to apps/docs/src/store/preferences-store.ts diff --git a/packages/ghost-ui/src/styles/dev-fonts.css b/apps/docs/src/styles/dev-fonts.css similarity index 100% rename from packages/ghost-ui/src/styles/dev-fonts.css rename to apps/docs/src/styles/dev-fonts.css diff --git a/packages/ghost-ui/src/types/split-type.d.ts b/apps/docs/src/types/split-type.d.ts similarity index 100% rename from packages/ghost-ui/src/types/split-type.d.ts rename to apps/docs/src/types/split-type.d.ts diff --git a/packages/ghost-ui/src/types/theme.ts b/apps/docs/src/types/theme.ts similarity index 100% rename from packages/ghost-ui/src/types/theme.ts rename to apps/docs/src/types/theme.ts diff --git a/packages/ghost-ui/src/vite-env.d.ts b/apps/docs/src/vite-env.d.ts similarity index 100% rename from packages/ghost-ui/src/vite-env.d.ts rename to apps/docs/src/vite-env.d.ts diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 0000000..a88c2b3 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ghost-ui/vite.config.ts b/apps/docs/vite.config.ts similarity index 77% rename from packages/ghost-ui/vite.config.ts rename to apps/docs/vite.config.ts index e2b0345..e936925 100644 --- a/packages/ghost-ui/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -1,6 +1,6 @@ +import { resolve } from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; -import { resolve } from "path"; import { defineConfig } from "vite"; export default defineConfig({ @@ -10,5 +10,5 @@ export default defineConfig({ "@": resolve(__dirname, "src"), }, }, - base: process.env.VITE_BASE_PATH || "/", + base: process.env.DEPLOY_BASE ?? "/", }); diff --git a/biome.json b/biome.json index 8129c64..bda865e 100644 --- a/biome.json +++ b/biome.json @@ -35,7 +35,7 @@ }, "overrides": [ { - "includes": ["packages/ghost-ui/**"], + "includes": ["packages/ghost-ui/**", "apps/docs/**"], "linter": { "enabled": false } diff --git a/docs/cli-consolidation-plan.md b/docs/cli-consolidation-plan.md new file mode 100644 index 0000000..2f35dc0 --- /dev/null +++ b/docs/cli-consolidation-plan.md @@ -0,0 +1,246 @@ +--- +name: CLI Consolidation Plan +status: draft — 2026-04-19 +owner: nahiyan +branch: refactor/fingerprint (or a follow-up branch) +--- + +# Ghost CLI consolidation — 18 verbs → 9 + +## Target surface + +``` +# Produce +ghost profile [targets] # extract fingerprint.md from target(s) (unchanged) +ghost emit # derive static artifacts from fingerprint.md + # kinds: review-command, context-bundle +ghost generate # LLM-produce UI artifact from fingerprint (unchanged) + +# Check +ghost review [scope] # unified drift detection + # scopes: files (default), project, suite + +# Compare +ghost compare [...] # N-way fingerprint comparison + # flags: --semantic, --cluster, --temporal + +# Govern +ghost ack # acknowledge drift stance (kept at top level — coherent trio) +ghost adopt +ghost diverge + +# Utility +ghost lint [fingerprint] # fingerprint.md hygiene (unchanged) +ghost discover [query] # find public systems (unchanged) +ghost viz <...> # 3D visualization (unchanged) +``` + +**9 top-level verbs.** Three removed (`scan`, `expr-diff`, `fleet`), three absorbed (`comply`, `verify`, `context`), one absorbed (`diff` — the component-vs-registry one, see phase 5). + +--- + +## Old → new mapping + +| Old | New | Notes | +|---|---|---| +| `scan` | **removed** | Legacy; required `ghost.config.ts`. Supersed by `review project`. | +| `profile` | `profile` | unchanged | +| `compare a b` | `compare a b` | unchanged | +| `compare a b --temporal` | `compare a b --temporal` | unchanged | +| `expr-diff a b` | `compare a b --semantic` | merged | +| `fleet a b c …` | `compare a b c … [--cluster]` | merged; N ≥ 2 auto-fleet | +| `diff [component]` | `compare --components [component]` | merged (rarely used) | +| `comply [target] [--against]` | `review project [target] [--against]` | merged into review | +| `verify [fingerprint]` | `review suite [fingerprint]` | merged into review | +| `review [files]` | `review [files]` or `review files [files]` | kept as default scope | +| `discover [query]` | `discover [query]` | unchanged | +| `ack` | `ack` | unchanged | +| `adopt ` | `adopt ` | unchanged | +| `diverge ` | `diverge ` | unchanged | +| `viz <…>` | `viz <…>` | unchanged | +| `context [expr]` | `emit context-bundle [--fingerprint …]` | merged into emit | +| `emit ` (currently only `review`) | `emit ` | kind renamed `review` → `review-command` for clarity | +| `generate ` | `generate ` | unchanged | +| `lint [expr]` | `lint [expr]` | unchanged | + +--- + +## Phase breakdown + +Each phase is one PR. Phases are independent; stopping at any point leaves the CLI coherent. + +### Phase 1 — Low-risk wins (delete + merge diff-verbs) + +**Removes:** `scan`, `expr-diff`, `fleet`, `diff` as top-level verbs. + +**Changes:** +- Delete `scan` command and its `src/scan.ts` codepath. + - Guard: check no test or doc depends on `scan`. Grep shows CLI doc page references it. +- Extend `compare` to accept N arguments. + - When N=2: pairwise (current behavior). + - When N≥3 or `--cluster`: fleet comparison (current `compareFleet`). +- Add `--semantic` flag to `compare`: dispatches to `diffFingerprints` instead of `compareFingerprints` (vector). +- Add `--components` flag to `compare`: dispatches to the existing `diff()` codepath (local vs registry). +- Remove old files: `expr-diff-command.ts`, fleet bits in `evolution-commands.ts` (keep ack/adopt/diverge), scan logic. + +**Touched files:** +- `packages/ghost-cli/src/bin.ts` — remove scan block, expand compare block +- `packages/ghost-cli/src/expr-diff-command.ts` — delete +- `packages/ghost-cli/src/evolution-commands.ts` — remove `registerFleetCommand` +- `packages/ghost-core/src/scan.ts` — delete (verify no imports) +- `packages/ghost-core/src/index.ts` — drop `scan` export +- Docs: `README.md`, `apps/docs/src/app/docs/cli/page.tsx`, `skills/ghost-profile/SKILL.md` +- Tests: remove/update scan tests, expr-diff tests; add compare-with-N tests + +**Public API note:** `scan` is exported from ghost-core. Removing it is a breaking change at the library level. Since ghost is 0.2.0, acceptable. Flag in release notes. + +**Exit criteria:** `pnpm check && pnpm test && pnpm build` green; help text shows 14 verbs (was 18). + +--- + +### Phase 2 — Emit consolidation (context + emit → one verb) + +**Merges:** `context` into `emit`. + +**Changes:** +- Rename `emit` kinds for clarity: + - `emit review` → `emit review-command` (explicit: produces `.claude/commands/design-review.md`) + - Add `emit context-bundle` → current `context` behavior (writes `ghost-context/` dir) +- Keep `context` as a deprecated alias for one release that prints a warning and forwards to `emit context-bundle`. +- Delete `context-command.ts`; fold logic into `emit-command.ts`. + +**Touched files:** +- `packages/ghost-cli/src/emit-command.ts` — expand kind dispatch +- `packages/ghost-cli/src/context-command.ts` — delete (or keep as thin deprecated shim) +- `packages/ghost-cli/src/bin.ts` — remove `registerContextCommand` import/call +- Docs, SKILL.md + +**Rationale for not folding `generate`:** `generate` takes a user prompt, does LLM calls, has its own retry/review loop. It's a runtime command, not a static derivation. Keep separate. + +**Exit criteria:** `ghost emit review-command`, `ghost emit context-bundle` work; `ghost context` still works with deprecation warning; 13 verbs (or 12 once `context` is removed next release). + +--- + +### Phase 3 — Drift consolidation (the big one) + +**Merges:** `comply` and `verify` into `review` as scopes. + +**Changes:** +- Introduce scope subcommands on `review`: + - `ghost review` / `ghost review files [files]` — current review behavior (code drift in files) + - `ghost review project [target] [--against parent.md]` — current comply behavior + - `ghost review suite [fingerprint] [--suite …]` — current verify behavior +- First positional is treated as scope name if it matches `files|project|suite`; otherwise treated as file list (backward compat for bare `ghost review src/`). +- Keep `comply` and `verify` as deprecated aliases for one release. + +**Flag surface for `review`:** +``` +Common: + --format cli | json | github | sarif (not all scopes support all) + --fingerprint override fingerprint source + +review files [files] + --staged staged changes only + --base git diff base + --dimensions palette,spacing,typography,surfaces + --all all lines, not just changed + +review project [target] + --against drift check against parent + --max-drift threshold (default: 0.3) + --verbose agent reasoning + +review suite [fingerprint] + --suite custom suite json + -n subsample + --concurrency + --retries +``` + +**Touched files:** +- `packages/ghost-cli/src/review-command.ts` — major rewrite: scope dispatch +- `packages/ghost-cli/src/verify-command.ts` — delete or shim +- `packages/ghost-cli/src/bin.ts` — remove comply block; remove verify registration +- Core: `packages/ghost-core/src/review/pipeline.ts` stays; `verify/` stays; the Director's `comply` method stays (library API preserved) +- Docs: biggest rewrite here — review is now three pages, not three commands +- Tests: expand review test to cover scope dispatch; keep verify/comply tests targeting the library APIs + +**Library API invariant:** `review()`, `verify()`, and `Director.comply()` all stay exported from `@ghost/core`. This is what the GitHub Action uses. The CLI restructure is presentation-layer only. + +**Exit criteria:** 10 verbs (9 after removing deprecation shims). The four drift verbs are now one. + +--- + +### Phase 4 — Flag hygiene on `profile` + +Not verb-level but worth doing while the CLI is unstable. + +**Changes:** +- Unify output flags: + - Drop `--emit-legacy` (legacy format is read-only supported; don't write it). + - Keep `--emit` as "write fingerprint.md to project root" convenience. + - Keep `--output ` for explicit path; extension (.md / .json) picks format. + - Drop `--format` on profile — it conflicts with `--output`. Use `--output -.json` or `-.md` if a user really wants to choose. + +Actually, the above reduces to: keep `--emit` (convenience) and `--output` (explicit). Drop `--emit-legacy` and `--format`. + +**Touched files:** `bin.ts` profile block, `profile.ts`, docs. + +**Exit criteria:** `ghost profile` has 6 flags instead of 9. + +--- + +### Phase 5 — Optional: governance grouping + +**Deferred.** Not worth the churn. + +`ack` / `adopt` / `diverge` are already a coherent trio, three verbs with clear meanings. Grouping under `ghost evolve ` would cost a migration for no readability win. Skip unless user feedback says otherwise. + +--- + +## Documentation surface to update + +Per phase, rewrite: +- `README.md` — CLI reference table +- `CLAUDE.md` — CLI commands table +- `packages/ghost-ui/src/app/docs/cli/page.tsx` — full CLI docs page +- `skills/ghost-profile/SKILL.md` +- Any mentions in `docs/*.md` + +Docs rewrite is the single biggest non-code task per phase. Roughly 1:1 time with the code work. + +--- + +## Risk register + +| Risk | Mitigation | +|---|---| +| Library API changes break GitHub Action | API untouched — only CLI presentation changes. Verify `action/index.ts` imports still resolve after each phase. | +| User muscle memory on old verbs | Keep deprecated aliases for one release with console warnings. Remove in v0.3.0 or v0.4.0. | +| Docs drift behind code | Treat doc rewrites as blocking for each PR, not follow-up. | +| Phase 3 is invasive | Ship phases 1–2 first; they stand alone. Phase 3 can be its own focused PR. | +| Hidden consumers | Grep `ghost-cli` and `ghost profile|compare|review|comply|verify|scan|diff|expr-diff|fleet|context|emit|generate|ack|adopt|diverge|lint|viz|discover` across the repo before each phase. | + +--- + +## Recommended order + +1. **Phase 1** (delete scan, merge diff verbs) — ship now, standalone win. +2. **Phase 4** (profile flag cleanup) — ride along with phase 1 or just after. Tiny. +3. **Phase 2** (emit consolidation) — ship next, self-contained. +4. **Phase 3** (drift consolidation) — ship last, most invasive, highest payoff. +5. **Phase 5** — skip. + +Total code time estimate: ~1–2 days per phase including docs and tests. Phase 3 is closer to 2 days. + +--- + +## Not in scope + +- Renaming `profile` (parked — keeping `profile`). +- Renaming `fingerprint.md`. +- New commands. +- Changing the library API (`@ghost/core` exports). +- Touching the Director orchestration layer. + +The scope is CLI surface only. Core stays stable. diff --git a/docs/fingerprint-format.md b/docs/fingerprint-format.md new file mode 100644 index 0000000..6c4d93f --- /dev/null +++ b/docs/fingerprint-format.md @@ -0,0 +1,354 @@ +# The `fingerprint.md` format + +A Ghost **fingerprint** is a single Markdown file that captures what a design system is trying to say — readable and editable by humans, natively consumable by LLMs, with a structured machine layer for `ghost compare`, `ghost review`, and friends. + +The file has two parts, and each owns **different data**: + +1. **Frontmatter (YAML)** — the **machine layer**. Identity, tokens, dimension slugs, evidence, personality/closestSystems tags, embedding. Validated by zod. Read by deterministic tools. +2. **Body (Markdown)** — the **prose layer**. Character paragraph, Signature bullets, Decision rationale. Read by humans and LLMs. + +Each field lives in exactly one place. There is no precedence rule because there is nothing to conflict over. + +Canonical filename: `fingerprint.md` (flat, no dotfile, no slug prefix). Zero-config default for every Ghost command that reads a fingerprint. + +Current schema version: **4**. + +Schema 4 extracts the 49-dimensional `embedding` into a sibling `embedding.md` fragment and adds a loose `metadata:` bag for LLM-authored extensions. The pattern mirrors [agent skills](https://agentskills.io/): a thin index file references sibling fragments via ordinary markdown links. + +--- + +## The partition (the one rule) + +The frontmatter and the body own disjoint fields. The reader unions them into a single in-memory Fingerprint. + +| Fingerprint field | Lives in | Section / key | +|---|---|---| +| `id`, `source`, `timestamp`, `sources` | Frontmatter | top-level | +| `observation.personality`, `observation.closestSystems` | Frontmatter | `observation:` | +| `observation.summary` | **Body** | `# Character` | +| `observation.distinctiveTraits` | **Body** | `# Signature` bullets | +| `decisions[].dimension`, `decisions[].evidence`, `decisions[].embedding` | Frontmatter | `decisions:` entry | +| `decisions[].decision` (prose rationale) | **Body** | `### dimension` block | +| `palette`, `spacing`, `typography`, `surfaces` | Frontmatter | top-level | +| `roles[]` (slot → token bindings) | Frontmatter | `roles:` | +| `embedding` (49-dim vector) | **Sibling file** | `embedding.md` (referenced from `# Fragments`) | +| `metadata` (loose extension bag) | Frontmatter | top-level, open-ended | + +The zod schema is `.strict()` on structural blocks — putting prose fields (summary, decision rationale) in YAML is a validation error. The writer enforces the other direction: serialization puts prose only in the body. The `metadata:` bag is the one escape hatch: a loose `Record` for LLM-authored extensions (e.g. `tone: magazine`) that don't fit the strict blocks. It's opaque to comparisons — never feeds the embedding. + +Schema 1 and 2 tried to mirror narrative fields across both sides and pick a winner. That split was the source of every "did my edit count?" confusion. Schema 3 removed the duplication. Schema 4 then extracts the embedding into a sibling fragment so the index stays thin and agents can progressively disclose context (cheap metadata first, vector on demand). + +--- + +## Frontmatter schema + +Validated by a zod schema (`packages/ghost-core/src/fingerprint/schema.ts`) and published as JSON Schema at `schemas/fingerprint.schema.json`. Below is the shape: + +```yaml +--- +# --- meta --- +name: Claude # display name +slug: claude # kebab-case id +schema: 4 # format version — required, rejected on mismatch +generator: ghost@0.9.0 # tool + version that produced this file +generated: 2026-04-18T00:00:00Z # ISO-8601 (alias for `timestamp`) +confidence: 0.87 # 0–1, overall inference confidence (optional) +extends: ./parent.fingerprint.md # optional — inherit from a parent (see Composition) +metadata: # optional — loose extension bag + tone: magazine + era: 2020s-editorial + +# --- fingerprint: identity --- +id: claude +source: llm # registry | extraction | llm | unknown +timestamp: 2026-04-18T00:00:00Z +sources: # optional, lists the targets that were combined + - github:anthropics/claude-code + - https://claude.ai + +# --- fingerprint: narrative tags --- +# NOTE: prose (summary, distinctiveTraits, decision rationale) lives +# in the body under # Character, # Signature, ### blocks. +observation: + personality: [restrained, editorial] + closestSystems: [notion, linear] + +decisions: + - dimension: warm-only-neutrals + evidence: ["#5e5d59", "#87867f", "#4d4c48"] + - dimension: serif-headlines + evidence: ["H1-H6 serif 500"] + +# --- fingerprint: structured tokens --- +palette: + dominant: + - { role: accent, value: '#c96442' } + - { role: surface, value: '#f5f4ed' } + neutrals: + steps: ['#faf9f5', '#e8e6dc', '#87867f', '#5e5d59', '#4d4c48', '#141413'] + count: 6 + semantic: + - { role: error, value: '#b53333' } + - { role: focus, value: '#3898ec' } + saturationProfile: muted # muted | vibrant | mixed + contrast: moderate # high | moderate | low + +typography: + families: ['Anthropic Serif', 'Anthropic Sans', 'Anthropic Mono'] + sizeRamp: [12, 14, 15, 16, 17, 20, 25.6, 32, 52, 64] + weightDistribution: { 400: 0.6, 500: 0.4 } + lineHeightPattern: loose # tight | normal | loose + +spacing: + scale: [4, 8, 12, 16, 24, 32] + baseUnit: 8 # null if no coherent base + regularity: 0.85 # 0–1 + +surfaces: + borderRadii: [8, 12, 16, 32] + shadowComplexity: subtle # none | subtle | layered + borderUsage: moderate # minimal | moderate | heavy + +# --- fingerprint: role bindings (optional) --- +# Semantic slot → token bindings. Bridges abstract tokens to rendering: +# a role names a slot (h1, card, button, …) and binds specific tokens +# from the dimensions above. Each sub-block is optional; omit what you +# cannot infer from source. Agents populate these from component files. +roles: + - name: h1 + tokens: + typography: { family: Anthropic Serif, size: 52, weight: 500 } + spacing: { margin: 32 } + evidence: ["components/Heading.tsx:12"] + - name: card + tokens: + surfaces: { borderRadius: 16, shadow: subtle } + spacing: { padding: 24 } + palette: { background: '#f5f4ed' } + evidence: ["components/ui/card.tsx"] + +# --- fingerprint: vector layer --- +# embedding is OPTIONAL at root in v4. Readers load it from the sibling +# `embedding.md` fragment (referenced in the body) or recompute from the +# structural blocks above. Omitting it keeps this file lean. +--- +``` + +**Required:** `id`, `source`, `timestamp`, `palette`, `spacing`, `typography`, `surfaces`. +**Required-but-conditional:** `schema` (if present, must equal 4). Missing `schema:` is warned but accepted. +**Optional:** `embedding` (omit to let readers load from `embedding.md` or recompute), `metadata` (loose key-value extension bag). +**Optional narrative tags:** `observation.personality`, `observation.closestSystems`, `decisions[]`. Omit rather than lie — a missing tag is truer than a fabricated one. +**Optional role bindings:** `roles[]`. Each role requires `name` and `evidence[]`; token sub-blocks (`typography`, `spacing`, `surfaces`, `palette`) are independently optional and strict — unknown keys reject. +**Optional meta:** `name`, `slug`, `generator`, `confidence`, `generated`, `sources`, `extends`. +**Forbidden in frontmatter:** `observation.summary`, `observation.distinctiveTraits`, `decisions[].decision`. These live in the body. + +When `extends:` is present, required fingerprint fields may be omitted — the child inherits them from the parent. The merged result is re-validated against the strict schema. + +--- + +## Body + +The body owns prose. Four section kinds, all optional, in this order: + +```markdown +# Character + +A literary salon reimagined as a product page — warm, unhurried. + +# Signature + +- Warm ring-shadows instead of drop-shadows +- Editorial serif/sans split + +# Decisions + +### warm-only-neutrals +Every gray carries a yellow-brown undertone. No cool blue-grays. + +### serif-headlines +All headlines use Serif 500. UI uses Sans 400–500. +``` + +The parser matches `### dimension` blocks to frontmatter `decisions[].dimension` by slug. A body block without a frontmatter entry is appended to the decisions list with empty evidence (and flagged `orphan-prose` by `ghost lint`). A frontmatter entry without a body block carries empty rationale (flagged `missing-rationale`). + +**Evidence does not appear in the body.** It lives in the frontmatter under `decisions[].evidence`. Legacy `**Evidence:**` bullets from schema 2 files are flagged by `ghost lint` as `stray-evidence-in-body`. + +### `# Fragments` section + +The body may also carry a `# Fragments` section that lists sibling files by markdown link: + +```markdown +# Fragments + +- [embedding](embedding.md) — 49-dim vector for compare/fleet/viz +``` + +Readers walk these links to progressively load sibling content. The current v4 writer always emits a link to `embedding.md` when the fingerprint carries an embedding (see [Embedding fragment](#embedding-fragment)). Future fragment types (palette, typography, motion, …) follow the same pattern: an entry in `# Fragments`, an own-validated file next to `fingerprint.md`. + +Link rules: + +- Only `.md` targets count as fragments. +- Absolute URLs (`http://…`) and anchors (`#foo`) are ignored. +- Paths are resolved relative to the fingerprint.md directory. +- One level deep — avoid nested chains. + +--- + +## Roles — the slot → token bridge + +Tokens alone are ingredients: "sizes 14, 16, 20, 32, 64 exist." A role is a recipe: "`h1` uses size 64, weight 500." `roles[]` is the layer that names which tokens belong to which semantic slot, so the fingerprint stops being an inventory and becomes something a renderer can act on. + +**Shape.** Each role has three parts: + +- `name` — the slot. Prefer HTML-like or archetype names: `h1`, `h2`, `body`, `caption`, `card`, `button`, `input`, `list-row`. +- `tokens` — the bindings, grouped by dimension. Each sub-block (`typography`, `spacing`, `surfaces`, `palette`) is independently optional and every field inside is optional. A role can be partial when the source only supplies some tokens. +- `evidence` — where the binding was observed. File paths or `path:line` references. + +**Authoring contract.** Only emit roles with direct source evidence. A plausible-but-unobserved role is worse than a missing one. A codebase with no component files may produce no roles at all — that is truthful. + +**Strictness.** The `tokens` sub-blocks are zod `.strict()` — unknown keys reject, so the schema stays disciplined as it grows. Add a field to the schema before emitting it. + +--- + +## Embedding fragment + +Schema 4 extracts the 49-dimensional embedding into `embedding.md` next to the fingerprint. The file carries only YAML — no prose: + +```markdown +--- +schema: 4 +kind: embedding +of: claude # parent fingerprint id +dimensions: 49 +vector: + - 0.218 + - 0 + - 0.249 + # …47 more floats… +--- +``` + +**Loader resolution order:** + +1. Inline `embedding:` in the root frontmatter (trusted as cache). +2. Body link to `embedding.md` (or other `.md` link matching `embedding.md`). +3. Conventional sibling `embedding.md` next to `fingerprint.md`. +4. Recompute from the structural blocks via `computeEmbedding`. + +Missing or stale files are never fatal — the loader silently falls back to recompute. Skip backfill entirely with `loadFingerprint(path, { noEmbeddingBackfill: true })`. + +The writer emits the sibling automatically when `serializeFingerprint(fp)` is called with `extractEmbedding: true` (default). Set `extractEmbedding: false` to keep the vector inline — useful for in-memory round-trips where no sibling is written. + +--- + +## Composition (`extends:`) + +A child fingerprint can inherit from a parent: + +```yaml +--- +schema: 4 +extends: ./parent.fingerprint.md +id: child-system +decisions: + - dimension: warm-neutrals + evidence: ["#3a3630"] +--- + +# Decisions + +### warm-neutrals +Now we also forbid warm grays. +``` + +**Merge rules** (see `packages/ghost-core/src/fingerprint/compose.ts`): + +- **Scalars / arrays:** child replaces parent when present. +- **`decisions[]`:** merged by `dimension` — child wins per-dim; parent-only decisions preserved. +- **`palette.dominant` / `palette.semantic`:** merged by `role` — child wins per-role. + +Cycles throw. Chains are resolved depth-first. After resolution, `extends:` is stripped from the returned meta. + +Skip resolution: `loadFingerprint(path, { noExtends: true })`. + +--- + +## Decision fragments + +Large systems can split decisions across files. If a `decisions/` directory sits next to the fingerprint.md, each `*.md` inside is read as a single decision and merged in by dimension: + +``` +my-system/ +├── fingerprint.md +└── decisions/ + ├── warm-neutrals.md + ├── serif-headlines.md + └── ring-shadows.md +``` + +Fragment format (evidence lives in the fragment's own frontmatter; prose is the body): + +```markdown +--- +dimension: warm-neutrals # optional — falls back to filename stem +evidence: ['#5e5d59', '#87867f'] # optional +--- + +Every gray carries a yellow-brown undertone. No cool blue-grays exist anywhere. +``` + +Fragments override inline decisions with the same dimension. Skip with `loadFingerprint(path, { noFragments: true })`. + +--- + +## Validation + +`parseFingerprint` runs two gates on every read (unless `skipValidation: true`): + +1. **Schema version gate.** `schema:` must equal 4. Stale files throw with a regenerate hint. +2. **Zod strict validation.** Structural errors (including unknown keys like `summary:` in YAML) are collected and surfaced with field paths: + + ``` + Invalid fingerprint frontmatter: + • observation: Unrecognized keys: "summary", "distinctiveTraits" + • decisions.0: Unrecognized key: "decision" + • palette.saturationProfile: Invalid enum value... + ``` + +For tooling that wants to inspect partial or in-progress files, `skipValidation` bypasses both gates. + +--- + +## Tooling surface + +| Command | Does | +|---|---| +| `ghost profile . --emit` | Write `fingerprint.md` (frontmatter machine-facts + body prose) | +| `ghost lint [path]` | Check schema validity, orphan prose, missing rationale, stray evidence in body, broken palette citations | +| `ghost compare --semantic` | Semantic diff: decisions added/removed/modified, value deltas, palette role swaps, token changes | +| `ghost compare ` | Vector distance (quantitative — use `--semantic` for qualitative) | +| `ghost emit context-bundle` | Emit a grounding skill bundle (`SKILL.md` + `fingerprint.md` + `tokens.css`) | +| `ghost emit review-command` | Emit a per-project drift-review slash command (`.claude/commands/design-review.md`) | + +Programmatic API (`@ghost/core`): `loadFingerprint`, `parseFingerprint`, `serializeFingerprint`, `lintFingerprint`, `compareFingerprints`, `mergeExpression`, `loadDecisionFragments`, `loadEmbeddingFragment`, `serializeEmbeddingFragment`, `findFragmentLinks`, `resolveEmbeddingReference`, `FrontmatterSchema`, `toJsonSchema`. + +--- + +## What's deliberately excluded + +- **Duplication.** A field cannot live in both places. Trying to put prose in YAML is a validation error; the writer never emits prose there. +- **Implementation-specific tokens.** No framework names, no CSS-in-JS specifics, no component library assumptions. Decisions are abstract ("warm-only neutrals"), not concrete ("`neutral-50` in `tailwind.config.js`"). +- **Confidence theatre.** If the generator isn't sure, omit `confidence` or set `source: unknown`. Fabricated `1.0` is worse than missing. +- **Schema migration.** Schema 1, 2, and 3 files are rejected outright. Regenerate with `ghost profile . --emit`. + +--- + +## JSON Schema + +`schemas/fingerprint.schema.json` is regenerated from the zod source: + +```bash +pnpm --filter @ghost/core build && node scripts/emit-fingerprint-schema.mjs +``` + +Point your editor at it via a comment or `yaml.schemas` config for autocomplete in the frontmatter. diff --git a/docs/generation-loop.md b/docs/generation-loop.md new file mode 100644 index 0000000..9deaf67 --- /dev/null +++ b/docs/generation-loop.md @@ -0,0 +1,104 @@ +# Generation Loop + +Ghost sits as pipeline infrastructure for AI-driven UI generation. The +`fingerprint.md` is the grounding input; `ghost review` is the post-generation +gate. The three new commands wire it together. + +## Pipeline shape + +``` +fingerprint.md ──► [ghost emit context-bundle] ──► SKILL.md / tokens.css / prompt.md + │ + ▼ + any generator + (ghost generate, Cursor, + v0, in-house tool) + │ + ▼ HTML / JSX + [ghost review] ──► drift disposition + (block / annotate + / ack / adopt) +``` + +## Commands + +### `ghost emit context-bundle [flags]` + +Emit a grounding bundle any generator can consume. Default output writes +`SKILL.md` + `fingerprint.md` + `tokens.css` into `./ghost-context/`. + +Flags: +- `--out ` — output directory (default: `./ghost-context`) +- `--prompt-only` — single `prompt.md` only; skips `SKILL.md` / `fingerprint.md` / `tokens.css` +- `--no-tokens` — skip `tokens.css` +- `--readme` — include `README.md` +- `--name ` — override the skill name (default: fingerprint id) + +Point a Claude Code or MCP client at the output directory and the agent +reads `SKILL.md`. + +### `ghost generate --fingerprint ` + +Reference generator. Loads the fingerprint, builds a system prompt from +Character/Signature/Decisions/Values + tokens, calls the LLM, extracts HTML, +and (by default) runs `ghost review` against its own output. If `errors > 0`, +it injects drift feedback and retries. Hard-capped to 3 retries. + +Not meant to compete with Cursor / v0 / in-house tools. It exists so the loop +is provable end-to-end, and so `ghost verify` has something to drive. + +Flags: +- `--no-review` — skip self-review (drift-blind, fast) +- `--retries ` — max retries, default 2, capped at 3 +- `--json` — structured output with per-attempt drift counts + +### `ghost verify [fingerprint] --n ` + +Run the generate→review loop over a versioned prompt suite (bundled v0.1, +~18 prompts). Aggregates drift per dimension and classifies: + +- **tight** (mean < 1): fingerprint reproduces faithfully +- **leaky** (1–3): generator drifts here often — tighten Decisions or Values +- **uncaptured** (≥ 3): fingerprint likely under-specifies this dimension + +Output is a per-dimension report plus actionable recommendations. The killer +demo: run `verify` on a mature fingerprint, intentionally drop a section +(e.g. motion), re-run, watch drift rise in dimensions that lost grounding. + +## The standard prompt suite + +Versioned JSON of UI-construction tasks, each tagged with the fingerprint +dimensions it stresses. Bundled inside core (see +`packages/ghost-core/src/verify/suite-v0.1.json`), also available as a runtime +TS constant (`BUNDLED_SUITE`) so it ships with compiled output. + +Tagging prompts with dimensions means we can distinguish *targeted* drift +(a pricing-page prompt leaking spacing) from *incidental* drift (the same +prompt leaking color, which it wasn't supposed to stress). + +## How the three-layer fingerprint format earns its keep + +Each layer has a concrete job somewhere in the loop: + +| Layer | Role in the loop | +|---|---| +| **Character** | Prompt context — shapes feel | +| **Signature** | Numeric signal in review and verify | +| **Decisions** | Lookup table the generator consults for specific choices | + +If a layer doesn't pull weight somewhere, that's a signal the format is +over-specified. Verify is the schema-discipline mechanism. + +## Integration patterns + +**CI**: `ghost review --against fingerprint.md` as a required check on PRs +that touch UI files. + +**In a generation pipeline**: `ghost emit context-bundle` writes the skill bundle into the +generator's context; the generator produces; `ghost review` gates the output. +Drift disposition belongs to the pipeline owner (block, annotate, +require `ghost ack`). + +**Fingerprint maintenance**: run `ghost verify` periodically. When a dimension +shows up consistently leaky, the fingerprint needs more Decisions or Values +rules for that dimension. diff --git a/justfile b/justfile index 2ce8d9b..4770a5d 100644 --- a/justfile +++ b/justfile @@ -41,17 +41,29 @@ test-watch: # ── Run ────────────────────────────────────────────────────── -# Run ghost-ui catalogue dev server +# Run docs dev server (design language + drift docs + component catalogue) dev: - cd packages/ghost-ui && pnpm dev + pnpm -F @ghost/docs dev -# Build ghost-ui catalogue (static export) +# Build docs site (static export) build-ui: - cd packages/ghost-ui && pnpm build + pnpm -F @ghost/docs build + +# Build @ghost/ui library (dist-lib + types) +build-lib: + pnpm -F @ghost/ui build:lib # Build ghost-ui shadcn registry build-registry: - cd packages/ghost-ui && pnpm build:registry + pnpm -F @ghost/ui build:registry + +# Build docs site for GitHub Pages (base=/ghost/) +build-pages: + DEPLOY_BASE="/ghost/" pnpm -F @ghost/docs build + rm -rf dist + mkdir -p dist + cp -r apps/docs/dist/. dist/ + cp dist/index.html dist/404.html # ── Utilities ──────────────────────────────────────────────── diff --git a/packages/ghost-cli/package.json b/packages/ghost-cli/package.json index 6cdeb5c..2272500 100644 --- a/packages/ghost-cli/package.json +++ b/packages/ghost-cli/package.json @@ -11,7 +11,7 @@ "dist" ], "scripts": { - "build": "tsc --build && cp -r src/viz dist/viz" + "build": "tsc --build && rm -rf dist/skill-bundle && cp -r src/skill-bundle dist/skill-bundle" }, "dependencies": { "@ghost/core": "workspace:*", diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index e322d75..2f551ff 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { existsSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; // Load .env from project root if present. @@ -16,305 +15,105 @@ for (const envFile of [".env", ".env.local"]) { } } -import type { DesignFingerprint, EmbeddingConfig } from "@ghost/core"; +import { readFile } from "node:fs/promises"; import { - compareFingerprints, - computeTemporalComparison, - diff, - formatCLIReport, + compare, + FINGERPRINT_FILENAME, formatComparison, formatComparisonJSON, - formatComplianceCLI, - formatComplianceJSON, - formatComplianceSARIF, - formatDiffCLI, - formatDiffJSON, - formatDiscoveryCLI, - formatDiscoveryJSON, - formatFingerprint, - formatFingerprintJSON, - formatJSONReport, + formatFleetComparison, + formatFleetComparisonJSON, + formatSemanticDiff, formatTemporalComparison, formatTemporalComparisonJSON, - loadConfig, - profile, - profileMultiTarget, - profileRegistry, - profileTarget, + lintFingerprint, + loadFingerprint, readHistory, readSyncManifest, - resolveTarget, - scan, } from "@ghost/core"; import { cac } from "cac"; +import { registerEmitCommand } from "./emit-command.js"; import { registerAckCommand, registerAdoptCommand, registerDivergeCommand, - registerFleetCommand, } from "./evolution-commands.js"; -import { registerReviewCommand } from "./review-command.js"; -import { registerVizCommand } from "./viz-command.js"; const cli = cac("ghost"); -// --- scan --- -cli - .command("scan", "Scan for design drift") - .option("-c, --config ", "Path to ghost config file") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .option("--no-color", "Disable colored output") - .action(async (opts) => { - try { - const config = await loadConfig(opts.config); - const report = await scan(config); - const output = - opts.format === "json" - ? formatJSONReport(report) - : formatCLIReport(report); - process.stdout.write(output); - process.exit(report.summary.errors > 0 ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - -// --- profile --- +// --- compare --- +// N=2 → pairwise (with optional --semantic / --temporal enrichment). +// N≥3 → fleet (pairwise matrix + clusters). cli .command( - "profile [...targets]", - "Generate a design fingerprint — accepts one or more targets (directory, URL, npm package, GitHub repo)", - ) - .option("-c, --config ", "Path to ghost config file") - .option( - "-r, --registry ", - "Path or URL to a registry.json (profiles registry directly)", + "compare [...fingerprints]", + "Compare two or more fingerprints (N≥3 = fleet).", ) - .option("-o, --output ", "Write fingerprint to file") - .option( - "--emit", - "Write .ghost-fingerprint.json to project root (publishable artifact)", - ) - .option("--ai", "Enable AI-powered enrichment (requires LLM API key)") - .option( - "--max-iterations ", - "Maximum agent iterations for exploration (default: 99). Lower for faster/cheaper runs.", - ) - .option("-v, --verbose", "Show agent reasoning") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (targets: string[], opts) => { - try { - let fingerprint: DesignFingerprint; - - if (opts.registry) { - let embeddingConfig: EmbeddingConfig | undefined; - try { - const cfg = await loadConfig(opts.config); - embeddingConfig = cfg.embedding; - } catch { - // No config file is fine - } - fingerprint = await profileRegistry(opts.registry, embeddingConfig); - } else { - const targetStrings = targets.length > 0 ? targets : ["."]; - const maxIterations = opts.maxIterations - ? Number.parseInt(String(opts.maxIterations), 10) - : undefined; - - if (targetStrings.length === 1 && targetStrings[0] === ".") { - const config = await loadConfig(opts.config); - fingerprint = await profile(config, { emit: opts.emit }); - } else if (targetStrings.length === 1) { - const config = await loadConfig(opts.config); - const target = resolveTarget(targetStrings[0]); - - if (opts.verbose) { - console.log(`Profiling ${target.type}: ${target.value}`); - } - - const result = await profileTarget(target, config); - fingerprint = result.fingerprint; - - if (opts.verbose) printVerboseResult(result); - } else { - const config = await loadConfig(opts.config); - const ts = targetStrings.map((s) => resolveTarget(s)); - - if (opts.verbose) { - console.log(`Profiling ${ts.length} sources:`); - for (const t of ts) console.log(` ${t.type}: ${t.value}`); - console.log(); - } - - const result = await profileMultiTarget(ts, config, { - maxIterations, - }); - fingerprint = result.fingerprint; - - if (opts.verbose) printVerboseResult(result); - } - } - - const output = - opts.format === "json" - ? formatFingerprintJSON(fingerprint) - : formatFingerprint(fingerprint); - - if (opts.output) { - await writeFile(opts.output, formatFingerprintJSON(fingerprint)); - console.log(`Fingerprint written to ${opts.output}`); - } - - if (opts.emit) { - console.log("Published .ghost-fingerprint.json"); - } - - process.stdout.write(`${output}\n`); - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - -function printVerboseResult(result: { - confidence: number; - reasoning: string[]; - warnings: string[]; -}) { - console.log(`Confidence: ${result.confidence.toFixed(2)}`); - for (const r of result.reasoning) { - console.log(` ${r}`); - } - if (result.warnings.length > 0) { - console.log("Warnings:"); - for (const w of result.warnings) { - console.log(` ! ${w}`); - } - } - console.log(); -} - -// --- compare --- -cli - .command("compare ", "Compare two design fingerprints") + .option("--semantic", "Qualitative diff of decisions + palette (N=2 only)") .option( "--temporal", - "Include temporal data: velocity, trajectory, ack status", + "Add velocity, trajectory, and ack bounds (N=2, reads .ghost/history.jsonl)", ) .option( "--history-dir ", "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", ) .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (source: string, target: string, opts) => { + .action(async (fingerprints: string[], opts) => { try { - const sourceData = await readFile(source, "utf-8"); - const targetData = await readFile(target, "utf-8"); - - const src: DesignFingerprint = JSON.parse(sourceData); - const tgt: DesignFingerprint = JSON.parse(targetData); - - const comparison = compareFingerprints(src, tgt, { - includeVectors: opts.temporal, - }); + const parsed = await Promise.all( + fingerprints.map((path) => loadFingerprint(path)), + ); + const exprs = parsed.map((p) => p.fingerprint); + let history: Awaited> | undefined; + let manifest: Awaited> | null = null; if (opts.temporal) { const historyDir = opts.historyDir ?? process.cwd(); - const [history, manifest] = await Promise.all([ + [history, manifest] = await Promise.all([ readHistory(historyDir), readSyncManifest(historyDir), ]); + } - const temporal = computeTemporalComparison({ - comparison, - history, - manifest, - }); + const result = compare(exprs, { + semantic: Boolean(opts.semantic), + history, + manifest, + }); - const output = - opts.format === "json" - ? formatTemporalComparisonJSON(temporal) - : formatTemporalComparison(temporal); + const isJson = opts.format === "json"; + if (result.mode === "fleet") { + const output = isJson + ? formatFleetComparisonJSON(result.fleet) + : formatFleetComparison(result.fleet); process.stdout.write(`${output}\n`); - process.exit(temporal.distance > 0.5 ? 1 : 0); - return; + process.exit(0); } - const output = - opts.format === "json" - ? formatComparisonJSON(comparison) - : formatComparison(comparison); - - process.stdout.write(`${output}\n`); - process.exit(comparison.distance > 0.5 ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - -// --- diff --- -cli - .command( - "diff [component]", - "Compare local components against registry with drift analysis", - ) - .option("-c, --config ", "Path to ghost config file") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (component: string | undefined, opts) => { - try { - const config = await loadConfig(opts.config); - const results = await diff(config, component || undefined); - - const output = - opts.format === "json" - ? formatDiffJSON(results) - : formatDiffCLI(results); - - process.stdout.write(output); - - const hasBreaking = results.some((r) => - r.components.some((c) => c.severity === "error"), - ); - process.exit(hasBreaking ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - -// --- discover --- -cli - .command("discover [query]", "Find public design systems matching a query") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (query: string | undefined, opts) => { - try { - const { Director } = await import("@ghost/core"); - const config = await loadConfig().catch(() => undefined); - const director = new Director(); - const result = await director.discover( - { query: query || undefined }, - { llm: config?.llm ?? (undefined as never) }, - ); + if (result.semantic) { + if (isJson) { + process.stdout.write(`${JSON.stringify(result.semantic, null, 2)}\n`); + } else { + process.stdout.write(formatSemanticDiff(result.semantic)); + } + process.exit(result.semantic.unchanged ? 0 : 1); + } - const output = - opts.format === "json" - ? formatDiscoveryJSON(result.data) - : formatDiscoveryCLI(result.data); + if (result.temporal) { + const output = isJson + ? formatTemporalComparisonJSON(result.temporal) + : formatTemporalComparison(result.temporal); + process.stdout.write(`${output}\n`); + process.exit(result.temporal.distance > 0.5 ? 1 : 0); + } + const output = isJson + ? formatComparisonJSON(result.comparison) + : formatComparison(result.comparison); process.stdout.write(`${output}\n`); - process.exit(0); + process.exit(result.comparison.distance > 0.5 ? 1 : 0); } catch (err) { console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, @@ -323,70 +122,40 @@ cli } }); -// --- comply --- +// --- lint --- cli .command( - "comply [target]", - "Check design system compliance against rules and parent", - ) - .option( - "--against ", - "Path to parent fingerprint JSON to check drift against", + "lint [fingerprint]", + "Validate fingerprint.md schema and body/frontmatter coherence", ) - .option("--max-drift ", "Maximum overall drift distance (default: 0.3)", { - default: "0.3", - }) - .option("-c, --config ", "Path to ghost config file") - .option("--format ", "Output format: cli, json, or sarif", { - default: "cli", - }) - .option("-v, --verbose", "Show agent reasoning") - .action(async (target: string | undefined, opts) => { + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (path: string | undefined, opts) => { try { - const { Director } = await import("@ghost/core"); - const config = await loadConfig(opts.config); - const targetStr = target || "."; - const resolvedTarget = resolveTarget(targetStr); - - let parentFingerprint: DesignFingerprint | undefined; - if (opts.against) { - const data = await readFile(opts.against, "utf-8"); - parentFingerprint = JSON.parse(data); - } + const target = resolve(process.cwd(), path ?? FINGERPRINT_FILENAME); + const raw = await readFile(target, "utf-8"); + const report = lintFingerprint(raw); - const director = new Director(); - const { fingerprint, compliance } = await director.comply( - resolvedTarget, - { - parentFingerprint, - thresholds: { - maxOverallDrift: Number.parseFloat(String(opts.maxDrift)), - }, - }, - { - llm: config.llm ?? (undefined as never), - embedding: config.embedding, - verbose: opts.verbose, - }, - ); - - if (opts.verbose) { - console.log(`Profiled ${resolvedTarget.type}: ${resolvedTarget.value}`); - console.log(`Confidence: ${fingerprint.confidence.toFixed(2)}`); - console.log(); - } - - let output: string; - if (opts.format === "sarif") { - output = formatComplianceSARIF(compliance.data); - } else if (opts.format === "json") { - output = formatComplianceJSON(compliance.data); + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); } else { - output = formatComplianceCLI(compliance.data); + for (const issue of report.issues) { + const prefix = + issue.severity === "error" + ? "ERROR" + : issue.severity === "warning" + ? "WARN " + : "INFO "; + const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; + process.stdout.write( + `${prefix} [${issue.rule}] ${issue.message}${pathSuffix}\n`, + ); + } + process.stdout.write( + `\n${report.errors} error(s), ${report.warnings} warning(s), ${report.info} info\n`, + ); } - process.stdout.write(`${output}\n`); - process.exit(compliance.data.passed ? 0 : 1); + process.exit(report.errors > 0 ? 1 : 0); } catch (err) { console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, @@ -396,12 +165,10 @@ cli }); // Commands defined in other files register themselves on the same cli instance -registerReviewCommand(cli); -registerFleetCommand(cli); registerAckCommand(cli); registerAdoptCommand(cli); registerDivergeCommand(cli); -registerVizCommand(cli); +registerEmitCommand(cli); cli.help(); cli.version("0.2.0"); diff --git a/packages/ghost-cli/src/emit-command.ts b/packages/ghost-cli/src/emit-command.ts new file mode 100644 index 0000000..591a413 --- /dev/null +++ b/packages/ghost-cli/src/emit-command.ts @@ -0,0 +1,158 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { + emitReviewCommand, + loadFingerprint, + writeContextBundle, +} from "@ghost/core"; +import type { CAC } from "cac"; +import { loadSkillBundle } from "./skill-bundle.js"; + +const DEFAULT_FINGERPRINT = "fingerprint.md"; +const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; +const DEFAULT_CONTEXT_OUT = "ghost-context"; +const DEFAULT_SKILL_OUT = ".claude/skills/ghost-drift"; + +export const SUPPORTED_KINDS = [ + "review-command", + "context-bundle", + "skill", +] as const; +export type EmitKind = (typeof SUPPORTED_KINDS)[number]; + +export type ParseEmitKindResult = + | { ok: true; kind: EmitKind } + | { ok: false; error: string }; + +/** + * Validate the positional emit kind against the supported set. + * Exported for unit testing. + */ +export function parseEmitKind(raw: string): ParseEmitKindResult { + if ((SUPPORTED_KINDS as readonly string[]).includes(raw)) { + return { ok: true, kind: raw as EmitKind }; + } + return { + ok: false, + error: `unknown emit kind '${raw}'. Supported: ${SUPPORTED_KINDS.join(", ")}`, + }; +} + +export function registerEmitCommand(cli: CAC): void { + cli + .command( + "emit ", + `Emit a derived artifact from fingerprint.md (kinds: ${SUPPORTED_KINDS.join(", ")})`, + ) + .option( + "-e, --fingerprint ", + `Source fingerprint file (default: ${DEFAULT_FINGERPRINT})`, + ) + .option( + "-o, --out ", + `Output path (review-command → ${DEFAULT_REVIEW_OUT}; context-bundle → ${DEFAULT_CONTEXT_OUT}/; skill → ${DEFAULT_SKILL_OUT}/)`, + ) + .option( + "--stdout", + "Write to stdout instead of a file (review-command only)", + ) + // context-bundle flags: + .option("--no-tokens", "Skip tokens.css output (context-bundle)") + .option("--readme", "Include README.md (context-bundle)") + .option( + "--prompt-only", + "Emit only prompt.md — skips SKILL.md / fingerprint.md / tokens.css (context-bundle)", + ) + .option( + "--name ", + "Override the skill name (default: fingerprint id) (context-bundle)", + ) + .action(async (kind: string, opts) => { + try { + const parsed = parseEmitKind(kind); + if (!parsed.ok) { + console.error(`Error: ${parsed.error}`); + process.exit(2); + } + + if (parsed.kind === "skill") { + const outDir = resolve( + process.cwd(), + (opts.out as string | undefined) ?? DEFAULT_SKILL_OUT, + ); + const bundle = loadSkillBundle(); + const written: string[] = []; + for (const file of bundle) { + const outPath = resolve(outDir, file.path); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, file.content, "utf-8"); + written.push(file.path); + } + process.stdout.write( + `Wrote ${written.length} file${written.length === 1 ? "" : "s"} to ${outDir}:\n`, + ); + for (const f of written) process.stdout.write(` ${f}\n`); + process.exit(0); + } + + const fingerprintPath = resolve( + process.cwd(), + opts.fingerprint ?? DEFAULT_FINGERPRINT, + ); + + if (parsed.kind === "review-command") { + const loaded = await loadFingerprint(fingerprintPath, { + noEmbeddingBackfill: true, + }); + const content = emitReviewCommand({ + fingerprint: loaded.fingerprint, + }); + + if (opts.stdout) { + process.stdout.write(content); + process.exit(0); + } + + const outPath = resolve( + process.cwd(), + opts.out ?? DEFAULT_REVIEW_OUT, + ); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, content, "utf-8"); + console.log(`Wrote ${outPath}`); + process.exit(0); + } + + // kind === "context-bundle" + const outDir = resolve( + process.cwd(), + (opts.out as string | undefined) ?? DEFAULT_CONTEXT_OUT, + ); + + const { fingerprint } = await loadFingerprint(fingerprintPath); + const result = await writeContextBundle(fingerprint, { + outDir, + tokens: opts.tokens !== false, + readme: Boolean(opts.readme), + promptOnly: Boolean(opts.promptOnly), + name: opts.name as string | undefined, + sourcePath: fingerprintPath, + }); + + process.stdout.write( + `Wrote ${result.files.length} file${ + result.files.length === 1 ? "" : "s" + } to ${result.outDir}:\n`, + ); + for (const f of result.files) { + process.stdout.write(` ${f}\n`); + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); +} diff --git a/packages/ghost-cli/src/evolution-commands.ts b/packages/ghost-cli/src/evolution-commands.ts index 0aca849..a04fcb1 100644 --- a/packages/ghost-cli/src/evolution-commands.ts +++ b/packages/ghost-cli/src/evolution-commands.ts @@ -1,16 +1,20 @@ -import { readFile } from "node:fs/promises"; -import type { DesignFingerprint, DimensionStance, Target } from "@ghost/core"; +import { resolve } from "node:path"; +import type { DimensionStance, Target } from "@ghost/core"; import { acknowledge, - compareFleet, - formatFleetComparison, - formatFleetComparisonJSON, + FINGERPRINT_FILENAME, loadConfig, - profile, + loadFingerprint, resolveParent, } from "@ghost/core"; import type { CAC } from "cac"; +async function loadLocalExpression() { + const path = resolve(process.cwd(), FINGERPRINT_FILENAME); + const { fingerprint } = await loadFingerprint(path); + return fingerprint; +} + export function registerAckCommand(cli: CAC): void { cli .command( @@ -36,7 +40,7 @@ export function registerAckCommand(cli: CAC): void { } const parentFp = await resolveParent(config.parent); - const childFp = await profile(config); + const childFp = await loadLocalExpression(); const { manifest, comparison } = await acknowledge({ child: childFp, @@ -87,16 +91,12 @@ export function registerAdoptCommand(cli: CAC): void { "adopt ", "Shift parent reference — adopt a new fingerprint as baseline", ) - .option("-c, --config ", "Path to ghost config file") .option("-d, --dimension ", "Adopt only for a specific dimension") .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (source: string, opts) => { try { - const sourceData = await readFile(source, "utf-8"); - const newParent: DesignFingerprint = JSON.parse(sourceData); - - const config = await loadConfig(opts.config); - const childFp = await profile(config); + const { fingerprint: newParent } = await loadFingerprint(source); + const childFp = await loadLocalExpression(); const newParentRef: Target = { type: "path", value: source }; @@ -155,7 +155,7 @@ export function registerDivergeCommand(cli: CAC): void { } const parentFp = await resolveParent(config.parent); - const childFp = await profile(config); + const childFp = await loadLocalExpression(); const { manifest } = await acknowledge({ child: childFp, @@ -191,44 +191,3 @@ export function registerDivergeCommand(cli: CAC): void { } }); } - -export function registerFleetCommand(cli: CAC): void { - cli - .command( - "fleet [...fingerprints]", - "Compare N fingerprints for an ecosystem-level view", - ) - .option("--cluster", "Include cluster analysis") - .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (paths: string[], opts) => { - try { - if (paths.length < 2) { - console.error("Error: fleet requires at least 2 fingerprint paths"); - process.exit(2); - } - - const members = await Promise.all( - paths.map(async (p) => { - const data = await readFile(p, "utf-8"); - const fingerprint: DesignFingerprint = JSON.parse(data); - return { id: fingerprint.id, fingerprint }; - }), - ); - - const fleet = compareFleet(members, { cluster: Boolean(opts.cluster) }); - - const output = - opts.format === "json" - ? formatFleetComparisonJSON(fleet) - : formatFleetComparison(fleet); - - process.stdout.write(`${output}\n`); - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} diff --git a/packages/ghost-cli/src/review-command.ts b/packages/ghost-cli/src/review-command.ts deleted file mode 100644 index 9f382cc..0000000 --- a/packages/ghost-cli/src/review-command.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - formatGitHubPRComments, - formatReviewCLI, - formatReviewJSON, - formatReviewSummary, - review, -} from "@ghost/core"; -import type { CAC } from "cac"; - -export function registerReviewCommand(cli: CAC): void { - cli - .command( - "review [files]", - "Review files for visual language drift against a design fingerprint", - ) - .option( - "-f, --fingerprint ", - "Path to fingerprint JSON (default: .ghost-fingerprint.json)", - ) - .option("--staged", "Review staged changes only") - .option("-b, --base ", "Base ref for git diff (default: HEAD)") - .option( - "--format ", - "Output format: cli, json, github (default: cli)", - { - default: "cli", - }, - ) - .option( - "--dimensions ", - "Comma-separated dimensions to check: palette,spacing,typography,surfaces", - ) - .option("--all", "Report issues on all lines, not just changed lines") - .action(async (filesArg: string | undefined, opts) => { - try { - // Parse dimensions flag - let dimensions: Record | undefined; - if (opts.dimensions) { - dimensions = {}; - for (const d of String(opts.dimensions).split(",")) { - const dim = d.trim(); - if ( - dim === "palette" || - dim === "spacing" || - dim === "typography" || - dim === "surfaces" - ) { - dimensions[dim] = true; - } - } - for (const d of ["palette", "spacing", "typography", "surfaces"]) { - if (!dimensions[d]) dimensions[d] = false; - } - } - - const files = - filesArg && typeof filesArg === "string" - ? filesArg - .split(",") - .map((f) => f.trim()) - .filter(Boolean) - : undefined; - - const report = await review({ - files: files && files.length > 0 ? files : undefined, - diff: - !files || files.length === 0 - ? { - base: opts.base as string | undefined, - staged: Boolean(opts.staged), - } - : undefined, - fingerprintPath: opts.fingerprint as string | undefined, - config: { - dimensions, - changedLinesOnly: !opts.all, - }, - }); - - let output: string; - switch (opts.format) { - case "json": - output = formatReviewJSON(report); - break; - case "github": { - const comments = formatGitHubPRComments(report); - const summary = formatReviewSummary(report); - output = JSON.stringify({ summary, comments }, null, 2); - break; - } - default: - output = formatReviewCLI(report); - } - - process.stdout.write(`${output}\n`); - process.exit(report.summary.errors > 0 ? 1 : 0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} diff --git a/packages/ghost-cli/src/skill-bundle.ts b/packages/ghost-cli/src/skill-bundle.ts new file mode 100644 index 0000000..c11e619 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle.ts @@ -0,0 +1,48 @@ +/** + * Ghost's agentskills.io-compatible skill bundle. + * + * The bundle's source files live in `src/skill-bundle/` as real markdown + * and are copied verbatim into `dist/skill-bundle/` by the build step. + * This loader walks the dist directory at runtime and returns a flat list + * of files so `ghost emit skill` can write them into a target project. + * + * Spec: https://agentskills.io/specification + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface SkillBundleFile { + /** Path relative to the skill root (e.g. "SKILL.md", "references/schema.md"). */ + path: string; + content: string; +} + +const BUNDLE_ROOT = fileURLToPath(new URL("./skill-bundle", import.meta.url)); + +function walk(dir: string, root: string, out: SkillBundleFile[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const absolute = join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolute, root, out); + continue; + } + if (!entry.isFile()) continue; + out.push({ + path: relative(root, absolute), + content: readFileSync(absolute, "utf-8"), + }); + } +} + +export function loadSkillBundle(): SkillBundleFile[] { + const out: SkillBundleFile[] = []; + walk(BUNDLE_ROOT, BUNDLE_ROOT, out); + out.sort((a, b) => { + if (a.path === "SKILL.md") return -1; + if (b.path === "SKILL.md") return 1; + return a.path.localeCompare(b.path); + }); + return out; +} diff --git a/packages/ghost-cli/src/skill-bundle/SKILL.md b/packages/ghost-cli/src/skill-bundle/SKILL.md new file mode 100644 index 0000000..b322856 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/SKILL.md @@ -0,0 +1,60 @@ +--- +name: ghost-drift +description: Detect and manage visual-language drift in design systems. Use when the user wants to write or update a fingerprint.md, review frontend code changes for design drift, compare design fingerprints, verify generated UI against a fingerprint, or discover public design systems. Triggers on phrases like "profile this design system", "check for drift", "review this PR for design issues", "write a fingerprint.md", "compare fingerprints", or whenever a `fingerprint.md` file is present and styling/design work is happening. +license: Apache-2.0 +metadata: + homepage: https://github.com/block/ghost + cli: ghost +--- + +# Ghost — Design Drift Detection + +Ghost captures a project's visual language as an **`fingerprint.md`** file (YAML frontmatter + three-layer Markdown: Character → Signature → Decisions → Values). + +Ghost's CLI is a set of **deterministic primitives**. It never calls an LLM. Synthesis, interpretation, and generation happen in **you, the host agent**; Ghost hands you the arithmetic (vector distance, schema validation, manifest writes) you call on when you need a stable answer. + +## CLI primitives + +| Verb | Purpose | +|---|---| +| `ghost compare [...more]` | Pairwise distance + per-dimension delta (N=2) or fleet analysis (N≥3). Pure math over fingerprint embeddings. `--semantic` and `--temporal` flags add qualitative enrichment for N=2. | +| `ghost lint [fingerprint.md]` | Validate schema + body/frontmatter coherence. Use this before declaring a fingerprint valid. | +| `ghost ack` / `ghost adopt ` / `ghost diverge ` | Record stance toward parent (aligned / accepted / diverging) in `.ghost-sync.json`. Reads the local `fingerprint.md`. | +| `ghost emit review-command` / `ghost emit context-bundle` / `ghost emit skill` | Derive per-project artifacts from `fingerprint.md`. | + +That's it. Six verbs. If you find yourself reaching for `ghost review` or `ghost profile` — those are *your* workflows, not CLI commands. Follow the recipes below. + +## Workflows (your job, not the CLI's) + +When the user asks you to: + +- "Profile my design system" / "write a fingerprint.md" → [references/profile.md](references/profile.md) +- "Review this PR/these changes for drift" → [references/review.md](references/review.md) +- "Verify this generated UI matches the fingerprint" → [references/verify.md](references/verify.md) +- "Generate a component matching our design system" → [references/generate.md](references/generate.md) +- "Compare these two fingerprints" → run `ghost compare `; if they ask *why* they drifted, add `--semantic`. See [references/compare.md](references/compare.md) for interpretation. +- "Find design systems like X" / "discover" → [references/discover.md](references/discover.md) + +## The fingerprint.md format + +An `fingerprint.md` has: + +- **YAML frontmatter (machine layer):** `id`, `schema`, `source`, `timestamp`, `observation.personality`, `observation.closestSystems`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`. +- **Markdown body (prose layer):** `# Character` (`observation.summary`), `# Signature` (bullets from `distinctiveTraits`), `# Decisions` with `### ` rationale blocks. + +Each field lives in exactly one layer — no duplication. Putting prose in frontmatter is a lint error. Full spec: [references/schema.md](references/schema.md). Starting template: [assets/fingerprint.template.md](assets/fingerprint.template.md). + +## Always + +- Use `fingerprint.md` as the canonical filename (no slug prefix, no dotfile). +- Resolve variable chains end-to-end. Follow `var(--primary) → --primary: var(--brand-500) → --brand-500: #0066cc` to the concrete value. +- Emit colors as hex in frontmatter. The CLI recomputes oklch when it needs it. +- Every `palette` entry should be cited in at least one decision's `evidence`, or dropped — uncited tokens are noise. +- Validate with `ghost lint` before declaring success. + +## Never + +- Never invent tokens. If you did not observe a value in the source, omit the field. A missing field is better than a fabricated one. +- Never use the W3C Design Tokens or Style Dictionary format. Ghost's `fingerprint.md` is the artifact. +- Never stop at the first variable indirection. Follow the chain. +- Never write prose into frontmatter or structural data into the body — the partition is load-bearing. diff --git a/packages/ghost-cli/src/skill-bundle/assets/fingerprint.template.md b/packages/ghost-cli/src/skill-bundle/assets/fingerprint.template.md new file mode 100644 index 0000000..f190dbf --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/assets/fingerprint.template.md @@ -0,0 +1,75 @@ +--- +# identity +id: PROJECT_ID +source: llm +timestamp: TIMESTAMP_ISO + +# narrative tags (prose lives in the body) +observation: + personality: + - adjective-1 + - adjective-2 + closestSystems: + - known-system + +# abstract design decisions +decisions: + - dimension: color-strategy + evidence: + - "--color-primary: #000000" + - dimension: spatial-system + evidence: + - "--space-4: 16px" + +# concrete tokens +palette: + dominant: + - { role: primary, value: "#000000" } + neutrals: + steps: ["#ffffff", "#0a0a0a"] + count: 2 + semantic: [] + saturationProfile: muted + contrast: high + +spacing: + scale: [4, 8, 16, 24, 32] + regularity: 1.0 + baseUnit: 4 + +typography: + families: ["Inter"] + sizeRamp: [14, 16, 20, 24, 32] + weightDistribution: { "400": 1, "700": 1 } + lineHeightPattern: normal + +surfaces: + borderRadii: [4, 8] + shadowComplexity: none + borderUsage: minimal + +roles: [] +--- + +# Character + +2-4 sentences on the personality of this design language. This prose becomes `observation.summary` when parsed. + +# Signature + +- Distinctive trait 1. +- Distinctive trait 2. + +# Decisions + +### color-strategy + +Prose rationale for the color-strategy decision. Implementation-agnostic: name the pattern, not the token. + +### spatial-system + +Prose rationale for the spatial-system decision. + +# Fragments + +- [embedding](embedding.md) diff --git a/packages/ghost-cli/src/skill-bundle/references/compare.md b/packages/ghost-cli/src/skill-bundle/references/compare.md new file mode 100644 index 0000000..b056f69 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/compare.md @@ -0,0 +1,31 @@ +# Recipe: Compare fingerprints + +**Goal:** answer "how different are these design systems?" or "how has ours drifted over time?" + +## Steps + +### Pairwise (N=2) + + ghost compare a.md b.md + +Output: distance (0 = identical, 1 = unrelated) and per-dimension deltas (palette, spacing, typography, surfaces). + +Flags: +- `--semantic` — add qualitative diff (which decisions changed, which colors appeared/disappeared) +- `--temporal` — add drift velocity, trajectory, and ack bounds (reads `.ghost/history.jsonl`) + +### Fleet (N≥3) + + ghost compare a.md b.md c.md d.md + +Output: pairwise distance matrix, centroid, spread, and cluster assignments. + +Use for: comparing multiple downstream consumers of a parent design system (which are closest to parent, which have drifted most, do they cluster?). + +### Interpreting output + +- **Distance < 0.2**: effectively the same system. +- **0.2 – 0.5**: recognizable drift; worth a qualitative review. +- **> 0.5**: the two fingerprints represent meaningfully different systems. Either one has diverged intentionally, or they were never the same. + +If the user asks "why did it change", follow up with `--semantic`. diff --git a/packages/ghost-cli/src/skill-bundle/references/discover.md b/packages/ghost-cli/src/skill-bundle/references/discover.md new file mode 100644 index 0000000..9d6e8a5 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/discover.md @@ -0,0 +1,39 @@ +# Recipe: Discover public design systems + +**Goal:** find public design systems matching a query — for benchmarking, inspiration, or competitive analysis. + +Ghost's CLI does not search the web. You do, using your host harness's web-search capability. + +## Steps + +### 1. Search + +Use WebSearch (or whatever search tool your harness provides) for: + +- " design system" +- " component library" +- " figma library" + +Collect candidate systems with: name, URL, public repo (if any), brief description. + +### 2. Filter + +Drop: toy repos, single-component libraries, abandoned projects (last commit > 2 years), paywalled systems with no public assets. + +Keep: systems with public token files, published component libraries, documented design principles, or public Figma files. + +### 3. (Optional) Profile + +For each kept candidate, if the user wants fingerprint-level detail: + +- Clone or fetch the public repo. +- Run the [profile recipe](profile.md) against it. +- Save the resulting `fingerprint.md` somewhere named for the system (e.g. `discovered/linear.fingerprint.md`). + +Then compare against the user's fingerprint: + + ghost compare my-fingerprint.md discovered/linear.fingerprint.md --semantic + +### 4. Report + +Summarize findings as a small table: name, URL, one-line character description, optional distance to the user's fingerprint. diff --git a/packages/ghost-cli/src/skill-bundle/references/generate.md b/packages/ghost-cli/src/skill-bundle/references/generate.md new file mode 100644 index 0000000..a2c7550 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/generate.md @@ -0,0 +1,35 @@ +# Recipe: Generate UI from a fingerprint.md + +**Goal:** produce a UI artifact (component, page, snippet) that lives within the `fingerprint.md` boundaries. + +Ghost's CLI does not generate code — you do. The fingerprint is the constraint. + +## Steps + +### 1. Load the fingerprint + +Read `fingerprint.md` from the project. The key constraints are: + +- `palette` — which colors are allowed, and what role each plays +- `spacing.scale` — which spacing values are allowed +- `typography.families` / `typography.sizeRamp` — allowed font families and sizes +- `surfaces.borderRadii` — allowed radii +- `decisions` — the *patterns* to respect (e.g. "no shadows", "all interactive surfaces animate on hover") +- `roles` — the existing slot → token bindings (reuse them where possible) + +### 2. Generate against those constraints + +Write the UI code using only values from the fingerprint. If you need a color, pick from `palette`. If you need spacing, snap to a step in `spacing.scale`. + +Respect the decisions. If the fingerprint says "no shadows", don't add `box-shadow`. If it says "all interactive surfaces animate", add the transition. + +If the fingerprint is missing a token you need (e.g. you need a warning color but `palette.semantic` has none), **do not invent one**. Flag the gap to the user — they either need to add it to the fingerprint, or use an existing semantic as the closest fit. + +### 3. Verify + +Run the [verify recipe](verify.md) — self-review the generated code against the fingerprint and iterate if needed. + +## Output conventions + +- Prefer CSS custom properties referencing the fingerprint's tokens (`var(--color-primary)`) over literal hex values, when the project uses custom properties. +- Prefer existing `roles[]` bindings over re-deriving slot styles from scratch. diff --git a/packages/ghost-cli/src/skill-bundle/references/profile.md b/packages/ghost-cli/src/skill-bundle/references/profile.md new file mode 100644 index 0000000..8883b34 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/profile.md @@ -0,0 +1,89 @@ +# Recipe: Profile a project into fingerprint.md + +**Goal:** produce a valid `fingerprint.md` that captures the project's visual language. Ghost's CLI does not call an LLM for this — you, the host agent, explore the repo and synthesize the result, then hand it to `ghost lint` for validation. + +## Steps + +### 1. Locate design sources + +Start from the project root. Look for: + +- `tailwind.config.{js,ts}` and `@theme { ... }` blocks in CSS +- `styles/globals.css`, `app/globals.css`, `index.css`, `theme.css` +- `tokens/`, `design-tokens/`, `theme/` directories +- SCSS variable files (`_variables.scss`, `_tokens.scss`) +- TypeScript theme objects (`const theme = { ... }`) +- shadcn-style CSS variables (`:root { --background: ... }`) +- JSON token files (Style Dictionary, W3C) + +Use Glob/Grep to find candidates. Read the real files — don't assume the project follows a convention. + +### 2. Resolve variable chains end-to-end + +If a value is a reference, follow it: + +`--btn-bg: var(--color-primary)` → `--color-primary: var(--brand-500)` → `--brand-500: #0066cc` + +Record the resolved concrete value. Stopping at the first indirection produces useless fingerprints. + +### 3. Read component files (for the roles layer) + +Open 3-6 component files: typography primitives (`H1`, `P`), `Button`, `Card`, `Input`, list/table primitives. Record which tokens bind to which semantic slot: + +- "h1 = serif 52px / weight 500" +- "Button uses `--primary` background with 8px radius" + +These become `roles[]`. Only record what you directly observed. Projects with no component files may produce empty `roles` — that's fine. + +### 4. Form Layer 1 — Observation (holistic) + +Write subjectively. 2-4 sentences capturing what this design language is and how it feels. Then: + +- `personality`: 3-6 adjectives (`utilitarian`, `editorial`, `dense`, `playful`, …) +- `distinctiveTraits`: what makes this system *visually recognizable* — include notable absences (e.g. "no decorative elements at all") +- `closestSystems`: 1-3 well-known systems this resembles (Linear, Geist, Material 3, …) + +### 5. Derive Layer 2 — Design Decisions (abstract) + +Name the pattern, not the token: + +- ✗ Weak: "Spacing follows a 4px base grid with Tailwind defaults." (restates a fact visible in the tokens) +- ✓ Strong: "Prefer explicit component-height tokens over padding arithmetic, so button/input sizing is decoupled from surrounding layout." (names the pattern and its consequence) + +Surface whatever dimensions fit. There is no fixed list. Common ones: `color-strategy`, `spatial-system`, `typography-voice`, `surface-hierarchy`, `density`, `motion`, `elevation`, `interactive-patterns`. **Absences are decisions** — "No animation — interactions are immediate and non-kinetic" is a valid decision. + +For each decision: `dimension` (slug), `decision` (prose, goes in body), `evidence` (list of concrete citations — prefer token definitions like `"--radius-pill: 999px"`; behavioral observations as `file:line` if needed). + +### 6. Extract Layer 3 — Concrete tokens + +Populate the structured fields: `palette.dominant`, `palette.neutrals`, `palette.semantic`, `palette.saturationProfile`, `palette.contrast`, `spacing.scale`, `spacing.regularity`, `spacing.baseUnit`, `typography.families`, `typography.sizeRamp`, `typography.weightDistribution`, `typography.lineHeightPattern`, `surfaces.borderRadii`, `surfaces.shadowComplexity`, `surfaces.borderUsage`. + +- Convert rem/em to px (1rem = 16px). +- Output colors as hex (`#1a1a1a`). The CLI computes oklch automatically. +- Every `palette` entry must be cited in at least one decision's `evidence`, or dropped. Uncited neutrals are noise. + +### 7. Write the file + +Copy [../assets/fingerprint.template.md](../assets/fingerprint.template.md) as a starting point. Fill in: + +- **Frontmatter:** all structured fields (identity, `observation.personality`/`.closestSystems`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`). +- **Body:** `# Character` (observation summary), `# Signature` (distinctiveTraits bullets), `# Decisions` (one `### ` block per decision, containing the prose rationale). + +Partition matters. See [schema.md](schema.md) for which field lives where. + +### 8. Validate + + ghost lint fingerprint.md + +Fix any errors it reports. Common ones: + +- Prose in frontmatter → move to body +- `### dim` in body with no matching `decisions[]` entry (or vice versa) → remove the orphan +- Palette entry not cited in any evidence → cite it or drop it +### 9. Sanity check + + ghost compare fingerprint.md fingerprint.md # self-distance should be 0 + +## When you cannot profile + +If the project has no styling (backend-only, no UI), say so. Do not fabricate a fingerprint. A placeholder fingerprint poisons every downstream comparison. diff --git a/packages/ghost-cli/src/skill-bundle/references/review.md b/packages/ghost-cli/src/skill-bundle/references/review.md new file mode 100644 index 0000000..87d241f --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/review.md @@ -0,0 +1,65 @@ +# Recipe: Review code changes for design drift + +**Goal:** flag frontend changes that drift from the local `fingerprint.md` and produce a review (chat summary or PR comments). + +Ghost has no `ghost review` CLI command. You — the host agent — are the reviewer. The `fingerprint.md` is your rubric. + +## Steps + +### 1. Read the fingerprint + + cat fingerprint.md + +Absorb: `palette` (allowed colors), `spacing.scale` (allowed spacing values), `typography.families`/`sizeRamp`, `surfaces.borderRadii`, `decisions` (the patterns), `roles` (slot bindings). + +If no `fingerprint.md` exists, tell the user. Offer to generate one via the [profile recipe](profile.md). Don't guess. + +### 2. Collect the changes + + git diff --name-only + git diff -- + +Scope to frontend-relevant files (`.tsx`, `.jsx`, `.css`, `.scss`, `.vue`, `.svelte`, `.html`, styled-components, Tailwind class strings). Skip lockfiles, binaries, generated code, test fixtures unless visually meaningful. + +### 3. Scan for drift + +For each changed file, read the diff and look for values that don't belong to the fingerprint: + +- **Palette drift:** hex codes (`#ff6600`), `rgb(...)`, `oklch(...)`, Tailwind color classes (`bg-slate-500`) that aren't in `palette.dominant`/`.neutrals`/`.semantic`. +- **Spacing drift:** `px`, `rem`, `em` values not in `spacing.scale` (converted: 1rem = 16px). Tailwind spacing classes (`p-3`, `mt-7`) that land off-grid. +- **Typography drift:** font-family declarations not in `typography.families`, font-size values not in `sizeRamp`, font-weight values far from the `weightDistribution`. +- **Surface drift:** `border-radius` not in `surfaces.borderRadii`, `box-shadow` present when `surfaces.shadowComplexity: none`, or absent when the fingerprint says shadows are load-bearing. +- **Decision drift:** behavior that contradicts a decision (e.g. decision says "no animation" and the change adds a `transition`; decision says "component-height tokens, not padding arithmetic" and the change uses `padding-y: 14px`). + +### 4. Filter noise + +Drop matches that are clearly not real drift: + +- Values in test fixtures, storybook demos, mock data +- Values in generated files (`*.generated.{ts,css}`, `*.d.ts`) +- Values in vendor/third-party code the project merely references +- Values that exactly equal a CSS var bound to a token (`var(--color-primary)` is fine even if `#0066cc` would be drift) +- Intentional divergence: if `.ghost-sync.json` records `dimension: X` as `diverging`, drift in that dimension is acknowledged — note it, don't flag it. + +### 5. Produce the review + +Group findings by dimension. Lead with the most load-bearing drift. For each finding: + +- `file:line` — where +- What was found (`#ff6600`) +- What the fingerprint allows (`palette.semantic.warning: #dc2626`) +- Why it matters (one sentence — reference the decision if applicable) +- Suggested fix (the token or var to use instead) + +Formats: +- **Ad-hoc chat:** markdown with `file:line` links. +- **PR review:** inline comments per finding + a summary comment with counts. +- **CI gate:** exit nonzero if any finding is severity `error`; markdown summary to stdout. + +### 6. Record stance if the user accepts the drift + +- `ghost ack` — "yes, the current fingerprint no longer matches reality; accept drift across the board and record it." +- `ghost diverge --reason "..."` — "this dimension is intentionally different; stop flagging it." +- `ghost adopt ` — "adopt a new parent baseline." + +These commands only work if the local `fingerprint.md` is up to date — offer to regenerate it first if the project has meaningfully shifted since it was written. diff --git a/packages/ghost-cli/src/skill-bundle/references/schema.md b/packages/ghost-cli/src/skill-bundle/references/schema.md new file mode 100644 index 0000000..fc5dc76 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/schema.md @@ -0,0 +1,128 @@ +# fingerprint.md schema reference + +Canonical filename: `fingerprint.md`. + +Companion file: `embedding.md` (sibling fragment containing the 49-dim vector). The CLI writes it automatically when you write a `fingerprint.md` via `ghost`; you can also compute and append it yourself. + +## Frontmatter (machine layer) + +```yaml +--- +# identity +id: my-project # required, slug-like +# (no schema version field — the format is unversioned for now) +source: llm # registry | extraction | llm | unknown +timestamp: 2026-04-20T00:00:00Z # ISO-8601 +sources: # optional — targets that were combined + - github:owner/repo + - ./local/path + +# narrative tags (prose lives in the body) +observation: + personality: [restrained, editorial] # 3-6 adjectives + closestSystems: [linear, notion] # 1-3 known systems this resembles + +# abstract design decisions +decisions: + - dimension: color-strategy # freeform slug + evidence: + - "--color-primary: #0066cc" + - "src/theme.ts:12" + - dimension: spatial-system + evidence: ["--space-4: 16px"] + +# concrete tokens +palette: + dominant: + - { role: primary, value: "#0066cc" } + neutrals: + steps: ["#ffffff", "#f5f5f5", "#999999", "#0a0a0a"] + count: 4 + semantic: + - { role: danger, value: "#dc2626" } + saturationProfile: muted # muted | vibrant | mixed + contrast: high # high | moderate | low + +spacing: + scale: [4, 8, 12, 16, 24, 32] # px + regularity: 0.9 # 0-1 + baseUnit: 4 # px | null + +typography: + families: ["Inter", "Geist Mono"] + sizeRamp: [12, 14, 16, 20, 24, 32] # px + weightDistribution: { "400": 5, "700": 3 } + lineHeightPattern: normal # tight | normal | loose + +surfaces: + borderRadii: [4, 8, 12] # px + shadowComplexity: subtle # none | subtle | layered + borderUsage: moderate # minimal | moderate | heavy + +# slot → token bindings (optional but strongly recommended) +roles: + - name: h1 + tokens: + typography: { family: "Geist", size: 52, weight: 500 } + evidence: ["src/components/h1.tsx:4"] + - name: button + tokens: + surfaces: { borderRadius: 8 } + palette: { background: "#0066cc", foreground: "#ffffff" } + evidence: ["src/components/button.tsx:12"] + +# extension bag (optional, opaque to comparisons) +metadata: + tone: editorial +--- +``` + +## Body (prose layer) + +```markdown +# Character + +2-4 sentences capturing the holistic personality of this design language. This is `observation.summary`. + +# Signature + +- What makes this system visually distinctive (becomes `observation.distinctiveTraits`). +- One bullet per trait. Include notable *absences* if they are load-bearing. + +# Decisions + +### color-strategy + +Prose rationale for the color-strategy decision. This is `decisions[i].decision` — the implementation-agnostic statement of the pattern. One `### ` block per entry in `decisions`, matched by dimension slug. + +### spatial-system + +... + +# Fragments + +- [embedding](embedding.md) +``` + +## The partition (the one rule) + +Every field lives in exactly one layer: + +| Field | Layer | +|---|---| +| `id`, `source`, `timestamp`, `sources` | Frontmatter | +| `observation.personality`, `observation.closestSystems` | Frontmatter | +| `observation.summary` | **Body** (`# Character`) | +| `observation.distinctiveTraits` | **Body** (`# Signature` bullets) | +| `decisions[].dimension`, `decisions[].evidence` | Frontmatter | +| `decisions[].decision` (prose) | **Body** (`### ` block) | +| `palette`, `spacing`, `typography`, `surfaces`, `roles` | Frontmatter | +| `embedding` | Sibling `embedding.md` | + +Putting prose into frontmatter is a schema error. The writer and reader both enforce this. When in doubt: structured data → frontmatter; narrative → body. + +## Validation + + ghost lint fingerprint.md + +This catches schema violations, missing required fields, prose-in-frontmatter, orphaned decision blocks (body `### dim` with no matching frontmatter entry, or vice versa), and uncited palette entries. diff --git a/packages/ghost-cli/src/skill-bundle/references/verify.md b/packages/ghost-cli/src/skill-bundle/references/verify.md new file mode 100644 index 0000000..ea2bdd0 --- /dev/null +++ b/packages/ghost-cli/src/skill-bundle/references/verify.md @@ -0,0 +1,37 @@ +# Recipe: Verify generated UI against the fingerprint + +**Goal:** confirm that generated UI (a component, a page, a variant) stays within the bounds of the local `fingerprint.md`. This is the "generate → review → iterate" loop. + +Ghost has no `ghost verify` CLI command. You drive the loop; the fingerprint is the contract. + +## Steps + +### 1. Generate + +Produce the UI code. See [generate.md](generate.md) for guidance, or work from whatever the user asked for. Respect `palette`, `spacing.scale`, `typography`, `surfaces`, `decisions`, `roles`. + +### 2. Self-review + +Apply the [review recipe](review.md) to the generated file. Scan for hardcoded values that drift from the fingerprint. Group findings by dimension. + +### 3. Decide + +- **No findings** → pass. The generation is aligned. Report back to the user. +- **Findings exist** → iterate: + - For each finding, identify the token the generator should have used. + - Regenerate with explicit guidance: "Use `palette.primary` (`#0066cc`) instead of `#3b82f6`; snap padding to `spacing.scale` step 4 (16px) instead of `14px`." + - Re-run the review. Up to 3 iterations. + - If still drifting after 3 tries: report to the user. The fingerprint may be missing a token the generator needs, or the generation prompt may be too loose. + +### 4. (Optional) Suite verification + +If the user is iterating on the fingerprint itself and wants coverage stats: + +- Generate against a suite of diverse prompts (button variants, a form, a data table, a hero section, etc. — pick a dozen). +- Run the review against each. +- Classify each dimension as **tight** (no drift), **leaky** (occasional drift), or **uncaptured** (frequent drift). +- "Uncaptured" dimensions are the signal the fingerprint is missing a decision. Tell the user which one to add. + +## Why the loop matters + +The fingerprint is a contract. Generation tests the contract. Drift shows where the contract is ambiguous or silent. Use verify results to refine both the generator's prompt and the fingerprint itself. diff --git a/packages/ghost-cli/src/viz-command.ts b/packages/ghost-cli/src/viz-command.ts deleted file mode 100644 index 62cbfe4..0000000 --- a/packages/ghost-cli/src/viz-command.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { exec } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { DesignFingerprint } from "@ghost/core"; -import { compareFleet, DIMENSION_RANGES } from "@ghost/core"; -import type { CAC } from "cac"; - -export function registerVizCommand(cli: CAC): void { - cli - .command( - "viz [...fingerprints]", - "Launch interactive 3D visualization of design fingerprints", - ) - .option("--port ", "Port for the visualization server", { - default: "3333", - }) - .option("--no-open", "Don't auto-open browser") - .action(async (paths: string[], opts) => { - try { - if (paths.length < 2) { - console.error("Error: viz requires at least 2 fingerprint paths"); - process.exit(2); - } - - const members = await Promise.all( - paths.map(async (p) => { - const data = await readFile(p, "utf-8"); - const fingerprint: DesignFingerprint = JSON.parse(data); - return { id: fingerprint.id, fingerprint }; - }), - ); - - const fleet = compareFleet(members, { cluster: true }); - - const payload = JSON.stringify({ - fleet: { - members: fleet.members.map((m) => ({ - id: m.id, - embedding: m.fingerprint.embedding, - fingerprint: m.fingerprint, - })), - pairwise: fleet.pairwise, - centroid: fleet.centroid, - spread: fleet.spread, - clusters: fleet.clusters, - }, - dimensionRanges: DIMENSION_RANGES, - }); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const htmlPath = join(__dirname, "viz", "index.html"); - const htmlContent = await readFile(htmlPath, "utf-8"); - - const port = Number.parseInt(String(opts.port), 10); - - const server = createServer((req, res) => { - if (req.url === "/" || req.url === "/index.html") { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(htmlContent); - } else if (req.url === "/api/data") { - res.writeHead(200, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }); - res.end(payload); - } else { - res.writeHead(404); - res.end("Not found"); - } - }); - - server.listen(port, () => { - const url = `http://localhost:${port}`; - console.log(`\n Ghost Viz → ${url}`); - console.log(` ${members.length} fingerprints loaded`); - console.log(" Press Ctrl+C to stop\n"); - - // cac negated-boolean options land as `open` (the opposite of --no-open) - if (opts.open !== false) { - const cmd = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - exec(`${cmd} ${url}`); - } - }); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); -} diff --git a/packages/ghost-cli/src/viz/index.html b/packages/ghost-cli/src/viz/index.html deleted file mode 100644 index 1275067..0000000 --- a/packages/ghost-cli/src/viz/index.html +++ /dev/null @@ -1,809 +0,0 @@ - - - - - -Ghost Viz - - - -
-
-
-
Ghost Fleet
-
-
-
- - - - - - - diff --git a/packages/ghost-cli/test/emit-kind.test.ts b/packages/ghost-cli/test/emit-kind.test.ts new file mode 100644 index 0000000..df245a3 --- /dev/null +++ b/packages/ghost-cli/test/emit-kind.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseEmitKind, SUPPORTED_KINDS } from "../src/emit-command.js"; + +describe("parseEmitKind", () => { + it("accepts review-command", () => { + expect(parseEmitKind("review-command")).toEqual({ + ok: true, + kind: "review-command", + }); + }); + + it("accepts context-bundle", () => { + expect(parseEmitKind("context-bundle")).toEqual({ + ok: true, + kind: "context-bundle", + }); + }); + + it("accepts skill", () => { + expect(parseEmitKind("skill")).toEqual({ + ok: true, + kind: "skill", + }); + }); + + it("rejects unknown kinds with a helpful error", () => { + const result = parseEmitKind("nope"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("unknown emit kind 'nope'"); + expect(result.error).toContain("review-command"); + expect(result.error).toContain("context-bundle"); + expect(result.error).toContain("skill"); + } + }); + + it("rejects the empty string", () => { + expect(parseEmitKind("").ok).toBe(false); + }); + + it("is case-sensitive (no surprising normalization)", () => { + expect(parseEmitKind("Review-Command").ok).toBe(false); + expect(parseEmitKind("CONTEXT-BUNDLE").ok).toBe(false); + }); + + it("covers every SUPPORTED_KINDS entry", () => { + for (const kind of SUPPORTED_KINDS) { + const r = parseEmitKind(kind); + expect(r.ok).toBe(true); + if (r.ok) expect(r.kind).toBe(kind); + } + }); +}); diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json index a507833..9fd8bb5 100644 --- a/packages/ghost-core/package.json +++ b/packages/ghost-core/package.json @@ -19,18 +19,13 @@ "build": "tsc --build" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.105", "diff": "^7.0.0", "jiti": "^2.4.0", - "pixelmatch": "^6.0.0", - "pngjs": "^7.0.0", - "postcss": "^8.5.0" - }, - "optionalDependencies": { - "playwright": "^1.50.0" + "postcss": "^8.5.0", + "yaml": "^2.8.3", + "zod": "^4.3.6" }, "devDependencies": { - "@types/diff": "^6.0.0", - "@types/pngjs": "^6.0.0" + "@types/diff": "^6.0.0" } } diff --git a/packages/ghost-core/src/agents/base.ts b/packages/ghost-core/src/agents/base.ts deleted file mode 100644 index 2ddc38d..0000000 --- a/packages/ghost-core/src/agents/base.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { AgentContext, AgentMessage, AgentResult } from "../types.js"; -import type { Agent, AgentState } from "./types.js"; - -/** - * Base class for stateful agent loops. - * - * Each iteration calls `step()` which can update the state, - * add messages, adjust confidence, and decide whether to continue. - * - * The agent loop continues until: - * - status is "completed" or "failed" - * - maxIterations is reached - * - * Without LLM config, agents run a single deterministic iteration. - */ -export abstract class BaseAgent - implements Agent -{ - abstract name: string; - abstract maxIterations: number; - abstract systemPrompt: string; - - /** - * Perform one iteration of the agent loop. - * Subclasses implement their per-step logic here. - */ - protected abstract step( - state: AgentState, - input: TInput, - ctx: AgentContext, - ): Promise>; - - /** - * Execute the agent loop. - */ - async execute( - input: TInput, - ctx: AgentContext, - ): Promise> { - const startTime = Date.now(); - let state = this.initState(); - - // Add system prompt - state.messages.push({ - role: "system", - content: this.systemPrompt, - }); - - // Notify start - this.emit(ctx, { - role: "assistant", - content: `[${this.name}] Starting...`, - metadata: { agent: this.name, event: "start" }, - }); - - // Determine effective max iterations - // Without LLM, agents run a single deterministic pass - // ctx.maxIterations overrides the agent default (escape hatch) - const effectiveMax = ctx.llm - ? (ctx.maxIterations ?? this.maxIterations) - : 1; - - while (state.status === "running" && state.iterations < effectiveMax) { - state = await this.step(state, input, ctx); - state.iterations++; - - this.emit(ctx, { - role: "assistant", - content: `[${this.name}] Iteration ${state.iterations}/${effectiveMax} — confidence: ${state.confidence.toFixed(2)}`, - metadata: { - agent: this.name, - event: "iteration", - iteration: state.iterations, - confidence: state.confidence, - }, - }); - } - - // If still running after all iterations, mark as completed with current state - if (state.status === "running") { - state.status = state.result ? "completed" : "failed"; - if (!state.result) { - state.warnings.push( - `Agent reached max iterations (${effectiveMax}) without producing a result`, - ); - } - } - - const duration = Date.now() - startTime; - - this.emit(ctx, { - role: "assistant", - content: `[${this.name}] ${state.status} in ${duration}ms (${state.iterations} iterations)`, - metadata: { agent: this.name, event: "done", status: state.status }, - }); - - if (state.status === "failed" && !state.result) { - const reasons = [...state.warnings, ...state.reasoning].filter(Boolean); - throw new Error( - `[${this.name}] Agent failed: ${reasons[0] ?? "unknown error"}`, - ); - } - - return this.toResult(state, duration); - } - - protected initState(): AgentState { - return { - messages: [], - confidence: 0, - status: "running", - iterations: 0, - reasoning: [], - warnings: [], - }; - } - - protected toResult( - state: AgentState, - duration: number, - ): AgentResult { - if (!state.result) { - throw new Error( - `[${this.name}] Agent completed without producing a result`, - ); - } - - return { - data: state.result, - confidence: state.confidence, - warnings: state.warnings, - reasoning: state.reasoning, - iterations: state.iterations, - duration, - }; - } - - protected emit(ctx: AgentContext, message: AgentMessage): void { - if (ctx.verbose && ctx.onMessage) { - ctx.onMessage(message); - } - } -} diff --git a/packages/ghost-core/src/agents/comparison.ts b/packages/ghost-core/src/agents/comparison.ts deleted file mode 100644 index d9a7b3e..0000000 --- a/packages/ghost-core/src/agents/comparison.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { compare } from "../stages/compare.js"; -import type { - AgentContext, - DesignFingerprint, - EnrichedComparison, -} from "../types.js"; -import { BaseAgent } from "./base.js"; -import type { AgentState } from "./types.js"; - -export interface ComparisonInput { - source: DesignFingerprint; - target: DesignFingerprint; - sourceLabel?: string; - targetLabel?: string; -} - -/** - * @deprecated Use `compare()` from `stages/compare` instead. - * This class is kept for backward compatibility but delegates to the stage function. - */ -export class ComparisonAgent extends BaseAgent< - ComparisonInput, - EnrichedComparison -> { - name = "comparison"; - maxIterations = 2; - systemPrompt = `You are a design comparison agent. Your job is to compare two -design fingerprints and explain the differences. - -For each divergent dimension, classify the divergence: -- accidental-drift: unintentional differences (hardcoded values, overrides) -- intentional-variant: coherent, systematic divergence (density variant, dark mode) -- evolution-lag: parent has moved, consumer hasn't caught up -- incompatible: fundamentally different design languages - -Provide human-readable explanations for each significant difference.`; - - protected async step( - state: AgentState, - input: ComparisonInput, - _ctx: AgentContext, - ): Promise> { - try { - const result = await compare(input); - state.result = result.data; - state.confidence = result.confidence; - state.warnings.push(...result.warnings); - state.reasoning.push(...result.reasoning); - state.status = "completed"; - } catch (err) { - state.warnings.push( - `Comparison failed: ${err instanceof Error ? err.message : String(err)}`, - ); - state.status = "failed"; - } - - return state; - } -} diff --git a/packages/ghost-core/src/agents/compliance.ts b/packages/ghost-core/src/agents/compliance.ts deleted file mode 100644 index b02f19a..0000000 --- a/packages/ghost-core/src/agents/compliance.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { comply } from "../stages/comply.js"; -import type { AgentContext, DesignFingerprint } from "../types.js"; -import { BaseAgent } from "./base.js"; -import type { AgentState } from "./types.js"; - -export interface ComplianceRule { - name: string; - description: string; - severity: "error" | "warning" | "info"; - check: (fingerprint: DesignFingerprint) => ComplianceViolation | null; -} - -export interface ComplianceViolation { - rule: string; - severity: "error" | "warning" | "info"; - message: string; - suggestion?: string; - dimension?: string; - value?: string | number; -} - -export interface ComplianceReport { - passed: boolean; - violations: ComplianceViolation[]; - score: number; - driftSummary?: { - distance: number; - dimensions: Record; - classification: string; - }; -} - -export interface ComplianceInput { - fingerprint: DesignFingerprint; - rules?: ComplianceRule[]; - parentFingerprint?: DesignFingerprint; - maxDriftDistance?: number; - thresholds?: ComplianceThresholds; -} - -export interface ComplianceThresholds { - minTokenization?: number; - minSemanticColors?: number; - minSpacingScale?: number; - maxDriftPerDimension?: number; - maxOverallDrift?: number; - requireFontFamilies?: boolean; - requireBorderRadii?: boolean; -} - -/** - * @deprecated Use `comply()` from `stages/comply` instead. - * This class is kept for backward compatibility but delegates to the stage function. - */ -export class ComplianceAgent extends BaseAgent< - ComplianceInput, - ComplianceReport -> { - name = "compliance"; - maxIterations = 2; - systemPrompt = `You are a design compliance agent. Your job is to evaluate whether -a design system meets specified standards and rules. Provide actionable suggestions -for each violation.`; - - protected async step( - state: AgentState, - input: ComplianceInput, - _ctx: AgentContext, - ): Promise> { - try { - const result = await comply(input); - state.result = result.data; - state.confidence = result.confidence; - state.warnings.push(...result.warnings); - state.reasoning.push(...result.reasoning); - state.status = "completed"; - } catch (err) { - state.warnings.push( - `Compliance check failed: ${err instanceof Error ? err.message : String(err)}`, - ); - state.status = "failed"; - } - - return state; - } -} diff --git a/packages/ghost-core/src/agents/director.ts b/packages/ghost-core/src/agents/director.ts deleted file mode 100644 index 04144dd..0000000 --- a/packages/ghost-core/src/agents/director.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { compareFleet } from "../evolution/fleet.js"; -import { compare as compareStage } from "../stages/compare.js"; -import type { ComplianceInput, ComplianceReport } from "../stages/comply.js"; -import { comply as complyStage } from "../stages/comply.js"; -import { extract } from "../stages/extract.js"; -import type { - AgentContext, - AgentResult, - DesignFingerprint, - EnrichedComparison, - EnrichedFingerprint, - FleetComparison, - FleetMember, - SampledMaterial, - Target, -} from "../types.js"; -import type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; -import { DiscoveryAgent } from "./discovery.js"; -import { FingerprintAgent } from "./fingerprint.js"; - -/** - * Director — orchestrates the fingerprinting pipeline. - * - * Uses plain stage functions for deterministic steps (extract, compare, comply) - * and agents for LLM-powered steps (fingerprint, discovery). - */ -export class Director { - private discoveryAgent = new DiscoveryAgent(); - - /** - * Profile one or more targets as a single design language. - * - * One target → standard fingerprint. Multiple targets → synthesized - * fingerprint across the combined sources (e.g. tokens package + iOS impl + web impl). - * The fingerprint agent explores all materialized source directories via its tools. - */ - async profile( - targets: Target[], - ctx: AgentContext, - ): Promise<{ - extraction: AgentResult; - fingerprint: AgentResult; - dirs: { label: string; dir: string }[]; - }> { - const extractionResult = await extract(targets); - const extraction = stageToAgentResult(extractionResult); - - // Fresh agent per call — FingerprintAgent holds per-run state that - // would collide if two profiles ran in parallel on the same instance. - const agent = new FingerprintAgent(); - agent.setToolContext({ - sourceDirs: extractionResult.dirs, - material: extraction.data, - }); - - const fingerprint = await agent.execute(extraction.data, ctx); - - // Stamp source provenance when more than one target contributed - if (targets.length > 1) { - fingerprint.data.sources = targets.map( - (t) => t.name ?? `${t.type}:${t.value}`, - ); - } - - return { extraction, fingerprint, dirs: extractionResult.dirs }; - } - - /** - * Compare two targets: (extract → fingerprint) × 2 → compare - * Runs the two profile pipelines in parallel. - */ - async compare( - sourceTarget: Target, - targetTarget: Target, - ctx: AgentContext, - ): Promise<{ - source: AgentResult; - target: AgentResult; - comparison: AgentResult; - }> { - const [sourceResult, targetResult] = await Promise.all([ - this.profile([sourceTarget], ctx), - this.profile([targetTarget], ctx), - ]); - - const comparisonResult = await compareStage({ - source: sourceResult.fingerprint.data, - target: targetResult.fingerprint.data, - sourceLabel: sourceTarget.name ?? sourceTarget.value, - targetLabel: targetTarget.name ?? targetTarget.value, - }); - - return { - source: sourceResult.fingerprint, - target: targetResult.fingerprint, - comparison: stageToAgentResult(comparisonResult), - }; - } - - /** - * Profile a target and compare against a known fingerprint. - */ - async drift( - target: Target, - parentFingerprint: DesignFingerprint, - ctx: AgentContext, - ): Promise<{ - fingerprint: AgentResult; - comparison: AgentResult; - }> { - const { fingerprint } = await this.profile([target], ctx); - - const comparisonResult = await compareStage({ - source: parentFingerprint, - target: fingerprint.data, - }); - - return { fingerprint, comparison: stageToAgentResult(comparisonResult) }; - } - - /** - * Discover design systems matching a query or similar to a fingerprint. - */ - async discover( - input: DiscoveryInput, - ctx: AgentContext, - ): Promise> { - return this.discoveryAgent.execute(input, ctx); - } - - /** - * Check compliance of a target against rules. - */ - async comply( - target: Target, - input: Omit, - ctx: AgentContext, - ): Promise<{ - fingerprint: AgentResult; - compliance: AgentResult; - }> { - const { fingerprint } = await this.profile([target], ctx); - - const complianceResult = await complyStage({ - ...input, - fingerprint: fingerprint.data, - }); - - return { - fingerprint, - compliance: stageToAgentResult(complianceResult), - }; - } - - /** - * Profile multiple targets and run fleet comparison. - * Profiles all targets in parallel, then computes pairwise distances and clustering. - */ - async fleet( - targets: Target[], - ctx: AgentContext, - options?: { cluster?: boolean }, - ): Promise<{ - members: Array<{ - target: Target; - fingerprint: AgentResult; - }>; - fleet: FleetComparison; - }> { - const profileResults = await Promise.all( - targets.map(async (target) => { - const result = await this.profile([target], ctx); - return { target, fingerprint: result.fingerprint }; - }), - ); - - const fleetMembers: FleetMember[] = profileResults.map((r) => ({ - id: r.target.name ?? r.target.value, - fingerprint: r.fingerprint.data, - parentRef: r.target, - })); - - const fleetResult = compareFleet(fleetMembers, { - cluster: options?.cluster ?? true, - }); - - return { - members: profileResults, - fleet: fleetResult, - }; - } -} - -/** Convert a StageResult to an AgentResult for backward compatibility. */ -function stageToAgentResult( - stage: import("../stages/types.js").StageResult, -): AgentResult { - return { - ...stage, - iterations: 1, - }; -} diff --git a/packages/ghost-core/src/agents/discovery.ts b/packages/ghost-core/src/agents/discovery.ts deleted file mode 100644 index 0bbf6d1..0000000 --- a/packages/ghost-core/src/agents/discovery.ts +++ /dev/null @@ -1,385 +0,0 @@ -import type { AgentContext } from "../types.js"; -import { BaseAgent } from "./base.js"; -import type { AgentState } from "./types.js"; - -export interface DiscoveredSystem { - name: string; - url: string; - description: string; - source: "npm" | "github" | "web" | "catalog"; - similarity?: number; - downloads?: number; - stars?: number; -} - -export interface DiscoveryInput { - query?: string; - similarTo?: import("../types.js").DesignFingerprint; - maxResults?: number; -} - -/** - * Discovery Agent — "What design systems exist?" - * - * Multi-turn search across npm registry, GitHub, and a curated catalog. - * Iteration 1: search the curated catalog (fast, offline) - * Iteration 2: search npm registry (if query provided) - * Iteration 3: search GitHub (if query provided and LLM available) - */ -export class DiscoveryAgent extends BaseAgent< - DiscoveryInput, - DiscoveredSystem[] -> { - name = "discovery"; - maxIterations = 3; - systemPrompt = `You are a design system discovery agent. Your job is to find -public design systems matching given criteria. Search npm, GitHub, and the web. -Combine results from multiple sources, deduplicate, and rank by relevance.`; - - protected async step( - state: AgentState, - input: DiscoveryInput, - ctx: AgentContext, - ): Promise> { - const maxResults = input.maxResults ?? 20; - - if (state.iterations === 0) { - // First iteration: curated catalog (always available) - const catalogResults = searchCatalog(input.query); - state.result = catalogResults; - state.confidence = catalogResults.length > 0 ? 0.6 : 0.2; - state.reasoning.push( - `Found ${catalogResults.length} systems in curated catalog`, - ); - - if (!input.query || !ctx.llm) { - state.status = "completed"; - } - - return state; - } - - if (state.iterations === 1 && input.query) { - // Second iteration: npm registry search - try { - const npmResults = await searchNpm(input.query, maxResults); - const merged = mergeResults(state.result ?? [], npmResults); - state.result = merged.slice(0, maxResults); - state.confidence = Math.min(state.confidence + 0.2, 1.0); - state.reasoning.push( - `Found ${npmResults.length} packages on npm, ${merged.length} total after dedup`, - ); - } catch (err) { - state.warnings.push( - `npm search failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - if (!ctx.llm) { - state.status = "completed"; - } - - return state; - } - - if (state.iterations === 2 && input.query) { - // Third iteration: GitHub search - try { - const ghResults = await searchGitHub(input.query, maxResults); - const merged = mergeResults(state.result ?? [], ghResults); - state.result = merged.slice(0, maxResults); - state.confidence = Math.min(state.confidence + 0.15, 1.0); - state.reasoning.push( - `Found ${ghResults.length} repos on GitHub, ${merged.length} total after dedup`, - ); - } catch (err) { - state.warnings.push( - `GitHub search failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - state.status = "completed"; - return state; - } - - state.status = "completed"; - return state; - } -} - -// --- npm registry search --- - -async function searchNpm( - query: string, - maxResults: number, -): Promise { - const searchTerms = `${query} design system component ui`; - const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(searchTerms)}&size=${maxResults}`; - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`npm search HTTP ${response.status}`); - } - - const data = (await response.json()) as { - objects: Array<{ - package: { - name: string; - description?: string; - links?: { homepage?: string; repository?: string; npm?: string }; - }; - score?: { detail?: { popularity?: number } }; - searchScore?: number; - }>; - }; - - return data.objects - .filter((obj) => { - const name = obj.package.name.toLowerCase(); - const desc = (obj.package.description ?? "").toLowerCase(); - // Filter for design-system-like packages - return ( - name.includes("ui") || - name.includes("design") || - name.includes("component") || - name.includes("theme") || - desc.includes("design system") || - desc.includes("component library") || - desc.includes("ui kit") - ); - }) - .map((obj) => ({ - name: obj.package.name, - url: - obj.package.links?.homepage ?? - obj.package.links?.repository ?? - `https://www.npmjs.com/package/${obj.package.name}`, - description: obj.package.description ?? "", - source: "npm" as const, - downloads: undefined, - })); -} - -// --- GitHub search --- - -async function searchGitHub( - query: string, - maxResults: number, -): Promise { - const searchTerms = `${query} design system component library`; - const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(searchTerms)}&sort=stars&per_page=${Math.min(maxResults, 30)}`; - - const headers: Record = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "ghost-cli", - }; - - // Use GitHub token if available - const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; - if (token) { - headers.Authorization = `token ${token}`; - } - - const response = await fetch(url, { headers }); - if (!response.ok) { - if (response.status === 403) { - throw new Error( - "GitHub API rate limit exceeded. Set GITHUB_TOKEN for higher limits.", - ); - } - throw new Error(`GitHub search HTTP ${response.status}`); - } - - const data = (await response.json()) as { - items: Array<{ - full_name: string; - html_url: string; - description?: string; - stargazers_count: number; - }>; - }; - - return data.items.map((repo) => ({ - name: repo.full_name, - url: repo.html_url, - description: repo.description ?? "", - source: "github" as const, - stars: repo.stargazers_count, - })); -} - -// --- Curated catalog --- - -function searchCatalog(query?: string): DiscoveredSystem[] { - const systems: DiscoveredSystem[] = [ - { - name: "shadcn/ui", - url: "https://github.com/shadcn-ui/ui", - description: - "Beautifully designed components built with Radix UI and Tailwind CSS", - source: "catalog", - stars: 75000, - }, - { - name: "Radix Themes", - url: "https://github.com/radix-ui/themes", - description: "A component library optimized for fast development", - source: "catalog", - stars: 5000, - }, - { - name: "Material UI", - url: "https://github.com/mui/material-ui", - description: "Ready-to-use React components implementing Material Design", - source: "catalog", - stars: 94000, - }, - { - name: "Chakra UI", - url: "https://github.com/chakra-ui/chakra-ui", - description: "Simple, modular and accessible React component library", - source: "catalog", - stars: 38000, - }, - { - name: "Ant Design", - url: "https://github.com/ant-design/ant-design", - description: - "An enterprise-class UI design language and React UI library", - source: "catalog", - stars: 93000, - }, - { - name: "Carbon Design System", - url: "https://github.com/carbon-design-system/carbon", - description: "IBM's open source design system", - source: "catalog", - stars: 7800, - }, - { - name: "Adobe Spectrum", - url: "https://github.com/adobe/react-spectrum", - description: - "A collection of libraries for building adaptive, accessible UIs", - source: "catalog", - stars: 13000, - }, - { - name: "Mantine", - url: "https://github.com/mantinedev/mantine", - description: - "A fully featured React components library with dark theme support", - source: "catalog", - stars: 27000, - }, - { - name: "NextUI", - url: "https://github.com/nextui-org/nextui", - description: "Beautiful, fast and modern React UI library", - source: "catalog", - stars: 22000, - }, - { - name: "Flowbite", - url: "https://github.com/themesberg/flowbite", - description: "Open-source UI component library based on Tailwind CSS", - source: "catalog", - stars: 8000, - }, - { - name: "Park UI", - url: "https://github.com/cschroeter/park-ui", - description: - "Beautifully designed components built on Ark UI and Panda CSS", - source: "catalog", - stars: 2000, - }, - { - name: "Tremor", - url: "https://github.com/tremorlabs/tremor", - description: "React components to build charts and dashboards", - source: "catalog", - stars: 16000, - }, - { - name: "daisyUI", - url: "https://github.com/saadeghi/daisyui", - description: "The most popular component library for Tailwind CSS", - source: "catalog", - stars: 34000, - }, - { - name: "Headless UI", - url: "https://github.com/tailwindlabs/headlessui", - description: - "Completely unstyled, fully accessible UI components by Tailwind Labs", - source: "catalog", - stars: 26000, - }, - { - name: "Primer", - url: "https://github.com/primer/react", - description: "GitHub's design system implemented in React", - source: "catalog", - stars: 3200, - }, - { - name: "Arco Design", - url: "https://github.com/arco-design/arco-design", - description: "A comprehensive React UI components library by ByteDance", - source: "catalog", - stars: 4800, - }, - { - name: "Semi Design", - url: "https://github.com/DouyinFE/semi-design", - description: "Modern, comprehensive, flexible design system by TikTok", - source: "catalog", - stars: 8500, - }, - ]; - - if (!query) return systems; - - const q = query.toLowerCase(); - return systems.filter( - (s) => - s.name.toLowerCase().includes(q) || - s.description.toLowerCase().includes(q), - ); -} - -// --- Merge and dedup --- - -function mergeResults( - existing: DiscoveredSystem[], - incoming: DiscoveredSystem[], -): DiscoveredSystem[] { - const seen = new Set(existing.map((s) => normalizeUrl(s.url))); - const merged = [...existing]; - - for (const system of incoming) { - const normalized = normalizeUrl(system.url); - if (!seen.has(normalized)) { - seen.add(normalized); - merged.push(system); - } - } - - // Sort: catalog first (curated), then by stars, then by name - return merged.sort((a, b) => { - if (a.source === "catalog" && b.source !== "catalog") return -1; - if (b.source === "catalog" && a.source !== "catalog") return 1; - if ((b.stars ?? 0) !== (a.stars ?? 0)) - return (b.stars ?? 0) - (a.stars ?? 0); - return a.name.localeCompare(b.name); - }); -} - -function normalizeUrl(url: string): string { - return url - .replace(/^https?:\/\//, "") - .replace(/\.git$/, "") - .replace(/\/$/, "") - .toLowerCase(); -} diff --git a/packages/ghost-core/src/agents/extraction.ts b/packages/ghost-core/src/agents/extraction.ts deleted file mode 100644 index 40e4b63..0000000 --- a/packages/ghost-core/src/agents/extraction.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { extract } from "../stages/extract.js"; -import type { AgentContext, SampledMaterial, Target } from "../types.js"; -import { BaseAgent } from "./base.js"; -import type { AgentState } from "./types.js"; - -/** - * @deprecated Use `extract()` from `stages/extract` instead. - * This class is kept for backward compatibility but delegates to the stage function. - */ -export class ExtractionAgent extends BaseAgent { - name = "extraction"; - maxIterations = 1; - systemPrompt = - "File extraction agent — walks and samples design-relevant files from any target."; - - protected async step( - state: AgentState, - input: Target, - _ctx: AgentContext, - ): Promise> { - try { - const result = await extract([input]); - state.result = result.data; - state.confidence = result.confidence; - state.warnings.push(...result.warnings); - state.reasoning.push(...result.reasoning); - state.status = result.confidence > 0 ? "completed" : "failed"; - } catch (err) { - state.warnings.push( - `Extraction failed: ${err instanceof Error ? err.message : String(err)}`, - ); - state.status = "failed"; - } - - return state; - } -} diff --git a/packages/ghost-core/src/agents/fingerprint-agent.ts b/packages/ghost-core/src/agents/fingerprint-agent.ts deleted file mode 100644 index 10232f7..0000000 --- a/packages/ghost-core/src/agents/fingerprint-agent.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Fingerprint Agent — powered by Claude Agent SDK. - * - * Instead of sampling files and stuffing them into a prompt, - * this gives the LLM filesystem tools and lets it explore the - * codebase itself to extract the visual language. - */ - -import { parseColorToOklch } from "../fingerprint/colors.js"; -import { - computeSemanticEmbedding, - embedTexts, -} from "../fingerprint/embed-api.js"; -import { computeEmbedding } from "../fingerprint/embedding.js"; -import { THREE_LAYER_SCHEMA } from "../llm/prompt.js"; -import type { - AgentContext, - AgentResult, - DesignFingerprint, - EnrichedFingerprint, - TargetType, -} from "../types.js"; - -const PROMPT = `You are producing a design fingerprint — a comprehensive extraction of the design language present in a codebase. - -Explore the codebase at the current directory. Find where visual design values are defined — theme files, CSS variables, token definitions, component styles. Read those definitions and form a complete picture. - -## Three-Layer Fingerprint - -Your output has three layers, produced in order: - -### Layer 1: Observation -First, form a holistic understanding. What design language is this? What personality does it project? What's distinctive? What known systems does it resemble? Write freely — this is your subjective read. - -### Layer 2: Design Decisions -Based on your observation, identify the abstract design decisions. These are the principles and rules — not the specific values, but the decisions those values serve. State each implementation-agnostically. - -Surface whatever dimensions you find relevant. There is no fixed list. Common dimensions include color-strategy, spatial-system, typography-voice, surface-hierarchy, density, motion, elevation, interactive-patterns — but use whatever fits. If a dimension is notably absent (e.g. no animation), note that absence as a decision. - -For each decision, cite specific evidence from the files you read. - -### Layer 3: Values -Extract the concrete tokens — hex codes, pixel values, font stacks, border radii. This is the greppable implementation layer. - -## Important - -- Read the actual value definitions. If a variable references another variable, follow the chain. -- Only report values you found in the source. Do not guess or fill in defaults. -- Resolve colors to hex (e.g. #1a1a1a). Do NOT output oklch. -- Convert rem/em to px (1rem = 16px). Output spacing and radii as numbers. - -## Output - -Respond with ONLY a JSON object matching this schema: - -${THREE_LAYER_SCHEMA} - -Set "id" to "PROJECT_ID". -Set "source" to "llm".`; - -export interface FingerprintAgentOptions { - targetDir: string; - targetType: TargetType; - projectId: string; - verbose?: boolean; - embedding?: AgentContext["embedding"]; -} - -export async function runFingerprintAgent( - options: FingerprintAgentOptions, -): Promise> { - const { query } = await import("@anthropic-ai/claude-agent-sdk"); - - const startTime = Date.now(); - const prompt = PROMPT.replace("PROJECT_ID", options.projectId); - const reasoning: string[] = []; - let resultText = ""; - - for await (const message of query({ - prompt, - options: { - allowedTools: ["Read", "Glob", "Grep"], - cwd: options.targetDir, - maxTurns: 60, - }, - })) { - // Log tool usage for verbose output - if ( - options.verbose && - message.type === "assistant" && - "message" in message - ) { - const msg = message.message as { - content?: Array<{ type: string; name?: string; text?: string }>; - }; - if (Array.isArray(msg?.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.name) { - reasoning.push(`Tool: ${block.name}`); - } - if (block.type === "text" && block.text?.trim()) { - reasoning.push(block.text.trim().slice(0, 200)); - } - } - } - } - - if (message.type === "result" && message.subtype === "success") { - resultText = message.result; - } - } - - if (!resultText) { - throw new Error("Agent did not produce a result"); - } - - // Parse fingerprint from result - const jsonMatch = resultText.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error("Failed to extract JSON from agent result"); - } - - const raw = JSON.parse(jsonMatch[0]); - const fp: DesignFingerprint = raw; - fp.source = "llm"; - fp.timestamp = new Date().toISOString(); - - // Preserve observation and decisions from the three-layer output - if (raw.observation && typeof raw.observation.summary === "string") { - fp.observation = raw.observation; - } - if (Array.isArray(raw.decisions) && raw.decisions.length > 0) { - fp.decisions = raw.decisions; - } - - // Recompute oklch from hex values deterministically - recomputeOklch(fp); - - // Embed design decisions for paraphrase-robust comparison downstream. - if (options.embedding && fp.decisions && fp.decisions.length > 0) { - try { - const texts = fp.decisions.map((d) => `${d.dimension}: ${d.decision}`); - const vectors = await embedTexts(texts, options.embedding); - for (let i = 0; i < fp.decisions.length; i++) { - fp.decisions[i].embedding = vectors[i]; - } - } catch (err) { - reasoning.push( - `Decision embedding failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Compute fingerprint-level embedding - fp.embedding = options.embedding - ? await computeSemanticEmbedding(fp, options.embedding) - : computeEmbedding(fp); - - const enriched: EnrichedFingerprint = { - ...fp, - targetType: options.targetType, - }; - - return { - data: enriched, - confidence: 0.85, - warnings: [], - reasoning, - iterations: 1, - duration: Date.now() - startTime, - }; -} - -function recomputeOklch(fp: DesignFingerprint): void { - for (const color of fp.palette.dominant) { - const oklch = parseColorToOklch(color.value); - if (oklch) color.oklch = oklch; - } - for (const color of fp.palette.semantic) { - const oklch = parseColorToOklch(color.value); - if (oklch) color.oklch = oklch; - } -} diff --git a/packages/ghost-core/src/agents/fingerprint.ts b/packages/ghost-core/src/agents/fingerprint.ts deleted file mode 100644 index a503b33..0000000 --- a/packages/ghost-core/src/agents/fingerprint.ts +++ /dev/null @@ -1,512 +0,0 @@ -import { parseColorToOklch } from "../fingerprint/colors.js"; -import { - computeSemanticEmbedding, - embedTexts, -} from "../fingerprint/embed-api.js"; -import { computeEmbedding } from "../fingerprint/embedding.js"; -import { createProvider } from "../llm/index.js"; -import { THREE_LAYER_SCHEMA } from "../llm/prompt.js"; -import type { - AgentContext, - DesignFingerprint, - EnrichedFingerprint, - SampledMaterial, -} from "../types.js"; -import { BaseAgent } from "./base.js"; -import { - DEFAULT_MAX_TOOL_CALLS, - executeTool, - getToolDefinitions, -} from "./tools/index.js"; -import type { ChatMessage, ToolContext } from "./tools/types.js"; -import type { AgentState } from "./types.js"; - -/** - * Fingerprint Agent — "What design language is this?" - * - * Agentic-first approach: the agent explores source directories using tools - * (list_files, search_files, read_file, run_extractor) to discover and - * extract the visual language. The initial sample provides a starting map, - * not the complete input. - * - * Iteration model: - * 0: Build overview from sampled material → send to LLM with tools - * 1..N: Tool call/response cycles as the LLM explores - * N+1: LLM produces fingerprint JSON → validate + compute embedding - */ -export class FingerprintAgent extends BaseAgent< - SampledMaterial, - EnrichedFingerprint -> { - name = "fingerprint"; - maxIterations = 99; - systemPrompt = `You are a design fingerprinting agent. You analyze source files -from design systems and produce three-layer fingerprints: an observation of the -design language, abstract design decisions, and concrete token values. - -You have tools to explore the source directories: list_files, search_files, -read_file, and run_extractor. Use them to find design tokens, theme files, -color definitions, spacing scales, typography configs, and surface treatments. - -First form a holistic understanding of the design language. Then identify the -abstract design decisions (implementation-agnostic principles). Finally extract -the concrete values. When you have gathered enough signal, produce a JSON -fingerprint matching the schema.`; - - // State preserved across iterations for tool-use loop - private chatMessages: ChatMessage[] = []; - private toolCallCount = 0; - private maxToolCalls = DEFAULT_MAX_TOOL_CALLS; - private toolCtx: ToolContext | null = null; - private pendingFingerprint: DesignFingerprint | null = null; - - protected async step( - state: AgentState, - input: SampledMaterial, - ctx: AgentContext, - ): Promise> { - if (state.iterations === 0) { - return this.initialExploration(state, input, ctx); - } - - // If we have a pending fingerprint, proceed to validation - if (this.pendingFingerprint) { - return this.validateAndFinalize(state, input, ctx); - } - - // Tool call/response cycle — the primary path - if ( - this.chatMessages.length > 0 && - this.hasPendingToolCalls() && - ctx.llm?.provider - ) { - return this.toolUseLoop(state, input, ctx); - } - - // No pending tool calls and no fingerprint — the exploration is done. - // If the last assistant message had content but didn't parse as JSON, - // we've already surfaced that in a warning; just complete. - state.status = "completed"; - return state; - } - - private async initialExploration( - state: AgentState, - input: SampledMaterial, - ctx: AgentContext, - ): Promise> { - if (!ctx.llm) { - state.warnings.push( - "No LLM configured. Ghost v2 requires an LLM API key for fingerprinting.", - ); - state.status = "failed"; - return state; - } - - // Reset per-run state - this.chatMessages = []; - this.toolCallCount = 0; - this.pendingFingerprint = null; - - try { - const provider = createProvider(ctx.llm); - - // Build the overview prompt with file map and top files pre-read - const overview = this.buildOverview(input); - const projectId = input.metadata.packageJson?.name ?? "project"; - - const userMessage = `Analyze this project and produce a three-layer design fingerprint. - -## Project: ${projectId} - -${overview} - -## Your Task - -Use the available tools to explore these source${input.metadata.sources && input.metadata.sources.length > 1 ? "s" : ""} and build a complete picture of the visual language. Work in three layers: - -### Layer 1: Observation -Form a holistic understanding. What design language is this? What personality does it project? What's distinctive? What known systems does it resemble? - -### Layer 2: Design Decisions -Identify the abstract design decisions — the principles and rules, not the specific values. Surface whatever dimensions are relevant (color-strategy, spatial-system, typography-voice, motion, density, elevation, etc.). If a dimension is notably absent, note it. Cite evidence. - -### Layer 3: Values -Extract the concrete tokens: -1. **Palette** — color definitions (tokens, variables, constants) -2. **Spacing** — spacing scales and base units -3. **Typography** — font families, size ramps, weight distributions -4. **Surfaces** — border radii, shadow styles, border usage patterns - -When you have enough signal, output a JSON fingerprint matching this schema: - -${THREE_LAYER_SCHEMA} - -Set "id" to "${projectId}". - -**Important:** Resolve colors to hex or rgb. Convert rem/em to px (1rem = 16px). Output spacing and radii as numbers.`; - - this.chatMessages = [{ role: "user", content: userMessage }]; - - state.reasoning.push( - `Starting exploration with ${input.files.length} overview files across ${input.metadata.sources?.length ?? 1} source(s)`, - ); - - // Send to LLM with tools - const response = await provider.chat( - this.chatMessages, - getToolDefinitions(), - ); - - if (response.tool_calls?.length) { - this.chatMessages.push({ - role: "assistant", - content: response.content ?? "", - tool_calls: response.tool_calls, - }); - state.reasoning.push( - `LLM requested ${response.tool_calls.length} tool(s): ${response.tool_calls.map((tc) => tc.name).join(", ")}`, - ); - } else if (response.content) { - // LLM produced a fingerprint directly from the overview - this.chatMessages.push({ - role: "assistant", - content: response.content, - }); - try { - this.pendingFingerprint = this.parseFingerprint(response.content); - state.confidence = 0.8; - state.reasoning.push( - "LLM produced fingerprint from overview (no tool use needed)", - ); - } catch { - state.reasoning.push( - "LLM responded but didn't produce valid JSON yet", - ); - } - } - } catch (err) { - state.warnings.push( - `Initial exploration failed: ${err instanceof Error ? err.message : String(err)}`, - ); - state.status = "failed"; - } - - return state; - } - - private async toolUseLoop( - state: AgentState, - _input: SampledMaterial, - ctx: AgentContext, - ): Promise> { - if (!ctx.llm || !this.toolCtx) { - state.status = "completed"; - return state; - } - - try { - const provider = createProvider(ctx.llm); - - // Execute pending tool calls from last assistant message - const lastMessage = this.chatMessages[this.chatMessages.length - 1]; - if (lastMessage?.tool_calls) { - for (const call of lastMessage.tool_calls) { - if (this.toolCallCount >= this.maxToolCalls) { - this.chatMessages.push({ - role: "tool", - content: - "Tool call budget exhausted. Please produce the fingerprint with available data.", - tool_call_id: call.id, - }); - continue; - } - - const result = await executeTool(call, this.toolCtx); - this.chatMessages.push({ - role: "tool", - content: result.content, - tool_call_id: call.id, - }); - this.toolCallCount++; - state.reasoning.push( - `Tool ${call.name}: ${result.content.slice(0, 100)}...`, - ); - } - } - - // Send tool results back to LLM - const response = await provider.chat( - this.chatMessages, - getToolDefinitions(), - ); - - if ( - response.tool_calls?.length && - this.toolCallCount < this.maxToolCalls - ) { - // More tool calls requested - this.chatMessages.push({ - role: "assistant", - content: response.content ?? "", - tool_calls: response.tool_calls, - }); - state.reasoning.push( - `LLM requested ${response.tool_calls.length} more tool(s): ${response.tool_calls.map((tc) => tc.name).join(", ")}`, - ); - return state; - } - - // LLM returned content — parse as fingerprint - if (response.content) { - this.chatMessages.push({ - role: "assistant", - content: response.content, - }); - try { - this.pendingFingerprint = this.parseFingerprint(response.content); - state.confidence = 0.85; - state.reasoning.push( - "LLM produced fingerprint after tool exploration", - ); - } catch { - // If tool budget exhausted but no valid JSON, ask one more time - if (this.toolCallCount >= this.maxToolCalls) { - this.chatMessages.push({ - role: "user", - content: - "Please produce the fingerprint JSON now with all the data you have gathered.", - }); - const finalResponse = await provider.chat(this.chatMessages, []); - if (finalResponse.content) { - this.pendingFingerprint = this.parseFingerprint( - finalResponse.content, - ); - state.confidence = 0.8; - state.reasoning.push( - "LLM produced fingerprint after final prompt", - ); - } - } - } - } - } catch (err) { - state.warnings.push( - `Tool use loop error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - return state; - } - - private async validateAndFinalize( - state: AgentState, - input: SampledMaterial, - ctx: AgentContext, - ): Promise> { - if (!this.pendingFingerprint) { - state.status = "failed"; - state.warnings.push("No fingerprint to validate"); - return state; - } - - const fp = this.pendingFingerprint; - - // Recompute all oklch tuples from value strings using deterministic math. - // Don't trust the LLM's mental color space conversion. - recomputeOklch(fp); - - // Embed design decisions so compare can match them by cosine similarity. - // Batched into one API call. No-op when no embedding provider configured. - if (ctx.embedding && fp.decisions && fp.decisions.length > 0) { - try { - const texts = fp.decisions.map((d) => `${d.dimension}: ${d.decision}`); - const vectors = await embedTexts(texts, ctx.embedding); - for (let i = 0; i < fp.decisions.length; i++) { - fp.decisions[i].embedding = vectors[i]; - } - } catch (err) { - state.warnings.push( - `Decision embedding failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Compute fingerprint-level embedding - fp.embedding = ctx.embedding - ? await computeSemanticEmbedding(fp, ctx.embedding) - : computeEmbedding(fp); - - // Validate - const issues = this.validateOutput(fp); - if (issues.length > 0) { - state.reasoning.push( - `Validation: ${issues.length} issue(s): ${issues.join("; ")}`, - ); - } else { - state.confidence = Math.min(state.confidence + 0.1, 0.95); - state.reasoning.push("Validation passed"); - } - - const enriched: EnrichedFingerprint = { - ...fp, - targetType: input.metadata.targetType, - }; - - state.result = enriched; - state.status = "completed"; - - // Clean up - this.pendingFingerprint = null; - this.chatMessages = []; - this.toolCtx = null; - - return state; - } - - /** - * Set the tool context for this agent run. - * Must be called before execute() when tools should be available. - */ - setToolContext(toolCtx: ToolContext): void { - this.toolCtx = toolCtx; - } - - private hasPendingToolCalls(): boolean { - const last = this.chatMessages[this.chatMessages.length - 1]; - return !!last?.tool_calls?.length; - } - - /** - * Build an overview of the sampled material for the LLM's first message. - * Includes source labels, a file map, and the top files pre-read. - */ - private buildOverview(input: SampledMaterial): string { - const parts: string[] = []; - - // Source summary - if (input.metadata.sources && input.metadata.sources.length > 1) { - parts.push("## Sources\n"); - parts.push("This design system spans multiple sources:\n"); - for (const src of input.metadata.sources) { - parts.push( - `- **${src.label}** (${src.targetType}) — ${src.fileCount} files, ${src.sampledCount} sampled`, - ); - } - parts.push(""); - } - - // Top files pre-read (the sampled material). - // Note: path column is the clean relative path — the `source:` suffix is - // metadata. When calling tools, pass the path unchanged and pass `source` - // separately for multi-source lookups. - if (input.files.length > 0) { - parts.push("## Pre-sampled Files (highest design-signal density)\n"); - const multi = (input.metadata.sources?.length ?? 1) > 1; - for (const f of input.files) { - const srcSuffix = - multi && f.sourceLabel ? ` [source: ${f.sourceLabel}]` : ""; - parts.push(`--- ${f.path}${srcSuffix} (${f.reason}) ---`); - parts.push(f.content); - parts.push(""); - } - } - - // Package manifest hints - if (input.metadata.packageJson?.name) { - parts.push(`Package: ${input.metadata.packageJson.name}`); - const deps = { - ...input.metadata.packageJson.dependencies, - ...input.metadata.packageJson.devDependencies, - }; - const designDeps = Object.keys(deps).filter((d) => - /tailwind|styled|emotion|chakra|mui|radix|shadcn|vanilla-extract|sass|postcss/i.test( - d, - ), - ); - if (designDeps.length > 0) { - parts.push(`Design-related dependencies: ${designDeps.join(", ")}`); - } - } - - if (input.metadata.packageSwift?.name) { - parts.push(`Swift Package: ${input.metadata.packageSwift.name}`); - } - - return parts.join("\n"); - } - - private parseFingerprint(text: string): DesignFingerprint { - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error("Failed to extract JSON from LLM response"); - } - const raw = JSON.parse(jsonMatch[0]); - const fingerprint: DesignFingerprint = raw; - fingerprint.source = "llm"; - fingerprint.timestamp = new Date().toISOString(); - - // Preserve three-layer fields - if (raw.observation && typeof raw.observation.summary === "string") { - fingerprint.observation = raw.observation; - } - if (Array.isArray(raw.decisions) && raw.decisions.length > 0) { - fingerprint.decisions = raw.decisions; - } - - return fingerprint; - } - - private validateOutput(fp: DesignFingerprint): string[] { - const issues: string[] = []; - - if (fp.palette.dominant.length === 0 && fp.palette.semantic.length === 0) { - issues.push("No colors detected — palette is empty"); - } - - if (fp.spacing.scale.length === 0) { - issues.push("No spacing scale detected"); - } - - if (fp.typography.families.length === 0) { - issues.push("No font families detected"); - } - - // Check for unreasonable values - const spacingMax = 500; - const radiusMax = 200; - - for (const s of fp.spacing.scale) { - if (s < 0 || s > spacingMax) { - issues.push(`Unreasonable spacing value: ${s}`); - break; - } - } - - for (const r of fp.surfaces.borderRadii) { - // 9999/999 are common "pill" values — allow them - if (r < 0 || (r > radiusMax && r !== 999 && r !== 9999)) { - issues.push(`Unreasonable border radius: ${r}`); - break; - } - } - - return issues; - } -} - -/** - * Recompute all oklch tuples from color value strings using deterministic math. - * The LLM is asked to provide color values but not to do color space conversion — - * we handle that precisely here. - */ -function recomputeOklch(fp: DesignFingerprint): void { - for (const color of fp.palette.dominant) { - const oklch = parseColorToOklch(color.value); - if (oklch) color.oklch = oklch; - } - for (const color of fp.palette.semantic) { - const oklch = parseColorToOklch(color.value); - if (oklch) color.oklch = oklch; - } -} diff --git a/packages/ghost-core/src/agents/index.ts b/packages/ghost-core/src/agents/index.ts deleted file mode 100644 index f3b48d5..0000000 --- a/packages/ghost-core/src/agents/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { BaseAgent } from "./base.js"; -export type { ComparisonInput } from "./comparison.js"; -export { ComparisonAgent } from "./comparison.js"; -export type { - ComplianceInput, - ComplianceReport, - ComplianceRule, - ComplianceThresholds, - ComplianceViolation, -} from "./compliance.js"; -export { ComplianceAgent } from "./compliance.js"; -export { Director } from "./director.js"; -export type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; -export { DiscoveryAgent } from "./discovery.js"; -export { ExtractionAgent } from "./extraction.js"; -export { FingerprintAgent } from "./fingerprint.js"; -export type { Agent, AgentState } from "./types.js"; diff --git a/packages/ghost-core/src/agents/tools/index.ts b/packages/ghost-core/src/agents/tools/index.ts deleted file mode 100644 index c1fea6f..0000000 --- a/packages/ghost-core/src/agents/tools/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { listFilesTool } from "./list-files.js"; -import { readFileTool } from "./read-file.js"; -import { runExtractorTool } from "./run-extractor.js"; -import { searchFilesTool } from "./search-files.js"; -import type { - AgentTool, - ToolCall, - ToolContext, - ToolDefinition, - ToolResult, -} from "./types.js"; - -export type { - AgentTool, - ChatMessage, - ChatResponse, - ToolCall, - ToolContext, - ToolDefinition, - ToolResult, -} from "./types.js"; - -/** All available fingerprint agent tools. */ -export const FINGERPRINT_TOOLS: AgentTool[] = [ - searchFilesTool, - readFileTool, - runExtractorTool, - listFilesTool, -]; - -/** Convert tools to LLM-provider-neutral definitions. */ -export function getToolDefinitions(): ToolDefinition[] { - return FINGERPRINT_TOOLS.map((tool) => ({ - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - })); -} - -/** Dispatch a tool call and return the result. */ -export async function executeTool( - call: ToolCall, - ctx: ToolContext, -): Promise { - const tool = FINGERPRINT_TOOLS.find((t) => t.name === call.name); - if (!tool) { - return { content: `Unknown tool: ${call.name}` }; - } - return tool.execute(call.args, ctx); -} - -/** Default maximum number of tool calls per fingerprint run. */ -export const DEFAULT_MAX_TOOL_CALLS = 50; diff --git a/packages/ghost-core/src/agents/tools/list-files.ts b/packages/ghost-core/src/agents/tools/list-files.ts deleted file mode 100644 index 6bc2d0a..0000000 --- a/packages/ghost-core/src/agents/tools/list-files.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { walkDirectory } from "../../extractors/walker.js"; -import type { AgentTool, ToolContext, ToolResult } from "./types.js"; - -/** - * list_files — list available files across all source directories. - * - * The LLM uses this to explore the project structure when the - * initial sample doesn't provide enough context. - */ -export const listFilesTool: AgentTool = { - name: "list_files", - description: - "List files across all source directories, optionally filtered by keyword or scoped to a specific source. Shows file paths with source labels and types.", - parameters: { - type: "object", - properties: { - filter: { - type: "string", - description: - "Optional keyword to filter file paths (e.g., 'theme', 'color', 'token')", - }, - source: { - type: "string", - description: - "Optional source label to scope the listing to one source (e.g., 'npm:@arcade/tokens')", - }, - }, - required: [], - }, - - async execute( - args: Record, - ctx: ToolContext, - ): Promise { - const filter = args.filter ? String(args.filter).toLowerCase() : undefined; - const sourceFilter = args.source ? String(args.source) : undefined; - - try { - const dirs = sourceFilter - ? ctx.sourceDirs.filter((s) => s.label === sourceFilter) - : ctx.sourceDirs; - - if (dirs.length === 0) { - const available = ctx.sourceDirs.map((s) => s.label).join(", "); - return { - content: `Source "${sourceFilter}" not found. Available sources: ${available}`, - }; - } - - const allEntries: { label: string; path: string; type: string }[] = []; - - for (const src of dirs) { - const files = await walkDirectory(src.dir); - for (const f of files) { - allEntries.push({ label: src.label, path: f.path, type: f.type }); - } - } - - const filtered = filter - ? allEntries.filter((f) => f.path.toLowerCase().includes(filter)) - : allEntries; - - const showLabels = ctx.sourceDirs.length > 1; - const listing = filtered - .slice(0, 100) - .map((f) => `${showLabels ? `[${f.label}] ` : ""}${f.path} [${f.type}]`) - .join("\n"); - - return { - content: `${filtered.length} file(s)${filter ? ` matching "${filter}"` : ""}:\n${listing}${filtered.length > 100 ? `\n... and ${filtered.length - 100} more` : ""}`, - metadata: { totalFiles: filtered.length }, - }; - } catch (err) { - return { - content: `Error listing files: ${err instanceof Error ? err.message : String(err)}`, - }; - } - }, -}; diff --git a/packages/ghost-core/src/agents/tools/read-file.ts b/packages/ghost-core/src/agents/tools/read-file.ts deleted file mode 100644 index 61e3667..0000000 --- a/packages/ghost-core/src/agents/tools/read-file.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { AgentTool, ToolContext, ToolResult } from "./types.js"; - -const MAX_FILE_SIZE = 20_000; - -/** - * read_file — read a specific file from any source directory. - * - * The LLM uses this when it has discovered a file via list_files or - * search_files and wants to read its full contents. - */ -export const readFileTool: AgentTool = { - name: "read_file", - description: - "Read the full contents of a specific file. Provide the file path as shown by list_files or search_files. For multi-source projects, optionally specify which source to read from.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: - "File path relative to the source directory (e.g., 'src/tokens/colors.ts')", - }, - source: { - type: "string", - description: - "Source label when multiple sources exist (e.g., 'npm:@arcade/tokens'). If omitted, searches all sources.", - }, - }, - required: ["path"], - }, - - async execute( - args: Record, - ctx: ToolContext, - ): Promise { - const filePath = String(args.path ?? ""); - if (!filePath) { - return { content: "Error: path is required" }; - } - - const sourceFilter = args.source ? String(args.source) : undefined; - const dirs = sourceFilter - ? ctx.sourceDirs.filter((s) => s.label === sourceFilter) - : ctx.sourceDirs; - - // Try each source directory until we find the file - for (const src of dirs) { - try { - const fullPath = join(src.dir, filePath); - const content = await readFile(fullPath, "utf-8"); - const showLabel = ctx.sourceDirs.length > 1; - const header = showLabel ? `[${src.label}] ${filePath}` : filePath; - - const truncated = - content.length > MAX_FILE_SIZE - ? `${content.slice(0, MAX_FILE_SIZE)}\n... (truncated, ${content.length} chars total)` - : content; - - return { - content: `--- ${header} ---\n${truncated}`, - metadata: { source: src.label, size: content.length }, - }; - } catch { - // File not found in this source, try next - } - } - - const available = ctx.sourceDirs.map((s) => s.label).join(", "); - return { - content: `File "${filePath}" not found in any source directory. Available sources: ${available}`, - }; - }, -}; diff --git a/packages/ghost-core/src/agents/tools/run-extractor.ts b/packages/ghost-core/src/agents/tools/run-extractor.ts deleted file mode 100644 index 54ae855..0000000 --- a/packages/ghost-core/src/agents/tools/run-extractor.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { parseCSS } from "../../resolvers/css.js"; -import type { CSSToken } from "../../types.js"; -import type { AgentTool, ToolContext, ToolResult } from "./types.js"; - -/** - * run_extractor — run a deterministic signal extractor on a specific file. - * - * The LLM uses this when it wants structured token data from a file, - * rather than interpreting the raw source. - */ -export const runExtractorTool: AgentTool = { - name: "run_extractor", - description: - "Run a deterministic extractor on a sampled file to get structured token data. Use when you want parsed CSS custom properties, JSON tokens, or Swift token definitions rather than interpreting raw source.", - parameters: { - type: "object", - properties: { - file_path: { - type: "string", - description: - "Path of the file to extract from (relative, as shown in the sample)", - }, - extractor: { - type: "string", - description: "Which extractor to run", - enum: ["css", "json"], - }, - source: { - type: "string", - description: - "Source label when multiple sources exist (e.g. 'npm:@arcade/tokens'). Required if the same path appears in more than one source.", - }, - }, - required: ["file_path", "extractor"], - }, - - async execute( - args: Record, - ctx: ToolContext, - ): Promise { - const filePath = String(args.file_path ?? ""); - const extractor = String(args.extractor ?? ""); - const sourceFilter = args.source ? String(args.source) : undefined; - - // Find the file in sampled material — match on path, and source when given. - const candidates = ctx.material.files.filter((f) => { - if (f.path !== filePath) return false; - if (sourceFilter && f.sourceLabel !== sourceFilter) return false; - return true; - }); - - if (candidates.length === 0) { - const available = ctx.material.files - .map((f) => (f.sourceLabel ? `${f.path} [${f.sourceLabel}]` : f.path)) - .join(", "); - return { - content: `File "${filePath}"${sourceFilter ? ` in source "${sourceFilter}"` : ""} not found in sampled material. Available: ${available}`, - }; - } - - if (candidates.length > 1) { - const sources = candidates - .map((f) => f.sourceLabel ?? "(unlabeled)") - .join(", "); - return { - content: `Ambiguous: "${filePath}" exists in multiple sources (${sources}). Pass the "source" argument to disambiguate.`, - }; - } - - const file = candidates[0]; - - try { - let tokens: CSSToken[] = []; - - switch (extractor) { - case "css": - tokens = parseCSS(file.content); - break; - case "json": - tokens = extractJSONTokens(file.content); - break; - default: - return { - content: `Unknown extractor: ${extractor}. Use "css" or "json".`, - }; - } - - if (tokens.length === 0) { - return { - content: `No tokens extracted from ${filePath} using ${extractor} extractor.`, - }; - } - - const summary = tokens - .slice(0, 50) - .map((t) => `${t.name}: ${t.resolvedValue ?? t.value} [${t.category}]`) - .join("\n"); - - return { - content: `Extracted ${tokens.length} tokens from ${filePath}:\n${summary}${tokens.length > 50 ? `\n... and ${tokens.length - 50} more` : ""}`, - metadata: { tokenCount: tokens.length }, - }; - } catch (err) { - return { - content: `Extraction error: ${err instanceof Error ? err.message : String(err)}`, - }; - } - }, -}; - -function extractJSONTokens(content: string): CSSToken[] { - try { - const json = JSON.parse(content); - return flattenTokenJSON(json, ""); - } catch { - return []; - } -} - -function flattenTokenJSON( - obj: Record, - prefix: string, -): CSSToken[] { - const tokens: CSSToken[] = []; - - for (const [key, val] of Object.entries(obj)) { - if (key.startsWith("$")) continue; - const path = prefix ? `${prefix}-${key}` : key; - - if (typeof val === "object" && val !== null) { - const record = val as Record; - if ("$value" in record || "value" in record) { - const value = String(record.$value ?? record.value); - tokens.push({ - name: `--${path}`, - value, - selector: ":root", - category: "other", - resolvedValue: value, - }); - } else { - tokens.push(...flattenTokenJSON(record, path)); - } - } - } - - return tokens; -} diff --git a/packages/ghost-core/src/agents/tools/search-files.ts b/packages/ghost-core/src/agents/tools/search-files.ts deleted file mode 100644 index 6bc706b..0000000 --- a/packages/ghost-core/src/agents/tools/search-files.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { walkDirectory } from "../../extractors/walker.js"; -import type { AgentTool, ToolContext, ToolResult } from "./types.js"; - -const MAX_RESULT_SIZE = 8000; - -/** - * search_files — search across all source directories for files matching a pattern. - * - * The LLM uses this when it needs more design signal files - * that weren't included in the initial sample. - */ -export const searchFilesTool: AgentTool = { - name: "search_files", - description: - "Search all source directories for files matching a keyword pattern. Returns file contents for matching files. Use when the initial sample is missing design tokens, theme files, or specific configuration.", - parameters: { - type: "object", - properties: { - pattern: { - type: "string", - description: - "Keyword or glob pattern to match file paths (e.g., 'spacing', 'tokens', '*.theme.ts', 'Color.swift')", - }, - source: { - type: "string", - description: - "Optional source label to scope the search (e.g., 'npm:@arcade/tokens')", - }, - reason: { - type: "string", - description: "Why you need these files (for logging)", - }, - }, - required: ["pattern"], - }, - - async execute( - args: Record, - ctx: ToolContext, - ): Promise { - const pattern = String(args.pattern ?? ""); - if (!pattern) { - return { content: "Error: pattern is required" }; - } - - const sourceFilter = args.source ? String(args.source) : undefined; - - try { - const dirs = sourceFilter - ? ctx.sourceDirs.filter((s) => s.label === sourceFilter) - : ctx.sourceDirs; - - const keyword = pattern.toLowerCase(); - const allMatches: { label: string; dir: string; path: string }[] = []; - - for (const src of dirs) { - const files = await walkDirectory(src.dir); - for (const f of files) { - if (f.path.toLowerCase().includes(keyword)) { - allMatches.push({ label: src.label, dir: src.dir, path: f.path }); - } - } - } - - if (allMatches.length === 0) { - return { - content: `No files matching "${pattern}" found.`, - metadata: { matchCount: 0 }, - }; - } - - // Read up to 5 matching files, respecting size budget - const results: string[] = []; - let totalSize = 0; - const showLabels = ctx.sourceDirs.length > 1; - - for (const match of allMatches.slice(0, 5)) { - if (totalSize > MAX_RESULT_SIZE) break; - try { - const fullPath = join(match.dir, match.path); - const content = await readFile(fullPath, "utf-8"); - const truncated = - content.length > 3000 - ? `${content.slice(0, 3000)}\n... (truncated)` - : content; - const header = showLabels - ? `[${match.label}] ${match.path}` - : match.path; - results.push(`--- ${header} ---\n${truncated}`); - totalSize += truncated.length; - } catch { - // Skip unreadable files - } - } - - return { - content: `Found ${allMatches.length} file(s) matching "${pattern}":\n\n${results.join("\n\n")}`, - metadata: { matchCount: allMatches.length }, - }; - } catch (err) { - return { - content: `Search error: ${err instanceof Error ? err.message : String(err)}`, - }; - } - }, -}; diff --git a/packages/ghost-core/src/agents/tools/types.ts b/packages/ghost-core/src/agents/tools/types.ts deleted file mode 100644 index 6a8e934..0000000 --- a/packages/ghost-core/src/agents/tools/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { SampledMaterial } from "../../types.js"; - -/** - * A tool the fingerprint agent can invoke during analysis. - */ -export interface AgentTool { - name: string; - description: string; - parameters: ToolParameters; - execute(args: Record, ctx: ToolContext): Promise; -} - -export interface ToolParameters { - type: "object"; - properties: Record< - string, - { - type: string; - description: string; - enum?: string[]; - } - >; - required: string[]; -} - -export interface ToolContext { - /** Source directories on disk, keyed by label. Single-source = one entry. */ - sourceDirs: { label: string; dir: string }[]; - /** The originally sampled material */ - material: SampledMaterial; -} - -export interface ToolResult { - content: string; - metadata?: Record; -} - -/** Tool definition in LLM-provider-neutral format (maps to both Anthropic and OpenAI). */ -export interface ToolDefinition { - name: string; - description: string; - input_schema: ToolParameters; -} - -/** A chat message in multi-turn tool-use conversation. */ -export interface ChatMessage { - role: "user" | "assistant" | "tool"; - content: string; - tool_call_id?: string; - tool_calls?: ToolCall[]; -} - -export interface ToolCall { - id: string; - name: string; - args: Record; -} - -export interface ChatResponse { - content?: string; - tool_calls?: ToolCall[]; - stop_reason?: string; -} diff --git a/packages/ghost-core/src/agents/types.ts b/packages/ghost-core/src/agents/types.ts deleted file mode 100644 index ccaa538..0000000 --- a/packages/ghost-core/src/agents/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AgentContext, AgentMessage, AgentResult } from "../types.js"; - -export interface AgentState { - messages: AgentMessage[]; - result?: T; - confidence: number; - status: "running" | "completed" | "failed" | "needs-input"; - iterations: number; - reasoning: string[]; - warnings: string[]; -} - -export interface Agent { - name: string; - maxIterations: number; - systemPrompt: string; - execute(input: TInput, ctx: AgentContext): Promise>; -} - -export type { AgentContext, AgentMessage, AgentResult }; diff --git a/packages/ghost-core/src/compare.ts b/packages/ghost-core/src/compare.ts new file mode 100644 index 0000000..3357e75 --- /dev/null +++ b/packages/ghost-core/src/compare.ts @@ -0,0 +1,90 @@ +import { compareFingerprints } from "./embedding/compare.js"; +import { compareFleet } from "./evolution/fleet.js"; +import { computeTemporalComparison } from "./evolution/temporal.js"; +import type { SemanticDiff } from "./fingerprint/diff.js"; +import { diffFingerprints } from "./fingerprint/diff.js"; +import type { + Fingerprint, + FingerprintComparison, + FingerprintHistoryEntry, + FleetComparison, + FleetMember, + SyncManifest, + TemporalComparison, +} from "./types.js"; + +export interface CompareOptions { + /** Include a qualitative semantic diff. N=2 only. */ + semantic?: boolean; + /** Enrich with drift velocity, trajectory, ack status. N=2 only. */ + history?: FingerprintHistoryEntry[]; + /** Companion to `history` — the ack manifest, if any. */ + manifest?: SyncManifest | null; + /** Explicit member ids for fleet mode. Defaults to `fingerprint.id`. */ + ids?: string[]; +} + +export type CompareResult = + | { + mode: "pairwise"; + comparison: FingerprintComparison; + semantic?: SemanticDiff; + temporal?: TemporalComparison; + } + | { + mode: "fleet"; + fleet: FleetComparison; + }; + +/** + * Unified fingerprint comparison. + * + * • N=2 → pairwise (distance + per-dimension delta). + * • N=2 + semantic → adds a qualitative diff (what decisions/colors changed). + * • N=2 + history → adds velocity, trajectory, ack bounds. + * • N≥3 → fleet (pairwise matrix, centroid, spread, clusters). + * + * Rejects semantic/temporal in fleet mode — both are pairwise concepts. + */ +export function compare( + fingerprints: Fingerprint[], + options: CompareOptions = {}, +): CompareResult { + if (fingerprints.length < 2) { + throw new Error("compare requires at least 2 fingerprints."); + } + + if (fingerprints.length >= 3) { + if (options.semantic || options.history) { + throw new Error( + "semantic and temporal require exactly 2 fingerprints (pairwise mode).", + ); + } + const ids = options.ids; + const members: FleetMember[] = fingerprints.map((fingerprint, i) => ({ + id: ids?.[i] ?? fingerprint.id, + fingerprint, + })); + return { mode: "fleet", fleet: compareFleet(members, { cluster: true }) }; + } + + const [a, b] = fingerprints; + const comparison = compareFingerprints(a, b); + + const semantic = options.semantic ? diffFingerprints(a, b) : undefined; + const temporal = + options.history !== undefined + ? computeTemporalComparison({ + comparison, + history: options.history, + manifest: options.manifest ?? null, + }) + : undefined; + + return { + mode: "pairwise", + comparison, + ...(semantic ? { semantic } : {}), + ...(temporal ? { temporal } : {}), + }; +} diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts index 54a6fcd..7ffd847 100644 --- a/packages/ghost-core/src/config.ts +++ b/packages/ghost-core/src/config.ts @@ -6,15 +6,12 @@ import type { GhostConfig, Target } from "./types.js"; const CONFIG_FILES = ["ghost.config.ts", "ghost.config.js", "ghost.config.mjs"]; const DEFAULT_CONFIG: GhostConfig = { - scan: { values: true, structure: true, visual: false, analysis: false }, rules: { "hardcoded-color": "error", "token-override": "warn", "missing-token": "warn", "structural-divergence": "error", "missing-component": "warn", - "visual-regression": "warn", - "visual-render-failure": "warn", }, ignore: [], }; @@ -130,15 +127,10 @@ function mergeDefaults(raw: GhostConfig): GhostConfig { return { targets: raw.targets, parent: normalizeParent(raw.parent as Target | string | undefined), - scan: { ...DEFAULT_CONFIG.scan, ...raw.scan }, rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, - visual: raw.visual, - llm: raw.llm, embedding: raw.embedding, extractors: raw.extractors, - agents: raw.agents, - review: raw.review, }; } diff --git a/packages/ghost-core/src/context/index.ts b/packages/ghost-core/src/context/index.ts new file mode 100644 index 0000000..2001255 --- /dev/null +++ b/packages/ghost-core/src/context/index.ts @@ -0,0 +1,9 @@ +export type { EmitReviewInput } from "./review-command.js"; +export { emitReviewCommand } from "./review-command.js"; +export { buildTokensCss } from "./tokens-css.js"; +export type { + ContextFormat, + WriteContextOptions, + WriteContextResult, +} from "./writer.js"; +export { buildSkillMd, writeContextBundle } from "./writer.js"; diff --git a/packages/ghost-core/src/context/review-command.ts b/packages/ghost-core/src/context/review-command.ts new file mode 100644 index 0000000..ce19120 --- /dev/null +++ b/packages/ghost-core/src/context/review-command.ts @@ -0,0 +1,333 @@ +import type { Fingerprint } from "../types.js"; + +export interface EmitReviewInput { + fingerprint: Fingerprint; +} + +/** + * Emit a project-fitted drift-review slash command from an fingerprint. + * + * Produces a single Markdown file styled after Rams' `/rams` slash command + * — role prompt, per-dimension rule tables, output template, guidelines — + * populated with this system's actual palette, radii, spacing, and + * typography values. Default output path: `.claude/commands/design-review.md`. + * + * Scope is drift-only: off-palette hex, off-ramp spacing, non-canonical + * radii and weights. Universal accessibility rules are out of scope — + * those belong in Rams or a sibling a11y skill. + * + * Pure: deterministic over the same fingerprint. The fingerprint is + * expected to be the unioned result of `loadFingerprint` — body prose + * (Character summary, per-decision rationale) is already folded into + * `observation.summary` and `decisions[].decision`. + */ +export function emitReviewCommand(input: EmitReviewInput): string { + const { fingerprint: fp } = input; + const id = fp.id; + const personality = (fp.observation?.personality ?? []).join(", "); + const cousins = (fp.observation?.closestSystems ?? []).join(", "); + const character = fp.observation?.summary?.trim() ?? ""; + + const parts = [ + frontmatter(id), + header(id, personality, cousins, character), + modeSection(), + paletteSection(fp), + radiusSection(fp), + spacingSection(fp), + typographySection(fp), + otherDimensions(fp), + outputTemplate(id), + guidelines(), + footer(fp), + ]; + return `${parts.filter(Boolean).join("\n\n").trim()}\n`; +} + +function frontmatter(id: string): string { + return `--- +description: Drift review for ${id} — fitted to this system's design fingerprint +---`; +} + +function header( + id: string, + personality: string, + cousins: string, + character: string, +): string { + const taste = personality + ? `This system reads as *${personality}*${cousins ? ` — closest cousins: ${cousins}` : ""}.` + : ""; + const lines = [`# ${id} drift review`, ""]; + lines.push( + `You are a drift reviewer for the **${id}** design system. ${taste}`.trim(), + ); + if (character) lines.push("", character); + lines.push( + "", + "Your job: check code for **drift** from the values below — hardcoded hexes, off-ramp spacing, typography outside the scale, radii outside the set. You are **not** checking accessibility or universal design rules; use `/rams` or a dedicated a11y skill for that.", + ); + return lines.join("\n"); +} + +function modeSection(): string { + return `## Mode + +If \`$ARGUMENTS\` is provided, analyze that specific file. +If \`$ARGUMENTS\` is empty, ask the user which file(s) to review, or offer to scan recently changed components.`; +} + +// --- Palette ------------------------------------------------------------ + +const TRUE_SEMANTIC_ROLES = new Set([ + "danger", + "success", + "info", + "warning", + "error", +]); + +function paletteSection(fp: Fingerprint): string { + const allowed = allowedPalette(fp); + const allowedList = allowed.map((h) => `\`${h}\``).join(", "); + const dominant = fp.palette.dominant + .map((c) => `\`${c.value}\` (${c.role})`) + .join(", "); + const neutrals = fp.palette.neutrals.steps.map((h) => `\`${h}\``).join(", "); + const semantic = fp.palette.semantic.filter((c) => + TRUE_SEMANTIC_ROLES.has(c.role), + ); + const rationale = findRationale(fp, ["color-strategy", "palette", "color"]); + + const lines: string[] = []; + lines.push("## 1. Palette drift"); + if (rationale) lines.push("", `> ${rationale}`); + lines.push( + "", + `**Allowed colors** (${allowed.length} total — prefer semantic tokens over raw hex):`, + "", + `- Dominant: ${dominant}`, + `- Neutrals (ramp): ${neutrals}`, + ); + if (semantic.length) { + const sem = semantic.map((c) => `\`${c.value}\` (${c.role})`).join(", "); + lines.push(`- Semantic hues: ${sem}`); + } + lines.push( + "", + "### Critical", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + `| Off-palette hex in JSX/CSS | ${truncateList(allowedList, 120)} | Any \`#[0-9a-fA-F]{3,8}\` literal not in the allowed list |`, + "| Tailwind arbitrary color | use semantic tokens | `bg-[#...]`, `text-[#...]`, `border-[#...]` with arbitrary hex |", + ); + if (semantic.length) { + lines.push( + "| Named Tailwind color for semantic role | use semantic token | `text-red-500`, `bg-green-600`, etc. when a matching semantic token exists |", + "", + "### Serious", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + ); + for (const c of semantic) { + lines.push( + `| ${c.role} must use the semantic token | \`${c.value}\` | Raw \`${c.value}\` or near-equivalent hardcoded; prefer the \`${c.role}\` token |`, + ); + } + } + return lines.join("\n"); +} + +function allowedPalette(fp: Fingerprint): string[] { + const all = [ + ...fp.palette.dominant.map((c) => c.value), + ...fp.palette.neutrals.steps, + ...fp.palette.semantic.map((c) => c.value), + ]; + return [...new Set(all.map((h) => h.toLowerCase()))]; +} + +// --- Radius ------------------------------------------------------------- + +function radiusSection(fp: Fingerprint): string { + const radii = fp.surfaces.borderRadii; + if (!radii?.length) return ""; + const labeled = radii.map((r) => (r >= 999 ? "999px (pill)" : `${r}px`)); + const allowedList = labeled.map((r) => `\`${r}\``).join(", "); + const rationale = findRationale(fp, ["shape-language", "shape", "radius"]); + const hasPill = radii.some((r) => r >= 999); + + const lines: string[] = ["## 2. Shape language (radius)"]; + if (rationale) lines.push("", `> ${rationale}`); + lines.push( + "", + `**Allowed radii**: ${allowedList}`, + "", + "### Critical", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + `| Custom radius value | ${allowedList} | \`rounded-[Npx]\`, \`border-radius: Npx\`, or \`--radius: Npx\` outside the set |`, + ); + if (hasPill) { + lines.push( + "| Interactive element not pill | `rounded-full` / `rounded-pill` | `