From 3dc8aeb21595a65bba8ced4c3373f9ca43925234 Mon Sep 17 00:00:00 2001 From: Ryan Chang Date: Mon, 11 May 2026 16:07:51 -0700 Subject: [PATCH 1/3] feat(ghost-drift): add --gate mode to compare for deterministic ledger reconciliation Compose the existing checkBounds() helper into a structured gate verdict on top of the existing compare command. New flags: --gate, --sync, and --max-divergence-days. Versioned JSON output as ghost.compare.gate/v1 suitable for CI and programmatic consumers; exit codes 0/1/2 reflect aligned-or-covered, uncovered drift, and hard error respectively. No new top-level orchestration verb; the existing review.md skill recipe gets a short pointer to the gate flag for agents that want a deterministic signal. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019e1874-9118-7527-b5e0-5bc0f8dcf913 --- .changeset/add-compare-gate-mode.md | 5 + apps/docs/src/generated/cli-manifest.json | 535 +++++++++--------- packages/ghost/src/cli.ts | 26 + packages/ghost/src/core/gate.ts | 328 +++++++++++ packages/ghost/src/core/index.ts | 15 + .../src/skill-bundle/references/review.md | 19 +- packages/ghost/test/gate.test.ts | 463 +++++++++++++++ 7 files changed, 1132 insertions(+), 259 deletions(-) create mode 100644 .changeset/add-compare-gate-mode.md create mode 100644 packages/ghost/src/core/gate.ts create mode 100644 packages/ghost/test/gate.test.ts diff --git a/.changeset/add-compare-gate-mode.md b/.changeset/add-compare-gate-mode.md new file mode 100644 index 0000000..57b38c5 --- /dev/null +++ b/.changeset/add-compare-gate-mode.md @@ -0,0 +1,5 @@ +--- +"ghost-drift": minor +--- + +Add `--gate` mode to `ghost-drift compare` that reads `.ghost-sync.json` and reports per-dimension verdicts (aligned / covered / reconverging / uncovered). Exits 0 when no uncovered drift, 1 when uncovered, 2 on hard error. Versioned JSON output via `--format json` (schema: `ghost.compare.gate/v1`). Composes over existing `compareFingerprints`, `readSyncManifest`, and `checkBounds` — no new orchestration. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 0b2b25a..5e5a02a 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,59 +1,59 @@ { - "generatedAt": "2026-05-19T14:36:11.941Z", + "generatedAt": "2026-05-11T23:06:36.378Z", "tools": [ { - "tool": "ghost", + "tool": "ghost-drift", "commands": [ { - "tool": "ghost", - "name": "lint", - "rawName": "lint [file]", - "description": "Validate a root fingerprint bundle, resources.yml, map.md, survey.json, patterns.yml, checks.yml, or markdown — defaults to .ghost", + "tool": "ghost-drift", + "name": "compare", + "rawName": "compare [...fingerprints]", + "description": "Compare two or more fingerprints or root .ghost bundles. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint.", "options": [ { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", + "rawName": "--semantic", + "name": "semantic", + "description": "Qualitative diff of decisions + palette (N=2 only)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--temporal", + "name": "temporal", + "description": "Add velocity, trajectory, and ack bounds (N=2, reads .ghost/history.jsonl)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--history-dir ", + "name": "historyDir", + "description": "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", + "default": null, "takesValue": true, "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "init", - "rawName": "init [dir]", - "description": "Create a root .ghost fingerprint bundle skeleton (resources.yml, map.md, survey.json, patterns.yml, checks.yml)", - "options": [ + }, { - "rawName": "--with-intent", - "name": "withIntent", - "description": "Also create optional intent.md for human-authored or human-approved intent", + "rawName": "--gate", + "name": "gate", + "description": "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", "default": null, "takesValue": false, "negated": false }, { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", + "rawName": "--sync ", + "name": "sync", + "description": "Sync manifest path for --gate (default: ./.ghost-sync.json)", + "default": null, "takesValue": true, "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "verify", - "rawName": "verify [dir]", - "description": "Verify a root fingerprint bundle: resources are reachable, patterns are survey-backed, and checks reference known patterns.", - "options": [ + }, { - "rawName": "--root ", - "name": "root", - "description": "Optional target root used to resolve resources.yml local paths (default: cwd)", + "rawName": "--max-divergence-days ", + "name": "maxDivergenceDays", + "description": "For --gate: flag diverging dimensions older than this many days as uncovered", "default": null, "takesValue": true, "negated": false @@ -69,42 +69,43 @@ ] }, { - "tool": "ghost", - "name": "scan", - "rawName": "scan [dir]", - "description": "Report fingerprint capture progress: produced artifacts, evidence readiness, and the next BYOA step.", + "tool": "ghost-drift", + "name": "ack", + "rawName": "ack", + "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", "options": [ { - "rawName": "--include-scopes", - "name": "includeScopes", - "description": "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", + "rawName": "-c, --config ", + "name": "config", + "description": "Path to ghost config file", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", + "rawName": "-d, --dimension ", + "name": "dimension", + "description": "Acknowledge a specific dimension only", + "default": null, "takesValue": true, "negated": false - } - ] - }, - { - "tool": "ghost", - "name": "inventory", - "rawName": "inventory [path]", - "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", - "options": [] - }, - { - "tool": "ghost", - "name": "describe", - "rawName": "describe [fingerprint]", - "description": "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", - "options": [ + }, + { + "rawName": "--stance ", + "name": "stance", + "description": "Stance: aligned, accepted, or diverging", + "default": "accepted", + "takesValue": true, + "negated": false + }, + { + "rawName": "--reason ", + "name": "reason", + "description": "Reason for this acknowledgment", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--format ", "name": "format", @@ -116,11 +117,19 @@ ] }, { - "tool": "ghost", - "name": "diff", - "rawName": "diff ", - "description": "Structural diff between two fingerprint.md files — what decisions, palette roles, and tokens changed (text-level, NOT embedding distance; for that, use `ghost compare`).", + "tool": "ghost-drift", + "name": "track", + "rawName": "track ", + "description": "Track another fingerprint as this repo's reference", "options": [ + { + "rawName": "-d, --dimension ", + "name": "dimension", + "description": "Track only for a specific dimension", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--format ", "name": "format", @@ -132,135 +141,103 @@ ] }, { - "tool": "ghost", - "name": "survey", - "rawName": "survey [...surveys]", - "description": "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", + "tool": "ghost-drift", + "name": "diverge", + "rawName": "diverge ", + "description": "Declare intentional divergence on a dimension", "options": [ { - "rawName": "-o, --out ", - "name": "out", - "description": "Write the result to this path (default: stdout)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: summarize/catalog use markdown or json; patterns use yaml, json, or markdown", + "rawName": "-c, --config ", + "name": "config", + "description": "Path to ghost config file", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--kind ", - "name": "kind", - "description": "survey catalog filter: include only this value kind", + "rawName": "-r, --reason ", + "name": "reason", + "description": "Why this dimension is intentionally diverging", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--budget ", - "name": "budget", - "description": "survey summarize budget: compact, standard, full", - "default": "standard", + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", "takesValue": true, "negated": false } ] }, { - "tool": "ghost", - "name": "emit", - "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle)", + "tool": "ghost-drift", + "name": "check", + "rawName": "check", + "description": "Run active ghost.checks/v1 gates from .ghost/checks.yml against a git diff.", "options": [ { - "rawName": "-f, --fingerprint ", - "name": "fingerprint", - "description": "Source legacy direct fingerprint markdown file (required for review-command; optional legacy mode for context-bundle)", + "rawName": "--base ", + "name": "base", + "description": "Git ref to diff against (default: HEAD)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "-o, --out ", - "name": "out", - "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/)", + "rawName": "--diff ", + "name": "diff", + "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--stdout", - "name": "stdout", - "description": "Write to stdout instead of a file (review-command only)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--no-tokens", - "name": "tokens", - "description": "Skip tokens.css output (legacy direct fingerprint context-bundle)", - "default": true, - "takesValue": false, - "negated": true - }, - { - "rawName": "--readme", - "name": "readme", - "description": "Include README.md (context-bundle)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--prompt-only", - "name": "promptOnly", - "description": "Emit only prompt.md (context-bundle)", + "rawName": "--package ", + "name": "package", + "description": "Fingerprint package directory (default: .ghost)", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { - "rawName": "--name ", - "name": "name", - "description": "Override the skill name (default: package or fingerprint id) (context-bundle)", - "default": null, + "rawName": "--format ", + "name": "format", + "description": "Output format: markdown or json", + "default": "markdown", "takesValue": true, "negated": false } ] }, { - "tool": "ghost", - "name": "compare", - "rawName": "compare [...fingerprints]", - "description": "Compare two or more fingerprints or root .ghost bundles. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint.", + "tool": "ghost-drift", + "name": "review", + "rawName": "review", + "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and a git diff.", "options": [ { - "rawName": "--semantic", - "name": "semantic", - "description": "Qualitative diff of decisions + palette (N=2 only)", + "rawName": "--base ", + "name": "base", + "description": "Git ref to diff against (default: HEAD)", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { - "rawName": "--temporal", - "name": "temporal", - "description": "Add velocity, trajectory, and ack bounds (N=2, reads .ghost/history.jsonl)", + "rawName": "--diff ", + "name": "diff", + "description": "Unified diff file to review instead of running git diff. Use '-' for stdin.", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { - "rawName": "--history-dir ", - "name": "historyDir", - "description": "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", + "rawName": "--package ", + "name": "package", + "description": "Fingerprint package directory (default: .ghost)", "default": null, "takesValue": true, "negated": false @@ -268,49 +245,76 @@ { "rawName": "--format ", "name": "format", - "description": "Output format: cli or json", - "default": "cli", + "description": "Output format: markdown or json", + "default": "markdown", "takesValue": true, "negated": false } ] }, { - "tool": "ghost", - "name": "ack", - "rawName": "ack", - "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", + "tool": "ghost-drift", + "name": "emit", + "rawName": "emit ", + "description": "Emit the ghost-drift agentskills.io bundle (kind: skill).", "options": [ { - "rawName": "-c, --config ", - "name": "config", - "description": "Path to ghost config file", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "-d, --dimension ", - "name": "dimension", - "description": "Acknowledge a specific dimension only", + "rawName": "-o, --out ", + "name": "out", + "description": "Output directory (default: .claude/skills/ghost-drift)", "default": null, "takesValue": true, "negated": false - }, + } + ] + } + ], + "globalOptions": [ + { + "rawName": "-h, --help", + "name": "help", + "description": "Display this message", + "default": null + }, + { + "rawName": "-v, --version", + "name": "version", + "description": "Display version number", + "default": null + } + ] + }, + { + "tool": "ghost-fingerprint", + "commands": [ + { + "tool": "ghost-fingerprint", + "name": "lint", + "rawName": "lint [file]", + "description": "Validate a root fingerprint bundle, resources.yml, map.md, survey.json, patterns.yml, checks.yml, or markdown — defaults to .ghost", + "options": [ { - "rawName": "--stance ", - "name": "stance", - "description": "Stance: aligned, accepted, or diverging", - "default": "accepted", + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", "takesValue": true, "negated": false - }, + } + ] + }, + { + "tool": "ghost-fingerprint", + "name": "init-package", + "rawName": "init-package [dir]", + "description": "Create a root .ghost fingerprint bundle skeleton (resources.yml, map.md, survey.json, patterns.yml, checks.yml)", + "options": [ { - "rawName": "--reason ", - "name": "reason", - "description": "Reason for this acknowledgment", + "rawName": "--with-intent", + "name": "withIntent", + "description": "Also create optional intent.md for human-authored or human-approved intent", "default": null, - "takesValue": true, + "takesValue": false, "negated": false }, { @@ -324,15 +328,15 @@ ] }, { - "tool": "ghost", - "name": "track", - "rawName": "track ", - "description": "Track another fingerprint as this repo's reference", + "tool": "ghost-fingerprint", + "name": "verify", + "rawName": "verify [dir]", + "description": "Verify a root fingerprint bundle: resources are reachable, patterns are survey-backed, and checks reference known patterns.", "options": [ { - "rawName": "-d, --dimension ", - "name": "dimension", - "description": "Track only for a specific dimension", + "rawName": "--root ", + "name": "root", + "description": "Optional target root used to resolve resources.yml local paths (default: cwd)", "default": null, "takesValue": true, "negated": false @@ -348,25 +352,17 @@ ] }, { - "tool": "ghost", - "name": "diverge", - "rawName": "diverge ", - "description": "Declare intentional divergence on a dimension", + "tool": "ghost-fingerprint", + "name": "scan-status", + "rawName": "scan-status [dir]", + "description": "Report which root fingerprint bundle stages have produced artifacts: resources.yml, map.md, survey.json, patterns.yml, and optional checks.yml/intent.md.", "options": [ { - "rawName": "-c, --config ", - "name": "config", - "description": "Path to ghost config file", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "-r, --reason ", - "name": "reason", - "description": "Why this dimension is intentionally diverging", + "rawName": "--include-scopes", + "name": "includeScopes", + "description": "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", "default": null, - "takesValue": true, + "takesValue": false, "negated": false }, { @@ -380,120 +376,143 @@ ] }, { - "tool": "ghost", - "name": "skill", - "rawName": "skill ", - "description": "Install the unified Ghost skill bundle.", + "tool": "ghost-fingerprint", + "name": "inventory", + "rawName": "inventory [path]", + "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", + "options": [] + }, + { + "tool": "ghost-fingerprint", + "name": "describe", + "rawName": "describe [fingerprint]", + "description": "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", "options": [ { - "rawName": "--dest ", - "name": "dest", - "description": "Install destination (default: detected agent skills directory + /ghost)", - "default": null, + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", "takesValue": true, "negated": false - }, + } + ] + }, + { + "tool": "ghost-fingerprint", + "name": "diff", + "rawName": "diff ", + "description": "Structural diff between two fingerprint.md files — what decisions, palette roles, and tokens changed (text-level, NOT embedding distance; for that, use `ghost-drift compare`).", + "options": [ { - "rawName": "--agent ", - "name": "agent", - "description": "Agent destination to use when --dest is omitted: claude, cursor, codex, opencode", - "default": null, + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", "takesValue": true, "negated": false - }, - { - "rawName": "--force", - "name": "force", - "description": "Overwrite an existing installed Ghost skill", - "default": null, - "takesValue": false, - "negated": false } ] }, { - "tool": "ghost", - "name": "check", - "rawName": "check", - "description": "Run active ghost.checks/v1 gates from .ghost/checks.yml against a git diff.", + "tool": "ghost-fingerprint", + "name": "survey", + "rawName": "survey [...surveys]", + "description": "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", "options": [ { - "rawName": "--base ", - "name": "base", - "description": "Git ref to diff against (default: HEAD)", + "rawName": "-o, --out ", + "name": "out", + "description": "Write the result to this path (default: stdout)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--diff ", - "name": "diff", - "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", + "rawName": "--format ", + "name": "format", + "description": "Output format: summarize/catalog use markdown or json; patterns use yaml, json, or markdown", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--package ", - "name": "package", - "description": "Fingerprint package directory (default: .ghost)", + "rawName": "--kind ", + "name": "kind", + "description": "survey catalog filter: include only this value kind", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--format ", - "name": "format", - "description": "Output format: markdown or json", - "default": "markdown", + "rawName": "--budget ", + "name": "budget", + "description": "survey summarize budget: compact, standard, full", + "default": "standard", "takesValue": true, "negated": false } ] }, { - "tool": "ghost", - "name": "review", - "rawName": "review", - "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and a git diff.", + "tool": "ghost-fingerprint", + "name": "emit", + "rawName": "emit ", + "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle, skill)", "options": [ { - "rawName": "--base ", - "name": "base", - "description": "Git ref to diff against (default: HEAD)", + "rawName": "-f, --fingerprint ", + "name": "fingerprint", + "description": "Source legacy direct fingerprint markdown file (required for review-command; optional legacy mode for context-bundle)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--diff ", - "name": "diff", - "description": "Unified diff file to review instead of running git diff. Use '-' for stdin.", + "rawName": "-o, --out ", + "name": "out", + "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/; skill → .claude/skills/ghost-fingerprint/)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--package ", - "name": "package", - "description": "Fingerprint package directory (default: .ghost)", + "rawName": "--stdout", + "name": "stdout", + "description": "Write to stdout instead of a file (review-command only)", "default": null, - "takesValue": true, + "takesValue": false, "negated": false }, { - "rawName": "--include-memory", - "name": "includeMemory", - "description": "Include accepted product-experience decisions from .ghost/decisions in the advisory packet.", + "rawName": "--no-tokens", + "name": "tokens", + "description": "Skip tokens.css output (legacy direct fingerprint context-bundle)", + "default": true, + "takesValue": false, + "negated": true + }, + { + "rawName": "--readme", + "name": "readme", + "description": "Include README.md (context-bundle)", "default": null, "takesValue": false, "negated": false }, { - "rawName": "--format ", - "name": "format", - "description": "Output format: markdown or json", - "default": "markdown", + "rawName": "--prompt-only", + "name": "promptOnly", + "description": "Emit only prompt.md (context-bundle)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--name ", + "name": "name", + "description": "Override the skill name (default: package or fingerprint id) (context-bundle)", + "default": null, "takesValue": true, "negated": false } diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 6c1a34d..124a95f 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -24,6 +24,7 @@ import { formatTemporalComparisonJSON, readHistory, readSyncManifest, + runGateCli, runGhostDriftCheck, } from "./core/index.js"; import { @@ -56,9 +57,34 @@ export function buildCli(): ReturnType { "--history-dir ", "Directory containing .ghost/history.jsonl (for --temporal, defaults to cwd)", ) + .option( + "--gate", + "Reconcile against a sync manifest and emit a structured pass/fail verdict (N=2 only)", + ) + .option( + "--sync ", + "Sync manifest path for --gate (default: ./.ghost-sync.json)", + ) + .option( + "--max-divergence-days ", + "For --gate: flag diverging dimensions older than this many days as uncovered", + ) .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (fingerprints: string[], opts) => { try { + if (opts.gate) { + await runGateCli({ + fingerprints, + cwd: process.cwd(), + sync: opts.sync, + format: opts.format, + maxDivergenceDays: opts.maxDivergenceDays, + loadFingerprint: loadComparableFingerprint, + compare, + }); + return; + } + const exprs = await Promise.all( fingerprints.map((path) => loadComparableFingerprint(path)), ); diff --git a/packages/ghost/src/core/gate.ts b/packages/ghost/src/core/gate.ts new file mode 100644 index 0000000..d8c92db --- /dev/null +++ b/packages/ghost/src/core/gate.ts @@ -0,0 +1,328 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { + DimensionAck, + Fingerprint, + FingerprintComparison, + SyncManifest, +} from "@ghost/core"; +import type { CompareResult } from "./compare.js"; +import { checkBounds } from "./evolution/sync.js"; + +const DEFAULT_SYNC_PATH = ".ghost-sync.json"; + +const GATE_SCHEMA = "ghost.compare.gate/v1" as const; +const ALIGNED_THRESHOLD = 0.01; +const DEFAULT_TOLERANCE = 0.05; + +export type GateDimensionVerdict = + | "aligned" + | "covered" + | "reconverging" + | "uncovered"; + +export type GateOverallVerdict = "aligned" | "covered" | "uncovered"; + +export interface GateDimensionReport { + distance: number; + ackDistance?: number; + stance?: DimensionAck["stance"]; + verdict: GateDimensionVerdict; + reason?: string; +} + +export interface GateReport { + schema: typeof GATE_SCHEMA; + trackedFingerprintId: string; + localFingerprintId: string; + overall: { + distance: number; + verdict: GateOverallVerdict; + }; + dimensions: Record; +} + +export interface BuildGateReportArgs { + comparison: FingerprintComparison; + manifest: SyncManifest; + tolerance?: number; + maxDivergenceDays?: number; +} + +/** + * Reconcile a pairwise comparison against a recorded sync manifest and + * produce a per-dimension verdict suitable for CI / programmatic gating. + * + * Composes over the existing `checkBounds` helper: a dimension is + * `uncovered` when checkBounds flags it (or when the comparison surfaces + * a dimension the manifest doesn't cover), `reconverging` when checkBounds + * marks it as such, `aligned` when the current distance is ~0, and + * otherwise `covered`. + */ +export function buildGateReport(args: BuildGateReportArgs): GateReport { + const { comparison, manifest } = args; + const tolerance = args.tolerance ?? DEFAULT_TOLERANCE; + + const bounds = checkBounds(manifest, comparison, { + tolerance, + maxDivergenceDays: args.maxDivergenceDays, + }); + const exceededSet = new Set(bounds.dimensions); + const reconvergingSet = new Set(bounds.reconverging); + + const dimensions: Record = {}; + + for (const [key, delta] of Object.entries(comparison.dimensions)) { + const ack = manifest.dimensions[key]; + const distance = delta.distance; + + if (!ack) { + dimensions[key] = { + distance, + verdict: "uncovered", + reason: "no ack recorded", + }; + continue; + } + + const effectiveTolerance = ack.tolerance ?? tolerance; + + if (exceededSet.has(key)) { + const reason = + ack.stance === "diverging" + ? buildDivergenceExceededReason(ack, args.maxDivergenceDays) + : `current ${formatNumber(distance)} exceeds acked ${formatNumber( + ack.distance, + )} + tolerance ${formatNumber(effectiveTolerance)}`; + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "uncovered", + reason, + }; + continue; + } + + if (reconvergingSet.has(key)) { + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "reconverging", + }; + continue; + } + + if ( + ack.stance === "aligned" && + distance < ALIGNED_THRESHOLD && + ack.distance < ALIGNED_THRESHOLD + ) { + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "aligned", + }; + continue; + } + + dimensions[key] = { + distance, + ackDistance: ack.distance, + stance: ack.stance, + verdict: "covered", + }; + } + + const verdicts = Object.values(dimensions).map((d) => d.verdict); + let overall: GateOverallVerdict; + if (verdicts.some((v) => v === "uncovered")) { + overall = "uncovered"; + } else if (verdicts.length > 0 && verdicts.every((v) => v === "aligned")) { + overall = "aligned"; + } else { + overall = "covered"; + } + + return { + schema: GATE_SCHEMA, + trackedFingerprintId: comparison.source.id, + localFingerprintId: comparison.target.id, + overall: { + distance: comparison.distance, + verdict: overall, + }, + dimensions, + }; +} + +/** + * Map a gate report to its CI exit code. + * - 0 when no uncovered drift (aligned, covered, or reconverging). + * - 1 when any dimension is uncovered. + */ +export function gateExitCode(report: GateReport): 0 | 1 { + return report.overall.verdict === "uncovered" ? 1 : 0; +} + +export function formatGateReportJSON(report: GateReport): string { + return JSON.stringify(report); +} + +const MARKERS: Record = { + aligned: "✓", + covered: "=", + reconverging: "~", + uncovered: "✗", +}; + +export function formatGateReportCLI(report: GateReport): string { + const lines: string[] = []; + lines.push( + `Gate: ${report.trackedFingerprintId} vs ${report.localFingerprintId}`, + ); + + const dimEntries = Object.entries(report.dimensions); + const nameWidth = dimEntries.reduce( + (max, [name]) => Math.max(max, name.length), + 0, + ); + + for (const [name, dim] of dimEntries) { + const marker = MARKERS[dim.verdict]; + const distance = formatPercent(dim.distance); + const tail = dim.reason ? ` ${dim.reason}` : ""; + lines.push( + ` ${marker} ${name.padEnd(nameWidth)} ${distance.padStart(6)} ${dim.verdict}${tail}`, + ); + } + + lines.push(""); + lines.push( + `Overall: ${report.overall.verdict} (${formatPercent(report.overall.distance)})`, + ); + return `${lines.join("\n")}\n`; +} + +function formatNumber(n: number): string { + return Number.isFinite(n) ? n.toFixed(3).replace(/\.?0+$/, "") : String(n); +} + +function formatPercent(n: number): string { + return `${(n * 100).toFixed(1)}%`; +} + +function buildDivergenceExceededReason( + ack: DimensionAck, + maxDivergenceDays?: number, +): string { + if (maxDivergenceDays === undefined || !ack.divergedAt) { + return "diverging stance exceeded recorded bounds"; + } + const divergedDate = new Date(ack.divergedAt); + const days = Math.floor( + (Date.now() - divergedDate.getTime()) / (1000 * 60 * 60 * 24), + ); + return `diverging for ${days} days exceeds --max-divergence-days ${maxDivergenceDays}`; +} + +// --- CLI runner --- + +export interface RunGateCliOptions { + fingerprints: string[]; + cwd: string; + sync?: unknown; + format?: unknown; + maxDivergenceDays?: unknown; + loadFingerprint: (path: string) => Promise; + compare: (fingerprints: Fingerprint[]) => CompareResult; +} + +/** + * CLI adapter for `ghost-drift compare --gate`. Validates inputs, loads + * the sync manifest, runs the comparison, and writes the verdict to + * stdout. Calls `process.exit` with the gate exit code (or 2 on error). + */ +export async function runGateCli(opts: RunGateCliOptions): Promise { + if (opts.fingerprints.length !== 2) { + console.error( + `Error: --gate requires exactly 2 fingerprints (got ${opts.fingerprints.length}).`, + ); + process.exit(2); + return; + } + + const syncPath = resolve( + opts.cwd, + typeof opts.sync === "string" ? opts.sync : DEFAULT_SYNC_PATH, + ); + if (!existsSync(syncPath)) { + console.error( + `Error: sync manifest not found at ${syncPath}. Run \`ghost-drift ack\` first or pass --sync .`, + ); + process.exit(2); + return; + } + + let manifest: SyncManifest; + try { + manifest = JSON.parse(await readFile(syncPath, "utf-8")) as SyncManifest; + } catch (err) { + console.error( + `Error: failed to load sync manifest at ${syncPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + process.exit(2); + return; + } + + if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { + console.error( + `Error: sync manifest at ${syncPath} is malformed (missing dimensions).`, + ); + process.exit(2); + return; + } + + const maxDivergenceDays = parseMaxDivergenceDays(opts.maxDivergenceDays); + if (maxDivergenceDays === "invalid") { + console.error( + "Error: --max-divergence-days must be a non-negative integer.", + ); + process.exit(2); + return; + } + + const exprs = await Promise.all(opts.fingerprints.map(opts.loadFingerprint)); + const result = opts.compare(exprs); + if (result.mode !== "pairwise") { + console.error("Error: --gate requires pairwise comparison."); + process.exit(2); + return; + } + + const report = buildGateReport({ + comparison: result.comparison, + manifest, + maxDivergenceDays: + maxDivergenceDays === undefined ? undefined : maxDivergenceDays, + }); + + const output = + opts.format === "json" + ? formatGateReportJSON(report) + : formatGateReportCLI(report); + process.stdout.write(`${output}\n`); + process.exit(gateExitCode(report)); +} + +function parseMaxDivergenceDays(raw: unknown): 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/core/index.ts b/packages/ghost/src/core/index.ts index cf50753..f50990e 100644 --- a/packages/ghost/src/core/index.ts +++ b/packages/ghost/src/core/index.ts @@ -94,6 +94,21 @@ export { resolveTrackedFingerprint, writeSyncManifest, } from "./evolution/index.js"; +export type { + BuildGateReportArgs, + GateDimensionReport, + GateDimensionVerdict, + GateOverallVerdict, + GateReport, + RunGateCliOptions, +} from "./gate.js"; +export { + buildGateReport, + formatGateReportCLI, + formatGateReportJSON, + gateExitCode, + runGateCli, +} from "./gate.js"; export { formatCompositeComparison, formatCompositeComparisonJSON, diff --git a/packages/ghost/src/skill-bundle/references/review.md b/packages/ghost/src/skill-bundle/references/review.md index e40fbd9..cff0679 100644 --- a/packages/ghost/src/skill-bundle/references/review.md +++ b/packages/ghost/src/skill-bundle/references/review.md @@ -65,7 +65,24 @@ Bad advisory topics: - restating pattern prose without a diff location - enforcing a rule that is not in `checks.yml` -### 4. Promote Durable Rules Later +### 4. Deterministic gate (CI / programmatic) + +When a non-interactive caller (CI, another agent, a script) needs a structured +pass/fail signal — not advisory prose — reach for the `--gate` mode of +`compare`. It reconciles the current pairwise distance against the recorded +ack stance in `.ghost-sync.json` and prints a per-dimension verdict +(`aligned` / `covered` / `reconverging` / `uncovered`). + +```bash +ghost-drift compare --gate --sync .ghost-sync.json --format json +``` + +Exit codes: `0` no uncovered drift, `1` at least one dimension is uncovered +(or new and unacked), `2` hard error (missing manifest, malformed JSON, N≠2). +The JSON schema is `ghost.compare.gate/v1` and is safe for programmatic +consumers to parse. + +### 5. Promote Durable Rules Later If an advisory finding recurs and can be detected deterministically, propose a new `ghost.checks/v1` entry. Do not add it to `checks.yml` unless a human diff --git a/packages/ghost/test/gate.test.ts b/packages/ghost/test/gate.test.ts new file mode 100644 index 0000000..5300cb8 --- /dev/null +++ b/packages/ghost/test/gate.test.ts @@ -0,0 +1,463 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + DimensionAck, + Fingerprint, + FingerprintComparison, + SyncManifest, +} from "@ghost/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildCli } from "../src/cli.js"; +import { + buildGateReport, + formatGateReportCLI, + formatGateReportJSON, + gateExitCode, +} from "../src/core/gate.js"; + +function makeFingerprint(id: string): Fingerprint { + return { + id, + source: "registry", + timestamp: new Date().toISOString(), + palette: { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "muted", + contrast: "moderate", + }, + spacing: { scale: [], regularity: 0, baseUnit: null }, + typography: { + families: [], + sizeRamp: [], + weightDistribution: {}, + lineHeightPattern: "normal", + }, + surfaces: { + borderRadii: [], + shadowComplexity: "deliberate-none", + borderUsage: "minimal", + }, + embedding: [], + }; +} + +function makeComparison( + dimensions: Record, + ids: { source: string; target: string } = { + source: "tracked", + target: "local", + }, +): FingerprintComparison { + const distances = Object.values(dimensions); + const distance = + distances.length === 0 + ? 0 + : distances.reduce((a, b) => a + b, 0) / distances.length; + return { + source: makeFingerprint(ids.source), + target: makeFingerprint(ids.target), + distance, + dimensions: Object.fromEntries( + Object.entries(dimensions).map(([key, dist]) => [ + key, + { dimension: key, distance: dist, description: "" }, + ]), + ), + summary: "", + }; +} + +function makeManifest( + dimensions: Record>, +): SyncManifest { + const fullDimensions: Record = {}; + for (const [key, partial] of Object.entries(dimensions)) { + fullDimensions[key] = { + distance: 0.1, + stance: "accepted", + ackedAt: new Date().toISOString(), + ...partial, + }; + } + return { + tracks: { type: "path", value: "./tracked.fingerprint.md" }, + ackedAt: new Date().toISOString(), + trackedFingerprintId: "tracked", + localFingerprintId: "local", + dimensions: fullDimensions, + overallDistance: 0, + }; +} + +describe("buildGateReport", () => { + it("schema field is present and correctly versioned", () => { + const report = buildGateReport({ + comparison: makeComparison({ palette: 0 }), + manifest: makeManifest({ palette: { distance: 0, stance: "aligned" } }), + }); + expect(report.schema).toBe("ghost.compare.gate/v1"); + }); + + it("aligned: zero distances and matching aligned acks", () => { + const comparison = makeComparison({ palette: 0, spacing: 0 }); + const manifest = makeManifest({ + palette: { distance: 0, stance: "aligned" }, + spacing: { distance: 0, stance: "aligned" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("aligned"); + expect(report.dimensions.spacing.verdict).toBe("aligned"); + expect(report.overall.verdict).toBe("aligned"); + expect(gateExitCode(report)).toBe(0); + }); + + it("covered (accepted): distances match acks, stance accepted", () => { + const comparison = makeComparison({ palette: 0.2, spacing: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + spacing: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("covered"); + expect(report.dimensions.spacing.verdict).toBe("covered"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("diverging covered: current ≤ acked, stance diverging", () => { + const comparison = makeComparison({ + palette: 0.2, + decisions: 0.0, + }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + decisions: { distance: 0.0, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.decisions.verdict).toBe("covered"); + expect(report.dimensions.decisions.stance).toBe("diverging"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("reconverging surfaced: diverging dim with current < 50% of acked", () => { + const comparison = makeComparison({ palette: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.4, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("reconverging"); + expect(report.overall.verdict).toBe("covered"); + expect(gateExitCode(report)).toBe(0); + }); + + it("uncovered (exceeded tolerance): exit 1, reason explains exceedance", () => { + const comparison = makeComparison({ palette: 0.95, spacing: 0.1 }); + const manifest = makeManifest({ + palette: { distance: 0.875, stance: "accepted" }, + spacing: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.palette.reason).toContain("exceeds"); + expect(report.dimensions.spacing.verdict).toBe("covered"); + expect(report.overall.verdict).toBe("uncovered"); + expect(gateExitCode(report)).toBe(1); + }); + + it("uncovered (new dimension): comparison has dimension manifest doesn't cover", () => { + const comparison = makeComparison({ + palette: 0.1, + newDimension: 0.4, + }); + const manifest = makeManifest({ + palette: { distance: 0.1, stance: "accepted" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.newDimension.verdict).toBe("uncovered"); + expect(report.dimensions.newDimension.reason).toBe("no ack recorded"); + expect(report.overall.verdict).toBe("uncovered"); + expect(gateExitCode(report)).toBe(1); + }); + + it("diverging exceeded --max-divergence-days flagged uncovered", () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 100); + const comparison = makeComparison({ palette: 0.5 }); + const manifest = makeManifest({ + palette: { + distance: 0.4, + stance: "diverging", + divergedAt: oldDate.toISOString(), + }, + }); + const report = buildGateReport({ + comparison, + manifest, + maxDivergenceDays: 30, + }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.palette.reason).toContain("max-divergence-days"); + expect(gateExitCode(report)).toBe(1); + }); + + it("CLI output: per-dimension lines with markers and final overall verdict", () => { + const comparison = makeComparison({ + palette: 0.95, + spacing: 0.0, + }); + const manifest = makeManifest({ + palette: { distance: 0.875, stance: "accepted" }, + spacing: { distance: 0.0, stance: "aligned" }, + }); + const report = buildGateReport({ comparison, manifest }); + const text = formatGateReportCLI(report); + expect(text).toContain("✓"); // aligned + expect(text).toContain("✗"); // uncovered + expect(text).toMatch(/Overall: uncovered/); + expect(text).toMatch(/palette/); + expect(text).toMatch(/spacing/); + }); + + it("JSON output: structured shape with schema field", () => { + const comparison = makeComparison({ palette: 0.2 }); + const manifest = makeManifest({ + palette: { distance: 0.2, stance: "accepted" }, + }); + const json = formatGateReportJSON( + buildGateReport({ comparison, manifest }), + ); + expect(json).toContain('"schema":"ghost.compare.gate/v1"'); + const parsed = JSON.parse(json); + expect(parsed.dimensions.palette.verdict).toBe("covered"); + }); +}); + +// --- CLI integration tests --- + +const FINGERPRINT = `--- +id: local +source: llm +timestamp: 2026-04-24T00:00:00.000Z +palette: + dominant: + - { role: primary, value: "#111111" } + neutrals: { steps: ["#ffffff", "#111111"], count: 2 } + semantic: [] + saturationProfile: muted + contrast: high +spacing: { scale: [4, 8, 16], baseUnit: 4, regularity: 1 } +typography: + families: ["Inter"] + sizeRamp: [12, 16, 24] + weightDistribution: { 400: 1 } + lineHeightPattern: normal +surfaces: + borderRadii: [4, 8] + shadowComplexity: deliberate-none + borderUsage: minimal +--- + +# Character + +Quiet and direct. + +# Decisions + +### shape-language +Use modest radii. +`; + +function fingerprintWithId(id: string): string { + return FINGERPRINT.replace("id: local", `id: ${id}`); +} + +async function runCli(argv: string[], cwd: string) { + const cli = buildCli(); + const previousCwd = process.cwd(); + let stdout = ""; + let stderr = ""; + let exitCode: number | undefined; + let finish: () => void = () => {}; + const done = new Promise((resolve) => { + finish = resolve; + }); + + const stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }); + const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => { + stdout += `${args.join(" ")}\n`; + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + stderr += `${args.join(" ")}\n`; + }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + exitCode = typeof code === "number" ? code : 0; + finish(); + return undefined as never; + }); + + try { + process.chdir(cwd); + cli.parse(["node", "ghost-drift", ...argv]); + await Promise.race([ + done, + new Promise((_, reject) => + setTimeout(() => reject(new Error("CLI command did not exit")), 5000), + ), + ]); + } finally { + process.chdir(previousCwd); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + } + + return { stdout, stderr, code: exitCode ?? 0 }; +} + +describe("ghost-drift compare --gate (CLI)", () => { + let dir: string; + + beforeEach(async () => { + dir = await import("node:fs/promises").then((fs) => + fs.mkdtemp(join(tmpdir(), "ghost-drift-gate-")), + ); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("missing sync file with --gate exits 2 and mentions the path", async () => { + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); + + const { stderr, code } = await runCli( + ["compare", "a.fingerprint.md", "b.fingerprint.md", "--gate"], + dir, + ); + + expect(code).toBe(2); + expect(stderr).toMatch(/sync manifest not found/); + expect(stderr).toContain(join(dir, ".ghost-sync.json")); + }); + + it("--gate --format json emits ghost.compare.gate/v1", async () => { + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + await writeFile(join(dir, "b.fingerprint.md"), fingerprintWithId("b")); + const manifest: SyncManifest = { + tracks: { type: "path", value: "./a.fingerprint.md" }, + ackedAt: new Date().toISOString(), + trackedFingerprintId: "a", + localFingerprintId: "b", + // Pre-populate every dimension with a generous tolerance so identical + // fingerprints come back fully covered. + dimensions: { + decisions: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + palette: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + spacing: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + typography: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + surfaces: { + distance: 0, + stance: "aligned", + ackedAt: new Date().toISOString(), + tolerance: 1, + }, + }, + overallDistance: 0, + }; + await writeFile( + join(dir, ".ghost-sync.json"), + JSON.stringify(manifest, null, 2), + ); + + const { stdout, code } = await runCli( + [ + "compare", + "a.fingerprint.md", + "b.fingerprint.md", + "--gate", + "--format", + "json", + ], + dir, + ); + + expect(code).toBe(0); + expect(stdout).toContain('"schema":"ghost.compare.gate/v1"'); + const parsed = JSON.parse(stdout.trim()); + expect(parsed.overall.verdict).toMatch(/aligned|covered/); + }); + + it("--gate with N≠2 exits 2", async () => { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "a.fingerprint.md"), fingerprintWithId("a")); + const { code, stderr } = await runCli( + ["compare", "a.fingerprint.md", "--gate"], + dir, + ); + expect(code).toBe(2); + expect(stderr).toMatch(/--gate requires exactly 2/); + }); +}); + +describe("uncovered JSON schema snippet (visible to caller)", () => { + it("matches the documented gate report shape for an uncovered dim", () => { + const comparison = makeComparison( + { + spacing: 0.73, + palette: 0.95, + decisions: 0.0, + newDimension: 0.4, + }, + { source: "market-theme", target: "example-app" }, + ); + const manifest = makeManifest({ + spacing: { distance: 0.73, stance: "accepted" }, + palette: { distance: 0.875, stance: "accepted" }, + decisions: { distance: 0.0, stance: "diverging" }, + }); + const report = buildGateReport({ comparison, manifest }); + expect(report.dimensions.palette.verdict).toBe("uncovered"); + expect(report.dimensions.newDimension.verdict).toBe("uncovered"); + expect(report.overall.verdict).toBe("uncovered"); + // Dump for the caller to verify the produced shape. + process.stdout.write(`${formatGateReportJSON(report)}\n`); + }); +}); From c3fd4e3c4ddda9a786f14437506843a3a6c5e03a Mon Sep 17 00:00:00 2001 From: Ryan Chang Date: Tue, 12 May 2026 11:49:11 -0700 Subject: [PATCH 2/3] fix(ghost-drift): tighten gate.ts types, stdout flush, and exit paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address sq agents review findings on the original gate scaffold: - Replace `unknown` types in RunGateCliOptions with concrete string | undefined / number | undefined; validate at the runGateCli boundary. - Use synchronous-flush stdout-write before process.exit so JSON output isn't truncated on piped or non-TTY runs. - Consolidate the multiple process.exit branches in runGateCli to a single exit at the end with intermediate paths returning the exit code via a typed result. - Remove (or wire up — verify intent) the unused effectiveTolerance in buildGateReport. No public-API or behavioral changes; the ghost.compare.gate/v1 schema and exit-code semantics are unchanged. Amp-Thread-ID: https://ampcode.com/threads/T-019e1874-9118-7527-b5e0-5bc0f8dcf913 Co-authored-by: Amp --- packages/ghost/src/core/gate.ts | 144 +++++++++++++++++++++---------- packages/ghost/test/gate.test.ts | 7 +- 2 files changed, 103 insertions(+), 48 deletions(-) diff --git a/packages/ghost/src/core/gate.ts b/packages/ghost/src/core/gate.ts index d8c92db..b80e808 100644 --- a/packages/ghost/src/core/gate.ts +++ b/packages/ghost/src/core/gate.ts @@ -86,9 +86,11 @@ export function buildGateReport(args: BuildGateReportArgs): GateReport { continue; } - const effectiveTolerance = ack.tolerance ?? tolerance; - if (exceededSet.has(key)) { + // `effectiveTolerance` mirrors the per-dimension override that + // `checkBounds` already applied; we recompute it here only to surface + // the same number in the human-readable reason string. + const effectiveTolerance = ack.tolerance ?? tolerance; const reason = ack.stance === "diverging" ? buildDivergenceExceededReason(ack, args.maxDivergenceDays) @@ -234,93 +236,141 @@ function buildDivergenceExceededReason( export interface RunGateCliOptions { fingerprints: string[]; cwd: string; - sync?: unknown; - format?: unknown; - maxDivergenceDays?: unknown; + /** From `--sync `; defaults to `./.ghost-sync.json`. */ + sync?: string; + /** From `--format `; "cli" (default) or "json". */ + format?: string; + /** + * From `--max-divergence-days `. cac may forward this as a number + * when it parses cleanly or as the raw string, so both shapes are + * accepted; `parseMaxDivergenceDays` validates inside this module. + */ + maxDivergenceDays?: number | string; loadFingerprint: (path: string) => Promise; compare: (fingerprints: Fingerprint[]) => CompareResult; } +type GateRunResult = + | { kind: "error"; code: 2; message: string } + | { kind: "ok"; code: 0 | 1; stdout: string }; + /** * CLI adapter for `ghost-drift compare --gate`. Validates inputs, loads * the sync manifest, runs the comparison, and writes the verdict to - * stdout. Calls `process.exit` with the gate exit code (or 2 on error). + * stdout. Calls `process.exit` exactly once at the end with the gate + * exit code (or 2 on any validation/error path). */ export async function runGateCli(opts: RunGateCliOptions): Promise { + const result = await computeGateRun(opts); + if (result.kind === "error") { + console.error(`Error: ${result.message}`); + } else { + await writeAndFlush(`${result.stdout}\n`); + } + process.exit(result.code); +} + +async function computeGateRun(opts: RunGateCliOptions): Promise { if (opts.fingerprints.length !== 2) { - console.error( - `Error: --gate requires exactly 2 fingerprints (got ${opts.fingerprints.length}).`, - ); - process.exit(2); - return; + return { + kind: "error", + code: 2, + message: `--gate requires exactly 2 fingerprints (got ${opts.fingerprints.length}).`, + }; } - const syncPath = resolve( - opts.cwd, - typeof opts.sync === "string" ? opts.sync : DEFAULT_SYNC_PATH, - ); + const syncPath = resolve(opts.cwd, opts.sync ?? DEFAULT_SYNC_PATH); if (!existsSync(syncPath)) { - console.error( - `Error: sync manifest not found at ${syncPath}. Run \`ghost-drift ack\` first or pass --sync .`, - ); - process.exit(2); - return; + return { + kind: "error", + code: 2, + message: `sync manifest not found at ${syncPath}. Run \`ghost-drift ack\` first or pass --sync .`, + }; } let manifest: SyncManifest; try { manifest = JSON.parse(await readFile(syncPath, "utf-8")) as SyncManifest; } catch (err) { - console.error( - `Error: failed to load sync manifest at ${syncPath}: ${ + return { + kind: "error", + code: 2, + message: `failed to load sync manifest at ${syncPath}: ${ err instanceof Error ? err.message : String(err) }`, - ); - process.exit(2); - return; + }; } if (!manifest || typeof manifest !== "object" || !manifest.dimensions) { - console.error( - `Error: sync manifest at ${syncPath} is malformed (missing dimensions).`, - ); - process.exit(2); - return; + return { + kind: "error", + code: 2, + message: `sync manifest at ${syncPath} is malformed (missing dimensions).`, + }; } const maxDivergenceDays = parseMaxDivergenceDays(opts.maxDivergenceDays); if (maxDivergenceDays === "invalid") { - console.error( - "Error: --max-divergence-days must be a non-negative integer.", + return { + kind: "error", + code: 2, + message: "--max-divergence-days must be a non-negative integer.", + }; + } + + let fingerprints: Fingerprint[]; + try { + fingerprints = await Promise.all( + opts.fingerprints.map((path) => opts.loadFingerprint(path)), ); - process.exit(2); - return; + } catch (err) { + return { + kind: "error", + code: 2, + message: `failed to load fingerprints: ${ + err instanceof Error ? err.message : String(err) + }`, + }; } - const exprs = await Promise.all(opts.fingerprints.map(opts.loadFingerprint)); - const result = opts.compare(exprs); - if (result.mode !== "pairwise") { - console.error("Error: --gate requires pairwise comparison."); - process.exit(2); - return; + const compared = opts.compare(fingerprints); + if (compared.mode !== "pairwise") { + return { + kind: "error", + code: 2, + message: "--gate requires pairwise comparison.", + }; } const report = buildGateReport({ - comparison: result.comparison, + comparison: compared.comparison, manifest, - maxDivergenceDays: - maxDivergenceDays === undefined ? undefined : maxDivergenceDays, + maxDivergenceDays, }); - const output = + const stdout = opts.format === "json" ? formatGateReportJSON(report) : formatGateReportCLI(report); - process.stdout.write(`${output}\n`); - process.exit(gateExitCode(report)); + + return { kind: "ok", code: gateExitCode(report), stdout }; +} + +/** + * Write to stdout and wait for the stream to flush before resolving. + * `process.exit` does not drain async stdout (e.g., when the gate + * report is piped into another command on Unix), so the explicit + * callback flush prevents truncated JSON output. + */ +async function writeAndFlush(text: string): Promise { + await new Promise((resolve) => { + process.stdout.write(text, () => resolve()); + }); } -function parseMaxDivergenceDays(raw: unknown): number | undefined | "invalid" { +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"; diff --git a/packages/ghost/test/gate.test.ts b/packages/ghost/test/gate.test.ts index 5300cb8..ccde13b 100644 --- a/packages/ghost/test/gate.test.ts +++ b/packages/ghost/test/gate.test.ts @@ -288,8 +288,13 @@ async function runCli(argv: string[], cwd: string) { const stdoutSpy = vi .spyOn(process.stdout, "write") - .mockImplementation((chunk: string | Uint8Array) => { + .mockImplementation((chunk: string | Uint8Array, ...rest: unknown[]) => { stdout += chunk.toString(); + // Honor the optional flush callback so writers using the + // `write(chunk, cb)` signature (see runGateCli's writeAndFlush) + // resolve instead of hanging on the test's mocked stdout. + const cb = rest[rest.length - 1]; + if (typeof cb === "function") cb(); return true; }); const stderrSpy = vi From ad91ee73b94ab0241123cef12f77cdc35dc6de4e Mon Sep 17 00:00:00 2001 From: Ryan Chang Date: Tue, 19 May 2026 09:33:51 -0700 Subject: [PATCH 3/3] fix(ghost-drift): adapt gate.ts to unified ghost package after rebase The main branch unified ghost-drift into the @anarchitecture/ghost package and switched internal core imports from '@ghost/core' to the '#ghost-core' subpath import. Update gate.ts to match, and regenerate the cli-manifest snapshot via pnpm dump:cli-help. No behavioral changes; the ghost.compare.gate/v1 schema and exit-code semantics are unchanged. Verified pnpm check + pnpm test (479 tests) pass. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019e2789-8b40-75ef-a9d0-adac2b13d89e --- apps/docs/src/generated/cli-manifest.json | 547 +++++++++++----------- packages/ghost/src/core/gate.ts | 2 +- 2 files changed, 277 insertions(+), 272 deletions(-) diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 5e5a02a..ac48d9a 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,11 +1,242 @@ { - "generatedAt": "2026-05-11T23:06:36.378Z", + "generatedAt": "2026-05-19T16:33:13.019Z", "tools": [ { - "tool": "ghost-drift", + "tool": "ghost", "commands": [ { - "tool": "ghost-drift", + "tool": "ghost", + "name": "lint", + "rawName": "lint [file]", + "description": "Validate a root fingerprint bundle, resources.yml, map.md, survey.json, patterns.yml, checks.yml, or markdown — defaults to .ghost", + "options": [ + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "init", + "rawName": "init [dir]", + "description": "Create a root .ghost fingerprint bundle skeleton (resources.yml, map.md, survey.json, patterns.yml, checks.yml)", + "options": [ + { + "rawName": "--with-intent", + "name": "withIntent", + "description": "Also create optional intent.md for human-authored or human-approved intent", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "verify", + "rawName": "verify [dir]", + "description": "Verify a root fingerprint bundle: resources are reachable, patterns are survey-backed, and checks reference known patterns.", + "options": [ + { + "rawName": "--root ", + "name": "root", + "description": "Optional target root used to resolve resources.yml local paths (default: cwd)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "scan", + "rawName": "scan [dir]", + "description": "Report fingerprint capture progress: produced artifacts, evidence readiness, and the next BYOA step.", + "options": [ + { + "rawName": "--include-scopes", + "name": "includeScopes", + "description": "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "inventory", + "rawName": "inventory [path]", + "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", + "options": [] + }, + { + "tool": "ghost", + "name": "describe", + "rawName": "describe [fingerprint]", + "description": "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", + "options": [ + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "diff", + "rawName": "diff ", + "description": "Structural diff between two fingerprint.md files — what decisions, palette roles, and tokens changed (text-level, NOT embedding distance; for that, use `ghost compare`).", + "options": [ + { + "rawName": "--format ", + "name": "format", + "description": "Output format: cli or json", + "default": "cli", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "survey", + "rawName": "survey [...surveys]", + "description": "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", + "options": [ + { + "rawName": "-o, --out ", + "name": "out", + "description": "Write the result to this path (default: stdout)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--format ", + "name": "format", + "description": "Output format: summarize/catalog use markdown or json; patterns use yaml, json, or markdown", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--kind ", + "name": "kind", + "description": "survey catalog filter: include only this value kind", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--budget ", + "name": "budget", + "description": "survey summarize budget: compact, standard, full", + "default": "standard", + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", + "name": "emit", + "rawName": "emit ", + "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle)", + "options": [ + { + "rawName": "-f, --fingerprint ", + "name": "fingerprint", + "description": "Source legacy direct fingerprint markdown file (required for review-command; optional legacy mode for context-bundle)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "-o, --out ", + "name": "out", + "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/)", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--stdout", + "name": "stdout", + "description": "Write to stdout instead of a file (review-command only)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--no-tokens", + "name": "tokens", + "description": "Skip tokens.css output (legacy direct fingerprint context-bundle)", + "default": true, + "takesValue": false, + "negated": true + }, + { + "rawName": "--readme", + "name": "readme", + "description": "Include README.md (context-bundle)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--prompt-only", + "name": "promptOnly", + "description": "Emit only prompt.md (context-bundle)", + "default": null, + "takesValue": false, + "negated": false + }, + { + "rawName": "--name ", + "name": "name", + "description": "Override the skill name (default: package or fingerprint id) (context-bundle)", + "default": null, + "takesValue": true, + "negated": false + } + ] + }, + { + "tool": "ghost", "name": "compare", "rawName": "compare [...fingerprints]", "description": "Compare two or more fingerprints or root .ghost bundles. N=2 returns a pairwise delta; N≥3 returns a composite fingerprint.", @@ -69,7 +300,7 @@ ] }, { - "tool": "ghost-drift", + "tool": "ghost", "name": "ack", "rawName": "ack", "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", @@ -117,7 +348,7 @@ ] }, { - "tool": "ghost-drift", + "tool": "ghost", "name": "track", "rawName": "track ", "description": "Track another fingerprint as this repo's reference", @@ -141,7 +372,7 @@ ] }, { - "tool": "ghost-drift", + "tool": "ghost", "name": "diverge", "rawName": "diverge ", "description": "Declare intentional divergence on a dimension", @@ -173,50 +404,42 @@ ] }, { - "tool": "ghost-drift", - "name": "check", - "rawName": "check", - "description": "Run active ghost.checks/v1 gates from .ghost/checks.yml against a git diff.", + "tool": "ghost", + "name": "skill", + "rawName": "skill ", + "description": "Install the unified Ghost skill bundle.", "options": [ { - "rawName": "--base ", - "name": "base", - "description": "Git ref to diff against (default: HEAD)", + "rawName": "--dest ", + "name": "dest", + "description": "Install destination (default: detected agent skills directory + /ghost)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--diff ", - "name": "diff", - "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", + "rawName": "--agent ", + "name": "agent", + "description": "Agent destination to use when --dest is omitted: claude, cursor, codex, opencode", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--package ", - "name": "package", - "description": "Fingerprint package directory (default: .ghost)", + "rawName": "--force", + "name": "force", + "description": "Overwrite an existing installed Ghost skill", "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: markdown or json", - "default": "markdown", - "takesValue": true, + "takesValue": false, "negated": false } ] }, { - "tool": "ghost-drift", - "name": "review", - "rawName": "review", - "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and a git diff.", + "tool": "ghost", + "name": "check", + "rawName": "check", + "description": "Run active ghost.checks/v1 gates from .ghost/checks.yml against a git diff.", "options": [ { "rawName": "--base ", @@ -229,7 +452,7 @@ { "rawName": "--diff ", "name": "diff", - "description": "Unified diff file to review instead of running git diff. Use '-' for stdin.", + "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", "default": null, "takesValue": true, "negated": false @@ -253,266 +476,48 @@ ] }, { - "tool": "ghost-drift", - "name": "emit", - "rawName": "emit ", - "description": "Emit the ghost-drift agentskills.io bundle (kind: skill).", - "options": [ - { - "rawName": "-o, --out ", - "name": "out", - "description": "Output directory (default: .claude/skills/ghost-drift)", - "default": null, - "takesValue": true, - "negated": false - } - ] - } - ], - "globalOptions": [ - { - "rawName": "-h, --help", - "name": "help", - "description": "Display this message", - "default": null - }, - { - "rawName": "-v, --version", - "name": "version", - "description": "Display version number", - "default": null - } - ] - }, - { - "tool": "ghost-fingerprint", - "commands": [ - { - "tool": "ghost-fingerprint", - "name": "lint", - "rawName": "lint [file]", - "description": "Validate a root fingerprint bundle, resources.yml, map.md, survey.json, patterns.yml, checks.yml, or markdown — defaults to .ghost", - "options": [ - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "init-package", - "rawName": "init-package [dir]", - "description": "Create a root .ghost fingerprint bundle skeleton (resources.yml, map.md, survey.json, patterns.yml, checks.yml)", - "options": [ - { - "rawName": "--with-intent", - "name": "withIntent", - "description": "Also create optional intent.md for human-authored or human-approved intent", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "verify", - "rawName": "verify [dir]", - "description": "Verify a root fingerprint bundle: resources are reachable, patterns are survey-backed, and checks reference known patterns.", - "options": [ - { - "rawName": "--root ", - "name": "root", - "description": "Optional target root used to resolve resources.yml local paths (default: cwd)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "scan-status", - "rawName": "scan-status [dir]", - "description": "Report which root fingerprint bundle stages have produced artifacts: resources.yml, map.md, survey.json, patterns.yml, and optional checks.yml/intent.md.", - "options": [ - { - "rawName": "--include-scopes", - "name": "includeScopes", - "description": "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "inventory", - "rawName": "inventory [path]", - "description": "Emit deterministic raw signals about a frontend repo as JSON: package manifests, language histogram, candidate config files, registry presence, top-level tree, git remote. Feeds the topology recipe (map.md authoring).", - "options": [] - }, - { - "tool": "ghost-fingerprint", - "name": "describe", - "rawName": "describe [fingerprint]", - "description": "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", - "options": [ - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "diff", - "rawName": "diff ", - "description": "Structural diff between two fingerprint.md files — what decisions, palette roles, and tokens changed (text-level, NOT embedding distance; for that, use `ghost-drift compare`).", - "options": [ - { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "survey", - "rawName": "survey [...surveys]", - "description": "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", + "tool": "ghost", + "name": "review", + "rawName": "review", + "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and a git diff.", "options": [ { - "rawName": "-o, --out ", - "name": "out", - "description": "Write the result to this path (default: stdout)", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--format ", - "name": "format", - "description": "Output format: summarize/catalog use markdown or json; patterns use yaml, json, or markdown", - "default": null, - "takesValue": true, - "negated": false - }, - { - "rawName": "--kind ", - "name": "kind", - "description": "survey catalog filter: include only this value kind", + "rawName": "--base ", + "name": "base", + "description": "Git ref to diff against (default: HEAD)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--budget ", - "name": "budget", - "description": "survey summarize budget: compact, standard, full", - "default": "standard", - "takesValue": true, - "negated": false - } - ] - }, - { - "tool": "ghost-fingerprint", - "name": "emit", - "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle, skill)", - "options": [ - { - "rawName": "-f, --fingerprint ", - "name": "fingerprint", - "description": "Source legacy direct fingerprint markdown file (required for review-command; optional legacy mode for context-bundle)", + "rawName": "--diff ", + "name": "diff", + "description": "Unified diff file to review instead of running git diff. Use '-' for stdin.", "default": null, "takesValue": true, "negated": false }, { - "rawName": "-o, --out ", - "name": "out", - "description": "Output path (review-command → .claude/commands/design-review.md; context-bundle → ghost-context/; skill → .claude/skills/ghost-fingerprint/)", + "rawName": "--package ", + "name": "package", + "description": "Fingerprint package directory (default: .ghost)", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--stdout", - "name": "stdout", - "description": "Write to stdout instead of a file (review-command only)", + "rawName": "--include-memory", + "name": "includeMemory", + "description": "Include accepted product-experience decisions from .ghost/decisions in the advisory packet.", "default": null, "takesValue": false, "negated": false }, { - "rawName": "--no-tokens", - "name": "tokens", - "description": "Skip tokens.css output (legacy direct fingerprint context-bundle)", - "default": true, - "takesValue": false, - "negated": true - }, - { - "rawName": "--readme", - "name": "readme", - "description": "Include README.md (context-bundle)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--prompt-only", - "name": "promptOnly", - "description": "Emit only prompt.md (context-bundle)", - "default": null, - "takesValue": false, - "negated": false - }, - { - "rawName": "--name ", - "name": "name", - "description": "Override the skill name (default: package or fingerprint id) (context-bundle)", - "default": null, + "rawName": "--format ", + "name": "format", + "description": "Output format: markdown or json", + "default": "markdown", "takesValue": true, "negated": false } diff --git a/packages/ghost/src/core/gate.ts b/packages/ghost/src/core/gate.ts index b80e808..5d8abee 100644 --- a/packages/ghost/src/core/gate.ts +++ b/packages/ghost/src/core/gate.ts @@ -6,7 +6,7 @@ import type { Fingerprint, FingerprintComparison, SyncManifest, -} from "@ghost/core"; +} from "#ghost-core"; import type { CompareResult } from "./compare.js"; import { checkBounds } from "./evolution/sync.js";