diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index fbc0bfc..ca83f9a 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -4,18 +4,29 @@ import { readFile, writeFile } from "node:fs/promises"; import type { DesignFingerprint } from "@ghost/core"; import { compareFingerprints, + computeTemporalComparison, formatCLIReport, formatComparison, formatComparisonJSON, formatFingerprint, formatFingerprintJSON, formatJSONReport, + formatTemporalComparison, + formatTemporalComparisonJSON, loadConfig, profile, profileRegistry, + readHistory, + readSyncManifest, scan, } from "@ghost/core"; import { defineCommand, runMain } from "citty"; +import { + ackCommand, + adoptCommand, + divergeCommand, + fleetCommand, +} from "./evolution-commands.js"; const scanCommand = defineCommand({ meta: { @@ -82,6 +93,12 @@ const profileCommand = defineCommand({ description: "Write fingerprint to file", alias: "o", }, + emit: { + type: "boolean", + description: + "Write .ghost-fingerprint.json to project root (publishable artifact)", + default: false, + }, format: { type: "string", description: "Output format: cli or json", @@ -110,7 +127,7 @@ const profileCommand = defineCommand({ configPath: args.config, requireDesignSystems: false, }); - fingerprint = await profile(config); + fingerprint = await profile(config, { emit: args.emit }); } const output = @@ -123,6 +140,10 @@ const profileCommand = defineCommand({ console.log(`Fingerprint written to ${args.output}`); } + if (args.emit) { + console.log("Published .ghost-fingerprint.json"); + } + process.stdout.write(`${output}\n`); process.exit(0); } catch (err) { @@ -150,6 +171,16 @@ const compareCommand = defineCommand({ description: "Path to target fingerprint JSON", required: true, }, + temporal: { + type: "boolean", + description: "Include temporal data: velocity, trajectory, ack status", + default: false, + }, + "history-dir": { + type: "string", + description: + "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", + }, format: { type: "string", description: "Output format: cli or json", @@ -164,7 +195,32 @@ const compareCommand = defineCommand({ const source: DesignFingerprint = JSON.parse(sourceData); const target: DesignFingerprint = JSON.parse(targetData); - const comparison = compareFingerprints(source, target); + const comparison = compareFingerprints(source, target, { + includeVectors: args.temporal, + }); + + if (args.temporal) { + const historyDir = args["history-dir"] ?? process.cwd(); + const [history, manifest] = await Promise.all([ + readHistory(historyDir), + readSyncManifest(historyDir), + ]); + + const temporal = computeTemporalComparison({ + comparison, + history, + manifest, + }); + + const output = + args.format === "json" + ? formatTemporalComparisonJSON(temporal) + : formatTemporalComparison(temporal); + + process.stdout.write(`${output}\n`); + process.exit(temporal.distance > 0.5 ? 1 : 0); + return; + } const output = args.format === "json" @@ -192,6 +248,10 @@ const main = defineCommand({ scan: scanCommand, profile: profileCommand, compare: compareCommand, + fleet: fleetCommand, + ack: ackCommand, + adopt: adoptCommand, + diverge: divergeCommand, }, }); diff --git a/packages/ghost-cli/src/evolution-commands.ts b/packages/ghost-cli/src/evolution-commands.ts new file mode 100644 index 0000000..d1c9fa9 --- /dev/null +++ b/packages/ghost-cli/src/evolution-commands.ts @@ -0,0 +1,308 @@ +import { readFile } from "node:fs/promises"; +import type { DesignFingerprint, DimensionStance } from "@ghost/core"; +import { + acknowledge, + compareFleet, + formatFleetComparison, + formatFleetComparisonJSON, + loadConfig, + profile, + resolveParent, +} from "@ghost/core"; +import { defineCommand } from "citty"; + +export const ackCommand = defineCommand({ + meta: { + name: "ack", + description: + "Acknowledge current drift — record intentional stance toward parent", + }, + args: { + config: { + type: "string", + description: "Path to ghost config file", + alias: "c", + }, + dimension: { + type: "string", + description: "Acknowledge a specific dimension only", + alias: "d", + }, + stance: { + type: "string", + description: "Stance: aligned, accepted, or diverging", + default: "accepted", + }, + reason: { + type: "string", + description: "Reason for this acknowledgment", + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + }, + async run({ args }) { + try { + const config = await loadConfig({ + configPath: args.config, + requireDesignSystems: false, + }); + + const parentRef = config.parent ?? { type: "default" as const }; + const parentFp = await resolveParent(parentRef); + const childFp = await profile(config); + + const { manifest, comparison } = await acknowledge({ + child: childFp, + parent: parentFp, + parentRef, + dimension: args.dimension, + stance: args.stance as DimensionStance, + reason: args.reason, + }); + + if (args.format === "json") { + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + } else { + console.log( + `Acknowledged drift from "${manifest.parentFingerprintId}"`, + ); + console.log(`Overall distance: ${comparison.distance.toFixed(3)}`); + console.log(); + for (const [key, ack] of Object.entries(manifest.dimensions)) { + const marker = + ack.stance === "aligned" + ? "=" + : ack.stance === "diverging" + ? "~" + : "*"; + const reasonSuffix = ack.reason ? ` (${ack.reason})` : ""; + console.log( + ` ${marker} ${key}: ${ack.distance.toFixed(3)} [${ack.stance}]${reasonSuffix}`, + ); + } + console.log(); + console.log("Written to .ghost-sync.json"); + } + + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); + +export const adoptCommand = defineCommand({ + meta: { + name: "adopt", + description: "Shift parent reference — adopt a new fingerprint as baseline", + }, + args: { + source: { + type: "positional", + description: + "Path to the fingerprint JSON to adopt as the new parent baseline", + required: true, + }, + config: { + type: "string", + description: "Path to ghost config file", + alias: "c", + }, + dimension: { + type: "string", + description: "Adopt only for a specific dimension", + alias: "d", + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + }, + async run({ args }) { + try { + const sourceData = await readFile(args.source, "utf-8"); + const newParent: DesignFingerprint = JSON.parse(sourceData); + + const config = await loadConfig({ + configPath: args.config, + requireDesignSystems: false, + }); + const childFp = await profile(config); + + const newParentRef = { type: "path" as const, path: args.source }; + + const { manifest, comparison } = await acknowledge({ + child: childFp, + parent: newParent, + parentRef: newParentRef, + dimension: args.dimension, + stance: "accepted", + }); + + if (args.format === "json") { + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + } else { + console.log(`Adopted "${newParent.id}" as parent baseline`); + console.log(`New distance: ${comparison.distance.toFixed(3)}`); + console.log(); + for (const [key, delta] of Object.entries(comparison.dimensions)) { + console.log(` ${key}: ${delta.distance.toFixed(3)}`); + } + console.log(); + console.log("Updated .ghost-sync.json"); + } + + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); + +export const divergeCommand = defineCommand({ + meta: { + name: "diverge", + description: "Declare intentional divergence on a dimension", + }, + args: { + dimension: { + type: "positional", + description: "The dimension to mark as intentionally diverging", + required: true, + }, + config: { + type: "string", + description: "Path to ghost config file", + alias: "c", + }, + reason: { + type: "string", + description: "Why this dimension is intentionally diverging", + alias: "r", + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + }, + async run({ args }) { + try { + const config = await loadConfig({ + configPath: args.config, + requireDesignSystems: false, + }); + + const parentRef = config.parent ?? { type: "default" as const }; + const parentFp = await resolveParent(parentRef); + const childFp = await profile(config); + + const { manifest } = await acknowledge({ + child: childFp, + parent: parentFp, + parentRef, + dimension: args.dimension, + stance: "diverging", + reason: args.reason, + }); + + const ack = manifest.dimensions[args.dimension]; + + if (args.format === "json") { + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + } else { + console.log(`Marked "${args.dimension}" as intentionally diverging`); + if (ack) { + console.log(` Distance: ${ack.distance.toFixed(3)}`); + } + if (args.reason) { + console.log(` Reason: ${args.reason}`); + } + console.log(); + console.log("Updated .ghost-sync.json"); + } + + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); + +export const fleetCommand = defineCommand({ + meta: { + name: "fleet", + description: "Compare N fingerprints for an ecosystem-level view", + }, + args: { + fingerprints: { + type: "positional", + description: "Paths to fingerprint JSON files (2 or more)", + required: true, + }, + cluster: { + type: "boolean", + description: "Include cluster analysis", + default: false, + }, + format: { + type: "string", + description: "Output format: cli or json", + default: "cli", + }, + }, + async run({ args }) { + try { + // citty gives us the first positional arg; remaining are in process.argv + // Parse all positional args after "fleet" + const fleetIdx = process.argv.indexOf("fleet"); + const paths: string[] = []; + for (let i = fleetIdx + 1; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg.startsWith("-")) break; + paths.push(arg); + } + + if (paths.length < 2) { + console.error("Error: fleet requires at least 2 fingerprint paths"); + process.exit(2); + } + + const members = await Promise.all( + paths.map(async (p) => { + const data = await readFile(p, "utf-8"); + const fingerprint: DesignFingerprint = JSON.parse(data); + return { id: fingerprint.id, fingerprint }; + }), + ); + + const fleet = compareFleet(members, { cluster: args.cluster }); + + const output = + args.format === "json" + ? formatFleetComparisonJSON(fleet) + : formatFleetComparison(fleet); + + process.stdout.write(`${output}\n`); + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); diff --git a/packages/ghost-core/src/config.ts b/packages/ghost-core/src/config.ts index 8d3270b..68b417c 100644 --- a/packages/ghost-core/src/config.ts +++ b/packages/ghost-core/src/config.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { createJiti } from "jiti"; +import { normalizeParentSource } from "./evolution/parent.js"; import type { GhostConfig } from "./types.js"; const CONFIG_FILES = ["ghost.config.ts", "ghost.config.js", "ghost.config.mjs"]; @@ -71,6 +72,9 @@ function validateDesignSystems(raw: GhostConfig): void { function mergeDefaults(raw: GhostConfig): GhostConfig { return { + parent: normalizeParentSource( + raw.parent as GhostConfig["parent"] | string | undefined, + ), designSystems: raw.designSystems, scan: { ...DEFAULT_CONFIG.scan, ...raw.scan }, rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, diff --git a/packages/ghost-core/src/evolution/emit.ts b/packages/ghost-core/src/evolution/emit.ts new file mode 100644 index 0000000..3f3990a --- /dev/null +++ b/packages/ghost-core/src/evolution/emit.ts @@ -0,0 +1,18 @@ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { DesignFingerprint } from "../types.js"; + +const FINGERPRINT_FILENAME = ".ghost-fingerprint.json"; + +/** + * Write a fingerprint as a publishable artifact to the project root. + * Other projects can reference this file as their parent. + */ +export async function emitFingerprint( + fingerprint: DesignFingerprint, + cwd: string = process.cwd(), +): Promise { + const target = resolve(cwd, FINGERPRINT_FILENAME); + await writeFile(target, JSON.stringify(fingerprint, null, 2), "utf-8"); + return target; +} diff --git a/packages/ghost-core/src/evolution/fleet.ts b/packages/ghost-core/src/evolution/fleet.ts new file mode 100644 index 0000000..95bcfe6 --- /dev/null +++ b/packages/ghost-core/src/evolution/fleet.ts @@ -0,0 +1,173 @@ +import { compareFingerprints } from "../fingerprint/compare.js"; +import { embeddingDistance } from "../fingerprint/embedding.js"; +import type { + FleetCluster, + FleetComparison, + FleetMember, + FleetPair, +} from "../types.js"; + +/** + * Compare N fingerprints for an ecosystem-level view. + * Computes pairwise distances, centroid, spread, and optional clusters. + */ +export function compareFleet( + members: FleetMember[], + options?: { cluster?: boolean }, +): FleetComparison { + const pairwise = computePairwise(members); + const centroid = computeCentroid(members); + const spread = computeSpread(members, centroid); + + const result: FleetComparison = { + members, + pairwise, + centroid, + spread, + }; + + if (options?.cluster && members.length >= 3) { + result.clusters = clusterMembers(members); + } + + return result; +} + +/** + * Compute pairwise distances between all fleet members. + */ +function computePairwise(members: FleetMember[]): FleetPair[] { + const pairs: FleetPair[] = []; + + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + const a = members[i]; + const b = members[j]; + const comparison = compareFingerprints(a.fingerprint, b.fingerprint); + + const dimensions: Record = {}; + for (const [key, delta] of Object.entries(comparison.dimensions)) { + dimensions[key] = delta.distance; + } + + pairs.push({ + a: a.id, + b: b.id, + distance: comparison.distance, + dimensions, + }); + } + } + + return pairs.sort((a, b) => a.distance - b.distance); +} + +/** + * Compute the centroid (average embedding) of all fleet members. + */ +function computeCentroid(members: FleetMember[]): number[] { + if (members.length === 0) return []; + + const dim = members[0].fingerprint.embedding.length; + const centroid = new Array(dim).fill(0); + + for (const member of members) { + for (let i = 0; i < dim; i++) { + centroid[i] += member.fingerprint.embedding[i] ?? 0; + } + } + + for (let i = 0; i < dim; i++) { + centroid[i] /= members.length; + } + + return centroid; +} + +/** + * Compute the spread (average embedding distance from centroid). + */ +function computeSpread(members: FleetMember[], centroid: number[]): number { + if (members.length === 0) return 0; + + let totalDistance = 0; + for (const member of members) { + totalDistance += embeddingDistance(member.fingerprint.embedding, centroid); + } + + return totalDistance / members.length; +} + +/** + * Basic k-means-style clustering (k=2 for now). + * Splits the fleet into two groups by finding the two most distant members + * and assigning the rest to the nearest one. + */ +function clusterMembers(members: FleetMember[]): FleetCluster[] { + if (members.length < 3) { + return [ + { + memberIds: members.map((m) => m.id), + centroid: computeCentroid(members), + }, + ]; + } + + // Find the two most distant members as initial centroids + let maxDist = -1; + let seedA = 0; + let seedB = 1; + + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + const dist = embeddingDistance( + members[i].fingerprint.embedding, + members[j].fingerprint.embedding, + ); + if (dist > maxDist) { + maxDist = dist; + seedA = i; + seedB = j; + } + } + } + + // Assign each member to the nearest seed + const groupA: FleetMember[] = []; + const groupB: FleetMember[] = []; + + for (let i = 0; i < members.length; i++) { + const distToA = embeddingDistance( + members[i].fingerprint.embedding, + members[seedA].fingerprint.embedding, + ); + const distToB = embeddingDistance( + members[i].fingerprint.embedding, + members[seedB].fingerprint.embedding, + ); + + if (distToA <= distToB) { + groupA.push(members[i]); + } else { + groupB.push(members[i]); + } + } + + const clusters: FleetCluster[] = []; + + if (groupA.length > 0) { + clusters.push({ + memberIds: groupA.map((m) => m.id), + centroid: computeCentroid(groupA), + }); + } + + if (groupB.length > 0) { + clusters.push({ + memberIds: groupB.map((m) => m.id), + centroid: computeCentroid(groupB), + }); + } + + return clusters; +} diff --git a/packages/ghost-core/src/evolution/history.ts b/packages/ghost-core/src/evolution/history.ts new file mode 100644 index 0000000..ef98e95 --- /dev/null +++ b/packages/ghost-core/src/evolution/history.ts @@ -0,0 +1,55 @@ +import { existsSync } from "node:fs"; +import { appendFile, mkdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { FingerprintHistoryEntry } from "../types.js"; + +const GHOST_DIR = ".ghost"; +const HISTORY_FILE = "history.jsonl"; + +function historyPath(cwd: string): string { + return resolve(cwd, GHOST_DIR, HISTORY_FILE); +} + +/** + * Append a fingerprint history entry to .ghost/history.jsonl. + * Creates the .ghost directory if it doesn't exist. + */ +export async function appendHistory( + entry: FingerprintHistoryEntry, + cwd: string = process.cwd(), +): Promise { + const dir = resolve(cwd, GHOST_DIR); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + const line = JSON.stringify(entry); + await appendFile(historyPath(cwd), `${line}\n`, "utf-8"); +} + +/** + * Read all history entries from .ghost/history.jsonl. + * Returns an empty array if no history exists. + */ +export async function readHistory( + cwd: string = process.cwd(), +): Promise { + const path = historyPath(cwd); + if (!existsSync(path)) return []; + + const content = await readFile(path, "utf-8"); + return content + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as FingerprintHistoryEntry); +} + +/** + * Read the most recent N history entries. + */ +export async function readRecentHistory( + count: number, + cwd: string = process.cwd(), +): Promise { + const all = await readHistory(cwd); + return all.slice(-count); +} diff --git a/packages/ghost-core/src/evolution/index.ts b/packages/ghost-core/src/evolution/index.ts new file mode 100644 index 0000000..d775c94 --- /dev/null +++ b/packages/ghost-core/src/evolution/index.ts @@ -0,0 +1,12 @@ +export { emitFingerprint } from "./emit.js"; +export { compareFleet } from "./fleet.js"; +export { appendHistory, readHistory, readRecentHistory } from "./history.js"; +export { normalizeParentSource, resolveParent } from "./parent.js"; +export { + acknowledge, + checkBounds, + readSyncManifest, + writeSyncManifest, +} from "./sync.js"; +export { computeTemporalComparison } from "./temporal.js"; +export { computeDriftVectors } from "./vector.js"; diff --git a/packages/ghost-core/src/evolution/parent.ts b/packages/ghost-core/src/evolution/parent.ts new file mode 100644 index 0000000..21d585d --- /dev/null +++ b/packages/ghost-core/src/evolution/parent.ts @@ -0,0 +1,82 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { DesignFingerprint, ParentSource } from "../types.js"; + +/** + * Resolve a ParentSource to a DesignFingerprint. + * + * - "default": looks for .ghost-fingerprint.json in cwd (ghostui implied) + * - "path": reads a local .ghost-fingerprint.json or fingerprint JSON file + * - "url": fetches a remote fingerprint JSON + * - "package": resolves node_modules//.ghost-fingerprint.json + */ +export async function resolveParent( + source: ParentSource, + cwd: string = process.cwd(), +): Promise { + switch (source.type) { + case "default": + throw new Error( + "No parent declared. Set `parent` in ghost.config.ts or use --parent.", + ); + + case "path": { + const resolved = resolve(cwd, source.path); + // If it points to a directory, look for .ghost-fingerprint.json inside it + const target = resolved.endsWith(".json") + ? resolved + : resolve(resolved, ".ghost-fingerprint.json"); + return readFingerprintFile(target); + } + + case "url": { + const response = await fetch(source.url); + if (!response.ok) { + throw new Error( + `Failed to fetch parent fingerprint from ${source.url}: ${response.status}`, + ); + } + return (await response.json()) as DesignFingerprint; + } + + case "package": { + // Resolve from node_modules + const target = resolve( + cwd, + "node_modules", + source.name, + ".ghost-fingerprint.json", + ); + return readFingerprintFile(target); + } + } +} + +async function readFingerprintFile(path: string): Promise { + try { + const data = await readFile(path, "utf-8"); + return JSON.parse(data) as DesignFingerprint; + } catch (err) { + throw new Error( + `Could not read fingerprint at ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Normalize a config parent value to a ParentSource. + * Accepts the discriminated union directly, or a string shorthand: + * - starts with http → url + * - otherwise → path + */ +export function normalizeParentSource( + value: ParentSource | string | undefined, +): ParentSource { + if (!value) return { type: "default" }; + if (typeof value === "string") { + return value.startsWith("http") + ? { type: "url", url: value } + : { type: "path", path: value }; + } + return value; +} diff --git a/packages/ghost-core/src/evolution/sync.ts b/packages/ghost-core/src/evolution/sync.ts new file mode 100644 index 0000000..a802571 --- /dev/null +++ b/packages/ghost-core/src/evolution/sync.ts @@ -0,0 +1,122 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { compareFingerprints } from "../fingerprint/compare.js"; +import type { + DesignFingerprint, + DimensionAck, + DimensionStance, + FingerprintComparison, + ParentSource, + SyncManifest, +} from "../types.js"; + +const SYNC_FILENAME = ".ghost-sync.json"; + +function syncPath(cwd: string): string { + return resolve(cwd, SYNC_FILENAME); +} + +/** + * Read the sync manifest from .ghost-sync.json. + * Returns null if no manifest exists. + */ +export async function readSyncManifest( + cwd: string = process.cwd(), +): Promise { + const path = syncPath(cwd); + if (!existsSync(path)) return null; + const data = await readFile(path, "utf-8"); + return JSON.parse(data) as SyncManifest; +} + +/** + * Write the sync manifest to .ghost-sync.json. + */ +export async function writeSyncManifest( + manifest: SyncManifest, + cwd: string = process.cwd(), +): Promise { + const path = syncPath(cwd); + await writeFile(path, JSON.stringify(manifest, null, 2), "utf-8"); + return path; +} + +/** + * Acknowledge the current drift state. + * Compares child to parent, records per-dimension distances with stances. + * + * If dimension/stance are provided, only that dimension is updated — + * the rest are preserved from the existing manifest or set to "accepted". + */ +export async function acknowledge(opts: { + child: DesignFingerprint; + parent: DesignFingerprint; + parentRef: ParentSource; + dimension?: string; + stance?: DimensionStance; + reason?: string; + cwd?: string; +}): Promise<{ manifest: SyncManifest; comparison: FingerprintComparison }> { + const cwd = opts.cwd ?? process.cwd(); + const comparison = compareFingerprints(opts.parent, opts.child); + const now = new Date().toISOString(); + + // Load existing manifest to preserve previous acks + const existing = await readSyncManifest(cwd); + + const dimensions: Record = {}; + + for (const [key, delta] of Object.entries(comparison.dimensions)) { + if (opts.dimension && key !== opts.dimension) { + // Preserve existing ack for this dimension, or default to accepted + dimensions[key] = existing?.dimensions[key] ?? { + distance: delta.distance, + stance: "accepted", + ackedAt: now, + }; + } else { + dimensions[key] = { + distance: delta.distance, + stance: opts.stance ?? "accepted", + ackedAt: now, + reason: key === opts.dimension ? opts.reason : undefined, + }; + } + } + + const manifest: SyncManifest = { + parent: opts.parentRef, + ackedAt: now, + parentFingerprintId: opts.parent.id, + childFingerprintId: opts.child.id, + dimensions, + overallDistance: comparison.distance, + }; + + await writeSyncManifest(manifest, cwd); + + return { manifest, comparison }; +} + +/** + * Check whether the current drift exceeds the acknowledged bounds. + * Returns dimensions that have drifted beyond what was acked. + */ +export function checkBounds( + manifest: SyncManifest, + current: FingerprintComparison, + tolerance: number = 0.05, +): { exceeded: boolean; dimensions: string[] } { + const exceeded: string[] = []; + + for (const [key, ack] of Object.entries(manifest.dimensions)) { + if (ack.stance === "diverging") continue; // intentionally diverging, no bound + const currentDistance = current.dimensions[key]?.distance ?? 0; + if (currentDistance > ack.distance + tolerance) { + exceeded.push(key); + } + } + + return { exceeded: exceeded.length > 0, dimensions: exceeded }; +} diff --git a/packages/ghost-core/src/evolution/temporal.ts b/packages/ghost-core/src/evolution/temporal.ts new file mode 100644 index 0000000..8f6ed19 --- /dev/null +++ b/packages/ghost-core/src/evolution/temporal.ts @@ -0,0 +1,131 @@ +import { compareFingerprints } from "../fingerprint/compare.js"; +import type { + DriftVelocity, + FingerprintComparison, + FingerprintHistoryEntry, + SyncManifest, + TemporalComparison, +} from "../types.js"; +import { checkBounds } from "./sync.js"; +import { computeDriftVectors } from "./vector.js"; + +/** + * Enrich a fingerprint comparison with temporal data: + * velocity, trajectory, ack status, and drift vectors. + */ +export function computeTemporalComparison(opts: { + comparison: FingerprintComparison; + history: FingerprintHistoryEntry[]; + manifest: SyncManifest | null; +}): TemporalComparison { + const { comparison, history, manifest } = opts; + + const vectors = computeDriftVectors(comparison.source, comparison.target); + const velocity = computeVelocity(comparison, history); + const trajectory = classifyTrajectory(velocity); + + let daysSinceAck: number | null = null; + let exceedsAckedBounds = false; + let exceedingDimensions: string[] = []; + + if (manifest) { + const ackDate = new Date(manifest.ackedAt); + daysSinceAck = Math.floor( + (Date.now() - ackDate.getTime()) / (1000 * 60 * 60 * 24), + ); + + const bounds = checkBounds(manifest, comparison); + exceedsAckedBounds = bounds.exceeded; + exceedingDimensions = bounds.dimensions; + } + + return { + ...comparison, + vectors, + velocity, + daysSinceAck, + exceedsAckedBounds, + exceedingDimensions, + trajectory, + }; +} + +/** + * Compute drift velocity per dimension from history entries. + * Uses the oldest and most recent entries to calculate rate of change. + */ +function computeVelocity( + current: FingerprintComparison, + history: FingerprintHistoryEntry[], +): DriftVelocity[] { + if (history.length < 2) { + // Not enough history to compute velocity — return stable for all dimensions + return Object.keys(current.dimensions).map((dimension) => ({ + dimension, + rate: 0, + direction: "stable" as const, + windowDays: 0, + })); + } + + const oldest = history[0]; + const newest = history[history.length - 1]; + + const oldestDate = new Date(oldest.fingerprint.timestamp); + const newestDate = new Date(newest.fingerprint.timestamp); + const windowDays = Math.max( + (newestDate.getTime() - oldestDate.getTime()) / (1000 * 60 * 60 * 24), + 1, + ); + + // Compare the oldest history entry's fingerprint against the current source + // to get a "then" comparison, and use the current comparison as "now" + const oldComparison = compareFingerprints(current.source, oldest.fingerprint); + + return Object.keys(current.dimensions).map((dimension) => { + const oldDistance = oldComparison.dimensions[dimension]?.distance ?? 0; + const newDistance = current.dimensions[dimension]?.distance ?? 0; + const delta = newDistance - oldDistance; + const rate = Math.abs(delta) / windowDays; + + let direction: "converging" | "diverging" | "stable"; + if (Math.abs(delta) < 0.01) { + direction = "stable"; + } else if (delta < 0) { + direction = "converging"; + } else { + direction = "diverging"; + } + + return { dimension, rate, direction, windowDays }; + }); +} + +/** + * Classify overall trajectory from per-dimension velocities. + */ +function classifyTrajectory( + velocity: DriftVelocity[], +): "converging" | "diverging" | "stable" | "oscillating" { + if (velocity.length === 0) return "stable"; + + const converging = velocity.filter( + (v) => v.direction === "converging", + ).length; + const diverging = velocity.filter((v) => v.direction === "diverging").length; + const stable = velocity.filter((v) => v.direction === "stable").length; + const total = velocity.length; + + // If most dimensions are stable, overall is stable + if (stable / total >= 0.6) return "stable"; + // If dimensions are split between converging and diverging, it's oscillating + if ( + converging > 0 && + diverging > 0 && + Math.abs(converging - diverging) <= 1 + ) { + return "oscillating"; + } + // Otherwise, majority wins + return converging > diverging ? "converging" : "diverging"; +} diff --git a/packages/ghost-core/src/evolution/vector.ts b/packages/ghost-core/src/evolution/vector.ts new file mode 100644 index 0000000..42be49a --- /dev/null +++ b/packages/ghost-core/src/evolution/vector.ts @@ -0,0 +1,44 @@ +import type { DesignFingerprint, DriftVector } from "../types.js"; + +/** + * Embedding dimension ranges per design dimension. + * Mirrors the layout in fingerprint/embedding.ts. + */ +const DIMENSION_RANGES: Record = { + palette: [0, 21], // dominant (0-11) + neutrals (12-17) + qualitative (18-20) + spacing: [21, 31], + typography: [31, 41], + surfaces: [41, 49], + architecture: [49, 64], +}; + +/** + * Compute per-dimension drift vectors from two fingerprints' embeddings. + * Each vector captures the direction and magnitude of change in embedding space + * for a specific design dimension. + */ +export function computeDriftVectors( + source: DesignFingerprint, + target: DesignFingerprint, +): DriftVector[] { + const vectors: DriftVector[] = []; + + for (const [dimension, [start, end]] of Object.entries(DIMENSION_RANGES)) { + const delta: number[] = []; + let sumSq = 0; + + for (let i = start; i < end; i++) { + const d = (target.embedding[i] ?? 0) - (source.embedding[i] ?? 0); + delta.push(d); + sumSq += d * d; + } + + vectors.push({ + dimension, + magnitude: Math.sqrt(sumSq), + embeddingDelta: delta, + }); + } + + return vectors; +} diff --git a/packages/ghost-core/src/fingerprint/compare.ts b/packages/ghost-core/src/fingerprint/compare.ts index 271ff3b..99ed511 100644 --- a/packages/ghost-core/src/fingerprint/compare.ts +++ b/packages/ghost-core/src/fingerprint/compare.ts @@ -1,9 +1,14 @@ +import { computeDriftVectors } from "../evolution/vector.js"; import type { DesignFingerprint, DimensionDelta, FingerprintComparison, } from "../types.js"; +export interface CompareOptions { + includeVectors?: boolean; +} + // Dimension weights — palette and spacing have higher visual impact const WEIGHTS: Record = { palette: 0.3, @@ -16,6 +21,7 @@ const WEIGHTS: Record = { export function compareFingerprints( source: DesignFingerprint, target: DesignFingerprint, + options?: CompareOptions, ): FingerprintComparison { const dimensions: Record = {}; @@ -33,7 +39,19 @@ export function compareFingerprints( const summary = buildSummary(dimensions, distance); - return { source, target, distance, dimensions, summary }; + const result: FingerprintComparison = { + source, + target, + distance, + dimensions, + summary, + }; + + if (options?.includeVectors) { + result.vectors = computeDriftVectors(source, target); + } + + return result; } function comparePalette( diff --git a/packages/ghost-core/src/index.ts b/packages/ghost-core/src/index.ts index 9213e47..fba1f2a 100644 --- a/packages/ghost-core/src/index.ts +++ b/packages/ghost-core/src/index.ts @@ -1,5 +1,21 @@ export { defineConfig, loadConfig } from "./config.js"; +export { + acknowledge, + appendHistory, + checkBounds, + compareFleet, + computeDriftVectors, + computeTemporalComparison, + emitFingerprint, + normalizeParentSource, + readHistory, + readRecentHistory, + readSyncManifest, + resolveParent, + writeSyncManifest, +} from "./evolution/index.js"; export { detectExtractors, extract } from "./extractors/index.js"; +export type { CompareOptions } from "./fingerprint/compare.js"; export { compareFingerprints, computeEmbedding, @@ -9,6 +25,7 @@ export { fingerprintFromRegistry, } from "./fingerprint/index.js"; export { createProvider } from "./llm/index.js"; +export type { ProfileOptions } from "./profile.js"; export { profile, profileRegistry } from "./profile.js"; export { formatReport as formatCLIReport } from "./reporters/cli.js"; export { @@ -17,7 +34,15 @@ export { formatFingerprint, formatFingerprintJSON, } from "./reporters/fingerprint.js"; +export { + formatFleetComparison, + formatFleetComparisonJSON, +} from "./reporters/fleet.js"; export { formatReport as formatJSONReport } from "./reporters/json.js"; +export { + formatTemporalComparison, + formatTemporalComparisonJSON, +} from "./reporters/temporal.js"; export { parseCSS } from "./resolvers/css.js"; export { resolveRegistry } from "./resolvers/registry.js"; export { scan } from "./scan.js"; @@ -28,18 +53,28 @@ export type { DesignFingerprint, DesignSystemConfig, DesignSystemReport, + DimensionAck, DimensionDelta, + DimensionStance, DriftReport, DriftSummary, + DriftVector, + DriftVelocity, EmbeddingConfig, ExtractedFile, ExtractedMaterial, Extractor, ExtractorOptions, FingerprintComparison, + FingerprintHistoryEntry, + FleetCluster, + FleetComparison, + FleetMember, + FleetPair, GhostConfig, LLMConfig, LLMProvider, + ParentSource, Registry, RegistryFile, RegistryItem, @@ -48,6 +83,8 @@ export type { ScanOptions, SemanticColor, StructureDrift, + SyncManifest, + TemporalComparison, TokenCategory, ValueDrift, VisualDrift, diff --git a/packages/ghost-core/src/profile.ts b/packages/ghost-core/src/profile.ts index 5affea6..1423196 100644 --- a/packages/ghost-core/src/profile.ts +++ b/packages/ghost-core/src/profile.ts @@ -1,3 +1,5 @@ +import { emitFingerprint } from "./evolution/emit.js"; +import { appendHistory } from "./evolution/history.js"; import { extract } from "./extractors/index.js"; import { computeSemanticEmbedding } from "./fingerprint/embed-api.js"; import { computeEmbedding } from "./fingerprint/embedding.js"; @@ -10,6 +12,11 @@ import type { GhostConfig, } from "./types.js"; +export interface ProfileOptions { + cwd?: string; + emit?: boolean; +} + /** * Compute the embedding for a fingerprint. * Uses semantic embedding API if configured, otherwise falls back to deterministic. @@ -35,8 +42,12 @@ async function embedFingerprint( */ export async function profile( config: GhostConfig, - cwd: string = process.cwd(), + cwdOrOptions: string | ProfileOptions = {}, ): Promise { + const opts = + typeof cwdOrOptions === "string" ? { cwd: cwdOrOptions } : cwdOrOptions; + const cwd = opts.cwd ?? process.cwd(); + const material = await extract(cwd, { ignore: config.ignore, extractorNames: config.extractors, @@ -44,32 +55,42 @@ export async function profile( styleEntry: config.designSystems?.[0]?.styleEntry, }); + let fingerprint: DesignFingerprint; + if (config.llm) { const provider = createProvider(config.llm); const projectId = config.designSystems?.[0]?.name ?? "project"; - const fingerprint = await provider.interpret(material, projectId); - + fingerprint = await provider.interpret(material, projectId); fingerprint.embedding = await embedFingerprint( fingerprint, config.embedding, ); - - return fingerprint; - } - - // Deterministic fallback — if we have a registry, use it - if (config.designSystems?.[0]?.registry) { + } else if (config.designSystems?.[0]?.registry) { const registry = await resolveRegistry(config.designSystems[0].registry); - const fingerprint = fingerprintFromRegistry(registry); + fingerprint = fingerprintFromRegistry(registry); fingerprint.embedding = await embedFingerprint( fingerprint, config.embedding, ); - return fingerprint; + } else { + fingerprint = await fingerprintFromExtraction(material, config.embedding); + } + + // Emit publishable fingerprint if requested + if (opts.emit) { + await emitFingerprint(fingerprint, cwd); } - // Deterministic extraction-only fingerprint (limited but functional) - return fingerprintFromExtraction(material, config.embedding); + // Always append to history + await appendHistory( + { + fingerprint, + parentRef: config.parent, + }, + cwd, + ); + + return fingerprint; } /** diff --git a/packages/ghost-core/src/reporters/fleet.ts b/packages/ghost-core/src/reporters/fleet.ts new file mode 100644 index 0000000..34130bb --- /dev/null +++ b/packages/ghost-core/src/reporters/fleet.ts @@ -0,0 +1,82 @@ +import type { FleetComparison } from "../types.js"; + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const CYAN = "\x1b[36m"; + +const useColor = + process.env.NO_COLOR === undefined && process.stdout.isTTY !== false; + +function c(code: string, text: string): string { + return useColor ? `${code}${text}${RESET}` : text; +} + +export function formatFleetComparison(fleet: FleetComparison): string { + const lines: string[] = []; + + lines.push(c(BOLD, `Fleet Overview: ${fleet.members.length} projects`)); + lines.push(""); + + // Spread + const spreadPct = (fleet.spread * 100).toFixed(1); + const spreadColor = + fleet.spread < 0.1 ? GREEN : fleet.spread < 0.3 ? YELLOW : RED; + lines.push(`Spread: ${c(spreadColor, `${spreadPct}%`)}`); + lines.push(""); + + // Members + lines.push(c(BOLD, "Members")); + for (const member of fleet.members) { + const parentStr = + member.distanceToParent != null + ? ` (${(member.distanceToParent * 100).toFixed(1)}% from parent)` + : ""; + lines.push(` ${member.id}${c(DIM, parentStr)}`); + } + lines.push(""); + + // Pairwise distances (sorted by distance) + lines.push(c(BOLD, "Pairwise Distances")); + for (const pair of fleet.pairwise) { + const pct = (pair.distance * 100).toFixed(1); + const color = + pair.distance < 0.1 ? GREEN : pair.distance < 0.3 ? YELLOW : RED; + lines.push(` ${pair.a} ${c(DIM, "<>")} ${pair.b} ${c(color, `${pct}%`)}`); + } + lines.push(""); + + // Clusters + if (fleet.clusters && fleet.clusters.length > 1) { + lines.push(c(CYAN, "Clusters")); + for (let i = 0; i < fleet.clusters.length; i++) { + const cluster = fleet.clusters[i]; + lines.push( + ` ${c(BOLD, `Cluster ${i + 1}:`)} ${cluster.memberIds.join(", ")}`, + ); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +export function formatFleetComparisonJSON(fleet: FleetComparison): string { + return JSON.stringify( + { + memberCount: fleet.members.length, + members: fleet.members.map((m) => ({ + id: m.id, + distanceToParent: m.distanceToParent, + })), + pairwise: fleet.pairwise, + spread: fleet.spread, + clusters: fleet.clusters, + }, + null, + 2, + ); +} diff --git a/packages/ghost-core/src/reporters/temporal.ts b/packages/ghost-core/src/reporters/temporal.ts new file mode 100644 index 0000000..f1b6f4c --- /dev/null +++ b/packages/ghost-core/src/reporters/temporal.ts @@ -0,0 +1,111 @@ +import type { TemporalComparison } from "../types.js"; + +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; +const YELLOW = "\x1b[33m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const CYAN = "\x1b[36m"; + +const useColor = + process.env.NO_COLOR === undefined && process.stdout.isTTY !== false; + +function c(code: string, text: string): string { + return useColor ? `${code}${text}${RESET}` : text; +} + +export function formatTemporalComparison(comp: TemporalComparison): string { + const lines: string[] = []; + + lines.push( + c(BOLD, `Temporal Comparison: ${comp.source.id} vs ${comp.target.id}`), + ); + lines.push(""); + + // Overall distance + trajectory + const distPct = (comp.distance * 100).toFixed(1); + const distColor = + comp.distance < 0.1 ? GREEN : comp.distance < 0.3 ? YELLOW : RED; + const trajColor = + comp.trajectory === "converging" + ? GREEN + : comp.trajectory === "diverging" + ? RED + : comp.trajectory === "oscillating" + ? YELLOW + : DIM; + + lines.push( + `Distance: ${c(distColor, `${distPct}%`)} Trajectory: ${c(trajColor, comp.trajectory)}`, + ); + lines.push(""); + + // Ack status + if (comp.daysSinceAck !== null) { + const ackColor = comp.daysSinceAck > 30 ? YELLOW : DIM; + lines.push(c(CYAN, "Acknowledgment")); + lines.push(` Last acked: ${c(ackColor, `${comp.daysSinceAck} days ago`)}`); + if (comp.exceedsAckedBounds) { + lines.push( + ` ${c(RED, "Exceeded bounds:")} ${comp.exceedingDimensions.join(", ")}`, + ); + } else { + lines.push(` ${c(GREEN, "Within acknowledged bounds")}`); + } + lines.push(""); + } + + // Per-dimension velocity + lines.push(c(BOLD, "Dimensions")); + for (const [key, delta] of Object.entries(comp.dimensions)) { + const pct = (delta.distance * 100).toFixed(1); + const color = + delta.distance < 0.1 ? GREEN : delta.distance < 0.3 ? YELLOW : RED; + + const vel = comp.velocity.find((v) => v.dimension === key); + let velStr = ""; + if (vel && vel.rate > 0) { + const arrow = + vel.direction === "converging" + ? c(GREEN, "\u2193") + : vel.direction === "diverging" + ? c(RED, "\u2191") + : c(DIM, "-"); + velStr = ` ${arrow} ${(vel.rate * 100).toFixed(2)}%/day`; + } + + lines.push( + ` ${key.padEnd(14)} ${c(color, `${pct}%`.padStart(6))}${velStr} ${c(DIM, delta.description)}`, + ); + } + lines.push(""); + + // Summary + if (comp.summary) { + lines.push(c(DIM, comp.summary)); + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +export function formatTemporalComparisonJSON(comp: TemporalComparison): string { + return JSON.stringify( + { + source: comp.source.id, + target: comp.target.id, + distance: comp.distance, + trajectory: comp.trajectory, + daysSinceAck: comp.daysSinceAck, + exceedsAckedBounds: comp.exceedsAckedBounds, + exceedingDimensions: comp.exceedingDimensions, + dimensions: comp.dimensions, + velocity: comp.velocity, + vectors: comp.vectors, + summary: comp.summary, + }, + null, + 2, + ); +} diff --git a/packages/ghost-core/src/scanners/values.ts b/packages/ghost-core/src/scanners/values.ts index 5561ee5..8eae02c 100644 --- a/packages/ghost-core/src/scanners/values.ts +++ b/packages/ghost-core/src/scanners/values.ts @@ -138,9 +138,9 @@ function detectHardcodedColors( // Skip comments if (trimmed.startsWith("/*") || trimmed.startsWith("//")) continue; - let match: RegExpExecArray | null; COLOR_REGEX.lastIndex = 0; - while ((match = COLOR_REGEX.exec(line)) !== null) { + let match: RegExpExecArray | null = COLOR_REGEX.exec(line); + while (match !== null) { const colorValue = match[0]; const tokenName = reverseMap.get(colorValue); const suggestion = tokenName ? `var(${tokenName})` : undefined; @@ -155,6 +155,7 @@ function detectHardcodedColors( line: i + 1, suggestion, }); + match = COLOR_REGEX.exec(line); } } diff --git a/packages/ghost-core/src/types.ts b/packages/ghost-core/src/types.ts index 3cc6b05..2f5a823 100644 --- a/packages/ghost-core/src/types.ts +++ b/packages/ghost-core/src/types.ts @@ -82,6 +82,7 @@ export interface VisualScanConfig { } export interface GhostConfig { + parent?: ParentSource; designSystems?: DesignSystemConfig[]; scan: ScanOptions; rules: Record; @@ -206,6 +207,45 @@ export interface LLMProvider { ) => Promise; } +// --- Parent / lineage types --- + +export type ParentSource = + | { type: "default" } + | { type: "url"; url: string } + | { type: "path"; path: string } + | { type: "package"; name: string }; + +// --- History types --- + +export interface FingerprintHistoryEntry { + fingerprint: DesignFingerprint; + parentRef?: ParentSource; + comparisonToParent?: { + distance: number; + dimensions: Record; + }; +} + +// --- Sync / acknowledgment types --- + +export type DimensionStance = "aligned" | "accepted" | "diverging"; + +export interface DimensionAck { + distance: number; + stance: DimensionStance; + ackedAt: string; + reason?: string; +} + +export interface SyncManifest { + parent: ParentSource; + ackedAt: string; + parentFingerprintId: string; + childFingerprintId: string; + dimensions: Record; + overallDistance: number; +} + // --- Comparison types --- export interface DimensionDelta { @@ -220,6 +260,59 @@ export interface FingerprintComparison { distance: number; dimensions: Record; summary: string; + vectors?: DriftVector[]; +} + +// --- Temporal / drift vector types --- + +export interface DriftVector { + dimension: string; + magnitude: number; + embeddingDelta: number[]; +} + +export interface DriftVelocity { + dimension: string; + rate: number; + direction: "converging" | "diverging" | "stable"; + windowDays: number; +} + +export interface TemporalComparison extends FingerprintComparison { + velocity: DriftVelocity[]; + daysSinceAck: number | null; + exceedsAckedBounds: boolean; + exceedingDimensions: string[]; + trajectory: "converging" | "diverging" | "stable" | "oscillating"; +} + +// --- Fleet types --- + +export interface FleetMember { + id: string; + fingerprint: DesignFingerprint; + parentRef?: ParentSource; + distanceToParent?: number; +} + +export interface FleetPair { + a: string; + b: string; + distance: number; + dimensions: Record; +} + +export interface FleetCluster { + memberIds: string[]; + centroid: number[]; +} + +export interface FleetComparison { + members: FleetMember[]; + pairwise: FleetPair[]; + centroid: number[]; + spread: number; + clusters?: FleetCluster[]; } // --- Drift report types ---