Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ghost-drift-loop-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anarchitecture/ghost": minor
---

Add a Ghost-owned drift check command and explicit design-loop opt-in config.
70 changes: 69 additions & 1 deletion apps/docs/src/generated/cli-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-16T20:57:35.926Z",
"generatedAt": "2026-06-17T18:35:34.326Z",
"tools": [
{
"tool": "ghost",
Expand Down Expand Up @@ -555,6 +555,74 @@
}
]
},
{
"tool": "ghost",
"name": "drift",
"rawName": "drift <action>",
"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 <dir>",
"name": "package",
"description": "Exact fingerprint package directory",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--config <path>",
"name": "config",
"description": "Path to ghost config file for tracked source",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--local <path>",
"name": "local",
"description": "Local fingerprint or bundle to check",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--tracked <path>",
"name": "tracked",
"description": "Tracked/reference fingerprint or bundle",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--sync <path>",
"name": "sync",
"description": "Sync manifest path (default: ./.ghost-sync.json)",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--max-divergence-days <n>",
"name": "maxDivergenceDays",
"description": "Flag diverging dimensions older than this many days as uncovered",
"default": null,
"takesValue": true,
"negated": false
},
{
"rawName": "--format <fmt>",
"name": "format",
"description": "Output format: markdown or json",
"default": "markdown",
"takesValue": true,
"negated": false
}
]
},
{
"tool": "ghost",
"name": "relay",
Expand Down
2 changes: 2 additions & 0 deletions packages/ghost/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
runGateCli,
runGhostDriftCheck,
} from "./core/index.js";
import { registerDriftCommand } from "./drift-command.js";
import {
registerAckCommand,
registerDivergeCommand,
Expand Down Expand Up @@ -153,6 +154,7 @@ export function buildCli(): ReturnType<typeof cac> {
registerAckCommand(cli);
registerTrackCommand(cli);
registerDivergeCommand(cli);
registerDriftCommand(cli);
registerRelayCommand(cli);
registerSkillCommand(cli);

Expand Down
7 changes: 7 additions & 0 deletions packages/ghost/src/command-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
226 changes: 223 additions & 3 deletions packages/ghost/src/comparable-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"),
Expand All @@ -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 | undefined>): 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,
Expand Down
Loading
Loading