From df1dca660651df630246b8dce7a646bd4ab4a722 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sat, 25 Apr 2026 00:49:48 -0400 Subject: [PATCH] Rename fingerprint artifacts to expressions --- .changeset/expression-canonical-artifact.md | 5 + CLAUDE.md | 45 ++--- INVARIANTS.md | 18 +- README.md | 79 ++++---- apps/docs/README.md | 2 +- apps/docs/src/app/docs/page.tsx | 12 +- apps/docs/src/app/docs/workflow/page.tsx | 121 ++++++------ apps/docs/src/app/page.tsx | 16 +- apps/docs/src/app/tools/page.tsx | 4 +- apps/docs/src/content/docs/cli-reference.mdx | 65 ++++--- .../docs/src/content/docs/getting-started.mdx | 66 ++++--- apps/docs/src/generated/cli-manifest.json | 36 ++-- docs/cli-consolidation-plan.md | 44 ++--- ...erprint-format.md => expression-format.md} | 86 ++++----- docs/generation-loop.md | 32 ++-- docs/ideas/guided-migration.md | 18 +- packages/ghost-drift/CHANGELOG.md | 12 +- packages/ghost-drift/README.md | 32 ++-- packages/ghost-drift/package.json | 6 +- packages/ghost-drift/src/cli.ts | 38 ++-- packages/ghost-drift/src/core/compare.ts | 42 ++-- packages/ghost-drift/src/core/config.ts | 6 +- .../src/core/context/review-command.ts | 36 ++-- .../src/core/context/tokens-css.ts | 30 +-- .../ghost-drift/src/core/context/writer.ts | 84 ++++---- .../ghost-drift/src/core/embedding/compare.ts | 38 ++-- .../src/core/embedding/describe.ts | 14 +- .../src/core/embedding/embed-api.ts | 12 +- .../src/core/embedding/embedding.ts | 28 +-- .../ghost-drift/src/core/embedding/index.ts | 4 +- .../src/core/evolution/composite.ts | 14 +- .../ghost-drift/src/core/evolution/emit.ts | 26 +-- .../ghost-drift/src/core/evolution/history.ts | 12 +- .../ghost-drift/src/core/evolution/index.ts | 7 +- .../ghost-drift/src/core/evolution/sync.ts | 27 +-- .../src/core/evolution/temporal.ts | 24 +-- .../core/evolution/{parent.ts => tracking.ts} | 44 ++--- .../ghost-drift/src/core/evolution/vector.ts | 8 +- .../core/{fingerprint => expression}/body.ts | 2 +- .../src/core/expression/compose.ts | 111 +++++++++++ .../core/{fingerprint => expression}/diff.ts | 12 +- .../{fingerprint => expression}/fragments.ts | 38 ++-- .../frontmatter.ts | 50 ++--- .../core/{fingerprint => expression}/index.ts | 123 ++++++------ .../{fingerprint => expression}/layout.ts | 22 +-- .../core/{fingerprint => expression}/lint.ts | 34 ++-- .../{fingerprint => expression}/parser.ts | 52 ++--- .../{fingerprint => expression}/references.ts | 8 +- .../{fingerprint => expression}/schema.ts | 24 +-- .../{fingerprint => expression}/writer.ts | 30 +-- .../src/core/fingerprint/compose.ts | 108 ----------- packages/ghost-drift/src/core/index.ts | 50 ++--- .../src/core/reporters/composite.ts | 12 +- .../{fingerprint.ts => expression.ts} | 18 +- packages/ghost-drift/src/core/types.ts | 58 +++--- packages/ghost-drift/src/emit-command.ts | 28 +-- .../ghost-drift/src/evolution-commands.ts | 66 +++---- .../ghost-drift/src/skill-bundle/SKILL.md | 32 ++-- ...int.template.md => expression.template.md} | 2 +- .../src/skill-bundle/references/compare.md | 14 +- .../src/skill-bundle/references/discover.md | 20 +- .../src/skill-bundle/references/generate.md | 26 +-- .../src/skill-bundle/references/profile.md | 28 +-- .../src/skill-bundle/references/review.md | 36 ++-- .../src/skill-bundle/references/schema.md | 14 +- .../src/skill-bundle/references/verify.md | 22 +-- packages/ghost-drift/test/cli.test.ts | 180 ++++++++++++++++++ packages/ghost-drift/test/compare.test.ts | 13 +- .../__snapshots__/review-command.test.ts.snap | 6 +- .../test/context/review-command.test.ts | 32 ++-- .../ghost-drift/test/context/writer.test.ts | 24 +-- .../test/embedding/compare-decisions.test.ts | 22 +-- .../test/embedding/embedding.test.ts | 8 +- .../test/evolution/composite.test.ts | 6 +- .../ghost-drift/test/evolution/sync.test.ts | 23 +-- .../compose.test.ts | 142 +++++++------- .../{fingerprint => expression}/diff.test.ts | 28 +-- .../fragments.test.ts | 78 ++++---- .../layout.test.ts | 26 +-- .../{fingerprint => expression}/lint.test.ts | 32 ++-- .../{fingerprint => expression}/load.test.ts | 179 +++++++++-------- .../references.test.ts | 8 +- packages/ghost-ui/README.md | 6 +- .../{fingerprint.md => expression.md} | 2 +- packages/ghost-ui/fingerprint.json | 121 ------------ ...int.schema.json => expression.schema.json} | 6 +- scripts/check-file-sizes.mjs | 4 +- ...-schema.mjs => emit-expression-schema.mjs} | 14 +- 88 files changed, 1623 insertions(+), 1570 deletions(-) create mode 100644 .changeset/expression-canonical-artifact.md rename docs/{fingerprint-format.md => expression-format.md} (77%) rename packages/ghost-drift/src/core/evolution/{parent.ts => tracking.ts} (52%) rename packages/ghost-drift/src/core/{fingerprint => expression}/body.ts (97%) create mode 100644 packages/ghost-drift/src/core/expression/compose.ts rename packages/ghost-drift/src/core/{fingerprint => expression}/diff.ts (94%) rename packages/ghost-drift/src/core/{fingerprint => expression}/fragments.ts (83%) rename packages/ghost-drift/src/core/{fingerprint => expression}/frontmatter.ts (72%) rename packages/ghost-drift/src/core/{fingerprint => expression}/index.ts (57%) rename packages/ghost-drift/src/core/{fingerprint => expression}/layout.ts (92%) rename packages/ghost-drift/src/core/{fingerprint => expression}/lint.ts (88%) rename packages/ghost-drift/src/core/{fingerprint => expression}/parser.ts (77%) rename packages/ghost-drift/src/core/{fingerprint => expression}/references.ts (96%) rename packages/ghost-drift/src/core/{fingerprint => expression}/schema.ts (90%) rename packages/ghost-drift/src/core/{fingerprint => expression}/writer.ts (85%) delete mode 100644 packages/ghost-drift/src/core/fingerprint/compose.ts rename packages/ghost-drift/src/core/reporters/{fingerprint.ts => expression.ts} (87%) rename packages/ghost-drift/src/skill-bundle/assets/{fingerprint.template.md => expression.template.md} (98%) create mode 100644 packages/ghost-drift/test/cli.test.ts rename packages/ghost-drift/test/{fingerprint => expression}/compose.test.ts (59%) rename packages/ghost-drift/test/{fingerprint => expression}/diff.test.ts (80%) rename packages/ghost-drift/test/{fingerprint => expression}/fragments.test.ts (70%) rename packages/ghost-drift/test/{fingerprint => expression}/layout.test.ts (89%) rename packages/ghost-drift/test/{fingerprint => expression}/lint.test.ts (88%) rename packages/ghost-drift/test/{fingerprint => expression}/load.test.ts (66%) rename packages/ghost-drift/test/{fingerprint => expression}/references.test.ts (95%) rename packages/ghost-ui/{fingerprint.md => expression.md} (99%) delete mode 100644 packages/ghost-ui/fingerprint.json rename schemas/{fingerprint.schema.json => expression.schema.json} (98%) rename scripts/{emit-fingerprint-schema.mjs => emit-expression-schema.mjs} (54%) diff --git a/.changeset/expression-canonical-artifact.md b/.changeset/expression-canonical-artifact.md new file mode 100644 index 0000000..e9c7a33 --- /dev/null +++ b/.changeset/expression-canonical-artifact.md @@ -0,0 +1,5 @@ +--- +"ghost-drift": major +--- + +Rename the public fingerprint artifact and APIs to expression, default CLI reads to expression.md, and replace adopt/parent tracking with track/tracks. diff --git a/CLAUDE.md b/CLAUDE.md index 609eed3..b09afaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,8 @@ pnpm --filter ghost-drift exec ghost 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). +- `OPENAI_API_KEY` / `VOYAGE_API_KEY` — optional, consumed only by `computeSemanticEmbedding` (library function; used when a host writes an expression.md and wants an enriched 49-dim vector for paraphrase-robust comparison). +- `GITHUB_TOKEN` — optional, for `resolveTrackedExpression` fetching a tracked expression from GitHub (avoids rate limits). The CLI auto-loads `.env` and `.env.local` from the working directory. @@ -51,10 +51,10 @@ Engine layout (lives under `packages/ghost-drift/src/core/`): - `core/compare.ts` — embedding-based comparison (pairwise + composite) - `core/embedding/` — 49-dim vector computation, optional semantic embedding via OpenAI/Voyage -- `core/fingerprint/` — parse/compose/diff/lint `fingerprint.md` -- `core/evolution/` — history, ack manifest, composite analysis, parent resolution +- `core/expression/` — parse/compose/diff/lint `expression.md` +- `core/evolution/` — history, ack manifest, composite analysis, tracked-expression resolution - `core/context/` — artifact generators (review-command, context-bundle, tokens.css) -- `core/reporters/` — output formatters for compare/composite/temporal/fingerprint +- `core/reporters/` — output formatters for compare/composite/temporal/expression CLI glue sits alongside under `packages/ghost-drift/src/` (`bin.ts`, `cli.ts`, `emit-command.ts`, `evolution-commands.ts`, `target-resolver.ts`, `skill-bundle.ts`). The `./core/index.js` barrel is the single library export; `./cli` is the CLI subpath export. @@ -64,28 +64,29 @@ What was removed in the BYOA migration: the Claude Agent SDK profiling loop (`sr | Package | Published? | Description | |---------|-----------|-------------| -| `packages/ghost-drift` | ✅ `ghost-drift` on npm | Merged engine + CLI — deterministic primitives (compare, embedding, fingerprint parse/lint, evolution, reporters) plus the cac-based CLI and the `ghost-drift` agentskills.io skill bundle under `src/skill-bundle/` | +| `packages/ghost-drift` | ✅ `ghost-drift` on npm | Merged engine + CLI — deterministic primitives (compare, embedding, expression parse/lint, evolution, reporters) plus the cac-based CLI and the `ghost-drift` agentskills.io skill bundle under `src/skill-bundle/` | | `packages/ghost-ui` | ❌ private | Reference component library — 49 UI primitives + 48 AI elements + theme + hooks, distributed via the shadcn `registry.json`, not npm. Also ships the `ghost-mcp` bin (`src/mcp/`, built via `tsconfig.mcp.json` → `dist-mcp/`) — an MCP server re-exposing the registry to AI assistants (5 tools, 2 resources). | | `apps/docs` | ❌ private | 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. +Seven deterministic primitives. Everything else (profile, review, verify, generate, discover) is a skill recipe the host agent executes. | Command | Description | |---------|-------------| -| `ghost-drift compare [...fingerprints]` | Pairwise (N=2) or composite (N≥3: pairwise matrix, centroid, clusters) over fingerprint embeddings. `--semantic`, `--temporal`. | -| `ghost-drift lint [fingerprint.md]` | Validate schema + body/frontmatter coherence | -| `ghost-drift ack` | Acknowledge drift; records stance in `.ghost-sync.json` (reads local `fingerprint.md`) | -| `ghost-drift adopt ` | Adopt a new parent baseline | +| `ghost-drift compare [...expressions]` | Pairwise (N=2) or composite (N≥3: pairwise matrix, centroid, clusters) over expression embeddings. `--semantic`, `--temporal`. | +| `ghost-drift lint [expression.md]` | Validate schema + body/frontmatter coherence | +| `ghost-drift describe [expression.md]` | Print section ranges and token estimates for selective loading | +| `ghost-drift ack` | Acknowledge drift; records stance in `.ghost-sync.json` (reads local `expression.md`) | +| `ghost-drift track ` | Track another expression as this repo's reference | | `ghost-drift diverge ` | Declare intentional divergence on a dimension | -| `ghost-drift emit ` | Derive artifacts from `fingerprint.md` — `review-command`, `context-bundle`, or `skill` (the agentskills.io bundle). Run `ghost-drift emit skill` to install the `ghost-drift` skill into your host agent. | +| `ghost-drift emit ` | Derive artifacts from `expression.md` — `review-command`, `context-bundle`, or `skill` (the agentskills.io bundle). Run `ghost-drift 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 (all under `packages/ghost-drift/src/skill-bundle/references/`): -- **Profile** (write `fingerprint.md` from a project) — `profile.md` +- **Profile** (write `expression.md` from a project) — `profile.md` - **Review** (flag drift in PR changes) — `review.md` - **Verify** (generate → review loop) — `verify.md` -- **Generate** (produce UI from fingerprint) — `generate.md` +- **Generate** (produce UI from expression) — `generate.md` - **Discover** (find public design languages) — `discover.md` ## Target Types @@ -99,11 +100,11 @@ The `resolveTarget()` function in `packages/ghost-drift/src/core/config.ts` acce - `https://...` — URL - `.` — current directory -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. +Used by `resolveTrackedExpression` (tracked expression resolution) and legacy library consumers. The profile flow itself no longer consumes targets — the host agent explores whatever directory is relevant. -## Fingerprint format +## Expression format -The canonical fingerprint artifact is **`fingerprint.md`** — a human-readable, LLM-editable Markdown file with YAML frontmatter (machine layer) and a three-section prose body (Character → Signature → Decisions). See `docs/fingerprint-format.md` for the full spec; a condensed reference ships inside the skill bundle at `packages/ghost-drift/src/skill-bundle/references/schema.md`. +The canonical expression artifact is **`expression.md`** — a human-readable, LLM-editable Markdown file with YAML frontmatter (machine layer) and a three-section prose body (Character → Signature → Decisions). See `docs/expression-format.md` for the full spec; a condensed reference ships inside the skill bundle at `packages/ghost-drift/src/skill-bundle/references/schema.md`. ## Releasing & Changesets @@ -123,7 +124,7 @@ Guidance on the bump level: - **`patch`** — bug fixes, doc fixes, non-breaking internal refactors. The default; when in doubt, pick this. - **`minor`** — new CLI verb, new flag, new library export, new capability. Anything a user might want to reach for. -- **`major`** — removed/renamed CLI verb, removed/renamed library export, changed default behavior, breaking fingerprint schema change, changed exit codes. **Always flag this explicitly in the PR description and ask the user to confirm — do not `major`-bump unreviewed.** +- **`major`** — removed/renamed CLI verb, removed/renamed library export, changed default behavior, breaking expression schema change, changed exit codes. **Always flag this explicitly in the PR description and ask the user to confirm — do not `major`-bump unreviewed.** Skip the changeset entirely for: CI/workflow-only changes, test-only changes, changes scoped to `packages/ghost-ui` or `apps/docs` (both private — not published). The Changesets config ignores those packages. @@ -131,7 +132,7 @@ The slug should be short and descriptive: `add-temporal-flag.md`, `fix-palette-l ## Key Conventions -- Each fingerprint carries a 49-dimensional embedding vector (palette [0–20], spacing [21–30], typography [31–40], surfaces [41–48]; see `packages/ghost-drift/src/core/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 returns a composite fingerprint. -- `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. +- Each expression carries a 49-dimensional embedding vector (palette [0–20], spacing [21–30], typography [31–40], surfaces [41–48]; see `packages/ghost-drift/src/core/embedding/embedding.ts`). The canonical on-disk form is `expression.md`. +- `compare` takes **file paths** to `expression.md`, not target strings. Mode auto-detects from N and flags: `--semantic` / `--temporal` require N=2; N≥3 returns a composite expression. +- `ack` / `track` / `diverge` read the local `expression.md`. The host agent is responsible for regenerating `expression.md` (via the profile recipe) before acknowledging drift. +- `lint` takes a single expression.md and reports schema/partition violations. Use as the success gate when writing an expression. diff --git a/INVARIANTS.md b/INVARIANTS.md index 25be1cf..ced4361 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -18,13 +18,13 @@ No CLI verb calls an LLM. No CLI verb takes an API key as a required input. Outp --- -## 2. fingerprint.md is the single canonical artifact. +## 2. expression.md is the single canonical artifact. -One on-disk format per fingerprint. No parallel JSON/YAML/TOML/DTCG representations. No compiled variants. Readers parse `fingerprint.md` directly. +One on-disk format per expression. No parallel JSON/YAML/TOML/DTCG representations. No compiled variants. Readers parse `expression.md` directly. -**Why:** Fingerprints are meant to be read by humans, edited by LLMs, and diffed in PRs. Multiple formats fracture all three. The artifact lives where design decisions already live — in the repo, versioned with code. +**Why:** Expressions are meant to be read by humans, edited by LLMs, and diffed in PRs. Multiple formats fracture all three. The artifact lives where design decisions already live — in the repo, versioned with code. -**Override:** Never for storage. Ephemeral runtime artifacts (composite centroids, cached embeddings) may exist in memory but must not be checked in or loaded as fingerprints. +**Override:** Never for storage. Ephemeral runtime artifacts (composite centroids, cached embeddings) may exist in memory but must not be checked in or loaded as expressions. --- @@ -32,7 +32,7 @@ One on-disk format per fingerprint. No parallel JSON/YAML/TOML/DTCG representati Frontmatter holds the machine layer: identity, tokens, dimension slugs, evidence paths. Body holds the prose layer: Character, Signature, decision rationale. Prose does not leak into frontmatter; structured data does not leak into prose. -**Why:** The partition is what makes fingerprints simultaneously human-readable and machine-comparable. Blurring it forces one audience to do the other's work. The linter enforces it at the boundary; this invariant enforces it in spirit. +**Why:** The partition is what makes expressions simultaneously human-readable and machine-comparable. Blurring it forces one audience to do the other's work. The linter enforces it at the boundary; this invariant enforces it in spirit. **Override:** Never. New fields are placed by layer, not by convenience. @@ -48,11 +48,11 @@ Profile, review, verify, generate, and discover are recipes the host agent execu --- -## 5. Fingerprints evolve by deliberate act, not by schedule. +## 5. Expressions evolve by deliberate act, not by schedule. -A fingerprint changes when a human or agent deliberately edits it — in a design PR, an `adopt`, or a `diverge`. There is no background re-profile. There is no drift-triggered auto-update. +An expression changes when a human or agent deliberately edits it — in a design PR, an `track`, or a `diverge`. There is no background re-profile. There is no drift-triggered auto-update. -**Why:** A fingerprint that silently re-profiles is no longer a contract. The whole point of `adopt` / `ack` / `diverge` is that drift becomes *a visible act with a stance*. Automatic updates hide the act and collapse governance into noise. +**Why:** An expression that silently re-profiles is no longer a contract. The whole point of `track` / `ack` / `diverge` is that drift becomes *a visible act with a stance*. Automatic updates hide the act and collapse governance into noise. **Override:** Never. Tooling may suggest re-profiling (e.g., after a major refactor), but the act itself is always explicit and human-authored. @@ -62,6 +62,6 @@ A fingerprint changes when a human or agent deliberately edits it — in a desig Each verb does one thing. No verb subsumes another. No `--mode` flag swaps a verb to a different conceptual operation. -**Why:** A small orthogonal surface is a product; a large overlapping surface is an accumulation. The current surface (`compare`, `lint`, `ack`, `adopt`, `diverge`, `emit`, `describe`) is a principled minimum — any addition must justify itself against merging with an existing verb. +**Why:** A small orthogonal surface is a product; a large overlapping surface is an accumulation. The current surface (`compare`, `lint`, `ack`, `track`, `diverge`, `emit`, `describe`) is a principled minimum — any addition must justify itself against merging with an existing verb. **Override:** New verbs are fine when they capture a genuinely new operation. New flags are fine when they refine behavior within the same operation. Rule of thumb: if explaining the flag requires "if X, the verb actually does Y," it's a new verb. diff --git a/README.md b/README.md index 1954f35..09956c7 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Ghost -**Brand fidelity infrastructure for an agent-authored world. Every generation gets a fingerprint the harness can read, enforce, and check.** +**Brand fidelity infrastructure for an agent-authored world. Every generation gets an expression the harness can read, enforce, and check.** AI is becoming the primary author of shipped code. Humans sit in fewer diffs; the harness (guardrails, reviewers, verifiers) catches drift before it lands. In that world, ensuring every generation reflects a brand's voice is paramount. Fonts and spacing are the easy half. The hard half is character: the posture a product takes, what it refuses to do. That's where generations drift first. -Ghost closes that loop. It captures a brand as a **fingerprint**: a human-readable `fingerprint.md` encoding character, signature traits, and concrete decisions. It gives any agent the primitives to author against it, detect drift the moment it happens, and record the right stance: **acknowledge**, **adopt**, or **intentionally diverge**. Power moves to the consumer: each team owns its fork, its trajectory, and its stance. The org's fingerprint drifts in the open. Nothing gets enforced; nothing drifts silently. Deterministic arithmetic lives in Ghost's CLI; judgment lives in whatever agent you already use. +Ghost closes that loop. It captures a brand as an **expression**: a human-readable `expression.md` encoding character, signature traits, and concrete decisions. It gives any agent the primitives to author against it, detect drift the moment it happens, and record the right stance: **acknowledge**, **track**, or **intentionally diverge**. Each repo owns its expression, its trajectory, and its stance. The fleet of expressions drifts in the open. Nothing gets enforced; nothing drifts silently. Deterministic arithmetic lives in Ghost's CLI; judgment lives in whatever agent you already use. ## BYOA: bring your own agent Ghost splits the work the way agents need it split: **judgement in the agent, arithmetic in the CLI**. -- **The CLI**: a set of **deterministic primitives**. Six verbs. It never calls an LLM. It does vector distance, schema validation, and manifest writes. Same answer every time. +- **The CLI**: a set of **deterministic primitives**. Seven verbs. It never calls an LLM. It does vector distance, schema validation, and manifest writes. Same answer every time. - **A skill bundle**: [agentskills.io](https://agentskills.io)-compatible recipes for the interpretive work (profile, review, verify, generate, discover). The host agent (Claude Code, Codex, Cursor, Goose, …) runs the recipes and calls the CLI for the arithmetic. No API key is required to use any CLI verb. Judgment work lives in whichever agent you already use; `ghost-drift emit skill` installs the recipes there. @@ -19,11 +19,11 @@ No API key is required to use any CLI verb. Judgment work lives in whichever age Ghost gives agents four capabilities the design-at-scale problem actually needs: -- **Author against a real quality bar**: `ghost-drift emit context-bundle` and the `generate` recipe turn a design language into grounding an agent can actually follow. The fingerprint is the bar; the agent authors to it. -- **Self-govern at author time**: the `review` and `verify` recipes run an agent's output against the fingerprint *before* a human sees it. Drift gets caught where it's cheap to fix, not after it ships. -- **Detect drift at the right time**: PR-time (via `review`), generation-time (via `verify`), or org-time (via `compare` on N≥3 consumers — the composite view). Timing is load-bearing: the same drift surfaced a month later is noise; surfaced inline, it's action. -- **Remediate with structured intent**: `ack`, `adopt`, `diverge` are the three moves. Every stance is published with reasoning and full lineage. Drift without intent is noise; drift with intent is signal the parent can heal from. -- **Human-readable, diff-friendly**: `fingerprint.md` is Markdown with YAML frontmatter (machine layer) plus a three-layer prose body (Character, Signature, Decisions). Humans read it, agents consume it, deterministic tools diff it. No DSL to learn. +- **Author against a real quality bar**: `ghost-drift emit context-bundle` and the `generate` recipe turn a design language into grounding an agent can actually follow. The expression is the bar; the agent authors to it. +- **Self-govern at author time**: the `review` and `verify` recipes run an agent's output against the expression *before* a human sees it. Drift gets caught where it's cheap to fix, not after it ships. +- **Detect drift at the right time**: PR-time (via `review`), generation-time (via `verify`), or org-time (via `compare` on N≥3 expressions — the composite view). Timing is load-bearing: the same drift surfaced a month later is noise; surfaced inline, it's action. +- **Remediate with structured intent**: `ack`, `track`, `diverge` are the three moves. Every stance is published with reasoning and full lineage. Drift without intent is noise; drift with intent becomes useful evidence. +- **Human-readable, diff-friendly**: `expression.md` is Markdown with YAML frontmatter (machine layer) plus a three-layer prose body (Character, Signature, Decisions). Humans read it, agents consume it, deterministic tools diff it. No DSL to learn. ## Repo layout @@ -32,10 +32,10 @@ Ghost is a monorepo. One main tool, one reference design system, one docs site | Path | Role | | ---- | ---- | | [`packages/ghost-drift`](./packages/ghost-drift) | **Main tool.** The deterministic CLI and skill bundle. The only published package (`ghost-drift` on npm). | -| [`packages/ghost-ui`](./packages/ghost-ui) | **Reference design system.** 97 components distributed via a shadcn registry. Also ships the `ghost-mcp` bin — an MCP server that re-exposes the registry to AI assistants. The system Ghost dogfoods its fingerprint against. Private. | +| [`packages/ghost-ui`](./packages/ghost-ui) | **Reference design system.** 97 components distributed via a shadcn registry. Also ships the `ghost-mcp` bin — an MCP server that re-exposes the registry to AI assistants. The system Ghost dogfoods its expression against. Private. | | [`apps/docs`](./apps/docs) | **Docs site.** `ghost-docs`, the deployed documentation for the project. Consumes `ghost-ui`. Private. | -`ghost-drift` is the product; the rest is how the fingerprint stays concrete. +`ghost-drift` is the product; the rest is how the expression stays concrete. ## Getting Started @@ -62,20 +62,20 @@ Once the skill is installed, ask your agent to "profile this design language" or ### Quick start -**1. Profile your system**: ask your host agent (Claude Code, Cursor, etc.) to write a `fingerprint.md`. It'll follow the `profile` recipe and validate with `ghost-drift lint` at the end. +**1. Profile your system**: ask your host agent (Claude Code, Cursor, etc.) to write an `expression.md`. It'll follow the `profile` recipe and validate with `ghost-drift lint` at the end. **2. Validate the result:** ```bash -ghost-drift lint # defaults to ./fingerprint.md -ghost-drift lint path/to/fingerprint.md --format json +ghost-drift lint # defaults to ./expression.md +ghost-drift lint path/to/expression.md --format json ``` -**3. Compare fingerprints:** +**3. Compare expressions:** ```bash # Pairwise: per-dimension distance -ghost-drift compare parent.fingerprint.md consumer.fingerprint.md +ghost-drift compare market.expression.md dashboard.expression.md # Add qualitative interpretation of decisions + palette ghost-drift compare a.md b.md --semantic @@ -83,15 +83,15 @@ ghost-drift compare a.md b.md --semantic # Add velocity / trajectory (reads .ghost/history.jsonl) ghost-drift compare before.md after.md --temporal -# Composite (N≥3): pairwise matrix, centroid, clusters — the org fingerprint -ghost-drift compare *.fingerprint.md +# Composite (N≥3): pairwise matrix, centroid, clusters — the org expression +ghost-drift compare *.expression.md ``` -**4. Track intent toward a parent:** +**4. Track intent toward another expression:** ```bash ghost-drift ack --stance aligned --reason "Initial baseline" -ghost-drift adopt new-parent.fingerprint.md +ghost-drift track new-tracked.expression.md ghost-drift diverge typography --reason "Editorial product uses a different type scale" ``` @@ -112,16 +112,17 @@ just dev ## CLI Commands -Six deterministic primitives, grouped by the loop: **author** (`emit`), **detect** (`compare`, `lint`), **remediate** (`ack`, `adopt`, `diverge`). Everything interpretive is a skill recipe the host agent runs. +Seven deterministic primitives, grouped by the loop: **author** (`emit`), **detect** (`compare`, `lint`, `describe`), **remediate** (`ack`, `track`, `diverge`). Everything interpretive is a skill recipe the host agent runs. | Command | Description | | ---------------- | ----------------------------------------------------------------------------------- | -| `ghost-drift compare` | Pairwise distance (N=2) or composite fingerprint (N≥3: pairwise matrix, centroid, clusters) over embeddings. `--semantic` and `--temporal` add qualitative enrichment for N=2. | -| `ghost-drift lint` | Validate `fingerprint.md` schema + body/frontmatter coherence. Use before declaring a fingerprint valid. | -| `ghost-drift ack` | Record a stance toward the parent (aligned / accepted / diverging) in `.ghost-sync.json`. | -| `ghost-drift adopt` | Shift parent baseline to a new fingerprint. | +| `ghost-drift compare` | Pairwise distance (N=2) or composite expression (N≥3: pairwise matrix, centroid, clusters) over embeddings. `--semantic` and `--temporal` add qualitative enrichment for N=2. | +| `ghost-drift lint` | Validate `expression.md` schema + body/frontmatter coherence. Use before declaring an expression valid. | +| `ghost-drift describe` | Print a section map for `expression.md` so agents can selectively load it. | +| `ghost-drift ack` | Record a stance toward the tracked expression (aligned / accepted / diverging) in `.ghost-sync.json`. | +| `ghost-drift track` | Shift tracked expression to a new expression. | | `ghost-drift diverge` | Declare intentional divergence on a dimension with reasoning. | -| `ghost-drift emit` | Derive an artifact from `fingerprint.md`: `review-command`, `context-bundle`, or `skill`. | +| `ghost-drift emit` | Derive an artifact from `expression.md`: `review-command`, `context-bundle`, or `skill`. | ### Skill recipes: run by the host agent @@ -129,54 +130,54 @@ Install once with `ghost-drift emit skill`. Each recipe gives the agent a specif | Recipe | Capability | Triggered by | | ---------- | ---------------------------------- | ---------------------------------------------- | -| `profile` | Author the quality bar | "profile this", "write a fingerprint.md" | +| `profile` | Author the quality bar | "profile this", "write expression.md" | | `generate` | Author *against* the quality bar | "generate a component matching our design" | | `review` | Self-govern at PR time | "review this PR for drift" | -| `verify` | Self-govern at generation time | "verify generated UI against the fingerprint" | -| `compare` | Detect drift across the org | "why did these two fingerprints drift?" | +| `verify` | Self-govern at generation time | "verify generated UI against the expression" | +| `compare` | Detect drift across the org | "why did these two expressions drift?" | | `discover` | Find quality bars worth borrowing | "find design languages like X" | These are instructions, not code. The agent executes them using its normal tools (file search, reading, editing) plus `ghost-drift` for the deterministic steps. ## Configuration -`ghost.config.ts` is optional — only `ack` and `diverge` consult it (to locate the parent fingerprint). Everything else is zero-config. +`ghost.config.ts` is optional — only `ack` and `diverge` consult it (to locate the tracked expression). Everything else is zero-config. ### Environment variables -- `OPENAI_API_KEY` / `VOYAGE_API_KEY`: optional, consumed by `computeSemanticEmbedding` when a host writes a fingerprint.md and wants the enriched 49-dim vector. -- `GITHUB_TOKEN`: optional, used by `resolveParent` when fetching a parent fingerprint from GitHub (avoids rate limits). +- `OPENAI_API_KEY` / `VOYAGE_API_KEY`: optional, consumed by `computeSemanticEmbedding` when a host writes an expression.md and wants the enriched 49-dim vector. +- `GITHUB_TOKEN`: optional, used by `resolveTrackedExpression` when fetching a tracked expression from GitHub (avoids rate limits). The CLI auto-loads `.env` and `.env.local` from the working directory. ## How It Works -### The fingerprint +### The expression -What the agent reads when it authors, reviews, or remediates. The canonical artifact is **`fingerprint.md`**: a Markdown document with YAML frontmatter (machine layer) plus a three-layer prose body. Human-readable, LLM-consumable, diff-friendly: +What the agent reads when it authors, reviews, or remediates. The canonical artifact is **`expression.md`**: a Markdown document with YAML frontmatter (machine layer) plus a three-layer prose body. Human-readable, LLM-consumable, diff-friendly: - **Frontmatter**: 49-dimensional embedding, palette, spacing, typography, surfaces, roles, 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. - **`# Decisions`**: abstract, implementation-agnostic choices with evidence. Each decision is embedded so `compare --semantic` can match semantically. -Generate one with the `profile` recipe. See [`docs/fingerprint-format.md`](./docs/fingerprint-format.md) for the full spec, including the 49-dim machine-vector breakdown. +Generate one with the `profile` recipe. See [`docs/expression-format.md`](./docs/expression-format.md) for the full spec, including the 49-dim machine-vector breakdown. ### Author + self-govern loop -The literal loop the pitch describes: the agent authors UI, Ghost detects drift against the fingerprint, a human (or the agent itself) picks the remediation. The fingerprint grounds the generator; the `review` recipe surfaces drift in the output so a decision (*acknowledge, adopt, or diverge*) can be made at the right time. The `verify` recipe drives the loop across a prompt suite and classifies 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. +The literal loop the pitch describes: the agent authors UI, Ghost detects drift against the expression, a human (or the agent itself) picks the remediation. The expression grounds the generator; the `review` recipe surfaces drift in the output so a decision (*acknowledge, track, or diverge*) can be made at the right time. The `verify` recipe drives the loop across a prompt suite and classifies each dimension as _tight_, _leaky_, or _uncaptured_: the mechanism that tells the expression where it needs to say more. See [`docs/generation-loop.md`](./docs/generation-loop.md) for details. ### Remediation Three responses, each with recorded reasoning and full lineage, so a year from now you know whether a divergence was meant or missed: -- **`fingerprint.md`**: The canonical fingerprint artifact. -- **`.ghost-sync.json`**: Per-dimension stances toward the parent (aligned, accepted, or diverging), each with recorded reasoning. Written by `ack` / `adopt` / `diverge`. -- **`.ghost/history.jsonl`**: Append-only fingerprint history for temporal analysis. Read by `compare --temporal`. +- **`expression.md`**: The canonical expression artifact. +- **`.ghost-sync.json`**: Per-dimension stances toward the tracked expression (aligned, accepted, or diverging), each with recorded reasoning. Written by `ack` / `track` / `diverge`. +- **`.ghost/history.jsonl`**: Append-only expression history for temporal analysis. Read by `compare --temporal`. ### Org-scale observability -Drift at scale: the signal the parent design language heals from. Run `ghost-drift compare` with three or more fingerprints and Ghost returns the **composite fingerprint** — pairwise distances, a centroid, and similarity clusters. Which consumers are coherent, which are drifting, and where the gaps are. A fingerprint of fingerprints. +Drift at scale: the fleet view. Run `ghost-drift compare` with three or more expressions and Ghost returns the **composite expression** — pairwise distances, a centroid, and similarity clusters. Which expressions cluster tightly, which are far apart, and where the gaps are. An expression of expressions. ## Project Resources diff --git a/apps/docs/README.md b/apps/docs/README.md index 3678374..01e81a0 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -2,7 +2,7 @@ **Documentation site for the Ghost project.** -`ghost-docs` is the deployed docs for everything in this monorepo — the `ghost-drift` CLI, the fingerprint format, the design language foundations, and the live `ghost-ui` component catalogue. A Vite + MDX app that consumes [`ghost-ui`](../../packages/ghost-ui) as a workspace dependency. +`ghost-docs` is the deployed docs for everything in this monorepo — the `ghost-drift` CLI, the expression format, the design language foundations, and the live `ghost-ui` component catalogue. A Vite + MDX app that consumes [`ghost-ui`](../../packages/ghost-ui) as a workspace dependency. ## Run diff --git a/apps/docs/src/app/docs/page.tsx b/apps/docs/src/app/docs/page.tsx index e4001b1..fcbfa37 100644 --- a/apps/docs/src/app/docs/page.tsx +++ b/apps/docs/src/app/docs/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useStaggerReveal } from "ghost-ui"; -import { BookOpen, Fingerprint, Rocket } from "lucide-react"; +import { BookOpen, Expression, Rocket } from "lucide-react"; import type { ReactNode } from "react"; import { Link } from "react-router"; import { AnimatedPageHeader } from "@/components/docs/animated-page-header"; @@ -17,21 +17,21 @@ const sections: { name: "Workflow", href: "/tools/drift/workflow", description: - "The five moves: profile, compare, review, evolve, and zoom out to the org fingerprint — with examples for each.", - icon: , + "The five moves: profile, compare, review, evolve, and zoom out to the org expression — with examples for each.", + icon: , }, { name: "Getting Started", href: "/tools/drift/getting-started", description: - "Install the skill bundle, write your first fingerprint.md, and track drift against a parent — in under five minutes.", + "Install the skill bundle, write your first expression.md, and track drift against another expression — in under five minutes.", icon: , }, { name: "CLI Reference", href: "/tools/drift/cli", description: - "Seven deterministic primitives — compare, lint, describe, ack, adopt, diverge, emit. Plus the skill recipes the host agent runs.", + "Seven deterministic primitives — compare, lint, describe, ack, track, diverge, emit. Plus the skill recipes the host agent runs.", icon: , }, ]; @@ -48,7 +48,7 @@ export default function DocsIndex() {
- packages/ghost-ui/fingerprint.md + packages/ghost-ui/expression.md excerpt @@ -120,7 +120,7 @@ function FingerprintExcerpt() { editorial{"\n"} {" - "} pill-shaped{"\n"} - {" closestSystems:\n"} + {" resembles:\n"} {" - "} Vercel Geist{"\n"} {" - "} @@ -222,12 +222,12 @@ function radarPath( } function RadarChart({ - parentValues, - childValues, + referenceValues, + localValues, animated, }: { - parentValues: number[]; - childValues: number[]; + referenceValues: number[]; + localValues: number[]; animated: boolean; }) { const cx = 150; @@ -265,7 +265,7 @@ function RadarChart({ })} trigger.kill(); }, []); - const parent = [0.85, 0.7, 0.8, 0.65, 0.75]; - const childTarget = [0.78, 0.6, 0.75, 0.5, 0.7]; - const child = animated ? childTarget : parent; + const reference = [0.85, 0.7, 0.8, 0.65, 0.75]; + const localTarget = [0.78, 0.6, 0.75, 0.5, 0.7]; + const local = animated ? localTarget : reference; const deltas = [ { dim: "palette", delta: 0.05, status: "aligned" }, @@ -336,8 +336,8 @@ function CompareSection() {
@@ -378,9 +378,9 @@ function CompareSection() {

- Parent   + Reference   - Your system + Local

@@ -419,9 +419,9 @@ const REVIEW_SCOPES: { { id: "files", name: "Files", - what: "The host agent diffs changed files against the local fingerprint.md. Zero-config; flags changed lines by default.", + what: "The host agent diffs changed files against the local expression.md. Zero-config; flags changed lines by default.", catches: - "Hardcoded colors outside the palette, off-scale spacing, type choices that violate the fingerprint's decisions, wrong-radius interactive surfaces.", + "Hardcoded colors outside the palette, off-scale spacing, type choices that violate the expression's decisions, wrong-radius interactive surfaces.", visual: (
@@ -465,7 +465,7 @@ const REVIEW_SCOPES: { { id: "project", name: "Project", - what: "The agent profiles the whole target, then ghost-drift compare returns per-dimension deltas against a parent. CI-friendly via --format json.", + what: "The agent profiles the whole target, then ghost-drift compare returns per-dimension deltas against a reference expression. CI-friendly via --format json.", catches: "Cumulative drift across an entire system: per-dimension deltas and a scalar distance you can fail builds on.", visual: ( @@ -498,7 +498,7 @@ const REVIEW_SCOPES: { name: "Suite", what: "The verify recipe drives the generate → review loop across a prompt suite. Classifies each dimension as tight, leaky, or uncaptured.", catches: - "Gaps in the fingerprint itself: dimensions the generator drifts on because Decisions under-specify them.", + "Gaps in the expression itself: dimensions the generator drifts on because Decisions under-specify them.", visual: (
18 prompts · 14 passed
@@ -637,7 +637,7 @@ function HistoryRibbon() {
- distance to parent, over time + distance to tracked expression, over time
.ghost/history.jsonl @@ -709,9 +709,9 @@ function HistoryRibbon() { ); } -/* ─────────────────── 5. Org — the org fingerprint ────────────────── */ +/* ─────────────────── 5. Org — the org expression ────────────────── */ -function OrgFingerprint() { +function OrgExpression() { const systems = [ { x: 105, y: 85, label: "Core", size: 8, cluster: "A" }, { x: 140, y: 105, label: "Marketing", size: 5, cluster: "A" }, @@ -833,22 +833,22 @@ export default function WorkflowPage() { {/* ── Step 1: Profile ─────────────────────────────────────────── */} Step 01 · Profile - Write a fingerprint.md + Write an expression.md Open your project in a host agent with the ghost-drift{" "} skill installed and ask it to profile this design language. The recipe walks the agent through your theme CSS, tailwind config, and component primitives, resolves variable chains, and writes a - single fingerprint.md at the repo root — YAML frontmatter + single expression.md at the repo root — YAML frontmatter for machines, Markdown body for humans. - +
{[ { @@ -884,7 +884,7 @@ export default function WorkflowPage() { ))}

- Ghost never calls an LLM itself. The agent writes the fingerprint; the + Ghost never calls an LLM itself. The agent writes the expression; the CLI lints, compares, and diffs it deterministically. The final step of every profile is ghost-drift lint — which validates the schema and flags body/frontmatter incoherence before anything else @@ -897,10 +897,10 @@ export default function WorkflowPage() { Step 02 · Compare Measure the distance - Two fingerprints in, one answer out: an overall distance, a + Two expressions in, one answer out: an overall distance, a per-dimension delta, and — with --semantic — a paraphrase-robust pairing of decisions. Similar systems produce - similar fingerprints; different ones don't. + similar expressions; different ones don't.

@@ -934,7 +934,7 @@ export default function WorkflowPage() {

Palette weighs heaviest — color is the first thing anyone notices. - Decisions contribute only when both fingerprints have embedded them; + Decisions contribute only when both expressions have embedded them; otherwise they're reported qualitatively and excluded from the scalar so unscored prose doesn't pollute the number.

@@ -947,7 +947,7 @@ export default function WorkflowPage() { Review is a skill recipe your host agent runs, not a CLI verb. It answers three scopes of drift question — tight (this PR), - medium (a target snapshot), broad (the whole fingerprint's schema + medium (a target snapshot), broad (the whole expression's schema discipline). Same answer shape every time. @@ -974,8 +974,8 @@ export default function WorkflowPage() { Turn drift into signal Not every drift is a bug. Sometimes you changed that radius on - purpose. Ghost tracks your intent through stances: a way for the - consumer to say “yes, I know, and here's why” — + purpose. Ghost tracks your intent through stances: a way for the local + team to say “yes, I know, and here's why” — per-dimension, with reasoning attached.
@@ -983,7 +983,7 @@ export default function WorkflowPage() { symbol="=" label="Aligned" speaker="ghost-drift ack --stance aligned" - message="We're tracking the parent. If this drifted, it's a bug. Fix it." + message="We're tracking this expression. If this drifted, it's a bug. Fix it." align="left" />
@@ -1020,25 +1020,25 @@ export default function WorkflowPage() { {/* ── Step 5: Org ────────────────────────────────────────────── */} Step 05 · Org - Zoom out to the org fingerprint + Zoom out to the org expression - Most orgs don't have one design language — they have a core plus - several forks, a legacy system still in production, an acquired - product finding its voice. The composite is a fingerprint of its own: - the org's fingerprint, made of the fingerprints inside it. Feed - three or more to compare and Ghost returns the pairwise - matrix, natural clusters, a centroid, and an outlier list. + Most orgs don't have one design language — they have several + product expressions, a legacy surface still in production, and an + acquired product finding its voice. The composite is an expression of + its own: the org's expression, made of the expressions inside it. + Feed three or more to compare and Ghost returns the + pairwise matrix, natural clusters, a centroid, and an outlier list.
- +
Find twins

- Systems that cluster tightly are candidates to share a parent — - or to fold together outright. + Expressions that cluster tightly are candidates to share a + reference — or to fold together outright.

@@ -1046,8 +1046,8 @@ export default function WorkflowPage() { Name the outlier

- One system drifting alone is either a legacy to retire or an - intentional fork worth naming as a new parent. + One expression drifting alone is either a legacy to retire or an + intentional fork worth naming as its own reference.

@@ -1055,7 +1055,7 @@ export default function WorkflowPage() { Watch the centroid

- The org fingerprint's centroid over time tells you whether + The org expression's centroid over time tells you whether the house style is holding or quietly sliding.

@@ -1064,22 +1064,21 @@ export default function WorkflowPage() {

Run{" "} - ghost-drift compare *.fingerprint.md + ghost-drift compare *.expression.md {" "} for the full matrix plus a 3D PCA projection you can render in - Three.js. For a parent team, this is the view that replaces “did - anyone answer my Slack about the button radius?” with - “here's which consumers drifted on shape-language this - quarter.” + Three.js. This is the view that replaces “which repo + drifted?” with “here's where the fleet clusters and + where it spreads this quarter.”

{/* ── Close the loop: Generation ──────────────────────────────── */} Closing the loop · Generation - Feed the fingerprint forward + Feed the expression forward - A fingerprint isn't only a measurement; it's a grounding + An expression isn't only a measurement; it's a grounding artifact. Pipe it into whatever generator you already use — your host agent, Cursor, v0, an in-house tool — and use the review recipe as the gate on its output. Drift you can see is drift you can steer. @@ -1089,7 +1088,7 @@ export default function WorkflowPage() { { step: "ghost-drift emit context-bundle", name: "Ground", - desc: "Write SKILL.md + tokens.css + prompt.md from fingerprint.md. Whatever the generator consumes.", + desc: "Write SKILL.md + tokens.css + prompt.md from expression.md. Whatever the generator consumes.", }, { step: "generate (recipe)", @@ -1104,7 +1103,7 @@ export default function WorkflowPage() { { step: "verify (recipe)", name: "Audit", - desc: "Loop over a prompt suite. Per-dimension drift tells you where the fingerprint leaks.", + desc: "Loop over a prompt suite. Per-dimension drift tells you where the expression leaks.", }, ].map((s) => (
Verify is the schema-discipline mechanism: each dimension gets classified as{" "} tight, leaky, or uncaptured — a map of - where the fingerprint needs sharpening. + where the expression needs sharpening.

@@ -1151,7 +1150,7 @@ export default function WorkflowPage() {
{[ { - file: "fingerprint.md", + file: "expression.md", desc: "What the system looks like, in three layers.", }, { diff --git a/apps/docs/src/app/page.tsx b/apps/docs/src/app/page.tsx index 84b00fb..e0b66ce 100644 --- a/apps/docs/src/app/page.tsx +++ b/apps/docs/src/app/page.tsx @@ -68,14 +68,14 @@ export default function Home() {

Which raises the governance question. The reflex is to centralize: - a parent team owns the source of truth, consumers pull, compliance - is tracked. Ghost takes the opposite approach. Power moves to the - consumer: each team owns its fork, its trajectory, and its stance. - Decentralization without intent is entropy, so stances ( - aligned, accepted, diverging) turn - drift into signal. The org's fingerprint drifts in the open; each - consumer owns its divergence with reasoning attached. Nothing is - prescriptive. Nothing drifts silently. Everything is transparent. + one source of truth, many downstream projects, compliance tracked + from above. Ghost takes the opposite approach. Each repo owns its + expression, its trajectory, and its stance. Decentralization + without intent is entropy, so stances (aligned,{" "} + accepted, diverging) turn drift into signal. The + fleet of expressions drifts in the open; every divergence carries + reasoning. Nothing is prescriptive. Nothing drifts silently. + Everything is transparent.

diff --git a/apps/docs/src/app/tools/page.tsx b/apps/docs/src/app/tools/page.tsx index f181f34..bb9cd60 100644 --- a/apps/docs/src/app/tools/page.tsx +++ b/apps/docs/src/app/tools/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useStaggerReveal } from "ghost-ui"; -import { Fingerprint } from "lucide-react"; +import { Expression } from "lucide-react"; import { Link } from "react-router"; import { AnimatedPageHeader } from "@/components/docs/animated-page-header"; import { SectionWrapper } from "@/components/docs/wrappers"; @@ -12,7 +12,7 @@ const tools = [ href: "/tools/drift", description: "Express design languages, track their evolution, and surface divergence before it compounds.", - icon: , + icon: , }, ]; diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index bda3280..b0bb71e 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -10,11 +10,11 @@ slug: cli Ghost's CLI is a set of deterministic primitives. It never calls an LLM. -The canonical artifact is `fingerprint.md` — a Markdown file with two +The canonical artifact is `expression.md` — a Markdown file with two layers: a machine layer (YAML frontmatter: 49-dim vector + palette, spacing, typography, surfaces, roles) and a prose body with three sections (Character, Signature, Decisions). Most commands accept a path -to a `fingerprint.md`; they default to `./fingerprint.md` in the current +to an `expression.md`; they default to `./expression.md` in the current directory. Workflows like _profile_, _review_, _verify_, _generate_, and _discover_ @@ -29,7 +29,7 @@ this reference — so these docs can't drift from the binary. -Pairwise distance (N=2) or fleet analysis (N≥3) over fingerprint +Pairwise distance (N=2) or fleet analysis (N≥3) over expression embeddings. Pure math. Exits non-zero when drift exceeds 0.5. `--semantic` and `--temporal` add qualitative enrichment for N=2. @@ -37,47 +37,47 @@ and `--temporal` add qualitative enrichment for N=2. ```bash # Pairwise (N=2) -ghost-drift compare parent.fingerprint.md consumer.fingerprint.md +ghost-drift compare market.expression.md dashboard.expression.md # Qualitative diff of decisions + palette -ghost-drift compare a.fingerprint.md b.fingerprint.md --semantic +ghost-drift compare a.expression.md b.expression.md --semantic # Velocity + trajectory -ghost-drift compare before.fingerprint.md after.fingerprint.md --temporal +ghost-drift compare before.expression.md after.expression.md --temporal # Fleet (N≥3) — pairwise matrix + centroid -ghost-drift compare *.fingerprint.md +ghost-drift compare *.expression.md ``` -Validate `fingerprint.md` schema + body/frontmatter coherence. Use this -before declaring a fingerprint valid — the profile recipe ends by calling +Validate `expression.md` schema + body/frontmatter coherence. Use this +before declaring an expression valid — the profile recipe ends by calling it. ```bash -# Default — reads ./fingerprint.md +# Default — reads ./expression.md ghost-drift lint # Specific file, JSON output -ghost-drift lint path/to/fingerprint.md --format json +ghost-drift lint path/to/expression.md --format json ``` -Print a section map of `fingerprint.md` — frontmatter range, body sections +Print a section map of `expression.md` — frontmatter range, body sections (`# Character`, `# Signature`, `# Decisions`, `# Fragments`), and each `### dimension` block under Decisions, with line ranges and token estimates. The host agent uses this to load only the sections it needs instead of the whole file. -A typical `fingerprint.md` runs 3–5k tokens. The `# Decisions` block alone +A typical `expression.md` runs 3–5k tokens. The `# Decisions` block alone is usually 60–80% of that, and an agent reviewing a single component change rarely needs every dimension. `describe` is the deterministic answer to "what's in this file and where" — the recall safety rule (when in doubt, @@ -87,20 +87,20 @@ recipes. ```bash -# Default — reads ./fingerprint.md +# Default — reads ./expression.md ghost-drift describe # Specific file -ghost-drift describe path/to/fingerprint.md +ghost-drift describe path/to/expression.md # Machine-readable for agents ghost-drift describe --format json ``` -Sample output (against `packages/ghost-ui/fingerprint.md`): +Sample output (against `packages/ghost-ui/expression.md`): ```text -fingerprint.md — 309 lines, ~3,955 tokens +expression.md — 309 lines, ~3,955 tokens FRONTMATTER 1–164 ~973 tok [palette, spacing, typography, surfaces, roles, observation, decisions] # Character 166–169 ~159 tok @@ -119,15 +119,15 @@ approximation, sufficient for context budgeting. - + These three verbs write per-dimension stances to `.ghost-sync.json`. `ack` -and `diverge` need a parent declared in `ghost.config.ts`; `adopt` takes -the new parent as its argument. +and `diverge` need a tracked expression declared in `ghost.config.ts`; +`track` takes the new tracked expression as its argument. Acknowledge current drift — record your intentional stance (aligned, -accepted, or diverging). Reads the parent from `ghost.config.ts` and the -local `fingerprint.md`. +accepted, or diverging). Reads the tracked expression from `ghost.config.ts` +and the local `expression.md`. @@ -139,15 +139,14 @@ ghost-drift ack --stance aligned --reason "Initial baseline" ghost-drift ack -d typography --stance diverging --reason "Brand refresh requires different type scale" ``` -Shift the parent baseline to a new fingerprint. Use this when the parent -design language has been updated and you want to re-anchor your drift -measurements. +Shift the tracked expression to a new expression. Use this when you want to +re-anchor your drift measurements against a different reference expression. - + ```bash -# Adopt a new parent fingerprint -ghost-drift adopt new-parent.fingerprint.md +# Track a new reference expression +ghost-drift track new-tracked.expression.md ``` Mark a specific dimension as intentionally diverging. Shorthand for @@ -161,9 +160,9 @@ ghost-drift diverge palette --reason "Dark-mode-first palette for this product" - + -Derive an artifact from `fingerprint.md`. Kinds: `review-command` (a +Derive an artifact from `expression.md`. Kinds: `review-command` (a per-project slash command at `.claude/commands/design-review.md`), `context-bundle` (SKILL.md + tokens.css + optional prompt.md for any generator), or `skill` (the `ghost-drift` agentskills.io bundle — install @@ -198,12 +197,12 @@ then ask your agent in plain English: | Recipe | Trigger | | ---------- | ------------------------------------------------------ | -| `profile` | "profile this design language" / "write a fingerprint.md" | +| `profile` | "profile this design language" / "write expression.md" | | `review` | "review this PR for drift" | -| `verify` | "verify generated UI against the fingerprint" | +| `verify` | "verify generated UI against the expression" | | `generate` | "generate a component matching our design" | | `discover` | "find design languages like Linear" | -| `compare` | "why did these two fingerprints drift?" | +| `compare` | "why did these two expressions drift?" | Source for each recipe lives under `packages/ghost-drift/src/skill-bundle/references/`. The agent executes the diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 48a42ac..152eaa7 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -1,6 +1,6 @@ --- title: Getting Started -description: Install the skill bundle, write your first fingerprint.md, and track drift against a parent — in under five minutes. +description: Install the skill bundle, write your first expression.md, and track drift against another expression — in under five minutes. kicker: Docs section: drift order: 10 @@ -38,7 +38,7 @@ No API key is required for any CLI verb. If your host agent uses Anthropic or OpenAI models, it'll handle auth itself. Ghost auto-loads `.env` and `.env.local` from the working directory for a couple of optional variables (`OPENAI_API_KEY` / `VOYAGE_API_KEY` for paraphrase-robust semantic -embeddings, `GITHUB_TOKEN` for parent fetching). +embeddings, `GITHUB_TOKEN` for fetching tracked expressions). @@ -60,8 +60,8 @@ The bundle contains a top-level `SKILL.md` entry point plus recipes under `references/` (`profile.md`, `review.md`, `verify.md`, `generate.md`, `discover.md`, `compare.md`) plus a schema reference (`schema.md`). Each recipe declares `handoffs` in its frontmatter so hosts can surface the -next step inline — e.g. after `profile`, a prompt to `compare` against a -parent, or to run `ghost-drift emit review-command`. Once installed, ask your +next step inline — e.g. after `profile`, a prompt to `compare` against another +expression, or to run `ghost-drift emit review-command`. Once installed, ask your agent to "profile this design language" and it'll follow the recipe, calling `ghost-drift` for any deterministic step. @@ -73,48 +73,48 @@ Profiling is a skill recipe, not a CLI verb. Open your host agent in the project you want to profile and ask it something like: ```text -Profile this design language into a fingerprint.md +Profile this design language into expression.md ``` The `profile` recipe walks the agent through finding design sources (tailwind config, theme CSS, token files, component primitives), resolving -variable chains end-to-end, and writing the fingerprint. The final step +variable chains end-to-end, and writing the expression. The final step is always `ghost-drift lint` — which the CLI runs for you, deterministically. -A **fingerprint** is a two-layer Markdown file: YAML frontmatter is the +An **expression** is a two-layer Markdown file: YAML frontmatter is the machine layer (49-dim vector + palette, spacing, typography, surfaces, roles), and the body is prose organized into Character, Signature, and Decisions. Humans can read it. LLMs can consume it. Deterministic tools -diff it. See [`packages/ghost-ui/fingerprint.md`](https://github.com/block/ghost/blob/main/packages/ghost-ui/fingerprint.md) +diff it. See [`packages/ghost-ui/expression.md`](https://github.com/block/ghost/blob/main/packages/ghost-ui/expression.md) in this repo for a full real-world example. ```bash -# Validate the result (zero-config — reads ./fingerprint.md) +# Validate the result (zero-config — reads ./expression.md) ghost-drift lint # Or validate a specific file -ghost-drift lint path/to/fingerprint.md --format json +ghost-drift lint path/to/expression.md --format json ``` -Once you have two fingerprints, `compare` returns a scalar distance, +Once you have two expressions, `compare` returns a scalar distance, per-dimension deltas, and (optionally) qualitative or temporal enrichment: ```bash # Pairwise (N=2) — distance + per-dimension delta -ghost-drift compare parent.fingerprint.md consumer.fingerprint.md +ghost-drift compare market.expression.md dashboard.expression.md # Qualitative diff of decisions + palette -ghost-drift compare a.fingerprint.md b.fingerprint.md --semantic +ghost-drift compare a.expression.md b.expression.md --semantic # Velocity + trajectory (reads .ghost/history.jsonl) -ghost-drift compare before.fingerprint.md after.fingerprint.md --temporal +ghost-drift compare before.expression.md after.expression.md --temporal # Fleet (N≥3) — pairwise matrix + centroid -ghost-drift compare *.fingerprint.md +ghost-drift compare *.expression.md ``` `compare` weights palette, spacing, typography, surfaces, and embedded @@ -132,14 +132,13 @@ branch in your host agent and ask: Review this PR for design drift ``` -The `review` recipe diffs changed files against the local `fingerprint.md` +The `review` recipe diffs changed files against the local `expression.md` and flags hardcoded colors off the palette, spacing off the scale, and type -choices that violate decisions. For a project-level check, ask for -"target-level compliance" and the agent will compare a fresh fingerprint -against a parent. +choices that violate decisions. For a project-level check, ask the agent to +compare a fresh expression against another expression. If you want a repeatable, per-project slash command for your agent, emit one -from the fingerprint itself: +from the expression itself: ```bash # Writes .claude/commands/design-review.md @@ -148,33 +147,33 @@ ghost-drift emit review-command - + Drift without intent is noise; drift with intent is signal. Ghost tracks -your stance toward a parent fingerprint in `.ghost-sync.json`: +your stance toward a tracked expression in `.ghost-sync.json`: ```bash -# Record an overall stance (reads ghost.config.ts for the parent) +# Record an overall stance (reads ghost.config.ts for the tracked expression) ghost-drift ack --stance aligned --reason "Initial baseline" -# Shift the parent baseline -ghost-drift adopt new-parent.fingerprint.md +# Shift the tracked expression +ghost-drift track new-tracked.expression.md # Mark a dimension as intentionally different ghost-drift diverge typography --reason "Editorial product uses a different type scale" ``` -`ack` and `diverge` need a parent declared in `ghost.config.ts`. `adopt` -takes the parent as its argument. +`ack` and `diverge` need a tracked expression declared in `ghost.config.ts`. +`track` takes the tracked expression as its argument. Ghost doubles as pipeline infrastructure for AI-generated UI. The -fingerprint grounds the generator; the `review` recipe gates the output. +expression grounds the generator; the `review` recipe gates the output. -1. `ghost-drift emit context-bundle` — emit a grounding bundle from a fingerprint +1. `ghost-drift emit context-bundle` — emit a grounding bundle from an expression (SKILL.md + tokens.css + optional prompt.md) that any generator can consume. 2. Run any generator — your host agent, Cursor, v0, or an in-house tool — @@ -198,17 +197,16 @@ ghost-drift emit context-bundle --out dist/context -`ack` and `diverge` need a parent declared. Most other verbs are +`ack` and `diverge` need a tracked expression declared. Most other verbs are zero-config. ```ts import { defineConfig } from "ghost-drift"; export default defineConfig({ - // Path to a local fingerprint.md, or a URL / npm package name. - // The profile recipe produces parent.fingerprint.md from any source; - // commit that file and point `parent` at it. - parent: { type: "path", value: "./parent.fingerprint.md" }, + // Path to a local expression.md, or a URL / npm package name. + // Commit that file and point `tracks` at it. + tracks: { type: "path", value: "./tracked.expression.md" }, targets: [{ type: "path", value: "./packages/my-ui" }], diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 6679157..fe5802b 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,10 +1,10 @@ { - "generatedAt": "2026-04-23T10:40:33.455Z", + "generatedAt": "2026-04-25T02:58:04.127Z", "commands": [ { "name": "compare", - "rawName": "compare [...fingerprints]", - "description": "Compare two or more fingerprints. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint (pairwise matrix, centroid, spread, clusters).", + "rawName": "compare [...expressions]", + "description": "Compare two or more expressions. N=2 returns a pairwise delta; N≥3 returns a composite expression (pairwise matrix, centroid, spread, clusters).", "options": [ { "rawName": "--semantic", @@ -42,8 +42,8 @@ }, { "name": "lint", - "rawName": "lint [fingerprint]", - "description": "Validate fingerprint.md schema and body/frontmatter coherence", + "rawName": "lint [expression]", + "description": "Validate expression.md schema and body/frontmatter coherence", "options": [ { "rawName": "--format ", @@ -57,8 +57,8 @@ }, { "name": "describe", - "rawName": "describe [fingerprint]", - "description": "Print a section map of fingerprint.md (line ranges + token estimates) so agents can selectively load only the sections they need.", + "rawName": "describe [expression]", + "description": "Print a section map of expression.md (line ranges + token estimates) so agents can selectively load only the sections they need.", "options": [ { "rawName": "--format ", @@ -73,7 +73,7 @@ { "name": "ack", "rawName": "ack", - "description": "Acknowledge current drift — record intentional stance toward parent", + "description": "Acknowledge current drift — record intentional stance toward the tracked expression", "options": [ { "rawName": "-c, --config ", @@ -118,14 +118,14 @@ ] }, { - "name": "adopt", - "rawName": "adopt ", - "description": "Shift parent reference — adopt a new fingerprint as baseline", + "name": "track", + "rawName": "track ", + "description": "Track another expression as this repo's reference", "options": [ { "rawName": "-d, --dimension ", "name": "dimension", - "description": "Adopt only for a specific dimension", + "description": "Track only for a specific dimension", "default": null, "takesValue": true, "negated": false @@ -174,12 +174,12 @@ { "name": "emit", "rawName": "emit ", - "description": "Emit a derived artifact from fingerprint.md (kinds: review-command, context-bundle, skill)", + "description": "Emit a derived artifact from expression.md (kinds: review-command, context-bundle, skill)", "options": [ { - "rawName": "-e, --fingerprint ", - "name": "fingerprint", - "description": "Source fingerprint file (default: fingerprint.md)", + "rawName": "-e, --expression ", + "name": "expression", + "description": "Source expression file (default: expression.md)", "default": null, "takesValue": true, "negated": false @@ -219,7 +219,7 @@ { "rawName": "--prompt-only", "name": "promptOnly", - "description": "Emit only prompt.md — skips SKILL.md / fingerprint.md / tokens.css (context-bundle)", + "description": "Emit only prompt.md — skips SKILL.md / expression.md / tokens.css (context-bundle)", "default": null, "takesValue": false, "negated": false @@ -227,7 +227,7 @@ { "rawName": "--name ", "name": "name", - "description": "Override the skill name (default: fingerprint id) (context-bundle)", + "description": "Override the skill name (default: expression id) (context-bundle)", "default": null, "takesValue": true, "negated": false diff --git a/docs/cli-consolidation-plan.md b/docs/cli-consolidation-plan.md index c4cad03..0ff8205 100644 --- a/docs/cli-consolidation-plan.md +++ b/docs/cli-consolidation-plan.md @@ -2,10 +2,10 @@ name: CLI Consolidation Plan status: historical — superseded by the BYOA refactor (commits 4245274, d938314, de3e1cc) owner: nahiyan -branch: refactor/fingerprint (or a follow-up branch) +branch: refactor/expression (or a follow-up branch) --- -> **Historical document.** This plan describes a pre-BYOA consolidation from 18 verbs to ~11. The final BYOA refactor cut the CLI further to **6 deterministic primitives** (`compare`, `lint`, `ack`, `adopt`, `diverge`, `emit`) and moved `profile`, `review`, `verify`, `generate`, and `discover` into skill recipes the host agent runs. Kept for history; don't treat its verb list as current. See the [README](../README.md) and the [CLI reference](../apps/docs/src/app/docs/cli/page.tsx) for the current surface. +> **Historical document.** This plan describes a pre-BYOA consolidation from 18 verbs to ~11. The current BYOA surface is **7 deterministic primitives** (`compare`, `lint`, `describe`, `ack`, `track`, `diverge`, `emit`) and `profile`, `review`, `verify`, `generate`, and `discover` are skill recipes the host agent runs. Kept for history; don't treat the exploratory verb list below as current. See the [README](../README.md) and the [CLI reference](../apps/docs/src/content/docs/cli-reference.mdx) for the current surface. # Ghost CLI consolidation — 18 verbs → 9 @@ -13,26 +13,26 @@ branch: refactor/fingerprint (or a follow-up branch) ``` # Produce -ghost profile [targets] # extract fingerprint.md from target(s) (unchanged) -ghost emit # derive static artifacts from fingerprint.md +ghost profile [targets] # extract expression.md from target(s) (unchanged) +ghost emit # derive static artifacts from expression.md # kinds: review-command, context-bundle -ghost generate # LLM-produce UI artifact from fingerprint (unchanged) +ghost generate # LLM-produce UI artifact from expression (unchanged) # Check ghost review [scope] # unified drift detection # scopes: files (default), project, suite # Compare -ghost compare [...] # N-way fingerprint comparison +ghost compare [...] # N-way expression comparison # flags: --semantic, --cluster, --temporal # Govern ghost ack # acknowledge drift stance (kept at top level — coherent trio) -ghost adopt +ghost track ghost diverge # Utility -ghost lint [fingerprint] # fingerprint.md hygiene (unchanged) +ghost lint [expression] # expression.md hygiene (unchanged) ghost discover [query] # find public systems (unchanged) ghost viz <...> # 3D visualization (unchanged) ``` @@ -53,14 +53,14 @@ ghost viz <...> # 3D visualization (unchanged) | `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 | +| `verify [expression]` | `review suite [expression]` | 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 | +| `track ` | `track ` | unchanged | | `diverge ` | `diverge ` | unchanged | | `viz <…>` | `viz <…>` | unchanged | -| `context [expr]` | `emit context-bundle [--fingerprint …]` | merged into emit | +| `context [expr]` | `emit context-bundle [--expression …]` | merged into emit | | `emit ` (currently only `review`) | `emit ` | kind renamed `review` → `review-command` for clarity | | `generate ` | `generate ` | unchanged | | `lint [expr]` | `lint [expr]` | unchanged | @@ -81,9 +81,9 @@ Each phase is one PR. Phases are independent; stopping at any point leaves the C - 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 `--semantic` flag to `compare`: dispatches to `diffExpressions` instead of `compareExpressions` (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. +- Remove old files: `expr-diff-command.ts`, fleet bits in `evolution-commands.ts` (keep ack/track/diverge), scan logic. **Touched files:** - `packages/ghost-cli/src/bin.ts` — remove scan block, expand compare block @@ -130,8 +130,8 @@ Each phase is one PR. Phases are independent; stopping at any point leaves the C **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 + - `ghost review project [target] [--against reference.md]` — current comply behavior + - `ghost review suite [expression] [--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. @@ -139,7 +139,7 @@ Each phase is one PR. Phases are independent; stopping at any point leaves the C ``` Common: --format cli | json | github | sarif (not all scopes support all) - --fingerprint override fingerprint source + --expression override expression source review files [files] --staged staged changes only @@ -148,11 +148,11 @@ review files [files] --all all lines, not just changed review project [target] - --against drift check against parent + --against drift check against a reference expression --max-drift threshold (default: 0.3) --verbose agent reasoning -review suite [fingerprint] +review suite [expression] --suite custom suite json -n subsample --concurrency @@ -180,7 +180,7 @@ 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 `--emit` as "write expression.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. @@ -196,7 +196,7 @@ Actually, the above reduces to: keep `--emit` (convenience) and `--output` (expl **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. +`ack` / `track` / `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. --- @@ -221,7 +221,7 @@ Docs rewrite is the single biggest non-code task per phase. Roughly 1:1 time wit | 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. | +| Hidden references | Grep `ghost-cli` and `ghost profile|compare|review|comply|verify|scan|diff|expr-diff|fleet|context|emit|generate|ack|track|diverge|lint|viz|discover` across the repo before each phase. | --- @@ -240,7 +240,7 @@ Total code time estimate: ~1–2 days per phase including docs and tests. Phase ## Not in scope - Renaming `profile` (parked — keeping `profile`). -- Renaming `fingerprint.md`. +- Renaming `expression.md`. - New commands. - Changing the library API (`@ghost/core` exports). - Touching the Director orchestration layer. diff --git a/docs/fingerprint-format.md b/docs/expression-format.md similarity index 77% rename from docs/fingerprint-format.md rename to docs/expression-format.md index 73a0772..26c3836 100644 --- a/docs/fingerprint-format.md +++ b/docs/expression-format.md @@ -1,15 +1,15 @@ -# The `fingerprint.md` format +# The `expression.md` format -A Ghost **fingerprint** is a single Markdown file that captures what a design language is trying to say — readable and editable by humans, natively consumable by LLMs, with a structured machine layer for `ghost-drift compare`, `ghost-drift lint`, and the skill recipes the host agent runs (profile, review, verify, generate). +A Ghost **expression** is a single Markdown file that captures what a design language is trying to say — readable and editable by humans, natively consumable by LLMs, with a structured machine layer for `ghost-drift compare`, `ghost-drift lint`, and the skill recipes the host agent runs (profile, review, verify, generate). 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. +1. **Frontmatter (YAML)** — the **machine layer**. Identity, tokens, dimension slugs, evidence, personality/resembles 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. +Canonical filename: `expression.md` (flat, no dotfile, no slug prefix). Zero-config default for every Ghost command that reads an expression. Current schema version: **4**. @@ -19,12 +19,12 @@ Schema 4 extracts the 49-dimensional `embedding` into a sibling `embedding.md` f ## The partition (the one rule) -The frontmatter and the body own disjoint fields. The reader unions them into a single in-memory Fingerprint. +The frontmatter and the body own disjoint fields. The reader unions them into a single in-memory Expression. -| Fingerprint field | Lives in | Section / key | +| Expression field | Lives in | Section / key | |---|---|---| | `id`, `source`, `timestamp`, `sources` | Frontmatter | top-level | -| `observation.personality`, `observation.closestSystems` | Frontmatter | `observation:` | +| `observation.personality`, `observation.resembles` | Frontmatter | `observation:` | | `observation.summary` | **Body** | `# Character` | | `observation.distinctiveTraits` | **Body** | `# Signature` bullets | | `decisions[].dimension`, `decisions[].evidence`, `decisions[].embedding` | Frontmatter | `decisions:` entry | @@ -42,7 +42,7 @@ Schema 1 and 2 tried to mirror narrative fields across both sides and pick a win ## Frontmatter schema -Validated by a zod schema (`packages/ghost-drift/src/core/fingerprint/schema.ts`) and published as JSON Schema at `schemas/fingerprint.schema.json`. Below is the shape: +Validated by a zod schema (`packages/ghost-drift/src/core/expression/schema.ts`) and published as JSON Schema at `schemas/expression.schema.json`. Below is the shape: ```yaml --- @@ -53,12 +53,12 @@ schema: 4 # format version — required, rejected on mis 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) +extends: ./base.expression.md # optional — inherit from a base expression (see Composition) metadata: # optional — loose extension bag tone: magazine era: 2020s-editorial -# --- fingerprint: identity --- +# --- expression: identity --- id: claude source: llm # registry | extraction | llm | unknown timestamp: 2026-04-18T00:00:00Z @@ -66,12 +66,12 @@ sources: # optional, lists the targets that were combin - github:anthropics/claude-code - https://claude.ai -# --- fingerprint: narrative tags --- +# --- expression: narrative tags --- # NOTE: prose (summary, distinctiveTraits, decision rationale) lives # in the body under # Character, # Signature, ### blocks. observation: personality: [restrained, editorial] - closestSystems: [notion, linear] + resembles: [notion, linear] decisions: - dimension: warm-only-neutrals @@ -79,7 +79,7 @@ decisions: - dimension: serif-headlines evidence: ["H1-H6 serif 500"] -# --- fingerprint: structured tokens --- +# --- expression: structured tokens --- palette: dominant: - { role: accent, value: '#c96442' } @@ -109,7 +109,7 @@ surfaces: shadowComplexity: subtle # none | subtle | layered borderUsage: moderate # minimal | moderate | heavy -# --- fingerprint: role bindings (optional) --- +# --- expression: 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 @@ -127,7 +127,7 @@ roles: palette: { background: '#f5f4ed' } evidence: ["components/ui/card.tsx"] -# --- fingerprint: vector layer --- +# --- expression: 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. @@ -137,12 +137,12 @@ roles: **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 narrative tags:** `observation.personality`, `observation.resembles`, `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. +When `extends:` is present, required expression fields may be omitted — the overlay inherits them from the base expression. The merged result is re-validated against the strict schema. --- @@ -183,20 +183,20 @@ The body may also carry a `# Fragments` section that lists sibling files by mark - [embedding](embedding.md) — 49-dim vector for compare/composite/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`. +Readers walk these links to progressively load sibling content. The current v4 writer always emits a link to `embedding.md` when the expression 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 `expression.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. +- Paths are resolved relative to the expression.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. +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 expression stops being an inventory and becomes something a renderer can act on. **Shape.** Each role has three parts: @@ -231,13 +231,13 @@ roles: ## Embedding fragment -Schema 4 extracts the 49-dimensional embedding into `embedding.md` next to the fingerprint. The file carries only YAML — no prose: +Schema 4 extracts the 49-dimensional embedding into `embedding.md` next to the expression. The file carries only YAML — no prose: ```markdown --- schema: 4 kind: embedding -of: claude # parent fingerprint id +of: claude # expression id dimensions: 49 vector: - 0.218 @@ -251,24 +251,24 @@ vector: 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`. +3. Conventional sibling `embedding.md` next to `expression.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 })`. +Missing or stale files are never fatal — the loader silently falls back to recompute. Skip backfill entirely with `loadExpression(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. +The writer emits the sibling automatically when `serializeExpression(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: +An overlay expression can inherit from a base expression: ```yaml --- schema: 4 -extends: ./parent.fingerprint.md -id: child-system +extends: ./base.expression.md +id: product-expression decisions: - dimension: warm-neutrals evidence: ["#3a3630"] @@ -280,25 +280,25 @@ decisions: Now we also forbid warm grays. ``` -**Merge rules** (see `packages/ghost-drift/src/core/fingerprint/compose.ts`): +**Merge rules** (see `packages/ghost-drift/src/core/expression/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. +- **Scalars / arrays:** overlay replaces base when present. +- **`decisions[]`:** merged by `dimension` — overlay wins per-dim; base-only decisions preserved. +- **`palette.dominant` / `palette.semantic`:** merged by `role` — overlay 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 })`. +Skip resolution: `loadExpression(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: +Large systems can split decisions across files. If a `decisions/` directory sits next to the expression.md, each `*.md` inside is read as a single decision and merged in by dimension: ``` my-system/ -├── fingerprint.md +├── expression.md └── decisions/ ├── warm-neutrals.md ├── serif-headlines.md @@ -316,19 +316,19 @@ 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 })`. +Fragments override inline decisions with the same dimension. Skip with `loadExpression(path, { noFragments: true })`. --- ## Validation -`parseFingerprint` runs two gates on every read (unless `skipValidation: true`): +`parseExpression` 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: + Invalid expression frontmatter: • observation: Unrecognized keys: "summary", "distinctiveTraits" • decisions.0: Unrecognized key: "decision" • palette.saturationProfile: Invalid enum value... @@ -342,15 +342,15 @@ For tooling that wants to inspect partial or in-progress files, `skipValidation` | Command | Does | |---|---| -| `profile` recipe (host agent) | Write `fingerprint.md` (frontmatter machine-facts + body prose); the agent ends by calling `ghost-drift lint` | +| `profile` recipe (host agent) | Write `expression.md` (frontmatter machine-facts + body prose); the agent ends by calling `ghost-drift lint` | | `ghost-drift lint [path]` | Check schema validity, orphan prose, missing rationale, stray evidence in body, broken palette citations | | `ghost-drift compare --semantic` | Semantic diff: decisions added/removed/modified, value deltas, palette role swaps, token changes | | `ghost-drift compare ` | Vector distance (quantitative — use `--semantic` for qualitative) | -| `ghost-drift emit context-bundle` | Emit a grounding skill bundle (`SKILL.md` + `fingerprint.md` + `tokens.css`) | +| `ghost-drift emit context-bundle` | Emit a grounding skill bundle (`SKILL.md` + `expression.md` + `tokens.css`) | | `ghost-drift emit review-command` | Emit a per-project drift-review slash command (`.claude/commands/design-review.md`) | | `ghost-drift emit skill` | Install the `ghost-drift` skill bundle into your host agent | -Programmatic API (`ghost-drift`): `loadFingerprint`, `parseFingerprint`, `serializeFingerprint`, `lintFingerprint`, `compareFingerprints`, `mergeExpression`, `loadDecisionFragments`, `loadEmbeddingFragment`, `serializeEmbeddingFragment`, `findFragmentLinks`, `resolveEmbeddingReference`, `FrontmatterSchema`, `toJsonSchema`. +Programmatic API (`ghost-drift`): `loadExpression`, `parseExpression`, `serializeExpression`, `lintExpression`, `compareExpressions`, `mergeExpression`, `loadDecisionFragments`, `loadEmbeddingFragment`, `serializeEmbeddingFragment`, `findFragmentLinks`, `resolveEmbeddingReference`, `FrontmatterSchema`, `toJsonSchema`. --- @@ -366,10 +366,10 @@ Programmatic API (`ghost-drift`): `loadFingerprint`, `parseFingerprint`, `serial ## JSON Schema -`schemas/fingerprint.schema.json` is regenerated from the zod source: +`schemas/expression.schema.json` is regenerated from the zod source: ```bash -pnpm --filter ghost-drift build && node scripts/emit-fingerprint-schema.mjs +pnpm --filter ghost-drift build && node scripts/emit-expression-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 index a7ac78e..574e661 100644 --- a/docs/generation-loop.md +++ b/docs/generation-loop.md @@ -1,9 +1,9 @@ # Generation Loop Ghost sits as pipeline infrastructure for AI-driven UI generation. The -`fingerprint.md` is the grounding input; the *review* recipe is the +`expression.md` is the grounding input; the *review* recipe is the post-generation gate; the *verify* recipe drives the loop over a prompt -suite to expose where the fingerprint leaks. +suite to expose where the expression leaks. Only the grounding step is a deterministic CLI verb (`ghost-drift emit context-bundle`). *Generate*, *review*, and *verify* are skill recipes @@ -12,7 +12,7 @@ the host agent follows — installed with `ghost-drift emit skill`. ## Pipeline shape ``` -fingerprint.md ──► [ghost-drift emit context-bundle] ──► SKILL.md / tokens.css / prompt.md +expression.md ──► [ghost-drift emit context-bundle] ──► SKILL.md / tokens.css / prompt.md │ ▼ any generator @@ -22,7 +22,7 @@ fingerprint.md ──► [ghost-drift emit context-bundle] ──► SKILL.m ▼ HTML / JSX [review recipe] ──► drift disposition (block / annotate - / ack / adopt) + / ack / track) ``` ## Pieces @@ -30,21 +30,21 @@ fingerprint.md ──► [ghost-drift emit context-bundle] ──► SKILL.m ### `ghost-drift emit context-bundle [flags]` — the one CLI verb Emit a grounding bundle any generator can consume. Default output writes -`SKILL.md` + `fingerprint.md` + `tokens.css` into `./ghost-context/`. +`SKILL.md` + `expression.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` +- `--prompt-only` — single `prompt.md` only; skips `SKILL.md` / `expression.md` / `tokens.css` - `--no-tokens` — skip `tokens.css` - `--readme` — include `README.md` -- `--name ` — override the skill name (default: fingerprint id) +- `--name ` — override the skill name (default: expression id) Point a Claude Code or MCP client at the output directory and the agent reads `SKILL.md`. ### The `generate` recipe -Driven by the host agent. Loads the fingerprint, builds a system prompt +Driven by the host agent. Loads the expression, builds a system prompt from Character/Signature/Decisions + tokens, asks the underlying model, extracts the artifact (HTML/JSX/etc.), and hands it to the `review` recipe for self-check. Retries with drift feedback until it passes or the agent @@ -57,7 +57,7 @@ Source: `packages/ghost-drift/src/skill-bundle/references/generate.md`. ### The `review` recipe -The agent diffs generated output against the fingerprint. Flags hardcoded +The agent diffs generated output against the expression. Flags hardcoded colors outside the palette, spacing off the scale, and type choices that violate decisions. For pre-baked, per-project review commands use `ghost-drift emit review-command` (which writes a slash command at @@ -70,11 +70,11 @@ Source: `packages/ghost-drift/src/skill-bundle/references/review.md`. Runs the generate→review loop over a versioned prompt suite. Aggregates drift per dimension and classifies: -- **tight** (mean < 1): fingerprint reproduces faithfully +- **tight** (mean < 1): expression reproduces faithfully - **leaky** (1–3): generator drifts here often — tighten Decisions -- **uncaptured** (≥ 3): fingerprint likely under-specifies this dimension +- **uncaptured** (≥ 3): expression likely under-specifies this dimension -The killer demo: run `verify` on a mature fingerprint, intentionally drop +The killer demo: run `verify` on a mature expression, intentionally drop a section (e.g. motion), re-run, watch drift rise in dimensions that lost grounding. @@ -82,13 +82,13 @@ Source: `packages/ghost-drift/src/skill-bundle/references/verify.md`. ## The standard prompt suite -A versioned set of UI-construction tasks, each tagged with the fingerprint +A versioned set of UI-construction tasks, each tagged with the expression dimensions it stresses. Tagging prompts with dimensions lets the agent 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 +## How the three-layer expression format earns its keep Each layer has a concrete job somewhere in the loop: @@ -112,6 +112,6 @@ skill bundle into the generator's context; the generator produces; the `review` recipe gates the output. Drift disposition belongs to the pipeline owner (block, annotate, require `ghost-drift ack`). -**Fingerprint maintenance**: run `verify` periodically. When a dimension -shows up consistently leaky, the fingerprint needs more Decisions for +**Expression maintenance**: run `verify` periodically. When a dimension +shows up consistently leaky, the expression needs more Decisions for that dimension. diff --git a/docs/ideas/guided-migration.md b/docs/ideas/guided-migration.md index 1c62912..6c7a52b 100644 --- a/docs/ideas/guided-migration.md +++ b/docs/ideas/guided-migration.md @@ -2,21 +2,21 @@ status: exploring --- -# Guided migration: drifting a fingerprint toward another +# Guided migration: drifting an expression toward another -An agentic loop where repo A consciously migrates toward repo B's visual direction, driven by fingerprint distance + vector as the signal. +An agentic loop where repo A consciously migrates toward repo B's visual direction, driven by expression distance + vector as the signal. ## The observation -Embedding distance is a *tier selector*, not a progress bar. Closing 0.6 → 0.3 is usually easier than 0.3 → 0.05: the first half removes obviously wrong answers (different font family, different base unit, different radii language); the second half means reconciling deliberate choices that both sides consider correct. The last 0.05 is where child identity lives and often shouldn't go to zero at all. +Embedding distance is a *tier selector*, not a progress bar. Closing 0.6 → 0.3 is usually easier than 0.3 → 0.05: the first half removes obviously wrong answers (different font family, different base unit, different radii language); the second half means reconciling deliberate choices that both sides consider correct. The last 0.05 is where local identity lives and often shouldn't go to zero at all. ## Distance tiers | Distance | Meaning | Mode | |---|---|---| | < 0.15 | Accidental drift | **Reconcile** — token renames, no philosophy work | -| 0.15 – 0.3 | Deliberate variance on shared foundation | **Negotiate** — diff decisions, `adopt` or `diverge` each gap | -| 0.3 – 0.5 | Different decisions on similar kind of system | **Adopt decisions first, tokens follow** — chasing the scalar alone will mimic without migrating | +| 0.15 – 0.3 | Deliberate variance on shared foundation | **Negotiate** — diff decisions, `track` or `diverge` each gap | +| 0.3 – 0.5 | Different decisions on similar kind of system | **Track decisions first, tokens follow** — chasing the scalar alone will mimic without migrating | | > 0.5 | Different design languages | **Question the premise** — often not a migration | ## What the scalar hides @@ -28,9 +28,9 @@ Embedding distance is a *tier selector*, not a progress bar. Closing 0.6 → 0.3 A `migrate.md` skill recipe alongside profile / review / verify / generate / discover. Loop shape: -1. `adopt B` — snapshot starting per-dim distances into `.ghost-sync.json`. +1. `track B` — snapshot starting per-dim distances into `.ghost-sync.json`. 2. Pick the steepest dim from `computeDriftVectors`. -3. Host agent translates that delta into code edits, respecting the tier (reconcile vs negotiate vs adopt). +3. Host agent translates that delta into code edits, respecting the tier (reconcile vs negotiate vs track). 4. Re-profile → `ack` → repeat. 5. Stop when `d ≤ floor + ε`, or agent hits a dim the user marks `diverging`. @@ -39,6 +39,6 @@ No CLI primitive is missing. All the judgement work lives in the recipe. ## Open questions - **Ordering within a tier.** Vector-first (fast, risks mimicry), decisions-first (correct, slow), or interleaved (each `ack` commits one dim + the decisions that justify it). Current lean: interleaved. -- **Detecting when the scalar is lying.** Child can descend the vector gradient without importing decisions, landing at low `d` but not actually looking like B. Candidate: don't declare success until *both* the machine dims and the decisions dim are inside tolerance. -- **Diverge budget up front.** Should `adopt` accept `--diverge ,` so the floor is known before the loop runs, instead of discovered mid-migration? +- **Detecting when the scalar is lying.** A local expression can descend the vector gradient without importing decisions, landing at low `d` but not actually looking like B. Candidate: don't declare success until *both* the machine dims and the decisions dim are inside tolerance. +- **Diverge budget up front.** Should `track` accept `--diverge ,` so the floor is known before the loop runs, instead of discovered mid-migration? - **Symmetry.** `checkBounds` already flags `reconverging` when a diverging dim has closed to < 50% of acked distance. Guided migration is the deliberate form of that — same bookkeeping, inverted intent. Worth thinking about whether the two should share a verb. diff --git a/packages/ghost-drift/CHANGELOG.md b/packages/ghost-drift/CHANGELOG.md index 32f1121..3d32b7b 100644 --- a/packages/ghost-drift/CHANGELOG.md +++ b/packages/ghost-drift/CHANGELOG.md @@ -4,16 +4,16 @@ ### Minor Changes -- [#51](https://github.com/block/ghost/pull/51) [`70e3816`](https://github.com/block/ghost/commit/70e38164fbb6bf1287567939e5986a4eaeb71a4c) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Add `ghost-drift describe` — prints a section map of `fingerprint.md` (frontmatter range, body sections, per-dimension decision blocks) with line ranges and token estimates, so host agents can selectively load only the sections they need instead of the whole file. The review and generate skill recipes now open with `describe` and teach a "load whole `# Decisions` block if uncertain" recall safety rule. +- [#51](https://github.com/block/ghost/pull/51) [`70e3816`](https://github.com/block/ghost/commit/70e38164fbb6bf1287567939e5986a4eaeb71a4c) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Add `ghost-drift describe` — prints a section map of `expression.md` (frontmatter range, body sections, per-dimension decision blocks) with line ranges and token estimates, so host agents can selectively load only the sections they need instead of the whole file. The review and generate skill recipes now open with `describe` and teach a "load whole `# Decisions` block if uncertain" recall safety rule. -- [#46](https://github.com/block/ghost/pull/46) [`a96e335`](https://github.com/block/ghost/commit/a96e3352545ebc4e33c2e575dc45abd624ade351) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Rename `fleet` mode to `composite` across the library and CLI. The N≥3 compare output now reads "Composite Fingerprint: N members" — the aggregate view is a fingerprint of fingerprints. +- [#46](https://github.com/block/ghost/pull/46) [`a96e335`](https://github.com/block/ghost/commit/a96e3352545ebc4e33c2e575dc45abd624ade351) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Rename `fleet` mode to `composite` across the library and CLI. The N≥3 compare output now reads "Composite Expression: N members" — the aggregate view is an expression of expressions. **BREAKING** (safe to bump minor while on 0.x, but pinning consumers should adjust): - Library exports renamed: `compareFleet` → `compareComposite`; `formatFleetComparison` / `formatFleetComparisonJSON` → `formatCompositeComparison` / `formatCompositeComparisonJSON`. - Type exports renamed: `FleetComparison` / `FleetMember` / `FleetPair` / `FleetCluster` / `FleetClusterOptions` → `Composite*` equivalents. - `compare()` result discriminator: `result.mode === "fleet"` is now `"composite"`, and `result.fleet` is now `result.composite`. - - CLI header: `Fleet Overview: N projects` → `Composite Fingerprint: N members`. + - CLI header: `Fleet Overview: N projects` → `Composite Expression: N members`. JSON output shape (member count, pairwise, spread, clusters) is unchanged. @@ -31,7 +31,7 @@ - [`6f9f36a`](https://github.com/block/ghost/commit/6f9f36aac35663bd020e771195ca4a729e4ead8a) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Initial public release. Deterministic design drift detection for agent-authored UI: - - **Six CLI verbs** — `compare` (pairwise + fleet over 49-dim fingerprints, with `--semantic` and `--temporal` enrichment), `lint`, `ack`, `adopt`, `diverge`, and `emit` (derives `review-command`, `context-bundle`, or the `ghost-drift` skill). - - **`fingerprint.md` format** — human-readable Markdown with a YAML machine layer (49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-layer prose body (Character, Signature, Decisions). Parse, lint, diff, compose, and compare programmatically via the library export. - - **Library API** — `parseFingerprint`, `lintFingerprint`, `diffFingerprints`, `compareFingerprints`, `compareFleet`, `loadFingerprint`, `defineConfig`, and the full `@ghost-drift` core usable headlessly from any Node app. + - **Six CLI verbs** — `compare` (pairwise + fleet over 49-dim expressions, with `--semantic` and `--temporal` enrichment), `lint`, `ack`, `track`, `diverge`, and `emit` (derives `review-command`, `context-bundle`, or the `ghost-drift` skill). + - **`expression.md` format** — human-readable Markdown with a YAML machine layer (49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-layer prose body (Character, Signature, Decisions). Parse, lint, diff, compose, and compare programmatically via the library export. + - **Library API** — `parseExpression`, `lintExpression`, `diffExpressions`, `compareExpressions`, `compareFleet`, `loadExpression`, `defineConfig`, and the full `@ghost-drift` core usable headlessly from any Node app. - **Agent skill bundle** — `ghost-drift emit skill` installs an [agentskills.io](https://agentskills.io)-compatible bundle (`profile`, `review`, `verify`, `generate`, `discover`, `compare` recipes + schema reference) into your host agent of choice. The CLI never calls an LLM; the host agent owns all interpretive work. diff --git a/packages/ghost-drift/README.md b/packages/ghost-drift/README.md index 486d31c..e48c992 100644 --- a/packages/ghost-drift/README.md +++ b/packages/ghost-drift/README.md @@ -1,8 +1,8 @@ # ghost-drift -**Deterministic design drift detection. Six verbs. No LLM calls.** +**Deterministic design drift detection. Seven verbs. No LLM calls.** -`ghost-drift` captures a design language as a human-readable `fingerprint.md` (49-dim embedding + three-layer prose body) and gives any agent the primitives to detect drift against it. Judgement lives in whatever agent you already use; arithmetic lives here. +`ghost-drift` captures a design language as a human-readable `expression.md` (49-dim embedding + three-layer prose body) and gives any agent the primitives to detect drift against it. Judgement lives in whatever agent you already use; arithmetic lives here. ## Requirements @@ -35,11 +35,11 @@ Once npm publishing is unblocked this will move to the registry — swap the URL ## Use ```bash -ghost-drift lint fingerprint.md # validate schema + partition -ghost-drift compare a/fingerprint.md b/fingerprint.md # pairwise distance (N=2) -ghost-drift compare ./*/fingerprint.md # composite, N≥3 -ghost-drift ack # acknowledge drift against parent -ghost-drift adopt path/to/new-parent.md # adopt a new parent baseline +ghost-drift lint expression.md # validate schema + partition +ghost-drift compare a/expression.md b/expression.md # pairwise distance (N=2) +ghost-drift compare ./*/expression.md # composite, N≥3 +ghost-drift ack # acknowledge drift against the tracked expression +ghost-drift track path/to/new-tracked.md # track another expression ghost-drift diverge # declare intentional divergence ghost-drift emit skill # install the agent recipe bundle ghost-drift emit review-command # emit a per-project review slash command @@ -52,18 +52,18 @@ Zero config for every verb. No API key needed. `OPENAI_API_KEY` / `VOYAGE_API_KE ```ts import { - parseFingerprint, - lintFingerprint, - diffFingerprints, - compareFingerprints, + parseExpression, + lintExpression, + diffExpressions, + compareExpressions, } from "ghost-drift"; -const { fingerprint } = parseFingerprint(await readFile("fingerprint.md", "utf8")); -const report = lintFingerprint(source); -const distance = compareFingerprints(a, b); +const { expression } = parseExpression(await readFile("expression.md", "utf8")); +const report = lintExpression(source); +const distance = compareExpressions(a, b); ``` -All exports are browser-safe except the ones that read from disk (history, sync manifest, parent resolution). +All exports are browser-safe except the ones that read from disk (history, sync manifest, tracked-expression resolution). ## BYOA — bring your own agent @@ -77,7 +77,7 @@ The agent runs the recipes; the CLI runs the arithmetic. The CLI never calls an ## Full story -See the [project README](https://github.com/block/ghost#readme) for the philosophy, the fingerprint format spec, the parent/child/composite topology, and the reference design language (Ghost UI). +See the [project README](https://github.com/block/ghost#readme) for the philosophy, the expression format spec, composite comparison, and the reference design language (Ghost UI). ## License diff --git a/packages/ghost-drift/package.json b/packages/ghost-drift/package.json index 6e87ec0..bbea6dd 100644 --- a/packages/ghost-drift/package.json +++ b/packages/ghost-drift/package.json @@ -1,7 +1,7 @@ { "name": "ghost-drift", "version": "0.2.0", - "description": "Deterministic design drift detection — CLI + engine for fingerprinting, comparing, and tracking design languages across projects and at org scale", + "description": "Deterministic design drift detection — CLI + engine for writing, comparing, and tracking design-language expressions across projects and at org scale", "license": "Apache-2.0", "author": "Block, Inc.", "repository": { @@ -15,7 +15,7 @@ "keywords": [ "design-system", "drift-detection", - "fingerprint", + "expression", "design-tokens", "shadcn", "design-language", @@ -45,7 +45,7 @@ "provenance": true }, "scripts": { - "build": "tsc --build && rm -rf dist/skill-bundle && cp -r src/skill-bundle dist/skill-bundle", + "build": "rm -rf dist && tsc --build --force && cp -r src/skill-bundle dist/skill-bundle", "prepublishOnly": "pnpm build" }, "dependencies": { diff --git a/packages/ghost-drift/src/cli.ts b/packages/ghost-drift/src/cli.ts index e8a68f4..2766c22 100644 --- a/packages/ghost-drift/src/cli.ts +++ b/packages/ghost-drift/src/cli.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { cac } from "cac"; import { compare, - FINGERPRINT_FILENAME, + EXPRESSION_FILENAME, formatComparison, formatComparisonJSON, formatCompositeComparison, @@ -14,17 +14,17 @@ import { formatSemanticDiff, formatTemporalComparison, formatTemporalComparisonJSON, - layoutFingerprint, - lintFingerprint, - loadFingerprint, + layoutExpression, + lintExpression, + loadExpression, readHistory, readSyncManifest, } from "./core/index.js"; import { registerEmitCommand } from "./emit-command.js"; import { registerAckCommand, - registerAdoptCommand, registerDivergeCommand, + registerTrackCommand, } from "./evolution-commands.js"; export function buildCli(): ReturnType { @@ -33,8 +33,8 @@ export function buildCli(): ReturnType { // --- compare --- cli .command( - "compare [...fingerprints]", - "Compare two or more fingerprints. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint (pairwise matrix, centroid, spread, clusters).", + "compare [...expressions]", + "Compare two or more expressions. N=2 returns a pairwise delta; N≥3 returns a composite expression (pairwise matrix, centroid, spread, clusters).", ) .option("--semantic", "Qualitative diff of decisions + palette (N=2 only)") .option( @@ -46,12 +46,12 @@ export function buildCli(): ReturnType { "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", ) .option("--format ", "Output format: cli or json", { default: "cli" }) - .action(async (fingerprints: string[], opts) => { + .action(async (expressions: string[], opts) => { try { const parsed = await Promise.all( - fingerprints.map((path) => loadFingerprint(path)), + expressions.map((path) => loadExpression(path)), ); - const exprs = parsed.map((p) => p.fingerprint); + const exprs = parsed.map((p) => p.expression); let history: Awaited> | undefined; let manifest: Awaited> | null = @@ -115,15 +115,15 @@ export function buildCli(): ReturnType { // --- lint --- cli .command( - "lint [fingerprint]", - "Validate fingerprint.md schema and body/frontmatter coherence", + "lint [expression]", + "Validate expression.md schema and body/frontmatter coherence", ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (path: string | undefined, opts) => { try { - const target = resolve(process.cwd(), path ?? FINGERPRINT_FILENAME); + const target = resolve(process.cwd(), path ?? EXPRESSION_FILENAME); const raw = await readFile(target, "utf-8"); - const report = lintFingerprint(raw); + const report = lintExpression(raw); if (opts.format === "json") { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); @@ -157,15 +157,15 @@ export function buildCli(): ReturnType { // --- describe --- cli .command( - "describe [fingerprint]", - "Print a section map of fingerprint.md (line ranges + token estimates) so agents can selectively load only the sections they need.", + "describe [expression]", + "Print a section map of expression.md (line ranges + token estimates) so agents can selectively load only the sections they need.", ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (path: string | undefined, opts) => { try { - const target = resolve(process.cwd(), path ?? FINGERPRINT_FILENAME); + const target = resolve(process.cwd(), path ?? EXPRESSION_FILENAME); const raw = await readFile(target, "utf-8"); - const layout = layoutFingerprint(raw); + const layout = layoutExpression(raw); if (opts.format === "json") { process.stdout.write( `${JSON.stringify({ path: target, ...layout }, null, 2)}\n`, @@ -183,7 +183,7 @@ export function buildCli(): ReturnType { }); registerAckCommand(cli); - registerAdoptCommand(cli); + registerTrackCommand(cli); registerDivergeCommand(cli); registerEmitCommand(cli); diff --git a/packages/ghost-drift/src/core/compare.ts b/packages/ghost-drift/src/core/compare.ts index 0e0950e..efbe9d5 100644 --- a/packages/ghost-drift/src/core/compare.ts +++ b/packages/ghost-drift/src/core/compare.ts @@ -1,14 +1,14 @@ -import { compareFingerprints } from "./embedding/compare.js"; +import { compareExpressions } from "./embedding/compare.js"; import { compareComposite } from "./evolution/composite.js"; import { computeTemporalComparison } from "./evolution/temporal.js"; -import type { SemanticDiff } from "./fingerprint/diff.js"; -import { diffFingerprints } from "./fingerprint/diff.js"; +import type { SemanticDiff } from "./expression/diff.js"; +import { diffExpressions } from "./expression/diff.js"; import type { CompositeComparison, CompositeMember, - Fingerprint, - FingerprintComparison, - FingerprintHistoryEntry, + Expression, + ExpressionComparison, + ExpressionHistoryEntry, SyncManifest, TemporalComparison, } from "./types.js"; @@ -17,17 +17,17 @@ 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[]; + history?: ExpressionHistoryEntry[]; /** Companion to `history` — the ack manifest, if any. */ manifest?: SyncManifest | null; - /** Explicit member ids for composite mode. Defaults to `fingerprint.id`. */ + /** Explicit member ids for composite mode. Defaults to `expression.id`. */ ids?: string[]; } export type CompareResult = | { mode: "pairwise"; - comparison: FingerprintComparison; + comparison: ExpressionComparison; semantic?: SemanticDiff; temporal?: TemporalComparison; } @@ -37,7 +37,7 @@ export type CompareResult = }; /** - * Unified fingerprint comparison. + * Unified expression comparison. * * • N=2 → pairwise (distance + per-dimension delta). * • N=2 + semantic → adds a qualitative diff (what decisions/colors changed). @@ -47,23 +47,23 @@ export type CompareResult = * Rejects semantic/temporal in composite mode — both are pairwise concepts. */ export function compare( - fingerprints: Fingerprint[], + expressions: Expression[], options: CompareOptions = {}, ): CompareResult { - if (fingerprints.length < 2) { - throw new Error("compare requires at least 2 fingerprints."); + if (expressions.length < 2) { + throw new Error("compare requires at least 2 expressions."); } - if (fingerprints.length >= 3) { + if (expressions.length >= 3) { if (options.semantic || options.history) { throw new Error( - "semantic and temporal require exactly 2 fingerprints (pairwise mode).", + "semantic and temporal require exactly 2 expressions (pairwise mode).", ); } const ids = options.ids; - const members: CompositeMember[] = fingerprints.map((fingerprint, i) => ({ - id: ids?.[i] ?? fingerprint.id, - fingerprint, + const members: CompositeMember[] = expressions.map((expression, i) => ({ + id: ids?.[i] ?? expression.id, + expression, })); return { mode: "composite", @@ -71,10 +71,10 @@ export function compare( }; } - const [a, b] = fingerprints; - const comparison = compareFingerprints(a, b); + const [a, b] = expressions; + const comparison = compareExpressions(a, b); - const semantic = options.semantic ? diffFingerprints(a, b) : undefined; + const semantic = options.semantic ? diffExpressions(a, b) : undefined; const temporal = options.history !== undefined ? computeTemporalComparison({ diff --git a/packages/ghost-drift/src/core/config.ts b/packages/ghost-drift/src/core/config.ts index 7ffd847..8b2af50 100644 --- a/packages/ghost-drift/src/core/config.ts +++ b/packages/ghost-drift/src/core/config.ts @@ -31,7 +31,7 @@ export function defineConfig(config: GhostConfig): GhostConfig { * Unambiguous patterns (no prefix needed): * /absolute/path → local path * ./relative/path → local path - * ../parent/path → local path + * ../tracked/path → local path * https://... → URL * * Ambiguous inputs without a prefix will throw an error @@ -113,7 +113,7 @@ async function resolveConfigFile( return null; } -function normalizeParent( +function normalizeTracked( value: Target | string | undefined, ): Target | undefined { if (!value) return undefined; @@ -126,7 +126,7 @@ function normalizeParent( function mergeDefaults(raw: GhostConfig): GhostConfig { return { targets: raw.targets, - parent: normalizeParent(raw.parent as Target | string | undefined), + tracks: normalizeTracked(raw.tracks as Target | string | undefined), rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, embedding: raw.embedding, diff --git a/packages/ghost-drift/src/core/context/review-command.ts b/packages/ghost-drift/src/core/context/review-command.ts index 99b16c1..25d4459 100644 --- a/packages/ghost-drift/src/core/context/review-command.ts +++ b/packages/ghost-drift/src/core/context/review-command.ts @@ -1,31 +1,31 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; export interface EmitReviewInput { - fingerprint: Fingerprint; + expression: Expression; } /** - * Emit a project-fitted drift-review slash command from an fingerprint. + * Emit a project-fitted drift-review slash command from an expression. * * 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 + * populated with this expression'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 + * Pure: deterministic over the same expression. The expression is + * expected to be the unioned result of `loadExpression` — 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 { expression: fp } = input; const id = fp.id; const personality = (fp.observation?.personality ?? []).join(", "); - const cousins = (fp.observation?.closestSystems ?? []).join(", "); + const cousins = (fp.observation?.resembles ?? []).join(", "); const character = fp.observation?.summary?.trim() ?? ""; const parts = [ @@ -46,7 +46,7 @@ export function emitReviewCommand(input: EmitReviewInput): string { function frontmatter(id: string): string { return `--- -description: Drift review for ${id} — fitted to this system's design fingerprint +description: Drift review for ${id} — fitted to this expression's design language ---`; } @@ -88,7 +88,7 @@ const TRUE_SEMANTIC_ROLES = new Set([ "error", ]); -function paletteSection(fp: Fingerprint): string { +function paletteSection(fp: Expression): string { const allowed = allowedPalette(fp); const allowedList = allowed.map((h) => `\`${h}\``).join(", "); const dominant = fp.palette.dominant @@ -141,7 +141,7 @@ function paletteSection(fp: Fingerprint): string { return lines.join("\n"); } -function allowedPalette(fp: Fingerprint): string[] { +function allowedPalette(fp: Expression): string[] { const all = [ ...fp.palette.dominant.map((c) => c.value), ...fp.palette.neutrals.steps, @@ -152,7 +152,7 @@ function allowedPalette(fp: Fingerprint): string[] { // --- Radius ------------------------------------------------------------- -function radiusSection(fp: Fingerprint): string { +function radiusSection(fp: Expression): string { const radii = fp.surfaces.borderRadii; if (!radii?.length) return ""; const labeled = radii.map((r) => (r >= 999 ? "999px (pill)" : `${r}px`)); @@ -182,7 +182,7 @@ function radiusSection(fp: Fingerprint): string { // --- Spacing ------------------------------------------------------------ -function spacingSection(fp: Fingerprint): string { +function spacingSection(fp: Expression): string { const scale = fp.spacing.scale; if (!scale?.length) return ""; const allowedList = scale.map((s) => `\`${s}px\``).join(", "); @@ -207,7 +207,7 @@ function spacingSection(fp: Fingerprint): string { // --- Typography --------------------------------------------------------- -function typographySection(fp: Fingerprint): string { +function typographySection(fp: Expression): string { const t = fp.typography; if (!t) return ""; const families = t.families @@ -254,7 +254,7 @@ const COVERED_DIMENSIONS = new Set([ "typography", ]); -function otherDimensions(fp: Fingerprint): string { +function otherDimensions(fp: Expression): string { const blocks: string[] = []; for (const d of fp.decisions ?? []) { if (COVERED_DIMENSIONS.has(d.dimension)) continue; @@ -304,17 +304,17 @@ function guidelines(): string { 5. If asked, offer to fix directly`; } -function footer(fp: Fingerprint): string { +function footer(fp: Expression): string { const count = fp.decisions?.length ?? 0; return `--- -Generated from \`fingerprint.md\` (${count} decisions). Re-run \`ghost-drift emit review-command\` after fingerprint updates.`; +Generated from \`expression.md\` (${count} decisions). Re-run \`ghost-drift emit review-command\` after expression updates.`; } // --- helpers ------------------------------------------------------------ function findRationale( - fp: Fingerprint, + fp: Expression, candidates: string[], ): string | undefined { for (const dim of candidates) { diff --git a/packages/ghost-drift/src/core/context/tokens-css.ts b/packages/ghost-drift/src/core/context/tokens-css.ts index 561d536..57b712a 100644 --- a/packages/ghost-drift/src/core/context/tokens-css.ts +++ b/packages/ghost-drift/src/core/context/tokens-css.ts @@ -1,27 +1,27 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; export interface TokensCssOptions { - /** Source path (e.g. "./fingerprint.md") — surfaced in the provenance header. */ + /** Source path (e.g. "./expression.md") — surfaced in the provenance header. */ sourcePath?: string; /** Generator version string — surfaced in the provenance header. */ generator?: string; } /** - * Derive a CSS custom-property sheet from a fingerprint. - * Emits only dimensions the fingerprint actually captures. + * Derive a CSS custom-property sheet from an expression. + * Emits only dimensions the expression actually captures. * * The file opens with a provenance header identifying the generator, - * source fingerprint, and timestamp. Hand-edits will silently drift from + * source expression, and timestamp. Hand-edits will silently drift from * the source — the header warns readers not to edit. */ export function buildTokensCss( - fingerprint: Fingerprint, + expression: Expression, options: TokensCssOptions = {}, ): string { const sections: string[] = []; - const dominant = fingerprint.palette?.dominant ?? []; + const dominant = expression.palette?.dominant ?? []; if (dominant.length) { sections.push( block( @@ -31,7 +31,7 @@ export function buildTokensCss( ); } - const semantic = fingerprint.palette?.semantic ?? []; + const semantic = expression.palette?.semantic ?? []; if (semantic.length) { sections.push( block( @@ -41,7 +41,7 @@ export function buildTokensCss( ); } - const neutrals = fingerprint.palette?.neutrals?.steps ?? []; + const neutrals = expression.palette?.neutrals?.steps ?? []; if (neutrals.length) { sections.push( block( @@ -51,7 +51,7 @@ export function buildTokensCss( ); } - const spacing = fingerprint.spacing?.scale ?? []; + const spacing = expression.spacing?.scale ?? []; if (spacing.length) { sections.push( block( @@ -61,7 +61,7 @@ export function buildTokensCss( ); } - const sizeRamp = fingerprint.typography?.sizeRamp ?? []; + const sizeRamp = expression.typography?.sizeRamp ?? []; if (sizeRamp.length) { sections.push( block( @@ -71,14 +71,14 @@ export function buildTokensCss( ); } - const families = fingerprint.typography?.families ?? []; + const families = expression.typography?.families ?? []; if (families.length) { sections.push( block("Font families", [`--font-sans: ${families.join(", ")};`]), ); } - const radii = fingerprint.surfaces?.borderRadii ?? []; + const radii = expression.surfaces?.borderRadii ?? []; if (radii.length) { sections.push( block( @@ -89,8 +89,8 @@ export function buildTokensCss( } const generator = options.generator ?? "ghost"; - const source = options.sourcePath ?? "fingerprint.md"; - const timestamp = fingerprint.timestamp ?? new Date().toISOString(); + const source = options.sourcePath ?? "expression.md"; + const timestamp = expression.timestamp ?? new Date().toISOString(); const header = [ "/*", ` * Generated by ${generator} from ${source} on ${timestamp}`, diff --git a/packages/ghost-drift/src/core/context/writer.ts b/packages/ghost-drift/src/core/context/writer.ts index 314d6ac..1959b4f 100644 --- a/packages/ghost-drift/src/core/context/writer.ts +++ b/packages/ghost-drift/src/core/context/writer.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { serializeFingerprint } from "../fingerprint/writer.js"; -import type { DesignDecision, Fingerprint } from "../types.js"; +import { serializeExpression } from "../expression/writer.js"; +import type { DesignDecision, Expression } from "../types.js"; import { buildTokensCss } from "./tokens-css.js"; /** @@ -17,9 +17,9 @@ export interface WriteContextOptions { tokens?: boolean; /** Emit README.md. Default: false. */ readme?: boolean; - /** Emit only prompt.md (skips SKILL.md / fingerprint.md / tokens.css). Default: false. */ + /** Emit only prompt.md (skips SKILL.md / expression.md / tokens.css). Default: false. */ promptOnly?: boolean; - /** Override the skill name. Default: derived from fingerprint.id. */ + /** Override the skill name. Default: derived from expression.id. */ name?: string; /** * @deprecated Pass `tokens`, `readme`, `promptOnly` instead. @@ -29,7 +29,7 @@ export interface WriteContextOptions { * "prompt" → promptOnly:true */ format?: ContextFormat; - /** Source path (e.g. "./fingerprint.md") — surfaced in generated file headers. */ + /** Source path (e.g. "./expression.md") — surfaced in generated file headers. */ sourcePath?: string; /** Generator version string — surfaced in generated file headers. */ generator?: string; @@ -41,16 +41,16 @@ export interface WriteContextResult { } export async function writeContextBundle( - fingerprint: Fingerprint, + expression: Expression, options: WriteContextOptions, ): Promise { - const resolved = resolveFlags(options, fingerprint); + const resolved = resolveFlags(options, expression); await mkdir(options.outDir, { recursive: true }); const files: string[] = []; if (resolved.promptOnly) { const p = join(options.outDir, "prompt.md"); - await writeFile(p, buildPromptMd(fingerprint, resolved.name)); + await writeFile(p, buildPromptMd(expression, resolved.name)); files.push(p); return { outDir: options.outDir, files }; } @@ -58,19 +58,19 @@ export async function writeContextBundle( const skillPath = join(options.outDir, "SKILL.md"); await writeFile( skillPath, - buildSkillMd(fingerprint, resolved.name, resolved.tokens), + buildSkillMd(expression, resolved.name, resolved.tokens), ); files.push(skillPath); - const exprPath = join(options.outDir, "fingerprint.md"); - await writeFile(exprPath, serializeFingerprint(fingerprint)); + const exprPath = join(options.outDir, "expression.md"); + await writeFile(exprPath, serializeExpression(expression)); files.push(exprPath); if (resolved.tokens) { const cssPath = join(options.outDir, "tokens.css"); await writeFile( cssPath, - buildTokensCss(fingerprint, { + buildTokensCss(expression, { sourcePath: options.sourcePath, generator: options.generator, }), @@ -80,7 +80,7 @@ export async function writeContextBundle( if (resolved.readme) { const readmePath = join(options.outDir, "README.md"); - await writeFile(readmePath, buildReadmeMd(fingerprint, resolved.name)); + await writeFile(readmePath, buildReadmeMd(expression, resolved.name)); files.push(readmePath); } @@ -96,7 +96,7 @@ interface ResolvedFlags { function resolveFlags( options: WriteContextOptions, - fp?: Fingerprint, + fp?: Expression, ): ResolvedFlags { // Legacy format flag takes precedence if explicitly set by an old caller. let tokens = options.tokens ?? true; @@ -122,25 +122,25 @@ function resolveFlags( } export function buildSkillMd( - fingerprint: Fingerprint, + expression: Expression, name: string, includesCss: boolean, ): string { - const description = buildSkillDescription(fingerprint, name); + const description = buildSkillDescription(expression, name); const fileList = [ - "- `fingerprint.md` — canonical design language (YAML tokens + Character/Signature/Decisions/Values)", + "- `expression.md` — canonical design language (YAML tokens + Character/Signature/Decisions/Values)", ...(includesCss ? [ - "- `tokens.css` — CSS custom properties derived from fingerprint tokens", + "- `tokens.css` — CSS custom properties derived from expression tokens", ] : []), ].join("\n"); const body = `This skill grounds UI generation in the **${name}** design language. -Read \`fingerprint.md\` first — it is the source of truth. It has four layered sections: +Read \`expression.md\` first — it is the source of truth. It has four layered sections: -1. **Character** — who this system is (one-paragraph summary) +1. **Character** — what this expression is (one-paragraph summary) 2. **Signature** — what makes it distinctive (bullet list of traits) 3. **Decisions** — specific design choices with evidence from the source 4. **Values** — hard Do / Don't rules @@ -166,24 +166,24 @@ user-invocable: true ${body}`; } -function buildPromptMd(fingerprint: Fingerprint, name: string): string { +function buildPromptMd(expression: Expression, name: string): string { const parts: string[] = []; parts.push( `You are generating UI in the **${name}** design language. Honor the rules below.`, ); - const summary = fingerprint.observation?.summary?.trim(); + const summary = expression.observation?.summary?.trim(); if (summary) parts.push(`# Character\n\n${summary}`); - const traits = fingerprint.observation?.distinctiveTraits ?? []; + const traits = expression.observation?.distinctiveTraits ?? []; if (traits.length) parts.push(`# Signature\n\n${traits.map((t) => `- ${t}`).join("\n")}`); - const decisions = fingerprint.decisions ?? []; + const decisions = expression.decisions ?? []; if (decisions.length) parts.push(`# Decisions\n\n${decisions.map(formatDecision).join("\n\n")}`); - parts.push(`# Tokens\n\n${formatTokens(fingerprint)}`); + parts.push(`# Tokens\n\n${formatTokens(expression)}`); parts.push( "# How to use this prompt\n\nWhen asked to build a component or screen, produce HTML that uses the tokens above. Cite the Decision that drove each non-trivial choice.", @@ -192,8 +192,8 @@ function buildPromptMd(fingerprint: Fingerprint, name: string): string { return `${parts.join("\n\n")}\n`; } -function buildReadmeMd(fingerprint: Fingerprint, name: string): string { - const traits = fingerprint.observation?.distinctiveTraits ?? []; +function buildReadmeMd(expression: Expression, name: string): string { + const traits = expression.observation?.distinctiveTraits ?? []; const traitsLine = traits.length ? ` Signature traits: ${traits.slice(0, 3).join(", ")}.` : ""; @@ -204,30 +204,30 @@ Generated by \`ghost-drift emit context-bundle\`. Grounding material for AI UI g ## Files - \`SKILL.md\` — Agent Skill manifest (user-invocable) -- \`fingerprint.md\` — canonical design language (YAML frontmatter + Character/Signature/Decisions) -- \`tokens.css\` — CSS custom properties derived from fingerprint tokens +- \`expression.md\` — canonical design language (YAML frontmatter + Character/Signature/Decisions) +- \`tokens.css\` — CSS custom properties derived from expression tokens - \`README.md\` — this file ## Using this bundle **As a Claude Code / MCP skill:** point the client at this directory. The agent will read \`SKILL.md\` and follow its instructions. -**As context for any LLM:** load \`fingerprint.md\` into the system prompt. For more explicit grounding, concatenate with \`tokens.css\`. +**As context for any LLM:** load \`expression.md\` into the system prompt. For more explicit grounding, concatenate with \`tokens.css\`. -**Feedback loop:** ask your host agent to review the generated output against this \`fingerprint.md\` (the \`review\` recipe, installed via \`ghost-drift emit skill\`). Drift signals whether the generator honored the system. +**Feedback loop:** ask your host agent to review the generated output against this \`expression.md\` (the \`review\` recipe, installed via \`ghost-drift emit skill\`). Drift signals whether the generator honored the system. `; } -function buildSkillDescription(fingerprint: Fingerprint, name: string): string { - const traits = fingerprint.observation?.distinctiveTraits ?? []; +function buildSkillDescription(expression: Expression, name: string): string { + const traits = expression.observation?.distinctiveTraits ?? []; const traitPhrase = traits.length ? ` (${traits.slice(0, 3).join(", ")})` : ""; - return `Use this skill to generate UI in the ${name} design language${traitPhrase}. Contains the canonical fingerprint and token reference.`; + return `Use this skill to generate UI in the ${name} design language${traitPhrase}. Contains the canonical expression and token reference.`; } -function defaultSkillName(fingerprint?: Fingerprint): string { - const candidate = fingerprint?.id || "design-language"; +function defaultSkillName(expression?: Expression): string { + const candidate = expression?.id || "design-language"; return candidate .toLowerCase() .replace(/[^a-z0-9]+/g, "-") @@ -238,22 +238,22 @@ function formatDecision(d: DesignDecision): string { return `## ${d.dimension}\n${d.decision.trim()}`; } -function formatTokens(fingerprint: Fingerprint): string { +function formatTokens(expression: Expression): string { const lines: string[] = []; - const semantic = fingerprint.palette?.semantic ?? []; + const semantic = expression.palette?.semantic ?? []; if (semantic.length) { lines.push("**Semantic colors**"); for (const c of semantic) lines.push(`- \`${c.role}\`: ${c.value}`); } - const spacing = fingerprint.spacing?.scale ?? []; + const spacing = expression.spacing?.scale ?? []; if (spacing.length) lines.push(`\n**Spacing scale:** ${spacing.join(", ")}px`); - const sizeRamp = fingerprint.typography?.sizeRamp ?? []; + const sizeRamp = expression.typography?.sizeRamp ?? []; if (sizeRamp.length) lines.push(`\n**Type scale:** ${sizeRamp.join(", ")}px`); - const families = fingerprint.typography?.families ?? []; + const families = expression.typography?.families ?? []; if (families.length) lines.push(`\n**Font families:** ${families.join(", ")}`); - const radii = fingerprint.surfaces?.borderRadii ?? []; + const radii = expression.surfaces?.borderRadii ?? []; if (radii.length) lines.push(`\n**Border radii:** ${radii.join(", ")}px`); return lines.join("\n"); } diff --git a/packages/ghost-drift/src/core/embedding/compare.ts b/packages/ghost-drift/src/core/embedding/compare.ts index eeb318f..7087144 100644 --- a/packages/ghost-drift/src/core/embedding/compare.ts +++ b/packages/ghost-drift/src/core/embedding/compare.ts @@ -1,8 +1,8 @@ import { computeDriftVectors } from "../evolution/vector.js"; import type { DimensionDelta, - Fingerprint, - FingerprintComparison, + Expression, + ExpressionComparison, } from "../types.js"; export interface CompareOptions { @@ -16,7 +16,7 @@ const WEIGHTS: Record = { surfaces: 0.15, }; -/** Redistributed weights when both fingerprints have design decisions */ +/** Redistributed weights when both expressions have design decisions */ const WEIGHTS_WITH_DECISIONS: Record = { decisions: 0.15, palette: 0.3, @@ -25,14 +25,14 @@ const WEIGHTS_WITH_DECISIONS: Record = { surfaces: 0.15, }; -export function compareFingerprints( - source: Fingerprint, - target: Fingerprint, +export function compareExpressions( + source: Expression, + target: Expression, options?: CompareOptions, -): FingerprintComparison { +): ExpressionComparison { const dimensions: Record = {}; - // Compare decisions when both fingerprints have them. + // Compare decisions when both expressions have them. // Decisions only contribute to the weighted distance when both sides have // embeddings — otherwise we record a qualitative delta without a scalar // that would pollute the number. @@ -63,7 +63,7 @@ export function compareFingerprints( const summary = buildSummary(dimensions, distance); - const result: FingerprintComparison = { + const result: ExpressionComparison = { source, target, distance, @@ -78,7 +78,7 @@ export function compareFingerprints( return result; } -function comparePalette(a: Fingerprint, b: Fingerprint): DimensionDelta { +function comparePalette(a: Expression, b: Expression): DimensionDelta { const distances: number[] = []; // Compare dominant colors by role, then by position for unmatched @@ -144,7 +144,7 @@ function comparePalette(a: Fingerprint, b: Fingerprint): DimensionDelta { return { dimension: "palette", distance, description }; } -function compareSpacing(a: Fingerprint, b: Fingerprint): DimensionDelta { +function compareSpacing(a: Expression, b: Expression): DimensionDelta { const distances: number[] = []; // Scale similarity (Jaccard-like) @@ -175,7 +175,7 @@ function compareSpacing(a: Fingerprint, b: Fingerprint): DimensionDelta { }; } -function compareTypography(a: Fingerprint, b: Fingerprint): DimensionDelta { +function compareTypography(a: Expression, b: Expression): DimensionDelta { const distances: number[] = []; // Family match — fuzzy comparison @@ -211,7 +211,7 @@ function compareTypography(a: Fingerprint, b: Fingerprint): DimensionDelta { }; } -function compareSurfaces(a: Fingerprint, b: Fingerprint): DimensionDelta { +function compareSurfaces(a: Expression, b: Expression): DimensionDelta { const distances: number[] = []; // Border radii overlap @@ -251,7 +251,7 @@ function compareSurfaces(a: Fingerprint, b: Fingerprint): DimensionDelta { const DECISION_MATCH_THRESHOLD = 0.75; /** - * Compare design decisions between two fingerprints. + * Compare design decisions between two expressions. * * When `bothEmbedded` is true: match decisions pairwise by cosine similarity * of their embeddings. Distance blends unmatched coverage with the cosine @@ -262,8 +262,8 @@ const DECISION_MATCH_THRESHOLD = 0.75; * from the weighted distance (see `WEIGHTS` vs `WEIGHTS_WITH_DECISIONS`). */ function compareDecisions( - a: Fingerprint, - b: Fingerprint, + a: Expression, + b: Expression, bothEmbedded: boolean, ): DimensionDelta { const aDecs = a.decisions ?? []; @@ -279,7 +279,7 @@ function compareDecisions( // Greedy one-to-one match: for each decision in A, find the best unmatched // decision in B above threshold. Stable and O(n*m), which is fine for - // fingerprints with ~5–15 decisions. + // expressions with ~5–15 decisions. const matchedB = new Set(); const matchedCosines: number[] = []; @@ -533,8 +533,8 @@ function avg(values: number[]): number { } function describePaletteChange( - _a: Fingerprint, - _b: Fingerprint, + _a: Expression, + _b: Expression, distance: number, ): string { if (distance < 0.1) return "Color palettes are nearly identical"; diff --git a/packages/ghost-drift/src/core/embedding/describe.ts b/packages/ghost-drift/src/core/embedding/describe.ts index 8851715..40194c9 100644 --- a/packages/ghost-drift/src/core/embedding/describe.ts +++ b/packages/ghost-drift/src/core/embedding/describe.ts @@ -1,13 +1,13 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; /** - * Render an Fingerprint as a standardized natural language description. + * Render an Expression as a standardized natural language description. * This text is fed to embedding models to produce semantic vectors. * * The description is structured to emphasize design-relevant signals * and minimize noise from identifiers or timestamps. */ -export function describeFingerprint(fp: Fingerprint): string { +export function describeExpression(fp: Expression): string { const sections: string[] = []; // Observation (Layer 1) — prepend when available for richer semantic embedding @@ -35,7 +35,7 @@ export function describeFingerprint(fp: Fingerprint): string { return sections.filter(Boolean).join(" "); } -function describePalette(fp: Fingerprint): string { +function describePalette(fp: Expression): string { const parts: string[] = []; const { palette } = fp; @@ -75,7 +75,7 @@ function describePalette(fp: Fingerprint): string { return parts.join(" "); } -function describeSpacing(fp: Fingerprint): string { +function describeSpacing(fp: Expression): string { const { spacing } = fp; const parts: string[] = []; @@ -100,7 +100,7 @@ function describeSpacing(fp: Fingerprint): string { return parts.join(" "); } -function describeTypography(fp: Fingerprint): string { +function describeTypography(fp: Expression): string { const { typography } = fp; const parts: string[] = []; @@ -129,7 +129,7 @@ function describeTypography(fp: Fingerprint): string { return parts.join(" "); } -function describeSurfaces(fp: Fingerprint): string { +function describeSurfaces(fp: Expression): string { const { surfaces } = fp; const parts: string[] = []; diff --git a/packages/ghost-drift/src/core/embedding/embed-api.ts b/packages/ghost-drift/src/core/embedding/embed-api.ts index 07cefa8..6ce2e01 100644 --- a/packages/ghost-drift/src/core/embedding/embed-api.ts +++ b/packages/ghost-drift/src/core/embedding/embed-api.ts @@ -1,10 +1,10 @@ -import type { EmbeddingConfig, Fingerprint } from "../types.js"; -import { describeFingerprint } from "./describe.js"; +import type { EmbeddingConfig, Expression } from "../types.js"; +import { describeExpression } from "./describe.js"; /** - * Generate a semantic embedding for an fingerprint using an external API. + * Generate a semantic embedding for an expression using an external API. * - * Converts the structured fingerprint into a natural language description, + * Converts the structured expression into a natural language description, * then sends it to an embedding model. The resulting vector captures semantic * similarity — two projects using `bg-slate-900` and `--color-gray-900: #0f172a` * will land nearby because the model understands they express the same intent. @@ -14,10 +14,10 @@ import { describeFingerprint } from "./describe.js"; * - voyage: Uses voyage-3 (default). Set VOYAGE_API_KEY env var. */ export async function computeSemanticEmbedding( - fingerprint: Fingerprint, + expression: Expression, config: EmbeddingConfig, ): Promise { - const text = describeFingerprint(fingerprint); + const text = describeExpression(expression); const [vec] = await embedTexts([text], config); return vec; } diff --git a/packages/ghost-drift/src/core/embedding/embedding.ts b/packages/ghost-drift/src/core/embedding/embedding.ts index 7a52538..121b3ce 100644 --- a/packages/ghost-drift/src/core/embedding/embedding.ts +++ b/packages/ghost-drift/src/core/embedding/embedding.ts @@ -1,7 +1,7 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; import { contrastScore, saturationScore } from "./colors.js"; -type FingerprintInput = Omit; +type ExpressionInput = Omit; // Fixed embedding size for comparability const EMBEDDING_SIZE = 49; @@ -38,8 +38,8 @@ function logNorm(count: number, logBase: number): number { } /** - * Compute a deterministic numeric embedding from a structured fingerprint. - * This ensures fingerprints from different sources (LLM, registry, extraction) + * Compute a deterministic numeric embedding from a structured expression. + * This ensures expressions from different sources (LLM, registry, extraction) * produce comparable vectors. * * Dimensions (49 total): @@ -50,14 +50,14 @@ function logNorm(count: number, logBase: number): number { * [31-40] Typography: families count, size ramp features, weight distribution, line height, weight spread, ramp range * [41-48] Surfaces: radii features, shadow complexity, border usage, radii spread, radii median, max radius */ -export function computeEmbedding(fingerprint: FingerprintInput): number[] { +export function computeEmbedding(expression: ExpressionInput): number[] { const vec: number[] = new Array(EMBEDDING_SIZE).fill(0); let i = 0; // --- Palette: dominant colors (12 dims) --- const dominantSlots = 4; for (let s = 0; s < dominantSlots; s++) { - const color = fingerprint.palette.dominant[s]; + const color = expression.palette.dominant[s]; if (color?.oklch) { vec[i++] = color.oklch[0]; // L (0-1) vec[i++] = color.oklch[1]; // C (0-0.4 typical) @@ -68,13 +68,13 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { } // --- Palette: neutral ramp (6 dims) --- - const neutralCount = fingerprint.palette.neutrals.count; + const neutralCount = expression.palette.neutrals.count; vec[i++] = Math.min(neutralCount / NORM.neutralCountMax, 1); vec[i++] = neutralCount > 0 ? 1 : 0; vec[i++] = Math.min(neutralCount / NORM.neutralDensityMax, 1); // Estimate lightness range from neutral steps using semantic colors as proxy - const neutralLightnesses = fingerprint.palette.semantic + const neutralLightnesses = expression.palette.semantic .filter( (c) => c.oklch && @@ -95,18 +95,18 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { // --- Palette: qualitative (3 dims) — continuous scoring --- const allSemanticAndDominant = [ - ...fingerprint.palette.semantic, - ...fingerprint.palette.dominant, + ...expression.palette.semantic, + ...expression.palette.dominant, ]; vec[i++] = saturationScore(allSemanticAndDominant); vec[i++] = contrastScore(allSemanticAndDominant); vec[i++] = Math.min( - fingerprint.palette.semantic.length / NORM.semanticCountMax, + expression.palette.semantic.length / NORM.semanticCountMax, 1, ); // --- Spacing (10 dims) --- - const spacing = fingerprint.spacing; + const spacing = expression.spacing; vec[i++] = logNorm(spacing.scale.length, NORM.spacingCountLogBase); vec[i++] = spacing.scale.length > 0 @@ -175,7 +175,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { } // --- Typography (10 dims) --- - const typo = fingerprint.typography; + const typo = expression.typography; vec[i++] = Math.min(typo.families.length / NORM.familyCountMax, 1); vec[i++] = Math.min(typo.sizeRamp.length / NORM.sizeRampCountMax, 1); // Size range @@ -238,7 +238,7 @@ export function computeEmbedding(fingerprint: FingerprintInput): number[] { } // --- Surfaces (8 dims) --- - const surfaces = fingerprint.surfaces; + const surfaces = expression.surfaces; vec[i++] = Math.min(surfaces.borderRadii.length / NORM.radiiCountMax, 1); vec[i++] = surfaces.borderRadii.length > 0 diff --git a/packages/ghost-drift/src/core/embedding/index.ts b/packages/ghost-drift/src/core/embedding/index.ts index dd9baab..56c16b5 100644 --- a/packages/ghost-drift/src/core/embedding/index.ts +++ b/packages/ghost-drift/src/core/embedding/index.ts @@ -1,6 +1,6 @@ export { colorToSemanticColor, parseColorToOklch } from "./colors.js"; -export { compareFingerprints } from "./compare.js"; -export { describeFingerprint } from "./describe.js"; +export { compareExpressions } from "./compare.js"; +export { describeExpression } from "./describe.js"; export { computeSemanticEmbedding, embedTexts } from "./embed-api.js"; export { computeEmbedding, embeddingDistance } from "./embedding.js"; export type { RoleCandidate } from "./semantic-roles.js"; diff --git a/packages/ghost-drift/src/core/evolution/composite.ts b/packages/ghost-drift/src/core/evolution/composite.ts index 01188ee..c4f822b 100644 --- a/packages/ghost-drift/src/core/evolution/composite.ts +++ b/packages/ghost-drift/src/core/evolution/composite.ts @@ -1,4 +1,4 @@ -import { compareFingerprints } from "../embedding/compare.js"; +import { compareExpressions } from "../embedding/compare.js"; import { embeddingDistance } from "../embedding/embedding.js"; import type { CompositeCluster, @@ -12,7 +12,7 @@ export interface CompositeClusterOptions { } /** - * Compare N fingerprints as a composite (org-scale) view. + * Compare N expressions as a composite (org-scale) view. * Computes pairwise distances, centroid, spread, and optional clusters. */ export function compareComposite( @@ -52,7 +52,7 @@ function computePairwise(members: CompositeMember[]): CompositePair[] { for (let j = i + 1; j < members.length; j++) { const a = members[i]; const b = members[j]; - const comparison = compareFingerprints(a.fingerprint, b.fingerprint); + const comparison = compareExpressions(a.expression, b.expression); const dimensions: Record = {}; for (const [key, delta] of Object.entries(comparison.dimensions)) { @@ -77,12 +77,12 @@ function computePairwise(members: CompositeMember[]): CompositePair[] { function computeCentroid(members: CompositeMember[]): number[] { if (members.length === 0) return []; - const dim = members[0].fingerprint.embedding.length; + const dim = members[0].expression.embedding.length; const centroid = new Array(dim).fill(0); for (const member of members) { for (let i = 0; i < dim; i++) { - centroid[i] += member.fingerprint.embedding[i] ?? 0; + centroid[i] += member.expression.embedding[i] ?? 0; } } @@ -101,7 +101,7 @@ function computeSpread(members: CompositeMember[], centroid: number[]): number { let totalDistance = 0; for (const member of members) { - totalDistance += embeddingDistance(member.fingerprint.embedding, centroid); + totalDistance += embeddingDistance(member.expression.embedding, centroid); } return totalDistance / members.length; @@ -237,7 +237,7 @@ function clusterMembers( ]; } - const embeddings = members.map((m) => m.fingerprint.embedding); + const embeddings = members.map((m) => m.expression.embedding); const kMax = Math.min(maxK ?? 6, members.length - 1); // Run k-means for K=1 through kMax, collect WCSS diff --git a/packages/ghost-drift/src/core/evolution/emit.ts b/packages/ghost-drift/src/core/evolution/emit.ts index 4c418b1..fed3e7e 100644 --- a/packages/ghost-drift/src/core/evolution/emit.ts +++ b/packages/ghost-drift/src/core/evolution/emit.ts @@ -2,30 +2,30 @@ import { writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { EMBEDDING_FRAGMENT_FILENAME, - FINGERPRINT_FILENAME, + EXPRESSION_FILENAME, serializeEmbeddingFragment, - serializeFingerprint, -} from "../fingerprint/index.js"; -import type { Fingerprint } from "../types.js"; + serializeExpression, +} from "../expression/index.js"; +import type { Expression } from "../types.js"; /** - * Write a fingerprint as a publishable artifact (fingerprint.md) to the - * project root. Other projects can reference this file as their parent. + * Write an expression as a publishable artifact (expression.md) to the + * project root. Other projects can track this file as a reference. */ -export async function emitFingerprint( - fingerprint: Fingerprint, +export async function emitExpression( + expression: Expression, cwd: string = process.cwd(), ): Promise { - const target = resolve(cwd, FINGERPRINT_FILENAME); - await writeFile(target, serializeFingerprint(fingerprint), "utf-8"); + const target = resolve(cwd, EXPRESSION_FILENAME); + await writeFile(target, serializeExpression(expression), "utf-8"); // The 49-dim embedding lives in a sibling `embedding.md` referenced from - // the fingerprint body. Readers fall back to recompute if it's missing. - if (fingerprint.embedding && fingerprint.embedding.length > 0) { + // the expression body. Readers fall back to recompute if it's missing. + if (expression.embedding && expression.embedding.length > 0) { const embeddingPath = resolve(dirname(target), EMBEDDING_FRAGMENT_FILENAME); await writeFile( embeddingPath, - serializeEmbeddingFragment(fingerprint.embedding, fingerprint.id), + serializeEmbeddingFragment(expression.embedding, expression.id), "utf-8", ); } diff --git a/packages/ghost-drift/src/core/evolution/history.ts b/packages/ghost-drift/src/core/evolution/history.ts index 5198bbe..28e708b 100644 --- a/packages/ghost-drift/src/core/evolution/history.ts +++ b/packages/ghost-drift/src/core/evolution/history.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import type { FingerprintHistoryEntry } from "../types.js"; +import type { ExpressionHistoryEntry } from "../types.js"; const GHOST_DIR = ".ghost"; const HISTORY_FILE = "history.jsonl"; @@ -11,11 +11,11 @@ function historyPath(cwd: string): string { } /** - * Append an fingerprint history entry to .ghost/history.jsonl. + * Append an expression history entry to .ghost/history.jsonl. * Creates the .ghost directory if it doesn't exist. */ export async function appendHistory( - entry: FingerprintHistoryEntry, + entry: ExpressionHistoryEntry, cwd: string = process.cwd(), ): Promise { const dir = resolve(cwd, GHOST_DIR); @@ -32,7 +32,7 @@ export async function appendHistory( */ export async function readHistory( cwd: string = process.cwd(), -): Promise { +): Promise { const path = historyPath(cwd); if (!existsSync(path)) return []; @@ -40,7 +40,7 @@ export async function readHistory( return content .split("\n") .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line) as FingerprintHistoryEntry); + .map((line) => JSON.parse(line) as ExpressionHistoryEntry); } /** @@ -49,7 +49,7 @@ export async function readHistory( export async function readRecentHistory( count: number, cwd: string = process.cwd(), -): Promise { +): Promise { const all = await readHistory(cwd); return all.slice(-count); } diff --git a/packages/ghost-drift/src/core/evolution/index.ts b/packages/ghost-drift/src/core/evolution/index.ts index a728f3d..13ca3ec 100644 --- a/packages/ghost-drift/src/core/evolution/index.ts +++ b/packages/ghost-drift/src/core/evolution/index.ts @@ -1,8 +1,7 @@ export type { CompositeClusterOptions } from "./composite.js"; export { compareComposite } from "./composite.js"; -export { emitFingerprint } from "./emit.js"; +export { emitExpression } from "./emit.js"; export { appendHistory, readHistory, readRecentHistory } from "./history.js"; -export { normalizeParentSource, resolveParent } from "./parent.js"; export type { CheckBoundsOptions } from "./sync.js"; export { acknowledge, @@ -11,4 +10,8 @@ export { writeSyncManifest, } from "./sync.js"; export { computeTemporalComparison } from "./temporal.js"; +export { + normalizeTrackedSource, + resolveTrackedExpression, +} from "./tracking.js"; export { computeDriftVectors, DIMENSION_RANGES } from "./vector.js"; diff --git a/packages/ghost-drift/src/core/evolution/sync.ts b/packages/ghost-drift/src/core/evolution/sync.ts index c5f9bc5..99b7316 100644 --- a/packages/ghost-drift/src/core/evolution/sync.ts +++ b/packages/ghost-drift/src/core/evolution/sync.ts @@ -1,12 +1,12 @@ import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { compareFingerprints } from "../embedding/compare.js"; +import { compareExpressions } from "../embedding/compare.js"; import type { DimensionAck, DimensionStance, - Fingerprint, - FingerprintComparison, + Expression, + ExpressionComparison, SyncManifest, Target, } from "../types.js"; @@ -44,23 +44,24 @@ export async function writeSyncManifest( /** * Acknowledge the current drift state. - * Compares child to parent, records per-dimension distances with stances. + * Compares the local expression to the tracked expression, recording + * per-dimension distances with stances. * * If dimension/stance are provided, only that dimension is updated — * the rest are preserved from the existing manifest or set to "accepted". */ export async function acknowledge(opts: { - child: Fingerprint; - parent: Fingerprint; - parentRef: Target; + local: Expression; + tracked: Expression; + tracks: Target; dimension?: string; stance?: DimensionStance; reason?: string; tolerance?: number; cwd?: string; -}): Promise<{ manifest: SyncManifest; comparison: FingerprintComparison }> { +}): Promise<{ manifest: SyncManifest; comparison: ExpressionComparison }> { const cwd = opts.cwd ?? process.cwd(); - const comparison = compareFingerprints(opts.parent, opts.child); + const comparison = compareExpressions(opts.tracked, opts.local); const now = new Date().toISOString(); // Load existing manifest to preserve previous acks @@ -90,10 +91,10 @@ export async function acknowledge(opts: { } const manifest: SyncManifest = { - parent: opts.parentRef, + tracks: opts.tracks, ackedAt: now, - parentFingerprintId: opts.parent.id, - childFingerprintId: opts.child.id, + trackedExpressionId: opts.tracked.id, + localExpressionId: opts.local.id, dimensions, overallDistance: comparison.distance, }; @@ -121,7 +122,7 @@ export interface CheckBoundsOptions { */ export function checkBounds( manifest: SyncManifest, - current: FingerprintComparison, + current: ExpressionComparison, toleranceOrOptions?: number | CheckBoundsOptions, ): { exceeded: boolean; dimensions: string[]; reconverging: string[] } { const opts: CheckBoundsOptions = diff --git a/packages/ghost-drift/src/core/evolution/temporal.ts b/packages/ghost-drift/src/core/evolution/temporal.ts index ce21277..72506a4 100644 --- a/packages/ghost-drift/src/core/evolution/temporal.ts +++ b/packages/ghost-drift/src/core/evolution/temporal.ts @@ -1,8 +1,8 @@ -import { compareFingerprints } from "../embedding/compare.js"; +import { compareExpressions } from "../embedding/compare.js"; import type { DriftVelocity, - FingerprintComparison, - FingerprintHistoryEntry, + ExpressionComparison, + ExpressionHistoryEntry, SyncManifest, TemporalComparison, } from "../types.js"; @@ -10,12 +10,12 @@ import { checkBounds } from "./sync.js"; import { computeDriftVectors } from "./vector.js"; /** - * Enrich an fingerprint comparison with temporal data: + * Enrich an expression comparison with temporal data: * velocity, trajectory, ack status, and drift vectors. */ export function computeTemporalComparison(opts: { - comparison: FingerprintComparison; - history: FingerprintHistoryEntry[]; + comparison: ExpressionComparison; + history: ExpressionHistoryEntry[]; manifest: SyncManifest | null; stabilityThreshold?: number; }): TemporalComparison { @@ -57,8 +57,8 @@ export function computeTemporalComparison(opts: { * Uses the oldest and most recent entries to calculate rate of change. */ function computeVelocity( - current: FingerprintComparison, - history: FingerprintHistoryEntry[], + current: ExpressionComparison, + history: ExpressionHistoryEntry[], stabilityThreshold: number = 0.01, ): DriftVelocity[] { if (history.length < 2) { @@ -74,16 +74,16 @@ function computeVelocity( const oldest = history[0]; const newest = history[history.length - 1]; - const oldestDate = new Date(oldest.fingerprint.timestamp); - const newestDate = new Date(newest.fingerprint.timestamp); + const oldestDate = new Date(oldest.expression.timestamp); + const newestDate = new Date(newest.expression.timestamp); const windowDays = Math.max( (newestDate.getTime() - oldestDate.getTime()) / (1000 * 60 * 60 * 24), 1, ); - // Compare the oldest history entry's fingerprint against the current source + // Compare the oldest history entry's expression against the current source // to get a "then" comparison, and use the current comparison as "now" - const oldComparison = compareFingerprints(current.source, oldest.fingerprint); + const oldComparison = compareExpressions(current.source, oldest.expression); return Object.keys(current.dimensions).map((dimension) => { const oldDistance = oldComparison.dimensions[dimension]?.distance ?? 0; diff --git a/packages/ghost-drift/src/core/evolution/parent.ts b/packages/ghost-drift/src/core/evolution/tracking.ts similarity index 52% rename from packages/ghost-drift/src/core/evolution/parent.ts rename to packages/ghost-drift/src/core/evolution/tracking.ts index c8d7b06..ca6ea19 100644 --- a/packages/ghost-drift/src/core/evolution/parent.ts +++ b/packages/ghost-drift/src/core/evolution/tracking.ts @@ -1,29 +1,29 @@ import { resolve } from "node:path"; import { resolveTarget } from "../config.js"; import { - FINGERPRINT_FILENAME, - loadFingerprint, - parseFingerprint, -} from "../fingerprint/index.js"; -import type { Fingerprint, Target } from "../types.js"; + EXPRESSION_FILENAME, + loadExpression, + parseExpression, +} from "../expression/index.js"; +import type { Expression, Target } from "../types.js"; /** - * Resolve a Target to an Fingerprint. + * Resolve a Target to an Expression. * - * - "path": reads a local fingerprint.md, or a directory containing one. - * - "url": fetches a remote fingerprint.md - * - "npm": resolves node_modules//fingerprint.md + * - "path": reads a local expression.md, or a directory containing one. + * - "url": fetches a remote expression.md + * - "npm": resolves node_modules//expression.md * - "github": not yet supported for direct resolution (use profile flow instead) */ -export async function resolveParent( +export async function resolveTrackedExpression( target: Target, cwd: string = process.cwd(), -): Promise { +): Promise { switch (target.type) { case "path": { const resolved = resolve(cwd, target.value); if (resolved.endsWith(".md")) { - return readFingerprintFile(resolved); + return readExpressionFile(resolved); } return readExpressionFromDir(resolved); } @@ -33,10 +33,10 @@ export async function resolveParent( const response = await fetch(target.value); if (!response.ok) { throw new Error( - `Failed to fetch parent fingerprint from ${target.value}: ${response.status}`, + `Failed to fetch tracked expression from ${target.value}: ${response.status}`, ); } - return parseFingerprint(await response.text()).fingerprint; + return parseExpression(await response.text()).expression; } case "npm": { @@ -45,30 +45,30 @@ export async function resolveParent( default: throw new Error( - `Cannot resolve parent fingerprint from target type "${target.type}". Generate one first by running the profile recipe in your host agent (install with "ghost-drift emit skill").`, + `Cannot resolve tracked expression from target type "${target.type}". Generate one first by running the profile recipe in your host agent (install with "ghost-drift emit skill").`, ); } } -async function readFingerprintFile(path: string): Promise { +async function readExpressionFile(path: string): Promise { try { - return (await loadFingerprint(path)).fingerprint; + return (await loadExpression(path)).expression; } catch (err) { throw new Error( - `Could not read fingerprint at ${path}: ${err instanceof Error ? err.message : String(err)}`, + `Could not read expression at ${path}: ${err instanceof Error ? err.message : String(err)}`, ); } } -async function readExpressionFromDir(dir: string): Promise { - return readFingerprintFile(resolve(dir, FINGERPRINT_FILENAME)); +async function readExpressionFromDir(dir: string): Promise { + return readExpressionFile(resolve(dir, EXPRESSION_FILENAME)); } /** - * Normalize a config parent value to a Target. + * Normalize a config tracks value to a Target. * Accepts a Target directly, or a string shorthand resolved via resolveTarget(). */ -export function normalizeParentSource( +export function normalizeTrackedSource( value: Target | string | undefined, ): Target | undefined { if (!value) return undefined; diff --git a/packages/ghost-drift/src/core/evolution/vector.ts b/packages/ghost-drift/src/core/evolution/vector.ts index f384074..1705927 100644 --- a/packages/ghost-drift/src/core/evolution/vector.ts +++ b/packages/ghost-drift/src/core/evolution/vector.ts @@ -1,4 +1,4 @@ -import type { DriftVector, Fingerprint } from "../types.js"; +import type { DriftVector, Expression } from "../types.js"; /** * Embedding dimension ranges per design dimension. @@ -12,13 +12,13 @@ export const DIMENSION_RANGES: Record = { }; /** - * Compute per-dimension drift vectors from two fingerprints' embeddings. + * Compute per-dimension drift vectors from two expressions' embeddings. * Each vector captures the direction and magnitude of change in embedding space * for a specific design dimension. */ export function computeDriftVectors( - source: Fingerprint, - target: Fingerprint, + source: Expression, + target: Expression, ): DriftVector[] { const vectors: DriftVector[] = []; diff --git a/packages/ghost-drift/src/core/fingerprint/body.ts b/packages/ghost-drift/src/core/expression/body.ts similarity index 97% rename from packages/ghost-drift/src/core/fingerprint/body.ts rename to packages/ghost-drift/src/core/expression/body.ts index eb4a812..d2f6c92 100644 --- a/packages/ghost-drift/src/core/fingerprint/body.ts +++ b/packages/ghost-drift/src/core/expression/body.ts @@ -1,7 +1,7 @@ import type { DesignDecision } from "../types.js"; /** - * Structured read of an fingerprint.md body. The body is authoritative for + * Structured read of an expression.md body. The body is authoritative for * prose — # Character, # Signature, and per-dimension rationale under * # Decisions. Machine-facts (dimension slugs, evidence, tokens) live in * the frontmatter and are joined in by `applyBody` during parse. diff --git a/packages/ghost-drift/src/core/expression/compose.ts b/packages/ghost-drift/src/core/expression/compose.ts new file mode 100644 index 0000000..9c831b4 --- /dev/null +++ b/packages/ghost-drift/src/core/expression/compose.ts @@ -0,0 +1,111 @@ +import type { DesignDecision, Expression } from "../types.js"; + +/** + * Merge an overlay expression on top of a base expression. Precedence rules: + * + * • Scalars / arrays → overlay replaces when present, else base + * • decisions → merged by `dimension` slug; overlay wins per-dim, + * base-only decisions are preserved + * • palette.dominant/semantic → merged by `role`; overlay wins per-role, + * base-only roles preserved + * + * This mirrors the intent of declaring "this expression is based on that one, + * with these specific changes" — untouched base decisions remain, while + * overrides swap in cleanly. + */ +export function mergeExpression( + base: Expression, + overlay: Partial, +): Expression { + const merged: Expression = { + ...base, + ...stripUndefined(overlay), + }; + + if (base.decisions || overlay.decisions) { + merged.decisions = mergeByKey( + base.decisions ?? [], + overlay.decisions ?? [], + (d) => d.dimension, + ); + } + + if (base.roles || overlay.roles) { + merged.roles = mergeByKey( + base.roles ?? [], + overlay.roles ?? [], + (r) => r.name, + ); + } + + if (base.palette || overlay.palette) { + const basePalette = base.palette; + const overlayPalette = overlay.palette; + merged.palette = { + ...(basePalette ?? emptyPalette()), + ...(overlayPalette ?? {}), + dominant: mergeByKey( + basePalette?.dominant ?? [], + overlayPalette?.dominant ?? [], + (c) => c.role, + ), + semantic: mergeByKey( + basePalette?.semantic ?? [], + overlayPalette?.semantic ?? [], + (c) => c.role, + ), + // neutrals / saturationProfile / contrast: overlay replaces if present + neutrals: overlayPalette?.neutrals ?? + basePalette?.neutrals ?? { steps: [], count: 0 }, + saturationProfile: + overlayPalette?.saturationProfile ?? + basePalette?.saturationProfile ?? + "muted", + contrast: overlayPalette?.contrast ?? basePalette?.contrast ?? "moderate", + }; + } + + return merged; +} + +function mergeByKey(base: T[], overlay: T[], key: (item: T) => string): T[] { + const overlayByKey = new Map(overlay.map((item) => [key(item), item])); + const out: T[] = []; + const seen = new Set(); + + // Base order first, with overlay overrides slotted in place + for (const item of base) { + const k = key(item); + seen.add(k); + const override = overlayByKey.get(k); + out.push(override ?? item); + } + // Overlay-only entries appended at the end + for (const item of overlay) { + const k = key(item); + if (!seen.has(k)) out.push(item); + } + return out; +} + +function stripUndefined(obj: T): Partial { + const out: Partial = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) (out as Record)[k] = v; + } + return out; +} + +function emptyPalette(): Expression["palette"] { + return { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "muted", + contrast: "moderate", + }; +} + +// Re-export the decision type so callers writing their own merges don't +// need to reach into ../types. +export type { DesignDecision }; diff --git a/packages/ghost-drift/src/core/fingerprint/diff.ts b/packages/ghost-drift/src/core/expression/diff.ts similarity index 94% rename from packages/ghost-drift/src/core/fingerprint/diff.ts rename to packages/ghost-drift/src/core/expression/diff.ts index f10efc0..451a98a 100644 --- a/packages/ghost-drift/src/core/fingerprint/diff.ts +++ b/packages/ghost-drift/src/core/expression/diff.ts @@ -1,4 +1,4 @@ -import type { DesignDecision, Fingerprint } from "../types.js"; +import type { DesignDecision, Expression } from "../types.js"; export interface DecisionChange { dimension: string; @@ -41,13 +41,13 @@ export interface SemanticDiff { } /** - * Produce a semantic diff between two fingerprints — decisions added/ + * Produce a semantic diff between two expressions — decisions added/ * removed/modified (matched by dimension slug), palette role swaps, and * token-scale changes. This is *not* a vector distance calculation (see - * compareFingerprints for that) — it's the qualitative "what changed in + * compareExpressions for that) — it's the qualitative "what changed in * meaning" that shows up in PR reviews. */ -export function diffFingerprints(a: Fingerprint, b: Fingerprint): SemanticDiff { +export function diffExpressions(a: Expression, b: Expression): SemanticDiff { const decisions = diffDecisions(a.decisions ?? [], b.decisions ?? []); const palette = diffPalette(a, b); const tokens = diffTokens(a, b); @@ -108,7 +108,7 @@ function diffDecisions( return { added, removed, modified }; } -function diffPalette(a: Fingerprint, b: Fingerprint): SemanticDiff["palette"] { +function diffPalette(a: Expression, b: Expression): SemanticDiff["palette"] { const fromDominant = byRole(a.palette?.dominant ?? []); const toDominant = byRole(b.palette?.dominant ?? []); const fromSemantic = byRole(a.palette?.semantic ?? []); @@ -156,7 +156,7 @@ function changedColors( return out; } -function diffTokens(a: Fingerprint, b: Fingerprint): TokenChange[] { +function diffTokens(a: Expression, b: Expression): TokenChange[] { const out: TokenChange[] = []; const pairs: Array<[string, unknown, unknown]> = [ ["spacing.scale", a.spacing?.scale, b.spacing?.scale], diff --git a/packages/ghost-drift/src/core/fingerprint/fragments.ts b/packages/ghost-drift/src/core/expression/fragments.ts similarity index 83% rename from packages/ghost-drift/src/core/fingerprint/fragments.ts rename to packages/ghost-drift/src/core/expression/fragments.ts index da3c661..9bdc6fe 100644 --- a/packages/ghost-drift/src/core/fingerprint/fragments.ts +++ b/packages/ghost-drift/src/core/expression/fragments.ts @@ -12,7 +12,7 @@ import type { DesignDecision } from "../types.js"; import { splitRaw } from "./parser.js"; /** - * If a `decisions/` directory exists next to the fingerprint.md, each + * If a `decisions/` directory exists next to the expression.md, each * .md file inside is read as a single DesignDecision: * * --- @@ -22,13 +22,13 @@ import { splitRaw } from "./parser.js"; * No cool blue-grays anywhere. Every gray carries a warm undertone. * * The file's markdown body becomes the decision's prose. The assembled - * decisions are then merged into the main fingerprint's `decisions` via + * decisions are then merged into the main expression's `decisions` via * the same by-dimension rules as extends composition. */ export async function loadDecisionFragments( - fingerprintDir: string, + expressionDir: string, ): Promise { - const fragDir = join(fingerprintDir, "decisions"); + const fragDir = join(expressionDir, "decisions"); let stats: Awaited>; try { stats = await stat(fragDir); @@ -79,23 +79,23 @@ function parseFragment( } /** - * Canonical filename for the embedding fragment sibling of fingerprint.md. - * Holds the 49-dim vector as YAML so the root fingerprint.md stays lean. + * Canonical filename for the embedding fragment sibling of expression.md. + * Holds the 49-dim vector as YAML so the root expression.md stays lean. */ export const EMBEDDING_FRAGMENT_FILENAME = "embedding.md"; /** * Serialize an embedding vector to a fragment file. The file carries only - * a `vector:` array — no prose body. `of:` ties it back to the parent - * fingerprint id so the link isn't ambiguous. + * a `vector:` array — no prose body. `of:` ties it back to the expression + * expression id so the link isn't ambiguous. */ export function serializeEmbeddingFragment( embedding: number[], - fingerprintId: string, + expressionId: string, ): string { const lines: string[] = ["---"]; lines.push("kind: embedding"); - lines.push(`of: ${fingerprintId}`); + lines.push(`of: ${expressionId}`); lines.push(`dimensions: ${embedding.length}`); lines.push("vector:"); for (const v of embedding) { @@ -111,14 +111,14 @@ export function serializeEmbeddingFragment( * isn't all numbers. */ export async function loadEmbeddingFragment( - fingerprintDir: string, + expressionDir: string, referencedPath?: string, ): Promise { const candidate = referencedPath ? isAbsolute(referencedPath) ? referencedPath - : resolve(fingerprintDir, referencedPath) - : join(fingerprintDir, EMBEDDING_FRAGMENT_FILENAME); + : resolve(expressionDir, referencedPath) + : join(expressionDir, EMBEDDING_FRAGMENT_FILENAME); let raw: string; try { @@ -142,7 +142,7 @@ export async function loadEmbeddingFragment( } /** - * Scan an fingerprint body for markdown fragment links `[label](path)`. + * Scan an expression body for markdown fragment links `[label](path)`. * Returns relative paths (no resolution). Used to progressively discover * which fragment files the author has chosen to attach — mirrors the * agent-skills pattern of references-as-body-links. @@ -171,7 +171,7 @@ export function findFragmentLinks(body: string): string[] { */ export function resolveEmbeddingReference( body: string, - fingerprintDir: string, + expressionDir: string, ): string | null { const links = findFragmentLinks(body); const match = links.find( @@ -180,13 +180,13 @@ export function resolveEmbeddingReference( p === EMBEDDING_FRAGMENT_FILENAME, ); if (match) { - return isAbsolute(match) ? match : resolve(fingerprintDir, match); + return isAbsolute(match) ? match : resolve(expressionDir, match); } // No body link — conventional sibling. - return join(fingerprintDir, EMBEDDING_FRAGMENT_FILENAME); + return join(expressionDir, EMBEDDING_FRAGMENT_FILENAME); } /** Directory-relative utility for writers that emit a fragment next to a target file. */ -export function embeddingSiblingPath(fingerprintPath: string): string { - return join(dirname(fingerprintPath), EMBEDDING_FRAGMENT_FILENAME); +export function embeddingSiblingPath(expressionPath: string): string { + return join(dirname(expressionPath), EMBEDDING_FRAGMENT_FILENAME); } diff --git a/packages/ghost-drift/src/core/fingerprint/frontmatter.ts b/packages/ghost-drift/src/core/expression/frontmatter.ts similarity index 72% rename from packages/ghost-drift/src/core/fingerprint/frontmatter.ts rename to packages/ghost-drift/src/core/expression/frontmatter.ts index 4c1789b..cdfbb73 100644 --- a/packages/ghost-drift/src/core/fingerprint/frontmatter.ts +++ b/packages/ghost-drift/src/core/expression/frontmatter.ts @@ -1,15 +1,15 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; /** - * Fingerprint-level metadata — lives in the frontmatter alongside the - * machine-layer of Fingerprint but is not part of the structured content. + * Expression-level metadata — lives in the frontmatter alongside the + * machine-layer of Expression but is not part of the structured content. */ -export interface FingerprintMeta { +export interface ExpressionMeta { name?: string; slug?: string; generator?: string; confidence?: number; - /** Path to a parent fingerprint.md to inherit from. Resolved by loadFingerprint. */ + /** Path to a base expression.md to inherit from. Resolved by loadExpression. */ extends?: string; /** * Loose passthrough bag for LLM-authored extensions that don't fit the @@ -20,17 +20,17 @@ export interface FingerprintMeta { } export interface FrontmatterData { - meta: FingerprintMeta; - fingerprint: Fingerprint; + meta: ExpressionMeta; + expression: Expression; } /** - * Fingerprint fields that are populated from YAML frontmatter. Prose + * Expression fields that are populated from YAML frontmatter. Prose * fields (observation.summary, observation.distinctiveTraits, decisions[].decision, * values) are populated from the markdown body by `applyBody` — they are * deliberately NOT listed here. */ -const FINGERPRINT_KEYS = new Set([ +const EXPRESSION_KEYS = new Set([ "id", "source", "timestamp", @@ -46,17 +46,17 @@ const FINGERPRINT_KEYS = new Set([ ]); /** - * Split a frontmatter object into the Fingerprint proper - * and fingerprint-level metadata (name, slug, etc.). + * Split a frontmatter object into the Expression proper + * and expression-level metadata (name, slug, etc.). */ export function splitFrontmatter( raw: Record, ): FrontmatterData { - const meta: FingerprintMeta = {}; + const meta: ExpressionMeta = {}; const fp: Record = {}; for (const [k, v] of Object.entries(raw)) { - if (FINGERPRINT_KEYS.has(k as keyof Fingerprint)) { + if (EXPRESSION_KEYS.has(k as keyof Expression)) { fp[k] = v; } else if ( k === "name" || @@ -82,18 +82,18 @@ export function splitFrontmatter( return { meta, - fingerprint: fp as unknown as Fingerprint, + expression: fp as unknown as Expression, }; } /** - * Build a plain object for YAML serialization from an fingerprint + meta. - * Meta comes first for readability; then fingerprint fields, with prose + * Build a plain object for YAML serialization from an expression + meta. + * Meta comes first for readability; then expression fields, with prose * fields stripped — those belong in the markdown body. */ export function mergeFrontmatter( - fingerprint: Fingerprint, - meta: FingerprintMeta = {}, + expression: Expression, + meta: ExpressionMeta = {}, ): Record { const out: Record = {}; if (meta.name) out.name = meta.name; @@ -104,7 +104,7 @@ export function mergeFrontmatter( out.metadata = meta.metadata; } - const ordered: (keyof Fingerprint)[] = [ + const ordered: (keyof Expression)[] = [ "id", "source", "timestamp", @@ -119,13 +119,13 @@ export function mergeFrontmatter( "embedding", ]; for (const key of ordered) { - const v = fingerprint[key]; + const v = expression[key]; if (v === undefined) continue; if (key === "observation") { - const stripped = stripObservationProse(v as Fingerprint["observation"]); + const stripped = stripObservationProse(v as Expression["observation"]); if (stripped) out.observation = stripped; } else if (key === "decisions") { - const stripped = stripDecisionProse(v as Fingerprint["decisions"]); + const stripped = stripDecisionProse(v as Expression["decisions"]); if (stripped?.length) out.decisions = stripped; } else { out[key] = v; @@ -135,12 +135,12 @@ export function mergeFrontmatter( } function stripObservationProse( - obs: Fingerprint["observation"], + obs: Expression["observation"], ): Record | undefined { if (!obs) return undefined; const out: Record = {}; if (obs.personality?.length) out.personality = obs.personality; - if (obs.closestSystems?.length) out.closestSystems = obs.closestSystems; + if (obs.resembles?.length) out.resembles = obs.resembles; return Object.keys(out).length ? out : undefined; } @@ -149,7 +149,7 @@ function stripObservationProse( * only. Prose rationale and evidence bullets both live in the body. */ function stripDecisionProse( - decisions: Fingerprint["decisions"], + decisions: Expression["decisions"], ): Array> | undefined { if (!decisions?.length) return undefined; return decisions.map((d) => { diff --git a/packages/ghost-drift/src/core/fingerprint/index.ts b/packages/ghost-drift/src/core/expression/index.ts similarity index 57% rename from packages/ghost-drift/src/core/fingerprint/index.ts rename to packages/ghost-drift/src/core/expression/index.ts index da24366..b7b8bfe 100644 --- a/packages/ghost-drift/src/core/fingerprint/index.ts +++ b/packages/ghost-drift/src/core/expression/index.ts @@ -1,21 +1,21 @@ import { readFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { computeEmbedding } from "../embedding/embedding.js"; -import type { DesignDecision, Fingerprint } from "../types.js"; -import { mergeFingerprint } from "./compose.js"; +import type { DesignDecision, Expression } from "../types.js"; +import { mergeExpression } from "./compose.js"; import { loadDecisionFragments, loadEmbeddingFragment, resolveEmbeddingReference, } from "./fragments.js"; import { mergeFrontmatter } from "./frontmatter.js"; -import { type ParsedFingerprint, parseFingerprint } from "./parser.js"; +import { type ParsedExpression, parseExpression } from "./parser.js"; import { validateFrontmatter } from "./schema.js"; function assertMarkdownPath(path: string): void { if (!path.endsWith(".md")) { throw new Error( - `Fingerprint files must be Markdown (.md). Got: ${path}. The legacy JSON format has been removed — regenerate by running the profile recipe in your host agent (install with \`ghost-drift emit skill\`).`, + `Expression files must be Markdown (.md). Got: ${path}. The legacy JSON format has been removed — regenerate by running the profile recipe in your host agent (install with \`ghost-drift emit skill\`).`, ); } } @@ -23,14 +23,14 @@ function assertMarkdownPath(path: string): void { export type { BodyData } from "./body.js"; export { parseBody } from "./body.js"; export type { DesignDecision } from "./compose.js"; -export { mergeFingerprint } from "./compose.js"; +export { mergeExpression } from "./compose.js"; export type { ColorChange, DecisionChange, SemanticDiff, TokenChange, } from "./diff.js"; -export { diffFingerprints, formatSemanticDiff } from "./diff.js"; +export { diffExpressions, formatSemanticDiff } from "./diff.js"; export { EMBEDDING_FRAGMENT_FILENAME, embeddingSiblingPath, @@ -40,21 +40,21 @@ export { resolveEmbeddingReference, serializeEmbeddingFragment, } from "./fragments.js"; -export type { FingerprintMeta, FrontmatterData } from "./frontmatter.js"; +export type { ExpressionMeta, FrontmatterData } from "./frontmatter.js"; export type { - FingerprintLayout, - FingerprintLayoutSection, + ExpressionLayout, + ExpressionLayoutSection, } from "./layout.js"; -export { formatLayout, layoutFingerprint } from "./layout.js"; +export { formatLayout, layoutExpression } from "./layout.js"; export type { LintIssue, LintOptions, LintReport, LintSeverity, } from "./lint.js"; -export { lintFingerprint } from "./lint.js"; -export type { ParsedFingerprint, ParseOptions } from "./parser.js"; -export { parseFingerprint, splitRaw } from "./parser.js"; +export { lintExpression } from "./lint.js"; +export type { ParsedExpression, ParseOptions } from "./parser.js"; +export { parseExpression, splitRaw } from "./parser.js"; export type { FrontmatterShape } from "./schema.js"; export { FrontmatterSchema, @@ -62,10 +62,10 @@ export { validateFrontmatter, } from "./schema.js"; export type { SerializeOptions } from "./writer.js"; -export { serializeFingerprint } from "./writer.js"; +export { serializeExpression } from "./writer.js"; -/** Canonical filename for the emitted fingerprint. */ -export const FINGERPRINT_FILENAME = "fingerprint.md"; +/** Canonical filename for the emitted expression. */ +export const EXPRESSION_FILENAME = "expression.md"; export interface LoadOptions { /** Skip `extends:` resolution. Default: false (extends chains are resolved). */ @@ -81,20 +81,20 @@ export interface LoadOptions { } /** - * Load a ParsedFingerprint from disk. + * Load a ParsedExpression from disk. * - * If the file declares `extends:`, the parent is loaded recursively and - * merged per the rules in compose.ts: child wins, decisions merged by + * If the file declares `extends:`, the base expression is loaded recursively and + * merged per the rules in compose.ts: overlay wins, decisions merged by * dimension, palette roles merged by role. * - * If a `decisions/` directory sits next to the fingerprint.md, each .md - * inside is assembled into the fingerprint's decisions[], merged by + * If a `decisions/` directory sits next to the expression.md, each .md + * inside is assembled into the expression's decisions[], merged by * dimension — allowing large systems to split their rules across files. */ -export async function loadFingerprint( +export async function loadExpression( path: string, options: LoadOptions = {}, -): Promise { +): Promise { assertMarkdownPath(path); const parsed = options.noExtends @@ -102,22 +102,22 @@ export async function loadFingerprint( : await loadWithExtends(path, new Set()); const absolute = isAbsolute(path) ? path : resolve(path); - const fingerprintDir = dirname(absolute); + const expressionDir = dirname(absolute); if (!options.noFragments) { - const fragments = await loadDecisionFragments(fingerprintDir); + const fragments = await loadDecisionFragments(expressionDir); if (fragments.length) { - parsed.fingerprint.decisions = mergeDecisionsByDimension( - parsed.fingerprint.decisions ?? [], + parsed.expression.decisions = mergeDecisionsByDimension( + parsed.expression.decisions ?? [], fragments, ); } } if (!options.noEmbeddingBackfill) { - parsed.fingerprint.embedding = await resolveEmbedding( - parsed.fingerprint, - fingerprintDir, + parsed.expression.embedding = await resolveEmbedding( + parsed.expression, + expressionDir, parsed.bodyRaw, ); } @@ -126,10 +126,10 @@ export async function loadFingerprint( } /** - * Resolve the embedding for an fingerprint.md in order: + * Resolve the embedding for an expression.md in order: * 1. Inline `embedding:` in frontmatter (trust as cache). * 2. Explicit body link to `embedding.md` (fragment file). - * 3. Conventional sibling `embedding.md` next to fingerprint.md. + * 3. Conventional sibling `embedding.md` next to expression.md. * 4. Recompute from the structured blocks. * * This matches the agent-skills progressive-disclosure model — the thin @@ -137,34 +137,31 @@ export async function loadFingerprint( * be rebuilt any time from source-of-truth data. */ async function resolveEmbedding( - fingerprint: Fingerprint, - fingerprintDir: string, + expression: Expression, + expressionDir: string, bodyRaw: string | undefined, ): Promise { - if (fingerprint.embedding && fingerprint.embedding.length > 0) { - return fingerprint.embedding; + if (expression.embedding && expression.embedding.length > 0) { + return expression.embedding; } const referenced = bodyRaw - ? resolveEmbeddingReference(bodyRaw, fingerprintDir) + ? resolveEmbeddingReference(bodyRaw, expressionDir) : null; if (referenced) { - const fromFragment = await loadEmbeddingFragment( - fingerprintDir, - referenced, - ); + const fromFragment = await loadEmbeddingFragment(expressionDir, referenced); if (fromFragment) return fromFragment; } // Only attempt to recompute when the structured blocks are all present. - // Partial fingerprints (e.g. an extends-child loaded with noExtends:true) + // Partial expressions (e.g. an extends overlay loaded with noExtends:true) // don't have enough signal yet — leave the embedding empty and let the // caller resolve it after composing. if ( - fingerprint.palette && - fingerprint.spacing && - fingerprint.typography && - fingerprint.surfaces + expression.palette && + expression.spacing && + expression.typography && + expression.surfaces ) { - return computeEmbedding(fingerprint); + return computeEmbedding(expression); } return []; } @@ -186,16 +183,16 @@ function mergeDecisionsByDimension( return out; } -async function loadRaw(path: string): Promise { +async function loadRaw(path: string): Promise { assertMarkdownPath(path); const raw = await readFile(path, "utf-8"); - return parseFingerprint(raw); + return parseExpression(raw); } async function loadWithExtends( path: string, visited: Set, -): Promise { +): Promise { assertMarkdownPath(path); const absolute = isAbsolute(path) ? path : resolve(path); if (visited.has(absolute)) { @@ -206,27 +203,27 @@ async function loadWithExtends( visited.add(absolute); const raw = await readFile(absolute, "utf-8"); - const child = parseFingerprint(raw); - if (!child.meta.extends) { - return child; + const overlay = parseExpression(raw); + if (!overlay.meta.extends) { + return overlay; } - const parentPath = resolve(dirname(absolute), child.meta.extends); - const parent = await loadWithExtends(parentPath, visited); + const basePath = resolve(dirname(absolute), overlay.meta.extends); + const base = await loadWithExtends(basePath, visited); - const merged = mergeFingerprint(parent.fingerprint, child.fingerprint); + const merged = mergeExpression(base.expression, overlay.expression); // The merged result must satisfy the strict YAML schema. The in-memory - // fingerprint may carry body-owned prose (summary, decision rationale, + // expression may carry body-owned prose (summary, decision rationale, // values) that the schema forbids — strip it via mergeFrontmatter before // validating. validateFrontmatter(mergeFrontmatter(merged)); - // Meta merge: child wins on everything except extends (dropped after resolve) - const { extends: _dropped, ...childMeta } = child.meta; + // Meta merge: overlay wins on everything except extends (dropped after resolve) + const { extends: _dropped, ...overlayMeta } = overlay.meta; return { - fingerprint: merged, - meta: { ...parent.meta, ...childMeta }, - body: child.body, - bodyRaw: child.bodyRaw, + expression: merged, + meta: { ...base.meta, ...overlayMeta }, + body: overlay.body, + bodyRaw: overlay.bodyRaw, }; } diff --git a/packages/ghost-drift/src/core/fingerprint/layout.ts b/packages/ghost-drift/src/core/expression/layout.ts similarity index 92% rename from packages/ghost-drift/src/core/fingerprint/layout.ts rename to packages/ghost-drift/src/core/expression/layout.ts index ae9801e..c2cb6b1 100644 --- a/packages/ghost-drift/src/core/fingerprint/layout.ts +++ b/packages/ghost-drift/src/core/expression/layout.ts @@ -1,14 +1,14 @@ import { parse as parseYaml } from "yaml"; /** - * A single addressable region of a fingerprint.md file. `start`/`end` are + * A single addressable region of an expression.md file. `start`/`end` are * 1-indexed line numbers (inclusive), chosen so they plug directly into * the Read tool's `offset`/`limit` pair (`limit = end - start + 1`). * * `tokens` is a char/4 approximation — cheap, stable, and sufficient for * an agent to budget context before loading a section. */ -export interface FingerprintLayoutSection { +export interface ExpressionLayoutSection { kind: "frontmatter" | "body" | "decision"; /** For body sections, the H1 heading text. For decisions, the H3 text. */ heading?: string; @@ -21,25 +21,25 @@ export interface FingerprintLayoutSection { tokens: number; } -export interface FingerprintLayout { +export interface ExpressionLayout { lines: number; tokens: number; - sections: FingerprintLayoutSection[]; + sections: ExpressionLayoutSection[]; } /** - * Produce a section map of a raw fingerprint.md string. The map is the + * Produce a section map of a raw expression.md string. The map is the * structural index an agent can use to selectively read only the parts * it needs — frontmatter alone, a single `### dimension` decision block, * etc. — without loading the whole file. * * The scan is line-oriented and deliberately tolerant: a malformed or - * partial fingerprint still produces a usable layout. Validation belongs + * partial expression still produces a usable layout. Validation belongs * to `lint`, not here. */ -export function layoutFingerprint(raw: string): FingerprintLayout { +export function layoutExpression(raw: string): ExpressionLayout { const lines = raw.split(/\r?\n/); - const sections: FingerprintLayoutSection[] = []; + const sections: ExpressionLayoutSection[] = []; const frontmatter = scanFrontmatter(lines); const bodyStart = frontmatter ? frontmatter.end + 1 : 1; @@ -173,7 +173,7 @@ function scanHeadings( out.push({ lineNumber: i + 1, level, text: m[2].trim() }); } else if (m[1].length < level) { // A shallower heading ends the region when scanning nested headings - // inside a bounded parent. + // inside a bounded section. if (endLine !== lines.length) break; } } @@ -222,7 +222,7 @@ function slug(s: string): string { * default output an agent streams into its context when it wants to * decide which sections to load. */ -export function formatLayout(layout: FingerprintLayout, path?: string): string { +export function formatLayout(layout: ExpressionLayout, path?: string): string { const header = `${path ? `${path} — ` : ""}${layout.lines} lines, ~${layout.tokens.toLocaleString()} tokens`; const rows: string[] = [header, ""]; for (const s of layout.sections) { @@ -231,7 +231,7 @@ export function formatLayout(layout: FingerprintLayout, path?: string): string { return rows.join("\n"); } -function formatRow(s: FingerprintLayoutSection): string { +function formatRow(s: ExpressionLayoutSection): string { const range = `${s.start}–${s.end}`; const tok = `~${s.tokens.toLocaleString()} tok`; if (s.kind === "frontmatter") { diff --git a/packages/ghost-drift/src/core/fingerprint/lint.ts b/packages/ghost-drift/src/core/expression/lint.ts similarity index 88% rename from packages/ghost-drift/src/core/fingerprint/lint.ts rename to packages/ghost-drift/src/core/expression/lint.ts index 8cc2cba..328d79c 100644 --- a/packages/ghost-drift/src/core/fingerprint/lint.ts +++ b/packages/ghost-drift/src/core/expression/lint.ts @@ -1,7 +1,7 @@ import { parse as parseYaml } from "yaml"; -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; import type { BodyData } from "./body.js"; -import { parseFingerprint, splitRaw } from "./parser.js"; +import { parseExpression, splitRaw } from "./parser.js"; import { formatReferenceError, isTokenReference, @@ -34,8 +34,8 @@ export interface LintOptions { } /** - * Lint an fingerprint.md string for schema correctness and partition - * violations. Unlike parseFingerprint, this never throws — every problem + * Lint an expression.md string for schema correctness and partition + * violations. Unlike parseExpression, this never throws — every problem * surfaces as a structured issue. * * Under schema 3 the body/frontmatter partition is enforced by zod-strict. @@ -43,7 +43,7 @@ export interface LintOptions { * entry), missing rationale (frontmatter entry with no body block), * legacy `**Evidence:**` bullets in the body, and broken palette citations. */ -export function lintFingerprint( +export function lintExpression( raw: string, options: LintOptions = {}, ): LintReport { @@ -51,9 +51,9 @@ export function lintFingerprint( const strict = new Set(options.strict ?? []); const off = new Set(options.off ?? []); - let parsed: ReturnType | null = null; + let parsed: ReturnType | null = null; try { - parsed = parseFingerprint(raw, { skipValidation: true }); + parsed = parseExpression(raw, { skipValidation: true }); } catch (err) { rawIssues.push({ severity: "error", @@ -63,16 +63,16 @@ export function lintFingerprint( return finalize(rawIssues, strict, off); } - const { fingerprint, body } = parsed; + const { expression, body } = parsed; const rawYaml = toRawFrontmatter(raw); const { body: bodyText } = splitRawSafe(raw); checkSchemaValidity(rawYaml, rawIssues); - checkDecisionPartition(fingerprint, body, rawIssues); + checkDecisionPartition(expression, body, rawIssues); checkStrayEvidenceInBody(bodyText, rawIssues); - checkEvidenceHexes(fingerprint, rawIssues); - checkUnusedPalette(fingerprint, rawIssues); - checkRoleReferences(fingerprint, rawIssues); + checkEvidenceHexes(expression, rawIssues); + checkUnusedPalette(expression, rawIssues); + checkRoleReferences(expression, rawIssues); return finalize(rawIssues, strict, off); } @@ -136,7 +136,7 @@ function checkSchemaValidity( * when a body block has no rationale at all. */ function checkDecisionPartition( - fp: Fingerprint, + fp: Expression, body: BodyData, issues: LintIssue[], ): void { @@ -174,7 +174,7 @@ function checkStrayEvidenceInBody( const HEX_RE = /#[0-9a-f]{3,8}\b/gi; -function checkEvidenceHexes(fp: Fingerprint, issues: LintIssue[]): void { +function checkEvidenceHexes(fp: Expression, issues: LintIssue[]): void { const paletteHexes = collectPaletteHexes(fp); if (paletteHexes.size === 0) return; @@ -197,7 +197,7 @@ function checkEvidenceHexes(fp: Fingerprint, issues: LintIssue[]): void { }); } -function checkUnusedPalette(fp: Fingerprint, issues: LintIssue[]): void { +function checkUnusedPalette(fp: Expression, issues: LintIssue[]): void { const paletteHexes = collectPaletteHexes(fp); if (paletteHexes.size === 0) return; @@ -222,7 +222,7 @@ function checkUnusedPalette(fp: Fingerprint, issues: LintIssue[]): void { } } -function collectPaletteHexes(fp: Fingerprint): Set { +function collectPaletteHexes(fp: Expression): Set { const out = new Set(); for (const c of fp.palette?.dominant ?? []) out.add(c.value.toLowerCase()); for (const c of fp.palette?.semantic ?? []) out.add(c.value.toLowerCase()); @@ -238,7 +238,7 @@ function collectPaletteHexes(fp: Fingerprint): Set { */ const ROLE_PALETTE_FIELDS = ["background", "foreground", "border"] as const; -function checkRoleReferences(fp: Fingerprint, issues: LintIssue[]): void { +function checkRoleReferences(fp: Expression, issues: LintIssue[]): void { const roles = fp.roles ?? []; roles.forEach((role, ri) => { const palette = role.tokens?.palette; diff --git a/packages/ghost-drift/src/core/fingerprint/parser.ts b/packages/ghost-drift/src/core/expression/parser.ts similarity index 77% rename from packages/ghost-drift/src/core/fingerprint/parser.ts rename to packages/ghost-drift/src/core/expression/parser.ts index 931fe2c..91ac5e0 100644 --- a/packages/ghost-drift/src/core/fingerprint/parser.ts +++ b/packages/ghost-drift/src/core/expression/parser.ts @@ -2,15 +2,15 @@ import { parse as parseYaml } from "yaml"; import type { DesignDecision, DesignObservation, - Fingerprint, + Expression, } from "../types.js"; import { type BodyData, parseBody } from "./body.js"; -import { type FingerprintMeta, splitFrontmatter } from "./frontmatter.js"; +import { type ExpressionMeta, splitFrontmatter } from "./frontmatter.js"; import { validateFrontmatter } from "./schema.js"; -export interface ParsedFingerprint { - fingerprint: Fingerprint; - meta: FingerprintMeta; +export interface ParsedExpression { + expression: Expression; + meta: ExpressionMeta; /** * Structured view of the body as it was read from disk. Kept for lint * tooling that wants to check orphan prose or missing rationale against @@ -28,13 +28,13 @@ export interface ParsedFingerprint { export interface ParseOptions { /** * Skip zod validation of the frontmatter. Only useful for tools that want - * to read partial or in-progress fingerprint files (e.g. lint). Default: false. + * to read partial or in-progress expression files (e.g. lint). Default: false. */ skipValidation?: boolean; } /** - * Split a raw fingerprint.md string into its YAML frontmatter and markdown body. + * Split a raw expression.md string into its YAML frontmatter and markdown body. * * A frontmatter block is delimited by two lines that are *exactly* `---` * (trailing whitespace tolerated). The opening delimiter must be the first @@ -47,12 +47,12 @@ export interface ParseOptions { export function splitRaw(raw: string): { frontmatter: string; body: string } { const lines = raw.split(/\r?\n/); let i = 0; - // Skip leading blank lines so an fingerprint.md with a BOM / stray newline + // Skip leading blank lines so an expression.md with a BOM / stray newline // before `---` still parses. while (i < lines.length && lines[i].trim() === "") i++; if (i >= lines.length || !isDelimiter(lines[i])) { throw new Error( - "Fingerprint is missing a YAML frontmatter block (--- … ---).", + "Expression is missing a YAML frontmatter block (--- … ---).", ); } const startOfYaml = i + 1; @@ -65,7 +65,7 @@ export function splitRaw(raw: string): { frontmatter: string; body: string } { } if (endOfYaml === -1) { throw new Error( - "Fingerprint frontmatter is unterminated — missing closing `---`.", + "Expression frontmatter is unterminated — missing closing `---`.", ); } const frontmatter = lines.slice(startOfYaml, endOfYaml).join("\n"); @@ -78,53 +78,53 @@ function isDelimiter(line: string): boolean { } /** - * Parse a raw fingerprint.md string into an Fingerprint plus metadata and + * Parse a raw expression.md string into an Expression plus metadata and * structured body. * * Contract: frontmatter and body own disjoint fields. * • Frontmatter owns machine-facts: id, tokens, dimension slugs, evidence, - * personality/closestSystems tags, embedding. + * personality/resembles tags, embedding. * • Body owns prose: `# Character` → summary, `# Signature` → distinctive * traits, `### dimension` → decision rationale. * - * The returned fingerprint unions both sources. Since the two sides never + * The returned expression unions both sources. Since the two sides never * carry the same field, there is no precedence rule — each field has one * home. * * Parse-time check (unless `skipValidation`): zod validation — throws a * readable error listing bad fields. */ -export function parseFingerprint( +export function parseExpression( raw: string, options: ParseOptions = {}, -): ParsedFingerprint { +): ParsedExpression { const { frontmatter, body: bodyText } = splitRaw(raw); const yamlObj = (parseYaml(frontmatter) ?? {}) as Record; if (!options.skipValidation) { - // Files that extend a parent may omit fields they inherit. Final - // validation happens after extends resolution (see loadFingerprint). + // Files that extend a base expression may omit fields they inherit. Final + // validation happens after extends resolution (see loadExpression). const partial = typeof yamlObj.extends === "string"; validateFrontmatter(yamlObj, { partial }); } - const { meta, fingerprint } = splitFrontmatter(yamlObj); + const { meta, expression } = splitFrontmatter(yamlObj); const body = parseBody(bodyText); - const merged = applyBody(fingerprint, body); - return { fingerprint: merged, meta, body, bodyRaw: bodyText }; + const merged = applyBody(expression, body); + return { expression: merged, meta, body, bodyRaw: bodyText }; } /** - * Fold body-owned prose fields into the fingerprint. The body provides + * Fold body-owned prose fields into the expression. The body provides * Character/Signature prose for `observation` and rationale for `decisions` * (keyed by dimension). Frontmatter-only dimensions keep their evidence * but get no body prose (decision text left empty). */ -export function applyBody(fp: Fingerprint, body: BodyData): Fingerprint { +export function applyBody(fp: Expression, body: BodyData): Expression { const observation = mergeObservation(fp.observation, body); const decisions = mergeDecisions(fp.decisions, body.decisions ?? []); - const out: Fingerprint = { ...fp }; + const out: Expression = { ...fp }; if (observation) out.observation = observation; else delete out.observation; if (decisions?.length) out.decisions = decisions; @@ -139,16 +139,16 @@ function mergeObservation( const summary = body.character?.trim() ?? ""; const distinctiveTraits = body.signature ?? []; const personality = yamlObs?.personality ?? []; - const closestSystems = yamlObs?.closestSystems ?? []; + const resembles = yamlObs?.resembles ?? []; if ( !summary && distinctiveTraits.length === 0 && personality.length === 0 && - closestSystems.length === 0 + resembles.length === 0 ) { return undefined; } - return { summary, personality, distinctiveTraits, closestSystems }; + return { summary, personality, distinctiveTraits, resembles }; } /** diff --git a/packages/ghost-drift/src/core/fingerprint/references.ts b/packages/ghost-drift/src/core/expression/references.ts similarity index 96% rename from packages/ghost-drift/src/core/fingerprint/references.ts rename to packages/ghost-drift/src/core/expression/references.ts index a88958a..2efa075 100644 --- a/packages/ghost-drift/src/core/fingerprint/references.ts +++ b/packages/ghost-drift/src/core/expression/references.ts @@ -1,4 +1,4 @@ -import type { Fingerprint } from "../types.js"; +import type { Expression } from "../types.js"; /** * Role token reference syntax: `{palette.dominant.}` or @@ -57,13 +57,13 @@ export interface ResolveResult { const SUPPORTED_NAMESPACES = ["palette.dominant", "palette.semantic"]; /** - * Resolve a `{...}` reference against a fingerprint. Returns the primitive + * Resolve a `{...}` reference against an expression. Returns the primitive * value (hex string) when resolvable, plus a structured error describing why * resolution failed otherwise. Callers that just want the value can ignore * `error`; the linter uses it to report `broken-role-reference` precisely. */ export function resolveTokenReference( - fp: Fingerprint, + fp: Expression, value: string, ): ResolveResult { const parsed = parseTokenReference(value); @@ -99,7 +99,7 @@ export function resolveTokenReference( } function lookupNamespace( - fp: Fingerprint, + fp: Expression, namespace: string, ): { role: string; value: string }[] | null { switch (namespace) { diff --git a/packages/ghost-drift/src/core/fingerprint/schema.ts b/packages/ghost-drift/src/core/expression/schema.ts similarity index 90% rename from packages/ghost-drift/src/core/fingerprint/schema.ts rename to packages/ghost-drift/src/core/expression/schema.ts index 5a27aad..a41a090 100644 --- a/packages/ghost-drift/src/core/fingerprint/schema.ts +++ b/packages/ghost-drift/src/core/expression/schema.ts @@ -46,7 +46,7 @@ const SurfacesSchema = z.object({ const DesignObservationSchema = z .object({ personality: z.array(z.string()).optional(), - closestSystems: z.array(z.string()).optional(), + resembles: z.array(z.string()).optional(), }) .strict(); @@ -65,7 +65,7 @@ const DesignDecisionSchema = z /** * Semantic slot → token binding. Each role names a slot ("h1", "card", - * "button") and binds tokens from the fingerprint dimensions. Every + * "button") and binds tokens from the expression dimensions. Every * sub-block is optional — a role can be partial when the source only * supplies some tokens. */ @@ -114,8 +114,8 @@ const DesignRoleSchema = z .strict(); /** - * Schema for the YAML frontmatter in an fingerprint.md file. Covers the - * machine-layer of Fingerprint plus fingerprint-level metadata. + * Schema for the YAML frontmatter in an expression.md file. Covers the + * machine-layer of Expression plus expression-level metadata. * * Note: narrative prose fields (observation.summary, distinctiveTraits, * decisions[].decision) are NOT allowed here — they belong in the body. @@ -136,22 +136,22 @@ export const FrontmatterSchema = z generator: z.string().optional(), generated: z.string().optional(), confidence: z.number().optional(), - /** Relative path to a parent fingerprint.md to inherit from. */ + /** Relative path to a base expression.md to inherit from. */ extends: z.string().optional(), /** Loose passthrough bag for LLM-authored extensions. Opaque to readers. */ metadata: z.record(z.string(), z.unknown()).optional(), - // fingerprint — required + // expression — required id: z.string(), source: z.enum(["registry", "extraction", "llm", "unknown"]), timestamp: z.string(), sources: z.array(z.string()).optional(), - // fingerprint — narrative tags (optional; prose lives in body) + // expression — narrative tags (optional; prose lives in body) observation: DesignObservationSchema.optional(), decisions: z.array(DesignDecisionSchema).optional(), - // fingerprint — structured (required) + // expression — structured (required) palette: PaletteSchema, spacing: SpacingSchema, typography: TypographySchema, @@ -175,7 +175,7 @@ export const FrontmatterSchema = z /** * Relaxed schema for files that declare `extends:`. Children may omit any - * fingerprint field they're inheriting from the parent. The merged result + * expression field they're inheriting from the base expression. The merged result * is re-validated against the strict FrontmatterSchema. */ export const PartialFrontmatterSchema = z @@ -210,8 +210,8 @@ export type FrontmatterShape = z.infer; /** * Export the frontmatter schema as a JSON Schema document. * - * Used to (a) publish schemas/fingerprint.schema.json for IDE autocomplete - * in .md files, and (b) back `ghost fingerprint schema` output. + * Used to (a) publish schemas/expression.schema.json for IDE autocomplete + * in .md files, and (b) back `ghost expression schema` output. */ export function toJsonSchema(): Record { return z.toJSONSchema(FrontmatterSchema) as Record; @@ -240,6 +240,6 @@ export function validateFrontmatter( ? `\n … and ${result.error.issues.length - 5} more` : ""; throw new Error( - `Invalid fingerprint frontmatter:\n${issues.join("\n")}${more}`, + `Invalid expression frontmatter:\n${issues.join("\n")}${more}`, ); } diff --git a/packages/ghost-drift/src/core/fingerprint/writer.ts b/packages/ghost-drift/src/core/expression/writer.ts similarity index 85% rename from packages/ghost-drift/src/core/fingerprint/writer.ts rename to packages/ghost-drift/src/core/expression/writer.ts index 63e6466..32a62ae 100644 --- a/packages/ghost-drift/src/core/fingerprint/writer.ts +++ b/packages/ghost-drift/src/core/expression/writer.ts @@ -2,13 +2,13 @@ import { stringify as stringifyYaml } from "yaml"; import type { DesignDecision, DesignObservation, - Fingerprint, + Expression, } from "../types.js"; import { EMBEDDING_FRAGMENT_FILENAME } from "./fragments.js"; -import { type FingerprintMeta, mergeFrontmatter } from "./frontmatter.js"; +import { type ExpressionMeta, mergeFrontmatter } from "./frontmatter.js"; export interface SerializeOptions { - meta?: FingerprintMeta; + meta?: ExpressionMeta; /** Omit the human-readable body (frontmatter-only output). Default: false. */ frontmatterOnly?: boolean; /** @@ -21,25 +21,25 @@ export interface SerializeOptions { } /** - * Serialize a Fingerprint to an fingerprint.md string. + * Serialize an Expression to an expression.md string. * * Contract: frontmatter and body own disjoint fields. * • Frontmatter carries the machine-layer (id, tokens, dimension slugs, - * evidence, personality/closestSystems tags, embedding). + * evidence, personality/resembles tags, embedding). * • Body carries prose (# Character, # Signature, # Decisions rationale). * * Each field has exactly one home — so there is no precedence rule and no * way for the two sides to drift. */ -export function serializeFingerprint( - fingerprint: Fingerprint, +export function serializeExpression( + expression: Expression, options: SerializeOptions = {}, ): string { - const meta: FingerprintMeta = { ...options.meta }; + const meta: ExpressionMeta = { ...options.meta }; const extractEmbedding = options.extractEmbedding ?? true; const forFrontmatter = extractEmbedding - ? stripEmbedding(fingerprint) - : fingerprint; + ? stripEmbedding(expression) + : expression; const obj = mergeFrontmatter(forFrontmatter, meta); const yaml = stringifyYaml(obj, { lineWidth: 0 }).trimEnd(); @@ -48,16 +48,16 @@ export function serializeFingerprint( } const body = buildBody( - fingerprint.observation, - fingerprint.decisions, - extractEmbedding && (fingerprint.embedding?.length ?? 0) > 0, + expression.observation, + expression.decisions, + extractEmbedding && (expression.embedding?.length ?? 0) > 0, ); return body ? `---\n${yaml}\n---\n\n${body}\n` : `---\n${yaml}\n---\n`; } -function stripEmbedding(fp: Fingerprint): Fingerprint { +function stripEmbedding(fp: Expression): Expression { const { embedding: _dropped, ...rest } = fp; - return rest as Fingerprint; + return rest as Expression; } function buildBody( diff --git a/packages/ghost-drift/src/core/fingerprint/compose.ts b/packages/ghost-drift/src/core/fingerprint/compose.ts deleted file mode 100644 index 3fa5aed..0000000 --- a/packages/ghost-drift/src/core/fingerprint/compose.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { DesignDecision, Fingerprint } from "../types.js"; - -/** - * Merge a child fingerprint on top of a parent. Precedence rules: - * - * • Scalars / arrays → child replaces when present, else parent - * • decisions → merged by `dimension` slug; child wins per-dim, - * parent-only decisions are preserved - * • palette.dominant/semantic → merged by `role`; child wins per-role, - * parent-only roles preserved - * - * This mirrors the intent of a designer declaring "my system is like the - * parent but with these specific changes" — they expect parent decisions - * they don't touch to remain, while overrides swap in cleanly. - */ -export function mergeFingerprint( - parent: Fingerprint, - child: Partial, -): Fingerprint { - const merged: Fingerprint = { - ...parent, - ...stripUndefined(child), - }; - - if (parent.decisions || child.decisions) { - merged.decisions = mergeByKey( - parent.decisions ?? [], - child.decisions ?? [], - (d) => d.dimension, - ); - } - - if (parent.roles || child.roles) { - merged.roles = mergeByKey( - parent.roles ?? [], - child.roles ?? [], - (r) => r.name, - ); - } - - if (parent.palette || child.palette) { - const pPal = parent.palette; - const cPal = child.palette; - merged.palette = { - ...(pPal ?? emptyPalette()), - ...(cPal ?? {}), - dominant: mergeByKey( - pPal?.dominant ?? [], - cPal?.dominant ?? [], - (c) => c.role, - ), - semantic: mergeByKey( - pPal?.semantic ?? [], - cPal?.semantic ?? [], - (c) => c.role, - ), - // neutrals / saturationProfile / contrast: child replaces if present - neutrals: cPal?.neutrals ?? pPal?.neutrals ?? { steps: [], count: 0 }, - saturationProfile: - cPal?.saturationProfile ?? pPal?.saturationProfile ?? "muted", - contrast: cPal?.contrast ?? pPal?.contrast ?? "moderate", - }; - } - - return merged; -} - -function mergeByKey(parent: T[], child: T[], key: (item: T) => string): T[] { - const childByKey = new Map(child.map((item) => [key(item), item])); - const out: T[] = []; - const seen = new Set(); - - // Parent order first, with child overrides slotted in place - for (const item of parent) { - const k = key(item); - seen.add(k); - const override = childByKey.get(k); - out.push(override ?? item); - } - // Child-only entries appended at the end - for (const item of child) { - const k = key(item); - if (!seen.has(k)) out.push(item); - } - return out; -} - -function stripUndefined(obj: T): Partial { - const out: Partial = {}; - for (const [k, v] of Object.entries(obj)) { - if (v !== undefined) (out as Record)[k] = v; - } - return out; -} - -function emptyPalette(): Fingerprint["palette"] { - return { - dominant: [], - neutrals: { steps: [], count: 0 }, - semantic: [], - saturationProfile: "muted", - contrast: "moderate", - }; -} - -// Re-export the decision type so callers writing their own merges don't -// need to reach into ../types. -export type { DesignDecision }; diff --git a/packages/ghost-drift/src/core/index.ts b/packages/ghost-drift/src/core/index.ts index 65fa2d0..d93126c 100644 --- a/packages/ghost-drift/src/core/index.ts +++ b/packages/ghost-drift/src/core/index.ts @@ -15,10 +15,10 @@ export { } from "./context/index.js"; export type { RoleCandidate } from "./embedding/index.js"; export { - compareFingerprints, + compareExpressions, computeEmbedding, computeSemanticEmbedding, - describeFingerprint, + describeExpression, embeddingDistance, inferSemanticRole, } from "./embedding/index.js"; @@ -34,53 +34,53 @@ export { computeDriftVectors, computeTemporalComparison, DIMENSION_RANGES, - emitFingerprint, - normalizeParentSource, + emitExpression, + normalizeTrackedSource, readHistory, readRecentHistory, readSyncManifest, - resolveParent, + resolveTrackedExpression, writeSyncManifest, } from "./evolution/index.js"; export type { BodyData, ColorChange, DecisionChange, - FingerprintLayout, - FingerprintLayoutSection, - FingerprintMeta, + ExpressionLayout, + ExpressionLayoutSection, + ExpressionMeta, FrontmatterData, FrontmatterShape, LintIssue, LintOptions, LintReport, LintSeverity, - ParsedFingerprint, + ParsedExpression, SemanticDiff, TokenChange, -} from "./fingerprint/index.js"; +} from "./expression/index.js"; export { - diffFingerprints, + diffExpressions, EMBEDDING_FRAGMENT_FILENAME, + EXPRESSION_FILENAME, embeddingSiblingPath, - FINGERPRINT_FILENAME, FrontmatterSchema, findFragmentLinks, formatLayout, formatSemanticDiff, - layoutFingerprint, - lintFingerprint, + layoutExpression, + lintExpression, loadEmbeddingFragment, - loadFingerprint, + loadExpression, parseBody, - parseFingerprint, + parseExpression, resolveEmbeddingReference, serializeEmbeddingFragment, - serializeFingerprint, + serializeExpression, splitRaw, toJsonSchema, validateFrontmatter, -} from "./fingerprint/index.js"; +} from "./expression/index.js"; export { formatCompositeComparison, formatCompositeComparisonJSON, @@ -88,9 +88,9 @@ export { export { formatComparison, formatComparisonJSON, - formatFingerprint, - formatFingerprintJSON, -} from "./reporters/fingerprint.js"; + formatExpression, + formatExpressionJSON, +} from "./reporters/expression.js"; export { formatTemporalComparison, formatTemporalComparisonJSON, @@ -115,14 +115,14 @@ export type { DriftVelocity, EmbeddingConfig, EnrichedComparison, - EnrichedFingerprint, + EnrichedExpression, + Expression, + ExpressionComparison, + ExpressionHistoryEntry, ExtractedFile, ExtractedMaterial, Extractor, ExtractorOptions, - Fingerprint, - FingerprintComparison, - FingerprintHistoryEntry, FontDescriptor, GhostConfig, NormalizedToken, diff --git a/packages/ghost-drift/src/core/reporters/composite.ts b/packages/ghost-drift/src/core/reporters/composite.ts index 80b0a08..8da1069 100644 --- a/packages/ghost-drift/src/core/reporters/composite.ts +++ b/packages/ghost-drift/src/core/reporters/composite.ts @@ -21,7 +21,7 @@ export function formatCompositeComparison( const lines: string[] = []; lines.push( - c(BOLD, `Composite Fingerprint: ${composite.members.length} members`), + c(BOLD, `Composite Expression: ${composite.members.length} members`), ); lines.push(""); @@ -35,11 +35,11 @@ export function formatCompositeComparison( // Members lines.push(c(BOLD, "Members")); for (const member of composite.members) { - const parentStr = - member.distanceToParent != null - ? ` (${(member.distanceToParent * 100).toFixed(1)}% from parent)` + const trackedStr = + member.distanceToTracked != null + ? ` (${(member.distanceToTracked * 100).toFixed(1)}% from tracked)` : ""; - lines.push(` ${member.id}${c(DIM, parentStr)}`); + lines.push(` ${member.id}${c(DIM, trackedStr)}`); } lines.push(""); @@ -76,7 +76,7 @@ export function formatCompositeComparisonJSON( memberCount: composite.members.length, members: composite.members.map((m) => ({ id: m.id, - distanceToParent: m.distanceToParent, + distanceToTracked: m.distanceToTracked, })), pairwise: composite.pairwise, spread: composite.spread, diff --git a/packages/ghost-drift/src/core/reporters/fingerprint.ts b/packages/ghost-drift/src/core/reporters/expression.ts similarity index 87% rename from packages/ghost-drift/src/core/reporters/fingerprint.ts rename to packages/ghost-drift/src/core/reporters/expression.ts index 712a8a3..4aa97b3 100644 --- a/packages/ghost-drift/src/core/reporters/fingerprint.ts +++ b/packages/ghost-drift/src/core/reporters/expression.ts @@ -1,4 +1,4 @@ -import type { Fingerprint, FingerprintComparison } from "../types.js"; +import type { Expression, ExpressionComparison } from "../types.js"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; @@ -14,10 +14,10 @@ function c(code: string, text: string): string { return useColor ? `${code}${text}${RESET}` : text; } -export function formatFingerprint(fp: Fingerprint): string { +export function formatExpression(fp: Expression): string { const lines: string[] = []; - lines.push(c(BOLD, `Fingerprint: ${fp.id}`)); + lines.push(c(BOLD, `Expression: ${fp.id}`)); lines.push(c(DIM, `Source: ${fp.source} | ${fp.timestamp}`)); if (fp.sources?.length) { lines.push(c(DIM, `Synthesized from: ${fp.sources.join(", ")}`)); @@ -36,8 +36,8 @@ export function formatFingerprint(fp: Fingerprint): string { lines.push(` ${c(DIM, "-")} ${trait}`); } } - if (fp.observation.closestSystems.length > 0) { - lines.push(` Resembles: ${fp.observation.closestSystems.join(", ")}`); + if (fp.observation.resembles.length > 0) { + lines.push(` Resembles: ${fp.observation.resembles.join(", ")}`); } lines.push(""); } @@ -102,7 +102,7 @@ export function formatFingerprint(fp: Fingerprint): string { return `${lines.join("\n")}\n`; } -export function formatComparison(comp: FingerprintComparison): string { +export function formatComparison(comp: ExpressionComparison): string { const lines: string[] = []; lines.push(c(BOLD, `Comparison: ${comp.source.id} vs ${comp.target.id}`)); @@ -136,12 +136,12 @@ export function formatComparison(comp: FingerprintComparison): string { return `${lines.join("\n")}\n`; } -export function formatFingerprintJSON(fp: Fingerprint): string { +export function formatExpressionJSON(fp: Expression): string { return JSON.stringify(fp, null, 2); } -export function formatComparisonJSON(comp: FingerprintComparison): string { - // Omit full fingerprints from JSON comparison to keep it concise +export function formatComparisonJSON(comp: ExpressionComparison): string { + // Omit full expressions from JSON comparison to keep it concise return JSON.stringify( { source: comp.source.id, diff --git a/packages/ghost-drift/src/core/types.ts b/packages/ghost-drift/src/core/types.ts index 86db23f..579dd43 100644 --- a/packages/ghost-drift/src/core/types.ts +++ b/packages/ghost-drift/src/core/types.ts @@ -1,4 +1,4 @@ -// --- Target system --- +// --- Target --- export type TargetType = | "path" @@ -160,14 +160,14 @@ export type RuleSeverity = "error" | "warn" | "off"; export interface GhostConfig { targets?: Target[]; - parent?: Target; + tracks?: Target; rules: Record; ignore: string[]; embedding?: EmbeddingConfig; extractors?: string[]; } -// --- Fingerprint types --- +// --- Expression types --- export interface SemanticColor { role: string; @@ -180,17 +180,17 @@ export interface ColorRamp { count: number; } -// --- Observation & decision types (three-layer fingerprint) --- +// --- Observation & decision types (three-layer expression) --- export interface DesignObservation { /** Holistic summary of the design language */ summary: string; /** Personality traits (e.g. "utilitarian", "restrained", "playful") */ personality: string[]; - /** What makes this system visually distinctive */ + /** What makes this expression visually distinctive */ distinctiveTraits: string[]; /** Closest well-known design languages for reference */ - closestSystems: string[]; + resembles: string[]; } export interface DesignDecision { @@ -220,7 +220,7 @@ export interface DesignDecision { export interface DesignRole { /** Semantic slot name — "h1", "body", "card", "button", "input", "list-row", etc. */ name: string; - /** Tokens the slot binds, grouped by fingerprint dimension. */ + /** Tokens the slot binds, grouped by expression dimension. */ tokens: { typography?: { family?: string; @@ -248,7 +248,7 @@ export interface DesignRole { evidence: string[]; } -export interface Fingerprint { +export interface Expression { id: string; source: "registry" | "extraction" | "llm" | "unknown"; timestamp: string; @@ -342,7 +342,7 @@ export interface SampledMaterial { // --- AI enrichment types --- -export interface EnrichedFingerprint extends Fingerprint { +export interface EnrichedExpression extends Expression { detectedFormats?: DetectedFormat[]; targetType: TargetType; } @@ -353,7 +353,7 @@ export type DivergenceClass = | "evolution-lag" | "incompatible"; -export interface EnrichedComparison extends FingerprintComparison { +export interface EnrichedComparison extends ExpressionComparison { classification: DivergenceClass; explanations: Record; } @@ -421,10 +421,10 @@ export interface EmbeddingConfig { // --- History types --- -export interface FingerprintHistoryEntry { - fingerprint: Fingerprint; - parentRef?: Target; - comparisonToParent?: { +export interface ExpressionHistoryEntry { + expression: Expression; + trackedRef?: Target; + comparisonToTracked?: { distance: number; dimensions: Record; }; @@ -448,10 +448,10 @@ export interface DimensionAck { } export interface SyncManifest { - parent: Target; + tracks: Target; ackedAt: string; - parentFingerprintId: string; - childFingerprintId: string; + trackedExpressionId: string; + localExpressionId: string; dimensions: Record; overallDistance: number; } @@ -464,9 +464,9 @@ export interface DimensionDelta { description: string; } -export interface FingerprintComparison { - source: Fingerprint; - target: Fingerprint; +export interface ExpressionComparison { + source: Expression; + target: Expression; distance: number; dimensions: Record; summary: string; @@ -488,7 +488,7 @@ export interface DriftVelocity { windowDays: number; } -export interface TemporalComparison extends FingerprintComparison { +export interface TemporalComparison extends ExpressionComparison { velocity: DriftVelocity[]; daysSinceAck: number | null; exceedsAckedBounds: boolean; @@ -496,13 +496,13 @@ export interface TemporalComparison extends FingerprintComparison { trajectory: "converging" | "diverging" | "stable" | "oscillating"; } -// --- Composite types (N≥3 fingerprint comparison) --- +// --- Composite types (N≥3 expression comparison) --- export interface CompositeMember { id: string; - fingerprint: Fingerprint; - parentRef?: Target; - distanceToParent?: number; + expression: Expression; + trackedRef?: Target; + distanceToTracked?: number; } export interface CompositePair { @@ -532,8 +532,8 @@ export interface ValueDrift { rule: string; severity: RuleSeverity; message: string; - registryValue?: string; - consumerValue?: string; + expressionValue?: string; + implementationValue?: string; selector?: string; file?: string; line?: number; @@ -548,6 +548,6 @@ export interface StructureDrift { diff?: string; linesAdded: number; linesRemoved: number; - registryFile?: string; - consumerFile?: string; + expressionFile?: string; + implementationFile?: string; } diff --git a/packages/ghost-drift/src/emit-command.ts b/packages/ghost-drift/src/emit-command.ts index 7447709..863c901 100644 --- a/packages/ghost-drift/src/emit-command.ts +++ b/packages/ghost-drift/src/emit-command.ts @@ -3,12 +3,12 @@ import { dirname, resolve } from "node:path"; import type { CAC } from "cac"; import { emitReviewCommand, - loadFingerprint, + loadExpression, writeContextBundle, } from "./core/index.js"; import { loadSkillBundle } from "./skill-bundle.js"; -const DEFAULT_FINGERPRINT = "fingerprint.md"; +const DEFAULT_EXPRESSION = "expression.md"; const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; const DEFAULT_CONTEXT_OUT = "ghost-context"; const DEFAULT_SKILL_OUT = ".claude/skills/ghost-drift"; @@ -42,11 +42,11 @@ export function registerEmitCommand(cli: CAC): void { cli .command( "emit ", - `Emit a derived artifact from fingerprint.md (kinds: ${SUPPORTED_KINDS.join(", ")})`, + `Emit a derived artifact from expression.md (kinds: ${SUPPORTED_KINDS.join(", ")})`, ) .option( - "-e, --fingerprint ", - `Source fingerprint file (default: ${DEFAULT_FINGERPRINT})`, + "-e, --expression ", + `Source expression file (default: ${DEFAULT_EXPRESSION})`, ) .option( "-o, --out ", @@ -61,11 +61,11 @@ export function registerEmitCommand(cli: CAC): void { .option("--readme", "Include README.md (context-bundle)") .option( "--prompt-only", - "Emit only prompt.md — skips SKILL.md / fingerprint.md / tokens.css (context-bundle)", + "Emit only prompt.md — skips SKILL.md / expression.md / tokens.css (context-bundle)", ) .option( "--name ", - "Override the skill name (default: fingerprint id) (context-bundle)", + "Override the skill name (default: expression id) (context-bundle)", ) .action(async (kind: string, opts) => { try { @@ -95,17 +95,17 @@ export function registerEmitCommand(cli: CAC): void { process.exit(0); } - const fingerprintPath = resolve( + const expressionPath = resolve( process.cwd(), - opts.fingerprint ?? DEFAULT_FINGERPRINT, + opts.expression ?? DEFAULT_EXPRESSION, ); if (parsed.kind === "review-command") { - const loaded = await loadFingerprint(fingerprintPath, { + const loaded = await loadExpression(expressionPath, { noEmbeddingBackfill: true, }); const content = emitReviewCommand({ - fingerprint: loaded.fingerprint, + expression: loaded.expression, }); if (opts.stdout) { @@ -129,14 +129,14 @@ export function registerEmitCommand(cli: CAC): void { (opts.out as string | undefined) ?? DEFAULT_CONTEXT_OUT, ); - const { fingerprint } = await loadFingerprint(fingerprintPath); - const result = await writeContextBundle(fingerprint, { + const { expression } = await loadExpression(expressionPath); + const result = await writeContextBundle(expression, { outDir, tokens: opts.tokens !== false, readme: Boolean(opts.readme), promptOnly: Boolean(opts.promptOnly), name: opts.name as string | undefined, - sourcePath: fingerprintPath, + sourcePath: expressionPath, }); process.stdout.write( diff --git a/packages/ghost-drift/src/evolution-commands.ts b/packages/ghost-drift/src/evolution-commands.ts index 829fcbd..47fbbcc 100644 --- a/packages/ghost-drift/src/evolution-commands.ts +++ b/packages/ghost-drift/src/evolution-commands.ts @@ -3,23 +3,23 @@ import type { CAC } from "cac"; import type { DimensionStance, Target } from "./core/index.js"; import { acknowledge, - FINGERPRINT_FILENAME, + EXPRESSION_FILENAME, loadConfig, - loadFingerprint, - resolveParent, + loadExpression, + resolveTrackedExpression, } from "./core/index.js"; async function loadLocalExpression() { - const path = resolve(process.cwd(), FINGERPRINT_FILENAME); - const { fingerprint } = await loadFingerprint(path); - return fingerprint; + const path = resolve(process.cwd(), EXPRESSION_FILENAME); + const { expression } = await loadExpression(path); + return expression; } export function registerAckCommand(cli: CAC): void { cli .command( "ack", - "Acknowledge current drift — record intentional stance toward parent", + "Acknowledge current drift — record intentional stance toward the tracked expression", ) .option("-c, --config ", "Path to ghost config file") .option("-d, --dimension ", "Acknowledge a specific dimension only") @@ -32,20 +32,20 @@ export function registerAckCommand(cli: CAC): void { try { const config = await loadConfig(opts.config); - if (!config.parent) { + if (!config.tracks) { console.error( - "Error: No parent declared. Set `parent` in ghost.config.ts or use --parent.", + "Error: No tracked expression declared. Set `tracks` in ghost.config.ts.", ); process.exit(2); } - const parentFp = await resolveParent(config.parent); - const childFp = await loadLocalExpression(); + const trackedExpression = await resolveTrackedExpression(config.tracks); + const localExpression = await loadLocalExpression(); const { manifest, comparison } = await acknowledge({ - child: childFp, - parent: parentFp, - parentRef: config.parent, + local: localExpression, + tracked: trackedExpression, + tracks: config.tracks, dimension: opts.dimension, stance: opts.stance as DimensionStance, reason: opts.reason, @@ -55,7 +55,7 @@ export function registerAckCommand(cli: CAC): void { process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); } else { console.log( - `Acknowledged drift from "${manifest.parentFingerprintId}"`, + `Acknowledged drift from "${manifest.trackedExpressionId}"`, ); console.log(`Overall distance: ${comparison.distance.toFixed(3)}`); console.log(); @@ -85,25 +85,25 @@ export function registerAckCommand(cli: CAC): void { }); } -export function registerAdoptCommand(cli: CAC): void { +export function registerTrackCommand(cli: CAC): void { cli .command( - "adopt ", - "Shift parent reference — adopt a new fingerprint as baseline", + "track ", + "Track another expression as this repo's reference", ) - .option("-d, --dimension ", "Adopt only for a specific dimension") + .option("-d, --dimension ", "Track only for a specific dimension") .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (source: string, opts) => { try { - const { fingerprint: newParent } = await loadFingerprint(source); - const childFp = await loadLocalExpression(); + const { expression: trackedExpression } = await loadExpression(source); + const localExpression = await loadLocalExpression(); - const newParentRef: Target = { type: "path", value: source }; + const tracks: Target = { type: "path", value: source }; const { manifest, comparison } = await acknowledge({ - child: childFp, - parent: newParent, - parentRef: newParentRef, + local: localExpression, + tracked: trackedExpression, + tracks, dimension: opts.dimension, stance: "accepted", }); @@ -111,7 +111,7 @@ export function registerAdoptCommand(cli: CAC): void { if (opts.format === "json") { process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); } else { - console.log(`Adopted "${newParent.id}" as parent baseline`); + console.log(`Now tracking "${trackedExpression.id}"`); console.log(`New distance: ${comparison.distance.toFixed(3)}`); console.log(); for (const [key, delta] of Object.entries(comparison.dimensions)) { @@ -147,20 +147,20 @@ export function registerDivergeCommand(cli: CAC): void { try { const config = await loadConfig(opts.config); - if (!config.parent) { + if (!config.tracks) { console.error( - "Error: No parent declared. Set `parent` in ghost.config.ts or use --parent.", + "Error: No tracked expression declared. Set `tracks` in ghost.config.ts.", ); process.exit(2); } - const parentFp = await resolveParent(config.parent); - const childFp = await loadLocalExpression(); + const trackedExpression = await resolveTrackedExpression(config.tracks); + const localExpression = await loadLocalExpression(); const { manifest } = await acknowledge({ - child: childFp, - parent: parentFp, - parentRef: config.parent, + local: localExpression, + tracked: trackedExpression, + tracks: config.tracks, dimension, stance: "diverging", reason: opts.reason, diff --git a/packages/ghost-drift/src/skill-bundle/SKILL.md b/packages/ghost-drift/src/skill-bundle/SKILL.md index 4ac29b6..f856388 100644 --- a/packages/ghost-drift/src/skill-bundle/SKILL.md +++ b/packages/ghost-drift/src/skill-bundle/SKILL.md @@ -1,6 +1,6 @@ --- name: ghost-drift -description: Detect and manage visual-language drift in design languages. 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 languages. Triggers on phrases like "profile this design language", "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. +description: Detect and manage visual-language drift in design languages. Use when the user wants to write or update an expression.md, review frontend code changes for design drift, compare design expressions, verify generated UI against an expression, or discover public design languages. Triggers on phrases like "profile this design language", "check for drift", "review this PR for design issues", "write expression.md", "compare expressions", or whenever an `expression.md` file is present and styling/design work is happening. license: Apache-2.0 metadata: homepage: https://github.com/block/ghost @@ -9,7 +9,7 @@ metadata: # 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 captures a project's visual language as an **`expression.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. @@ -17,11 +17,11 @@ Ghost's CLI is a set of **deterministic primitives**. It never calls an LLM. Syn | Verb | Purpose | |---|---| -| `ghost-drift compare [...more]` | Pairwise distance + per-dimension delta (N=2) or composite (N≥3: pairwise matrix, centroid, spread, clusters). Pure math over fingerprint embeddings. `--semantic` and `--temporal` flags add qualitative enrichment for N=2. | -| `ghost-drift lint [fingerprint.md]` | Validate schema + body/frontmatter coherence. Use this before declaring a fingerprint valid. | -| `ghost-drift describe [fingerprint.md]` | Print a section map (line ranges + token estimates) so you can selectively read only the sections you need instead of loading the whole file. Use before review/generate when the fingerprint is large. | -| `ghost-drift ack` / `ghost-drift adopt ` / `ghost-drift diverge ` | Record stance toward parent (aligned / accepted / diverging) in `.ghost-sync.json`. Reads the local `fingerprint.md`. | -| `ghost-drift emit review-command` / `ghost-drift emit context-bundle` / `ghost-drift emit skill` | Derive per-project artifacts from `fingerprint.md`. | +| `ghost-drift compare [...more]` | Pairwise distance + per-dimension delta (N=2) or composite (N≥3: pairwise matrix, centroid, spread, clusters). Pure math over expression embeddings. `--semantic` and `--temporal` flags add qualitative enrichment for N=2. | +| `ghost-drift lint [expression.md]` | Validate schema + body/frontmatter coherence. Use this before declaring an expression valid. | +| `ghost-drift describe [expression.md]` | Print a section map (line ranges + token estimates) so you can selectively read only the sections you need instead of loading the whole file. Use before review/generate when the expression is large. | +| `ghost-drift ack` / `ghost-drift track ` / `ghost-drift diverge ` | Record stance toward the tracked expression (aligned / accepted / diverging) in `.ghost-sync.json`. Reads the local `expression.md`. | +| `ghost-drift emit review-command` / `ghost-drift emit context-bundle` / `ghost-drift emit skill` | Derive per-project artifacts from `expression.md`. | That's it. Seven verbs. If you find yourself reaching for `ghost review` or `ghost profile` — those are *your* workflows, not CLI commands. Follow the recipes below. @@ -29,25 +29,25 @@ That's it. Seven verbs. If you find yourself reaching for `ghost review` or `gho When the user asks you to: -- "Profile my design language" / "write a fingerprint.md" → [references/profile.md](references/profile.md) +- "Profile my design language" / "write expression.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) +- "Verify this generated UI matches the expression" → [references/verify.md](references/verify.md) - "Generate a component matching our design language" → [references/generate.md](references/generate.md) -- "Compare these two fingerprints" → run `ghost-drift compare `; if they ask *why* they drifted, add `--semantic`. See [references/compare.md](references/compare.md) for interpretation. +- "Compare these two expressions" → run `ghost-drift compare `; if they ask *why* they drifted, add `--semantic`. See [references/compare.md](references/compare.md) for interpretation. - "Find design languages like X" / "discover" → [references/discover.md](references/discover.md) -## The fingerprint.md format +## The expression.md format -An `fingerprint.md` has: +An `expression.md` has: -- **YAML frontmatter (machine layer):** `id`, `schema`, `source`, `timestamp`, `observation.personality`, `observation.closestSystems`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`. +- **YAML frontmatter (machine layer):** `id`, `schema`, `source`, `timestamp`, `observation.personality`, `observation.resembles`, `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). +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/expression.template.md](assets/expression.template.md). ## Always -- Use `fingerprint.md` as the canonical filename (no slug prefix, no dotfile). +- Use `expression.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. @@ -56,6 +56,6 @@ Each field lives in exactly one layer — no duplication. Putting prose in front ## 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 use the W3C Design Tokens or Style Dictionary format. Ghost's `expression.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-drift/src/skill-bundle/assets/fingerprint.template.md b/packages/ghost-drift/src/skill-bundle/assets/expression.template.md similarity index 98% rename from packages/ghost-drift/src/skill-bundle/assets/fingerprint.template.md rename to packages/ghost-drift/src/skill-bundle/assets/expression.template.md index f190dbf..cf07e70 100644 --- a/packages/ghost-drift/src/skill-bundle/assets/fingerprint.template.md +++ b/packages/ghost-drift/src/skill-bundle/assets/expression.template.md @@ -9,7 +9,7 @@ observation: personality: - adjective-1 - adjective-2 - closestSystems: + resembles: - known-system # abstract design decisions diff --git a/packages/ghost-drift/src/skill-bundle/references/compare.md b/packages/ghost-drift/src/skill-bundle/references/compare.md index 0f6ef58..4e86766 100644 --- a/packages/ghost-drift/src/skill-bundle/references/compare.md +++ b/packages/ghost-drift/src/skill-bundle/references/compare.md @@ -5,15 +5,15 @@ handoffs: - label: Accept the drift as aligned reality command: ghost-drift ack prompt: Accept current drift across the board - - label: Adopt the other fingerprint as a new parent baseline - command: ghost-drift adopt - prompt: Adopt the other fingerprint.md as the new parent baseline + - label: Track the other expression + command: ghost-drift track + prompt: Track the other expression.md as the new reference - label: Declare a dimension intentionally divergent command: ghost-drift diverge prompt: Record an intentional divergence on a specific dimension --- -# Recipe: Compare fingerprints +# Recipe: Compare expressions **Goal:** answer "how different are these design languages?" or "how has ours drifted over time?" @@ -33,14 +33,14 @@ Flags: ghost-drift compare a.md b.md c.md d.md -Output: pairwise distance matrix, centroid, spread, and cluster assignments. The centroid is the composite (org-scale) fingerprint: what the members average out to. +Output: pairwise distance matrix, centroid, spread, and cluster assignments. The centroid is the composite (org-scale) expression: what the members average out to. -Use for: comparing multiple downstream consumers of a parent design language (which are closest to parent, which have drifted most, do they cluster?). Or viewing the whole org's design language at a glance. +Use for: comparing a collection of expressions at the same elevation: which are closest, which are far apart, and whether they cluster into coherent families. ### 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. +- **> 0.5**: the two expressions 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-drift/src/skill-bundle/references/discover.md b/packages/ghost-drift/src/skill-bundle/references/discover.md index d7d2cbd..878187f 100644 --- a/packages/ghost-drift/src/skill-bundle/references/discover.md +++ b/packages/ghost-drift/src/skill-bundle/references/discover.md @@ -1,13 +1,13 @@ --- name: discover -description: Find public design languages matching a query — for benchmarking, inspiration, or adoption. +description: Find public design languages matching a query — for benchmarking, inspiration, or reference material. handoffs: - - label: Profile a discovered system into its own fingerprint.md + - label: Profile a discovered system into its own expression.md skill: profile - prompt: Profile the discovered system into a fingerprint.md under discovered/ - - label: Compare my fingerprint against a discovered system + prompt: Profile the discovered system into expression.md under discovered/ + - label: Compare my expression against a discovered system skill: compare - prompt: Compare my fingerprint.md against the discovered system's fingerprint with --semantic + prompt: Compare my expression.md against the discovered system's expression with --semantic --- # Recipe: Discover public design languages @@ -36,16 +36,16 @@ Keep: systems with public token files, published component libraries, documented ### 3. (Optional) Profile -For each kept candidate, if the user wants fingerprint-level detail: +For each kept candidate, if the user wants expression-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`). +- Save the resulting `expression.md` somewhere named for the system (e.g. `discovered/linear.expression.md`). -Then compare against the user's fingerprint: +Then compare against the user's expression: - ghost-drift compare my-fingerprint.md discovered/linear.fingerprint.md --semantic + ghost-drift compare my-expression.md discovered/linear.expression.md --semantic ### 4. Report -Summarize findings as a small table: name, URL, one-line character description, optional distance to the user's fingerprint. +Summarize findings as a small table: name, URL, one-line character description, optional distance to the user's expression. diff --git a/packages/ghost-drift/src/skill-bundle/references/generate.md b/packages/ghost-drift/src/skill-bundle/references/generate.md index da6d6f2..2723f68 100644 --- a/packages/ghost-drift/src/skill-bundle/references/generate.md +++ b/packages/ghost-drift/src/skill-bundle/references/generate.md @@ -1,25 +1,25 @@ --- name: generate -description: Produce UI code that lives within fingerprint.md bounds. +description: Produce UI code that lives within expression.md bounds. handoffs: - - label: Verify the generated UI matches the fingerprint + - label: Verify the generated UI matches the expression skill: verify - prompt: Verify the UI I just generated against fingerprint.md + prompt: Verify the UI I just generated against expression.md --- -# Recipe: Generate UI from a fingerprint.md +# Recipe: Generate UI from an expression.md -**Goal:** produce a UI artifact (component, page, snippet) that lives within the `fingerprint.md` boundaries. +**Goal:** produce a UI artifact (component, page, snippet) that lives within the `expression.md` boundaries. -Ghost's CLI does not generate code — you do. The fingerprint is the constraint. +Ghost's CLI does not generate code — you do. The expression is the constraint. ## Steps -### 1. Load the fingerprint +### 1. Load the expression Start with a section map: - ghost-drift describe fingerprint.md + ghost-drift describe expression.md Generation always needs the **frontmatter** (palette, spacing.scale, typography.families/sizeRamp, surfaces.borderRadii, roles[]) — read that whole range. Then layer on decision sections by relevance to what you're generating: @@ -41,17 +41,17 @@ The key constraints surfaced in the frontmatter are: ### 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`. +Write the UI code using only values from the expression. 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. +Respect the decisions. If the expression 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. +If the expression 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 expression, 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. +Run the [verify recipe](verify.md) — self-review the generated code against the expression 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 CSS custom properties referencing the expression'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-drift/src/skill-bundle/references/profile.md b/packages/ghost-drift/src/skill-bundle/references/profile.md index cfba234..075422b 100644 --- a/packages/ghost-drift/src/skill-bundle/references/profile.md +++ b/packages/ghost-drift/src/skill-bundle/references/profile.md @@ -1,18 +1,18 @@ --- name: profile -description: Write fingerprint.md from a project's design sources. +description: Write expression.md from a project's design sources. handoffs: - - label: Compare against a parent or peer fingerprint + - label: Compare against another expression skill: compare - prompt: Compare the fingerprint.md I just wrote against a parent or peer + prompt: Compare the expression.md I just wrote against another expression - label: Emit a project-scoped drift review command command: ghost-drift emit review-command - prompt: Emit a per-project review command derived from this fingerprint.md + prompt: Emit a per-project review command derived from this expression.md --- -# Recipe: Profile a project into fingerprint.md +# Recipe: Profile a project into expression.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-drift lint` for validation. +**Goal:** produce a valid `expression.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-drift lint` for validation. ## Steps @@ -36,7 +36,7 @@ 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. +Record the resolved concrete value. Stopping at the first indirection produces useless expressions. ### 3. Read component files (for the roles layer) @@ -52,8 +52,8 @@ These become `roles[]`. Only record what you directly observed. Projects with no 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, …) +- `distinctiveTraits`: what makes this expression *visually recognizable* — include notable absences (e.g. "no decorative elements at all") +- `resembles`: 1-3 well-known references this resembles (Linear, Geist, Material 3, …) ### 5. Derive Layer 2 — Design Decisions (abstract) @@ -76,16 +76,16 @@ Populate the structured fields: `palette.dominant`, `palette.neutrals`, `palette ### 7. Write the file -Copy [../assets/fingerprint.template.md](../assets/fingerprint.template.md) as a starting point. Fill in: +Copy [../assets/expression.template.md](../assets/expression.template.md) as a starting point. Fill in: -- **Frontmatter:** all structured fields (identity, `observation.personality`/`.closestSystems`, `decisions[].dimension`/`.evidence`, `palette`, `spacing`, `typography`, `surfaces`, `roles`). +- **Frontmatter:** all structured fields (identity, `observation.personality`/`.resembles`, `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-drift lint fingerprint.md + ghost-drift lint expression.md Fix any errors it reports. Common ones: @@ -94,8 +94,8 @@ Fix any errors it reports. Common ones: - Palette entry not cited in any evidence → cite it or drop it ### 9. Sanity check - ghost-drift compare fingerprint.md fingerprint.md # self-distance should be 0 + ghost-drift compare expression.md expression.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. +If the project has no styling (backend-only, no UI), say so. Do not fabricate an expression. A placeholder expression poisons every downstream comparison. diff --git a/packages/ghost-drift/src/skill-bundle/references/review.md b/packages/ghost-drift/src/skill-bundle/references/review.md index b33e875..1a08ef5 100644 --- a/packages/ghost-drift/src/skill-bundle/references/review.md +++ b/packages/ghost-drift/src/skill-bundle/references/review.md @@ -1,32 +1,32 @@ --- name: review -description: Flag PR or working-tree changes that drift from the local fingerprint.md. +description: Flag PR or working-tree changes that drift from the local expression.md. handoffs: - - label: Regenerate drifting components to match the fingerprint + - label: Regenerate drifting components to match the expression skill: verify - prompt: Regenerate the drifting code against fingerprint.md and re-review + prompt: Regenerate the drifting code against expression.md and re-review - label: Accept the drift as aligned reality command: ghost-drift ack - prompt: Acknowledge that the current fingerprint.md no longer matches and record the drift + prompt: Acknowledge that the current expression.md no longer matches and record the drift - label: Declare a dimension intentionally divergent command: ghost-drift diverge prompt: Record an intentional divergence on a specific dimension so it stops flagging - - label: Adopt a new parent baseline - command: ghost-drift adopt - prompt: Adopt the provided fingerprint.md as a new parent baseline + - label: Track a new expression + command: ghost-drift track + prompt: Track the provided expression.md as the new reference --- # 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). +**Goal:** flag frontend changes that drift from the local `expression.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. +Ghost has no `ghost review` CLI command. You — the host agent — are the reviewer. The `expression.md` is your rubric. ## Steps -### 1. Read the fingerprint +### 1. Read the expression - ghost-drift describe fingerprint.md + ghost-drift describe expression.md This prints a section map — frontmatter range, body sections (`# Character`, `# Signature`, `# Decisions`, `# Fragments`), and each `### dimension` block under Decisions, with line ranges and token estimates. Use it to plan what to load. @@ -36,7 +36,7 @@ Then read selectively: - **Read decision sections by dimension name.** If the diff touches colors, you'll want `### color-strategy` (and any other `color-*` / `palette-*` dimension). If it touches radii, `### shape-language`, `### surface-hierarchy`, `### elevation`. Match on slug. - **If you're not confident which decisions are relevant — or the diff spans more than two partitions — read the entire `# Decisions` block.** It's typically 2–4k tokens; cheaper than missing a constraint. The describe output tells you the exact line range. -If no `fingerprint.md` exists, tell the user. Offer to generate one via the [profile recipe](profile.md). Don't guess. +If no `expression.md` exists, tell the user. Offer to generate one via the [profile recipe](profile.md). Don't guess. ### 2. Collect the changes @@ -47,12 +47,12 @@ Scope to frontend-relevant files (`.tsx`, `.jsx`, `.css`, `.scss`, `.vue`, `.sve ### 3. Scan for drift -For each changed file, read the diff and look for values that don't belong to the fingerprint: +For each changed file, read the diff and look for values that don't belong to the expression: - **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. +- **Surface drift:** `border-radius` not in `surfaces.borderRadii`, `box-shadow` present when `surfaces.shadowComplexity: none`, or absent when the expression 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 @@ -71,7 +71,7 @@ Group findings by dimension. Lead with the most load-bearing drift. For each fin - `file:line` — where - What was found (`#ff6600`) -- What the fingerprint allows (`palette.semantic.warning: #dc2626`) +- What the expression allows (`palette.semantic.warning: #dc2626`) - Why it matters (one sentence — reference the decision if applicable) - Suggested fix (the token or var to use instead) @@ -82,8 +82,8 @@ Formats: ### 6. Record stance if the user accepts the drift -- `ghost-drift ack` — "yes, the current fingerprint no longer matches reality; accept drift across the board and record it." +- `ghost-drift ack` — "yes, the current expression no longer matches reality; accept drift across the board and record it." - `ghost-drift diverge --reason "..."` — "this dimension is intentionally different; stop flagging it." -- `ghost-drift adopt ` — "adopt a new parent baseline." +- `ghost-drift track ` — "track another expression as the reference." -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. +These commands only work if the local `expression.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-drift/src/skill-bundle/references/schema.md b/packages/ghost-drift/src/skill-bundle/references/schema.md index f98e15c..cd4c998 100644 --- a/packages/ghost-drift/src/skill-bundle/references/schema.md +++ b/packages/ghost-drift/src/skill-bundle/references/schema.md @@ -1,8 +1,8 @@ -# fingerprint.md schema reference +# expression.md schema reference -Canonical filename: `fingerprint.md`. +Canonical filename: `expression.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-drift`; you can also compute and append it yourself. +Companion file: `embedding.md` (sibling fragment containing the 49-dim vector). The CLI writes it automatically when you write an `expression.md` via `ghost-drift`; you can also compute and append it yourself. ## Frontmatter (machine layer) @@ -20,7 +20,7 @@ sources: # optional — targets that were combined # narrative tags (prose lives in the body) observation: personality: [restrained, editorial] # 3-6 adjectives - closestSystems: [linear, notion] # 1-3 known systems this resembles + resembles: [linear, notion] # 1-3 known references this resembles # abstract design decisions decisions: @@ -91,7 +91,7 @@ metadata: # Signature -- What makes this system visually distinctive (becomes `observation.distinctiveTraits`). +- What makes this expression visually distinctive (becomes `observation.distinctiveTraits`). - One bullet per trait. Include notable *absences* if they are load-bearing. # Decisions @@ -116,7 +116,7 @@ Every field lives in exactly one layer: | Field | Layer | |---|---| | `id`, `source`, `timestamp`, `sources` | Frontmatter | -| `observation.personality`, `observation.closestSystems` | Frontmatter | +| `observation.personality`, `observation.resembles` | Frontmatter | | `observation.summary` | **Body** (`# Character`) | | `observation.distinctiveTraits` | **Body** (`# Signature` bullets) | | `decisions[].dimension`, `decisions[].evidence` | Frontmatter | @@ -128,6 +128,6 @@ Putting prose into frontmatter is a schema error. The writer and reader both enf ## Validation - ghost-drift lint fingerprint.md + ghost-drift lint expression.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-drift/src/skill-bundle/references/verify.md b/packages/ghost-drift/src/skill-bundle/references/verify.md index 71637cf..688ca01 100644 --- a/packages/ghost-drift/src/skill-bundle/references/verify.md +++ b/packages/ghost-drift/src/skill-bundle/references/verify.md @@ -1,20 +1,20 @@ --- name: verify -description: Confirm generated UI stays within fingerprint.md bounds; iterate if not. +description: Confirm generated UI stays within expression.md bounds; iterate if not. handoffs: - label: Regenerate with feedback from the review skill: generate prompt: Regenerate the UI using the review findings as constraints - - label: Update the fingerprint to capture an uncaptured decision + - label: Update the expression to capture an uncaptured decision skill: profile - prompt: Add the missing decision to fingerprint.md and re-lint + prompt: Add the missing decision to expression.md and re-lint --- -# Recipe: Verify generated UI against the fingerprint +# Recipe: Verify generated UI against the expression -**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. +**Goal:** confirm that generated UI (a component, a page, a variant) stays within the bounds of the local `expression.md`. This is the "generate → review → iterate" loop. -Ghost has no `ghost verify` CLI command. You drive the loop; the fingerprint is the contract. +Ghost has no `ghost verify` CLI command. You drive the loop; the expression is the contract. ## Steps @@ -24,7 +24,7 @@ Produce the UI code. See [generate.md](generate.md) for guidance, or work from w ### 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. +Apply the [review recipe](review.md) to the generated file. Scan for hardcoded values that drift from the expression. Group findings by dimension. ### 3. Decide @@ -33,17 +33,17 @@ Apply the [review recipe](review.md) to the generated file. Scan for hardcoded v - 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. + - If still drifting after 3 tries: report to the user. The expression 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: +If the user is iterating on the expression 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. +- "Uncaptured" dimensions are the signal the expression 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. +The expression 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 expression itself. diff --git a/packages/ghost-drift/test/cli.test.ts b/packages/ghost-drift/test/cli.test.ts new file mode 100644 index 0000000..059c986 --- /dev/null +++ b/packages/ghost-drift/test/cli.test.ts @@ -0,0 +1,180 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildCli } from "../src/cli.js"; + +const BASE_EXPRESSION = `--- +id: local +source: llm +timestamp: 2026-04-24T00:00:00.000Z +palette: + dominant: + - { role: primary, value: "#111111" } + neutrals: { steps: ["#ffffff", "#111111"], count: 2 } + semantic: [] + saturationProfile: muted + contrast: high +spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 } +typography: + families: ["Inter"] + sizeRamp: [12, 16, 24] + weightDistribution: { 400: 1 } + lineHeightPattern: normal +surfaces: + borderRadii: [4, 8] + shadowComplexity: none + borderUsage: minimal +--- + +# Character + +Quiet and direct. + +# Signature + +- Small, plain surfaces + +# Decisions + +### shape-language +Use modest radii. +`; + +function expressionWithId(id: string): string { + return BASE_EXPRESSION.replace("id: local", `id: ${id}`); +} + +async function runCli(argv: string[], cwd: string) { + const cli = buildCli(); + const previousCwd = process.cwd(); + let stdout = ""; + let stderr = ""; + let exitCode: number | undefined; + let finish: () => void = () => {}; + const done = new Promise((resolve) => { + finish = resolve; + }); + + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { + stdout += `${args.join(" ")}\n`; + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + stderr += `${args.join(" ")}\n`; + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + exitCode = typeof code === "number" ? code : 0; + finish(); + return undefined as never; + }); + + try { + process.chdir(cwd); + cli.parse(["node", "ghost-drift", ...argv]); + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command did not exit")), 2000), + ), + ]); + } finally { + process.chdir(previousCwd); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } + + return { stdout, stderr, code: exitCode ?? 0 }; +} + +describe("ghost-drift CLI expression defaults", () => { + let dir: string; + + beforeEach(async () => { + dir = join( + tmpdir(), + `ghost-cli-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(dir, { recursive: true }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("lint defaults to ./expression.md", async () => { + await writeFile(join(dir, "expression.md"), expressionWithId("local")); + + const result = await runCli(["lint"], dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("0 error(s)"); + expect(result.stderr).toBe(""); + }); + + it("describe defaults to ./expression.md", async () => { + await writeFile(join(dir, "expression.md"), expressionWithId("local")); + + const result = await runCli(["describe"], dir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("expression.md"); + expect(result.stdout).toContain("# Character"); + }); + + it("compares explicitly supplied expression files", async () => { + await writeFile(join(dir, "a.expression.md"), expressionWithId("a")); + await writeFile(join(dir, "b.expression.md"), expressionWithId("b")); + + const result = await runCli( + ["compare", "a.expression.md", "b.expression.md"], + dir, + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Distance"); + }); + + it("track writes the neutral sync manifest shape", async () => { + await writeFile(join(dir, "expression.md"), expressionWithId("local")); + await writeFile( + join(dir, "tracked.expression.md"), + expressionWithId("tracked"), + ); + + const result = await runCli(["track", "tracked.expression.md"], dir); + const manifest = JSON.parse( + await readFile(join(dir, ".ghost-sync.json"), "utf-8"), + ) as Record; + + expect(result.code).toBe(0); + expect(manifest.tracks).toEqual({ + type: "path", + value: "tracked.expression.md", + }); + expect(manifest.trackedExpressionId).toBe("tracked"); + expect(manifest.localExpressionId).toBe("local"); + const legacyRelationFields = [ + "parent", + ["parent", "ExpressionId"].join(""), + ["child", "ExpressionId"].join(""), + ]; + for (const field of legacyRelationFields) { + expect(manifest).not.toHaveProperty(field); + } + }); +}); diff --git a/packages/ghost-drift/test/compare.test.ts b/packages/ghost-drift/test/compare.test.ts index aae2938..8db1d25 100644 --- a/packages/ghost-drift/test/compare.test.ts +++ b/packages/ghost-drift/test/compare.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { compare } from "../src/core/compare.js"; -import type { Fingerprint } from "../src/core/types.js"; +import type { Expression } from "../src/core/types.js"; -const BASE: Fingerprint = { +const BASE: Expression = { id: "base", source: "llm", timestamp: "2026-04-17T00:00:00.000Z", @@ -28,15 +28,12 @@ const BASE: Fingerprint = { embedding: [0.1, 0.2], }; -function variant( - id: string, - overrides: Partial = {}, -): Fingerprint { +function variant(id: string, overrides: Partial = {}): Expression { return { ...structuredClone(BASE), id, ...overrides }; } describe("compare dispatch", () => { - it("throws when given fewer than 2 fingerprints", () => { + it("throws when given fewer than 2 expressions", () => { expect(() => compare([variant("a")])).toThrow(/at least 2/); expect(() => compare([])).toThrow(/at least 2/); }); @@ -83,7 +80,7 @@ describe("compare dispatch", () => { expect(() => compare(exprs, { history: [] })).toThrow(/pairwise/); }); - it("composite uses provided ids, falls back to fingerprint.id", () => { + it("composite uses provided ids, falls back to expression.id", () => { const result = compare([variant("a"), variant("b"), variant("c")], { ids: ["alpha", "beta", "gamma"], }); diff --git a/packages/ghost-drift/test/context/__snapshots__/review-command.test.ts.snap b/packages/ghost-drift/test/context/__snapshots__/review-command.test.ts.snap index 183b730..d689ca5 100644 --- a/packages/ghost-drift/test/context/__snapshots__/review-command.test.ts.snap +++ b/packages/ghost-drift/test/context/__snapshots__/review-command.test.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`emitReviewCommand > produces a stable artifact for the ghost-ui fingerprint 1`] = ` +exports[`emitReviewCommand > produces a stable artifact for the ghost-ui expression 1`] = ` "--- -description: Drift review for ghost-ui — fitted to this system's design fingerprint +description: Drift review for ghost-ui — fitted to this expression's design language --- # ghost-ui drift review @@ -146,6 +146,6 @@ Drift score: XX/100 --- -Generated from \`fingerprint.md\` (11 decisions). Re-run \`ghost-drift emit review-command\` after fingerprint updates. +Generated from \`expression.md\` (11 decisions). Re-run \`ghost-drift emit review-command\` after expression updates. " `; diff --git a/packages/ghost-drift/test/context/review-command.test.ts b/packages/ghost-drift/test/context/review-command.test.ts index 56c6d76..b098954 100644 --- a/packages/ghost-drift/test/context/review-command.test.ts +++ b/packages/ghost-drift/test/context/review-command.test.ts @@ -2,26 +2,26 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { emitReviewCommand } from "../../src/core/context/review-command.js"; -import { loadFingerprint } from "../../src/core/fingerprint/index.js"; -import type { Fingerprint } from "../../src/core/types.js"; +import { loadExpression } from "../../src/core/expression/index.js"; +import type { Expression } from "../../src/core/types.js"; const GHOST_UI_EXPRESSION = fileURLToPath( - new URL("../../../ghost-ui/fingerprint.md", import.meta.url), + new URL("../../../ghost-ui/expression.md", import.meta.url), ); describe("emitReviewCommand", () => { - it("produces a stable artifact for the ghost-ui fingerprint", async () => { - const parsed = await loadFingerprint(GHOST_UI_EXPRESSION, { + it("produces a stable artifact for the ghost-ui expression", async () => { + const parsed = await loadExpression(GHOST_UI_EXPRESSION, { noEmbeddingBackfill: true, }); - const out = emitReviewCommand({ fingerprint: parsed.fingerprint }); + const out = emitReviewCommand({ expression: parsed.expression }); expect(out).toMatchSnapshot(); }); it("opens with the description frontmatter and an H1 role prompt", () => { const fp = minimalExpression(); - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); expect(out.startsWith("---\ndescription: Drift review for ")).toBe(true); expect(out).toMatch(/^# minimal drift review$/m); @@ -35,7 +35,7 @@ describe("emitReviewCommand", () => { { role: "surface-muted", value: "#f5f5f5" }, { role: "text-alt", value: "#666666" }, ]; - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); expect(out).toMatch(/\| danger must use the semantic token \| `#ff0000`/); expect(out).not.toMatch(/surface-muted must use the semantic token/); @@ -45,16 +45,16 @@ describe("emitReviewCommand", () => { it("elides the Serious palette table when no true semantic hues exist", () => { const fp = minimalExpression(); fp.palette.semantic = [{ role: "surface-muted", value: "#f5f5f5" }]; - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); expect(out).not.toMatch(/^### Serious$/m); }); - it("drops universal sections when the fingerprint has no data for them", () => { + it("drops universal sections when the expression has no data for them", () => { const fp = minimalExpression(); fp.surfaces.borderRadii = []; fp.spacing.scale = []; - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); expect(out).not.toMatch(/Shape language/); expect(out).not.toMatch(/Spacing drift/); @@ -67,9 +67,9 @@ describe("emitReviewCommand", () => { summary: "A spartan, monospaced system driven by code-native aesthetics.", personality: ["spartan", "monospaced"], distinctiveTraits: [], - closestSystems: [], + resembles: [], }; - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); expect(out).toMatch(/A spartan, monospaced system/); expect(out).toMatch(/reads as \*spartan, monospaced\*/); @@ -89,7 +89,7 @@ describe("emitReviewCommand", () => { evidence: [], }, ]; - const out = emitReviewCommand({ fingerprint: fp }); + const out = emitReviewCommand({ expression: fp }); // color-strategy appears as the palette rationale, not in "Other dimensions" expect(out).toMatch(/> Use color sparingly\./); @@ -100,7 +100,7 @@ describe("emitReviewCommand", () => { expect(otherBlock).not.toMatch(/### color-strategy/); }); - it("discovers the spike output ran against a real fingerprint", async () => { + it("discovers the spike output ran against a real expression", async () => { // Smoke: the file exists and parses, otherwise the first test would also // fail with a less obvious error. const raw = await readFile(GHOST_UI_EXPRESSION, "utf-8"); @@ -108,7 +108,7 @@ describe("emitReviewCommand", () => { }); }); -function minimalExpression(): Fingerprint { +function minimalExpression(): Expression { return { id: "minimal", source: "llm", diff --git a/packages/ghost-drift/test/context/writer.test.ts b/packages/ghost-drift/test/context/writer.test.ts index 3d90015..3c88f63 100644 --- a/packages/ghost-drift/test/context/writer.test.ts +++ b/packages/ghost-drift/test/context/writer.test.ts @@ -7,9 +7,9 @@ import { writeContextBundle, } from "../../src/core/context/index.js"; import { buildTokensCss } from "../../src/core/context/tokens-css.js"; -import type { Fingerprint } from "../../src/core/types.js"; +import type { Expression } from "../../src/core/types.js"; -const EXPRESSION: Fingerprint = { +const EXPRESSION: Expression = { id: "sample-ds", source: "llm", timestamp: "2026-04-17T00:00:00.000Z", @@ -17,7 +17,7 @@ const EXPRESSION: Fingerprint = { summary: "Restrained, utilitarian — warm neutrals on black.", personality: ["restrained", "utilitarian"], distinctiveTraits: ["true-black backgrounds", "tight type scale"], - closestSystems: ["Vercel", "Linear"], + resembles: ["Vercel", "Linear"], }, decisions: [ { @@ -63,10 +63,10 @@ afterEach(async () => { }); describe("writeContextBundle", () => { - it("default: emits SKILL.md + fingerprint.md + tokens.css", async () => { + it("default: emits SKILL.md + expression.md + tokens.css", async () => { const res = await writeContextBundle(EXPRESSION, { outDir: dir }); const names = res.files.map((f) => f.split("/").pop()); - expect(names).toEqual(["SKILL.md", "fingerprint.md", "tokens.css"]); + expect(names).toEqual(["SKILL.md", "expression.md", "tokens.css"]); const skill = await readFile(res.files[0], "utf-8"); expect(skill).toContain("user-invocable: true"); @@ -74,13 +74,13 @@ describe("writeContextBundle", () => { expect(skill).toContain("tokens.css"); }); - it("--no-tokens: emits SKILL.md + fingerprint.md only", async () => { + it("--no-tokens: emits SKILL.md + expression.md only", async () => { const res = await writeContextBundle(EXPRESSION, { outDir: dir, tokens: false, }); const names = res.files.map((f) => f.split("/").pop()); - expect(names).toEqual(["SKILL.md", "fingerprint.md"]); + expect(names).toEqual(["SKILL.md", "expression.md"]); const skill = await readFile(res.files[0], "utf-8"); expect(skill).not.toContain("tokens.css"); @@ -94,7 +94,7 @@ describe("writeContextBundle", () => { const names = res.files.map((f) => f.split("/").pop()); expect(names).toEqual([ "SKILL.md", - "fingerprint.md", + "expression.md", "tokens.css", "README.md", ]); @@ -120,14 +120,14 @@ describe("writeContextBundle", () => { it("tokens.css carries a provenance header with source path and timestamp", async () => { const res = await writeContextBundle(EXPRESSION, { outDir: dir, - sourcePath: "/path/to/fingerprint.md", + sourcePath: "/path/to/expression.md", generator: "ghost@0.9.0", }); const cssFile = res.files.find((f) => f.endsWith("tokens.css")); if (!cssFile) throw new Error("tokens.css missing from output"); const css = await readFile(cssFile, "utf-8"); expect(css).toMatch(/Generated by ghost@0\.9\.0/); - expect(css).toContain("/path/to/fingerprint.md"); + expect(css).toContain("/path/to/expression.md"); expect(css).toContain("DO NOT EDIT"); expect(css).toContain("2026-04-17T00:00:00.000Z"); }); @@ -144,8 +144,8 @@ describe("writeContextBundle", () => { }); describe("buildTokensCss", () => { - it("emits only dimensions present on the fingerprint", () => { - const minimal: Fingerprint = { + it("emits only dimensions present on the expression", () => { + const minimal: Expression = { ...EXPRESSION, typography: { families: [], diff --git a/packages/ghost-drift/test/embedding/compare-decisions.test.ts b/packages/ghost-drift/test/embedding/compare-decisions.test.ts index cce2e6c..50758cb 100644 --- a/packages/ghost-drift/test/embedding/compare-decisions.test.ts +++ b/packages/ghost-drift/test/embedding/compare-decisions.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; -import { compareFingerprints } from "../../src/core/embedding/compare.js"; -import type { DesignDecision, Fingerprint } from "../../src/core/types.js"; +import { compareExpressions } from "../../src/core/embedding/compare.js"; +import type { DesignDecision, Expression } from "../../src/core/types.js"; /** - * Minimal fingerprint skeleton — identical palette/spacing/typography/surfaces + * Minimal expression skeleton — identical palette/spacing/typography/surfaces * across fixtures so only the decisions layer affects the compare output. */ function baseExpression( id: string, decisions: DesignDecision[] = [], -): Fingerprint { +): Expression { return { id, source: "llm", @@ -46,9 +46,9 @@ function conceptVector(concept: number, dims = 8, jitter = 0): number[] { return v.map((x) => x / norm); } -describe("compareFingerprints — decisions", () => { +describe("compareExpressions — decisions", () => { it("paraphrased decisions match when embeddings are close", () => { - // Two fingerprints describe the same design decision with different words, + // Two expressions describe the same design decision with different words, // but the embeddings (simulated here) cluster near the same concept index. const a = baseExpression("a", [ { @@ -80,7 +80,7 @@ describe("compareFingerprints — decisions", () => { }, ]); - const result = compareFingerprints(a, b); + const result = compareExpressions(a, b); const decisionsDim = result.dimensions.decisions; expect(decisionsDim).toBeDefined(); @@ -119,7 +119,7 @@ describe("compareFingerprints — decisions", () => { }, ]); - const result = compareFingerprints(a, b); + const result = compareExpressions(a, b); expect(result.dimensions.decisions.distance).toBeGreaterThan(0.5); expect(result.dimensions.decisions.description).toMatch( /fundamentally|divergence/i, @@ -128,7 +128,7 @@ describe("compareFingerprints — decisions", () => { it("missing embeddings: decisions recorded but not scored", () => { // Decisions exist on both sides but neither has embeddings (pre-embedding - // fingerprint or no embedding provider was configured). The dimension + // expression or no embedding provider was configured). The dimension // should be reported qualitatively and contribute 0 to the weighted score. const a = baseExpression("a", [ { dimension: "color-strategy", decision: "X", evidence: [] }, @@ -137,7 +137,7 @@ describe("compareFingerprints — decisions", () => { { dimension: "color-strategy", decision: "Y", evidence: [] }, ]); - const result = compareFingerprints(a, b); + const result = compareExpressions(a, b); expect(result.dimensions.decisions.distance).toBe(0); expect(result.dimensions.decisions.description).toMatch(/not scored/i); @@ -150,7 +150,7 @@ describe("compareFingerprints — decisions", () => { const a = baseExpression("a", []); const b = baseExpression("b", []); - const result = compareFingerprints(a, b); + const result = compareExpressions(a, b); expect(result.dimensions.decisions).toBeUndefined(); }); }); diff --git a/packages/ghost-drift/test/embedding/embedding.test.ts b/packages/ghost-drift/test/embedding/embedding.test.ts index bfa5a7a..e2907a7 100644 --- a/packages/ghost-drift/test/embedding/embedding.test.ts +++ b/packages/ghost-drift/test/embedding/embedding.test.ts @@ -3,11 +3,11 @@ import { computeEmbedding, embeddingDistance, } from "../../src/core/embedding/embedding.js"; -import type { Fingerprint } from "../../src/core/types.js"; +import type { Expression } from "../../src/core/types.js"; function makeExpression( - overrides: Partial> = {}, -): Omit { + overrides: Partial> = {}, +): Omit { return { id: "test", source: "registry", @@ -69,7 +69,7 @@ describe("computeEmbedding", () => { } }); - it("identical fingerprints produce identical embeddings", () => { + it("identical expressions produce identical embeddings", () => { const fp = makeExpression(); const e1 = computeEmbedding(fp); const e2 = computeEmbedding(fp); diff --git a/packages/ghost-drift/test/evolution/composite.test.ts b/packages/ghost-drift/test/evolution/composite.test.ts index 696f4b5..60aaa77 100644 --- a/packages/ghost-drift/test/evolution/composite.test.ts +++ b/packages/ghost-drift/test/evolution/composite.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { compareComposite } from "../../src/core/evolution/composite.js"; -import type { CompositeMember, Fingerprint } from "../../src/core/types.js"; +import type { CompositeMember, Expression } from "../../src/core/types.js"; function makeCompositeMember( id: string, @@ -11,7 +11,7 @@ function makeCompositeMember( embedding[Number(idx)] = val; } - const fp: Fingerprint = { + const fp: Expression = { id, source: "registry", timestamp: new Date().toISOString(), @@ -44,7 +44,7 @@ function makeCompositeMember( embedding, }; - return { id, fingerprint: fp }; + return { id, expression: fp }; } describe("compareComposite", () => { diff --git a/packages/ghost-drift/test/evolution/sync.test.ts b/packages/ghost-drift/test/evolution/sync.test.ts index 1422245..2fd9de6 100644 --- a/packages/ghost-drift/test/evolution/sync.test.ts +++ b/packages/ghost-drift/test/evolution/sync.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { checkBounds } from "../../src/core/evolution/sync.js"; import type { DimensionAck, - Fingerprint, - FingerprintComparison, + Expression, + ExpressionComparison, SyncManifest, } from "../../src/core/types.js"; @@ -21,10 +21,10 @@ function makeManifest( } return { - parent: { type: "default" }, + tracks: { type: "path", value: "./tracked.expression.md" }, ackedAt: new Date().toISOString(), - parentFingerprintId: "parent", - childFingerprintId: "child", + trackedExpressionId: "tracked", + localExpressionId: "local", dimensions: fullDimensions, overallDistance: 0.2, }; @@ -32,8 +32,8 @@ function makeManifest( function makeComparison( dimensions: Record, -): FingerprintComparison { - const fp: Fingerprint = { +): ExpressionComparison { + const fp: Expression = { id: "test", source: "registry", timestamp: new Date().toISOString(), @@ -56,13 +56,6 @@ function makeComparison( shadowComplexity: "none", borderUsage: "minimal", }, - architecture: { - tokenization: 0, - methodology: [], - componentCount: 0, - componentCategories: {}, - namingPattern: "unknown", - }, embedding: [], }; @@ -122,7 +115,7 @@ describe("checkBounds", () => { expect(result.reconverging).toContain("palette"); }); - it("does not flag reconverging if still far from parent", () => { + it("does not flag reconverging if still far from tracked expression", () => { const manifest = makeManifest({ palette: { distance: 0.4, stance: "diverging" }, }); diff --git a/packages/ghost-drift/test/fingerprint/compose.test.ts b/packages/ghost-drift/test/expression/compose.test.ts similarity index 59% rename from packages/ghost-drift/test/fingerprint/compose.test.ts rename to packages/ghost-drift/test/expression/compose.test.ts index cedb058..4a5e9a1 100644 --- a/packages/ghost-drift/test/fingerprint/compose.test.ts +++ b/packages/ghost-drift/test/expression/compose.test.ts @@ -3,13 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - loadFingerprint, - mergeFingerprint, -} from "../../src/core/fingerprint/index.js"; -import type { Fingerprint } from "../../src/core/types.js"; + loadExpression, + mergeExpression, +} from "../../src/core/expression/index.js"; +import type { Expression } from "../../src/core/types.js"; -const PARENT: Fingerprint = { - id: "parent", +const BASE: Expression = { + id: "base", source: "llm", timestamp: "2026-04-17T00:00:00.000Z", decisions: [ @@ -50,15 +50,15 @@ const PARENT: Fingerprint = { embedding: [0.1, 0.2], }; -describe("mergeFingerprint", () => { - it("child scalar replaces parent scalar", () => { - const child: Partial = { id: "child" }; - const merged = mergeFingerprint(PARENT, child); - expect(merged.id).toBe("child"); +describe("mergeExpression", () => { + it("overlay scalar replaces base scalar", () => { + const overlay: Partial = { id: "overlay" }; + const merged = mergeExpression(BASE, overlay); + expect(merged.id).toBe("overlay"); }); - it("decisions merge by dimension: child wins per-dim, parent-only kept", () => { - const child: Partial = { + it("decisions merge by dimension: overlay wins per-dim, base-only kept", () => { + const overlay: Partial = { decisions: [ { dimension: "warm-neutrals", @@ -72,7 +72,7 @@ describe("mergeFingerprint", () => { }, ], }; - const merged = mergeFingerprint(PARENT, child); + const merged = mergeExpression(BASE, overlay); expect(merged.decisions).toHaveLength(3); const warm = merged.decisions?.find((d) => d.dimension === "warm-neutrals"); expect(warm?.decision).toBe("Now no warm grays either."); @@ -85,67 +85,67 @@ describe("mergeFingerprint", () => { }); it("palette.dominant merges by role", () => { - const child: Partial = { + const overlay: Partial = { palette: { dominant: [{ role: "accent", value: "#ff0000" }], - neutrals: PARENT.palette.neutrals, + neutrals: BASE.palette.neutrals, semantic: [], saturationProfile: "muted", contrast: "moderate", }, }; - const merged = mergeFingerprint(PARENT, child); + const merged = mergeExpression(BASE, overlay); const accent = merged.palette.dominant.find((c) => c.role === "accent"); expect(accent?.value).toBe("#ff0000"); const surface = merged.palette.dominant.find((c) => c.role === "surface"); - expect(surface?.value).toBe("#f5f4ed"); // parent-only preserved + expect(surface?.value).toBe("#f5f4ed"); // base-only preserved }); - it("values replace wholesale when child has them", () => { - const child: Partial = { + it("values replace wholesale when overlay has them", () => { + const overlay: Partial = { values: { do: ["new-do"], dont: [] }, }; - const merged = mergeFingerprint(PARENT, child); + const merged = mergeExpression(BASE, overlay); expect(merged.values?.do).toEqual(["new-do"]); expect(merged.values?.dont).toEqual([]); }); - it("roles merge by name: child wins per-slot, parent-only roles kept", () => { - const parentWithRoles: Fingerprint = { - ...PARENT, + it("roles merge by name: overlay wins per-slot, base-only roles kept", () => { + const baseWithRoles: Expression = { + ...BASE, roles: [ { name: "h1", tokens: { typography: { family: "Serif", size: 32 } }, - evidence: ["parent.tsx"], + evidence: ["base.tsx"], }, { name: "body", tokens: { typography: { family: "Sans", size: 16 } }, - evidence: ["parent.tsx"], + evidence: ["base.tsx"], }, ], }; - const child: Partial = { + const overlay: Partial = { roles: [ { name: "h1", tokens: { typography: { family: "Serif", size: 64 } }, - evidence: ["child.tsx"], + evidence: ["overlay.tsx"], }, ], }; - const merged = mergeFingerprint(parentWithRoles, child); + const merged = mergeExpression(baseWithRoles, overlay); expect(merged.roles).toHaveLength(2); const h1 = merged.roles?.find((r) => r.name === "h1"); expect(h1?.tokens.typography?.size).toBe(64); - expect(h1?.evidence).toEqual(["child.tsx"]); + expect(h1?.evidence).toEqual(["overlay.tsx"]); const body = merged.roles?.find((r) => r.name === "body"); expect(body?.tokens.typography?.size).toBe(16); }); }); -describe("loadFingerprint extends resolution", () => { +describe("loadExpression extends resolution", () => { let dir: string; beforeEach(async () => { @@ -160,12 +160,12 @@ describe("loadFingerprint extends resolution", () => { await rm(dir, { recursive: true, force: true }); }); - it("child inherits parent fields and overrides what it specifies", async () => { - const parentPath = join(dir, "parent.fingerprint.md"); - const childPath = join(dir, "child.fingerprint.md"); + it("overlay inherits base fields and overrides what it specifies", async () => { + const basePath = join(dir, "base.expression.md"); + const overlayPath = join(dir, "overlay.expression.md"); - const parentMd = `--- -id: parent + const baseMd = `--- +id: base source: llm timestamp: 2026-04-17T00:00:00.000Z palette: @@ -193,56 +193,56 @@ decisions: # Decisions ### warm -parent warm rule +base warm rule **Evidence:** - \`#111\` `; - const childMd = `--- -extends: ./parent.fingerprint.md -id: child + const overlayMd = `--- +extends: ./base.expression.md +id: overlay decisions: - dimension: warm - - dimension: child-new + - dimension: overlay-new --- # Decisions ### warm -child overrides warm +overlay overrides warm -### child-new +### overlay-new a new decision `; - await writeFile(parentPath, parentMd, "utf-8"); - await writeFile(childPath, childMd, "utf-8"); + await writeFile(basePath, baseMd, "utf-8"); + await writeFile(overlayPath, overlayMd, "utf-8"); - const { fingerprint, meta } = await loadFingerprint(childPath); + const { expression, meta } = await loadExpression(overlayPath); expect(meta.extends).toBeUndefined(); // stripped after resolve - expect(fingerprint.id).toBe("child"); - // Inherited from parent - expect(fingerprint.palette.dominant).toHaveLength(1); - expect(fingerprint.palette.dominant[0].value).toBe("#c96442"); - expect(fingerprint.spacing.scale).toEqual([8, 16]); + expect(expression.id).toBe("overlay"); + // Inherited from base + expect(expression.palette.dominant).toHaveLength(1); + expect(expression.palette.dominant[0].value).toBe("#c96442"); + expect(expression.spacing.scale).toEqual([8, 16]); // Decision overrides - const warm = fingerprint.decisions?.find((d) => d.dimension === "warm"); - expect(warm?.decision).toBe("child overrides warm"); - // New child decision - const added = fingerprint.decisions?.find( - (d) => d.dimension === "child-new", + const warm = expression.decisions?.find((d) => d.dimension === "warm"); + expect(warm?.decision).toBe("overlay overrides warm"); + // New overlay decision + const added = expression.decisions?.find( + (d) => d.dimension === "overlay-new", ); expect(added).toBeDefined(); }); it("detects cycles in extends chains", async () => { - const aPath = join(dir, "a.fingerprint.md"); - const bPath = join(dir, "b.fingerprint.md"); + const aPath = join(dir, "a.expression.md"); + const bPath = join(dir, "b.expression.md"); await writeFile( aPath, `--- -extends: ./b.fingerprint.md +extends: ./b.expression.md id: a --- `, @@ -251,32 +251,32 @@ id: a await writeFile( bPath, `--- -extends: ./a.fingerprint.md +extends: ./a.expression.md id: b --- `, "utf-8", ); - await expect(loadFingerprint(aPath)).rejects.toThrow(/[Cc]ycle/); + await expect(loadExpression(aPath)).rejects.toThrow(/[Cc]ycle/); }); - it("noExtends: true skips parent resolution", async () => { - const parentPath = join(dir, "parent.fingerprint.md"); - const childPath = join(dir, "child.fingerprint.md"); - await writeFile(parentPath, "---\nschema: 6\nid: parent\n---\n", "utf-8"); + it("noExtends: true skips base resolution", async () => { + const basePath = join(dir, "base.expression.md"); + const overlayPath = join(dir, "overlay.expression.md"); + await writeFile(basePath, "---\nschema: 6\nid: base\n---\n", "utf-8"); await writeFile( - childPath, + overlayPath, `--- -extends: ./parent.fingerprint.md -id: child +extends: ./base.expression.md +id: overlay --- `, "utf-8", ); - const { fingerprint, meta } = await loadFingerprint(childPath, { + const { expression, meta } = await loadExpression(overlayPath, { noExtends: true, }); - expect(fingerprint.id).toBe("child"); - expect(meta.extends).toBe("./parent.fingerprint.md"); + expect(expression.id).toBe("overlay"); + expect(meta.extends).toBe("./base.expression.md"); }); }); diff --git a/packages/ghost-drift/test/fingerprint/diff.test.ts b/packages/ghost-drift/test/expression/diff.test.ts similarity index 80% rename from packages/ghost-drift/test/fingerprint/diff.test.ts rename to packages/ghost-drift/test/expression/diff.test.ts index 0ac493b..c76aecc 100644 --- a/packages/ghost-drift/test/fingerprint/diff.test.ts +++ b/packages/ghost-drift/test/expression/diff.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { diffFingerprints } from "../../src/core/fingerprint/index.js"; -import type { Fingerprint } from "../../src/core/types.js"; +import { diffExpressions } from "../../src/core/expression/index.js"; +import type { Expression } from "../../src/core/types.js"; -const BASE: Fingerprint = { +const BASE: Expression = { id: "base", source: "llm", timestamp: "2026-04-17T00:00:00.000Z", @@ -40,14 +40,14 @@ const BASE: Fingerprint = { embedding: [0.1, 0.2], }; -describe("diffFingerprints", () => { - it("returns unchanged=true for an identical fingerprint", () => { - const diff = diffFingerprints(BASE, structuredClone(BASE)); +describe("diffExpressions", () => { + it("returns unchanged=true for an identical expression", () => { + const diff = diffExpressions(BASE, structuredClone(BASE)); expect(diff.unchanged).toBe(true); }); it("detects added and removed decisions by dimension", () => { - const b: Fingerprint = structuredClone(BASE); + const b: Expression = structuredClone(BASE); const first = BASE.decisions?.[0]; if (!first) throw new Error("BASE must have a first decision"); b.decisions = [ @@ -58,7 +58,7 @@ describe("diffFingerprints", () => { evidence: [], }, ]; - const diff = diffFingerprints(BASE, b); + const diff = diffExpressions(BASE, b); expect(diff.decisions.added.map((d) => d.dimension)).toEqual(["new-thing"]); expect(diff.decisions.removed.map((d) => d.dimension)).toEqual([ "serif-headlines", @@ -66,12 +66,12 @@ describe("diffFingerprints", () => { }); it("detects modified decisions (both prose and evidence deltas)", () => { - const b: Fingerprint = structuredClone(BASE); + const b: Expression = structuredClone(BASE); const d0 = b.decisions?.[0]; if (!d0) throw new Error("BASE must have a first decision"); d0.decision = "No cool grays, no cool whites."; d0.evidence = ["#141413", "#4d4c48", "#5e5d59"]; - const diff = diffFingerprints(BASE, b); + const diff = diffExpressions(BASE, b); expect(diff.decisions.modified).toHaveLength(1); expect(diff.decisions.modified[0].dimension).toBe("warm-neutrals"); expect(diff.decisions.modified[0].decisionChanged).toBe(true); @@ -79,13 +79,13 @@ describe("diffFingerprints", () => { }); it("detects palette role swaps and value changes", () => { - const b: Fingerprint = structuredClone(BASE); + const b: Expression = structuredClone(BASE); b.palette.dominant = [{ role: "accent", value: "#d15a40" }]; b.palette.semantic = [ { role: "error", value: "#b53333" }, { role: "focus", value: "#3898ec" }, ]; - const diff = diffFingerprints(BASE, b); + const diff = diffExpressions(BASE, b); expect(diff.palette.dominantChanged).toEqual([ { role: "accent", from: "#c96442", to: "#d15a40" }, ]); @@ -95,10 +95,10 @@ describe("diffFingerprints", () => { }); it("detects scalar token changes", () => { - const b: Fingerprint = structuredClone(BASE); + const b: Expression = structuredClone(BASE); b.spacing.scale = [4, 8, 16, 24, 32]; b.surfaces.shadowComplexity = "layered"; - const diff = diffFingerprints(BASE, b); + const diff = diffExpressions(BASE, b); const fields = diff.tokens.map((t) => t.field); expect(fields).toContain("spacing.scale"); expect(fields).toContain("surfaces.shadowComplexity"); diff --git a/packages/ghost-drift/test/fingerprint/fragments.test.ts b/packages/ghost-drift/test/expression/fragments.test.ts similarity index 70% rename from packages/ghost-drift/test/fingerprint/fragments.test.ts rename to packages/ghost-drift/test/expression/fragments.test.ts index 4fe1fce..14d8592 100644 --- a/packages/ghost-drift/test/fingerprint/fragments.test.ts +++ b/packages/ghost-drift/test/expression/fragments.test.ts @@ -5,11 +5,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { EMBEDDING_FRAGMENT_FILENAME, findFragmentLinks, - loadFingerprint, + loadExpression, serializeEmbeddingFragment, - serializeFingerprint, -} from "../../src/core/fingerprint/index.js"; -import type { Fingerprint } from "../../src/core/types.js"; + serializeExpression, +} from "../../src/core/expression/index.js"; +import type { Expression } from "../../src/core/types.js"; const BASE_EXPRESSION = `--- id: base @@ -57,9 +57,9 @@ describe("decision fragments", () => { await rm(dir, { recursive: true, force: true }); }); - it("assembles decisions/*.md into the fingerprint", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - await writeFile(fingerprintPath, BASE_EXPRESSION, "utf-8"); + it("assembles decisions/*.md into the expression", async () => { + const expressionPath = join(dir, "expression.md"); + await writeFile(expressionPath, BASE_EXPRESSION, "utf-8"); await mkdir(join(dir, "decisions"), { recursive: true }); await writeFile( join(dir, "decisions", "warm-neutrals.md"), @@ -79,12 +79,12 @@ No cool grays anywhere. "utf-8", ); - const { fingerprint } = await loadFingerprint(fingerprintPath); - const dims = fingerprint.decisions?.map((d) => d.dimension) ?? []; + const { expression } = await loadExpression(expressionPath); + const dims = expression.decisions?.map((d) => d.dimension) ?? []; expect(dims).toContain("inline-rule"); expect(dims).toContain("warm-neutrals"); expect(dims).toContain("from-filename"); - const warm = fingerprint.decisions?.find( + const warm = expression.decisions?.find( (d) => d.dimension === "warm-neutrals", ); expect(warm?.evidence).toEqual(["#111", "#222"]); @@ -92,8 +92,8 @@ No cool grays anywhere. }); it("fragment overrides inline decision with same dimension", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - await writeFile(fingerprintPath, BASE_EXPRESSION, "utf-8"); + const expressionPath = join(dir, "expression.md"); + await writeFile(expressionPath, BASE_EXPRESSION, "utf-8"); await mkdir(join(dir, "decisions"), { recursive: true }); await writeFile( join(dir, "decisions", "inline-rule.md"), @@ -106,16 +106,16 @@ Overridden by fragment. "utf-8", ); - const { fingerprint } = await loadFingerprint(fingerprintPath); - const rule = fingerprint.decisions?.find( + const { expression } = await loadExpression(expressionPath); + const rule = expression.decisions?.find( (d) => d.dimension === "inline-rule", ); expect(rule?.decision).toBe("Overridden by fragment."); }); it("noFragments: true skips the decisions/ directory", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - await writeFile(fingerprintPath, BASE_EXPRESSION, "utf-8"); + const expressionPath = join(dir, "expression.md"); + await writeFile(expressionPath, BASE_EXPRESSION, "utf-8"); await mkdir(join(dir, "decisions"), { recursive: true }); await writeFile( join(dir, "decisions", "extra.md"), @@ -123,22 +123,22 @@ Overridden by fragment. "utf-8", ); - const { fingerprint } = await loadFingerprint(fingerprintPath, { + const { expression } = await loadExpression(expressionPath, { noFragments: true, }); - const dims = fingerprint.decisions?.map((d) => d.dimension) ?? []; + const dims = expression.decisions?.map((d) => d.dimension) ?? []; expect(dims).not.toContain("extra"); }); it("ignores absent decisions/ directory silently", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - await writeFile(fingerprintPath, BASE_EXPRESSION, "utf-8"); - const { fingerprint } = await loadFingerprint(fingerprintPath); - expect(fingerprint.decisions).toHaveLength(1); + const expressionPath = join(dir, "expression.md"); + await writeFile(expressionPath, BASE_EXPRESSION, "utf-8"); + const { expression } = await loadExpression(expressionPath); + expect(expression.decisions).toHaveLength(1); }); }); -const V4_FINGERPRINT: Fingerprint = { +const V4_EXPRESSION: Expression = { id: "v4-sample", source: "llm", timestamp: "2026-04-19T00:00:00.000Z", @@ -180,44 +180,44 @@ describe("embedding fragment", () => { }); it("loader reads the vector from a sibling embedding.md when frontmatter omits it", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - const md = serializeFingerprint(V4_FINGERPRINT); - await writeFile(fingerprintPath, md, "utf-8"); + const expressionPath = join(dir, "expression.md"); + const md = serializeExpression(V4_EXPRESSION); + await writeFile(expressionPath, md, "utf-8"); const vector = [0.9, 0.8, 0.7, 0.6, 0.5]; const fragPath = join(dir, EMBEDDING_FRAGMENT_FILENAME); await writeFile( fragPath, - serializeEmbeddingFragment(vector, V4_FINGERPRINT.id), + serializeEmbeddingFragment(vector, V4_EXPRESSION.id), "utf-8", ); - const { fingerprint } = await loadFingerprint(fingerprintPath); + const { expression } = await loadExpression(expressionPath); // The sibling vector wins over the recomputed one - expect(fingerprint.embedding).toEqual(vector); + expect(expression.embedding).toEqual(vector); }); it("loader recomputes the embedding when no sibling file exists", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - const md = serializeFingerprint(V4_FINGERPRINT); - await writeFile(fingerprintPath, md, "utf-8"); + const expressionPath = join(dir, "expression.md"); + const md = serializeExpression(V4_EXPRESSION); + await writeFile(expressionPath, md, "utf-8"); - const { fingerprint } = await loadFingerprint(fingerprintPath); + const { expression } = await loadExpression(expressionPath); // No sibling; loader falls back to computeEmbedding — which produces a // 49-dim vector. Length tells us the recompute path fired. - expect(fingerprint.embedding).toHaveLength(49); + expect(expression.embedding).toHaveLength(49); }); it("noEmbeddingBackfill skips both fragment read and recompute", async () => { - const fingerprintPath = join(dir, "fingerprint.md"); - const md = serializeFingerprint(V4_FINGERPRINT); - await writeFile(fingerprintPath, md, "utf-8"); + const expressionPath = join(dir, "expression.md"); + const md = serializeExpression(V4_EXPRESSION); + await writeFile(expressionPath, md, "utf-8"); - const { fingerprint } = await loadFingerprint(fingerprintPath, { + const { expression } = await loadExpression(expressionPath, { noEmbeddingBackfill: true, }); // Frontmatter had no embedding and backfill is off — stays empty. - expect(fingerprint.embedding ?? []).toHaveLength(0); + expect(expression.embedding ?? []).toHaveLength(0); }); }); diff --git a/packages/ghost-drift/test/fingerprint/layout.test.ts b/packages/ghost-drift/test/expression/layout.test.ts similarity index 89% rename from packages/ghost-drift/test/fingerprint/layout.test.ts rename to packages/ghost-drift/test/expression/layout.test.ts index 6da6fe5..a6e7bc1 100644 --- a/packages/ghost-drift/test/fingerprint/layout.test.ts +++ b/packages/ghost-drift/test/expression/layout.test.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { layoutFingerprint } from "../../src/core/fingerprint/layout.js"; +import { layoutExpression } from "../../src/core/expression/layout.js"; const here = resolve(fileURLToPath(import.meta.url), ".."); @@ -57,9 +57,9 @@ More prose. - [embedding](embedding.md) `; -describe("layoutFingerprint", () => { - it("maps frontmatter, body sections, and decision H3s on a typical fingerprint", () => { - const layout = layoutFingerprint(SAMPLE); +describe("layoutExpression", () => { + it("maps frontmatter, body sections, and decision H3s on a typical expression", () => { + const layout = layoutExpression(SAMPLE); const fm = layout.sections.find((s) => s.kind === "frontmatter"); expect(fm).toBeDefined(); @@ -95,7 +95,7 @@ describe("layoutFingerprint", () => { "color-strategy", "shape-language", ]); - // Each decision must sit fully inside the parent Decisions section. + // Each decision must sit fully inside the enclosing Decisions section. for (const d of decisionBlocks) { expect(d.start).toBeGreaterThanOrEqual(decisions?.start ?? 0); expect(d.end).toBeLessThanOrEqual(decisions?.end ?? 0); @@ -105,7 +105,7 @@ describe("layoutFingerprint", () => { }); it("produces 1-indexed inclusive ranges suitable for the Read tool's offset/limit", () => { - const layout = layoutFingerprint(SAMPLE); + const layout = layoutExpression(SAMPLE); const lines = SAMPLE.split("\n"); for (const s of layout.sections) { // start line is the heading itself for body/decision, or `---` for frontmatter @@ -121,7 +121,7 @@ describe("layoutFingerprint", () => { }); it("returns no frontmatter section when the file lacks one", () => { - const layout = layoutFingerprint("# Character\n\nProse only.\n"); + const layout = layoutExpression("# Character\n\nProse only.\n"); expect( layout.sections.find((s) => s.kind === "frontmatter"), ).toBeUndefined(); @@ -133,7 +133,7 @@ describe("layoutFingerprint", () => { }); it("returns no frontmatter section when the YAML block is unterminated", () => { - const layout = layoutFingerprint( + const layout = layoutExpression( `---\nid: x\npalette: foo\n# stray heading\n`, ); // No closing `---` → describe must not invent one. The H1 still surfaces. @@ -153,7 +153,7 @@ spacing: { scale: [1] } x `; - const layout = layoutFingerprint(broken); + const layout = layoutExpression(broken); const fm = layout.sections.find((s) => s.kind === "frontmatter"); expect(fm?.partitions).toContain("palette"); expect(fm?.partitions).toContain("spacing"); @@ -161,7 +161,7 @@ x it("emits zero decision sections when # Decisions has no H3s", () => { const md = `${FRONTMATTER}\n\n# Character\n\nx\n\n# Decisions\n\nNo subheadings yet.\n`; - const layout = layoutFingerprint(md); + const layout = layoutExpression(md); expect(layout.sections.filter((s) => s.kind === "decision")).toHaveLength( 0, ); @@ -172,10 +172,10 @@ x ).toBeDefined(); }); - it("matches structural expectations against the real ghost-ui fingerprint", async () => { - const path = resolve(here, "../../../ghost-ui/fingerprint.md"); + it("matches structural expectations against the real ghost-ui expression", async () => { + const path = resolve(here, "../../../ghost-ui/expression.md"); const raw = await readFile(path, "utf-8"); - const layout = layoutFingerprint(raw); + const layout = layoutExpression(raw); const fm = layout.sections.find((s) => s.kind === "frontmatter"); expect(fm?.start).toBe(1); diff --git a/packages/ghost-drift/test/fingerprint/lint.test.ts b/packages/ghost-drift/test/expression/lint.test.ts similarity index 88% rename from packages/ghost-drift/test/fingerprint/lint.test.ts rename to packages/ghost-drift/test/expression/lint.test.ts index 557f2da..fe069e1 100644 --- a/packages/ghost-drift/test/fingerprint/lint.test.ts +++ b/packages/ghost-drift/test/expression/lint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { lintFingerprint } from "../../src/core/fingerprint/index.js"; +import { lintExpression } from "../../src/core/expression/index.js"; const HEADER = `--- name: Claude @@ -35,12 +35,12 @@ function build(frontmatterExtras: string, body: string): string { return `${HEADER}${frontmatterExtras}\n${PALETTE_BLOCK}\n---\n\n${body}`; } -describe("lintFingerprint", () => { +describe("lintExpression", () => { it("reports no errors on a clean file", () => { const md = build( `\nobservation: personality: [] - closestSystems: [] + resembles: [] decisions: - dimension: warm-neutrals`, `# Character @@ -60,7 +60,7 @@ No cool grays - \`#141413\` `, ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect(report.errors).toBe(0); }); @@ -70,7 +70,7 @@ No cool grays - dimension: warm-neutrals`, `# Only a stray heading`, ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect( report.issues.some( (i) => i.rule === "orphan-dimension" && /warm-neutrals/.test(i.message), @@ -87,7 +87,7 @@ No cool grays Rationale without frontmatter decisions[] entry — body is authoritative. `, ); - const report = lintFingerprint(md); + const report = lintExpression(md); // Schema 5: body ### headings are authoritative and can stand alone; // only unmatched frontmatter slugs are flagged. expect(report.issues.some((i) => i.rule === "orphan-dimension")).toBe( @@ -100,13 +100,13 @@ Rationale without frontmatter decisions[] entry — body is authoritative. `\nobservation: summary: "prose in YAML — should fail" personality: [] - closestSystems: [] + resembles: [] values: do: ["stray YAML"] dont: []`, ``, ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect(report.issues.some((i) => i.rule === "schema-invalid")).toBe(true); expect(report.errors).toBeGreaterThan(0); }); @@ -124,13 +124,13 @@ refers to a ghost color - \`#000000\` `, ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect(report.issues.some((i) => i.rule === "broken-evidence")).toBe(true); }); it("flags palette colors not cited in any decision as info", () => { const md = build("", ""); - const report = lintFingerprint(md); + const report = lintExpression(md); const unused = report.issues.filter((i) => i.rule === "unused-palette"); expect(unused.length).toBeGreaterThan(0); expect(unused.every((i) => i.severity === "info")).toBe(true); @@ -138,13 +138,13 @@ refers to a ghost color it("honors --off to silence a rule", () => { const md = build("", ""); - const report = lintFingerprint(md, { off: ["unused-palette"] }); + const report = lintExpression(md, { off: ["unused-palette"] }); expect(report.issues.some((i) => i.rule === "unused-palette")).toBe(false); }); it("honors --strict to promote a rule to error", () => { const md = build("", ""); - const report = lintFingerprint(md, { strict: ["unused-palette"] }); + const report = lintExpression(md, { strict: ["unused-palette"] }); const unused = report.issues.filter((i) => i.rule === "unused-palette"); expect(unused.length).toBeGreaterThan(0); expect(unused.every((i) => i.severity === "error")).toBe(true); @@ -160,7 +160,7 @@ roles: evidence: ["src/ui/button.tsx:12"]`, "", ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( false, ); @@ -176,7 +176,7 @@ roles: evidence: ["src/ui/button.tsx:12"]`, "", ); - const report = lintFingerprint(md); + const report = lintExpression(md); const broken = report.issues.filter( (i) => i.rule === "broken-role-reference", ); @@ -195,7 +195,7 @@ roles: evidence: ["src/ui/button.tsx:12"]`, "", ); - const report = lintFingerprint(md); + const report = lintExpression(md); const broken = report.issues.find( (i) => i.rule === "broken-role-reference", ); @@ -213,7 +213,7 @@ roles: evidence: ["src/ui/button.tsx:12"]`, "", ); - const report = lintFingerprint(md); + const report = lintExpression(md); expect(report.issues.some((i) => i.rule === "broken-role-reference")).toBe( false, ); diff --git a/packages/ghost-drift/test/fingerprint/load.test.ts b/packages/ghost-drift/test/expression/load.test.ts similarity index 66% rename from packages/ghost-drift/test/fingerprint/load.test.ts rename to packages/ghost-drift/test/expression/load.test.ts index 1d59e56..964a71f 100644 --- a/packages/ghost-drift/test/fingerprint/load.test.ts +++ b/packages/ghost-drift/test/expression/load.test.ts @@ -3,13 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { - loadFingerprint, - parseFingerprint, - serializeFingerprint, -} from "../../src/core/fingerprint/index.js"; -import type { Fingerprint } from "../../src/core/types.js"; + loadExpression, + parseExpression, + serializeExpression, +} from "../../src/core/expression/index.js"; +import type { Expression } from "../../src/core/types.js"; -const SAMPLE_EXPRESSION: Fingerprint = { +const SAMPLE_EXPRESSION: Expression = { id: "claude", source: "llm", timestamp: "2026-04-17T00:00:00.000Z", @@ -36,7 +36,7 @@ const SAMPLE_EXPRESSION: Fingerprint = { }; // Schema 5: frontmatter carries machine-facts only (dimension slug + -// optional embedding, personality/closestSystems tags). Prose + evidence +// optional embedding, personality/resembles tags). Prose + evidence // (Character, Signature, `### dimension` rationale + `**Evidence:**` // bullets) all live in the body. const SAMPLE_MD = `--- @@ -49,7 +49,7 @@ source: llm timestamp: 2026-04-17T00:00:00.000Z observation: personality: [restrained, editorial] - closestSystems: [notion, linear] + resembles: [notion, linear] decisions: - dimension: warm-only-neutrals - dimension: serif-authority-sans-utility @@ -107,50 +107,47 @@ All headlines serif 500. UI sans 400-500. - \`Buttons and labels sans 400-500\` `; -describe("parseFingerprint", () => { +describe("parseExpression", () => { it("extracts machine-layer fields from the frontmatter", () => { - const { fingerprint, meta } = parseFingerprint(SAMPLE_MD); - expect(fingerprint.id).toBe("claude"); - expect(fingerprint.palette.dominant[0].value).toBe("#c96442"); - expect(fingerprint.palette.neutrals.steps).toHaveLength(3); - expect(fingerprint.typography.families).toContain("Anthropic Serif"); - expect(fingerprint.spacing.baseUnit).toBe(8); - expect(fingerprint.surfaces.borderRadii).toEqual([8, 12, 16]); - expect(fingerprint.embedding).toHaveLength(8); + const { expression, meta } = parseExpression(SAMPLE_MD); + expect(expression.id).toBe("claude"); + expect(expression.palette.dominant[0].value).toBe("#c96442"); + expect(expression.palette.neutrals.steps).toHaveLength(3); + expect(expression.typography.families).toContain("Anthropic Serif"); + expect(expression.spacing.baseUnit).toBe(8); + expect(expression.surfaces.borderRadii).toEqual([8, 12, 16]); + expect(expression.embedding).toHaveLength(8); expect(meta.name).toBe("Claude"); expect(meta.confidence).toBe(0.87); }); it("merges body Character into observation.summary", () => { - const { fingerprint } = parseFingerprint(SAMPLE_MD); - expect(fingerprint.observation?.summary).toContain("literary salon"); + const { expression } = parseExpression(SAMPLE_MD); + expect(expression.observation?.summary).toContain("literary salon"); }); it("merges body Signature into observation.distinctiveTraits", () => { - const { fingerprint } = parseFingerprint(SAMPLE_MD); - expect(fingerprint.observation?.distinctiveTraits).toEqual([ + const { expression } = parseExpression(SAMPLE_MD); + expect(expression.observation?.distinctiveTraits).toEqual([ "Warm ring-shadows instead of drop-shadows", "Editorial serif/sans split", "Light/dark section alternation", ]); }); - it("keeps observation tags (personality, closestSystems) from frontmatter", () => { - const { fingerprint } = parseFingerprint(SAMPLE_MD); - expect(fingerprint.observation?.personality).toEqual([ + it("keeps observation tags (personality, resembles) from frontmatter", () => { + const { expression } = parseExpression(SAMPLE_MD); + expect(expression.observation?.personality).toEqual([ "restrained", "editorial", ]); - expect(fingerprint.observation?.closestSystems).toEqual([ - "notion", - "linear", - ]); + expect(expression.observation?.resembles).toEqual(["notion", "linear"]); }); it("joins frontmatter evidence with body rationale by dimension", () => { - const { fingerprint } = parseFingerprint(SAMPLE_MD); - expect(fingerprint.decisions).toHaveLength(2); - const warm = fingerprint.decisions?.[0]; + const { expression } = parseExpression(SAMPLE_MD); + expect(expression.decisions).toHaveLength(2); + const warm = expression.decisions?.[0]; expect(warm?.dimension).toBe("warm-only-neutrals"); expect(warm?.evidence).toEqual(["#141413", "#4d4c48", "#87867f"]); expect(warm?.decision).toContain("yellow-brown undertone"); @@ -161,13 +158,13 @@ describe("parseFingerprint", () => { "observation:\n personality: [restrained, editorial]", `observation:\n summary: "prose in YAML"\n personality: [restrained, editorial]`, ); - expect(() => parseFingerprint(bad)).toThrow( - /Invalid fingerprint frontmatter[\s\S]*observation/, + expect(() => parseExpression(bad)).toThrow( + /Invalid expression frontmatter[\s\S]*observation/, ); }); it("throws when the frontmatter delimiter is missing", () => { - expect(() => parseFingerprint("# just a heading")).toThrow(/frontmatter/i); + expect(() => parseExpression("# just a heading")).toThrow(/frontmatter/i); }); it("surfaces the bad field path when validation fails", () => { @@ -175,7 +172,7 @@ describe("parseFingerprint", () => { "saturationProfile: muted", "saturationProfile: electric", ); - expect(() => parseFingerprint(bad)).toThrow(/palette\.saturationProfile/); + expect(() => parseExpression(bad)).toThrow(/palette\.saturationProfile/); }); it("skipValidation bypasses zod (for lint tooling)", () => { @@ -183,29 +180,29 @@ describe("parseFingerprint", () => { "saturationProfile: muted", "saturationProfile: electric", ); - expect(() => parseFingerprint(bad, { skipValidation: true })).not.toThrow(); + expect(() => parseExpression(bad, { skipValidation: true })).not.toThrow(); }); it("tolerates an hrule `---` in the markdown body (not confused with frontmatter close)", () => { const withHrule = `${SAMPLE_MD}\n\n---\n\nSome trailing paragraph after an hrule.\n`; - const { fingerprint } = parseFingerprint(withHrule); - expect(fingerprint.id).toBe("claude"); - expect(fingerprint.observation?.summary).toContain("literary salon"); + const { expression } = parseExpression(withHrule); + expect(expression.id).toBe("claude"); + expect(expression.observation?.summary).toContain("literary salon"); }); it("throws when the frontmatter is opened but never closed", () => { const unterminated = `---\nid: foo\nsource: unknown\n`; - expect(() => parseFingerprint(unterminated)).toThrow(/unterminated/i); + expect(() => parseExpression(unterminated)).toThrow(/unterminated/i); }); it("frontmatter decision with no body block surfaces empty rationale and empty evidence", () => { // Strip the # Decisions section entirely const md = SAMPLE_MD.replace(/# Decisions[\s\S]*$/, ""); - const { fingerprint } = parseFingerprint(md); - expect(fingerprint.decisions).toHaveLength(2); - expect(fingerprint.decisions?.[0].decision).toBe(""); + const { expression } = parseExpression(md); + expect(expression.decisions).toHaveLength(2); + expect(expression.decisions?.[0].decision).toBe(""); // Schema 5: evidence lives in the body — stripping the body loses it. - expect(fingerprint.decisions?.[0].evidence).toEqual([]); + expect(expression.decisions?.[0].evidence).toEqual([]); }); it("body-only decision (no frontmatter entry) appends with body evidence", () => { @@ -213,26 +210,26 @@ describe("parseFingerprint", () => { /decisions:\n(?:\s{2}- dimension: [^\n]+\n)+/, "", ); - const { fingerprint } = parseFingerprint(md); - expect(fingerprint.decisions?.map((d) => d.dimension)).toEqual([ + const { expression } = parseExpression(md); + expect(expression.decisions?.map((d) => d.dimension)).toEqual([ "warm-only-neutrals", "serif-authority-sans-utility", ]); // Evidence came from the body `**Evidence:**` block - expect(fingerprint.decisions?.[0].evidence).toEqual([ + expect(expression.decisions?.[0].evidence).toEqual([ "#141413", "#4d4c48", "#87867f", ]); - expect(fingerprint.decisions?.[0].decision).toContain("yellow-brown"); + expect(expression.decisions?.[0].decision).toContain("yellow-brown"); }); }); -describe("loadFingerprint", () => { - it("parses .md files as fingerprints", async () => { +describe("loadExpression", () => { + it("parses .md files as expressions", async () => { const path = join(tmpdir(), `ghost-test-${Date.now()}.md`); await writeFile(path, SAMPLE_MD, "utf-8"); - const { fingerprint: fp } = await loadFingerprint(path); + const { expression: fp } = await loadExpression(path); expect(fp.id).toBe("claude"); expect(fp.palette.dominant[0].value).toBe("#c96442"); }); @@ -240,21 +237,21 @@ describe("loadFingerprint", () => { it("rejects non-.md paths with a clear error", async () => { const path = join(tmpdir(), `ghost-test-${Date.now()}.json`); await writeFile(path, JSON.stringify(SAMPLE_EXPRESSION), "utf-8"); - await expect(loadFingerprint(path)).rejects.toThrow( + await expect(loadExpression(path)).rejects.toThrow( /must be Markdown \(\.md\)/, ); }); }); -describe("serializeFingerprint round-trip", () => { +describe("serializeExpression round-trip", () => { it("preserves every structured field when serialized and re-parsed", () => { - const fpWithProse: Fingerprint = { + const fpWithProse: Expression = { ...SAMPLE_EXPRESSION, observation: { summary: "Warm, editorial, unhurried.", personality: ["warm", "editorial"], distinctiveTraits: ["ring-shadows", "warm-only neutrals"], - closestSystems: ["notion"], + resembles: ["notion"], }, decisions: [ { @@ -267,44 +264,44 @@ describe("serializeFingerprint round-trip", () => { // Keep embedding inline so a pure in-memory round-trip round-trips // without needing a sibling embedding.md on disk. - const md = serializeFingerprint(fpWithProse, { + const md = serializeExpression(fpWithProse, { meta: { name: "Claude", slug: "claude" }, extractEmbedding: false, }); - const { fingerprint, meta } = parseFingerprint(md); + const { expression, meta } = parseExpression(md); expect(meta.name).toBe("Claude"); - expect(fingerprint.id).toBe(fpWithProse.id); - expect(fingerprint.palette).toEqual(fpWithProse.palette); - expect(fingerprint.spacing).toEqual(fpWithProse.spacing); - expect(fingerprint.typography).toEqual(fpWithProse.typography); - expect(fingerprint.surfaces).toEqual(fpWithProse.surfaces); - expect(fingerprint.embedding).toEqual(fpWithProse.embedding); - expect(fingerprint.observation?.summary).toBe( + expect(expression.id).toBe(fpWithProse.id); + expect(expression.palette).toEqual(fpWithProse.palette); + expect(expression.spacing).toEqual(fpWithProse.spacing); + expect(expression.typography).toEqual(fpWithProse.typography); + expect(expression.surfaces).toEqual(fpWithProse.surfaces); + expect(expression.embedding).toEqual(fpWithProse.embedding); + expect(expression.observation?.summary).toBe( fpWithProse.observation?.summary, ); - expect(fingerprint.observation?.distinctiveTraits).toEqual( + expect(expression.observation?.distinctiveTraits).toEqual( fpWithProse.observation?.distinctiveTraits, ); - expect(fingerprint.observation?.personality).toEqual( + expect(expression.observation?.personality).toEqual( fpWithProse.observation?.personality, ); - expect(fingerprint.observation?.closestSystems).toEqual( - fpWithProse.observation?.closestSystems, + expect(expression.observation?.resembles).toEqual( + fpWithProse.observation?.resembles, ); - expect(fingerprint.decisions).toHaveLength(1); - expect(fingerprint.decisions?.[0].decision).toBe( + expect(expression.decisions).toHaveLength(1); + expect(expression.decisions?.[0].decision).toBe( fpWithProse.decisions?.[0].decision, ); - expect(fingerprint.decisions?.[0].evidence).toEqual( + expect(expression.decisions?.[0].evidence).toEqual( fpWithProse.decisions?.[0].evidence, ); }); it("emits a frontmatter-only file when observation, decisions, and embedding are absent", () => { const { embedding: _drop, ...noEmbedding } = SAMPLE_EXPRESSION; - const md = serializeFingerprint(noEmbedding as Fingerprint); + const md = serializeExpression(noEmbedding as Expression); expect(md).toMatch(/^---\n/); expect(md).toMatch(/\n---\n$/); expect(md).not.toMatch(/^# Character/m); @@ -313,7 +310,7 @@ describe("serializeFingerprint round-trip", () => { }); it("appends a # Fragments body link when embedding is extracted", () => { - const md = serializeFingerprint(SAMPLE_EXPRESSION); + const md = serializeExpression(SAMPLE_EXPRESSION); // Frontmatter no longer carries the embedding const yaml = md.slice(md.indexOf("---") + 3, md.lastIndexOf("---")); expect(yaml).not.toMatch(/^embedding:/m); @@ -322,7 +319,7 @@ describe("serializeFingerprint round-trip", () => { }); it("extractEmbedding: false keeps the embedding inline", () => { - const md = serializeFingerprint(SAMPLE_EXPRESSION, { + const md = serializeExpression(SAMPLE_EXPRESSION, { extractEmbedding: false, }); const yaml = md.slice(md.indexOf("---") + 3, md.lastIndexOf("---")); @@ -331,13 +328,13 @@ describe("serializeFingerprint round-trip", () => { }); it("emits prose in body only — no duplication in frontmatter", () => { - const fpWithProse: Fingerprint = { + const fpWithProse: Expression = { ...SAMPLE_EXPRESSION, observation: { summary: "Warm and editorial.", personality: ["warm"], distinctiveTraits: ["ring-shadows"], - closestSystems: [], + resembles: [], }, decisions: [ { @@ -347,7 +344,7 @@ describe("serializeFingerprint round-trip", () => { }, ], }; - const md = serializeFingerprint(fpWithProse); + const md = serializeExpression(fpWithProse); // Frontmatter has machine-facts only const yamlSection = md.slice(md.indexOf("---") + 3, md.lastIndexOf("---")); expect(yamlSection).not.toContain("summary:"); @@ -364,7 +361,7 @@ describe("serializeFingerprint round-trip", () => { }); it("round-trips roles (slot → token bindings) through serialize → parse", () => { - const fpWithRoles: Fingerprint = { + const fpWithRoles: Expression = { ...SAMPLE_EXPRESSION, roles: [ { @@ -386,22 +383,22 @@ describe("serializeFingerprint round-trip", () => { }, ], }; - const md = serializeFingerprint(fpWithRoles, { extractEmbedding: false }); + const md = serializeExpression(fpWithRoles, { extractEmbedding: false }); const yamlSection = md.slice(md.indexOf("---") + 3, md.lastIndexOf("---")); expect(yamlSection).toMatch(/^roles:/m); expect(yamlSection).toContain("name: h1"); expect(yamlSection).toContain("name: card"); - const { fingerprint } = parseFingerprint(md); - expect(fingerprint.roles).toHaveLength(2); - expect(fingerprint.roles?.[0].name).toBe("h1"); - expect(fingerprint.roles?.[0].tokens.typography?.size).toBe(64); - expect(fingerprint.roles?.[0].evidence).toEqual([ + const { expression } = parseExpression(md); + expect(expression.roles).toHaveLength(2); + expect(expression.roles?.[0].name).toBe("h1"); + expect(expression.roles?.[0].tokens.typography?.size).toBe(64); + expect(expression.roles?.[0].evidence).toEqual([ "components/Heading.tsx:12", ]); - expect(fingerprint.roles?.[1].tokens.surfaces?.borderRadius).toBe(16); - expect(fingerprint.roles?.[1].tokens.surfaces?.shadow).toBe("subtle"); - expect(fingerprint.roles?.[1].tokens.palette?.background).toBe("#f5f4ed"); + expect(expression.roles?.[1].tokens.surfaces?.borderRadius).toBe(16); + expect(expression.roles?.[1].tokens.surfaces?.shadow).toBe("subtle"); + expect(expression.roles?.[1].tokens.palette?.background).toBe("#f5f4ed"); }); it("rejects unknown keys in role token sub-blocks (strict schema)", () => { @@ -417,10 +414,10 @@ describe("serializeFingerprint round-trip", () => { evidence: [], }, ], - } as Fingerprint; - const md = serializeFingerprint(fpBad, { extractEmbedding: false }); - expect(() => parseFingerprint(md)).toThrow( - /Invalid fingerprint frontmatter[\s\S]*roles/, + } as Expression; + const md = serializeExpression(fpBad, { extractEmbedding: false }); + expect(() => parseExpression(md)).toThrow( + /Invalid expression frontmatter[\s\S]*roles/, ); }); }); diff --git a/packages/ghost-drift/test/fingerprint/references.test.ts b/packages/ghost-drift/test/expression/references.test.ts similarity index 95% rename from packages/ghost-drift/test/fingerprint/references.test.ts rename to packages/ghost-drift/test/expression/references.test.ts index 5c9fe76..a79487a 100644 --- a/packages/ghost-drift/test/fingerprint/references.test.ts +++ b/packages/ghost-drift/test/expression/references.test.ts @@ -4,10 +4,10 @@ import { isTokenReference, parseTokenReference, resolveTokenReference, -} from "../../src/core/fingerprint/references.js"; -import type { Fingerprint } from "../../src/core/types.js"; +} from "../../src/core/expression/references.js"; +import type { Expression } from "../../src/core/types.js"; -function buildFingerprint(): Fingerprint { +function buildExpression(): Expression { return { id: "x", source: "llm", @@ -82,7 +82,7 @@ describe("parseTokenReference", () => { }); describe("resolveTokenReference", () => { - const fp = buildFingerprint(); + const fp = buildExpression(); it("resolves a dominant role to its hex", () => { const result = resolveTokenReference(fp, "{palette.dominant.accent}"); diff --git a/packages/ghost-ui/README.md b/packages/ghost-ui/README.md index 431cea3..c0bae49 100644 --- a/packages/ghost-ui/README.md +++ b/packages/ghost-ui/README.md @@ -2,14 +2,14 @@ **Reference design system for the Ghost project. 97 components, shadcn registry, not published to npm.** -`ghost-ui` is the design language Ghost dogfoods its fingerprint against. It's distributed as a shadcn registry (`registry.json`) for drop-in consumption, not as an npm package. If you're looking for the drift-detection tool, that's [`ghost-drift`](../ghost-drift). This package exists so the fingerprint has a real, evolving system to describe. +`ghost-ui` is the design language Ghost dogfoods its expression against. It's distributed as a shadcn registry (`registry.json`) for drop-in consumption, not as an npm package. If you're looking for the drift-detection tool, that's [`ghost-drift`](../ghost-drift). This package exists so the expression has a real, evolving system to describe. ## What's here - **Components** — 49 UI primitives (Radix-based) + 48 AI elements (chat, streaming, agent UI) + theme + hooks. -- **Tokens** — `src/styles/` CSS custom properties consumed by the registry and the fingerprint. +- **Tokens** — `src/styles/` CSS custom properties consumed by the registry and the expression. - **Registry** — `registry.json`, shadcn-compatible catalogue. Rebuilt by `just build-registry`. -- **Fingerprint** — `fingerprint.md`, the canonical design description this system evolves by. +- **Expression** — `expression.md`, the canonical design description this system evolves by. ## Use diff --git a/packages/ghost-ui/fingerprint.md b/packages/ghost-ui/expression.md similarity index 99% rename from packages/ghost-ui/fingerprint.md rename to packages/ghost-ui/expression.md index 96404c5..4f9df48 100644 --- a/packages/ghost-ui/fingerprint.md +++ b/packages/ghost-ui/expression.md @@ -10,7 +10,7 @@ observation: - pill-shaped - magazine-like - themeable - closestSystems: + resembles: - Vercel Geist - Linear - Apple Human Interface Guidelines diff --git a/packages/ghost-ui/fingerprint.json b/packages/ghost-ui/fingerprint.json deleted file mode 100644 index 2575233..0000000 --- a/packages/ghost-ui/fingerprint.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "id": "ghost-ui", - "source": "llm", - "timestamp": "2026-04-14T12:52:50.477Z", - "palette": { - "dominant": [ - { - "role": "primary", - "value": "#1a1a1a", - "oklch": [0.218, 0, 89.9] - }, - { - "role": "surface", - "value": "#ffffff", - "oklch": [1, 0, 89.9] - }, - { - "role": "secondary", - "value": "#f5f5f5", - "oklch": [0.97, 0, 89.9] - }, - { - "role": "accent", - "value": "#1a1a1a", - "oklch": [0.218, 0, 89.9] - }, - { - "role": "muted", - "value": "#f0f0f0", - "oklch": [0.955, 0, 89.9] - }, - { - "role": "inverse", - "value": "#000000", - "oklch": [0, 0, 0] - } - ], - "neutrals": { - "steps": [ - "#ffffff", - "#f5f5f5", - "#f0f0f0", - "#e8e8e8", - "#e5e5e5", - "#cccccc", - "#999999", - "#666666", - "#333333", - "#232323", - "#1a1a1a", - "#000000" - ], - "count": 12 - }, - "semantic": [ - { "role": "surface", "value": "#ffffff", "oklch": [1, 0, 89.9] }, - { "role": "surface-alt", "value": "#f5f5f5", "oklch": [0.97, 0, 89.9] }, - { - "role": "surface-muted", - "value": "#f0f0f0", - "oklch": [0.955, 0, 89.9] - }, - { "role": "surface-dark", "value": "#0a0a0a", "oklch": [0.145, 0, 89.9] }, - { "role": "text", "value": "#1a1a1a", "oklch": [0.218, 0, 89.9] }, - { "role": "text-muted", "value": "#999999", "oklch": [0.683, 0, 89.9] }, - { "role": "text-alt", "value": "#666666", "oklch": [0.51, 0, 89.9] }, - { "role": "text-inverse", "value": "#ffffff", "oklch": [1, 0, 89.9] }, - { "role": "border", "value": "#e8e8e8", "oklch": [0.931, 0, 89.9] }, - { "role": "border-input", "value": "#e5e5e5", "oklch": [0.922, 0, 89.9] }, - { - "role": "border-strong", - "value": "#1a1a1a", - "oklch": [0.218, 0, 89.9] - }, - { "role": "danger", "value": "#f94b4b", "oklch": [0.661, 0.211, 25] }, - { "role": "success", "value": "#91cb80", "oklch": [0.784, 0.119, 138.8] }, - { "role": "warning", "value": "#fbcd44", "oklch": [0.866, 0.156, 89.6] }, - { "role": "info", "value": "#5c98f9", "oklch": [0.684, 0.157, 259.6] }, - { "role": "chart-1", "value": "#f6b44a", "oklch": [0.813, 0.142, 75.9] }, - { "role": "chart-2", "value": "#7585ff", "oklch": [0.663, 0.18, 274.8] }, - { "role": "chart-3", "value": "#d76a6a", "oklch": [0.653, 0.137, 21.6] }, - { "role": "chart-4", "value": "#d185e0", "oklch": [0.727, 0.151, 320.8] }, - { "role": "chart-5", "value": "#91cb80", "oklch": [0.784, 0.119, 138.8] } - ], - "saturationProfile": "muted", - "contrast": "high" - }, - "spacing": { - "scale": [4, 6, 8, 12, 16, 20, 24, 32, 40, 48], - "regularity": 0.95, - "baseUnit": 4 - }, - "typography": { - "families": [ - "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", - "Geist Mono, monospace", - "Cash Sans" - ], - "sizeRamp": [10, 12, 14, 16, 18, 20, 24, 30, 36, 48, 60, 72, 96, 128], - "weightDistribution": { - "300": 16, - "400": 26, - "500": 72, - "600": 67, - "700": 23, - "900": 9 - }, - "lineHeightPattern": "tight" - }, - "surfaces": { - "borderRadii": [10, 14, 16, 20, 24, 999], - "shadowComplexity": "subtle", - "borderUsage": "heavy" - }, - "embedding": [ - 0.218, 0, 0.2497, 1, 0, 0.2497, 0.97, 0, 0.2497, 0.218, 0, 0.2497, 1, 1, - 0.6, 0.145, 1, 0.855, 0.6098, 1, 1, 0.6919, 0.04, 0.48, 0.95, 0.125, 0.2, - 0.2835, 0.3306, 0.2273, 0.24, 0.6, 1, 0.1, 1, 0.8682, 0, 1, 0.75, 1, 0.3, 1, - 0.1563, 1, 0.5, 1, 1, 0.3125, 1 - ] -} diff --git a/schemas/fingerprint.schema.json b/schemas/expression.schema.json similarity index 98% rename from schemas/fingerprint.schema.json rename to schemas/expression.schema.json index eaaf84b..be3194b 100644 --- a/schemas/fingerprint.schema.json +++ b/schemas/expression.schema.json @@ -52,7 +52,7 @@ "type": "string" } }, - "closestSystems": { + "resembles": { "type": "array", "items": { "type": "string" @@ -370,6 +370,6 @@ "surfaces" ], "additionalProperties": false, - "title": "Ghost Fingerprint Frontmatter", - "description": "Schema for YAML frontmatter in Ghost fingerprint.md files." + "title": "Ghost Expression Frontmatter", + "description": "Schema for YAML frontmatter in Ghost expression.md files." } diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index dc50b5b..8bc08f8 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -8,7 +8,7 @@ const EXCEPTIONS = { "packages/ghost-drift/src/core/types.ts": { limit: 780, justification: - "Canonical type barrel — all shared types in one file for discoverability, including three-layer fingerprint types and role bindings", + "Canonical type barrel — all shared types in one file for discoverability, including three-layer expression types and role bindings", }, "packages/ghost-drift/src/bin.ts": { limit: 580, @@ -18,7 +18,7 @@ const EXCEPTIONS = { "packages/ghost-drift/src/core/embedding/compare.ts": { limit: 600, justification: - "Fingerprint comparison — cosine-based decision matching alongside existing value comparison", + "Expression comparison — cosine-based decision matching alongside existing value comparison", }, }; diff --git a/scripts/emit-fingerprint-schema.mjs b/scripts/emit-expression-schema.mjs similarity index 54% rename from scripts/emit-fingerprint-schema.mjs rename to scripts/emit-expression-schema.mjs index b887a4e..3cc2f62 100644 --- a/scripts/emit-fingerprint-schema.mjs +++ b/scripts/emit-expression-schema.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** - * Emit schemas/fingerprint.schema.json from the zod source of truth. - * Run after changes to packages/ghost-drift/src/core/fingerprint/schema.ts: + * Emit schemas/expression.schema.json from the zod source of truth. + * Run after changes to packages/ghost-drift/src/core/expression/schema.ts: * - * pnpm --filter ghost-drift build && node scripts/emit-fingerprint-schema.mjs + * pnpm --filter ghost-drift build && node scripts/emit-expression-schema.mjs */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -12,16 +12,16 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, ".."); const { toJsonSchema } = await import( - resolve(root, "packages/ghost-drift/dist/core/fingerprint/schema.js") + resolve(root, "packages/ghost-drift/dist/core/expression/schema.js") ); const schema = toJsonSchema(); -schema.title = "Ghost Fingerprint Frontmatter"; +schema.title = "Ghost Expression Frontmatter"; schema.description = - "Schema for YAML frontmatter in Ghost fingerprint.md files."; + "Schema for YAML frontmatter in Ghost expression.md files."; const outDir = resolve(root, "schemas"); if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); -const outPath = resolve(outDir, "fingerprint.schema.json"); +const outPath = resolve(outDir, "expression.schema.json"); writeFileSync(outPath, `${JSON.stringify(schema, null, 2)}\n`); console.log(`Wrote ${outPath}`);