From 012140fec70b1a6f8c2a7f82e1ad29c7de37a5b6 Mon Sep 17 00:00:00 2001 From: Ryan Chang Date: Tue, 16 Jun 2026 13:12:56 -0700 Subject: [PATCH 1/2] Add Ghost drift loop contract Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019ed188-9353-773a-ad4d-39c1a5fe740e --- .changeset/ghost-drift-loop-contract.md | 5 + apps/docs/src/generated/cli-manifest.json | 70 ++- packages/ghost/src/cli.ts | 2 + packages/ghost/src/command-discovery.ts | 7 + packages/ghost/src/comparable-fingerprint.ts | 226 +++++++- packages/ghost/src/core/config.ts | 10 +- packages/ghost/src/drift-command.ts | 356 ++++++++++++ packages/ghost/src/evolution-commands.ts | 32 +- packages/ghost/src/index.ts | 1 + packages/ghost/src/scan/package-config.ts | 19 + packages/ghost/test/cli.test.ts | 537 ++++++++++++++++++- 11 files changed, 1248 insertions(+), 17 deletions(-) create mode 100644 .changeset/ghost-drift-loop-contract.md create mode 100644 packages/ghost/src/drift-command.ts diff --git a/.changeset/ghost-drift-loop-contract.md b/.changeset/ghost-drift-loop-contract.md new file mode 100644 index 00000000..51ba9054 --- /dev/null +++ b/.changeset/ghost-drift-loop-contract.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Add a Ghost-owned drift check command and explicit design-loop opt-in config. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index e7ee3ed4..d2fe9c9c 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-15T13:20:23.174Z", + "generatedAt": "2026-06-16T20:04:07.390Z", "tools": [ { "tool": "ghost", @@ -555,6 +555,74 @@ } ] }, + { + "tool": "ghost", + "name": "drift", + "rawName": "drift ", + "description": "Inspect continuous design-loop drift status or run the stance-ledger check.", + "group": "compare", + "defaultHelp": false, + "compactName": "drift check", + "summary": "Run the continuous design-loop drift check.", + "options": [ + { + "rawName": "--package ", + "name": "package", + "description": "Exact fingerprint package directory", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--config ", + "name": "config", + "description": "Path to ghost config file for tracked source", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--local ", + "name": "local", + "description": "Local fingerprint or bundle to check", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--tracked ", + "name": "tracked", + "description": "Tracked/reference fingerprint or bundle", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--sync ", + "name": "sync", + "description": "Sync manifest path (default: ./.ghost-sync.json)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--max-divergence-days ", + "name": "maxDivergenceDays", + "description": "Flag diverging dimensions older than this many days as uncovered", + "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": "relay", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 4dccdffc..742bb787 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -21,6 +21,7 @@ import { runGateCli, runGhostDriftCheck, } from "./core/index.js"; +import { registerDriftCommand } from "./drift-command.js"; import { registerAckCommand, registerDivergeCommand, @@ -153,6 +154,7 @@ export function buildCli(): ReturnType { registerAckCommand(cli); registerTrackCommand(cli); registerDivergeCommand(cli); + registerDriftCommand(cli); registerRelayCommand(cli); registerSkillCommand(cli); diff --git a/packages/ghost/src/command-discovery.ts b/packages/ghost/src/command-discovery.ts index 5e1cbdaa..9345760d 100644 --- a/packages/ghost/src/command-discovery.ts +++ b/packages/ghost/src/command-discovery.ts @@ -122,6 +122,13 @@ const COMMAND_DISCOVERY = [ compactName: "compare", summary: "Compare fingerprint packages.", }, + { + name: "drift", + group: "compare", + defaultHelp: false, + compactName: "drift check", + summary: "Run the continuous design-loop drift check.", + }, { name: "ack", group: "compare", diff --git a/packages/ghost/src/comparable-fingerprint.ts b/packages/ghost/src/comparable-fingerprint.ts index 4283d5c2..1099f16a 100644 --- a/packages/ghost/src/comparable-fingerprint.ts +++ b/packages/ghost/src/comparable-fingerprint.ts @@ -1,13 +1,23 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { computeEmbedding, + type DesignDecision, type Fingerprint, + type GhostFingerprintDocument, + type GhostFingerprintPackageManifest, type GhostPatternsDocument, type Survey, } from "#ghost-core"; -import { loadFingerprint, resolveFingerprintPackage } from "./fingerprint.js"; +import { + loadFingerprint, + loadFingerprintPackage, + resolveFingerprintPackage, +} from "./fingerprint.js"; + +const PACKAGE_DECISION_EMBEDDING_SIZE = 64; export async function loadComparableFingerprint( path: string, @@ -17,7 +27,19 @@ export async function loadComparableFingerprint( return (await loadFingerprint(target)).fingerprint; } - const paths = resolveFingerprintPackage(path, process.cwd()); + const paths = resolveFingerprintPackage( + normalizeFingerprintPackageInput(path), + process.cwd(), + ); + if (existsSync(paths.manifest)) { + const { manifest, fingerprint } = await loadFingerprintPackage(paths); + return synthesizeFingerprintFromPackage(paths.dir, manifest, fingerprint); + } + + if (target === paths.dir && existsSync(paths.fingerprint)) { + return (await loadFingerprint(paths.fingerprint)).fingerprint; + } + try { const [surveyRaw, patternsRaw] = await Promise.all([ readFile(paths.survey, "utf-8"), @@ -33,6 +55,204 @@ export async function loadComparableFingerprint( } } +function normalizeFingerprintPackageInput(path: string): string { + const normalized = path.replace(/\\/g, "/"); + return /(^|\/)fingerprint\/manifest\.ya?ml$/i.test(normalized) + ? dirname(dirname(normalized)) + : path; +} + +function synthesizeFingerprintFromPackage( + path: string, + manifest: GhostFingerprintPackageManifest, + document: GhostFingerprintDocument, +): Fingerprint { + const decisions: DesignDecision[] = [ + ...packageDigestDecisions(document), + { + dimension: "summary", + dimension_kind: "experience-summary", + decision: compactJoin([ + document.prose.summary.product, + ...(document.prose.summary.audience ?? []), + ...(document.prose.summary.goals ?? []), + ...(document.prose.summary.anti_goals ?? []), + ...(document.prose.summary.tradeoffs ?? []), + ...(document.prose.summary.tone ?? []), + ]), + evidence: [], + }, + ...document.prose.situations.map((situation) => ({ + dimension: situation.id, + dimension_kind: "experience-situation", + decision: compactJoin([ + situation.title, + situation.user_intent, + situation.product_obligation, + ]), + evidence: evidenceStrings(situation.evidence), + })), + ...document.prose.principles.map((principle) => ({ + dimension: principle.id, + dimension_kind: "experience-principle", + decision: principle.principle, + evidence: evidenceStrings(principle.evidence), + })), + ...document.prose.experience_contracts.map((contract) => ({ + dimension: contract.id, + dimension_kind: "experience-contract", + decision: contract.contract, + evidence: evidenceStrings(contract.evidence), + })), + ...document.composition.patterns.map((pattern) => ({ + dimension: pattern.id, + dimension_kind: `composition-${pattern.kind}`, + decision: pattern.pattern, + evidence: evidenceStrings(pattern.evidence), + })), + ...buildingBlockDecisions(document), + ...document.inventory.exemplars.map((exemplar) => ({ + dimension: exemplar.id, + dimension_kind: "inventory-exemplar", + decision: compactJoin([ + exemplar.title, + exemplar.surface_type, + exemplar.scope, + exemplar.note, + exemplar.why, + exemplar.path, + ]), + evidence: [exemplar.path], + })), + ].map((decision) => ({ + ...decision, + embedding: deterministicTextEmbedding( + `${decision.dimension} ${decision.dimension_kind ?? ""} ${decision.decision}`, + ), + })); + + const fingerprint: Fingerprint = { + id: manifest.id, + source: "extraction", + timestamp: new Date(0).toISOString(), + sources: [path], + observation: { + summary: document.prose.summary.product ?? manifest.id, + personality: document.prose.summary.tone ?? [], + resembles: document.inventory.building_blocks.libraries ?? [], + }, + decisions, + palette: { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "mixed", + contrast: "moderate", + }, + spacing: { + scale: [], + regularity: 0, + baseUnit: null, + }, + typography: { + families: [], + sizeRamp: [], + weightDistribution: {}, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [], + shadowComplexity: "deliberate-none", + borderUsage: "minimal", + }, + embedding: [], + }; + fingerprint.embedding = computeEmbedding(fingerprint); + return fingerprint; +} + +function compactJoin(values: Array): string { + const joined = values + .filter((value): value is string => Boolean(value)) + .join(" — "); + return joined || "No situation decision recorded."; +} + +function evidenceStrings( + evidence: GhostFingerprintDocument["prose"]["principles"][number]["evidence"], +): string[] { + return ( + evidence?.map((entry) => entry.locator ?? entry.path ?? entry.note ?? "") ?? + [] + ).filter(Boolean); +} + +function packageDigestDecisions( + document: GhostFingerprintDocument, +): DesignDecision[] { + const digest = stableHash( + JSON.stringify({ + prose: document.prose, + inventory: document.inventory, + composition: document.composition, + }), + ).toString(16); + return Array.from({ length: 16 }, (_, index) => { + const token = `pkgdigest-${index + 1}-${digest}`; + return { + dimension: token, + dimension_kind: "package-digest", + decision: `${token} ${token} ${token} ${token}`, + evidence: [], + }; + }); +} + +function buildingBlockDecisions( + document: GhostFingerprintDocument, +): DesignDecision[] { + const blocks = document.inventory.building_blocks; + return [ + ["tokens", blocks.tokens], + ["components", blocks.components], + ["libraries", blocks.libraries], + ["assets", blocks.assets], + ["routes", blocks.routes], + ["files", blocks.files], + ["notes", blocks.notes], + ].flatMap(([dimension, values]) => { + if (!Array.isArray(values) || values.length === 0) return []; + return [ + { + dimension: `building-blocks-${dimension}`, + dimension_kind: "inventory-building-blocks", + decision: values.join(", "), + evidence: [], + }, + ]; + }); +} + +function deterministicTextEmbedding(text: string): number[] { + const vector = new Array(PACKAGE_DECISION_EMBEDDING_SIZE).fill(0); + const tokens = text.toLowerCase().match(/[a-z0-9_-]+/g) ?? []; + for (const token of tokens) { + vector[stableHash(token) % PACKAGE_DECISION_EMBEDDING_SIZE] += 1; + } + const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)); + if (norm === 0) return vector; + return vector.map((value) => value / norm); +} + +function stableHash(value: string): number { + let hash = 2166136261; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + function synthesizeFingerprintFromBundle( path: string, survey: Survey, diff --git a/packages/ghost/src/core/config.ts b/packages/ghost/src/core/config.ts index 1294bdf0..f8b5239e 100644 --- a/packages/ghost/src/core/config.ts +++ b/packages/ghost/src/core/config.ts @@ -50,19 +50,23 @@ async function resolveConfigFile( } function normalizeTracked( + cwd: string, value: Target | string | undefined, ): Target | undefined { if (!value) return undefined; if (typeof value === "string") { + if (existsSync(resolve(cwd, value))) { + return { type: "path", value }; + } return resolveTarget(value); } return value; } -function mergeDefaults(raw: GhostConfig): GhostConfig { +function mergeDefaults(raw: GhostConfig, cwd: string): GhostConfig { return { targets: raw.targets, - tracks: normalizeTracked(raw.tracks as Target | string | undefined), + tracks: normalizeTracked(cwd, raw.tracks as Target | string | undefined), rules: { ...DEFAULT_CONFIG.rules, ...raw.rules }, ignore: raw.ignore ?? DEFAULT_CONFIG.ignore, embedding: raw.embedding, @@ -98,5 +102,5 @@ export async function loadConfig( const raw = (mod as { default?: GhostConfig }).default ?? (mod as GhostConfig); - return mergeDefaults(raw); + return mergeDefaults(raw, cwd); } diff --git a/packages/ghost/src/drift-command.ts b/packages/ghost/src/drift-command.ts new file mode 100644 index 00000000..19200310 --- /dev/null +++ b/packages/ghost/src/drift-command.ts @@ -0,0 +1,356 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { CAC } from "cac"; +import type { Fingerprint, SyncManifest, Target } from "#ghost-core"; +import { compareFingerprints } from "#ghost-core"; +import { loadComparableFingerprint } from "./comparable-fingerprint.js"; +import { + buildGateReport, + formatGateReportCLI, + type GateReport, + gateExitCode, + loadConfig, + resolveTarget, + resolveTrackedFingerprint, +} from "./core/index.js"; +import { + readOptionalPackageConfig, + resolveFingerprintPackage, +} from "./fingerprint.js"; + +const DEFAULT_SYNC_PATH = ".ghost-sync.json"; +const DRIFT_STATUS_SCHEMA = "ghost.drift.status/v1" as const; +const DRIFT_CHECK_SCHEMA = "ghost.drift.check/v1" as const; + +export type DesignLoopMode = "off" | "advisory" | "required"; + +export interface DriftStatusReport { + schema: typeof DRIFT_STATUS_SCHEMA; + designLoop: { + enabled: boolean; + mode: DesignLoopMode; + source: "config" | "default"; + }; + fingerprintDir: string; + configPath: string; +} + +export interface DriftCheckReport { + schema: typeof DRIFT_CHECK_SCHEMA; + designLoop: DriftStatusReport["designLoop"]; + trackedFingerprintId: string; + localFingerprintId: string; + overall: GateReport["overall"]; + dimensions: GateReport["dimensions"]; + gate: GateReport; +} + +interface DriftStatusOptions { + cwd?: string; + packageDir?: string; +} + +interface DriftCheckOptions extends DriftStatusOptions { + config?: string; + local?: string; + tracked?: string; + sync?: string; + maxDivergenceDays?: number | string; +} + +export async function getDriftStatus( + options: DriftStatusOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const paths = resolveFingerprintPackage(options.packageDir, cwd); + const config = await readOptionalPackageConfig(paths.config); + const designLoop = config?.design_loop ?? { enabled: false, mode: "off" }; + + return { + schema: DRIFT_STATUS_SCHEMA, + designLoop: { + enabled: designLoop.enabled, + mode: designLoop.mode, + source: config ? "config" : "default", + }, + fingerprintDir: paths.fingerprintDir, + configPath: paths.config, + }; +} + +export async function runDriftCheck( + options: DriftCheckOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const status = await getDriftStatus(options); + const manifest = await readSyncManifest(cwd, options.sync); + const local = await loadLocalFingerprint( + cwd, + options.local, + options.packageDir, + ); + const tracked = await loadTrackedFingerprint(cwd, { + explicitPath: options.tracked, + configPath: options.config, + ledgerTarget: manifest.tracks, + }); + validateManifestFingerprintIds(manifest, { local, tracked }); + const maxDivergenceDays = parseMaxDivergenceDays(options.maxDivergenceDays); + if (maxDivergenceDays === "invalid") { + throw new Error("--max-divergence-days must be a non-negative integer."); + } + + const comparison = compareFingerprints(tracked, local); + const gate = buildGateReport({ + comparison, + manifest, + maxDivergenceDays, + }); + + return { + schema: DRIFT_CHECK_SCHEMA, + designLoop: status.designLoop, + trackedFingerprintId: gate.trackedFingerprintId, + localFingerprintId: gate.localFingerprintId, + overall: gate.overall, + dimensions: gate.dimensions, + gate, + }; +} + +export function formatDriftStatusMarkdown(report: DriftStatusReport): string { + return [ + "# Ghost drift status", + "", + `Design loop: ${report.designLoop.enabled ? "enabled" : "disabled"} (${report.designLoop.mode})`, + `Source: ${report.designLoop.source}`, + `Fingerprint dir: ${report.fingerprintDir}`, + `Config: ${report.configPath}`, + "", + ].join("\n"); +} + +export function formatDriftCheckMarkdown(report: DriftCheckReport): string { + const lines = [ + "# Ghost drift check", + "", + `Design loop: ${report.designLoop.enabled ? "enabled" : "disabled"} (${report.designLoop.mode})`, + "", + formatGateReportCLI(report.gate).trimEnd(), + "", + ]; + return lines.join("\n"); +} + +export function registerDriftCommand(cli: CAC): void { + cli + .command( + "drift ", + "Inspect continuous design-loop drift status or run the stance-ledger check.", + ) + .option("--package ", "Exact fingerprint package directory") + .option("--config ", "Path to ghost config file for tracked source") + .option("--local ", "Local fingerprint or bundle to check") + .option("--tracked ", "Tracked/reference fingerprint or bundle") + .option("--sync ", "Sync manifest path (default: ./.ghost-sync.json)") + .option( + "--max-divergence-days ", + "Flag diverging dimensions older than this many days as uncovered", + ) + .option("--format ", "Output format: markdown or json", { + default: "markdown", + }) + .action(async (action: string, opts) => { + try { + if (opts.format !== "markdown" && opts.format !== "json") { + console.error("Error: --format must be 'markdown' or 'json'"); + process.exit(2); + return; + } + + if (action === "status") { + const report = await getDriftStatus({ + packageDir: + typeof opts.package === "string" ? opts.package : undefined, + }); + await writeAndFlush( + opts.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : formatDriftStatusMarkdown(report), + ); + process.exit(0); + return; + } + + if (action !== "check") { + console.error( + "Error: unknown drift action. Supported: status, check", + ); + process.exit(2); + return; + } + + const report = await runDriftCheck({ + packageDir: + typeof opts.package === "string" ? opts.package : undefined, + config: typeof opts.config === "string" ? opts.config : undefined, + local: typeof opts.local === "string" ? opts.local : undefined, + tracked: typeof opts.tracked === "string" ? opts.tracked : undefined, + sync: typeof opts.sync === "string" ? opts.sync : undefined, + maxDivergenceDays: opts.maxDivergenceDays, + }); + await writeAndFlush( + opts.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : formatDriftCheckMarkdown(report), + ); + process.exit(gateExitCode(report.gate)); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); +} + +async function readSyncManifest( + cwd: string, + syncPathOption: string | undefined, +): Promise { + const syncPath = resolve(cwd, syncPathOption ?? DEFAULT_SYNC_PATH); + if (!existsSync(syncPath)) { + throw new Error( + `sync manifest not found at ${syncPath}. Run \`ghost track\` or \`ghost ack\` first, or pass --sync .`, + ); + } + const manifest = JSON.parse( + await readFile(syncPath, "utf-8"), + ) as SyncManifest; + if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { + throw new Error( + `sync manifest at ${syncPath} is malformed (missing dimensions).`, + ); + } + return manifest; +} + +function validateManifestFingerprintIds( + manifest: SyncManifest, + fingerprints: { local: Fingerprint; tracked: Fingerprint }, +): void { + if ( + manifest.trackedFingerprintId && + manifest.trackedFingerprintId !== fingerprints.tracked.id + ) { + throw new Error( + `sync manifest tracks fingerprint "${manifest.trackedFingerprintId}" but resolved tracked fingerprint "${fingerprints.tracked.id}". Run \`ghost track\`/\`ghost ack\` for this tracked source, or pass the matching --sync manifest.`, + ); + } + if ( + manifest.localFingerprintId && + manifest.localFingerprintId !== fingerprints.local.id + ) { + throw new Error( + `sync manifest was recorded for local fingerprint "${manifest.localFingerprintId}" but resolved local fingerprint "${fingerprints.local.id}". Run \`ghost ack\` for the current local fingerprint, or pass the matching --sync manifest.`, + ); + } +} + +async function writeAndFlush(text: string): Promise { + await new Promise((resolve) => { + process.stdout.write(text, () => resolve()); + }); +} + +async function loadLocalFingerprint( + cwd: string, + localPath: string | undefined, + packageDir: string | undefined, +): Promise { + const source = localPath ?? packageDir ?? ".ghost"; + try { + return await loadComparableFingerprintFrom(cwd, source); + } catch (err) { + const defaultPackage = !localPath && !packageDir; + const manifestPath = resolve(cwd, source, "fingerprint", "manifest.yml"); + if (!defaultPackage || existsSync(manifestPath)) throw err; + return await loadComparableFingerprintFrom(cwd, ".ghost/fingerprint.md"); + } +} + +async function loadTrackedFingerprint( + cwd: string, + options: { + explicitPath?: string; + configPath?: string; + ledgerTarget?: Target | string; + }, +): Promise { + if (options.explicitPath) { + return loadComparableFingerprintFrom(cwd, options.explicitPath); + } + + const config = await loadConfig({ configPath: options.configPath, cwd }); + const target = config.tracks ?? normalizeTarget(options.ledgerTarget); + if (!target) { + throw new Error( + "No tracked fingerprint declared. Set `tracks` in ghost.config.ts/js, run `ghost track`, or pass --tracked .", + ); + } + return loadTargetFingerprint(cwd, target); +} + +function normalizeTarget( + value: Target | string | undefined, +): Target | undefined { + if (!value) return undefined; + return typeof value === "string" ? resolveTarget(value) : value; +} + +async function loadTargetFingerprint( + cwd: string, + target: Target, +): Promise { + if (target.type === "path") { + return loadComparableFingerprintFrom(cwd, target.value); + } + if (target.type === "npm") { + const packageGhostDir = resolve( + cwd, + "node_modules", + target.value, + ".ghost", + ); + if ( + existsSync(resolve(packageGhostDir, "fingerprint", "manifest.yml")) || + existsSync(resolve(packageGhostDir, "fingerprint.md")) + ) { + return loadComparableFingerprintFrom(cwd, packageGhostDir); + } + } + return resolveTrackedFingerprint(target, cwd); +} + +async function loadComparableFingerprintFrom( + cwd: string, + path: string, +): Promise { + const previousCwd = process.cwd(); + try { + process.chdir(cwd); + return await loadComparableFingerprint(path); + } finally { + process.chdir(previousCwd); + } +} + +function parseMaxDivergenceDays( + raw: number | string | undefined, +): number | undefined | "invalid" { + if (raw === undefined) return undefined; + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return "invalid"; + return n; +} diff --git a/packages/ghost/src/evolution-commands.ts b/packages/ghost/src/evolution-commands.ts index 0dc064b9..d017280e 100644 --- a/packages/ghost/src/evolution-commands.ts +++ b/packages/ghost/src/evolution-commands.ts @@ -1,16 +1,33 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import type { CAC } from "cac"; +import { loadComparableFingerprint } from "./comparable-fingerprint.js"; 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; - const { fingerprint } = await loadFingerprint(path); - return fingerprint; + return loadComparableFingerprint(".ghost"); +} + +async function loadTrackedComparableFingerprint( + target: Target, +): Promise>> { + if (target.type === "path") return loadComparableFingerprint(target.value); + if (target.type === "npm") { + const packageGhostDir = resolve("node_modules", target.value, ".ghost"); + if ( + existsSync(resolve(packageGhostDir, "fingerprint", "manifest.yml")) || + existsSync(resolve(packageGhostDir, "fingerprint.md")) + ) { + return loadComparableFingerprint(packageGhostDir); + } + return resolveTrackedFingerprint(target); + } + return resolveTrackedFingerprint(target); } export function registerAckCommand(cli: CAC): void { @@ -37,7 +54,7 @@ export function registerAckCommand(cli: CAC): void { process.exit(2); } - const trackedFingerprint = await resolveTrackedFingerprint( + const trackedFingerprint = await loadTrackedComparableFingerprint( config.tracks, ); const localFingerprint = await loadLocalFingerprint(); @@ -95,8 +112,7 @@ export function registerTrackCommand(cli: CAC): void { .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (source: string, opts) => { try { - const { fingerprint: trackedFingerprint } = - await loadFingerprint(source); + const trackedFingerprint = await loadComparableFingerprint(source); const localFingerprint = await loadLocalFingerprint(); const tracks: Target = { type: "path", value: source }; @@ -155,7 +171,7 @@ export function registerDivergeCommand(cli: CAC): void { process.exit(2); } - const trackedFingerprint = await resolveTrackedFingerprint( + const trackedFingerprint = await loadTrackedComparableFingerprint( config.tracks, ); const localFingerprint = await loadLocalFingerprint(); diff --git a/packages/ghost/src/index.ts b/packages/ghost/src/index.ts index d13620a5..cd6d6712 100644 --- a/packages/ghost/src/index.ts +++ b/packages/ghost/src/index.ts @@ -5,6 +5,7 @@ import { compare as compareFunction } from "./core/index.js"; export * as drift from "./core/index.js"; export * from "./core/index.js"; export const compare = Object.assign(compareFunction, compareApi); +export * as driftCommand from "./drift-command.js"; export * as fingerprint from "./fingerprint.js"; export * as core from "./ghost-core/index.js"; export * as govern from "./govern.js"; diff --git a/packages/ghost/src/scan/package-config.ts b/packages/ghost/src/scan/package-config.ts index e02deee4..e54ca1f2 100644 --- a/packages/ghost/src/scan/package-config.ts +++ b/packages/ghost/src/scan/package-config.ts @@ -34,15 +34,33 @@ const GhostPackageConfigLibrarySchema = z }) .strict(); +const GhostPackageDesignLoopModeSchema = z.enum([ + "off", + "advisory", + "required", +]); + +const GhostPackageDesignLoopSchema = z + .object({ + enabled: z.boolean().default(false), + mode: GhostPackageDesignLoopModeSchema.default("off"), + }) + .strict(); + export const GhostPackageConfigSchema = z .object({ schema: z.literal(GHOST_PACKAGE_CONFIG_SCHEMA), targets: z.array(GhostPackageConfigTargetSchema).default([]), libraries: z.array(GhostPackageConfigLibrarySchema).default([]), + design_loop: GhostPackageDesignLoopSchema.default({ + enabled: false, + mode: "off", + }), }) .strict(); export type GhostPackageConfig = z.infer; +export type GhostPackageDesignLoop = GhostPackageConfig["design_loop"]; export type GhostPackageConfigTarget = GhostPackageConfig["targets"][number]; export type GhostPackageConfigLibrary = GhostPackageConfig["libraries"][number]; @@ -114,6 +132,7 @@ export function templatePackageConfig(reference?: string): string { schema: GHOST_PACKAGE_CONFIG_SCHEMA, targets: [{ id: "product", platform: "web", roots: [] }], libraries, + design_loop: { enabled: false, mode: "off" }, }; return stringifyYaml(config, { lineWidth: 0 }); } diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index f53963b8..90a65d38 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { buildCli } from "../src/cli.js"; +import { runDriftCheck } from "../src/drift-command.js"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -62,14 +63,16 @@ async function runCli( const stdoutSpy = vi .spyOn(process.stdout, "write") - .mockImplementation((chunk: string | Uint8Array) => { + .mockImplementation((chunk: string | Uint8Array, callback?: unknown) => { stdout += chunk.toString(); + if (typeof callback === "function") callback(); return true; }); const stderrSpy = vi .spyOn(process.stderr, "write") - .mockImplementation((chunk: string | Uint8Array) => { + .mockImplementation((chunk: string | Uint8Array, callback?: unknown) => { stderr += chunk.toString(); + if (typeof callback === "function") callback(); return true; }); const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { @@ -174,6 +177,7 @@ describe("ghost CLI", () => { "relay [target]", "emit ", "compare [...fingerprints]", + "drift ", "ack", "track ", "diverge ", @@ -257,6 +261,28 @@ describe("ghost CLI", () => { } }); + it("track bootstraps the sync manifest for canonical fingerprint packages", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + + const track = await runCli(["track", "tracked/.ghost"], dir); + const check = await runCli(["drift", "check", "--format", "json"], dir); + + expect(track.code).toBe(0); + const manifest = JSON.parse( + await readFile(join(dir, ".ghost-sync.json"), "utf-8"), + ); + expect(manifest.tracks).toEqual({ type: "path", value: "tracked/.ghost" }); + expect(manifest.trackedFingerprintId).toBe("tracked"); + expect(manifest.localFingerprintId).toBe("local"); + expect(check.code).toBe(0); + expect(JSON.parse(check.stdout).overall.verdict).toBe("covered"); + }); + it("ack and diverge write stance updates from the unified cli", async () => { await mkdir(join(dir, ".ghost"), { recursive: true }); await writeFile( @@ -297,6 +323,484 @@ describe("ghost CLI", () => { expect(manifest.dimensions.typography.reason).toBe("editorial"); }); + it("ack reads canonical fingerprint packages from config tracks", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: './tracked/.ghost' };\n", + ); + + const ack = await runCli(["ack", "--format", "json"], dir); + + expect(ack.code).toBe(0); + const manifest = JSON.parse(ack.stdout); + expect(manifest.trackedFingerprintId).toBe("tracked"); + expect(manifest.localFingerprintId).toBe("local"); + }); + + it("ack preserves npm tracks that expose a .ghost fingerprint", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local"), + ); + await mkdir(join(dir, "node_modules", "@scope", "tracked", ".ghost"), { + recursive: true, + }); + await writeFile( + join( + dir, + "node_modules", + "@scope", + "tracked", + ".ghost", + "fingerprint.md", + ), + fingerprintWithId("tracked"), + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: 'npm:@scope/tracked' };\n", + ); + + const ack = await runCli(["ack", "--format", "json"], dir); + + expect(ack.code).toBe(0); + const manifest = JSON.parse(ack.stdout); + expect(manifest.tracks).toEqual({ type: "npm", value: "@scope/tracked" }); + expect(manifest.trackedFingerprintId).toBe("tracked"); + expect(manifest.localFingerprintId).toBe("local"); + }); + + it("drift check resolves npm tracks that expose canonical fingerprint packages", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "node_modules", "@scope", "tracked"), { + checks: false, + }); + await writeFile( + join( + dir, + "node_modules", + "@scope", + "tracked", + ".ghost", + "fingerprint", + "manifest.yml", + ), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: 'npm:@scope/tracked' };\n", + ); + + const ack = await runCli(["ack", "--format", "json"], dir); + const check = await runCli(["drift", "check", "--format", "json"], dir); + + expect(ack.code).toBe(0); + expect(check.code).toBe(0); + const report = JSON.parse(check.stdout); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + expect(report.overall.verdict).toBe("covered"); + }); + + it("reports design-loop status as disabled by default", async () => { + const result = await runCli(["drift", "status", "--format", "json"], dir); + + expect(result.code).toBe(0); + const status = JSON.parse(result.stdout); + expect(status.schema).toBe("ghost.drift.status/v1"); + expect(status.designLoop).toEqual({ + enabled: false, + mode: "off", + source: "default", + }); + }); + + it("runs the Ghost-owned drift check contract through the stance ledger", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local"), + ); + await writeFile( + join(dir, "tracked.fingerprint.md"), + fingerprintWithId("tracked"), + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: './tracked.fingerprint.md' };\n", + ); + await runCli(["track", "tracked.fingerprint.md"], dir); + + const result = await runCli(["drift", "check", "--format", "json"], dir); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.schema).toBe("ghost.drift.check/v1"); + expect(report.designLoop).toEqual({ + enabled: false, + mode: "off", + source: "default", + }); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + expect(report.overall.verdict).toBe("covered"); + expect(report.gate.schema).toBe("ghost.compare.gate/v1"); + }); + + it("drift check prefers legacy fingerprint.md over survey cache identity", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local"), + ); + await writeFile( + join(dir, ".ghost", "survey.json"), + JSON.stringify({ + schema: "ghost.survey/v1", + sources: [ + { id: "cache", target: ".", scanned_at: "2026-05-10T00:00:00Z" }, + ], + values: [], + tokens: [], + components: [], + ui_surfaces: [], + }), + ); + await writeFile( + join(dir, ".ghost", "patterns.yml"), + `schema: ghost.patterns/v1 +id: cache-local +surface_types: [] +composition_patterns: [] +`, + ); + await writeFile( + join(dir, "tracked.fingerprint.md"), + fingerprintWithId("tracked"), + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked.fingerprint.md" }); + + const result = await runCli( + [ + "drift", + "check", + "--tracked", + "tracked.fingerprint.md", + "--format", + "json", + ], + dir, + ); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.localFingerprintId).toBe("local"); + expect(report.trackedFingerprintId).toBe("tracked"); + }); + + it("drift check loads canonical fingerprint packages", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, ".ghost-sync.json"), + JSON.stringify({ + tracks: { type: "path", value: "tracked/.ghost" }, + ackedAt: "2026-06-16T00:00:00.000Z", + trackedFingerprintId: "tracked", + localFingerprintId: "local", + overallDistance: 0, + dimensions: { + spacing: { + distance: 0, + stance: "accepted", + ackedAt: "2026-06-16T00:00:00.000Z", + }, + palette: { + distance: 0, + stance: "accepted", + ackedAt: "2026-06-16T00:00:00.000Z", + }, + typography: { + distance: 0, + stance: "accepted", + ackedAt: "2026-06-16T00:00:00.000Z", + }, + surfaces: { + distance: 0, + stance: "accepted", + ackedAt: "2026-06-16T00:00:00.000Z", + }, + decisions: { + distance: 0, + stance: "accepted", + ackedAt: "2026-06-16T00:00:00.000Z", + }, + }, + }), + ); + + const result = await runCli( + [ + "drift", + "check", + "--package", + ".ghost", + "--tracked", + "tracked/.ghost", + "--format", + "json", + ], + dir, + ); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.schema).toBe("ghost.drift.check/v1"); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + }); + + it("drift check uses ledger tracks when no config is present", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli(["drift", "check", "--format", "json"], dir); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + }); + + it("drift check resolves config tracks that point at canonical packages", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: './tracked/.ghost' };\n", + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli(["drift", "check", "--format", "json"], dir); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + }); + + it("runDriftCheck resolves config tracks relative to the supplied cwd", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, "ghost.config.js"), + "export default { tracks: 'tracked/.ghost' };\n", + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const report = await runDriftCheck({ cwd: dir, config: "ghost.config.js" }); + + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + expect(report.overall.verdict).toBe("covered"); + }); + + it("drift check resolves tracked canonical package manifest paths", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli( + [ + "drift", + "check", + "--tracked", + "tracked/.ghost/fingerprint/manifest.yml", + "--format", + "json", + ], + dir, + ); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.trackedFingerprintId).toBe("tracked"); + expect(report.localFingerprintId).toBe("local"); + }); + + it("drift check rejects tracked fingerprints that do not match the ledger", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeCheckPackage(join(dir, "other"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, "other", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: other\n", + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli( + ["drift", "check", "--tracked", "other/.ghost", "--format", "json"], + dir, + ); + + expect(result.code).toBe(2); + expect(result.stderr).toContain( + 'sync manifest tracks fingerprint "tracked" but resolved tracked fingerprint "other"', + ); + }); + + it("drift check reports uncovered canonical package changes", async () => { + await writeCheckPackage(dir, { checks: false }); + await writeCheckPackage(join(dir, "tracked"), { checks: false }); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, ".ghost", "fingerprint", "prose.yml"), + `summary: + product: Cash iOS +situations: [] +principles: + - id: tokenized-ui-color + principle: Use celebratory spring motion and playful transitions throughout lending. +experience_contracts: [] +`, + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli(["drift", "check", "--format", "json"], dir); + + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.overall.verdict).toBe("uncovered"); + expect(report.dimensions.decisions.verdict).toBe("uncovered"); + }); + + it("drift check reports digest-only canonical package changes", async () => { + const manyPrinciples = Array.from( + { length: 30 }, + (_, index) => ` - id: principle-${index + 1} + principle: Preserve durable product surface rule ${index + 1}. +`, + ).join(""); + const fingerprintRaw = `schema: ghost.fingerprint/v1 +prose: + summary: + product: Cash iOS + situations: [] + principles: +${manyPrinciples} experience_contracts: [] +inventory: + topology: + surface_types: [native-feature] + building_blocks: {} + exemplars: [] + sources: [] +composition: + patterns: [] +`; + await mkdir(join(dir, ".ghost"), { recursive: true }); + await mkdir(join(dir, "tracked", ".ghost"), { recursive: true }); + await writeSplitFingerprintPackage(join(dir, ".ghost"), fingerprintRaw); + await writeSplitFingerprintPackage( + join(dir, "tracked", ".ghost"), + fingerprintRaw, + ); + await writeFile( + join(dir, "tracked", ".ghost", "fingerprint", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: tracked\n", + ); + await writeFile( + join(dir, ".ghost", "fingerprint", "inventory.yml"), + `topology: + surface_types: [native-feature, digest-only-change] +building_blocks: {} +exemplars: [] +sources: [] +`, + ); + await writeCoveredSyncManifest(dir, { tracked: "tracked/.ghost" }); + + const result = await runCli(["drift", "check", "--format", "json"], dir); + + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.overall.verdict).toBe("uncovered"); + expect(report.dimensions.decisions.verdict).toBe("uncovered"); + }); + + it("exits with uncovered drift when current distance exceeds the stance ledger", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local"), + ); + await writeFile( + join(dir, "tracked.fingerprint.md"), + fingerprintWithId("tracked"), + ); + await runCli(["track", "tracked.fingerprint.md"], dir); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local").replace( + "spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 }", + "spacing: { scale: [2, 3, 5, 7, 11, 13], baseUnit: 2, regularity: 0.1 }", + ), + ); + + const result = await runCli( + [ + "drift", + "check", + "--tracked", + "tracked.fingerprint.md", + "--format", + "json", + ], + dir, + ); + + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.schema).toBe("ghost.drift.check/v1"); + expect(report.overall.verdict).toBe("uncovered"); + expect(report.dimensions.spacing.verdict).toBe("uncovered"); + }); + it("initializes the default fingerprint package without cache", async () => { const init = await runCli(["init", "--format", "json"], dir); const scan = await runCli(["scan", "--format", "json"], dir); @@ -599,6 +1103,7 @@ checks: ) as Record; expect(config).toMatchObject({ schema: "ghost.config/v1", + design_loop: { enabled: false, mode: "off" }, targets: [{ id: "product", platform: "web", roots: [] }], libraries: [ { @@ -1668,6 +2173,34 @@ composition_patterns: [] ); } +async function writeCoveredSyncManifest( + dir: string, + options: { tracked: string }, +): Promise { + const ackedAt = "2026-06-16T00:00:00.000Z"; + await writeFile( + join(dir, ".ghost-sync.json"), + JSON.stringify( + { + tracks: { type: "path", value: options.tracked }, + ackedAt, + trackedFingerprintId: "tracked", + localFingerprintId: "local", + overallDistance: 0, + dimensions: { + spacing: { distance: 0, stance: "accepted", ackedAt }, + palette: { distance: 0, stance: "accepted", ackedAt }, + typography: { distance: 0, stance: "accepted", ackedAt }, + surfaces: { distance: 0, stance: "accepted", ackedAt }, + decisions: { distance: 0, stance: "accepted", ackedAt }, + }, + }, + null, + 2, + ), + ); +} + async function writeSplitFingerprintPackage( pkg: string, fingerprintRaw: string, From e64836b554f3daf4ef60d27a4f7cb5446711cfa9 Mon Sep 17 00:00:00 2001 From: Ryan Chang Date: Wed, 17 Jun 2026 11:36:09 -0700 Subject: [PATCH 2/2] Fix drift manifest and advisory exit Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019ed698-27ef-73f9-bd08-21e0117db073 --- apps/docs/src/generated/cli-manifest.json | 20 ++++----- packages/ghost/src/drift-command.ts | 17 ++++---- packages/ghost/test/cli.test.ts | 52 +++++++++++++++++++++++ 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 253de27e..ed55d3cf 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-17T17:20:21.308Z", + "generatedAt": "2026-06-17T18:35:34.326Z", "tools": [ { "tool": "ghost", @@ -33,7 +33,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for --all and default package lookup (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers, --all, and default package lookup (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -61,7 +61,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for init --scope or default root init (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers, init --scope, and default root init (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -145,7 +145,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for --all and default package lookup (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers, --all, and default package lookup (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -181,7 +181,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for nested discovery and default scan (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers, nested discovery, and default scan (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -209,7 +209,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for stack discovery (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers and stack discovery (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -348,7 +348,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for --path stack resolution (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers and --path stack resolution (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -644,7 +644,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for stack resolution (default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers and stack resolution (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -740,7 +740,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for stack discovery (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers and stack discovery (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -792,7 +792,7 @@ { "rawName": "--memory-dir ", "name": "memoryDir", - "description": "Relative fingerprint package directory for stack discovery (flag name retained; default: .ghost)", + "description": "Relative fingerprint package directory for host wrappers and stack discovery (env: GHOST_MEMORY_DIR; default: .ghost)", "default": null, "takesValue": true, "negated": false diff --git a/packages/ghost/src/drift-command.ts b/packages/ghost/src/drift-command.ts index 19200310..02ee9a41 100644 --- a/packages/ghost/src/drift-command.ts +++ b/packages/ghost/src/drift-command.ts @@ -205,7 +205,7 @@ export function registerDriftCommand(cli: CAC): void { ? `${JSON.stringify(report, null, 2)}\n` : formatDriftCheckMarkdown(report), ); - process.exit(gateExitCode(report.gate)); + process.exit(driftCheckExitCode(report)); } catch (err) { console.error( `Error: ${err instanceof Error ? err.message : String(err)}`, @@ -215,6 +215,13 @@ export function registerDriftCommand(cli: CAC): void { }); } +function driftCheckExitCode(report: DriftCheckReport): number { + if (report.designLoop.enabled && report.designLoop.mode === "advisory") { + return 0; + } + return gateExitCode(report.gate); +} + async function readSyncManifest( cwd: string, syncPathOption: string | undefined, @@ -337,13 +344,7 @@ async function loadComparableFingerprintFrom( cwd: string, path: string, ): Promise { - const previousCwd = process.cwd(); - try { - process.chdir(cwd); - return await loadComparableFingerprint(path); - } finally { - process.chdir(previousCwd); - } + return loadComparableFingerprint(resolve(cwd, path)); } function parseMaxDivergenceDays( diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 53dd4349..a06b25cc 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -822,6 +822,58 @@ sources: [] expect(report.dimensions.spacing.verdict).toBe("uncovered"); }); + it("keeps advisory design-loop drift non-blocking", async () => { + await mkdir(join(dir, ".ghost"), { recursive: true }); + await writeFile( + join(dir, ".ghost", "config.yml"), + `schema: ghost.config/v1 +targets: [] +libraries: [] +design_loop: + enabled: true + mode: advisory +`, + ); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local"), + ); + await writeFile( + join(dir, "tracked.fingerprint.md"), + fingerprintWithId("tracked"), + ); + await runCli(["track", "tracked.fingerprint.md"], dir); + await writeFile( + join(dir, ".ghost", "fingerprint.md"), + fingerprintWithId("local").replace( + "spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 }", + "spacing: { scale: [2, 3, 5, 7, 11, 13], baseUnit: 2, regularity: 0.1 }", + ), + ); + + const result = await runCli( + [ + "drift", + "check", + "--tracked", + "tracked.fingerprint.md", + "--format", + "json", + ], + dir, + ); + + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.designLoop).toEqual({ + enabled: true, + mode: "advisory", + source: "config", + }); + expect(report.overall.verdict).toBe("uncovered"); + expect(report.dimensions.spacing.verdict).toBe("uncovered"); + }); + it("initializes the default fingerprint package without cache", async () => { const init = await runCli(["init", "--format", "json"], dir); const scan = await runCli(["scan", "--format", "json"], dir);