diff --git a/.changeset/clean-scan-boundary.md b/.changeset/clean-scan-boundary.md new file mode 100644 index 00000000..3fcb9fbf --- /dev/null +++ b/.changeset/clean-scan-boundary.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": major +--- + +Restore scan to readiness and source-signal APIs, move fingerprint package operations to the fingerprint export, remove context-bundle emission, and add Relay gather for agent context. diff --git a/CLAUDE.md b/CLAUDE.md index 738b81db..576c42d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,16 +94,17 @@ fingerprint input for new Ghost work. | `ghost ack` | Record stance toward the tracked fingerprint in `.ghost-sync.json`. | | `ghost track` | Shift the tracked fingerprint. | | `ghost diverge` | Declare intentional divergence on a dimension. | -| `ghost emit ` | Emit `review-command` or the `context-bundle` generation packet. | +| `ghost emit ` | Emit `review-command`. | | `ghost skill install` | Install the unified `ghost` agentskills.io bundle. | -`ghost scan --format json` is deterministic handoff state for the host agent. +`ghost scan --format json` is deterministic readiness and source-signal state. It does not run an LLM. ## Public Exports - `@anarchitecture/ghost` for the combined surface. -- `@anarchitecture/ghost/scan` for scan and bundle helpers. +- `@anarchitecture/ghost/scan` for scan readiness, source signals, and stack discovery. +- `@anarchitecture/ghost/fingerprint` for fingerprint package authoring, linting, verification, parsing, and serialization. - `@anarchitecture/ghost/drift` for check/review/compare/stance helpers. - `@anarchitecture/ghost/core` for shared schemas, types, and loaders. - `@anarchitecture/ghost/cli` for `buildCli()`. diff --git a/README.md b/README.md index f983f437..64239207 100644 --- a/README.md +++ b/README.md @@ -112,16 +112,16 @@ truth. ## Generate From Ghost -Before generating or revising UI, emit the context bundle: +Before generating or revising UI, gather the Relay brief for the target path: ```bash -ghost emit context-bundle +ghost relay gather apps/checkout/review/page.tsx ``` -The bundle gives a host agent the selected prose, inventory, composition, -optional memory, and active checks. The important shift is timing: Ghost gives -agents surface-composition context before they build, not only after a review -finds drift. +Relay gives a host agent the selected prose, inventory, composition, optional +memory, active checks, suggested reads, and provenance. The important shift is +timing: Ghost gives agents surface-composition context before they build, not +only after a review finds drift. After implementation, run the deterministic and advisory workflows against the same fingerprint: @@ -162,7 +162,8 @@ useful layer content. It does not call an LLM. | `ghost verify` | Validate evidence paths, exemplar paths, typed check refs, and optional rationale files. | | `ghost check` | Run active deterministic gates against a diff. | | `ghost review` | Emit an evidence-routed advisory packet from fingerprint layers and a diff. | -| `ghost emit ` | Emit `review-command` or `context-bundle` artifacts. | +| `ghost relay gather` | Gather fingerprint-grounded context for an agent target. | +| `ghost emit ` | Emit `review-command` artifacts. | | `ghost skill install` | Install the unified Ghost skill bundle. | | `ghost stack` | Inspect resolved root-to-leaf fingerprint stacks. | | `ghost inventory` | Emit raw repo signals as JSON for optional cache material. | diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index b29a5fef..ed55cdc1 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -119,14 +119,21 @@ ghost verify .ghost --root . ghost verify --all --memory-dir .design/memory ``` -### Generate handoff packets - `emit` +### Reusable Review Command - `emit` -Emit `review-command` or the `context-bundle` compact entrypoint from split -fingerprint layers. Use `context-bundle` before generation and -`review-command` when a host wants a reusable review prompt. +Emit `review-command` from split fingerprint layers when a host wants a +reusable review prompt. +### Agent Context - `relay gather` + +Gather a compact Relay brief from the resolved fingerprint stack for a target +path. Use this before generation so the host agent starts with selected refs, +suggested reads, active checks, and provenance. + + + ### Inspection - `describe` Print optional `.ghost/fingerprint/memory/intent.md` or a markdown section map. diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 9143edb6..c91ef963 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -114,17 +114,17 @@ approval. For a fuller human-agent workflow, read - + -Before generating or revising UI, emit the upstream compact entrypoint: +Before generating or revising UI, gather a Relay brief for the target path: ```bash -ghost emit context-bundle +ghost relay gather apps/checkout/review/page.tsx ``` -Give the generated bundle to Codex, Claude, Cursor, Goose, or another host -agent so the work starts from the approved product fingerprint rather than from -post-hoc review feedback alone. +Relay points the host agent at selected fingerprint refs, suggested reads, +active checks, and provenance. The package remains the approved product-surface +context; review and check commands apply it after implementation. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 38b73a8e..4934aa13 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-10T17:47:36.455Z", + "generatedAt": "2026-06-12T19:26:14.540Z", "tools": [ { "tool": "ghost", @@ -315,11 +315,11 @@ "tool": "ghost", "name": "emit", "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package (review command or compact context entrypoint)", + "description": "Emit a derived artifact from the fingerprint package (review-command).", "group": "core", "defaultHelp": true, "compactName": "emit", - "summary": "Emit review-command or context-bundle artifacts.", + "summary": "Emit review-command artifacts.", "options": [ { "rawName": "--path ", @@ -348,7 +348,7 @@ { "rawName": "-o, --out ", "name": "out", - "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/)", + "description": "Output path (review-command → .claude/commands/design-review.md)", "default": null, "takesValue": true, "negated": false @@ -356,34 +356,10 @@ { "rawName": "--stdout", "name": "stdout", - "description": "Write to stdout instead of a file (review-command only)", + "description": "Write to stdout instead of a file", "default": null, "takesValue": false, "negated": false - }, - { - "rawName": "--readme", - "name": "readme", - "description": "Include README.md (context-bundle)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--prompt-only", - "name": "promptOnly", - "description": "Emit only prompt.md (context-bundle compact entrypoint)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--name ", - "name": "name", - "description": "Override the skill name (default: prose.yml product or first scope) (context-bundle)", - "default": null, - "takesValue": true, - "negated": false } ] }, @@ -571,6 +547,50 @@ } ] }, + { + "tool": "ghost", + "name": "relay", + "rawName": "relay [target]", + "description": "Gather fingerprint-grounded context for an agent target.", + "group": "core", + "defaultHelp": true, + "compactName": "relay gather", + "summary": "Gather fingerprint context for an agent target.", + "options": [ + { + "rawName": "--package ", + "name": "package", + "description": "Use exactly this fingerprint package directory instead of resolving a stack", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--memory-dir ", + "name": "memoryDir", + "description": "Relative fingerprint package directory for stack resolution (default: .ghost)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--name ", + "name": "name", + "description": "Override the gathered context name (default: prose.yml product or resolved scope)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--format ", + "name": "format", + "description": "Output format: markdown or json", + "default": "markdown", + "takesValue": true, + "negated": false + } + ] + }, { "tool": "ghost", "name": "skill", diff --git a/package.json b/package.json index 865bc938..8eeef9e4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": "^20.19.0 || >=22.12.0" }, "scripts": { - "build": "tsc --build", + "build": "tsc --build --force", "clean": "tsc --build --clean", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 50697d7c..dc18f4ba 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -43,10 +43,15 @@ ghost lint .ghost ghost verify .ghost --root . ``` -Emit the pre-generation packet and govern changes afterward: +Gather context before generation: + +```bash +ghost relay gather apps/checkout/review/page.tsx +``` + +Govern changes afterward: ```bash -ghost emit context-bundle ghost check --base main ghost review --base main --include-memory ``` @@ -70,6 +75,7 @@ host opts in. ```ts import { compare } from "@anarchitecture/ghost/compare"; import { runGhostCheck } from "@anarchitecture/ghost/govern"; +import { gatherRelayContext } from "@anarchitecture/ghost/relay"; import { initFingerprintPackage, lintFingerprintPackage, @@ -80,8 +86,8 @@ import { ## BYOA Ghost is bring-your-own-agent. The CLI performs deterministic work: inventory, -readiness reporting, linting, verification, comparison, checks, and handoff -packet generation. The installed `ghost` skill teaches a host agent how to +readiness reporting, linting, verification, comparison, checks, and advisory +review packet generation. The installed `ghost` skill teaches a host agent how to capture canonical `.ghost/fingerprint/` surface-composition context, brief and generate work from it, review changes against it, verify generated UI, remediate issues, and suggest fingerprint edits when the user asks. diff --git a/packages/ghost/package.json b/packages/ghost/package.json index 8891a866..953f84a0 100644 --- a/packages/ghost/package.json +++ b/packages/ghost/package.json @@ -59,6 +59,10 @@ "types": "./dist/compare.d.ts", "import": "./dist/compare.js" }, + "./relay": { + "types": "./dist/relay.d.ts", + "import": "./dist/relay.js" + }, "./drift": { "types": "./dist/core/index.d.ts", "import": "./dist/core/index.js" diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 1bff0842..9e9f14e4 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -26,12 +26,13 @@ import { registerDivergeCommand, registerTrackCommand, } from "./evolution-commands.js"; +import { formatSemanticDiff } from "./fingerprint.js"; +import { registerFingerprintCommands } from "./fingerprint-commands.js"; +import { registerRelayCommand } from "./relay.js"; import { buildReviewPacket, formatReviewPacketMarkdown, } from "./review-packet.js"; -import { formatSemanticDiff } from "./scan/index.js"; -import { registerScanCommands } from "./scan-commands.js"; import { registerSkillCommand } from "./skill-command.js"; const execFileAsync = promisify(execFile); @@ -41,7 +42,7 @@ export { getCommandDiscoveryMetadata } from "./command-discovery.js"; export function buildCli(): ReturnType { const cli = cac("ghost"); - registerScanCommands(cli); + registerFingerprintCommands(cli); // --- compare --- cli @@ -152,6 +153,7 @@ export function buildCli(): ReturnType { registerAckCommand(cli); registerTrackCommand(cli); registerDivergeCommand(cli); + registerRelayCommand(cli); registerSkillCommand(cli); // --- check --- diff --git a/packages/ghost/src/command-discovery.ts b/packages/ghost/src/command-discovery.ts index a54e6abd..5e1cbdaa 100644 --- a/packages/ghost/src/command-discovery.ts +++ b/packages/ghost/src/command-discovery.ts @@ -73,12 +73,19 @@ const COMMAND_DISCOVERY = [ compactName: "review", summary: "Emit an advisory packet from fingerprint layers and a diff.", }, + { + name: "relay", + group: "core", + defaultHelp: true, + compactName: "relay gather", + summary: "Gather fingerprint context for an agent target.", + }, { name: "emit", group: "core", defaultHelp: true, compactName: "emit", - summary: "Emit review-command or context-bundle artifacts.", + summary: "Emit review-command artifacts.", }, { name: "skill", diff --git a/packages/ghost/src/comparable-fingerprint.ts b/packages/ghost/src/comparable-fingerprint.ts index 6b9ae7b5..4283d5c2 100644 --- a/packages/ghost/src/comparable-fingerprint.ts +++ b/packages/ghost/src/comparable-fingerprint.ts @@ -7,7 +7,7 @@ import { type GhostPatternsDocument, type Survey, } from "#ghost-core"; -import { loadFingerprint, resolveFingerprintPackage } from "#scan"; +import { loadFingerprint, resolveFingerprintPackage } from "./fingerprint.js"; export async function loadComparableFingerprint( path: string, diff --git a/packages/ghost/src/scan/context/entrypoint-markdown.ts b/packages/ghost/src/context/entrypoint-markdown.ts similarity index 100% rename from packages/ghost/src/scan/context/entrypoint-markdown.ts rename to packages/ghost/src/context/entrypoint-markdown.ts diff --git a/packages/ghost/src/scan/context/entrypoint.ts b/packages/ghost/src/context/entrypoint.ts similarity index 100% rename from packages/ghost/src/scan/context/entrypoint.ts rename to packages/ghost/src/context/entrypoint.ts diff --git a/packages/ghost/src/scan/context/graph.ts b/packages/ghost/src/context/graph.ts similarity index 100% rename from packages/ghost/src/scan/context/graph.ts rename to packages/ghost/src/context/graph.ts diff --git a/packages/ghost/src/scan/context/package-context.ts b/packages/ghost/src/context/package-context.ts similarity index 99% rename from packages/ghost/src/scan/context/package-context.ts rename to packages/ghost/src/context/package-context.ts index 8748044d..fc01a084 100644 --- a/packages/ghost/src/scan/context/package-context.ts +++ b/packages/ghost/src/context/package-context.ts @@ -10,7 +10,7 @@ import { import { type FingerprintPackagePaths, loadFingerprintPackage, -} from "../fingerprint-package.js"; +} from "../scan/fingerprint-package.js"; export interface PackageInventorySummary { root?: string; diff --git a/packages/ghost/src/scan/context/package-review-command.ts b/packages/ghost/src/context/package-review-command.ts similarity index 100% rename from packages/ghost/src/scan/context/package-review-command.ts rename to packages/ghost/src/context/package-review-command.ts diff --git a/packages/ghost/src/core/check.ts b/packages/ghost/src/core/check.ts index 183d0522..14a9bff2 100644 --- a/packages/ghost/src/core/check.ts +++ b/packages/ghost/src/core/check.ts @@ -14,11 +14,13 @@ import { routeGhostChecksForPath, } from "#ghost-core"; import { - groupFingerprintStacksForPaths, loadFingerprintPackage, - mapFromFingerprint, resolveFingerprintPackage, -} from "../scan/index.js"; +} from "../scan/fingerprint-package.js"; +import { + groupFingerprintStacksForPaths, + mapFromFingerprint, +} from "../scan/fingerprint-stack.js"; import { INLINE_COLOR_LITERAL_PATTERN, isInlineColorDetector, diff --git a/packages/ghost/src/core/compare.ts b/packages/ghost/src/core/compare.ts index 09b15fc2..d507b01e 100644 --- a/packages/ghost/src/core/compare.ts +++ b/packages/ghost/src/core/compare.ts @@ -8,8 +8,8 @@ import type { TemporalComparison, } from "#ghost-core"; import { compareFingerprints } from "#ghost-core"; -import type { SemanticDiff } from "#scan"; -import { diffFingerprints } from "#scan"; +import type { SemanticDiff } from "../scan/diff.js"; +import { diffFingerprints } from "../scan/diff.js"; import { compareComposite } from "./evolution/composite.js"; import { computeTemporalComparison } from "./evolution/temporal.js"; diff --git a/packages/ghost/src/core/evolution/emit.ts b/packages/ghost/src/core/evolution/emit.ts index 55fd937a..28913354 100644 --- a/packages/ghost/src/core/evolution/emit.ts +++ b/packages/ghost/src/core/evolution/emit.ts @@ -1,6 +1,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { Fingerprint } from "#ghost-core"; -import { resolveFingerprintPackage, serializeFingerprint } from "#scan"; +import { + resolveFingerprintPackage, + serializeFingerprint, +} from "../../fingerprint.js"; /** * Write a fingerprint as the publishable design-language prior inside the diff --git a/packages/ghost/src/core/evolution/tracking.ts b/packages/ghost/src/core/evolution/tracking.ts index 129e98a8..a085312e 100644 --- a/packages/ghost/src/core/evolution/tracking.ts +++ b/packages/ghost/src/core/evolution/tracking.ts @@ -6,7 +6,7 @@ import { loadFingerprint, parseFingerprint, resolveFingerprintPackage, -} from "#scan"; +} from "../../fingerprint.js"; /** * Resolve a Target to a Fingerprint. diff --git a/packages/ghost/src/core/scope-resolver.ts b/packages/ghost/src/core/scope-resolver.ts index 958ecf0e..d1ad6a94 100644 --- a/packages/ghost/src/core/scope-resolver.ts +++ b/packages/ghost/src/core/scope-resolver.ts @@ -9,7 +9,7 @@ import { MapFrontmatterSchema, type MapScope, } from "#ghost-core"; -import { FINGERPRINT_FILENAME } from "#scan"; +import { FINGERPRINT_FILENAME } from "../scan/constants.js"; const FINGERPRINTS_DIRNAME = "fingerprints"; diff --git a/packages/ghost/src/evolution-commands.ts b/packages/ghost/src/evolution-commands.ts index 10689d84..0dc064b9 100644 --- a/packages/ghost/src/evolution-commands.ts +++ b/packages/ghost/src/evolution-commands.ts @@ -1,11 +1,11 @@ import type { CAC } from "cac"; -import { loadFingerprint, resolveFingerprintPackage } from "#scan"; import type { DimensionStance, Target } from "./core/index.js"; import { acknowledge, loadConfig, resolveTrackedFingerprint, } from "./core/index.js"; +import { loadFingerprint, resolveFingerprintPackage } from "./fingerprint.js"; async function loadLocalFingerprint() { const path = resolveFingerprintPackage(undefined, process.cwd()).fingerprint; diff --git a/packages/ghost/src/scan-commands.ts b/packages/ghost/src/fingerprint-commands.ts similarity index 98% rename from packages/ghost/src/scan-commands.ts rename to packages/ghost/src/fingerprint-commands.ts index 5561dff2..78f74488 100644 --- a/packages/ghost/src/scan-commands.ts +++ b/packages/ghost/src/fingerprint-commands.ts @@ -15,28 +15,30 @@ import { type SurveySummaryBudget, summarizeSurvey, } from "#ghost-core"; -import { detectFileKind, lintDetectedFileKind } from "./scan/file-kind.js"; import { diffFingerprints, - discoverGhostPackages, - fingerprintPackageDisplayPath, formatLayout, formatSemanticDiff, formatVerifyFingerprintReport, initFingerprintPackage, initScopedFingerprintPackage, - inventory, layoutFingerprint, lintAllFingerprintStacks, type lintFingerprint, lintFingerprintPackage, loadFingerprint, loadFingerprintPackage, - normalizeMemoryDir, resolveFingerprintPackage, - scanStatus, verifyAllFingerprintStacks, verifyFingerprintPackage, +} from "./fingerprint.js"; +import { detectFileKind, lintDetectedFileKind } from "./scan/file-kind.js"; +import { + discoverGhostPackages, + fingerprintPackageDisplayPath, + inventory, + normalizeMemoryDir, + scanStatus, } from "./scan/index.js"; import { registerEmitCommand } from "./scan-emit-command.js"; import { registerStackCommand } from "./scan-stack-command.js"; @@ -48,12 +50,12 @@ import { registerStackCommand } from "./scan-stack-command.js"; * `lint` (schema check, auto-detects file kind), `verify` (cross-artifact * fidelity), `describe` (section ranges + token estimates for intent or direct * fingerprint markdown), `diff` (structural prose-level diff between direct - * fingerprint files), `emit` (derive review-command, context-bundle, or skill - * artifacts), and `survey` operations for deterministic `ghost.survey/v1` + * fingerprint files), `emit` (derive review-command artifacts), and `survey` + * operations for deterministic `ghost.survey/v1` * merge, ID repair, bounded summary output, derived value catalogs, and * operational pattern synthesis. */ -export function registerScanCommands(cli: CAC): void { +export function registerFingerprintCommands(cli: CAC): void { // --- lint --- cli .command( @@ -313,7 +315,7 @@ export function registerScanCommands(cli: CAC): void { if (opts.format === "json") { process.stdout.write( `${JSON.stringify( - nested ? { ...status, nested_bundles: nested } : status, + nested ? { ...status, nested_packages: nested } : status, null, 2, )}\n`, @@ -397,13 +399,13 @@ export function registerScanCommands(cli: CAC): void { } } if (nested) { - process.stdout.write("\nnested bundles:\n"); + process.stdout.write("\nnested packages:\n"); if (nested.length === 0) { process.stdout.write(" none\n"); } else { - for (const bundle of nested) { + for (const pkg of nested) { process.stdout.write( - ` ${fingerprintPackageDisplayPath(bundle.relative_root, bundle.fingerprint_dir)}: ${bundle.readiness.state}\n`, + ` ${fingerprintPackageDisplayPath(pkg.relative_root, pkg.fingerprint_dir)}: ${pkg.readiness.state}\n`, ); } } diff --git a/packages/ghost/src/fingerprint-load.ts b/packages/ghost/src/fingerprint-load.ts new file mode 100644 index 00000000..956057c5 --- /dev/null +++ b/packages/ghost/src/fingerprint-load.ts @@ -0,0 +1,129 @@ +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import type { Fingerprint, SemanticColor } from "#ghost-core"; +import { computeEmbedding, parseColorToOklch } from "#ghost-core"; +import { mergeFingerprint } from "./scan/compose.js"; +import { mergeFrontmatter } from "./scan/frontmatter.js"; +import { type ParsedFingerprint, parseFingerprint } from "./scan/parser.js"; +import { validateFrontmatter } from "./scan/schema.js"; + +export interface LoadOptions { + /** Skip `extends:` resolution. Default: false (extends chains are resolved). */ + noExtends?: boolean; + /** + * Skip embedding backfill. When true, a missing `embedding` stays empty; + * useful for read-only tooling (lint, diff-on-disk) that doesn't need + * the vector. + */ + noEmbeddingBackfill?: boolean; +} + +/** + * Load a ParsedFingerprint from disk. + * + * If the file declares `extends:`, the base fingerprint is loaded recursively and + * merged per the rules in compose.ts: overlay wins, decisions merged by + * dimension, palette colors merged by role. + */ +export async function loadFingerprint( + path: string, + options: LoadOptions = {}, +): Promise { + assertMarkdownPath(path); + + const parsed = options.noExtends + ? await loadRaw(path) + : await loadWithExtends(path, new Set()); + + // Backfill `oklch` on palette colors that arrived hex-only. Deterministic + // (same hex -> same oklch), so re-parsing the same fingerprint always + // yields the same in-memory shape. + backfillPaletteOklch(parsed.fingerprint); + + if (!options.noEmbeddingBackfill) { + parsed.fingerprint.embedding = resolveEmbedding(parsed.fingerprint); + } + + return parsed; +} + +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 fingerprint recipe in your host agent (install with \`ghost skill install\`).`, + ); + } +} + +function backfillPaletteOklch(fingerprint: Fingerprint): void { + if (!fingerprint.palette) return; + if (fingerprint.palette.dominant) { + fingerprint.palette.dominant = + fingerprint.palette.dominant.map(ensureOklch); + } + if (fingerprint.palette.semantic) { + fingerprint.palette.semantic = + fingerprint.palette.semantic.map(ensureOklch); + } +} + +function ensureOklch(color: SemanticColor): SemanticColor { + if (color.oklch && color.oklch.length === 3) return color; + const oklch = parseColorToOklch(color.value); + return oklch ? { ...color, oklch } : color; +} + +function resolveEmbedding(fingerprint: Fingerprint): number[] { + if (fingerprint.embedding && fingerprint.embedding.length > 0) { + return fingerprint.embedding; + } + if ( + fingerprint.palette && + fingerprint.spacing && + fingerprint.typography && + fingerprint.surfaces + ) { + return computeEmbedding(fingerprint); + } + return []; +} + +async function loadRaw(path: string): Promise { + assertMarkdownPath(path); + const raw = await readFile(path, "utf-8"); + return parseFingerprint(raw); +} + +async function loadWithExtends( + path: string, + visited: Set, +): Promise { + assertMarkdownPath(path); + const absolute = isAbsolute(path) ? path : resolve(path); + if (visited.has(absolute)) { + throw new Error( + `Cycle detected while resolving extends: chain — ${absolute} visited twice.`, + ); + } + visited.add(absolute); + + const raw = await readFile(absolute, "utf-8"); + const overlay = parseFingerprint(raw); + if (!overlay.meta.extends) { + return overlay; + } + + const basePath = resolve(dirname(absolute), overlay.meta.extends); + const base = await loadWithExtends(basePath, visited); + + const merged = mergeFingerprint(base.fingerprint, overlay.fingerprint); + validateFrontmatter(mergeFrontmatter(merged)); + + const { extends: _dropped, ...overlayMeta } = overlay.meta; + return { + fingerprint: merged, + meta: { ...base.meta, ...overlayMeta }, + body: overlay.body, + bodyRaw: overlay.bodyRaw, + }; +} diff --git a/packages/ghost/src/fingerprint.ts b/packages/ghost/src/fingerprint.ts index a7261edc..1bc83d2b 100644 --- a/packages/ghost/src/fingerprint.ts +++ b/packages/ghost/src/fingerprint.ts @@ -1 +1,112 @@ -export * from "./scan/index.js"; +export type { LoadOptions } from "./fingerprint-load.js"; +export { loadFingerprint } from "./fingerprint-load.js"; +export type { BodyData } from "./scan/body.js"; +export { parseBody } from "./scan/body.js"; +export type { DesignDecision } from "./scan/compose.js"; +export { mergeFingerprint } from "./scan/compose.js"; +export { + CACHE_DIRNAME, + CHECKS_FILENAME, + CONFIG_FILENAME, + FINGERPRINT_COMPOSITION_FILENAME, + FINGERPRINT_DIRNAME, + FINGERPRINT_ENFORCEMENT_DIRNAME, + FINGERPRINT_FILENAME, + FINGERPRINT_INVENTORY_FILENAME, + FINGERPRINT_MANIFEST_FILENAME, + FINGERPRINT_MEMORY_DIRNAME, + FINGERPRINT_PACKAGE_DIR, + FINGERPRINT_PROSE_FILENAME, + FINGERPRINT_SOURCES_DIRNAME, + FINGERPRINT_YML_FILENAME, + FINGERPRINTS_DIRNAME, + INTENT_FILENAME, + PATTERNS_FILENAME, + RESOURCES_FILENAME, + SCOPE_SURVEYS_DIRNAME, +} from "./scan/constants.js"; +export type { + ColorChange, + DecisionChange, + SemanticDiff, + TokenChange, +} from "./scan/diff.js"; +export { diffFingerprints, formatSemanticDiff } from "./scan/diff.js"; +export type { + FingerprintPackagePaths, + LoadedFingerprintPackage, +} from "./scan/fingerprint-package.js"; +export { + initFingerprintPackage, + lintFingerprintPackage, + loadFingerprintPackage, + resolveFingerprintPackage, +} from "./scan/fingerprint-package.js"; +export type { + LoadedFingerprintNode, + LoadedFingerprintSet, + LoadFingerprintSetOptions, +} from "./scan/fingerprint-set.js"; +export { loadFingerprintSet } from "./scan/fingerprint-set.js"; +export { + initScopedFingerprintPackage, + lintAllFingerprintStacks, + verifyAllFingerprintStacks, +} from "./scan/fingerprint-stack.js"; +export type { FingerprintMeta, FrontmatterData } from "./scan/frontmatter.js"; +export type { + FingerprintLayout, + FingerprintLayoutSection, +} from "./scan/layout.js"; +export { formatLayout, layoutFingerprint } from "./scan/layout.js"; +export type { + LintIssue, + LintOptions, + LintReport, + LintSeverity, +} from "./scan/lint.js"; +export { lintFingerprint } from "./scan/lint.js"; +export type { + MapLintIssue, + MapLintReport, + MapLintSeverity, +} from "./scan/lint-map.js"; +export { lintMap } from "./scan/lint-map.js"; +export type { + GhostPackageConfig, + GhostPackageConfigLibrary, + GhostPackageConfigTarget, +} from "./scan/package-config.js"; +export { + GHOST_PACKAGE_CONFIG_SCHEMA, + GhostPackageConfigSchema, + lintGhostPackageConfig, + normalizeReferenceInput, + parsePackageConfig, + readOptionalPackageConfig, + readOptionalPackageConfigSync, + templatePackageConfig, +} from "./scan/package-config.js"; +export type { ParsedFingerprint, ParseOptions } from "./scan/parser.js"; +export { parseFingerprint, splitRaw } from "./scan/parser.js"; +export type { FrontmatterShape } from "./scan/schema.js"; +export { + FrontmatterSchema, + PartialFrontmatterSchema, + toJsonSchema, + validateFrontmatter, +} from "./scan/schema.js"; +export type { + VerifyFingerprintIssue, + VerifyFingerprintOptions, + VerifyFingerprintReport, + VerifyFingerprintSeverity, +} from "./scan/verify-fingerprint.js"; +export { + formatVerifyFingerprintReport, + verifyFingerprint, +} from "./scan/verify-fingerprint.js"; +export type { VerifyFingerprintPackageOptions } from "./scan/verify-package.js"; +export { verifyFingerprintPackage } from "./scan/verify-package.js"; +export type { SerializeOptions } from "./scan/writer.js"; +export { serializeFingerprint } from "./scan/writer.js"; diff --git a/packages/ghost/src/index.ts b/packages/ghost/src/index.ts index 05609a94..d13620a5 100644 --- a/packages/ghost/src/index.ts +++ b/packages/ghost/src/index.ts @@ -8,5 +8,6 @@ export const compare = Object.assign(compareFunction, compareApi); export * as fingerprint from "./fingerprint.js"; export * as core from "./ghost-core/index.js"; export * as govern from "./govern.js"; +export * as relay from "./relay.js"; /** @deprecated Use `fingerprint` or `@anarchitecture/ghost/fingerprint`. */ export * as scan from "./scan/index.js"; diff --git a/packages/ghost/src/relay.ts b/packages/ghost/src/relay.ts new file mode 100644 index 00000000..bb139d1c --- /dev/null +++ b/packages/ghost/src/relay.ts @@ -0,0 +1,178 @@ +import type { CAC } from "cac"; +import { + buildContextEntrypoint, + type ContextEntrypoint, +} from "./context/entrypoint.js"; +import { formatContextEntrypointMarkdown } from "./context/entrypoint-markdown.js"; +import { + loadPackageContext, + type PackageContext, +} from "./context/package-context.js"; +import { resolveFingerprintPackage } from "./fingerprint.js"; +import { + fingerprintStackToPackageContext, + type GhostFingerprintStack, + loadFingerprintStackForPath, + normalizeMemoryDir, +} from "./scan/fingerprint-stack.js"; + +export const RELAY_GATHER_SCHEMA = "ghost.relay.gather/v1" as const; + +export interface GatherRelayContextOptions { + cwd?: string; + target?: string; + packageDir?: string; + memoryDir?: string; + name?: string; +} + +export type RelayGatherSource = + | { + kind: "stack"; + repoRoot: string; + targetPath: string; + fingerprintDir: string; + layers: string[]; + provenance: GhostFingerprintStack["provenance"]; + } + | { + kind: "package"; + packageDir: string; + targetPath: string | null; + }; + +export interface RelayGatherResult { + schema: typeof RELAY_GATHER_SCHEMA; + name: string; + source: RelayGatherSource; + targetPaths: string[]; + fingerprintDir?: string; + layerDirs: string[]; + entrypoint: ContextEntrypoint; + brief: string; +} + +export async function gatherRelayContext( + options: GatherRelayContextOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const target = options.target ?? "."; + + if (options.packageDir) { + const context = await loadPackageContext( + resolveFingerprintPackage(options.packageDir, cwd), + options.name, + ); + const targetPaths = target === "." ? [] : [target]; + context.targetPaths = targetPaths; + return gatherFromContext(context, { + source: { + kind: "package", + packageDir: context.fingerprintDir ?? options.packageDir, + targetPath: targetPaths[0] ?? null, + }, + targetPaths, + }); + } + + const memoryDir = normalizeMemoryDir(options.memoryDir); + const stack = await loadFingerprintStackForPath(target, cwd, { memoryDir }); + const context = fingerprintStackToPackageContext(stack, options.name); + return gatherFromContext(context, { + source: { + kind: "stack", + repoRoot: stack.repo_root, + targetPath: stack.target_path, + fingerprintDir: stack.fingerprint_dir, + layers: stack.layers.map((layer) => layer.dir), + provenance: stack.provenance, + }, + targetPaths: context.targetPaths ?? [stack.target_path], + }); +} + +export function formatRelayBrief( + result: Pick, +): string { + return formatContextEntrypointMarkdown(result.entrypoint, { + heading: "# Ghost Relay Brief", + }); +} + +export function registerRelayCommand(cli: CAC): void { + cli + .command( + "relay [target]", + "Gather fingerprint-grounded context for an agent target.", + ) + .option( + "--package ", + "Use exactly this fingerprint package directory instead of resolving a stack", + ) + .option( + "--memory-dir ", + "Relative fingerprint package directory for stack resolution (default: .ghost)", + ) + .option( + "--name ", + "Override the gathered context name (default: prose.yml product or resolved scope)", + ) + .option("--format ", "Output format: markdown or json", { + default: "markdown", + }) + .action(async (action: string, target: string | undefined, opts) => { + try { + if (action !== "gather") { + console.error("Error: unknown relay action. Supported: gather"); + process.exit(2); + return; + } + if (opts.format !== "markdown" && opts.format !== "json") { + console.error("Error: --format must be 'markdown' or 'json'"); + process.exit(2); + return; + } + + const result = await gatherRelayContext({ + target: target ?? ".", + packageDir: + typeof opts.package === "string" ? opts.package : undefined, + memoryDir: + typeof opts.memoryDir === "string" ? opts.memoryDir : undefined, + name: typeof opts.name === "string" ? opts.name : undefined, + }); + + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stdout.write(result.brief); + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); +} + +function gatherFromContext( + context: PackageContext, + options: { source: RelayGatherSource; targetPaths: string[] }, +): RelayGatherResult { + const entrypoint = buildContextEntrypoint(context, { + targetPaths: options.targetPaths, + }); + const partial = { entrypoint }; + return { + schema: RELAY_GATHER_SCHEMA, + name: context.name, + source: options.source, + targetPaths: entrypoint.match.requestedPaths, + fingerprintDir: context.fingerprintDir, + layerDirs: context.layerDirs ?? [], + entrypoint, + brief: formatRelayBrief(partial), + }; +} diff --git a/packages/ghost/src/review-packet.ts b/packages/ghost/src/review-packet.ts index e7c0ef4b..9ece7e8b 100644 --- a/packages/ghost/src/review-packet.ts +++ b/packages/ghost/src/review-packet.ts @@ -3,18 +3,20 @@ import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { type GhostDecisionDocument, lintGhostDecision } from "#ghost-core"; +import { buildContextEntrypoint } from "./context/entrypoint.js"; +import { formatContextEntrypointMarkdown } from "./context/entrypoint-markdown.js"; +import { loadPackageContext } from "./context/package-context.js"; import { parseUnifiedDiff } from "./core/index.js"; -import { buildContextEntrypoint } from "./scan/context/entrypoint.js"; -import { formatContextEntrypointMarkdown } from "./scan/context/entrypoint-markdown.js"; +import { resolveFingerprintPackage } from "./scan/fingerprint-package.js"; import { fingerprintStackToPackageContext, type GhostFingerprintStack, - type GhostPackageConfig, groupFingerprintStacksForPaths, - loadPackageContext, +} from "./scan/fingerprint-stack.js"; +import { + type GhostPackageConfig, readOptionalPackageConfig, - resolveFingerprintPackage, -} from "./scan/index.js"; +} from "./scan/package-config.js"; export async function buildReviewPacket(options: { packageDir?: string; @@ -23,11 +25,11 @@ export async function buildReviewPacket(options: { includeAcceptedDecisions: boolean; }): Promise { return options.packageDir - ? buildSingleBundleReviewPacket(options) + ? buildSinglePackageReviewPacket(options) : buildStackReviewPacket(options); } -async function buildSingleBundleReviewPacket(options: { +async function buildSinglePackageReviewPacket(options: { packageDir?: string; diffText: string; includeAcceptedDecisions: boolean; diff --git a/packages/ghost/src/scan-emit-command.ts b/packages/ghost/src/scan-emit-command.ts index 438ee9be..dcf1a2f9 100644 --- a/packages/ghost/src/scan-emit-command.ts +++ b/packages/ghost/src/scan-emit-command.ts @@ -2,21 +2,20 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { CAC } from "cac"; import { - emitPackageReviewCommand, + loadPackageContext, + type PackageContext, +} from "./context/package-context.js"; +import { emitPackageReviewCommand } from "./context/package-review-command.js"; +import { resolveFingerprintPackage } from "./fingerprint.js"; +import { fingerprintStackToPackageContext, loadFingerprintStackForPath, - loadPackageContext, normalizeMemoryDir, - type PackageContext, - resolveFingerprintPackage, - writePackageContextBundle, - writePackageContextBundleFromContext, -} from "./scan/index.js"; +} from "./scan/fingerprint-stack.js"; const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; -const DEFAULT_CONTEXT_OUT = "ghost-context"; -export const SUPPORTED_KINDS = ["review-command", "context-bundle"] as const; +export const SUPPORTED_KINDS = ["review-command"] as const; export type EmitKind = (typeof SUPPORTED_KINDS)[number]; export type ParseEmitKindResult = @@ -41,7 +40,7 @@ export function registerEmitCommand(cli: CAC): void { cli .command( "emit ", - `Emit a derived artifact from the fingerprint package (review command or compact context entrypoint)`, + "Emit a derived artifact from the fingerprint package (review-command).", ) .option( "--path ", @@ -57,22 +56,9 @@ export function registerEmitCommand(cli: CAC): void { ) .option( "-o, --out ", - `Output path (review-command → ${DEFAULT_REVIEW_OUT}; context-bundle → ${DEFAULT_CONTEXT_OUT}/)`, - ) - .option( - "--stdout", - "Write to stdout instead of a file (review-command only)", - ) - // context-bundle flags: - .option("--readme", "Include README.md (context-bundle)") - .option( - "--prompt-only", - "Emit only prompt.md (context-bundle compact entrypoint)", - ) - .option( - "--name ", - "Override the skill name (default: prose.yml product or first scope) (context-bundle)", + `Output path (review-command → ${DEFAULT_REVIEW_OUT})`, ) + .option("--stdout", "Write to stdout instead of a file") .action(async (kind: string, opts) => { try { const parsed = parseEmitKind(kind); @@ -93,61 +79,21 @@ export function registerEmitCommand(cli: CAC): void { return; } - if (parsed.kind === "review-command") { - const context = await loadEmitPackageContext(opts); - const content = emitPackageReviewCommand({ - context, - }); - - if (opts.stdout) { - process.stdout.write(content); - process.exit(0); - return; - } + const context = await loadEmitPackageContext(opts); + const content = emitPackageReviewCommand({ + context, + }); - const outPath = resolve( - process.cwd(), - opts.out ?? DEFAULT_REVIEW_OUT, - ); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, content, "utf-8"); - console.log(`Wrote ${outPath}`); + if (opts.stdout) { + process.stdout.write(content); process.exit(0); return; } - // kind === "context-bundle" - const outDir = resolve( - process.cwd(), - (opts.out as string | undefined) ?? DEFAULT_CONTEXT_OUT, - ); - - const context = await loadEmitPackageContext(opts); - const result = explicitPackage - ? await writePackageContextBundle( - resolveFingerprintPackage(opts.package, process.cwd()), - { - outDir, - readme: Boolean(opts.readme), - promptOnly: Boolean(opts.promptOnly), - name: opts.name as string | undefined, - }, - ) - : await writePackageContextBundleFromContext(context, { - outDir, - readme: Boolean(opts.readme), - promptOnly: Boolean(opts.promptOnly), - name: opts.name as string | undefined, - }); - - process.stdout.write( - `Wrote ${result.files.length} file${ - result.files.length === 1 ? "" : "s" - } to ${result.outDir}:\n`, - ); - for (const f of result.files) { - process.stdout.write(` ${f}\n`); - } + const outPath = resolve(process.cwd(), opts.out ?? DEFAULT_REVIEW_OUT); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, content, "utf-8"); + console.log(`Wrote ${outPath}`); process.exit(0); return; } catch (err) { @@ -162,13 +108,11 @@ export function registerEmitCommand(cli: CAC): void { async function loadEmitPackageContext(opts: { path?: unknown; package?: unknown; - name?: unknown; memoryDir?: unknown; }): Promise { if (typeof opts.package === "string") { return loadPackageContext( resolveFingerprintPackage(opts.package, process.cwd()), - typeof opts.name === "string" ? opts.name : undefined, ); } @@ -181,8 +125,5 @@ async function loadEmitPackageContext(opts: { ), }, ); - return fingerprintStackToPackageContext( - stack, - typeof opts.name === "string" ? opts.name : undefined, - ); + return fingerprintStackToPackageContext(stack); } diff --git a/packages/ghost/src/scan/context/checks.ts b/packages/ghost/src/scan/context/checks.ts deleted file mode 100644 index 6622c505..00000000 --- a/packages/ghost/src/scan/context/checks.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Check, DriftSeverity, Fingerprint } from "#ghost-core"; -import { - computeCheckSeverity, - resolveMatchShape, - resolveTolerance, -} from "#ghost-core"; - -export interface ResolvedCheck { - check: Check; - surveyCount: number; - severity: DriftSeverity; - match: string; - tolerance: number | undefined; -} - -const SEVERITY_ORDER: Record = { - critical: 0, - serious: 1, - nit: 2, -}; - -export function resolveFingerprintChecks(fp: Fingerprint): ResolvedCheck[] { - return (fp.checks ?? []).map((check) => { - const surveyCount = surveyCountForCheck(check, fp); - return { - check, - surveyCount, - severity: computeCheckSeverity(check, surveyCount), - match: resolveMatchShape(check), - tolerance: resolveTolerance(check), - }; - }); -} - -export function bySeverityThenId(a: ResolvedCheck, b: ResolvedCheck): number { - return ( - SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || - a.check.id.localeCompare(b.check.id) - ); -} - -/** - * Use check-authored observed counts when present. Otherwise fall back to a - * coarse proxy for survey-count per canonical dimension, derived from the - * structured frontmatter fields. v0 fingerprints don't carry the survey - * directly; the proxy keeps presence-floor escalation deterministic until - * the check author supplies `observed_count`. - */ -export function surveyCountForCheck(check: Check, fp: Fingerprint): number { - if (typeof check.observed_count === "number") return check.observed_count; - - switch (check.canonical) { - case "color-strategy": - return ( - fp.palette.dominant.length + - fp.palette.neutrals.count + - fp.palette.semantic.length - ); - case "surface-hierarchy": - return fp.palette.semantic.length + fp.palette.dominant.length; - case "shape-language": - return fp.surfaces.borderRadii.length; - case "elevation": - return fp.surfaces.shadowComplexity === "deliberate-none" - ? 0 - : fp.surfaces.shadowComplexity === "subtle" - ? 2 - : 5; - case "spatial-system": - case "density": - return fp.spacing.scale.length; - case "typography-voice": - return fp.typography.sizeRamp.length; - case "font-sourcing": - return fp.typography.families.length; - case "motion": - // Motion isn't in structured fields; default to a count above - // typical floors so escalation only happens via explicit author - // hint (check.presence_floor: 2+). - return 100; - default: - // Unknown canonical -> leave room above floor 0 so escalation - // doesn't fire incorrectly, but author can override via floor. - return 100; - } -} diff --git a/packages/ghost/src/scan/context/index.ts b/packages/ghost/src/scan/context/index.ts deleted file mode 100644 index 6ffcd70a..00000000 --- a/packages/ghost/src/scan/context/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type { PackageContext } from "./package-context.js"; -export { loadPackageContext } from "./package-context.js"; -export type { EmitPackageReviewInput } from "./package-review-command.js"; -export { emitPackageReviewCommand } from "./package-review-command.js"; -export type { WritePackageContextOptions } from "./package-writer.js"; -export { - writePackageContextBundle, - writePackageContextBundleFromContext, -} from "./package-writer.js"; -export { buildTokensCss } from "./tokens-css.js"; -export type { - ContextFormat, - WriteContextOptions, - WriteContextResult, -} from "./writer.js"; -export { buildSkillMd, writeContextBundle } from "./writer.js"; diff --git a/packages/ghost/src/scan/context/package-writer.ts b/packages/ghost/src/scan/context/package-writer.ts deleted file mode 100644 index dc4b1ab4..00000000 --- a/packages/ghost/src/scan/context/package-writer.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { stringify as stringifyYaml } from "yaml"; -import { GHOST_FINGERPRINT_PACKAGE_SCHEMA } from "#ghost-core"; -import type { FingerprintPackagePaths } from "../fingerprint-package.js"; -import { buildContextEntrypoint } from "./entrypoint.js"; -import { formatContextEntrypointMarkdown } from "./entrypoint-markdown.js"; -import { loadPackageContext, type PackageContext } from "./package-context.js"; -import type { WriteContextResult } from "./writer.js"; - -export interface WritePackageContextOptions { - outDir: string; - /** Override the skill/package name. Default: prose.yml summary product. */ - name?: string; - /** Emit only prompt.md. Default: false. */ - promptOnly?: boolean; - /** Include README.md. Default: false. */ - readme?: boolean; -} - -export async function writePackageContextBundle( - paths: FingerprintPackagePaths, - options: WritePackageContextOptions, -): Promise { - const context = await loadPackageContext(paths, options.name); - return writePackageContextBundleFromContext(context, options); -} - -export async function writePackageContextBundleFromContext( - context: PackageContext, - options: WritePackageContextOptions, -): Promise { - await mkdir(options.outDir, { recursive: true }); - const files: string[] = []; - - const promptPath = join(options.outDir, "prompt.md"); - await writeFile(promptPath, buildPackagePromptMd(context), "utf-8"); - files.push(promptPath); - - if (options.promptOnly) { - return { outDir: options.outDir, files }; - } - - const skillPath = join(options.outDir, "SKILL.md"); - await writeFile(skillPath, buildPackageSkillMd(context), "utf-8"); - files.push(skillPath); - - await writeContextFile( - options.outDir, - files, - "fingerprint/manifest.yml", - context.fingerprintLayers?.manifest ?? - `schema: ${GHOST_FINGERPRINT_PACKAGE_SCHEMA}\nid: ${context.name}\n`, - ); - await writeContextFile( - options.outDir, - files, - "fingerprint/prose.yml", - context.fingerprintLayers?.prose ?? - stringifyYaml(context.fingerprint.prose, { lineWidth: 0 }), - ); - await writeContextFile( - options.outDir, - files, - "fingerprint/inventory.yml", - context.fingerprintLayers?.inventory ?? - stringifyYaml(context.fingerprint.inventory, { lineWidth: 0 }), - ); - await writeContextFile( - options.outDir, - files, - "fingerprint/composition.yml", - context.fingerprintLayers?.composition ?? - stringifyYaml(context.fingerprint.composition, { lineWidth: 0 }), - ); - if (context.checksRaw) { - await writeContextFile( - options.outDir, - files, - "fingerprint/enforcement/checks.yml", - context.checksRaw, - ); - } - if (context.intent) { - await writeContextFile( - options.outDir, - files, - "fingerprint/memory/intent.md", - context.intent, - ); - } - if (options.readme) { - await writeContextFile( - options.outDir, - files, - "README.md", - buildPackageReadmeMd(context), - ); - } - - return { outDir: options.outDir, files }; -} - -async function writeContextFile( - outDir: string, - files: string[], - name: string, - content: string, -): Promise { - const outPath = join(outDir, name); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, ensureTrailingNewline(content), "utf-8"); - files.push(outPath); -} - -function buildPackageSkillMd(context: PackageContext): string { - return `--- -name: ${context.name} -description: Use this Ghost surface-composition fingerprint to preserve coherent UI generation and review. -user-invocable: true ---- - -This skill grounds work in the **${context.name}** Ghost fingerprint. - -Treat this bundle as the upstream handoff for agentic UI work. Use it before -generation so the output extends the same product-surface composition, then -validate the resulting diff with Ghost checks or review when available. - -Read the files in this order: - -1. \`prompt.md\` - compact entrypoint: selected refs, suggested reads, omissions, and validation notes. -2. \`fingerprint/prose.yml\`, \`fingerprint/inventory.yml\`, and \`fingerprint/composition.yml\` - canonical core layers. -3. \`fingerprint/enforcement/checks.yml\` when present - deterministic gates; only \`active\` checks block. -4. \`fingerprint/memory/intent.md\` when present - supplemental human-authored context. - -When generating UI, combine prose, inventory, and composition from -\`fingerprint/\`. Use generated cache only as replaceable source material -that may help satisfy canonical prose, inventory, and composition. -When reviewing, use active checks for blocking validation and keep other -findings advisory. - -When fingerprint layers are silent, proceed from nearby product surfaces, local -components, token and copy conventions, optional rationale files when present, -and ordinary UX reasoning when safe. Label that reasoning as provisional and -non-Ghost-backed. Ask a human before making high-risk, irreversible, -privacy/security/legal, or product-surface-defining choices. Fingerprint edits -are ordinary Git-reviewed edits to \`fingerprint/\` files and optional local -\`config.yml\` when present. -`; -} - -function buildPackagePromptMd(context: PackageContext): string { - return formatContextEntrypointMarkdown(buildContextEntrypoint(context)); -} - -function buildPackageReadmeMd(context: PackageContext): string { - return `# ${context.name} context bundle - -Generated by \`ghost emit context-bundle\` from a root Ghost fingerprint -package. - -This is an upstream agent handoff. Give it to Codex, Cursor, Claude, or another -host agent before asking for UI work, then validate the resulting diff with -Ghost checks or review when available. - -## Files - -- \`SKILL.md\` - agent skill manifest. -- \`prompt.md\` - compact entrypoint with selected refs, suggested reads, omissions, and validation notes. -- \`fingerprint/manifest.yml\` - portable fingerprint package anchor. -- \`fingerprint/prose.yml\` - surface intent. -- \`fingerprint/inventory.yml\` - canonical curated material and source links. -- \`fingerprint/composition.yml\` - canonical experience patterns. -${context.checksRaw ? "- `fingerprint/enforcement/checks.yml` - deterministic gates.\n" : ""}${context.intent ? "- `fingerprint/memory/intent.md` - supplemental human-authored context.\n" : ""} -Regenerate this bundle when \`fingerprint/\` core layers, active checks, or -optional rationale files change. -`; -} - -function ensureTrailingNewline(value: string): string { - return value.endsWith("\n") ? value : `${value}\n`; -} diff --git a/packages/ghost/src/scan/context/tokens-css.ts b/packages/ghost/src/scan/context/tokens-css.ts deleted file mode 100644 index 6b257c23..00000000 --- a/packages/ghost/src/scan/context/tokens-css.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { Fingerprint } from "#ghost-core"; - -export interface TokensCssOptions { - /** Source path (e.g. ".ghost/fingerprint.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. - * - * The file opens with a provenance header identifying the generator, - * source fingerprint, and timestamp. Hand-edits will silently drift from - * the source — the header warns readers not to edit. - */ -export function buildTokensCss( - fingerprint: Fingerprint, - options: TokensCssOptions = {}, -): string { - const sections: string[] = []; - - const dominant = fingerprint.palette?.dominant ?? []; - if (dominant.length) { - sections.push( - block( - "Dominant brand", - dominant.map((c) => `--brand-${slug(c.role)}: ${c.value};`), - ), - ); - } - - const semantic = fingerprint.palette?.semantic ?? []; - if (semantic.length) { - sections.push( - block( - "Semantic colors", - semantic.map((c) => `--color-${slug(c.role)}: ${c.value};`), - ), - ); - } - - const neutrals = fingerprint.palette?.neutrals?.steps ?? []; - if (neutrals.length) { - sections.push( - block( - "Neutral ramp", - neutrals.map((hex, i) => `--neutral-${i}: ${hex};`), - ), - ); - } - - const spacing = fingerprint.spacing?.scale ?? []; - if (spacing.length) { - sections.push( - block( - "Spacing scale", - spacing.map((n, i) => `--space-${i}: ${n}px;`), - ), - ); - } - - const sizeRamp = fingerprint.typography?.sizeRamp ?? []; - if (sizeRamp.length) { - sections.push( - block( - "Typography scale", - sizeRamp.map((n, i) => `--text-${i}: ${n}px;`), - ), - ); - } - - const families = fingerprint.typography?.families ?? []; - if (families.length) { - sections.push( - block("Font families", [`--font-sans: ${families.join(", ")};`]), - ); - } - - const radii = fingerprint.surfaces?.borderRadii ?? []; - if (radii.length) { - sections.push( - block( - "Border radii", - radii.map((n, i) => `--radius-${i}: ${n}px;`), - ), - ); - } - - const generator = options.generator ?? "ghost"; - const source = options.sourcePath ?? ".ghost/fingerprint.md"; - const timestamp = fingerprint.timestamp ?? new Date().toISOString(); - const header = [ - "/*", - ` * Generated by ${generator} from ${source} on ${timestamp}`, - " * DO NOT EDIT — regenerate with `ghost emit context-bundle`.", - " */", - ].join("\n"); - const body = sections.length - ? `:root {\n${sections.join("\n\n")}\n}` - : ":root {}"; - return `${header}\n\n${body}\n`; -} - -function block(label: string, lines: string[]): string { - const indented = lines.map((l) => ` ${l}`).join("\n"); - return ` /* ${label} */\n${indented}`; -} - -function slug(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} diff --git a/packages/ghost/src/scan/context/writer.ts b/packages/ghost/src/scan/context/writer.ts deleted file mode 100644 index c519a525..00000000 --- a/packages/ghost/src/scan/context/writer.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { DesignDecision, Fingerprint } from "#ghost-core"; -import { serializeFingerprint } from "../writer.js"; -import { - bySeverityThenId, - type ResolvedCheck, - resolveFingerprintChecks, -} from "./checks.js"; -import { buildTokensCss } from "./tokens-css.js"; - -/** - * @deprecated Legacy union retained for API compatibility with existing - * CLI flags. Prefer the boolean flag options (`tokens`, `readme`, - * `promptOnly`) on WriteContextOptions. - */ -export type ContextFormat = "skill" | "prompt" | "bundle"; - -export interface WriteContextOptions { - outDir: string; - /** Emit tokens.css. Default: true. */ - tokens?: boolean; - /** Emit README.md. Default: false. */ - readme?: boolean; - /** Emit only prompt.md (skips SKILL.md / fingerprint.md / tokens.css). Default: false. */ - promptOnly?: boolean; - /** Override the skill name. Default: derived from fingerprint.id. */ - name?: string; - /** - * @deprecated Pass `tokens`, `readme`, `promptOnly` instead. - * Still honored for one release to avoid breaking callers: - * "skill" → tokens:true, readme:false - * "bundle" → tokens:true, readme:true - * "prompt" → promptOnly:true - */ - format?: ContextFormat; - /** Source path (e.g. ".ghost/fingerprint.md") surfaced in generated file headers. */ - sourcePath?: string; - /** Generator version string — surfaced in generated file headers. */ - generator?: string; -} - -export interface WriteContextResult { - outDir: string; - files: string[]; -} - -export async function writeContextBundle( - fingerprint: Fingerprint, - options: WriteContextOptions, -): Promise { - const resolved = resolveFlags(options, fingerprint); - 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)); - files.push(p); - return { outDir: options.outDir, files }; - } - - const skillPath = join(options.outDir, "SKILL.md"); - await writeFile( - skillPath, - buildSkillMd(fingerprint, resolved.name, resolved.tokens), - ); - files.push(skillPath); - - const exprPath = join(options.outDir, "fingerprint.md"); - await writeFile(exprPath, serializeFingerprint(fingerprint)); - files.push(exprPath); - - const promptPath = join(options.outDir, "prompt.md"); - await writeFile(promptPath, buildPromptMd(fingerprint, resolved.name)); - files.push(promptPath); - - if (resolved.tokens) { - const cssPath = join(options.outDir, "tokens.css"); - await writeFile( - cssPath, - buildTokensCss(fingerprint, { - sourcePath: options.sourcePath, - generator: options.generator, - }), - ); - files.push(cssPath); - } - - if (resolved.readme) { - const readmePath = join(options.outDir, "README.md"); - await writeFile(readmePath, buildReadmeMd(fingerprint, resolved.name)); - files.push(readmePath); - } - - return { outDir: options.outDir, files }; -} - -interface ResolvedFlags { - name: string; - tokens: boolean; - readme: boolean; - promptOnly: boolean; -} - -function resolveFlags( - options: WriteContextOptions, - fp?: Fingerprint, -): ResolvedFlags { - // Legacy format flag takes precedence if explicitly set by an old caller. - let tokens = options.tokens ?? true; - let readme = options.readme ?? false; - let promptOnly = options.promptOnly ?? false; - if (options.format) { - if (options.format === "prompt") { - promptOnly = true; - } else if (options.format === "skill") { - tokens = false; - readme = false; - } else if (options.format === "bundle") { - tokens = true; - readme = true; - } - } - return { - name: options.name ?? defaultSkillName(fp), - tokens, - readme, - promptOnly, - }; -} - -export function buildSkillMd( - fingerprint: Fingerprint, - name: string, - includesCss: boolean, -): string { - const description = buildSkillDescription(fingerprint, name); - const fileList = [ - "- `fingerprint.md` — deprecated legacy direct-markdown design-language context (YAML digest + Character/Signature/References/Decisions)", - "- `prompt.md` — generation prompt distilled from legacy fingerprint.md", - ...(includesCss - ? [ - "- `tokens.css` — CSS custom properties derived from fingerprint tokens", - ] - : []), - ].join("\n"); - - const body = `This skill grounds UI generation in the **${name}** design language from a legacy direct-markdown Ghost context. Prefer a canonical \`.ghost/fingerprint/\` package when one is available. - -Read \`fingerprint.md\` as source context for this legacy bundle. It has these layered sections: - -1. **Character** — what this fingerprint is (one-paragraph summary in the body) -2. **Signature** — dominant moves and the recognizable output posture -3. **References** — local provenance and optional source material (frontmatter \`references\`) -4. **Decisions** — abstract design choices with evidence from the source (body \`### dimension\` blocks) -5. **Checks** — embedded legacy gate hints when available - -When generating UI in this language: - -- Treat **Checks** as curated gates when they are provided. -- Use **Decisions** as the lookup for specific choices (spacing scale, type ramp, radii). -- When working inside the source repo, open **References** before inventing new components or values. When applying this language elsewhere, treat references as provenance; do not assume those paths exist. -- Let **Character** shape overall feel, density, and voice. -- Let **Signature** shape the final picture: layout posture, dominant moves, and recognizable habits. -- Before composing, infer the output shape the task calls for: article, tracker, comparison, card, or control surface. Card is one shape, not the default form of every answer. -- Prefer tokens from the YAML frontmatter (palette, spacing, typography, surfaces) over arbitrary values. - -## Files - -${fileList} -`; - - return `--- -name: ${name} -description: ${description} -user-invocable: true ---- - -${body}`; -} - -function buildPromptMd(fingerprint: Fingerprint, name: string): string { - const parts: string[] = []; - parts.push( - `You are generating UI in the **${name}** design language from a legacy direct-markdown Ghost context. Produce the requested UI artifact; explain design decisions only when the user asks.`, - ); - - const summary = fingerprint.observation?.summary?.trim(); - if (summary) parts.push(`# Character\n\n${summary}`); - - const signature = fingerprint.signature?.trim(); - parts.push( - `# Signature\n\n${signature || "No signature prose has been authored yet. Use Character, Local References when accessible, Decisions, and Tokens conservatively."}`, - ); - - parts.push(`# Local References\n\n${formatReferences(fingerprint)}`); - - const decisions = fingerprint.decisions ?? []; - if (decisions.length) - parts.push(`# Decisions\n\n${decisions.map(formatDecision).join("\n\n")}`); - else parts.push("# Decisions\n\nNo decision prose has been authored yet."); - - const checks = resolveFingerprintChecks(fingerprint).sort(bySeverityThenId); - if (checks.length) { - parts.push(`# Checks\n\n${formatChecks(checks)}`); - } else { - parts.push( - "# Checks\n\nNo package checks were embedded in this context bundle. Treat this as generation guidance; run `ghost check` in the source repo for active gates.", - ); - } - - parts.push(`# Shape Selection\n\n${formatShapeSelection(fingerprint)}`); - - parts.push(`# Tokens\n\n${formatTokens(fingerprint)}`); - - const usageLead = checks.length - ? "use embedded legacy checks as gates, local references when accessible, decisions as style direction, and tokens as the value digest" - : "use local references when accessible, decisions as style direction, and tokens as the value digest; run package checks separately when available"; - parts.push( - `# How to use this prompt\n\nWhen asked to build a component or screen, ${usageLead}. Prefer existing local components and token names when they are available in the current project. If this fingerprint is being used outside its source repo, ignore inaccessible paths and follow Character, Signature, Decisions, Checks, and Tokens. Avoid arbitrary hex, spacing, font, radius, shadow, or motion values unless the fingerprint explicitly allows them.`, - ); - - return `${parts.join("\n\n")}\n`; -} - -function buildReadmeMd(fingerprint: Fingerprint, name: string): string { - const personality = fingerprint.observation?.personality ?? []; - const personalityLine = personality.length - ? ` Personality: ${personality.slice(0, 3).join(", ")}.` - : ""; - return `# ${name} — legacy design context bundle - -Generated from deprecated direct-markdown input. Grounding material for AI UI generation in the **${name}** design language.${personalityLine} - -## Files - -- \`SKILL.md\` — Agent Skill manifest (user-invocable) -- \`fingerprint.md\` — legacy direct-markdown design-language context (YAML frontmatter + Character/Signature/Decisions) -- \`prompt.md\` — portable prompt distilled from the fingerprint -- \`tokens.css\` — CSS custom properties derived from fingerprint 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:** use \`prompt.md\` as the portable prompt, and add \`fingerprint.md\` or \`tokens.css\` only when the host supports extra context files. - -**Canonical path:** prefer a \`.ghost/fingerprint/\` package and emit a package context bundle when one is available. - -**Feedback loop:** ask your host agent to review the generated output against this context and run \`ghost check\` in the source repo when a full package is available. -`; -} - -function buildSkillDescription(fingerprint: Fingerprint, name: string): string { - const personality = fingerprint.observation?.personality ?? []; - const traitPhrase = personality.length - ? ` (${personality.slice(0, 3).join(", ")})` - : ""; - return `Use this legacy skill to generate UI in the ${name} design language${traitPhrase}. Contains direct-markdown fingerprint and token reference.`; -} - -function defaultSkillName(fingerprint?: Fingerprint): string { - const candidate = fingerprint?.id || "design-language"; - return candidate - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -function formatDecision(d: DesignDecision): string { - return `## ${d.dimension}\n${d.decision.trim()}`; -} - -function formatShapeSelection(fingerprint: Fingerprint): string { - const lines: string[] = []; - const composition = findCompositionDecision(fingerprint); - if (composition?.decision.trim()) { - lines.push(`Fingerprint guidance: ${composition.decision.trim()}`, ""); - } - - lines.push( - "- Before layout, infer a narrow intent/shape slice from the user's task and select examples or patterns that match that slice.", - "- Use `article` for plans, timelines, worksheets, narrative/canvas outputs, and long-form synthesized answers.", - "- Use `tracker` for metrics, progress, runway, review queues, audit status, and recurring operational views.", - "- Use `comparison` for tradeoffs, allocation, option sets, before/after states, and side-by-side decisions.", - "- Use `card` for compact focused recommendations or repeated peer items. Do not turn every answer into a stack of cards.", - "- Use local controls for explicit editing, filtering, configuration, or approval tasks.", - "- A restrained fingerprint is not permission to make everything plain. Create variety through allowed scale contrast, shaped composition, semantic/data color, role-based elevation, functional motion, and themeable tokens.", - ); - - return lines.join("\n"); -} - -function findCompositionDecision( - fingerprint: Fingerprint, -): DesignDecision | undefined { - return (fingerprint.decisions ?? []).find((decision) => { - const dimension = decision.dimension.toLowerCase(); - const kind = decision.dimension_kind?.toLowerCase(); - return ( - dimension === "composition-patterns" || - kind === "composition-patterns" || - dimension === "response-shapes" || - dimension === "output-shapes" - ); - }); -} - -function formatChecks(checks: ResolvedCheck[]): string { - return checks.map(formatCheck).join("\n"); -} - -function formatCheck(item: ResolvedCheck): string { - const { check, severity, match, tolerance } = item; - const parts = [ - `- **${severity.toUpperCase()}** \`${check.id}\`${check.canonical ? ` (${check.canonical})` : ""}: ${check.summary ?? check.pattern}`, - ]; - parts.push(` Avoid: matches to \`${check.pattern}\`.`); - parts.push( - tolerance !== undefined - ? ` Match: \`${match}\` with tolerance \`${tolerance}\`.` - : ` Match: \`${match}\`.`, - ); - if (check.paths?.length) { - parts.push(` Paths: ${check.paths.map((e) => `\`${e}\``).join(", ")}.`); - } - if (check.contexts?.length) { - parts.push( - ` Contexts: ${check.contexts.map((e) => `\`${e}\``).join(", ")}.`, - ); - } - return parts.join("\n"); -} - -function formatReferences(fingerprint: Fingerprint): string { - const refs = fingerprint.references ?? {}; - const groups: Array<[string, string[] | undefined]> = [ - ["Specs", refs.specs], - ["Components", refs.components], - ["Examples", refs.examples], - ]; - const lines: string[] = [ - "These paths are local provenance and optional source material. Use them when they exist in the current workspace; otherwise treat the fingerprint body and token digest as the portable contract.", - "", - ]; - for (const [label, values] of groups) { - lines.push(`**${label}**`); - if (values?.length) { - for (const value of values) lines.push(`- \`${value}\``); - } else { - lines.push("- None promoted yet"); - } - lines.push(""); - } - return lines.join("\n").trimEnd(); -} - -function formatTokens(fingerprint: Fingerprint): string { - const lines: string[] = []; - const dominant = fingerprint.palette?.dominant ?? []; - if (dominant.length) { - lines.push("**Dominant colors**"); - for (const c of dominant) lines.push(`- \`${c.role}\`: ${c.value}`); - } - const neutrals = fingerprint.palette?.neutrals?.steps ?? []; - if (neutrals.length) lines.push(`\n**Neutral ramp:** ${neutrals.join(", ")}`); - const semantic = fingerprint.palette?.semantic ?? []; - if (semantic.length) { - if (lines.length) lines.push(""); - lines.push("**Semantic colors**"); - for (const c of semantic) lines.push(`- \`${c.role}\`: ${c.value}`); - } - const spacing = fingerprint.spacing?.scale ?? []; - if (spacing.length) - lines.push(`\n**Spacing scale:** ${spacing.join(", ")}px`); - const sizeRamp = fingerprint.typography?.sizeRamp ?? []; - if (sizeRamp.length) lines.push(`\n**Type scale:** ${sizeRamp.join(", ")}px`); - const families = fingerprint.typography?.families ?? []; - if (families.length) - lines.push(`\n**Font families:** ${families.join(", ")}`); - const radii = fingerprint.surfaces?.borderRadii ?? []; - if (radii.length) lines.push(`\n**Border radii:** ${radii.join(", ")}px`); - lines.push(`\n**Shadow posture:** ${fingerprint.surfaces.shadowComplexity}`); - lines.push(`**Border usage:** ${fingerprint.surfaces.borderUsage}`); - return lines.join("\n"); -} diff --git a/packages/ghost/src/scan/fingerprint-set.ts b/packages/ghost/src/scan/fingerprint-set.ts index e8594eeb..8d3f8f1a 100644 --- a/packages/ghost/src/scan/fingerprint-set.ts +++ b/packages/ghost/src/scan/fingerprint-set.ts @@ -1,8 +1,8 @@ import { existsSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import type { Fingerprint, MapScope } from "#ghost-core"; +import { loadFingerprint } from "../fingerprint-load.js"; import { FINGERPRINT_FILENAME, FINGERPRINTS_DIRNAME } from "./constants.js"; -import { loadFingerprint } from "./index.js"; export interface LoadedFingerprintNode { id: string; diff --git a/packages/ghost/src/scan/fingerprint-stack.ts b/packages/ghost/src/scan/fingerprint-stack.ts index 618a9326..d12d0d29 100644 --- a/packages/ghost/src/scan/fingerprint-stack.ts +++ b/packages/ghost/src/scan/fingerprint-stack.ts @@ -28,16 +28,16 @@ import { lintGhostFingerprint, type MapFrontmatter, } from "#ghost-core"; +import { + loadPackageInventory, + type PackageContext, + type PackageInventory, +} from "../context/package-context.js"; import { FINGERPRINT_DIRNAME, FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_PACKAGE_DIR, } from "./constants.js"; -import { - loadPackageInventory, - type PackageContext, - type PackageInventory, -} from "./context/package-context.js"; import type { FingerprintPackagePaths } from "./fingerprint-package.js"; import { lintFingerprintPackage, diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index 295fbdfd..b987574a 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -1,86 +1,11 @@ -import { readFile } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; -import type { Fingerprint, SemanticColor } from "#ghost-core"; -import { computeEmbedding, parseColorToOklch } from "#ghost-core"; -import { mergeFingerprint } from "./compose.js"; -import { mergeFrontmatter } from "./frontmatter.js"; -import { type ParsedFingerprint, parseFingerprint } 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 fingerprint recipe in your host agent (install with \`ghost skill install\`).`, - ); - } -} - -export type { BodyData } from "./body.js"; -export { parseBody } from "./body.js"; -export type { DesignDecision } from "./compose.js"; -export { mergeFingerprint } from "./compose.js"; export { CACHE_DIRNAME, - CHECKS_FILENAME, CONFIG_FILENAME, - FINGERPRINT_COMPOSITION_FILENAME, FINGERPRINT_DIRNAME, - FINGERPRINT_ENFORCEMENT_DIRNAME, - FINGERPRINT_FILENAME, - FINGERPRINT_INVENTORY_FILENAME, - FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_MEMORY_DIRNAME, FINGERPRINT_PACKAGE_DIR, - FINGERPRINT_PROSE_FILENAME, FINGERPRINT_SOURCES_DIRNAME, - FINGERPRINT_YML_FILENAME, - FINGERPRINTS_DIRNAME, - INTENT_FILENAME, - PATTERNS_FILENAME, - RESOURCES_FILENAME, - SCOPE_SURVEYS_DIRNAME, } from "./constants.js"; -// --- Context (review-command + context-bundle) --- -export type { - ContextFormat, - EmitPackageReviewInput, - PackageContext, - WriteContextOptions, - WriteContextResult, - WritePackageContextOptions, -} from "./context/index.js"; -export { - buildSkillMd, - buildTokensCss, - emitPackageReviewCommand, - loadPackageContext, - writeContextBundle, - writePackageContextBundle, - writePackageContextBundleFromContext, -} from "./context/index.js"; -export type { - ColorChange, - DecisionChange, - SemanticDiff, - TokenChange, -} from "./diff.js"; -export { diffFingerprints, formatSemanticDiff } from "./diff.js"; -export type { - FingerprintPackagePaths, - LoadedFingerprintPackage, -} from "./fingerprint-package.js"; -export { - initFingerprintPackage, - lintFingerprintPackage, - loadFingerprintPackage, - resolveFingerprintPackage, -} from "./fingerprint-package.js"; -export type { - LoadedFingerprintNode, - LoadedFingerprintSet, - LoadFingerprintSetOptions, -} from "./fingerprint-set.js"; -export { loadFingerprintSet } from "./fingerprint-set.js"; export type { DiscoveredGhostPackage, FingerprintDirectoryOptions, @@ -94,53 +19,12 @@ export { discoverFingerprintStack, discoverGhostPackages, fingerprintPackageDisplayPath, - fingerprintStackToPackageContext, groupFingerprintStacksForPaths, - initScopedFingerprintPackage, - lintAllFingerprintStacks, loadFingerprintStackForPath, - mapFromFingerprint, normalizeMemoryDir, resolveGitRoot, - verifyAllFingerprintStacks, } from "./fingerprint-stack.js"; -export type { FingerprintMeta, FrontmatterData } from "./frontmatter.js"; export { inventory } from "./inventory.js"; -export type { - FingerprintLayout, - FingerprintLayoutSection, -} from "./layout.js"; -export { formatLayout, layoutFingerprint } from "./layout.js"; -export type { - LintIssue, - LintOptions, - LintReport, - LintSeverity, -} from "./lint.js"; -export { lintFingerprint } from "./lint.js"; -export type { - MapLintIssue, - MapLintReport, - MapLintSeverity, -} from "./lint-map.js"; -export { lintMap } from "./lint-map.js"; -export type { - GhostPackageConfig, - GhostPackageConfigLibrary, - GhostPackageConfigTarget, -} from "./package-config.js"; -export { - GHOST_PACKAGE_CONFIG_SCHEMA, - GhostPackageConfigSchema, - lintGhostPackageConfig, - normalizeReferenceInput, - parsePackageConfig, - readOptionalPackageConfig, - readOptionalPackageConfigSync, - templatePackageConfig, -} from "./package-config.js"; -export type { ParsedFingerprint, ParseOptions } from "./parser.js"; -export { parseFingerprint, splitRaw } from "./parser.js"; export type { ScanScopeReport, ScanStage, @@ -150,148 +34,3 @@ export type { ScanStatusOptions, } from "./scan-status.js"; export { scanStatus } from "./scan-status.js"; -export type { FrontmatterShape } from "./schema.js"; -export { - FrontmatterSchema, - PartialFrontmatterSchema, - toJsonSchema, - validateFrontmatter, -} from "./schema.js"; -export type { - VerifyFingerprintIssue, - VerifyFingerprintOptions, - VerifyFingerprintReport, - VerifyFingerprintSeverity, -} from "./verify-fingerprint.js"; -export { - formatVerifyFingerprintReport, - verifyFingerprint, -} from "./verify-fingerprint.js"; -export type { VerifyFingerprintPackageOptions } from "./verify-package.js"; -export { verifyFingerprintPackage } from "./verify-package.js"; -export type { SerializeOptions } from "./writer.js"; -export { serializeFingerprint } from "./writer.js"; - -export interface LoadOptions { - /** Skip `extends:` resolution. Default: false (extends chains are resolved). */ - noExtends?: boolean; - /** - * Skip embedding backfill. When true, a missing `embedding` stays empty; - * useful for read-only tooling (lint, diff-on-disk) that doesn't need - * the vector. - */ - noEmbeddingBackfill?: boolean; -} - -/** - * Load a ParsedFingerprint from disk. - * - * If the file declares `extends:`, the base fingerprint is loaded recursively and - * merged per the rules in compose.ts: overlay wins, decisions merged by - * dimension, palette colors merged by role. - */ -export async function loadFingerprint( - path: string, - options: LoadOptions = {}, -): Promise { - assertMarkdownPath(path); - - const parsed = options.noExtends - ? await loadRaw(path) - : await loadWithExtends(path, new Set()); - - // Backfill `oklch` on palette colors that arrived hex-only. Deterministic - // (same hex → same oklch), so re-parsing the same fingerprint always - // yields the same in-memory shape. Without this, `comparePalette` - // misreads hex-only colors as fully unmatched (distance 1) and even - // self-distance comes out non-zero. - backfillPaletteOklch(parsed.fingerprint); - - if (!options.noEmbeddingBackfill) { - parsed.fingerprint.embedding = resolveEmbedding(parsed.fingerprint); - } - - return parsed; -} - -function backfillPaletteOklch(fingerprint: Fingerprint): void { - if (!fingerprint.palette) return; - if (fingerprint.palette.dominant) { - fingerprint.palette.dominant = - fingerprint.palette.dominant.map(ensureOklch); - } - if (fingerprint.palette.semantic) { - fingerprint.palette.semantic = - fingerprint.palette.semantic.map(ensureOklch); - } -} - -function ensureOklch(color: SemanticColor): SemanticColor { - if (color.oklch && color.oklch.length === 3) return color; - const oklch = parseColorToOklch(color.value); - return oklch ? { ...color, oklch } : color; -} - -function resolveEmbedding(fingerprint: Fingerprint): number[] { - if (fingerprint.embedding && fingerprint.embedding.length > 0) { - return fingerprint.embedding; - } - // Only recompute when the structured blocks are all present. - // Partial fingerprints (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 - ) { - return computeEmbedding(fingerprint); - } - return []; -} - -async function loadRaw(path: string): Promise { - assertMarkdownPath(path); - const raw = await readFile(path, "utf-8"); - return parseFingerprint(raw); -} - -async function loadWithExtends( - path: string, - visited: Set, -): Promise { - assertMarkdownPath(path); - const absolute = isAbsolute(path) ? path : resolve(path); - if (visited.has(absolute)) { - throw new Error( - `Cycle detected while resolving extends: chain — ${absolute} visited twice.`, - ); - } - visited.add(absolute); - - const raw = await readFile(absolute, "utf-8"); - const overlay = parseFingerprint(raw); - if (!overlay.meta.extends) { - return overlay; - } - - const basePath = resolve(dirname(absolute), overlay.meta.extends); - const base = await loadWithExtends(basePath, visited); - - const merged = mergeFingerprint(base.fingerprint, overlay.fingerprint); - // The merged result must satisfy the strict YAML schema. The in-memory - // fingerprint may carry body-owned prose (summary, decision rationale, - // values) that the schema forbids — strip it via mergeFrontmatter before - // validating. - validateFrontmatter(mergeFrontmatter(merged)); - - // Meta merge: overlay wins on everything except extends (dropped after resolve) - const { extends: _dropped, ...overlayMeta } = overlay.meta; - return { - fingerprint: merged, - meta: { ...base.meta, ...overlayMeta }, - body: overlay.body, - bodyRaw: overlay.bodyRaw, - }; -} diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 884a224f..d5e3c703 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -69,7 +69,8 @@ or check format. | `ghost verify [dir] --root ` | Validate evidence paths, exemplar paths, and typed check refs. | | `ghost check --base ` | Run active deterministic gates against a diff. | | `ghost review --base ` | Emit an advisory review packet grounded in fingerprint layers, exemplars, checks, and diff evidence. | -| `ghost emit ` | Emit `review-command` or the `context-bundle` compact entrypoint. | +| `ghost relay gather [target]` | Gather fingerprint-grounded context for an agent target. | +| `ghost emit ` | Emit `review-command`. | | `ghost skill install` | Install this unified skill bundle. | ## Advanced CLI Verbs diff --git a/packages/ghost/src/skill-bundle/references/verify.md b/packages/ghost/src/skill-bundle/references/verify.md index 5b34c29e..b09db2b3 100644 --- a/packages/ghost/src/skill-bundle/references/verify.md +++ b/packages/ghost/src/skill-bundle/references/verify.md @@ -9,9 +9,8 @@ description: Verify generated UI or fingerprint edits against Ghost. fingerprint edits. 2. Run `ghost check --base ` after implementation changes. 3. For advisory review, run `ghost review --base --include-memory`. -4. For generation setup, run `ghost emit context-bundle` and inspect the - compact entrypoint first, then follow suggested reads into prose, inventory, - composition, and active checks when the task widens. +4. For generation setup, run `ghost relay gather ` and read suggested + fingerprint files when the task widens. 5. Inspect generated UI manually or with screenshots when visual fidelity matters. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index f4b3577f..2b94026a 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -136,6 +136,7 @@ describe("ghost CLI", () => { "verify", "check", "review", + "relay gather", "emit", "skill install", ]) { @@ -170,6 +171,7 @@ describe("ghost CLI", () => { "describe [fingerprint]", "diff ", "survey [...surveys]", + "relay [target]", "emit ", "compare [...fingerprints]", "ack", @@ -331,7 +333,6 @@ describe("ghost CLI", () => { const check = await runCli(["check", "--diff", "change.patch"], dir); const review = await runCli(["review", "--diff", "change.patch"], dir); const reviewCommand = await runCli(["emit", "review-command"], dir); - const contextBundle = await runCli(["emit", "context-bundle"], dir); expect(lint.code).toBe(0); expect(verify.code).toBe(0); @@ -339,7 +340,6 @@ describe("ghost CLI", () => { expect(review.code).toBe(0); expect(review.stdout).toContain("## Selected Fingerprint Context"); expect(reviewCommand.code).toBe(0); - expect(contextBundle.code).toBe(0); }); it("warns for checks grounded in omitted sparse fingerprint refs", async () => { @@ -719,7 +719,7 @@ checks: expect(fixed.values[0].id).toBeTruthy(); }); - it("emits review commands and context bundles from the unified cli", async () => { + it("emits review commands from the unified cli", async () => { await writeCheckPackage(dir); await mkdir(join(dir, ".ghost", "fingerprint", "sources", "cache"), { recursive: true, @@ -747,7 +747,6 @@ checks: ); const reviewCommand = await runCli(["emit", "review-command"], dir); - const contextBundle = await runCli(["emit", "context-bundle"], dir); expect(reviewCommand.code).toBe(0); expect(reviewCommand.stdout).toContain("design-review.md"); @@ -768,93 +767,93 @@ checks: expect(emittedReviewCommand).not.toContain( "deprecated legacy direct-markdown", ); - expect(contextBundle.code).toBe(0); - expect(contextBundle.stdout).toContain("prompt.md"); - expect(contextBundle.stdout).toContain("fingerprint/prose.yml"); - expect(contextBundle.stdout).toContain("fingerprint/inventory.yml"); - expect(contextBundle.stdout).toContain("fingerprint/composition.yml"); - expect(contextBundle.stdout).not.toContain("fingerprint.yml"); - expect(contextBundle.stdout).not.toContain("survey-summary.md"); - await expect( - readFile( - join(dir, "ghost-context", "fingerprint", "manifest.yml"), - "utf-8", - ), - ).resolves.toContain("schema: ghost.fingerprint-package/v1"); - await expect( - readFile(join(dir, "ghost-context", "fingerprint", "prose.yml"), "utf-8"), - ).resolves.toContain("summary:"); - await expect( - readFile(join(dir, "ghost-context", "fingerprint.yml"), "utf-8"), - ).rejects.toThrow(); - const prompt = await readFile( - join(dir, "ghost-context", "prompt.md"), - "utf-8", + }); + + it("rejects removed context-bundle emit kind", async () => { + await writeCheckPackage(dir); + + const contextBundle = await runCli(["emit", "context-bundle"], dir); + + expect(contextBundle.code).toBe(2); + expect(contextBundle.stderr).toContain( + "unknown emit kind 'context-bundle'", ); - expect(prompt).toContain("# Agent Handoff"); - expect(prompt).toContain("compact entrypoint into the fingerprint"); - expect(prompt).toContain("Agent Handoff"); - expect(prompt).toContain("## Identity Capsule"); - expect(prompt).toContain("## Context Match"); - expect(prompt).toContain("## Read First"); - expect(prompt).toContain("## Suggested Reads"); - expect(prompt).toContain("## Omissions"); - expect(prompt).not.toContain("```yaml"); - expect(prompt).not.toMatch(/^# Prose$/m); - expect(prompt).not.toMatch(/^# Inventory$/m); - expect(prompt).not.toMatch(/^# Composition$/m); - expect(prompt).toContain("Generated cache is optional source material"); - expect(prompt).toContain("Package.swift"); - expect(prompt).toContain("no-hardcoded-ui-color"); - expect(prompt).not.toContain("candidate-density-check"); - expect(prompt).not.toContain("status: proposed"); - expect(prompt).not.toContain("Proposal Threshold"); - expect(prompt).toContain("provisional and non-Ghost-backed"); - const scopedContextBundle = await runCli( + }); + + it("gathers a Relay brief from the resolved fingerprint stack", async () => { + await writeCheckPackage(dir); + + const result = await runCli( + ["relay", "gather", "Code/Features/Lending/LendingUI"], + dir, + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("# Ghost Relay Brief"); + expect(result.stdout).toContain("Status: path matched"); + expect(result.stdout).toContain("Matched scopes: `lending`"); + expect(result.stdout).toContain("prose.principle:tokenized-ui-color"); + expect(result.stdout).toContain("composition.pattern:tokenized-ui-color"); + expect(result.stdout).toContain( + "inventory.exemplar:lending-tokenized-screen", + ); + expect(result.stdout).toContain("no-hardcoded-ui-color"); + expect(result.stdout).not.toContain("candidate-density-check"); + }); + + it("gathers Relay context as json from an exact package", async () => { + await writeCheckPackage(dir); + + const result = await runCli( [ - "emit", - "context-bundle", - "--path", + "relay", + "gather", "Code/Features/Lending/LendingUI", - "-o", - "ghost-context-scoped", + "--package", + ".ghost", + "--format", + "json", ], dir, ); - expect(scopedContextBundle.code).toBe(0); - const scopedPrompt = await readFile( - join(dir, "ghost-context-scoped", "prompt.md"), - "utf-8", - ); - expect(scopedPrompt).toContain("Status: path matched"); - expect(scopedPrompt).toContain("Matched scopes: `lending`"); - expect(scopedPrompt).toContain( - "inventory.exemplar:lending-tokenized-screen", - ); - await expect( - readFile(join(dir, "ghost-context", "SKILL.md"), "utf-8"), - ).resolves.toContain("provisional and\nnon-Ghost-backed"); - await expect( - readFile(join(dir, "ghost-context", "SKILL.md"), "utf-8"), - ).resolves.toContain("upstream handoff for agentic UI work"); + + expect(result.code).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.schema).toBe("ghost.relay.gather/v1"); + expect(json.source.kind).toBe("package"); + expect(json.targetPaths).toEqual(["Code/Features/Lending/LendingUI"]); + expect(json.entrypoint.match.status).toBe("path-match"); + expect(json.brief).toContain("# Ghost Relay Brief"); }); - it("emits context bundles when generated cache is malformed", async () => { + it("ignores memory-dir when gathering Relay context from an exact package", async () => { await writeCheckPackage(dir); - await mkdir(join(dir, ".ghost", "fingerprint", "sources", "cache"), { - recursive: true, - }); - await writeFile( - join(dir, ".ghost", "fingerprint", "sources", "cache", "inventory.json"), - "{nope", + + const result = await runCli( + [ + "relay", + "gather", + "--package", + ".ghost", + "--memory-dir", + "../not-used", + "--format", + "json", + ], + dir, ); - const contextBundle = await runCli(["emit", "context-bundle"], dir); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).source.kind).toBe("package"); + }); - expect(contextBundle.code).toBe(0); - await expect( - readFile(join(dir, "ghost-context", "prompt.md"), "utf-8"), - ).resolves.toContain("could not be read"); + it("rejects invalid Relay output formats", async () => { + await writeCheckPackage(dir); + + const result = await runCli(["relay", "gather", "--format", "yaml"], dir); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("--format must be 'markdown' or 'json'"); }); it("warns when fingerprint exemplar paths are unreachable", async () => { @@ -1411,7 +1410,7 @@ libraries: ).toContain("ghost.fingerprint-package/v1"); }); - it("lint --all and verify --all include nested bundles", async () => { + it("lint --all and verify --all include nested packages", async () => { await writeNestedCheckPackage(dir); const lint = await runCli(["lint", "--all", "--format", "json"], dir); @@ -1423,7 +1422,7 @@ libraries: expect(lint.code).toBe(0); expect(verify.code).toBe(0); - expect(JSON.parse(scan.stdout).nested_bundles).toHaveLength(2); + expect(JSON.parse(scan.stdout).nested_packages).toHaveLength(2); }); it("lint, verify, and scan discover nested custom fingerprint directories", async () => { @@ -1451,7 +1450,7 @@ libraries: expect(lint.code).toBe(0); expect(verify.code).toBe(0); - expect(JSON.parse(scan.stdout).nested_bundles).toHaveLength(2); + expect(JSON.parse(scan.stdout).nested_packages).toHaveLength(2); }); }); diff --git a/packages/ghost/test/context-entrypoint.test.ts b/packages/ghost/test/context-entrypoint.test.ts index 400ebabb..ba2290a0 100644 --- a/packages/ghost/test/context-entrypoint.test.ts +++ b/packages/ghost/test/context-entrypoint.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { buildContextEntrypoint, buildFingerprintGraph, -} from "../src/scan/context/entrypoint.js"; -import type { PackageContext } from "../src/scan/context/package-context.js"; +} from "../src/context/entrypoint.js"; +import type { PackageContext } from "../src/context/package-context.js"; describe("context entrypoint", () => { it("builds graph nodes and explicit edges from fingerprint refs", () => { diff --git a/packages/ghost/test/fingerprint-package.test.ts b/packages/ghost/test/fingerprint-package.test.ts index 3f6fa080..72c74416 100644 --- a/packages/ghost/test/fingerprint-package.test.ts +++ b/packages/ghost/test/fingerprint-package.test.ts @@ -6,7 +6,7 @@ import { lintFingerprintPackage, loadFingerprintPackage, resolveFingerprintPackage, -} from "../src/scan/index.js"; +} from "../src/fingerprint.js"; describe("split fingerprint package", () => { let dir: string; diff --git a/packages/ghost/test/fixtures/context-sandboxes/harness.ts b/packages/ghost/test/fixtures/context-sandboxes/harness.ts new file mode 100644 index 00000000..54911930 --- /dev/null +++ b/packages/ghost/test/fixtures/context-sandboxes/harness.ts @@ -0,0 +1,479 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { vi } from "vitest"; +import { buildCli } from "../../../src/cli.js"; + +export const REPO_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../..", +); + +export type CacheState = "missing" | "present" | "malformed"; + +export async function createSingleSurfaceSandbox( + options: { cache?: CacheState; reorderUnrelated?: boolean } = {}, +): Promise { + const root = await createTempRoot("single"); + await writeFiles(root, [ + "apps/refunds/settings/page.tsx", + "apps/refunds/settings/primary.tsx", + "apps/refunds/settings/secondary.tsx", + "apps/refunds/settings/tertiary.tsx", + "apps/refunds/settings/quaternary.tsx", + "apps/onboarding/page.tsx", + "shared/ui/Button.tsx", + ]); + await writePackage(join(root, ".ghost"), { + fingerprint: singleSurfaceFingerprint(options.reorderUnrelated ?? false), + checks: refundChecks(), + }); + await writeCache(root, options.cache ?? "missing"); + return root; +} + +export async function createNestedSandbox(): Promise { + const root = await createTempRoot("nested"); + await writeFiles(root, [ + "apps/dashboard/refunds/page.tsx", + "apps/dashboard/refunds/detail.tsx", + "apps/portal/payments/page.tsx", + ]); + await writePackage(join(root, ".ghost"), { + fingerprint: rootProductFingerprint(), + checks: rootChecks(), + }); + await writePackage(join(root, "apps/dashboard/.ghost"), { + fingerprint: dashboardFingerprint(), + checks: dashboardChecks(), + }); + return root; +} + +export async function createMultiStackSandbox(): Promise { + const root = await createTempRoot("multi"); + await writeFiles(root, [ + "apps/dashboard/refunds/page.tsx", + "apps/portal/payments/page.tsx", + ]); + await writePackage(join(root, ".ghost"), { + fingerprint: rootProductFingerprint(), + checks: rootChecks(), + }); + await writePackage(join(root, "apps/dashboard/.ghost"), { + fingerprint: dashboardFingerprint(), + checks: dashboardChecks(), + }); + await writePackage(join(root, "apps/portal/.ghost"), { + fingerprint: portalFingerprint(), + checks: portalChecks(), + }); + return root; +} + +export async function removeSandbox(root: string): Promise { + await rm(root, { recursive: true, force: true }); +} + +export async function runCli( + argv: string[], + cwd: string, + options: { allowNoExit?: boolean } = {}, +) { + 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", ...argv]); + if (options.allowNoExit) setTimeout(finish, 500); + 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 }; +} + +export async function readPrompt(root: string, outDir: string): Promise { + return readFile(join(root, outDir, "prompt.md"), "utf-8"); +} + +export function diffFor(...paths: string[]): string { + return paths + .map( + (path) => `diff --git a/${path} b/${path} +--- a/${path} ++++ b/${path} +@@ -0,0 +1,1 @@ ++const changed = true; +`, + ) + .join("\n"); +} + +async function createTempRoot(label: string): Promise { + const root = join( + tmpdir(), + `ghost-context-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(root, { recursive: true }); + return root; +} + +async function writeFiles(root: string, paths: string[]): Promise { + await Promise.all( + paths.map(async (path) => { + const full = join(root, path); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, `// ${path}\n`, "utf-8"); + }), + ); +} + +async function writePackage( + pkg: string, + options: { fingerprint: string; checks?: string }, +): Promise { + const fingerprintDir = join(pkg, "fingerprint"); + await mkdir(join(fingerprintDir, "enforcement"), { recursive: true }); + await writeFile( + join(fingerprintDir, "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\n", + "utf-8", + ); + await writeFile(join(fingerprintDir, "prose.yml"), proseLayer(options.fingerprint)); + await writeFile( + join(fingerprintDir, "inventory.yml"), + inventoryLayer(options.fingerprint), + ); + await writeFile( + join(fingerprintDir, "composition.yml"), + compositionLayer(options.fingerprint), + ); + if (options.checks) { + await writeFile( + join(fingerprintDir, "enforcement", "checks.yml"), + options.checks, + "utf-8", + ); + } +} + +async function writeCache(root: string, state: CacheState): Promise { + if (state === "missing") return; + const cacheDir = join(root, ".ghost", "fingerprint", "sources", "cache"); + await mkdir(cacheDir, { recursive: true }); + await writeFile( + join(cacheDir, "inventory.json"), + state === "malformed" + ? "{nope" + : JSON.stringify( + { + platform_hints: ["web"], + build_system_hints: ["vite"], + package_manifests: ["package.json"], + candidate_config_files: ["apps/refunds/theme.ts"], + }, + null, + 2, + ), + "utf-8", + ); +} + +function proseLayer(raw: string): string { + return raw.split("# inventory")[0].replace("# prose\n", ""); +} + +function inventoryLayer(raw: string): string { + return raw.split("# inventory\n")[1].split("# composition\n")[0]; +} + +function compositionLayer(raw: string): string { + return raw.split("# composition\n")[1]; +} + +function singleSurfaceFingerprint(reorderUnrelated: boolean): string { + const unrelated = ` - id: onboarding-welcome + path: apps/onboarding/page.tsx + title: Onboarding welcome + surface_type: setup + scope: onboarding + why: Unrelated setup flow. +`; + const refundExemplars = ` - id: refund-settings-primary + path: apps/refunds/settings/primary.tsx + title: Refund settings primary + surface_type: settings + scope: refund-settings + why: Shows consequence copy beside refund controls. + refs: [prose.principle:refund-trust, composition.pattern:refund-disclosure] + - id: refund-settings-secondary + path: apps/refunds/settings/secondary.tsx + title: Refund settings secondary + surface_type: settings + scope: refund-settings + why: Shows recovery affordances. + refs: [prose.principle:refund-trust] + - id: refund-settings-tertiary + path: apps/refunds/settings/tertiary.tsx + title: Refund settings tertiary + surface_type: settings + scope: refund-settings + why: Shows compact review hierarchy. + refs: [composition.pattern:refund-disclosure] + - id: refund-settings-quaternary + path: apps/refunds/settings/quaternary.tsx + title: Refund settings quaternary + surface_type: settings + scope: refund-settings + why: Extra exemplar used to prove omission caps. +`; + return `# prose +summary: + product: Sandbox Pay + goals: [make refund settings trustworthy] +situations: + - id: refund-review + title: Refund review + user_intent: Understand refund impact before saving. + product_obligation: Keep consequences visible before action. + surface_type: settings + principles: [prose.principle:refund-trust] + experience_contracts: [prose.experience_contract:refund-reversibility] + patterns: [composition.pattern:refund-disclosure] +principles: + - id: refund-trust + principle: Refund controls must make consequence and recovery visible. + applies_to: + scopes: [refund-settings] + check_refs: [check:no-hardcoded-ui-color] +experience_contracts: + - id: refund-reversibility + contract: Destructive settings changes expose a recovery path. + applies_to: + surface_types: [settings] +# inventory +topology: + scopes: + - id: refund-settings + paths: [apps/refunds/settings] + surface_types: [settings] + - id: onboarding + paths: [apps/onboarding] + surface_types: [setup] + surface_types: [settings, setup] +building_blocks: + components: [RefundSettingsForm] + tokens: [color.intent.warning] +exemplars: +${reorderUnrelated ? unrelated : ""}${refundExemplars}${reorderUnrelated ? "" : unrelated}sources: [] +# composition +patterns: + - id: refund-disclosure + kind: flow + pattern: Reveal refund consequences before the save action. + applies_to: + scopes: [refund-settings] + guidance: [Keep recovery affordances next to confirmation copy.] + check_refs: [check:no-hardcoded-ui-color] +`; +} + +function refundChecks(): string { + return `schema: ghost.checks/v1 +id: sandbox-pay +checks: + - id: no-hardcoded-ui-color + title: Use semantic UI color + status: active + severity: serious + derivation: + prose: [prose.principle:refund-trust] + composition: [composition.pattern:refund-disclosure] + inventory: [inventory.exemplar:refund-settings-primary] + applies_to: + scopes: [refund-settings] + paths: [apps/refunds/settings] + detector: + type: forbidden-regex + pattern: '#[0-9a-fA-F]{3,8}' + evidence: + support: 0.9 + observed_count: 3 + examples: [apps/refunds/settings/primary.tsx] + repair: Use semantic warning tokens. + - id: proposed-density + title: Proposed density check + status: proposed + severity: nit + detector: + type: required-regex + pattern: Density + - id: disabled-motion + title: Disabled motion check + status: disabled + severity: nit + detector: + type: required-regex + pattern: motion +`; +} + +function rootProductFingerprint(): string { + return `# prose +summary: + product: Sandbox Suite + goals: [keep administrative workflows calm] +situations: [] +principles: + - id: suite-restraint + principle: Shared administrative surfaces stay calm and reversible. +experience_contracts: [] +# inventory +topology: + scopes: + - id: suite-root + paths: [apps] + surface_types: [admin] + surface_types: [admin] +building_blocks: {} +exemplars: [] +sources: [] +# composition +patterns: [] +`; +} + +function rootChecks(): string { + return `schema: ghost.checks/v1 +id: suite +checks: [] +`; +} + +function dashboardFingerprint(): string { + return `# prose +summary: + product: Dashboard +situations: [] +principles: + - id: dashboard-refund-focus + principle: Dashboard refund work keeps review state close to action state. + applies_to: + scopes: [dashboard-refunds] +experience_contracts: [] +# inventory +topology: + scopes: + - id: dashboard-refunds + paths: [apps/dashboard/refunds] + surface_types: [admin] + surface_types: [admin] +building_blocks: {} +exemplars: + - id: dashboard-refunds-page + path: apps/dashboard/refunds/page.tsx + scope: dashboard-refunds + surface_type: admin + why: Shows local dashboard refund hierarchy. + refs: [prose.principle:dashboard-refund-focus] +sources: [] +# composition +patterns: + - id: dashboard-review-column + kind: layout + pattern: Keep refund review content in a stable right column. + applies_to: + scopes: [dashboard-refunds] +`; +} + +function dashboardChecks(): string { + return `schema: ghost.checks/v1 +id: dashboard +checks: [] +`; +} + +function portalFingerprint(): string { + return `# prose +summary: + product: Portal +situations: [] +principles: + - id: portal-payment-clarity + principle: Portal payment edits name settlement impact before action. + applies_to: + scopes: [portal-payments] +experience_contracts: [] +# inventory +topology: + scopes: + - id: portal-payments + paths: [apps/portal/payments] + surface_types: [admin] + surface_types: [admin] +building_blocks: {} +exemplars: + - id: portal-payments-page + path: apps/portal/payments/page.tsx + scope: portal-payments + surface_type: admin + why: Shows settlement-impact copy. + refs: [prose.principle:portal-payment-clarity] +sources: [] +# composition +patterns: [] +`; +} + +function portalChecks(): string { + return `schema: ghost.checks/v1 +id: portal +checks: [] +`; +} diff --git a/packages/ghost/test/public-exports.test.ts b/packages/ghost/test/public-exports.test.ts index 134ec8fd..7e878292 100644 --- a/packages/ghost/test/public-exports.test.ts +++ b/packages/ghost/test/public-exports.test.ts @@ -10,15 +10,33 @@ const hasBuiltExports = existsSync( describe.runIf(hasBuiltExports)("built public exports", () => { it("exposes fingerprint-first package subpaths", async () => { - const [fingerprint, govern, compareApi] = await Promise.all([ + const [fingerprint, scan, relay, govern, compareApi] = await Promise.all([ import("@anarchitecture/ghost/fingerprint"), + import("@anarchitecture/ghost/scan"), + import("@anarchitecture/ghost/relay"), import("@anarchitecture/ghost/govern"), import("@anarchitecture/ghost/compare"), ]); - expect(fingerprint.initFingerprintPackage).toBeTypeOf("function"); - expect(fingerprint.lintFingerprintPackage).toBeTypeOf("function"); - expect(fingerprint.scanStatus).toBeTypeOf("function"); + const fingerprintApi = fingerprint as Record; + const scanApi = scan as Record; + + expect(fingerprintApi.initFingerprintPackage).toBeTypeOf("function"); + expect(fingerprintApi.lintFingerprintPackage).toBeTypeOf("function"); + expect(fingerprintApi.verifyFingerprintPackage).toBeTypeOf("function"); + expect(fingerprintApi.loadFingerprint).toBeTypeOf("function"); + expect(fingerprintApi.writePackageContextBundle).toBeUndefined(); + expect(fingerprintApi.writeContextBundle).toBeUndefined(); + + expect(scanApi.scanStatus).toBeTypeOf("function"); + expect(scanApi.inventory).toBeTypeOf("function"); + expect(scanApi.loadFingerprintStackForPath).toBeTypeOf("function"); + expect(scanApi.initFingerprintPackage).toBeUndefined(); + expect(scanApi.lintFingerprintPackage).toBeUndefined(); + expect(scanApi.writePackageContextBundle).toBeUndefined(); + + expect(relay.gatherRelayContext).toBeTypeOf("function"); + expect(relay.formatRelayBrief).toBeTypeOf("function"); expect(govern.runGhostCheck).toBeTypeOf("function"); expect(govern.runGhostCheck).toBe(govern.runGhostDriftCheck); diff --git a/packages/ghost/test/relay.test.ts b/packages/ghost/test/relay.test.ts new file mode 100644 index 00000000..08d094e8 --- /dev/null +++ b/packages/ghost/test/relay.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { gatherRelayContext } from "../src/relay.js"; +import { + createSingleSurfaceSandbox, + removeSandbox, +} from "./fixtures/context-sandboxes/harness.js"; + +describe("relay", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => removeSandbox(root))); + }); + + it("gathers structured fingerprint context for a target", async () => { + const root = await track(createSingleSurfaceSandbox({ cache: "present" })); + + const result = await gatherRelayContext({ + cwd: root, + target: "apps/refunds/settings/page.tsx", + }); + + expect(result.schema).toBe("ghost.relay.gather/v1"); + expect(result.source.kind).toBe("stack"); + expect(result.targetPaths).toEqual(["apps/refunds/settings/page.tsx"]); + expect(result.entrypoint.match.status).toBe("path-match"); + expect(result.entrypoint.match.matchedScopes).toEqual(["refund-settings"]); + expect(result.brief).toContain("# Ghost Relay Brief"); + expect(result.brief).toContain("prose.principle:refund-trust"); + }); + + async function track(rootPromise: Promise): Promise { + const root = await rootPromise; + roots.push(root); + return root; + } +}); diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index 691c03c9..9fdee56e 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -15,10 +15,10 @@ const EXCEPTIONS = { justification: "Unified CLI command registry — review/check/compare plus drift stance verbs live together for one public bin", }, - "packages/ghost/src/scan-commands.ts": { - limit: 1130, + "packages/ghost/src/fingerprint-commands.ts": { + limit: 1135, justification: - "Scan and fingerprint package command registry — temporarily holds legacy markdown, fingerprint.yml package verbs, and adapter-neutral memory-dir routing until the command registry is split", + "Fingerprint package command registry — temporarily holds package lifecycle, legacy markdown, survey/cache, scan readiness, and adapter-neutral memory-dir routing until command groups are split further", }, "packages/ghost/src/scan/inventory.ts": { limit: 1120,