From e5163a6449cf44a93be0d69c6556d5560d16f73a Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Mon, 18 May 2026 15:12:31 -0400 Subject: [PATCH] Publish unified ghost package under anarchitecture --- .../add-composition-pattern-steering.md | 5 - .changeset/add-product-experience-memory.md | 6 - .changeset/add-surface-scoped-fingerprints.md | 6 - .changeset/add-survey-schema-and-merge.md | 27 - .changeset/add-survey-summary.md | 5 - .changeset/add-verify-profile-gate.md | 5 - .changeset/agent-led-framing.md | 6 - .changeset/anarchitecture-ghost.md | 5 + .changeset/authored-fingerprint-contract.md | 5 - .changeset/canonical-decision-vocabulary.md | 5 - .changeset/clarify-agent-guidance.md | 6 - .changeset/config.json | 8 +- .changeset/drift-readme-post-decompose.md | 5 - .changeset/drop-roles.md | 6 - .changeset/emit-package-context-bundle.md | 5 - .changeset/extract-ghost-core.md | 5 - .changeset/extract-ghost-scan.md | 5 - .changeset/fingerprint-canonical-artifact.md | 6 - ...fingerprint-signature-references-checks.md | 5 - .../fix-oklch-backfill-and-tighten-survey.md | 14 - .changeset/fix-profile-recipe-paths.md | 5 - .changeset/fix-review-severity-counts.md | 5 - .changeset/implemented-surface-evidence.md | 5 - .changeset/initial-ghost-scan.md | 5 - .changeset/phase-4b-schema-enrichment.md | 7 - .changeset/phase-4c-recipe-branching.md | 5 - .changeset/phase-5-bugs-and-schema.md | 15 - .changeset/refactor-fingerprint-package.md | 6 - .changeset/remove-profile-public-concept.md | 6 - .changeset/rename-evidence-to-survey.md | 5 - .changeset/rename-ghost-scan.md | 5 - .changeset/root-fingerprint-bundle.md | 6 - .changeset/source-graph-scans.md | 5 - .changeset/tailwind-class-atom-pass.md | 5 - .changeset/tighten-fingerprint-evidence.md | 5 - .changeset/tighten-terminal-context.md | 5 - .changeset/update-drift-fingerprint-recap.md | 5 - .changeset/v0-checks-and-perceptual-prior.md | 5 - .github/workflows/deploy-pages.yml | 6 +- .github/workflows/release-tarball.yml | 16 +- .github/workflows/release.yml | 4 +- CLAUDE.md | 245 ++-- README.md | 367 ++---- apps/docs/README.md | 2 +- apps/docs/src/app/tools/drift/page.tsx | 6 +- apps/docs/src/app/tools/page.tsx | 6 +- apps/docs/src/app/tools/scan/page.tsx | 4 +- apps/docs/src/components/docs/cli-help.tsx | 4 +- apps/docs/src/components/docs/dock.tsx | 4 +- apps/docs/src/content/docs/cli-reference.mdx | 396 +++--- .../docs/src/content/docs/getting-started.mdx | 66 +- apps/docs/src/generated/cli-manifest.json | 511 ++++---- docs/fingerprint-format.md | 18 +- docs/generation-loop.md | 20 +- install/install.sh | 5 +- install/manifest.json | 10 +- map.md | 130 +- package.json | 2 +- packages/ghost-drift/CHANGELOG.md | 37 - packages/ghost-drift/README.md | 111 -- .../ghost-drift/src/skill-bundle/SKILL.md | 72 -- packages/ghost-fleet/package.json | 3 +- packages/ghost-fleet/src/cli.ts | 6 +- packages/ghost-fleet/src/core/compute.ts | 2 +- packages/ghost-fleet/src/core/members.ts | 7 +- packages/ghost-fleet/src/core/types.ts | 8 +- packages/ghost-fleet/tsconfig.json | 2 +- packages/ghost-scan/package.json | 5 +- packages/ghost/CHANGELOG.md | 6 + packages/ghost/README.md | 67 + packages/{ghost-drift => ghost}/package.json | 39 +- packages/{ghost-drift => ghost}/src/bin.ts | 0 packages/{ghost-drift => ghost}/src/cli.ts | 74 +- .../src/comparable-fingerprint.ts | 6 +- .../{ghost-drift => ghost}/src/core/check.ts | 6 +- .../src/core/compare.ts | 8 +- .../{ghost-drift => ghost}/src/core/config.ts | 4 +- .../src/core/evolution/composite.ts | 4 +- .../src/core/evolution/emit.ts | 4 +- .../src/core/evolution/history.ts | 2 +- .../src/core/evolution/index.ts | 2 +- .../src/core/evolution/sync.ts | 4 +- .../src/core/evolution/temporal.ts | 4 +- .../src/core/evolution/tracking.ts | 8 +- .../{ghost-drift => ghost}/src/core/index.ts | 4 +- .../src/core/reporters/composite.ts | 2 +- .../src/core/reporters/fingerprint.ts | 2 +- .../src/core/reporters/temporal.ts | 2 +- .../src/core/scope-resolver.ts | 6 +- .../src/evolution-commands.ts | 2 +- packages/ghost/src/ghost-core/checks/index.ts | 30 + packages/ghost/src/ghost-core/checks/lint.ts | 205 ++++ .../ghost/src/ghost-core/checks/routing.ts | 84 ++ .../ghost/src/ghost-core/checks/schema.ts | 80 ++ packages/ghost/src/ghost-core/checks/types.ts | 76 ++ .../src/ghost-core/decision-vocabulary.ts | 282 +++++ .../ghost/src/ghost-core/embedding/colors.ts | 335 +++++ .../ghost/src/ghost-core/embedding/compare.ts | 591 +++++++++ .../src/ghost-core/embedding/describe.ts | 145 +++ .../src/ghost-core/embedding/embed-api.ts | 120 ++ .../src/ghost-core/embedding/embedding.ts | 333 +++++ .../ghost/src/ghost-core/embedding/index.ts | 17 + .../ghost-core/embedding/semantic-roles.ts | 160 +++ .../ghost/src/ghost-core/embedding/vector.ts | 43 + .../src/ghost-core/fingerprint-package.ts | 21 + packages/ghost/src/ghost-core/index.ts | 308 +++++ packages/ghost/src/ghost-core/map/index.ts | 27 + packages/ghost/src/ghost-core/map/schema.ts | 235 ++++ packages/ghost/src/ghost-core/map/scopes.ts | 49 + packages/ghost/src/ghost-core/map/types.ts | 65 + packages/ghost/src/ghost-core/memory/index.ts | 28 + packages/ghost/src/ghost-core/memory/lint.ts | 42 + .../ghost/src/ghost-core/memory/schema.ts | 80 ++ packages/ghost/src/ghost-core/memory/types.ts | 79 ++ .../ghost/src/ghost-core/patterns/index.ts | 22 + .../ghost/src/ghost-core/patterns/lint.ts | 140 +++ .../ghost/src/ghost-core/patterns/schema.ts | 74 ++ .../ghost/src/ghost-core/patterns/types.ts | 67 + .../ghost/src/ghost-core/perceptual-prior.ts | 223 ++++ .../ghost/src/ghost-core/resources/index.ts | 18 + .../ghost/src/ghost-core/resources/lint.ts | 90 ++ .../ghost/src/ghost-core/resources/schema.ts | 48 + .../ghost/src/ghost-core/resources/types.ts | 50 + .../src/ghost-core/skill-bundle-loader.ts | 56 + .../src/ghost-core/survey/catalog-format.ts | 85 ++ .../src/ghost-core/survey/catalog-types.ts | 44 + .../ghost/src/ghost-core/survey/catalog.ts | 204 ++++ .../ghost/src/ghost-core/survey/fix-ids.ts | 55 + packages/ghost/src/ghost-core/survey/id.ts | 65 + packages/ghost/src/ghost-core/survey/index.ts | 97 ++ packages/ghost/src/ghost-core/survey/lint.ts | 250 ++++ packages/ghost/src/ghost-core/survey/merge.ts | 76 ++ .../ghost/src/ghost-core/survey/schema.ts | 250 ++++ .../src/ghost-core/survey/summary-budget.ts | 55 + .../src/ghost-core/survey/summary-format.ts | 259 ++++ .../src/ghost-core/survey/summary-types.ts | 169 +++ .../ghost/src/ghost-core/survey/summary.ts | 472 +++++++ packages/ghost/src/ghost-core/survey/types.ts | 289 +++++ .../ghost/src/ghost-core/target-resolver.ts | 70 ++ packages/ghost/src/ghost-core/types.ts | 634 ++++++++++ packages/ghost/src/index.ts | 4 + packages/ghost/src/scan-commands.ts | 977 +++++++++++++++ packages/ghost/src/scan-emit-command.ts | 163 +++ packages/ghost/src/scan/body.ts | 116 ++ packages/ghost/src/scan/compose.ts | 103 ++ packages/ghost/src/scan/constants.ts | 29 + packages/ghost/src/scan/context/checks.ts | 86 ++ packages/ghost/src/scan/context/index.ts | 11 + .../ghost/src/scan/context/package-writer.ts | 276 +++++ .../ghost/src/scan/context/review-command.ts | 498 ++++++++ packages/ghost/src/scan/context/tokens-css.ts | 116 ++ packages/ghost/src/scan/context/writer.ts | 386 ++++++ packages/ghost/src/scan/diff.ts | 267 ++++ .../ghost/src/scan/fingerprint-package.ts | 443 +++++++ packages/ghost/src/scan/fingerprint-set.ts | 84 ++ packages/ghost/src/scan/frontmatter.ts | 154 +++ packages/ghost/src/scan/index.ts | 241 ++++ packages/ghost/src/scan/inventory.ts | 1082 +++++++++++++++++ packages/ghost/src/scan/layout.ts | 250 ++++ packages/ghost/src/scan/lint-map.ts | 369 ++++++ packages/ghost/src/scan/lint.ts | 330 +++++ packages/ghost/src/scan/parser.ts | 189 +++ packages/ghost/src/scan/scan-status.ts | 249 ++++ packages/ghost/src/scan/schema.ts | 198 +++ packages/ghost/src/scan/verify-fingerprint.ts | 845 +++++++++++++ packages/ghost/src/scan/verify-package.ts | 298 +++++ packages/ghost/src/scan/writer.ts | 96 ++ packages/ghost/src/skill-bundle/SKILL.md | 76 ++ .../assets/fingerprint.template.md | 72 ++ .../src/skill-bundle/references/brief.md | 30 + .../src/skill-bundle/references/capture.md | 40 + .../src/skill-bundle/references/compare.md | 12 +- .../src/skill-bundle/references/critique.md | 28 + .../ghost/src/skill-bundle/references/map.md | 156 +++ .../src/skill-bundle/references/patterns.md | 46 + .../src/skill-bundle/references/promote.md | 25 + .../src/skill-bundle/references/recall.md | 30 + .../src/skill-bundle/references/remediate.md | 14 +- .../src/skill-bundle/references/review.md | 6 +- .../ghost/src/skill-bundle/references/scan.md | 121 ++ .../src/skill-bundle/references/schema.md | 145 +++ .../src/skill-bundle/references/survey.md | 292 +++++ .../src/skill-bundle/references/verify.md | 4 +- packages/ghost/src/skill-command.ts | 101 ++ .../{ghost-drift => ghost}/test/cli.test.ts | 179 ++- .../test/compare.test.ts | 2 +- .../test/embedding/colors.test.ts | 6 +- .../test/embedding/compare-decisions.test.ts | 4 +- .../embedding/compare-oklch-fallback.test.ts | 6 +- .../test/embedding/embedding.test.ts | 4 +- .../test/embedding/semantic-roles.test.ts | 2 +- .../test/evolution/composite.test.ts | 2 +- .../test/evolution/sync.test.ts | 4 +- .../consumer-clean/components/ui/button.tsx | 0 .../consumer-clean/components/ui/card.tsx | 0 .../consumer-clean/src/styles/main.css | 0 .../consumer-drifted/components/ui/button.tsx | 0 .../consumer-drifted/src/styles/main.css | 0 .../test/fixtures/registry/out/r/button.json | 0 .../test/fixtures/registry/out/r/card.json | 0 .../fixtures/registry/out/r/styles-main.json | 0 .../test/fixtures/registry/registry.json | 0 .../registry/src/components/ui/button.tsx | 0 .../registry/src/components/ui/card.tsx | 0 .../fixtures/registry/src/styles/main.css | 0 .../test/scope-resolver.test.ts | 2 +- packages/{ghost-drift => ghost}/tsconfig.json | 3 +- pnpm-lock.yaml | 34 +- scripts/check-file-sizes.mjs | 28 +- scripts/dump-cli-help.mjs | 15 +- scripts/emit-fingerprint-schema.mjs | 6 +- tsconfig.json | 2 +- 212 files changed, 17518 insertions(+), 1712 deletions(-) delete mode 100644 .changeset/add-composition-pattern-steering.md delete mode 100644 .changeset/add-product-experience-memory.md delete mode 100644 .changeset/add-surface-scoped-fingerprints.md delete mode 100644 .changeset/add-survey-schema-and-merge.md delete mode 100644 .changeset/add-survey-summary.md delete mode 100644 .changeset/add-verify-profile-gate.md delete mode 100644 .changeset/agent-led-framing.md create mode 100644 .changeset/anarchitecture-ghost.md delete mode 100644 .changeset/authored-fingerprint-contract.md delete mode 100644 .changeset/canonical-decision-vocabulary.md delete mode 100644 .changeset/clarify-agent-guidance.md delete mode 100644 .changeset/drift-readme-post-decompose.md delete mode 100644 .changeset/drop-roles.md delete mode 100644 .changeset/emit-package-context-bundle.md delete mode 100644 .changeset/extract-ghost-core.md delete mode 100644 .changeset/extract-ghost-scan.md delete mode 100644 .changeset/fingerprint-canonical-artifact.md delete mode 100644 .changeset/fingerprint-signature-references-checks.md delete mode 100644 .changeset/fix-oklch-backfill-and-tighten-survey.md delete mode 100644 .changeset/fix-profile-recipe-paths.md delete mode 100644 .changeset/fix-review-severity-counts.md delete mode 100644 .changeset/implemented-surface-evidence.md delete mode 100644 .changeset/initial-ghost-scan.md delete mode 100644 .changeset/phase-4b-schema-enrichment.md delete mode 100644 .changeset/phase-4c-recipe-branching.md delete mode 100644 .changeset/phase-5-bugs-and-schema.md delete mode 100644 .changeset/refactor-fingerprint-package.md delete mode 100644 .changeset/remove-profile-public-concept.md delete mode 100644 .changeset/rename-evidence-to-survey.md delete mode 100644 .changeset/rename-ghost-scan.md delete mode 100644 .changeset/root-fingerprint-bundle.md delete mode 100644 .changeset/source-graph-scans.md delete mode 100644 .changeset/tailwind-class-atom-pass.md delete mode 100644 .changeset/tighten-fingerprint-evidence.md delete mode 100644 .changeset/tighten-terminal-context.md delete mode 100644 .changeset/update-drift-fingerprint-recap.md delete mode 100644 .changeset/v0-checks-and-perceptual-prior.md delete mode 100644 packages/ghost-drift/CHANGELOG.md delete mode 100644 packages/ghost-drift/README.md delete mode 100644 packages/ghost-drift/src/skill-bundle/SKILL.md create mode 100644 packages/ghost/CHANGELOG.md create mode 100644 packages/ghost/README.md rename packages/{ghost-drift => ghost}/package.json (52%) rename packages/{ghost-drift => ghost}/src/bin.ts (100%) rename packages/{ghost-drift => ghost}/src/cli.ts (86%) rename packages/{ghost-drift => ghost}/src/comparable-fingerprint.ts (97%) rename packages/{ghost-drift => ghost}/src/core/check.ts (99%) rename packages/{ghost-drift => ghost}/src/core/compare.ts (93%) rename packages/{ghost-drift => ghost}/src/core/config.ts (96%) rename packages/{ghost-drift => ghost}/src/core/evolution/composite.ts (98%) rename packages/{ghost-drift => ghost}/src/core/evolution/emit.ts (91%) rename packages/{ghost-drift => ghost}/src/core/evolution/history.ts (96%) rename packages/{ghost-drift => ghost}/src/core/evolution/index.ts (89%) rename packages/{ghost-drift => ghost}/src/core/evolution/sync.ts (98%) rename packages/{ghost-drift => ghost}/src/core/evolution/temporal.ts (97%) rename packages/{ghost-drift => ghost}/src/core/evolution/tracking.ts (92%) rename packages/{ghost-drift => ghost}/src/core/index.ts (98%) rename packages/{ghost-drift => ghost}/src/core/reporters/composite.ts (97%) rename packages/{ghost-drift => ghost}/src/core/reporters/fingerprint.ts (98%) rename packages/{ghost-drift => ghost}/src/core/reporters/temporal.ts (98%) rename packages/{ghost-drift => ghost}/src/core/scope-resolver.ts (98%) rename packages/{ghost-drift => ghost}/src/evolution-commands.ts (98%) create mode 100644 packages/ghost/src/ghost-core/checks/index.ts create mode 100644 packages/ghost/src/ghost-core/checks/lint.ts create mode 100644 packages/ghost/src/ghost-core/checks/routing.ts create mode 100644 packages/ghost/src/ghost-core/checks/schema.ts create mode 100644 packages/ghost/src/ghost-core/checks/types.ts create mode 100644 packages/ghost/src/ghost-core/decision-vocabulary.ts create mode 100644 packages/ghost/src/ghost-core/embedding/colors.ts create mode 100644 packages/ghost/src/ghost-core/embedding/compare.ts create mode 100644 packages/ghost/src/ghost-core/embedding/describe.ts create mode 100644 packages/ghost/src/ghost-core/embedding/embed-api.ts create mode 100644 packages/ghost/src/ghost-core/embedding/embedding.ts create mode 100644 packages/ghost/src/ghost-core/embedding/index.ts create mode 100644 packages/ghost/src/ghost-core/embedding/semantic-roles.ts create mode 100644 packages/ghost/src/ghost-core/embedding/vector.ts create mode 100644 packages/ghost/src/ghost-core/fingerprint-package.ts create mode 100644 packages/ghost/src/ghost-core/index.ts create mode 100644 packages/ghost/src/ghost-core/map/index.ts create mode 100644 packages/ghost/src/ghost-core/map/schema.ts create mode 100644 packages/ghost/src/ghost-core/map/scopes.ts create mode 100644 packages/ghost/src/ghost-core/map/types.ts create mode 100644 packages/ghost/src/ghost-core/memory/index.ts create mode 100644 packages/ghost/src/ghost-core/memory/lint.ts create mode 100644 packages/ghost/src/ghost-core/memory/schema.ts create mode 100644 packages/ghost/src/ghost-core/memory/types.ts create mode 100644 packages/ghost/src/ghost-core/patterns/index.ts create mode 100644 packages/ghost/src/ghost-core/patterns/lint.ts create mode 100644 packages/ghost/src/ghost-core/patterns/schema.ts create mode 100644 packages/ghost/src/ghost-core/patterns/types.ts create mode 100644 packages/ghost/src/ghost-core/perceptual-prior.ts create mode 100644 packages/ghost/src/ghost-core/resources/index.ts create mode 100644 packages/ghost/src/ghost-core/resources/lint.ts create mode 100644 packages/ghost/src/ghost-core/resources/schema.ts create mode 100644 packages/ghost/src/ghost-core/resources/types.ts create mode 100644 packages/ghost/src/ghost-core/skill-bundle-loader.ts create mode 100644 packages/ghost/src/ghost-core/survey/catalog-format.ts create mode 100644 packages/ghost/src/ghost-core/survey/catalog-types.ts create mode 100644 packages/ghost/src/ghost-core/survey/catalog.ts create mode 100644 packages/ghost/src/ghost-core/survey/fix-ids.ts create mode 100644 packages/ghost/src/ghost-core/survey/id.ts create mode 100644 packages/ghost/src/ghost-core/survey/index.ts create mode 100644 packages/ghost/src/ghost-core/survey/lint.ts create mode 100644 packages/ghost/src/ghost-core/survey/merge.ts create mode 100644 packages/ghost/src/ghost-core/survey/schema.ts create mode 100644 packages/ghost/src/ghost-core/survey/summary-budget.ts create mode 100644 packages/ghost/src/ghost-core/survey/summary-format.ts create mode 100644 packages/ghost/src/ghost-core/survey/summary-types.ts create mode 100644 packages/ghost/src/ghost-core/survey/summary.ts create mode 100644 packages/ghost/src/ghost-core/survey/types.ts create mode 100644 packages/ghost/src/ghost-core/target-resolver.ts create mode 100644 packages/ghost/src/ghost-core/types.ts create mode 100644 packages/ghost/src/index.ts create mode 100644 packages/ghost/src/scan-commands.ts create mode 100644 packages/ghost/src/scan-emit-command.ts create mode 100644 packages/ghost/src/scan/body.ts create mode 100644 packages/ghost/src/scan/compose.ts create mode 100644 packages/ghost/src/scan/constants.ts create mode 100644 packages/ghost/src/scan/context/checks.ts create mode 100644 packages/ghost/src/scan/context/index.ts create mode 100644 packages/ghost/src/scan/context/package-writer.ts create mode 100644 packages/ghost/src/scan/context/review-command.ts create mode 100644 packages/ghost/src/scan/context/tokens-css.ts create mode 100644 packages/ghost/src/scan/context/writer.ts create mode 100644 packages/ghost/src/scan/diff.ts create mode 100644 packages/ghost/src/scan/fingerprint-package.ts create mode 100644 packages/ghost/src/scan/fingerprint-set.ts create mode 100644 packages/ghost/src/scan/frontmatter.ts create mode 100644 packages/ghost/src/scan/index.ts create mode 100644 packages/ghost/src/scan/inventory.ts create mode 100644 packages/ghost/src/scan/layout.ts create mode 100644 packages/ghost/src/scan/lint-map.ts create mode 100644 packages/ghost/src/scan/lint.ts create mode 100644 packages/ghost/src/scan/parser.ts create mode 100644 packages/ghost/src/scan/scan-status.ts create mode 100644 packages/ghost/src/scan/schema.ts create mode 100644 packages/ghost/src/scan/verify-fingerprint.ts create mode 100644 packages/ghost/src/scan/verify-package.ts create mode 100644 packages/ghost/src/scan/writer.ts create mode 100644 packages/ghost/src/skill-bundle/SKILL.md create mode 100644 packages/ghost/src/skill-bundle/assets/fingerprint.template.md create mode 100644 packages/ghost/src/skill-bundle/references/brief.md create mode 100644 packages/ghost/src/skill-bundle/references/capture.md rename packages/{ghost-drift => ghost}/src/skill-bundle/references/compare.md (84%) create mode 100644 packages/ghost/src/skill-bundle/references/critique.md create mode 100644 packages/ghost/src/skill-bundle/references/map.md create mode 100644 packages/ghost/src/skill-bundle/references/patterns.md create mode 100644 packages/ghost/src/skill-bundle/references/promote.md create mode 100644 packages/ghost/src/skill-bundle/references/recall.md rename packages/{ghost-drift => ghost}/src/skill-bundle/references/remediate.md (85%) rename packages/{ghost-drift => ghost}/src/skill-bundle/references/review.md (94%) create mode 100644 packages/ghost/src/skill-bundle/references/scan.md create mode 100644 packages/ghost/src/skill-bundle/references/schema.md create mode 100644 packages/ghost/src/skill-bundle/references/survey.md rename packages/{ghost-drift => ghost}/src/skill-bundle/references/verify.md (93%) create mode 100644 packages/ghost/src/skill-command.ts rename packages/{ghost-drift => ghost}/test/cli.test.ts (71%) rename packages/{ghost-drift => ghost}/test/compare.test.ts (98%) rename packages/{ghost-drift => ghost}/test/embedding/colors.test.ts (98%) rename packages/{ghost-drift => ghost}/test/embedding/compare-decisions.test.ts (97%) rename packages/{ghost-drift => ghost}/test/embedding/compare-oklch-fallback.test.ts (95%) rename packages/{ghost-drift => ghost}/test/embedding/embedding.test.ts (97%) rename packages/{ghost-drift => ghost}/test/embedding/semantic-roles.test.ts (98%) rename packages/{ghost-drift => ghost}/test/evolution/composite.test.ts (98%) rename packages/{ghost-drift => ghost}/test/evolution/sync.test.ts (99%) rename packages/{ghost-drift => ghost}/test/fixtures/consumer-clean/components/ui/button.tsx (100%) rename packages/{ghost-drift => ghost}/test/fixtures/consumer-clean/components/ui/card.tsx (100%) rename packages/{ghost-drift => ghost}/test/fixtures/consumer-clean/src/styles/main.css (100%) rename packages/{ghost-drift => ghost}/test/fixtures/consumer-drifted/components/ui/button.tsx (100%) rename packages/{ghost-drift => ghost}/test/fixtures/consumer-drifted/src/styles/main.css (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/out/r/button.json (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/out/r/card.json (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/out/r/styles-main.json (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/registry.json (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/src/components/ui/button.tsx (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/src/components/ui/card.tsx (100%) rename packages/{ghost-drift => ghost}/test/fixtures/registry/src/styles/main.css (100%) rename packages/{ghost-drift => ghost}/test/scope-resolver.test.ts (97%) rename packages/{ghost-drift => ghost}/tsconfig.json (58%) diff --git a/.changeset/add-composition-pattern-steering.md b/.changeset/add-composition-pattern-steering.md deleted file mode 100644 index 2f7c41b..0000000 --- a/.changeset/add-composition-pattern-steering.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Add composition-pattern steering so generated fingerprints and context bundles distinguish article, tracker, comparison, and card output shapes. diff --git a/.changeset/add-product-experience-memory.md b/.changeset/add-product-experience-memory.md deleted file mode 100644 index 709f0c9..0000000 --- a/.changeset/add-product-experience-memory.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-drift": minor -"ghost-scan": minor ---- - -Add optional product-experience memory validation and advisory review context for accepted decisions. diff --git a/.changeset/add-surface-scoped-fingerprints.md b/.changeset/add-surface-scoped-fingerprints.md deleted file mode 100644 index 20e5cca..0000000 --- a/.changeset/add-surface-scoped-fingerprints.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-scan": minor -"ghost-drift": minor ---- - -Add surface-scoped fingerprint overlays, scoped scan status, and path-based scoped fingerprint resolution. diff --git a/.changeset/add-survey-schema-and-merge.md b/.changeset/add-survey-schema-and-merge.md deleted file mode 100644 index 37fe351..0000000 --- a/.changeset/add-survey-schema-and-merge.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"ghost-scan": minor ---- - -Land the three-stage scan pipeline: map (`map.md`) → survey (`survey.json`) → express (`fingerprint.md`). All three stages are now owned by `ghost-scan`; the previously separate `ghost-map` package is folded in. - -**New artifact: `ghost.survey/v1`** — catalogues every concrete design value with structured specs, occurrence counts, and deterministic content-hashed IDs. - -**New verbs:** -- `ghost-scan inventory [path]` — emit deterministic raw repo signals as JSON (manifests, language histogram, registry, top-level tree, git remote). Feeds the topology recipe. (Migrated from `ghost-map inventory`.) -- `ghost-scan survey ` — `merge` (concat with id-based dedup, idempotent — useful for modular rollups and fleet cohort views), `fix-ids` (recompute every row's `id` from content, so surveyor agents can author rows with empty `id` fields and finalize in one pass). -- `ghost-scan scan-status [dir]` — report which scan stages have produced artifacts (`map.md`, `survey.json`, `fingerprint.md`) and which stage to run next. The build-system glue orchestrators call between stages. - -**Updated verbs:** -- `ghost-scan lint` now auto-detects file kind by extension/content and dispatches to the right validator (`fingerprint.md`, `map.md`, or `survey.json`). - -**New skill recipes:** -- `map.md` — author `map.md` from a target (the topology stage). Migrated from the standalone `ghost-map` package. -- `survey.md` — author `survey.json` from a target (the observed evidence stage). Walks the agent through LLM-driven extraction with dialect-specific grep strategies, exhaustiveness discipline, and saturation predicate. -- `scan.md` — meta-recipe that orchestrates map → survey → fingerprint end-to-end via `scan-status` checkpoints. Use when the user wants a full scan rather than a specific stage. - -**Refactored skill recipe:** -- `fingerprint.md` — now strictly the fingerprint stage. Reads `survey.json` as ground truth; cannot fabricate values not in the survey; cites survey rows as evidence. Pre-requires `map.md` + `survey.json`. Hard split from the previous one-pass extract+interpret recipe. - -**Removed:** the `ghost-map` package is deleted. `ghost.map/v1` schema and types now live in `@ghost/core`; `inventory` and `lint` (for `map.md`) move to `ghost-scan`. Consumers that imported from `ghost-map` should switch to `@ghost/core` (schemas/types) or `ghost-scan` (CLI verbs / library functions). - -Survey schema, deterministic-id generation, lint, merge, and fix-ids primitives live in `@ghost/core`. diff --git a/.changeset/add-survey-summary.md b/.changeset/add-survey-summary.md deleted file mode 100644 index 53becaa..0000000 --- a/.changeset/add-survey-summary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Add a bounded `ghost-scan survey summarize` digest for fingerprint authoring large surveys without loading full raw evidence into agent context. diff --git a/.changeset/add-verify-profile-gate.md b/.changeset/add-verify-profile-gate.md deleted file mode 100644 index 7514be8..0000000 --- a/.changeset/add-verify-profile-gate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Add a deterministic fingerprint verification command that checks fingerprint palette provenance and promoted check calibration against survey evidence. diff --git a/.changeset/agent-led-framing.md b/.changeset/agent-led-framing.md deleted file mode 100644 index 0be9c1c..0000000 --- a/.changeset/agent-led-framing.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-drift": patch -"ghost-scan": patch ---- - -Reframe skill-bundle copy to lead with the agent and drift, not the deterministic CLI. Same architecture; clearer story when an agent loads the skill. diff --git a/.changeset/anarchitecture-ghost.md b/.changeset/anarchitecture-ghost.md new file mode 100644 index 0000000..537a6a5 --- /dev/null +++ b/.changeset/anarchitecture-ghost.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Publish Ghost as one scoped package with the `ghost` CLI, unified scan and drift workflows, and one installed skill bundle. diff --git a/.changeset/authored-fingerprint-contract.md b/.changeset/authored-fingerprint-contract.md deleted file mode 100644 index 5ffec4f..0000000 --- a/.changeset/authored-fingerprint-contract.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": major ---- - -Make fingerprint.md an authored-only contract, remove fragment-era on-disk embedding and check fields, add survey catalog, and expand fingerprint verification. diff --git a/.changeset/canonical-decision-vocabulary.md b/.changeset/canonical-decision-vocabulary.md deleted file mode 100644 index 5307963..0000000 --- a/.changeset/canonical-decision-vocabulary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Add a controlled vocabulary of 12 canonical decision dimensions (`color-strategy`, `surface-hierarchy`, `shape-language`, `typography-voice`, `spatial-system`, `density`, `motion`, `elevation`, `theming-architecture`, `interactive-patterns`, `token-architecture`, `font-sourcing`) so fleet-aggregation primitives can group decisions across members. Fingerprint recipe nudges authors toward canonical slugs; novel project-flavored slugs may pair with an optional `dimension_kind` that maps to a canonical survey. New soft `non-canonical-dimension` lint warning suggests the closest canonical match. The schema accepts the optional `dimension_kind` field on `decisions[]`; existing fingerprints remain valid. diff --git a/.changeset/clarify-agent-guidance.md b/.changeset/clarify-agent-guidance.md deleted file mode 100644 index 5eaab07..0000000 --- a/.changeset/clarify-agent-guidance.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-drift": patch -"ghost-scan": patch ---- - -Clarifies agent-facing scan and drift guidance across docs and skill bundles. diff --git a/.changeset/config.json b/.changeset/config.json index 346c54b..857deef 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,11 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["ghost-ui", "ghost-docs", "@ghost/core", "ghost-fleet"] + "ignore": [ + "ghost-ui", + "ghost-docs", + "@ghost/core", + "ghost-scan", + "ghost-fleet" + ] } diff --git a/.changeset/drift-readme-post-decompose.md b/.changeset/drift-readme-post-decompose.md deleted file mode 100644 index c86aaf2..0000000 --- a/.changeset/drift-readme-post-decompose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-drift": patch ---- - -Rewrite README to reflect the five-tool decomposition: drift now lists five verbs (compare, ack, track, diverge, emit skill) and points users at `ghost-scan` for the moved authoring verbs (lint, describe, diff, emit review-command, emit context-bundle). diff --git a/.changeset/drop-roles.md b/.changeset/drop-roles.md deleted file mode 100644 index f148689..0000000 --- a/.changeset/drop-roles.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-scan": major -"ghost-drift": patch ---- - -Drop `roles[]` from the `fingerprint.md` schema. Slot → token bindings either fall out of decisions[] (pattern consequences) or live in survey.json components[] (exhaustive catalog). The hybrid `roles[]` slot was filling neither role cleanly and didn't scale to systems with many components. Existing files that carry `roles:` will fail strict lint — drop the section to migrate. Drift skill recipes that referenced `roles[]` as part of the fingerprint frontmatter have been updated. diff --git a/.changeset/emit-package-context-bundle.md b/.changeset/emit-package-context-bundle.md deleted file mode 100644 index 696956f..0000000 --- a/.changeset/emit-package-context-bundle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Emit context bundles from the root fingerprint package by default, while preserving legacy direct fingerprint emission behind `--fingerprint`. diff --git a/.changeset/extract-ghost-core.md b/.changeset/extract-ghost-core.md deleted file mode 100644 index 7c2f94e..0000000 --- a/.changeset/extract-ghost-core.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-drift": patch ---- - -Extract embedding math, target resolution, skill-bundle loader, and shared types into the new internal `@ghost/core` workspace package. No user-facing CLI or library API changes. diff --git a/.changeset/extract-ghost-scan.md b/.changeset/extract-ghost-scan.md deleted file mode 100644 index 8fc5fe5..0000000 --- a/.changeset/extract-ghost-scan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-drift": major ---- - -Keep `ghost-drift` drift-only: authoring verbs and context emit live in `ghost-scan`, `ghost-drift emit` only accepts `skill`, and drift no longer re-exports the fingerprint authoring API. diff --git a/.changeset/fingerprint-canonical-artifact.md b/.changeset/fingerprint-canonical-artifact.md deleted file mode 100644 index 07168f6..0000000 --- a/.changeset/fingerprint-canonical-artifact.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-drift": major -"ghost-scan": major ---- - -Rename Ghost's authored design-language artifact back to `fingerprint.md`, rename the authoring package and CLI to `ghost-scan`, and remove the old expression APIs, filenames, and drift compatibility shims. diff --git a/.changeset/fingerprint-signature-references-checks.md b/.changeset/fingerprint-signature-references-checks.md deleted file mode 100644 index c9c8904..0000000 --- a/.changeset/fingerprint-signature-references-checks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": major ---- - -Make `fingerprint.md` the generation and drift root by restoring first-class `# Signature`, adding direct `references` for living specs/components/examples, and using `checks[]` for promoted review gates. diff --git a/.changeset/fix-oklch-backfill-and-tighten-survey.md b/.changeset/fix-oklch-backfill-and-tighten-survey.md deleted file mode 100644 index eb09148..0000000 --- a/.changeset/fix-oklch-backfill-and-tighten-survey.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"ghost-scan": patch -"ghost-drift": patch ---- - -Fix self-distance bug + tighten the survey recipe's exhaustiveness rule. - -**Bug fix.** `loadFingerprint` now backfills `oklch` on palette colors that arrive hex-only (frontmatter without an explicit `oklch` tuple). Without this, `comparePalette` treated hex-only colors as fully unmatched and contributed distance `1.0` per color — even when comparing an fingerprint to itself. Self-distance was reported as 17.5% on a freshly authored fingerprint. Backfill is deterministic (same hex → same oklch), so re-parsing the same file always yields the same in-memory shape. - -**Defensive fallback.** `comparePalette` also now resolves oklch on-the-fly when missing, and falls back to hex-string equality when even on-the-fly compute can't parse the color (CSS variables, opaque external refs). This covers third-party producers that don't backfill on write. - -**Recipe tightening.** `survey.md` now states the exhaustiveness rule up front and applies it per section. The rule is repo-agnostic — the recipe doesn't name specific signal sources (no "use registry.json"); the agent identifies the canonical signal in *this* repo, enumerates exhaustively, and cross-checks counts. New `Never sample` rule and explicit guidance against placeholder/glob names. Triggered by a dogfood scan that produced ~10% recall on `components[]` (6 rows for a 97-component package). - -**Schema cut: `survey.libraries[]` removed.** External libraries (icon sets, primitive collections, motion libs, charting) no longer have a top-level survey section. Whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*. When a library is load-bearing for the design language (icon family, font sourcing), it surfaces as prose evidence in the interpreter stage under the relevant decision dimension. Survey sections are now `values`, `tokens`, `components`. `LibraryRow` / `LibraryRowSchema` / `libraryRowId` removed from `@ghost/core` exports. Existing `survey.json` files with a `libraries` field will fail lint (use a no-op migration: drop the field). diff --git a/.changeset/fix-profile-recipe-paths.md b/.changeset/fix-profile-recipe-paths.md deleted file mode 100644 index ec44568..0000000 --- a/.changeset/fix-profile-recipe-paths.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Fix the fingerprint recipe — it now reads `design_system.paths` (the actual map.md frontmatter field) instead of the nonexistent `design_system.location`. The skill bundle ships under ghost-scan, so the broken recipe shipped to host agents. diff --git a/.changeset/fix-review-severity-counts.md b/.changeset/fix-review-severity-counts.md deleted file mode 100644 index 35b2e5f..0000000 --- a/.changeset/fix-review-severity-counts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Clarify emitted terminal context by reporting final review-command severity counts after escalation and naming lower-enforcement prompt context when no checks are promoted. diff --git a/.changeset/implemented-surface-evidence.md b/.changeset/implemented-surface-evidence.md deleted file mode 100644 index 574e224..0000000 --- a/.changeset/implemented-surface-evidence.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": major ---- - -Upgrade scans to `ghost.map/v2` and `ghost.survey/v2`, requiring implemented UI surface evidence and using those surfaces to guide fingerprint authoring. diff --git a/.changeset/initial-ghost-scan.md b/.changeset/initial-ghost-scan.md deleted file mode 100644 index c4b7420..0000000 --- a/.changeset/initial-ghost-scan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Bootstrap `ghost-scan` — Ghost's fingerprint.md authoring package. CLI verbs: `lint`, `describe`, `diff` (new — structural prose-level diff), and `emit ` (kinds: review-command, context-bundle, skill). The skill bundle ships the map-aware `fingerprint.md` recipe alongside the condensed schema reference. All four verbs are deterministic; fingerprint is a recipe the host agent executes. Mirrors the BYOA contract that the rest of Ghost follows. diff --git a/.changeset/phase-4b-schema-enrichment.md b/.changeset/phase-4b-schema-enrichment.md deleted file mode 100644 index 32f73d6..0000000 --- a/.changeset/phase-4b-schema-enrichment.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"ghost-scan": minor ---- - -`fingerprint.md`: `surfaces.shadowComplexity` enum value `none` is renamed to `deliberate-none` so the choice reads as a positive design stance rather than as "we forgot." The `unused-palette` lint now also counts hex citations in `roles[]` (palette field bindings + inline references in `evidence` strings), so role-bound colors no longer require name-dropping in decision prose. - -The `none` → `deliberate-none` change is breaking for any `fingerprint.md` setting `shadowComplexity: none`. `ghost-scan` is pre-1.0 and not yet published, so no major bump; existing files should update their value. diff --git a/.changeset/phase-4c-recipe-branching.md b/.changeset/phase-4c-recipe-branching.md deleted file mode 100644 index 6a511ad..0000000 --- a/.changeset/phase-4c-recipe-branching.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Branch the fingerprint recipe by detected repo kind. The recipe now reads `design_system.token_source`, `composition.frameworks`, `registry`, and `platform` from `map.md` and chooses one of three sampling strategies — ui-library (default), token-pipeline (sample at layer level through YAML graph), or consumer-of-external-DS (record upstream slugs and override patterns instead of resolving to hex). Library-mode `feature_areas` guidance now distinguishes component categories from token-architecture layers. No schema changes. diff --git a/.changeset/phase-5-bugs-and-schema.md b/.changeset/phase-5-bugs-and-schema.md deleted file mode 100644 index cb7dbb4..0000000 --- a/.changeset/phase-5-bugs-and-schema.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"ghost-scan": minor ---- - -Phase 5 fixes and schema widening for real-world repo variety. - -Bug fixes (5a): - -- Skill-bundle `schema.md` and `fingerprint.md`: `decisions[].evidence` belongs in the body under `**Evidence:**` bullets, not as a frontmatter array. The condensed reference the LLM loads still showed the old shape; agents following the fingerprint recipe were hitting 10 schema errors on first lint. -- `unused-palette` now propagates slug-bindings: `roles[].tokens.palette.` referencing `{palette.dominant.X}` marks the underlying hex as cited. Phase 4b claimed this; the code only matched literal hexes. - -Schema widenings (5b): - -- `roles[].tokens.palette` is now an open record (`Record`) instead of a fixed three-key object. Conventional vocabulary (`background`, `foreground`, `surface`, `border`, `accent`, `muted`, `link`) is documented in the schema reference and `fingerprint-format.md`; richer slot names (`ring`, `popover`, `separator`, …) no longer hard-error. -- `broken-role-reference` accepts opaque external token refs (`{base.color.brand.x}`, `{semantic.text.on-brand}`, …) without trying to resolve them. Style-Dictionary-style consumer fingerprints can now bind role slots to upstream tokens without the linter rejecting them. diff --git a/.changeset/refactor-fingerprint-package.md b/.changeset/refactor-fingerprint-package.md deleted file mode 100644 index 64b0e0a..0000000 --- a/.changeset/refactor-fingerprint-package.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-scan": major -"ghost-drift": major ---- - -Redefine the canonical fingerprint as `.ghost/fingerprint/` with fingerprint guidance, survey evidence, map routing, checks gates, and deterministic drift checking. diff --git a/.changeset/remove-profile-public-concept.md b/.changeset/remove-profile-public-concept.md deleted file mode 100644 index 356fa3c..0000000 --- a/.changeset/remove-profile-public-concept.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-scan": major -"ghost-drift": major ---- - -Replace the public fingerprint artifact and CLI surface with canonical fingerprint naming. diff --git a/.changeset/rename-evidence-to-survey.md b/.changeset/rename-evidence-to-survey.md deleted file mode 100644 index 5de03cc..0000000 --- a/.changeset/rename-evidence-to-survey.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": major ---- - -Rename the observed design evidence artifact to survey across the schema, CLI, public types, recipes, and generated scan artifacts. diff --git a/.changeset/rename-ghost-scan.md b/.changeset/rename-ghost-scan.md deleted file mode 100644 index 3afafde..0000000 --- a/.changeset/rename-ghost-scan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": major ---- - -Rename the fingerprint authoring package and CLI to `ghost-scan`. diff --git a/.changeset/root-fingerprint-bundle.md b/.changeset/root-fingerprint-bundle.md deleted file mode 100644 index ac39a09..0000000 --- a/.changeset/root-fingerprint-bundle.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"ghost-scan": major -"ghost-drift": major ---- - -Make the root `.ghost/` directory the canonical Ghost fingerprint bundle, replacing `.ghost/fingerprint/fingerprint.md` package flows with resources, survey, patterns, checks, and optional intent artifacts. diff --git a/.changeset/source-graph-scans.md b/.changeset/source-graph-scans.md deleted file mode 100644 index 4b7015a..0000000 --- a/.changeset/source-graph-scans.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Scans can now declare a source graph: one primary subject supplies usage and salience while resolver sources supply concrete values for imported symbols, with survey rows preserving resolution provenance. diff --git a/.changeset/tailwind-class-atom-pass.md b/.changeset/tailwind-class-atom-pass.md deleted file mode 100644 index d760d44..0000000 --- a/.changeset/tailwind-class-atom-pass.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -survey.md now requires a Tailwind class-atom pass for Tailwind targets — class atoms (`p-2`, `bg-orange-500`) get resolved to literals and recorded as survey rows alongside declared `@theme` tokens. Without it, surveys undercount the rendered spacing/typography/color scale because Tailwind synthesizes most of it from `--spacing` / `--text-*` / `--color-*` rather than declaring each step. diff --git a/.changeset/tighten-fingerprint-evidence.md b/.changeset/tighten-fingerprint-evidence.md deleted file mode 100644 index 9cf5c56..0000000 --- a/.changeset/tighten-fingerprint-evidence.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Tighten fingerprint verification and linting so survey-backed evidence is recognized and missing decision evidence is surfaced. diff --git a/.changeset/tighten-terminal-context.md b/.changeset/tighten-terminal-context.md deleted file mode 100644 index 2eaa7cc..0000000 --- a/.changeset/tighten-terminal-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": patch ---- - -Sharpen emitted generation and review context around promoted checks, and add advisory lint hints for under-curated fingerprints. diff --git a/.changeset/update-drift-fingerprint-recap.md b/.changeset/update-drift-fingerprint-recap.md deleted file mode 100644 index 071ea56..0000000 --- a/.changeset/update-drift-fingerprint-recap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-drift": patch ---- - -Update the drift skill's fingerprint recap to reference Signature, direct references, and human-promoted checks. diff --git a/.changeset/v0-checks-and-perceptual-prior.md b/.changeset/v0-checks-and-perceptual-prior.md deleted file mode 100644 index 01a17d4..0000000 --- a/.changeset/v0-checks-and-perceptual-prior.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"ghost-scan": minor ---- - -Add `checks[]` as the v0 authoring surface for drift review and a perceptual-weight prior that calibrates emitted severity. An `fingerprint.md` can now declare human-promoted grep-friendly checks in frontmatter (id, canonical, kind, pattern, optional severity / match / tolerance / observed_count / presence_floor / support); the emitter renders them as a Rams-shaped slash command grouped by Critical / Serious / Nit, with rationale + match shape + observed count + support cited per check and a calibration footer that explains why severities landed where they did. Severity is computed from a fixed perceptual tier (color and font-family are loud, shape and elevation structural, spacing and motion-detail rhythmic) plus presence-floor escalation against the guarded pattern — adding to a silent pattern is louder than tweaking a populated one. Existing `decisions[]`-driven fingerprints continue to emit the previous structured-section layout; the new checks-driven path activates only when `checks[]` is present. Ships a one-liner curl installer at `install/install.sh` so the skill bundle can be installed without npm or a build step; recipes carry prose fallbacks for the most-called CLI verbs (`scan-status`, `inventory`, `lint`) so the no-CLI path is real, not degraded. diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index b9b8535..1fc56ce 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -2,7 +2,7 @@ name: Deploy to GitHub Pages # Builds apps/docs with DEPLOY_BASE=/ghost/ and uploads the dist as the # Pages artifact. apps/docs is the one deployed site — it owns the home, -# the design language catalogue (/ui/*), and the drift tooling docs +# the design language catalogue (/ui/*), and the Ghost tooling docs # (/tools/drift/*) under a single aesthetic. on: @@ -32,8 +32,8 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - name: Build ghost-drift library - run: pnpm --filter ghost-drift build + - name: Build Ghost CLI package + run: pnpm --filter @anarchitecture/ghost build - name: Build ghost-ui library run: pnpm --filter ghost-ui build:lib diff --git a/.github/workflows/release-tarball.yml b/.github/workflows/release-tarball.yml index 909fc70..43f7041 100644 --- a/.github/workflows/release-tarball.yml +++ b/.github/workflows/release-tarball.yml @@ -1,21 +1,21 @@ name: Publish tarball to GitHub Release # Temporary distribution channel while npm publish is blocked. Packs -# ghost-drift and attaches the .tgz to a GitHub Release, so consumers can: +# @anarchitecture/ghost and attaches the .tgz to a GitHub Release, so consumers can: # # npm install https://github.com/block/ghost/releases/download//.tgz # -# Triggered by pushing a tag of the form `ghost-drift@` or by +# Triggered by pushing a tag of the form `anarchitecture-ghost@` or by # manual workflow_dispatch. on: push: tags: - - "ghost-drift@*" + - "anarchitecture-ghost@*" workflow_dispatch: inputs: version: - description: "Version to release (must match packages/ghost-drift/package.json)" + description: "Version to release (must match packages/ghost/package.json)" required: true permissions: @@ -45,7 +45,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build - run: pnpm --filter ghost-drift build + run: pnpm --filter @anarchitecture/ghost build # `pnpm --filter pack` writes the tarball to the workspace root, # not the package dir, in pnpm 10. Force it into a known staging dir so @@ -53,7 +53,7 @@ jobs: - name: Pack run: | mkdir -p dist-tarball - pnpm --filter ghost-drift pack --pack-destination "$GITHUB_WORKSPACE/dist-tarball" + pnpm --filter @anarchitecture/ghost pack --pack-destination "$GITHUB_WORKSPACE/dist-tarball" ls -la dist-tarball # Resolve the release tag. Inputs from workflow_dispatch are attacker- @@ -69,7 +69,7 @@ jobs: if [ "$EVENT_NAME" = "push" ]; then TAG="$GITHUB_REF_NAME" else - TAG="ghost-drift@$INPUT_VERSION" + TAG="anarchitecture-ghost@$INPUT_VERSION" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" @@ -81,4 +81,4 @@ jobs: gh release create "$TAG" \ --title "$TAG" \ --generate-notes \ - dist-tarball/ghost-drift-*.tgz + dist-tarball/*.tgz diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 235794b..86cefa9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,12 +34,12 @@ jobs: - run: pnpm install --frozen-lockfile - - run: pnpm --filter ghost-fingerprint build && pnpm --filter ghost-drift build + - run: pnpm --filter @anarchitecture/ghost build - name: Create Release PR or publish uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1.7.0 with: - publish: pnpm -r publish --access public --no-git-checks + publish: pnpm --filter @anarchitecture/ghost publish --access public --no-git-checks version: pnpm changeset version commit: "chore: version packages" title: "chore: version packages" diff --git a/CLAUDE.md b/CLAUDE.md index de3a245..208cf7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,192 +1,135 @@ # Ghost -Agents can write UI. What they cannot reliably preserve is the identity of the product that UI belongs to. The failure mode is structural: models generate by matching local patterns, so they reproduce components, tokens, and layouts while losing the higher-order decisions that make a surface feel intentional. +Agents can write UI. What they cannot reliably preserve is the product +experience identity behind that UI: hierarchy, density, restraint, repetition, +trust, flow, and the decisions that make a surface feel intentional. -Ghost introduces a second layer: a repository-local, versioned fingerprint that captures the product's composition policy — the constraints, preferences, recurring decisions, and anti-patterns that shape how the design system is actually used. Agents read `fingerprint.md` before generating, compare against it after, and either correct drift or codify the divergence as a deliberate change. A scan runs in three stages — map (`map.md`) → survey (`survey.json`) → express (`fingerprint.md`) — all owned by `ghost-scan`. Four tools plus a reference design system split the loop: - -- **ghost-scan** — authors all three scan artifacts (`map.md`, `survey.json`, `fingerprint.md`); the canonical home of the design language -- **ghost-drift** — when generated UI strays -- **ghost-fleet** — how the language propagates across many projects -- **ghost-ui** — a reference design system to test the loop against +Ghost keeps that memory in a repo-local `.ghost/` bundle. The public npm shape +is one package, `@anarchitecture/ghost`, with one user-facing bin, `ghost`. +The CLI validates, computes, compares, and emits deterministic packets. The +host agent does the interpretive BYOA work through the installed `ghost` skill. ## Build & Run ```bash pnpm install # install dependencies (pnpm 10+, Node 18+) -pnpm build # build all packages (tsc --build, copies skill-bundle to dist) +pnpm build # build all packages +pnpm test # vitest across packages +pnpm check # biome, typecheck, file-size, CLI manifest drift +pnpm dump:cli-help # regenerate apps/docs/src/generated/cli-manifest.json ``` -Run any tool's CLI after building: +Run the public CLI after building: ```bash -node packages/ghost-drift/dist/bin.js -node packages/ghost-scan/dist/bin.js -node packages/ghost-fleet/dist/bin.js -# or via the workspace -pnpm --filter ghost-drift exec ghost-drift -pnpm --filter ghost-scan exec ghost-scan +node packages/ghost/dist/bin.js +pnpm --filter @anarchitecture/ghost exec ghost ``` -## Environment Variables - -No API key is required to run Ghost. The variables below are optional. - -- `OPENAI_API_KEY` / `VOYAGE_API_KEY` — consumed only by `computeSemanticEmbedding` (library function in `@ghost/core`; used when a host writes an `fingerprint.md` and wants an enriched 49-dim vector for paraphrase-robust comparison). -- `GITHUB_TOKEN` — used by `resolveTrackedFingerprint` when fetching a tracked fingerprint from GitHub (avoids rate limits). - -Each CLI auto-loads `.env` and `.env.local` from the working directory. - -## Test & Lint - -```bash -pnpm test # vitest run (across all packages) -pnpm test:watch # vitest watch mode -pnpm check # biome check + typecheck + file-size check + cli-manifest drift check -pnpm fmt # biome format --write -pnpm lint # biome lint -``` - -Pre-commit hook (lefthook): `biome format --write`, `biome check --fix`, `just check`. -Pre-push hook: `just check`, `just test`, `just build` (parallel). - -## Justfile - -Run `just` to list all recipes. Key ones: `setup`, `build`, `check`, `fmt`, `test`, `dev` (docs site at apps/docs), `build-ui` (docs build), `build-lib` (ghost-ui library), `build-registry`, `build-pages`, `clean`, `ci`. - ## Architecture -Ghost is **BYOA (bring-your-own-agent)**. The host agent — Claude Code, Codex, Cursor, Goose, whatever ships next — does the reading, deciding, and writing. The judgement work (fingerprint, review, verify, remediate) lives in [agentskills.io](https://agentskills.io)-compatible skill bundles the agent executes. Ghost's CLIs are the calculator the agent reaches for when it needs a reproducible answer (vector math, schema validation, structural diffs). +Ghost is **BYOA (bring your own agent)**. Claude Code, Codex, Cursor, Goose, or +another host agent reads, decides, and writes. Ghost is the deterministic +calculator the agent reaches for: schema validation, survey transforms, +structural diffs, drift checks, comparison math, and handoff packets. -The repo decomposes into **four tools plus a reference design system**, each with a single responsibility: +The root `.ghost/` bundle follows: -``` -@ghost/core library only — embedding math, target resolver, skill loader, - ghost.map/v2 schema, ghost.survey/v2 schema -ghost-scan scan pipeline (map.md → survey.json → fingerprint.md) - — inventory, lint, describe, diff, survey, emit -ghost-drift drift (.ghost/*) — compare, ack, track, diverge, emit skill -ghost-fleet elevation (fleet.md) — members, view, emit skill -ghost-ui reference design system — 97 shadcn components + MCP server +```text +resources.yml -> map.md -> survey.json -> patterns.yml +what to read where UI lives what exists composition grammar ``` -Dependency flow: `@ghost/core` ← everyone. `ghost-scan` ← `ghost-drift`, `ghost-fleet`. No cycles. +Optional memory lives beside it: -Each tool lives under `packages//` with the same shape: - -- `src/bin.ts` — shebang entry -- `src/cli.ts` — `buildCli()` builder (cac) -- `src/core/` — deterministic library surface, public via `src/core/index.ts` -- `src/skill-bundle/` — `SKILL.md` + `references/*.md` (only tools that ship recipes) -- `test/` — vitest, with `test/fixtures/` for sample data +- `checks.yml` for deterministic gates. +- `intent.md` for human-authored or human-approved product intent. +- `decisions/*.yml` for accepted/rejected product-experience rationale. +- `proposals/*.yml` for staged memory changes before promotion. ## Packages | Package | Published? | Description | -|---------|-----------|-------------| -| `packages/ghost-core` | ❌ private (`@ghost/core`) | Workspace-only library. Embedding math, shared types, target resolution, skill-bundle loader, `ghost.map/v2` schema, `ghost.survey/v2` schema + lint/merge/fix-ids primitives. No CLI. Consumed by every other tool. | -| `packages/ghost-drift` | ✅ `ghost-drift` on npm (v0.2+) | Drift detection. CLI verbs: `compare`, `ack`, `track`, `diverge`, `emit skill`. Skill recipes: `compare.md`, `review.md`, `verify.md`, `remediate.md`. Old `lint`/`describe`/`emit review-command`/`emit context-bundle` stay registered as stub commands that point users to `ghost-scan`. | -| `packages/ghost-scan` | ✅ intended-public (`publishConfig.access: public`, currently v0.0.0) | Owns the root `.ghost/` fingerprint bundle (`resources.yml` → `map.md` → `survey.json` → `patterns.yml`, plus optional `checks.yml` / `intent.md`). CLI verbs: `init-package`, `lint`, `verify`, `inventory`, `describe`, `diff`, `survey `, `emit`. Skill recipes: `scan.md`, `map.md`, `survey.md`, `patterns.md`, `schema.md`. | -| `packages/ghost-fleet` | ❌ private | Read-only elevation across many `(map.md, fingerprint.md)` members. CLI verbs: `members`, `view`, `emit skill`. Skill recipes: `target.md`. | -| `packages/ghost-ui` | ❌ private | Reference component library — 49 UI primitives + 48 AI elements + theme + hooks, distributed via the shadcn `registry.json`, not npm. Also ships the `ghost-mcp` bin (`src/mcp/`, built via `tsconfig.mcp.json` → `dist-mcp/`) — an MCP server re-exposing the registry to AI assistants (5 tools, 2 resources). | -| `apps/docs` | ❌ private | The deployed docs site (`ghost-docs`) — home, drift tooling docs, design language foundations, live component catalogue. Consumes `ghost-ui`. | +| --- | --- | --- | +| `packages/ghost` | yes: `@anarchitecture/ghost` | Unified public package. Ships the `ghost` CLI, scan/memory authoring, checks, advisory review packets, comparison, drift stance verbs, and the unified skill bundle. | +| `packages/ghost-core` | no | Private historical shared package. Runtime code needed by npm is folded into `packages/ghost/src/ghost-core`. | +| `packages/ghost-scan` | no | Private historical scan package. Runtime code needed by npm is folded into `packages/ghost/src/scan`. | +| `packages/ghost-fleet` | no | Private fleet view across many Ghost bundles. Consumes workspace exports from `@anarchitecture/ghost`. | +| `packages/ghost-ui` | no | Reference design system: shadcn registry plus `ghost-mcp` MCP server. | +| `apps/docs` | no | Docs site. | ## CLI Commands -Verbs are scoped to the tool that owns the artifact. The full surface across all three tools: - -| Tool | Command | Description | -|------|---------|-------------| -| `ghost-scan` | `inventory [path]` | Emit raw repo signals (manifests, language histogram, registry, top-level tree, git remote) as JSON. Feeds the topology recipe. | -| `ghost-scan` | `scan-status [dir]` | Report which scan stages have produced artifacts (`resources.yml` / `map.md` / `survey.json` / `patterns.yml`) and which stage to run next. | -| `ghost-scan` | `lint [file]` | Validate a root `.ghost/` bundle or a single artifact — auto-detects the kind from path/content. | -| `ghost-scan` | `verify [dir] --root ` | Verify cross-artifact fidelity: pattern evidence exists in survey, resources are reachable, and checks reference known scopes/patterns. | -| `ghost-scan` | `describe [fingerprint]` | Print section ranges + token estimates (so agents can selectively load). | -| `ghost-scan` | `diff ` | Structural prose-level diff between fingerprints (decisions + palette roles). **Not** vector distance. | -| `ghost-scan` | `survey [...surveys]` | Operate on `ghost.survey/v2` files. Ops: `merge` (concat with id-based dedup), `fix-ids` (recompute IDs from content). | -| `ghost-scan` | `emit ` | Derive an artifact from `fingerprint.md`: `review-command`, `context-bundle`, or `skill`. | -| `ghost-drift` | `compare [...fingerprints]` | Pairwise (N=2) or composite (N≥3) over fingerprint embeddings. `--semantic`, `--temporal`. | -| `ghost-drift` | `ack` | Record a stance toward the tracked fingerprint in `.ghost-sync.json`. | -| `ghost-drift` | `track ` | Shift the tracked fingerprint. | -| `ghost-drift` | `diverge ` | Declare intentional divergence on a dimension. | -| `ghost-drift` | `emit skill` | Install the `ghost-drift` agentskills.io bundle. | -| `ghost-fleet` | `members [dir]` | List registered fleet members + freshness. | -| `ghost-fleet` | `view [dir]` | Compute pairwise distances + group-by tables; emit `fleet.md` + `fleet.json`. | -| `ghost-fleet` | `emit skill` | Install the `ghost-fleet` agentskills.io bundle. | - -**Workflows (agent recipes).** Each tool ships its own skill-bundle references under `packages//src/skill-bundle/references/`. These are the agent's job, not CLI verbs: - -- **Scan** (orchestrate map → survey → fingerprint end-to-end) — `ghost-scan/.../scan.md` -- **Map** (write `map.md` from a repo, the topology stage) — `ghost-scan/.../map.md` -- **Survey** (write `survey.json` from a target, the observed evidence stage) — `ghost-scan/.../survey.md` -- **Fingerprint** (interpret a `survey.json` into `fingerprint.md`, the fingerprint stage) — `ghost-scan/.../fingerprint.md` -- **Review** (flag drift in PR changes) — `ghost-drift/.../review.md` -- **Verify** (generate → review loop) — `ghost-drift/.../verify.md` -- **Compare interpretation** — `ghost-drift/.../compare.md` -- **Remediate** (suggest minimal fixes for drift) — `ghost-drift/.../remediate.md` -- **Fleet narrative** (synthesize `fleet.md` prose from CLI output) — `ghost-fleet/.../target.md` - -## Target Types - -The `resolveTarget()` function in `@ghost/core` (`packages/ghost-core/src/target-resolver.ts`) accepts: - -- `github:owner/repo` — GitHub repository -- `npm:package-name` — npm package -- `figma:file-url` — Figma file -- `./path` or `/absolute/path` — local directory -- `https://...` — URL -- `.` — current directory - -Used by `resolveTrackedFingerprint` (in `ghost-drift`) and legacy library consumers. Fingerprint and map flows don't consume targets directly — the host agent explores whatever directory is relevant. - -## Canonical artifacts - -Three artifacts produced in sequence by a scan, all owned by `ghost-scan`: - -- **`map.md`** — the topology card (stage 1). Human-readable answer to "where is the design system, which folders matter, and where are implemented surfaces observable?" Schema is `ghost.map/v2` (lives in `@ghost/core`), validated by `ghost-scan lint map.md`. Authored from `ghost-scan inventory` + the `map.md` skill recipe. The repo's own `map.md` lives at the root. -- **`survey.json`** — the observed evidence scan (stage 2). Catalogues every concrete design value (colors, spacings, typography, radii, shadows, breakpoints, motion, layout primitives) plus tokens, components, and implemented UI surfaces observed in the target. Each row carries occurrence counts and a deterministic content-hashed `id`. Schema is `ghost.survey/v2` (lives in `@ghost/core`); four sections — `values`, `tokens`, `components`, `ui_surfaces`. External libraries (icons, primitives, charting) deliberately *do not* have a survey section — whether a system uses Radix or hand-rolls primitives doesn't change what its design language *is*; load-bearing library choices surface as prose evidence in the interpreter stage. Validated by `ghost-scan lint survey.json`. Authored via the `survey.md` skill recipe. -- **`fingerprint.md`** — the design language (stage 3, terminal). Human-readable, LLM-editable, with YAML frontmatter (machine layer: references + 49-dim embedding + palette/spacing/typography/surfaces/checks) and a three-section prose body (Character → Signature → Decisions). Authored by interpreting `survey.json` per the `fingerprint.md` skill recipe. See `docs/fingerprint-format.md` for the full spec; the condensed reference ships at `packages/ghost-scan/src/skill-bundle/references/schema.md`. +| Command | Description | +| --- | --- | +| `ghost init` | Create `.ghost/{resources.yml,map.md,survey.json,patterns.yml,checks.yml}`. | +| `ghost scan` | Report scan state and BYOA next-step guidance. | +| `ghost inventory` | Emit raw repo signals as JSON for map authoring. | +| `ghost lint` | Validate a bundle or single artifact. | +| `ghost verify` | Validate resource reachability, pattern evidence, checks, and optional memory. | +| `ghost describe` | Print optional `intent.md` or direct markdown section ranges. | +| `ghost diff` | Structural prose-level diff between direct fingerprints. | +| `ghost survey ` | `merge`, `fix-ids`, `summarize`, `catalog`, or `patterns` over `ghost.survey/v2`. | +| `ghost check` | Run active `ghost.checks/v1` deterministic gates against a diff. | +| `ghost review` | Emit an evidence-routed advisory review packet. | +| `ghost compare` | Pairwise or composite comparison over bundles or direct fingerprints. | +| `ghost ack` | Record stance toward the tracked fingerprint in `.ghost-sync.json`. | +| `ghost track` | Shift the tracked fingerprint. | +| `ghost diverge` | Declare intentional divergence on a dimension. | +| `ghost emit ` | Emit `review-command` or `context-bundle`. | +| `ghost skill install` | Install the unified `ghost` agentskills.io bundle. | + +`ghost scan --format json` is deterministic handoff state for the host agent. +It does not run an LLM. + +## Public Exports + +- `@anarchitecture/ghost` for the combined surface. +- `@anarchitecture/ghost/scan` for scan and bundle helpers. +- `@anarchitecture/ghost/drift` for check/review/compare/stance helpers. +- `@anarchitecture/ghost/core` for shared schemas, types, and loaders. +- `@anarchitecture/ghost/cli` for `buildCli()`. -## Releasing & Changesets +## Environment Variables -`ghost-drift` is the only currently-published package. `ghost-scan` is set up to publish (`publishConfig.access: public`); `ghost-fleet` is private workspace-only for now. Releases go through [Changesets](https://github.com/changesets/changesets); the `.github/workflows/release.yml` workflow opens a "Version Packages" PR whenever pending changesets are on `main`, and publishes to npm when that PR merges. +No API key is required to run Ghost. Optional variables: -The Changesets config ignores private packages (`@ghost/core`, `ghost-fleet`, `ghost-ui`, `apps/docs`) — they don't appear in version PRs. +- `OPENAI_API_KEY` / `VOYAGE_API_KEY` are consumed only by semantic embedding + helpers when a host opts into enriched comparison. +- `GITHUB_TOKEN` helps resolve tracked fingerprints from GitHub without rate + limit surprises. -**When you (the agent) complete a user-visible change to a published package, write a changeset file yourself instead of asking the user to run `pnpm changeset`.** Create `.changeset/.md` with this shape: +Each CLI auto-loads `.env` and `.env.local` from the working directory. -```markdown ---- -"ghost-drift": patch ---- +## Releasing & Changesets -One sentence, user-facing, present tense. What changed from the user's POV — not "refactor the X module." -``` +`@anarchitecture/ghost` is the only public package. Private packages are +ignored by Changesets. -Multiple packages can be bumped in one changeset: +When an agent completes a user-visible change to the public package, write a +changeset file instead of asking the user to run `pnpm changeset`: ```markdown --- -"ghost-drift": patch -"ghost-scan": minor +"@anarchitecture/ghost": patch --- -``` - -Guidance on the bump level: - -- **`patch`** — bug fixes, doc fixes, non-breaking internal refactors. The default; when in doubt, pick this. -- **`minor`** — new CLI verb, new flag, new library export, new capability. Anything a user might want to reach for. -- **`major`** — removed/renamed CLI verb, removed/renamed library export, changed default behavior, breaking fingerprint schema change, changed exit codes. **Always flag this explicitly in the PR description and ask the user to confirm — do not `major`-bump unreviewed.** -Skip the changeset entirely for: CI/workflow-only changes, test-only changes, changes scoped to private packages. - -The slug should be short and descriptive: `add-temporal-flag.md`, `fix-palette-lint-crash.md`. Avoid dates or PR numbers — Changesets consumes and deletes the file at version time. - -## Key Conventions +One sentence, user-facing, present tense. +``` -- The canonical on-disk form is the root `.ghost/` bundle. Direct `fingerprint.md` remains only for legacy/direct compare and context-bundle flows. -- `ghost-drift compare` accepts `.ghost` bundle directories and direct fingerprint markdown files. Mode auto-detects from N and flags: `--semantic` / `--temporal` require N=2; N≥3 returns a composite fingerprint. -- `ghost-drift ack` / `track` / `diverge` read the local `fingerprint.md`. The host agent is responsible for regenerating `fingerprint.md` (via the `fingerprint` recipe) before acknowledging drift. -- `ghost-scan lint` takes a single `fingerprint.md` and reports schema/partition violations. Use as the shape gate when authoring a fingerprint. -- `ghost-scan verify .ghost --root .` is the required scan-stage fidelity gate after bundle authoring. -- `ghost-scan lint ` validates against `ghost.map/v2` (auto-detected by frontmatter or filename). Use as the success gate when authoring a map. -- The CLI manifest at `apps/docs/src/generated/cli-manifest.json` is auto-generated by `pnpm dump:cli-help`. CI guards drift via `pnpm check:cli-manifest`. Re-run `pnpm dump:cli-help` after adding/removing flags or verbs to any tool. +Use `patch` for fixes and docs, `minor` for new commands/flags/exports, and +`major` for removed or renamed public behavior. For this PR 81 package-shape +change, the source version stays `0.0.0` and the changeset is `minor` so the +first publish becomes `0.1.0`. + +## Conventions + +- Keep publishable runtime code self-contained in `packages/ghost`; no + `workspace:*` runtime dependencies in the packed public artifact. +- The canonical on-disk form is the root `.ghost/` bundle. Direct + `fingerprint.md` remains for legacy/direct compare and context-bundle flows. +- Skill recipes live in `packages/ghost/src/skill-bundle/references/`; install + them with `ghost skill install`. +- The CLI manifest at `apps/docs/src/generated/cli-manifest.json` is generated + by `pnpm dump:cli-help`. Re-run it after adding, removing, or renaming CLI + commands or flags. diff --git a/README.md b/README.md index f33afe6..92e567d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,14 @@ # Ghost -**Ghost gives agents auditable, repo-local memory for product experience.** +**Ghost gives agents repo-local design memory for product experience.** -Agents can write UI. What they cannot reliably preserve is the thought behind the product experience they are changing. +Agents can write UI. What they cannot reliably preserve is the thought behind +the product experience they are changing: hierarchy, density, restraint, +repetition, refusal, reversibility, trust, and flow. Ghost captures that second +layer as a versioned `.ghost/` fingerprint bundle that agents can read before +generation and validate after changes. -The failure mode is structural. Large language models generate by matching local patterns. They reproduce components, tokens, and layouts, but they do not consistently preserve the higher-order decisions that make a surface feel intentional: hierarchy, density, restraint, repetition, refusal, reversibility, trust, and flow. - -Most design systems encode product inventory: colors, type scales, components. That inventory is necessary, but not sufficient. The same system can produce many different products. What is missing is the policy that governs how those parts are composed. - -Ghost introduces that second layer as a repository-local, versioned fingerprint. It captures the product's composition policy — the constraints, preferences, recurring decisions, and anti-patterns that shape how the system is actually used. It does not replace the design system; it conditions it. - -The broader boundary is product experience: anything that shapes how the product is perceived, used, trusted, understood, or safely changed. Ghost keeps that memory evidence-backed and reviewable instead of hiding it in chat history. - -The scan earns that package with evidence: +The bundle is evidence-first: - **`.ghost/resources.yml`** declares the references that define the product. - **`.ghost/map.md`** routes changes to repo scopes and surfaces. @@ -23,290 +19,131 @@ The scan earns that package with evidence: - **`.ghost/decisions/*.yml`** optionally records accepted/rejected product-experience rationale. - **`.ghost/proposals/*.yml`** optionally stages candidate memory changes before promotion. -Specs describe what exists. The fingerprint bundle describes how the product repeatedly chooses to use what exists. Survey grounds it; patterns operationalize composition; optional checks fail builds; optional intent preserves the human voice; optional decisions and proposals capture the why behind product-experience changes. - -## Works with your agent - -Every Ghost workflow runs in the host agent you already use: Claude Code, Codex, Cursor, Goose, or another agent. Each tool ships an [agentskills.io](https://agentskills.io)-compatible recipe bundle for work like scan, review, verify, remediate, or summarize a fleet. The agent reads the files, makes the calls, and writes the outputs. When it needs a reproducible answer, such as schema validation or fingerprint distance, it calls a Ghost CLI. - -No API key is required to run any CLI verb. Each tool's `emit skill` verb installs its bundle into your agent. - -## Why Ghost? - -Ghost gives agents a few practical abilities: - -- **Generate from repo-local memory**: `.ghost/patterns.yml`, `.ghost/survey.json`, and optional `.ghost/intent.md` tell the agent how the product composes UI before it writes. -- **Fail deterministic drift**: `ghost-drift check` applies active `checks.yml` gates to a diff. -- **Review changes with evidence**: `ghost-drift review` emits an advisory packet grounded in patterns, survey, optional intent, checks, and diff. -- **Recall product-experience memory**: `ghost-scan` helps agents brief, critique, capture, and promote optional decisions/proposals without making them deterministic gates. -- **Compare systems**: `ghost-drift compare` and `ghost-fleet view` show how fingerprints differ across projects. -- **Record intent**: `ack`, `track`, and `diverge` record whether drift is accepted, tracked against a new reference, or intentionally different. -- **Stay readable**: `map.md` and optional `intent.md` are Markdown, `survey.json` is factual evidence, `patterns.yml` is operational grammar, and `checks.yml` is the human-curated gate layer. - -## Tools around the loop - -Ghost is split into focused tools. The common path is simple: - -```text -.ghost/resources.yml -> map.md -> survey.json -> patterns.yml -> check/review -``` - -| Tool | Job | Verbs | -| --- | --- | --- | -| **`ghost-scan`** | Create and check the root `.ghost/` bundle. | `init-package`, `inventory`, `lint`, `verify`, `describe`, `diff`, `survey `, `emit` | -| **`ghost-drift`** | Run deterministic checks, emit advisory review packets, compare fingerprints, and record what changed intentionally. | `check`, `review`, `compare`, `ack`, `track`, `diverge`, `emit skill` | -| **`ghost-fleet`** | See how many project fingerprints relate. | `members`, `view`, `emit skill` | -| **`ghost-ui`** | Reference design system Ghost dogfoods — 97 shadcn components + an MCP server. | (no verbs) | - -Scans describe one subject, but they can read more than one source. For example, an app can read tokens from an upstream design-system package while still producing a fingerprint about the app's actual UI. - -`@ghost/core` underneath is a workspace-only library with embedding math, target resolution, skill-bundle loader, and the `ghost.map/v2` + `ghost.survey/v2` schemas the three CLIs share. - -## Repo layout - -Ghost is a pnpm monorepo. Four tools, one reference design system, one docs site. - -| Path | Role | Published? | -| ---- | ---- | --- | -| [`packages/ghost-core`](./packages/ghost-core) | Workspace-only shared library — embedding math, target resolver, skill loader, `ghost.resources/v1`, `ghost.map/v2`, `ghost.survey/v2`, `ghost.patterns/v1`, `ghost.checks/v1`, `ghost.decision/v1`, and `ghost.proposal/v1` schemas. | ❌ private (`@ghost/core`) | -| [`packages/ghost-scan`](./packages/ghost-scan) | The root bundle pipeline: `.ghost/{resources.yml,map.md,survey.json,patterns.yml}` plus optional checks and intent. Authoring, lint, verify, describe, diff, survey ops, emit. | ✅ intended-public on npm | -| [`packages/ghost-drift`](./packages/ghost-drift) | Deterministic check, advisory review, comparison, and stance verbs. | ✅ `ghost-drift` on npm | -| [`packages/ghost-fleet`](./packages/ghost-fleet) | Fleet view across many members. | ❌ private | -| [`packages/ghost-ui`](./packages/ghost-ui) | Reference design system: 97 shadcn components + the `ghost-mcp` MCP server. | ❌ private (distributed via shadcn registry, not npm) | -| [`apps/docs`](./apps/docs) | Deployed docs site (`ghost-docs`). | ❌ private | - -Dependency flow: `@ghost/core` ← everyone. `ghost-scan` ← `ghost-drift`, `ghost-fleet`. No cycles. - -## Quick install +## Install -If you just want the design-language scan + emit recipes installed into your host agent — no Node, no pnpm, no build: +The public npm package is **`@anarchitecture/ghost`**. It installs one CLI: +**`ghost`**. ```bash -curl -fsSL https://raw.githubusercontent.com/block/ghost/main/install/install.sh | sh +npm install -D @anarchitecture/ghost +npx ghost --help ``` -The installer detects your agent (`claude` / `cursor` / `codex` / `opencode`), drops the `ghost` skill bundle into the right skills directory (e.g. `~/.claude/skills/ghost/`), and tells you what to do next. Pass `--agent claude` (or `--dest `) to override detection. Re-run with `--force` to upgrade. - -After install, in any repo: - -``` -> Scan this project with ghost -``` - -The agent walks `.ghost/resources.yml` → `map.md` → `survey.json` → `patterns.yml` + optional `checks.yml` / `intent.md`, then checks or reviews UI changes against that bundle. The recipes work without any Ghost CLI on PATH — every CLI-using step has a prose fallback. - -If you want the CLI helpers for linting, fingerprint verification, diffing, comparing, and fleet views, install from source instead. See *Getting Started* below. - -## Getting Started - -### Prerequisites - -- Node.js 18+ -- [pnpm](https://pnpm.io/) 10+ - -### Install +Install the unified BYOA skill bundle: ```bash -pnpm install -pnpm build +npx ghost skill install +# or choose an explicit destination +npx ghost skill install --dest ~/.codex/skills/ghost ``` -### Install the skill bundles into your host agent - -Each tool ships its own bundle. Install whichever you need. +After that, ask your agent in plain English: -```bash -ghost-drift emit skill # → ./.claude/skills/ghost-drift -ghost-scan emit skill # → ./.claude/skills/ghost-scan -ghost-fleet emit skill # → ./.claude/skills/ghost-fleet -``` - -Once a skill is installed, ask your agent in plain English ("scan this project with Ghost", "review this PR for drift", "compute the fleet view") and it'll follow the recipe, calling the relevant CLI whenever it needs a reproducible answer. - -### Quick start - -**0. Initialize the package**: - -```bash -ghost-scan init-package -``` - -**1. Map the repo**. Ask your host agent to write `.ghost/map.md`, then validate: - -```bash -ghost-scan inventory -ghost-scan lint .ghost -``` - -**2. Survey the design values and composition observations**. Ask your host agent to write `.ghost/survey.json`, then validate: - -```bash -ghost-scan survey fix-ids .ghost/survey.json -o .ghost/survey.json -ghost-scan lint .ghost -``` - -**3. Codify patterns and promote checks** — ask your host agent to write `.ghost/patterns.yml` and propose lintable checks. Humans promote durable gates into `.ghost/checks.yml`: - -```bash -ghost-scan survey patterns .ghost/survey.json -o .ghost/patterns.yml -ghost-scan verify .ghost --root . -ghost-scan lint +```text +Scan this project with Ghost. +Review this PR for Ghost drift. +Compare these two Ghost bundles. +Brief this work from Ghost memory. ``` -**4. Check or review a diff**: +## Core Workflow ```bash -ghost-drift check --base main -ghost-drift check --diff change.patch --format json -ghost-drift review --base main -``` +# 0. Create the bundle skeleton +ghost init --with-intent -**5. Compare fingerprints:** +# 1. Ask your agent to map the repo, then validate +ghost inventory +ghost lint .ghost -```bash -# Pairwise: per-dimension distance -ghost-drift compare market/.ghost dashboard/.ghost +# 2. Ask your agent to survey values and composition evidence +ghost survey fix-ids .ghost/survey.json -o .ghost/survey.json +ghost lint .ghost -# Add qualitative interpretation of decisions + palette -ghost-drift compare a.md b.md --semantic +# 3. Derive patterns and verify evidence +ghost survey patterns .ghost/survey.json -o .ghost/patterns.yml +ghost verify .ghost --root . +ghost lint .ghost -# Add velocity / trajectory (reads .ghost/history.jsonl) -ghost-drift compare before.md after.md --temporal +# 4. Check or review changes +ghost check --base main +ghost review --base main --include-memory -# Composite (N≥3): pairwise matrix, centroid, clusters — the org fingerprint -ghost-drift compare */.ghost -``` +# 5. Compare fingerprints or bundles +ghost compare market/.ghost dashboard/.ghost +ghost compare a.md b.md --semantic +ghost compare before.md after.md --temporal +ghost compare */.ghost -**6. Track intent toward another fingerprint:** +# 6. Record intentional drift +ghost ack --stance aligned --reason "Initial baseline" +ghost track new-tracked.fingerprint.md +ghost diverge typography --reason "Editorial product uses a different type scale" -```bash -ghost-drift ack --stance aligned --reason "Initial baseline" -ghost-drift track new-tracked.fingerprint.md -ghost-drift diverge typography --reason "Editorial product uses a different type scale" +# 7. Emit derived context +ghost emit review-command +ghost emit context-bundle ``` -**7. Emit derived outputs**: +`ghost scan --format json` emits deterministic BYOA state: which artifacts are +present, which stage is next, and enough structure for the host agent to choose +the next recipe. It does not call an LLM. -```bash -ghost-scan emit review-command # .claude/commands/design-review.md (per-project slash command) -ghost-scan emit context-bundle # ghost-context/ (SKILL.md + fingerprint context + prompt.md + tokens.css) -ghost-scan emit skill # .claude/skills/ghost-scan (the agentskills.io bundle) -``` +## CLI Commands -**8. View a fleet** (when you have ≥2 members each with their own package/fingerprint): +| Command | Description | +| --- | --- | +| `ghost init` | Create `.ghost/{resources.yml,map.md,survey.json,patterns.yml,checks.yml}`. | +| `ghost scan` | Report scan state and emit the next BYOA handoff. | +| `ghost inventory` | Emit raw repo signals as JSON for map authoring. | +| `ghost lint` | Validate a bundle or individual artifact. | +| `ghost verify` | Validate resource reachability, pattern evidence, checks, and optional memory. | +| `ghost describe` | Print optional `intent.md` or direct markdown section ranges + token estimates. | +| `ghost diff` | Structural prose-level diff between direct fingerprints. | +| `ghost survey ` | Operate on `ghost.survey/v2` files: `merge`, `fix-ids`, `summarize`, `catalog`, `patterns`. | +| `ghost check` | Run active `ghost.checks/v1` deterministic gates against a diff. | +| `ghost review` | Emit an evidence-routed advisory review packet. | +| `ghost compare` | Pairwise or composite comparison over bundles or direct fingerprints. | +| `ghost ack` | Record stance toward the tracked fingerprint in `.ghost-sync.json`. | +| `ghost track` | Shift the tracked fingerprint. | +| `ghost diverge` | Declare intentional divergence on a dimension. | +| `ghost emit ` | Emit `review-command` or `context-bundle`. | +| `ghost skill install` | Install the unified `ghost` agentskills.io bundle. | + +## Repo Layout + +Ghost is a pnpm monorepo. The public package is self-contained for npm; private +workspace packages remain only for historical/development context. -```bash -ghost-fleet members ./fleet # list registered members + freshness -ghost-fleet view ./fleet # emit fleet.md + fleet.json with pairwise matrix, centroid, clusters -``` +| Path | Role | Published? | +| ---- | ---- | --- | +| [`packages/ghost`](./packages/ghost) | Unified public package. Ships the `ghost` CLI, scan/memory authoring, deterministic checks, advisory review packets, comparison, stance tracking, and the unified skill bundle. | yes: `@anarchitecture/ghost` | +| [`packages/ghost-core`](./packages/ghost-core) | Private historical shared library. Runtime code is folded into `packages/ghost` for publishing. | no | +| [`packages/ghost-scan`](./packages/ghost-scan) | Private historical scan package. Runtime code is folded into `packages/ghost` for publishing. | no | +| [`packages/ghost-fleet`](./packages/ghost-fleet) | Private fleet view across many members. | no | +| [`packages/ghost-ui`](./packages/ghost-ui) | Reference design system: shadcn registry + `ghost-mcp` MCP server. | no | +| [`apps/docs`](./apps/docs) | Docs site. | no | -**Run the docs site locally:** +## Development ```bash -just dev -# or: pnpm -F ghost-docs dev +pnpm install +pnpm build +pnpm test +pnpm check +pnpm dump:cli-help +pnpm --filter @anarchitecture/ghost pack ``` -## CLI Commands - -Commands are grouped by the tool that owns the file. Pure inputs → pure outputs, no API key required. - -| Tool | Command | Description | -| --- | --- | --- | -| `ghost-scan` | `inventory` | Emit raw repo signals (manifests, language histogram, registry presence, top-level tree, git remote) as JSON. Feeds the map recipe. | -| `ghost-scan` | `init-package` | Create `.ghost/{resources.yml,map.md,survey.json,patterns.yml,checks.yml}`. | -| `ghost-scan` | `lint` | Validate the fingerprint bundle or an individual artifact. | -| `ghost-scan` | `verify` | Validate cross-artifact fidelity: resources, pattern evidence, and check references. | -| `ghost-scan` | `describe` | Print optional `intent.md` or direct markdown section ranges + token estimates. | -| `ghost-scan` | `diff` | Structural prose-level diff between two fingerprints (NOT vector distance — for that, use `ghost-drift compare`). | -| `ghost-scan` | `survey ` | Operate on `ghost.survey/v2` files. Ops: `merge`, `fix-ids`, `summarize`, `catalog`, `patterns`. | -| `ghost-scan` | `emit` | Derive an output from direct fingerprint markdown or install the skill bundle. | -| `ghost-drift` | `check` | Run active `ghost.checks/v1` deterministic gates against a diff; exits nonzero on failures. | -| `ghost-drift` | `review` | Emit an evidence-routed advisory review packet; findings are non-blocking unless tied to active checks. | -| `ghost-drift` | `compare` | Pairwise (N=2) or composite (N≥3) over runtime fingerprint embeddings. `--semantic` / `--temporal` add qualitative enrichment. | -| `ghost-drift` | `ack` | Record stance toward the tracked fingerprint in `.ghost-sync.json`. | -| `ghost-drift` | `track` | Shift the tracked fingerprint. | -| `ghost-drift` | `diverge` | Declare intentional divergence on a dimension. | -| `ghost-drift` | `emit skill` | Install the `ghost-drift` agentskills.io bundle. | -| `ghost-fleet` | `members` | List registered fleet members + freshness. | -| `ghost-fleet` | `view` | Compute pairwise distances + group-by tables; emit `fleet.md` + `fleet.json`. | -| `ghost-fleet` | `emit skill` | Install the `ghost-fleet` agentskills.io bundle. | - -### Skill recipes: run by the host agent - -The interpretive work is done by recipes the agent runs. Install the relevant bundle once, then ask in plain English. Each tool ships its own recipes. - -| Recipe | Bundle | Capability | Triggered by | -| --- | --- | --- | --- | -| `map` | `ghost-scan` | Write the repo map (stage 1) | "map this repo", "write map.md" | -| `survey` | `ghost-scan` | Author the survey of values (stage 2) | "survey design values", "extract design tokens" | -| `patterns` | `ghost-scan` | Author operational composition grammar (stage 4) | "write patterns.yml", "codify composition patterns" | -| `review` | `ghost-drift` | Review PR changes for drift | "review this PR for drift" | -| `verify` | `ghost-drift` | Check generated UI against the fingerprint | "verify generated UI against the fingerprint" | -| `compare` | `ghost-drift` | Compare fingerprints | "why did these two fingerprints drift?" | -| `remediate` | `ghost-drift` | Suggest minimal fixes for drift | "fix this drift" | -| `target` | `ghost-fleet` | Synthesize fleet narrative | "describe this fleet" | -| `recall` / `brief` / `critique` / `capture` / `promote` | `ghost-scan` | Activate and capture product-experience memory | "what does Ghost remember?", "brief this work", "capture this decision" | - -These are instructions, not code. The agent executes them using its normal tools (file search, reading, editing) plus the relevant Ghost CLI when it needs a reproducible answer. (`discover` and `generate` are intentionally not migrated — see [`docs/ideas/phase-0-decisions.md`](./docs/ideas/phase-0-decisions.md).) - -## Configuration - -`ghost.config.ts` is optional — only `ghost-drift ack` and `ghost-drift diverge` consult it (to locate the tracked fingerprint). Everything else is zero-config. - -### Environment variables - -- `OPENAI_API_KEY` / `VOYAGE_API_KEY`: optional, consumed by `computeSemanticEmbedding` (a `@ghost/core` library function) when a host wants enriched semantic comparison. -- `GITHUB_TOKEN`: optional, used by `resolveTrackedFingerprint` when fetching a tracked fingerprint from GitHub (avoids rate limits). - -Each CLI auto-loads `.env` and `.env.local` from the working directory. - -## How It Works - -### The fingerprint - -What the agent reads when it writes or reviews UI is the **fingerprint bundle**: - -- **`map.md`**: where surfaces live and how changed files route to scopes. -- **`survey.json`**: factual observed evidence. -- **`patterns.yml`**: operational composition grammar. It shapes advisory review but never fails CI by itself. -- **`intent.md`**: optional human-authored or human-approved product intent. -- **`checks.yml`**: optional human-promoted enforceable gates. These are the only blocking mechanism in v1. -- **`decisions/*.yml`**: optional accepted/rejected product-experience rationale. -- **`proposals/*.yml`**: optional candidate memory changes. Proposals are never canonical until promoted. - -Generate one with the `scan` and `patterns` recipes (in the `ghost-scan` skill bundle). See [`docs/fingerprint-format.md`](./docs/fingerprint-format.md) for the full spec. - -### The map - -What Ghost uses during scan and drift workflows to understand the repo. **`.ghost/map.md`** is the topology stage of a scan. It records languages, build system, package manifests, registry files, design-system paths, observable surfaces, feature areas, and scopes. Deterministic drift starts by routing changed files through this map. - -Generate one with the `map` recipe (in the `ghost-scan` skill bundle). The agent reads `ghost-scan inventory` (raw repo signals as JSON) and writes the short body. - -### Author + Review Loop - -The loop is simple: the agent writes UI, `ghost-drift check` fails active gates, `ghost-drift review` provides advisory critique grounded in evidence, and a human or agent decides what to do next. Fix the drift, accept it, track a new fingerprint, promote a durable rule into `checks.yml`, or capture a candidate product-experience memory proposal. See [`docs/generation-loop.md`](./docs/generation-loop.md) for details. - -### Remediation - -Three files record what happened: - -- **`.ghost/`**: The repo-local design memory bundle. -- **`.ghost-sync.json`**: Per-dimension stances toward the tracked fingerprint (aligned, accepted, or diverging), each with recorded reasoning. Written by `ghost-drift ack` / `track` / `diverge`. -- **`.ghost/history.jsonl`**: Append-only fingerprint history for temporal analysis. Read by `ghost-drift compare --temporal`. - -### Org-scale observability - -To look across many projects: - -- **Many bundles, no fleet**: run `ghost-drift compare` with three or more `.ghost` bundle directories. It returns pairwise distances, a centroid, and similarity clusters. -- **A registered fleet** (members each with a fingerprint bundle): run `ghost-fleet view`. It adds groupings such as platform, build system, and design-system status. +No API key is required to run Ghost. `OPENAI_API_KEY` / `VOYAGE_API_KEY` are +optional and only used by semantic embedding helpers when a host opts in. +`GITHUB_TOKEN` is optional for resolving tracked fingerprints from GitHub. -## Project Resources +## Resources -| Resource | Description | -| ------------------------------------ | --------------------------- | -| [CODEOWNERS](./CODEOWNERS) | Project lead(s) | -| [CONTRIBUTING.md](./CONTRIBUTING.md) | How to contribute | -| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | -| [LICENSE](./LICENSE) | Apache License, Version 2.0 | +| Resource | Description | +| --- | --- | +| [docs/fingerprint-format.md](./docs/fingerprint-format.md) | Root `.ghost/` bundle format | +| [docs/generation-loop.md](./docs/generation-loop.md) | Generate, check, review, and remediate loop | +| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | +| [LICENSE](./LICENSE) | Apache License, Version 2.0 | diff --git a/apps/docs/README.md b/apps/docs/README.md index 3678374..4768727 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -2,7 +2,7 @@ **Documentation site for the Ghost project.** -`ghost-docs` is the deployed docs for everything in this monorepo — the `ghost-drift` CLI, the fingerprint format, the design language foundations, and the live `ghost-ui` component catalogue. A Vite + MDX app that consumes [`ghost-ui`](../../packages/ghost-ui) as a workspace dependency. +`ghost-docs` is the deployed docs for everything in this monorepo: the `ghost` CLI, the fingerprint format, the design language foundations, and the live `ghost-ui` component catalogue. A Vite + MDX app that consumes [`ghost-ui`](../../packages/ghost-ui) as a workspace dependency. ## Run diff --git a/apps/docs/src/app/tools/drift/page.tsx b/apps/docs/src/app/tools/drift/page.tsx index f2cab9b..0298a5a 100644 --- a/apps/docs/src/app/tools/drift/page.tsx +++ b/apps/docs/src/app/tools/drift/page.tsx @@ -28,7 +28,7 @@ const cards: { }, { name: "CLI reference", - href: "/docs/cli#ghost-drift--review-and-compare", + href: "/docs/cli#ghost--review-and-compare", description: "Run checks, emit advisory review, compare fingerprints, and record intent.", icon: , @@ -45,9 +45,9 @@ export default function GhostDriftLanding() { return (
, }, { - name: "ghost-drift", + name: "ghost review", href: "/tools/drift", blurb: "Review UI drift", icon: , @@ -78,7 +78,7 @@ export default function ToolsIndex() { diff --git a/apps/docs/src/app/tools/scan/page.tsx b/apps/docs/src/app/tools/scan/page.tsx index 90eaa8a..11bdf04 100644 --- a/apps/docs/src/app/tools/scan/page.tsx +++ b/apps/docs/src/app/tools/scan/page.tsx @@ -22,7 +22,7 @@ const cards: { }, { name: "CLI reference", - href: "/docs/cli#ghost-scan--authoring--validation", + href: "/docs/cli#ghost--authoring--validation", description: "Validate, describe, diff, and emit agent-ready context.", icon: , }, @@ -45,7 +45,7 @@ export default function GhostScanLanding() { return ( diff --git a/apps/docs/src/components/docs/cli-help.tsx b/apps/docs/src/components/docs/cli-help.tsx index 6e20ec8..1632e83 100644 --- a/apps/docs/src/components/docs/cli-help.tsx +++ b/apps/docs/src/components/docs/cli-help.tsx @@ -1,6 +1,6 @@ import manifest from "@/generated/cli-manifest.json"; -type ToolName = "ghost-drift" | "ghost-scan" | "ghost-fleet"; +type ToolName = "ghost" | "ghost-fleet"; interface CliHelpProps { command: string; @@ -43,7 +43,7 @@ function findCommand(tool: ToolName, name: string): CliCommand | undefined { export function CliHelp({ command, - tool = "ghost-drift", + tool = "ghost", show = "all", hideDescription = false, }: CliHelpProps) { diff --git a/apps/docs/src/components/docs/dock.tsx b/apps/docs/src/components/docs/dock.tsx index cbcd39d..a6cae6e 100644 --- a/apps/docs/src/components/docs/dock.tsx +++ b/apps/docs/src/components/docs/dock.tsx @@ -234,7 +234,7 @@ export function Dock() { }} > - ghost-scan + ghost scan { @@ -243,7 +243,7 @@ export function Dock() { }} > - ghost-drift + ghost review { diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index cbc2796..a724085 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -9,322 +9,276 @@ slug: cli -The CLIs do the repeatable parts: validate files, compare bundles, and -record decisions. Your agent does the reading, writing, and reviewing. +The CLI does the repeatable parts: initialize bundles, validate files, transform +survey data, compare bundles, check diffs, emit handoff packets, and record +intent. Your agent does the reading, writing, and reviewing. -A scan follows one path, all owned by `ghost-scan`: +A scan follows one path: ```text resources.yml -> map.md -> survey.json -> patterns.yml ``` -Most commands accept a path; bundle-aware commands default to `.ghost`. -No API key required. +Install the public package and one skill bundle: + +```bash +npm install -D @anarchitecture/ghost +npx ghost skill install +``` Commands are grouped by job: -- **Create and check the fingerprint bundle** — `ghost-scan`: `inventory`, `scan-status`, `lint`, `verify`, `describe`, `diff`, `survey `, `emit` -- **Review and compare drift** — `ghost-drift`: `check`, `review`, `compare`, `ack`, `track`, `diverge`, `emit skill` -- **View many projects** — `ghost-fleet`: `members`, `view`, `emit skill` +- **Create and check the fingerprint bundle**: `init`, `scan`, `inventory`, `lint`, `verify`, `describe`, `diff`, `survey `, `emit`. +- **Review and compare drift**: `check`, `review`, `compare`, `ack`, `track`, `diverge`. +- **Install BYOA recipes**: `skill install`. +- **View many projects**: `ghost-fleet members`, `ghost-fleet view`, `ghost-fleet emit skill`. -Workflows like _scan_, _map_, _survey_, _patterns_, _review_, _verify_, and -_remediate_ are skill recipes your host agent runs — not CLI verbs. Install -them with each tool's `emit skill` verb. +Workflows like _scan_, _map_, _survey_, _patterns_, _recall_, _brief_, +_critique_, _capture_, _promote_, _review_, _verify_, _compare_, and +_remediate_ are skill recipes your host agent runs. They are installed by +`ghost skill install`; they are not LLM-running CLI verbs. -The tables below are generated from each CLI's source at build time. If a -flag changes in any `cli.ts`, the next `pnpm dump:cli-help` run regenerates -this reference. +The tables below are generated from each CLI's source at build time. If a flag +changes in any `cli.ts`, the next `pnpm dump:cli-help` run regenerates this +reference. - + -`ghost-scan` owns the root bundle scan: `resources.yml` → `map.md` → -`survey.json` → `patterns.yml`. It inventories the repo, validates each file, +`ghost` owns the root bundle scan. It inventories the repo, validates each file, verifies cross-artifact fidelity, describes markdown, diffs direct fingerprint files, runs survey ops, and emits agent-ready outputs. -### Repo map — `inventory` +### Initialize - `init` -Emit raw signals about a frontend repo as JSON: package -manifests, language histogram, candidate config files, registry presence, -top-level tree, git remote. The map recipe uses this when writing `map.md`. +Create a `.ghost/` bundle skeleton with `resources.yml`, `map.md`, +`survey.json`, `patterns.yml`, and `checks.yml`. - + ```bash -# Inventory the current directory -ghost-scan inventory - -# Inventory a different repo -ghost-scan inventory ../other-repo +ghost init +ghost init --with-intent ``` -### Pipeline progress — `scan-status` +### Pipeline progress - `scan` -Report which scan stages already exist in a directory: resources -(`resources.yml`), map (`map.md`), survey (`survey.json`), and patterns -(`patterns.yml`). This tells the agent which stage to run next. +Report which scan stages already exist in a directory and which stage the +agent should run next. `--format json` is the deterministic BYOA handoff. - + ```bash -# Status of the current directory -ghost-scan scan-status - -# Status of a different scan dir -ghost-scan scan-status fleet/members/cash-ios - -# Machine-readable for orchestrators -ghost-scan scan-status . --format json +ghost scan +ghost scan fleet/members/cash-ios +ghost scan . --format json ``` -Output reports each stage as `present` or `missing` and surfaces the -recommended next stage (or "scan complete" when every file is present). +### Repo map - `inventory` -### Survey ops — `survey ` +Emit raw signals about a frontend repo as JSON: package manifests, language +histogram, candidate config files, registry presence, top-level tree, and git +remote. The map recipe uses this when writing `map.md`. -Operate on `ghost.survey/v2` files: - -- **`merge`** — combine surveys with id-based dedup. -- **`fix-ids`** — recompute every row's `id` from its content. -- **`summarize`** — print a short Markdown or JSON digest for pattern authoring. -- **`catalog`** — print exact value enums/specs derived from survey rows. - Markdown is the default; JSON uses `ghost.survey.catalog/v1`. -- **`patterns`** — derive a `ghost.patterns/v1` draft from surface evidence. - - + ```bash -# Merge two surveys -ghost-scan survey merge a.json b.json -o merged.json - -# Merge a directory of survey files -ghost-scan survey merge fleet/members/*/survey.json -o cohort.json - -# Populate IDs after authoring rows with empty id fields -ghost-scan survey fix-ids draft.json -o draft.json - -# Broad bundle context -ghost-scan survey summarize survey.json - -# Exact value enums/specs -ghost-scan survey catalog survey.json --kind color -ghost-scan survey catalog survey.json --format json -o catalog.json - -# Stream output to stdout if -o is omitted -ghost-scan survey merge a.json b.json | jq '.values | length' +ghost inventory +ghost inventory ../other-repo ``` -### Validation — `lint` +### Validation - `lint` Validate a root `.ghost` bundle or an individual artifact against its schema. Use this before declaring a file structurally valid; every authoring recipe calls it. Follow `lint` with `verify` to check cross-artifact fidelity. - + ```bash -# Default — reads .ghost -ghost-scan lint +ghost lint +ghost lint map.md +ghost lint survey.json +ghost lint .ghost/patterns.yml --format json +``` -# Validate a map.md (auto-detected by frontmatter) -ghost-scan lint map.md +### Bundle fidelity - `verify` -# Validate a survey.json -ghost-scan lint survey.json +Validate that a root `.ghost` bundle is internally faithful. Resources should +resolve from `--root`, composition patterns must cite survey-backed evidence, +and checks must reference known pattern IDs when they use pattern metadata. -# JSON output -ghost-scan lint .ghost/patterns.yml --format json + + +```bash +ghost verify .ghost --root . +ghost verify .ghost --root . --format json ``` -### Bundle fidelity — `verify` +### Survey ops - `survey ` -Validate that a root `.ghost` bundle is internally faithful. This is stricter -than `lint`: resources should resolve from `--root`, composition patterns must -cite survey-backed evidence, and checks must reference known pattern IDs when -they use pattern metadata. +Operate on `ghost.survey/v2` files. - +- `merge` combines surveys with id-based dedup. +- `fix-ids` recomputes every row's `id` from its content. +- `summarize` prints a bounded Markdown or JSON digest for pattern authoring. +- `catalog` prints exact value enums/specs derived from survey rows. +- `patterns` derives a `ghost.patterns/v1` draft from surface evidence. -```bash -# Bundle success gate after lint -ghost-scan verify .ghost --root . + -# Machine-readable for CI -ghost-scan verify .ghost --root . --format json +```bash +ghost survey merge a.json b.json -o merged.json +ghost survey fix-ids draft.json -o draft.json +ghost survey summarize survey.json +ghost survey catalog survey.json --kind color +ghost survey catalog survey.json --format json -o catalog.json +ghost survey patterns survey.json -o .ghost/patterns.yml ``` -### Inspection — `describe` +### Inspection - `describe` Print a section map of optional `.ghost/intent.md` or a direct fingerprint markdown file. The host agent uses this to load only the sections it needs. - + ```bash -# Default — reads .ghost/intent.md -ghost-scan describe - -# Specific file -ghost-scan describe path/to/fingerprint.md - -# Machine-readable for agents -ghost-scan describe --format json +ghost describe +ghost describe path/to/fingerprint.md +ghost describe --format json ``` -Line ranges are 1-indexed and inclusive; token counts are a `chars / 4` -approximation. +### Structural diff - `diff` -### Structural diff — `diff` +Diff two fingerprints at the prose and structural level: decisions, palette +roles, and tokens. This is not vector distance; use `ghost compare` for that. -Diff two fingerprints at the prose / structural level — what decisions, -palette roles, and tokens changed. **Not** vector distance; for that, use -`ghost-drift compare`. - - + ```bash -ghost-scan diff a/fingerprint.md b/fingerprint.md -ghost-scan diff a.md b.md --format json +ghost diff a/fingerprint.md b/fingerprint.md +ghost diff a.md b.md --format json ``` -### Emit — derived outputs and skill bundles +### Derived outputs - `emit` -Kinds: `review-command`, `context-bundle`, or `skill`. The skill bundle installs -scan/map/survey/patterns plus recall/brief/critique/capture/promote recipes -into your host agent. Review/context outputs still operate on direct fingerprint -markdown until their bundle-native replacement lands. +Emit deterministic artifacts for agents and editors. Supported kinds are +`review-command` and `context-bundle`; skill installation lives at +`ghost skill install`. - + ```bash -# Install the ghost-scan skill bundle -ghost-scan emit skill +ghost emit review-command +ghost emit context-bundle +ghost emit context-bundle --prompt-only +ghost emit context-bundle --out dist/context +``` -# Emit a per-project design-review slash command -ghost-scan emit review-command + + + + +### Deterministic gates - `check` -# Emit a context bundle any generator can consume -ghost-scan emit context-bundle +Run active `ghost.checks/v1` gates from `.ghost/checks.yml` against a git diff. -# Single prompt.md for plain-text LLM context -ghost-scan emit context-bundle --prompt-only + -# Custom output directory -ghost-scan emit context-bundle --out dist/context +```bash +ghost check --base main +ghost check --diff patch.diff --format json ``` - +### Advisory packet - `review` - +Emit an evidence-routed advisory review packet grounded in `.ghost/patterns.yml`, +`.ghost/survey.json`, optional `.ghost/intent.md`, optional memory, checks, and +the diff. -### Comparison — `compare` + -Pairwise distance (N=2) or composite analysis (N≥3) over root bundles or direct +```bash +ghost review --base main +ghost review --base main --include-memory --format json +``` + +### Comparison - `compare` + +Pairwise distance (N=2) or composite analysis (N>=3) over root bundles or direct fingerprint markdown. Bundle inputs are synthesized from survey value distributions and pattern frequencies. Direct markdown inputs still support `--semantic` and `--temporal`. - + ```bash -# Pairwise (N=2) -ghost-drift compare market/.ghost dashboard/.ghost - -# Qualitative diff of decisions + palette -ghost-drift compare a.fingerprint.md b.fingerprint.md --semantic - -# Velocity + trajectory -ghost-drift compare before.fingerprint.md after.fingerprint.md --temporal - -# Composite (N≥3) — pairwise matrix + centroid + clusters -ghost-drift compare */.ghost +ghost compare market/.ghost dashboard/.ghost +ghost compare a.fingerprint.md b.fingerprint.md --semantic +ghost compare before.fingerprint.md after.fingerprint.md --temporal +ghost compare */.ghost ``` -### Intent — `ack` / `track` / `diverge` - -These three verbs write per-dimension stances to `.ghost-sync.json`. `ack` -and `diverge` need a tracked fingerprint declared in `ghost.config.ts`; -`track` takes the new tracked fingerprint as its argument. +### Intent - `ack` / `track` / `diverge` -Acknowledge current drift — record your intentional stance (aligned, -accepted, or diverging). Reads the tracked fingerprint from `ghost.config.ts` -and the local direct fingerprint markdown. +These three verbs write per-dimension stances to `.ghost-sync.json`. `ack` and +`diverge` need a tracked fingerprint declared in `ghost.config.ts`; `track` +takes the new tracked fingerprint as its argument. - + ```bash -# Acknowledge all dimensions as aligned -ghost-drift ack --stance aligned --reason "Initial baseline" - -# Mark typography as intentionally diverging -ghost-drift ack -d typography --stance diverging --reason "Brand refresh requires different type scale" +ghost ack --stance aligned --reason "Initial baseline" +ghost ack -d typography --stance diverging --reason "Brand refresh requires different type scale" ``` -Shift the tracked fingerprint to a new fingerprint. Use this when you want to -re-anchor your drift measurements against a different reference fingerprint. - - + ```bash -# Track a new reference fingerprint -ghost-drift track new-tracked.fingerprint.md +ghost track new-tracked.fingerprint.md ``` -Mark a specific dimension as intentionally diverging. Shorthand for -`ack --stance diverging` that also records a reason. - - + ```bash -ghost-drift diverge palette --reason "Dark-mode-first palette for this product" +ghost diverge palette --reason "Dark-mode-first palette for this product" ``` -### Emit — `emit skill` +### Skill bundle - `skill install` -`ghost-drift emit` ships a single kind: `skill`, the agentskills.io bundle -that teaches your host agent the review / verify / compare / remediate -recipes. (Authoring outputs — `review-command`, `context-bundle` — live in -`ghost-scan`.) +Install the unified `ghost` skill bundle into a host agent. It contains scan, +map, survey, patterns, schema, recall, brief, critique, capture, promote, +review, verify, compare, and remediate recipes. - + ```bash -# Install the ghost-drift skill bundle (default: .claude/skills/ghost-drift) -ghost-drift emit skill - -# Custom location -ghost-drift emit skill --out ~/.my-agent/skills/ghost-drift +ghost skill install +ghost skill install --agent codex --force +ghost skill install --dest ~/.my-agent/skills/ghost ``` - + A fleet is a directory containing many projects, each with a Ghost fingerprint -bundle. -`ghost-fleet` reads the fleet, emits per-fleet outputs (`fleet.md` + -`fleet.json`), and ships the agentskills.io bundle for the narrative -recipe. +bundle. `ghost-fleet` reads the fleet, emits per-fleet outputs (`fleet.md` and +`fleet.json`), and ships the agentskills.io bundle for the narrative recipe. ```bash -# List members in the current fleet directory ghost-fleet members - -# JSON output for scripting ghost-fleet members --json ``` ```bash -# Compute pairwise distances + group-by tables; write fleet.md + fleet.json ghost-fleet view ./fleet - -# Override the fleet id and reports directory ghost-fleet view ./fleet --id cash-mobile --out reports/cash ``` @@ -336,44 +290,26 @@ ghost-fleet emit skill - - -These aren't CLI verbs — they're instruction files inside the per-tool -skill bundles that your host agent follows. Install the relevant bundle -once, then ask your agent in plain English: - -| Recipe | Bundle | Trigger | -| ---------- | ------------------ | ------------------------------------------------------ | -| `scan` | `ghost-scan` | "scan this project" / "go end-to-end" — meta-recipe orchestrating resources → map → survey → patterns | -| `map` | `ghost-scan` | "map this repo" / "write map.md" | -| `survey` | `ghost-scan` | "survey design values" / "extract tokens" | -| `patterns` | `ghost-scan` | "write patterns.yml" / "codify composition patterns" | -| `review` | `ghost-drift` | "review this PR for drift" | -| `verify` | `ghost-drift` | "verify generated UI against the bundle" | -| `compare` | `ghost-drift` | "why did these two fingerprints drift?" | -| `remediate`| `ghost-drift` | "fix this drift" | -| `target` | `ghost-fleet` | "describe this fleet" | -| `recall` / `brief` / `critique` / `capture` / `promote` | `ghost-scan` | "what does Ghost remember?" / "brief this work" / "capture this decision" | - -Source for each recipe lives under -`packages//src/skill-bundle/references/`. The agent executes the -steps with its normal tools and calls the relevant CLI when it needs a -reproducible answer. - -Each recipe also declares `handoffs` in its frontmatter — structured -next-step suggestions that point at another skill (e.g. `fingerprint` → -`compare`) or a CLI invocation (e.g. `fingerprint` → -`ghost-scan emit review-command`). Hosts that read the field can -surface the next action inline; hosts that ignore unknown frontmatter -see no change. - -`discover` and `generate` from earlier Ghost iterations are intentionally -not migrated to any tool (see [`docs/ideas/phase-0-decisions.md`](https://github.com/block/ghost/blob/main/docs/ideas/phase-0-decisions.md)). - ---- - -See [How Ghost Works](/docs/getting-started#how-ghost-works) for the loop -these primitives fit into, or [Getting Started](/docs/getting-started) for an -installation-first guide. + + +These are instruction files inside the unified skill bundle. Install them once, +then ask your agent in plain English. + +| Recipe | Bundle | Trigger | +| --- | --- | --- | +| `scan` | `ghost` | "scan this project" / "go end-to-end" | +| `map` | `ghost` | "map this repo" / "write map.md" | +| `survey` | `ghost` | "survey design values" / "extract tokens" | +| `patterns` | `ghost` | "write patterns.yml" / "codify composition patterns" | +| `recall` / `brief` / `critique` / `capture` / `promote` | `ghost` | "what does Ghost remember?" / "brief this work" / "capture this decision" | +| `review` | `ghost` | "review this PR for drift" | +| `verify` | `ghost` | "verify generated UI against the bundle" | +| `compare` | `ghost` | "why did these two fingerprints drift?" | +| `remediate` | `ghost` | "fix this drift" | +| `target` | `ghost-fleet` | "describe this fleet" | + +Source lives under `packages/ghost/src/skill-bundle/references/`. The agent +executes the steps with its normal tools and calls the relevant CLI command +when it needs a reproducible answer. diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index e243d55..cbd726d 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -10,7 +10,9 @@ slug: getting-started Ghost keeps a project's product-experience memory in the repo, where your agent -can use it. +can use it. The public package is `@anarchitecture/ghost`, and it installs one +CLI: `ghost`. + The fingerprint is the root `.ghost/` bundle: ```text @@ -22,26 +24,25 @@ what to read where UI lives what exists composition grammar `decisions/*.yml`, and `proposals/*.yml` are optional human-approved or human-staged product-experience memory. -| Tool | Job | Verbs | +| Surface | Job | Verbs | | --- | --- | --- | -| `ghost-scan` | Create and check the root `.ghost/` bundle. | `inventory`, `scan-status`, `lint`, `verify`, `describe`, `diff`, `survey `, `emit` | -| `ghost-drift` | Check diffs, emit advisory review packets, compare bundles, and record drift decisions. | `check`, `review`, `compare`, `ack`, `track`, `diverge`, `emit skill` | +| `ghost` | Create and check the root `.ghost/` bundle, review diffs, compare bundles, and record drift intent. | `init`, `scan`, `inventory`, `lint`, `verify`, `describe`, `diff`, `survey `, `check`, `review`, `compare`, `ack`, `track`, `diverge`, `emit`, `skill install` | | `ghost-fleet` | See how many project fingerprints relate. | `members`, `view`, `emit skill` | -| `ghost-ui` | Reference design system Ghost uses to test itself. | — | +| `ghost-ui` | Reference design system Ghost uses to test itself. | - | - + ```bash -ghost-drift emit skill -ghost-scan emit skill -# ghost-fleet emit skill +npm install -D @anarchitecture/ghost +npx ghost --help +npx ghost skill install ``` -Once installed, ask your agent in plain English: "scan this project with Ghost" -or "review this PR for drift". The recipe tells the agent what to read, what to -write, and which CLI checks to run. +Once the skill is installed, ask your agent in plain English: "scan this +project with Ghost" or "review this PR for drift". The recipe tells the agent +what to read, what to write, and which CLI checks to run. @@ -53,22 +54,22 @@ Scan this project with Ghost The `scan` recipe checkpoints between stages: -1. **Resources (`resources.yml`)** — declare the primary target, design-system +1. **Resources (`resources.yml`)** - declare the primary target, design-system references, canonical surfaces, screenshots, docs, resolvers, and path boundaries. -2. **Map (`map.md`)** — find where UI, tokens, components, examples, and +2. **Map (`map.md`)** - find where UI, tokens, components, examples, and design-system files live. -3. **Survey (`survey.json`)** — record values, tokens, components, implemented +3. **Survey (`survey.json`)** - record values, tokens, components, implemented surfaces, and factual composition observations. -4. **Patterns (`patterns.yml`)** — codify surface types and repeated +4. **Patterns (`patterns.yml`)** - codify surface types and repeated composition grammar with survey-backed evidence. ```bash -ghost-scan init-package -ghost-scan scan-status -ghost-scan lint -ghost-scan survey patterns .ghost/survey.json -o .ghost/patterns.yml -ghost-scan verify .ghost --root . +ghost init +ghost scan +ghost lint +ghost survey patterns .ghost/survey.json -o .ghost/patterns.yml +ghost verify .ghost --root . ``` @@ -76,8 +77,8 @@ ghost-scan verify .ghost --root . ```bash -ghost-drift compare market/.ghost dashboard/.ghost -ghost-drift compare */.ghost +ghost compare market/.ghost dashboard/.ghost +ghost compare */.ghost ``` Bundle comparison synthesizes comparable design signals from survey value @@ -85,7 +86,7 @@ distributions and pattern frequencies. Direct fingerprint markdown files still work for legacy semantic comparison: ```bash -ghost-drift compare a.fingerprint.md b.fingerprint.md --semantic +ghost compare a.fingerprint.md b.fingerprint.md --semantic ``` @@ -96,8 +97,8 @@ ghost-drift compare a.fingerprint.md b.fingerprint.md --semantic Review this PR for design drift ``` -`ghost-drift check` applies active deterministic gates from `.ghost/checks.yml`. -`ghost-drift review` emits advisory context grounded in `.ghost/patterns.yml`, +`ghost check` applies active deterministic gates from `.ghost/checks.yml`. +`ghost review` emits advisory context grounded in `.ghost/patterns.yml`, `.ghost/survey.json`, optional `.ghost/intent.md`, checks, and the diff. Add `--include-memory` to include accepted `.ghost/decisions/*.yml` in the advisory packet. @@ -106,7 +107,8 @@ advisory packet. -Use `ghost-scan` when work reveals a decision Ghost should remember: +Use the installed `ghost` skill when work reveals a decision Ghost should +remember: ```text Brief this work with Ghost memory @@ -114,8 +116,8 @@ Capture this product-experience decision as a proposal Promote this accepted proposal ``` -`ghost-scan` recipes write proposals first. Humans promote durable memory into -accepted decisions, `patterns.yml`, `checks.yml`, or `intent.md`. +The recipes write proposals first. Humans promote durable memory into accepted +decisions, `patterns.yml`, `checks.yml`, or `intent.md`. @@ -125,9 +127,9 @@ Drift without intent is noise; drift with intent is signal. `ack`, `track`, and `diverge` still operate on tracked direct fingerprint markdown files: ```bash -ghost-drift ack --stance aligned --reason "Initial baseline" -ghost-drift track new-tracked.fingerprint.md -ghost-drift diverge typography --reason "Editorial product uses a different type scale" +ghost ack --stance aligned --reason "Initial baseline" +ghost track new-tracked.fingerprint.md +ghost diverge typography --reason "Editorial product uses a different type scale" ``` diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 452995e..fa32630 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,39 +1,15 @@ { - "generatedAt": "2026-05-18T16:49:21.560Z", + "generatedAt": "2026-05-18T18:58:16.311Z", "tools": [ { - "tool": "ghost-drift", + "tool": "ghost", "commands": [ { - "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.", + "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": "--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 - }, { "rawName": "--format ", "name": "format", @@ -45,39 +21,39 @@ ] }, { - "tool": "ghost-drift", - "name": "ack", - "rawName": "ack", - "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", + "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": "-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": "--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 }, { - "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", + "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": "--reason ", - "name": "reason", - "description": "Reason for this acknowledgment", + "rawName": "--root ", + "name": "root", + "description": "Optional target root used to resolve resources.yml local paths (default: cwd)", "default": null, "takesValue": true, "negated": false @@ -93,17 +69,17 @@ ] }, { - "tool": "ghost-drift", - "name": "track", - "rawName": "track ", - "description": "Track another fingerprint as this repo's reference", + "tool": "ghost", + "name": "scan", + "rawName": "scan [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": "-d, --dimension ", - "name": "dimension", - "description": "Track only for a specific dimension", + "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 }, { @@ -117,27 +93,34 @@ ] }, { - "tool": "ghost-drift", - "name": "diverge", - "rawName": "diverge ", - "description": "Declare intentional divergence on a dimension", + "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": "-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", - "default": null, + "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", @@ -149,134 +132,139 @@ ] }, { - "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": "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-drift", - "name": "review", - "rawName": "review", - "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and a git diff.", + "tool": "ghost", + "name": "emit", + "rawName": "emit ", + "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle)", "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/)", "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 } ] }, { - "tool": "ghost-drift", - "name": "emit", - "rawName": "emit ", - "description": "Emit the ghost-drift agentskills.io bundle (kind: skill).", + "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.", "options": [ { - "rawName": "-o, --out ", - "name": "out", - "description": "Output directory (default: .claude/skills/ghost-drift)", + "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 - } - ] - } - ], - "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-scan", - "commands": [ - { - "tool": "ghost-scan", - "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", @@ -288,39 +276,39 @@ ] }, { - "tool": "ghost-scan", - "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)", + "tool": "ghost", + "name": "ack", + "rawName": "ack", + "description": "Acknowledge current drift — record intentional stance toward the tracked fingerprint", "options": [ { - "rawName": "--with-intent", - "name": "withIntent", - "description": "Also create optional intent.md for human-authored or human-approved intent", + "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-scan", - "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": "--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 @@ -336,17 +324,17 @@ ] }, { - "tool": "ghost-scan", - "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.", + "tool": "ghost", + "name": "track", + "rawName": "track ", + "description": "Track another fingerprint as this repo's reference", "options": [ { - "rawName": "--include-scopes", - "name": "includeScopes", - "description": "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", + "rawName": "-d, --dimension ", + "name": "dimension", + "description": "Track only for a specific dimension", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { @@ -360,18 +348,27 @@ ] }, { - "tool": "ghost-scan", - "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-scan", - "name": "describe", - "rawName": "describe [fingerprint]", - "description": "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", + "tool": "ghost", + "name": "diverge", + "rawName": "diverge ", + "description": "Declare intentional divergence on a dimension", "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", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--format ", "name": "format", @@ -383,120 +380,120 @@ ] }, { - "tool": "ghost-scan", - "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`).", + "tool": "ghost", + "name": "skill", + "rawName": "skill ", + "description": "Install the unified Ghost skill bundle.", "options": [ { - "rawName": "--format ", - "name": "format", - "description": "Output format: cli or json", - "default": "cli", + "rawName": "--dest ", + "name": "dest", + "description": "Install destination (default: detected agent skills directory + /ghost)", + "default": null, "takesValue": true, "negated": false + }, + { + "rawName": "--agent ", + "name": "agent", + "description": "Agent destination to use when --dest is omitted: claude, cursor, codex, opencode", + "default": null, + "takesValue": true, + "negated": false + }, + { + "rawName": "--force", + "name": "force", + "description": "Overwrite an existing installed Ghost skill", + "default": null, + "takesValue": false, + "negated": false } ] }, { - "tool": "ghost-scan", - "name": "survey", - "rawName": "survey [...surveys]", - "description": "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", + "tool": "ghost", + "name": "check", + "rawName": "check", + "description": "Run active ghost.checks/v1 gates from .ghost/checks.yml against a git diff.", "options": [ { - "rawName": "-o, --out ", - "name": "out", - "description": "Write the result to this path (default: stdout)", + "rawName": "--base ", + "name": "base", + "description": "Git ref to diff against (default: HEAD)", "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": "--diff ", + "name": "diff", + "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.", "default": null, "takesValue": true, "negated": false }, { - "rawName": "--kind ", - "name": "kind", - "description": "survey catalog filter: include only this value kind", + "rawName": "--package ", + "name": "package", + "description": "Fingerprint package directory (default: .ghost)", "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: markdown or json", + "default": "markdown", "takesValue": true, "negated": false } ] }, { - "tool": "ghost-scan", - "name": "emit", - "rawName": "emit ", - "description": "Emit a derived artifact from the fingerprint package (kinds: review-command, context-bundle, skill)", + "tool": "ghost", + "name": "review", + "rawName": "review", + "description": "Emit an evidence-routed advisory review prompt from the fingerprint package and 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/; skill → .claude/skills/ghost-scan/)", + "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": "--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)", + "rawName": "--package ", + "name": "package", + "description": "Fingerprint package directory (default: .ghost)", "default": null, - "takesValue": false, + "takesValue": true, "negated": false }, { - "rawName": "--prompt-only", - "name": "promptOnly", - "description": "Emit only prompt.md (context-bundle)", + "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": "--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/docs/fingerprint-format.md b/docs/fingerprint-format.md index 8a64086..76f9dcd 100644 --- a/docs/fingerprint-format.md +++ b/docs/fingerprint-format.md @@ -145,7 +145,7 @@ evidence: decided_at: "2026-05-17T00:00:00.000Z" ``` -`ghost-drift review --include-memory` reads only decisions with +`ghost review --include-memory` reads only decisions with `status: accepted`. ### `proposals/*.yml` @@ -174,15 +174,15 @@ proposed_action: ## Commands ```bash -ghost-scan init-package --with-intent -ghost-scan lint -ghost-scan survey patterns .ghost/survey.json -o .ghost/patterns.yml -ghost-scan verify --root . -ghost-drift check --base main -ghost-drift review --base main --include-memory -ghost-drift compare .ghost ../other/.ghost +ghost init --with-intent +ghost lint +ghost survey patterns .ghost/survey.json -o .ghost/patterns.yml +ghost verify --root . +ghost check --base main +ghost review --base main --include-memory +ghost compare .ghost ../other/.ghost ``` -`ghost-scan verify` validates cross-artifact fidelity: resources should +`ghost verify` validates cross-artifact fidelity: resources should resolve, composition patterns must cite survey-backed evidence, and checks must reference known pattern IDs when they use pattern metadata. diff --git a/docs/generation-loop.md b/docs/generation-loop.md index 17e1cb7..e134fbc 100644 --- a/docs/generation-loop.md +++ b/docs/generation-loop.md @@ -43,7 +43,7 @@ any generator HTML / JSX / app code | v -ghost-drift review + ghost-drift check +ghost review + ghost check | v advisory composition findings + deterministic check results @@ -96,7 +96,7 @@ but narrower than product strategy: the boundary is anything that shapes how the product is perceived, used, trusted, understood, or safely changed. Accepted decisions can be included in advisory review with -`ghost-drift review --include-memory`. They do not affect `ghost-drift check`. +`ghost review --include-memory`. They do not affect `ghost check`. ### `.ghost/proposals/*.yml` @@ -106,23 +106,23 @@ discussion. They are never canonical until a human promotes them. ## Review Loop -`ghost-drift review` reads `.ghost/patterns.yml`, `.ghost/survey.json`, +`ghost review` reads `.ghost/patterns.yml`, `.ghost/survey.json`, optional `.ghost/intent.md`, and optional `.ghost/checks.yml`. With `--include-memory`, it also reads accepted `.ghost/decisions/*.yml`. Advisory findings should cite pattern evidence, survey evidence, and accepted decisions when relevant. -`ghost-drift check` reads `.ghost/checks.yml` and remains deterministic. It is +`ghost check` reads `.ghost/checks.yml` and remains deterministic. It is the blocking side of the loop. When review flags drift, the host agent applies the smallest correction that brings the output back toward the observed composition grammar. If the drift is -intentional, record a stance with `ghost-drift ack`, `ghost-drift track`, or -`ghost-drift diverge` as appropriate. +intentional, record a stance with `ghost ack`, `ghost track`, or +`ghost diverge` as appropriate. ## Verification -`ghost-scan verify [dir] --root ` checks cross-artifact fidelity: +`ghost verify [dir] --root ` checks cross-artifact fidelity: - pattern evidence exists in `survey.json` - resource paths are reachable from the supplied root when local @@ -136,8 +136,8 @@ bundle itself. ## Integration Patterns **In a generation pipeline:** load the root `.ghost/` bundle into the host -agent, generate the requested UI, then run `ghost-drift review` and -`ghost-drift check`. +agent, generate the requested UI, then run `ghost review` and +`ghost check`. **In CI:** run deterministic checks for UI-touching changes and attach advisory review packets when generated or changed UI appears to drift from @@ -148,7 +148,7 @@ review packets when generated or changed UI appears to drift from repeated composition observations into `patterns.yml`, and add `intent.md` only when a human has supplied or approved the intent. -**Product-experience memory:** use `ghost-scan` recipes to recall, brief, +**Product-experience memory:** use `ghost` recipes to recall, brief, critique, capture, and promote optional decisions/proposals. Keep promotion deliberate: proposals are working memory; accepted decisions are advisory memory; active checks are the only blocking mechanism. diff --git a/install/install.sh b/install/install.sh index 8ce0798..000d5bc 100755 --- a/install/install.sh +++ b/install/install.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Ghost — install the design-language scan + emit skill bundle. +# Ghost — install the unified design-memory skill bundle. # # Usage: # curl -fsSL https://raw.githubusercontent.com/block/ghost/main/install/install.sh | sh @@ -16,6 +16,7 @@ # SKILL.md # references/scan.md, map.md, survey.md, patterns.md, schema.md # references/recall.md, brief.md, critique.md, capture.md, promote.md +# references/review.md, verify.md, compare.md, remediate.md # assets/fingerprint.template.md # # Exit codes: @@ -28,7 +29,7 @@ set -eu GHOST_REF="${GHOST_REF:-main}" GHOST_SOURCE="${GHOST_SOURCE:-https://raw.githubusercontent.com/block/ghost/${GHOST_REF}}" -GHOST_PACKAGE_PATH="packages/ghost-scan/src/skill-bundle" +GHOST_PACKAGE_PATH="packages/ghost/src/skill-bundle" GHOST_BUNDLE_NAME="ghost" FORCE=0 diff --git a/install/manifest.json b/install/manifest.json index 26cb0f3..d7234d2 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -1,10 +1,10 @@ { "$schema": "ghost.install-manifest/v1", - "name": "ghost-scan", - "description": "Ghost Scan recipes — create and activate the repo-local fingerprint bundle.", + "name": "ghost", + "description": "Ghost recipes — create, activate, review, and evolve the repo-local fingerprint bundle.", "version": "0.1.0", "source": { - "package": "packages/ghost-scan/src/skill-bundle" + "package": "packages/ghost/src/skill-bundle" }, "files": [ "SKILL.md", @@ -18,6 +18,10 @@ "references/critique.md", "references/capture.md", "references/promote.md", + "references/review.md", + "references/verify.md", + "references/compare.md", + "references/remediate.md", "assets/fingerprint.template.md" ] } diff --git a/map.md b/map.md index 0e222b4..e8e1acd 100644 --- a/map.md +++ b/map.md @@ -2,7 +2,7 @@ schema: ghost.map/v2 id: ghost repo: block/ghost -mapped_at: 2026-04-27 +mapped_at: 2026-05-18 platform: web languages: - { name: typescript, files: 580, share: 0.78 } @@ -51,23 +51,37 @@ surface_sources: - packages/ghost-ui/scripts/** - packages/*/dist/** feature_areas: - - name: ghost-drift + - name: ghost-cli-package paths: - - packages/ghost-drift/src/core - - packages/ghost-drift/src/cli.ts - - packages/ghost-drift/src/skill-bundle + - packages/ghost/src/cli.ts + - packages/ghost/src/scan-commands.ts + - packages/ghost/src/skill-command.ts + - packages/ghost/src/skill-bundle sub_areas: + - scan + - review - compare + - stance + - skill-install + - name: ghost-shared-core + paths: + - packages/ghost/src/ghost-core + - packages/ghost/src/core + - packages/ghost/src/scan + sub_areas: + - schemas - fingerprint - - evolution - - reporters - - name: ghost-map + - survey + - checks + - memory + - embedding + - name: ghost-fleet paths: - - packages/ghost-map/src/core - - packages/ghost-map/src/cli.ts + - packages/ghost-fleet/src sub_areas: - - inventory - - lint + - members + - view + - fleet-skill - name: ghost-ui-components paths: - packages/ghost-ui/src/components @@ -81,81 +95,57 @@ feature_areas: paths: - apps/docs/src sub_areas: - - drift + - cli-reference - design-language - catalogue orientation_files: - README.md - CLAUDE.md - docs/fingerprint-format.md - - docs/ideas/phase-0-decisions.md + - docs/generation-loop.md --- ## Identity -Ghost is a TypeScript pnpm monorepo that helps agents detect and manage -visual-language drift in the design systems they generate against. The -canonical artifact is `fingerprint.md` — a human-readable, LLM-editable -Markdown file with a YAML machine layer plus a three-section prose body. -Ghost is BYOA: judgement work (fingerprint, review, verify, generate, discover) -lives in skill recipes the host agent executes; the CLIs are the calculator -the agent reaches for when it needs a reproducible answer. +Ghost is a TypeScript pnpm monorepo that gives AI agents repo-local design and +product-experience memory. The public npm package is +`@anarchitecture/ghost`, and its only user-facing bin is `ghost`. -The repository is in the middle of a five-tool decomposition. Today -`ghost-drift` is the only published package; alongside it sit `ghost-ui` -(private reference component library, distributed via shadcn registry) and -`apps/docs` (the deployed docs site). The `ghost-map` package — the source -of this map.md — is being bootstrapped here as the first phase of that -decomposition; future phases extract `@ghost/core`, `ghost-scan`, -and `ghost-fleet` as siblings. +The canonical artifact is the root `.ghost/` bundle: `resources.yml` declares +what to read, `map.md` routes repo topology, `survey.json` records observed +design evidence, and `patterns.yml` turns repeated composition into a grammar. +Optional `checks.yml`, `intent.md`, `decisions/*.yml`, and `proposals/*.yml` +carry enforcement and product-experience memory. -## Topology +Ghost is BYOA. The host agent performs the reading, judgement, and authoring. +The CLI validates schemas, computes deterministic transforms, compares +fingerprints, checks diffs, emits review packets, and installs the unified +`ghost` skill bundle. -The design system lives in `packages/ghost-ui/src`. Tokens resolve through -`src/styles/tokens.css` (the canonical CSS variable layer) and the shadcn -`registry.json` describes the 97 components shipped to consumers. The -fingerprint.md at `packages/ghost-ui/fingerprint.md` is the authoritative -language description; `embedding.md` carries the precomputed 49-dim vector. +## Topology -Customer UI lives in two places. The reference primitives under -`packages/ghost-ui/src/components` are the catalogue tools index, and the -docs site under `apps/docs/src` consumes them as a live showcase. Excludes -are the standard monorepo noise (`dist/`, `node_modules/`) plus the -`packages/ghost-ui/scripts/` build harness, which generates the registry -but is not itself customer-visible. +The publishable package lives in `packages/ghost`. It folds the previous drift, +scan, and shared-core runtime into one npm-safe package with no `workspace:*` +runtime dependencies. Its public exports are split by subpath: +`@anarchitecture/ghost`, `/scan`, `/drift`, `/core`, and `/cli`. -Feature surfaces follow the package boundaries because the repo is a -verb-decomposed CLI. `ghost-drift` is the engine that owns embedding, -comparison, fingerprint parsing, evolution (track/ack/diverge), and the -existing skill bundle. `ghost-map` (this slice) ships only `inventory` and -`lint` today; `describe` and `emit skill` are deferred to a later -deliverable. `ghost-ui-components` is the registry surface; `ghost-ui-mcp` -is the MCP server re-exposing the registry to AI assistants. -`docs-site` consumes both of the above for the deployed marketing and -catalogue site. +`packages/ghost-fleet` remains private and consumes workspace exports from +`@anarchitecture/ghost`. `packages/ghost-ui` is the reference component +library and MCP server. `apps/docs` is the deployed documentation site and +renders CLI help from the generated manifest. -Orientation reading order is `README.md` → `CLAUDE.md` (agent context) → -`docs/fingerprint-format.md` (the canonical artifact spec) → -`docs/ideas/phase-0-decisions.md` (the decomposition plan that contextualizes -ghost-map's existence). +The design system lives in `packages/ghost-ui/src`. Tokens resolve through +`src/styles/tokens.css`, and the shadcn `registry.json` describes the 97 +components shipped to consumers. ## Conventions -Each published package mirrors the same shape: `src/bin.ts` is the -shebang entry, `src/cli.ts` exposes a `buildCli()` builder, `src/core/` -holds the deterministic library surface, and tests live under -`test/` (with `test/fixtures/` for sample data — biome ignores fixtures). -Public exports flow through `src/core/index.ts` only; deep imports from -`./core/*` are not part of the contract. - -Code style is enforced by Biome (`pnpm fmt`, `pnpm check`) with the lefthook -pre-commit hook running `biome format --write && biome check --fix && just -check`. Line-length budgets per file are enforced by -`scripts/check-file-sizes.mjs` (default 500, narrow exceptions inline). -TypeScript is strict everywhere with project references — every package -ships its own `tsconfig.json` extending the root. +Each package keeps the standard shape: `src/bin.ts` for the shebang entry, +`src/cli.ts` for the `buildCli()` builder when it has a CLI, `src/core/` for +deterministic library code, `src/skill-bundle/` for agent recipes, and `test/` +for vitest coverage. -Skill bundles live in `src/skill-bundle/` per package. Each carries its own -`SKILL.md` plus `references/`. Cross-recipe references happen through verb -names in prose, not through a meta-emit step. Vitest is the test runner; -tests are colocated under each package's `test/` directory. +For the public package, keep npm runtime code under `packages/ghost/src`. +Private historical packages may remain in the workspace, but the packed +`@anarchitecture/ghost` artifact must not reference `@ghost/core`, +`ghost-scan`, `ghost-drift`, or any `workspace:*` dependency. diff --git a/package.json b/package.json index 0f24d06..a567a48 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "lint": "biome lint .", "changeset": "changeset", "version-packages": "changeset version", - "release": "pnpm --filter ghost-scan build && pnpm --filter ghost-drift build && changeset publish" + "release": "pnpm --filter @anarchitecture/ghost build && changeset publish" }, "devDependencies": { "@anthropic-ai/sdk": "^0.90.0", diff --git a/packages/ghost-drift/CHANGELOG.md b/packages/ghost-drift/CHANGELOG.md deleted file mode 100644 index 03e39c3..0000000 --- a/packages/ghost-drift/CHANGELOG.md +++ /dev/null @@ -1,37 +0,0 @@ -# ghost-drift - -## 0.2.0 - -### Minor Changes - -- [#51](https://github.com/block/ghost/pull/51) [`70e3816`](https://github.com/block/ghost/commit/70e38164fbb6bf1287567939e5986a4eaeb71a4c) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Add `ghost-drift describe` — prints a section map of `fingerprint.md` (frontmatter range, body sections, per-dimension decision blocks) with line ranges and token estimates, so host agents can selectively load only the sections they need instead of the whole file. The review and generate skill recipes now open with `describe` and teach a "load whole `# Decisions` block if uncertain" recall safety rule. - -- [#46](https://github.com/block/ghost/pull/46) [`a96e335`](https://github.com/block/ghost/commit/a96e3352545ebc4e33c2e575dc45abd624ade351) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Rename `fleet` mode to `composite` across the library and CLI. The N≥3 compare output now reads "Composite Fingerprint: N members" — the aggregate view is a fingerprint of fingerprints. - - **BREAKING** (safe to bump minor while on 0.x, but pinning consumers should adjust): - - - Library exports renamed: `compareFleet` → `compareComposite`; `formatFleetComparison` / `formatFleetComparisonJSON` → `formatCompositeComparison` / `formatCompositeComparisonJSON`. - - Type exports renamed: `FleetComparison` / `FleetMember` / `FleetPair` / `FleetCluster` / `FleetClusterOptions` → `Composite*` equivalents. - - `compare()` result discriminator: `result.mode === "fleet"` is now `"composite"`, and `result.fleet` is now `result.composite`. - - CLI header: `Fleet Overview: N projects` → `Composite Fingerprint: N members`. - - JSON output shape (member count, pairwise, spread, clusters) is unchanged. - -- [#48](https://github.com/block/ghost/pull/48) [`a822e7c`](https://github.com/block/ghost/commit/a822e7cecae39801c53de03f836ae5e2f29b1470) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Role palette fields accept `{palette.dominant.}` and `{palette.semantic.}` references, so renames in the palette cascade into every role that cites them. `ghost-drift lint` flags unresolved references as `broken-role-reference`. - -## 0.1.1 - -### Patch Changes - -- [#43](https://github.com/block/ghost/pull/43) [`a8d1726`](https://github.com/block/ghost/commit/a8d1726c2870cf74d347409e4e7fb6eb2958f454) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Document the Node.js 18+ requirement in the package README so it's visible on the npm listing page without readers having to open the `engines` field in `package.json`. - -## 0.1.0 - -### Minor Changes - -- [`6f9f36a`](https://github.com/block/ghost/commit/6f9f36aac35663bd020e771195ca4a729e4ead8a) Thanks [@nahiyankhan](https://github.com/nahiyankhan)! - Initial public release. Deterministic design drift detection for agent-authored UI: - - - **Six CLI verbs** — `compare` (pairwise + fleet over 49-dim fingerprints, with `--semantic` and `--temporal` enrichment), `lint`, `ack`, `track`, `diverge`, and `emit` (derives `review-command`, `context-bundle`, or the `ghost-drift` skill). - - **`fingerprint.md` format** — human-readable Markdown with a YAML machine layer (49-dim embedding + palette/spacing/typography/surfaces/roles) and a three-layer prose body (Character, Signature, Decisions). Parse, lint, diff, compose, and compare programmatically via the library export. - - **Library API** — `parseFingerprint`, `lintFingerprint`, `diffFingerprints`, `compareFingerprints`, `compareFleet`, `loadFingerprint`, `defineConfig`, and the full `@ghost-drift` core usable headlessly from any Node app. - - **Agent skill bundle** — `ghost-drift emit skill` installs an [agentskills.io](https://agentskills.io)-compatible bundle (`profile`, `review`, `verify`, `generate`, `discover`, `compare` recipes + schema reference) into your host agent of choice. The CLI never calls an LLM; the host agent owns all interpretive work. diff --git a/packages/ghost-drift/README.md b/packages/ghost-drift/README.md deleted file mode 100644 index 0c91d16..0000000 --- a/packages/ghost-drift/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# ghost-drift - -**Deterministic design drift detection. Five verbs. No LLM calls.** - -`ghost-drift` checks root Ghost fingerprint bundles, compares bundle-derived design signals, records intent across drift, and ships the agentskills.io recipes a host agent uses to review, verify, and remediate. It pairs with **[`ghost-scan`](../ghost-scan)** — the package that owns authoring `.ghost/`. - -## Requirements - -- Node.js **18+** - -## Install - -> While `ghost-drift` is being registered on the npm public registry, the package is distributed as a tarball attached to each [GitHub Release](https://github.com/block/ghost/releases). Install directly from the release URL: - -```bash -# latest release -npm install https://github.com/block/ghost/releases/download/ghost-drift%400.2.0/ghost-drift-0.2.0.tgz - -# pnpm / yarn work the same -pnpm add https://github.com/block/ghost/releases/download/ghost-drift%400.2.0/ghost-drift-0.2.0.tgz -``` - -Or pin in `package.json`: - -```json -{ - "dependencies": { - "ghost-drift": "https://github.com/block/ghost/releases/download/ghost-drift%400.2.0/ghost-drift-0.2.0.tgz" - } -} -``` - -Once npm publishing is unblocked this will move to the registry — swap the URL for a plain `^0.2.0`. - -## Use - -```bash -ghost-drift compare a/.ghost b/.ghost # pairwise bundle distance (N=2) -ghost-drift compare ./*/.ghost # composite bundle comparison, N≥3 -ghost-drift compare a.md b.md --semantic # direct markdown qualitative diff -ghost-drift compare a.md b.md --temporal # add velocity / trajectory -ghost-drift review --include-memory # include accepted .ghost/decisions in advisory packets -ghost-drift ack # acknowledge drift against the tracked fingerprint -ghost-drift track path/to/new-tracked.fingerprint.md # track another fingerprint -ghost-drift diverge # declare intentional divergence -ghost-drift emit skill # install the agent recipe bundle -``` - -Zero config for every verb. No API key needed. `OPENAI_API_KEY` / `VOYAGE_API_KEY` are optional and only consumed if you ask for semantic-enriched runtime embeddings via the library. - -### Authoring a scan? - -Scans live in **[`ghost-scan`](../ghost-scan)**, which owns the root bundle pipeline (`resources.yml` → `map.md` → `survey.json` → `patterns.yml`). Install it for `inventory`, `lint`, `verify`, `describe`, `diff`, `survey merge` / `fix-ids` / `summarize` / `catalog` / `patterns`, `scan-status`, and `emit review-command` / `emit context-bundle`: - -```bash -ghost-scan inventory # raw repo signals → JSON (feeds map.md) -ghost-scan scan-status # per-stage state + next stage -ghost-scan lint # auto-detects .ghost bundle artifacts -ghost-scan verify .ghost --root . # cross-artifact fidelity gate -ghost-scan survey merge a.json b.json # union with id-based dedup -ghost-scan survey catalog survey.json # derived value enum/spec view -ghost-scan diff a.md b.md # structural prose-level diff between fingerprints -ghost-scan emit review-command # per-project slash command -ghost-scan emit context-bundle # generation context bundle -``` - -The authoring verbs that used to live under `ghost-drift` were moved in v0.2.0; running them on `ghost-drift` now prints a deprecation message pointing here. - -### Product-experience memory - -`ghost-drift check` remains deterministic and reads only active `checks.yml` -gates. `ghost-drift review --include-memory` can include accepted -`.ghost/decisions/*.yml` in the advisory packet. Rejected decisions and -`.ghost/proposals/*.yml` are ignored by drift. - -## As a library - -```ts -import { - compareFingerprints, - loadFingerprint, - readHistory, - readSyncManifest, -} from "ghost-drift"; - -const { fingerprint: a } = await loadFingerprint("a/fingerprint.md"); -const { fingerprint: b } = await loadFingerprint("b/fingerprint.md"); -const distance = compareFingerprints(a, b); -``` - -Parsing, linting, layout, and diff utilities live in `ghost-scan` (re-exported from there). The shared embedding math lives in `@ghost/core`. All exports are browser-safe except the ones that read from disk (history, sync manifest, tracked-fingerprint resolution). - -## BYOA — bring your own agent - -Ghost ships per-tool [agentskills.io](https://agentskills.io)-compatible bundles. The `ghost-drift` bundle teaches your host agent (Claude Code, Codex, Cursor, Goose, …) how to **review** drift in a PR, **verify** generated UI, **interpret** comparison output, and **remediate** with `ack` / `track` / `diverge`. Install it with: - -```bash -ghost-drift emit skill -``` - -The agent runs the recipes; the CLI runs the arithmetic. The CLI never calls an LLM. - -(Authoring recipes — `scan` / `map` / `survey` / `patterns` — all ship in `ghost-scan`'s skill bundle, since one tool now owns the root bundle pipeline. Fleet narrative recipes ship in `ghost-fleet`.) - -## Full story - -See the [project README](https://github.com/block/ghost#readme) for the philosophy, the four-tool decomposition, the three-stage scan pipeline, the fingerprint format spec, composite comparison, and the reference design language (Ghost UI). - -## License - -Apache-2.0 diff --git a/packages/ghost-drift/src/skill-bundle/SKILL.md b/packages/ghost-drift/src/skill-bundle/SKILL.md deleted file mode 100644 index 120f12a..0000000 --- a/packages/ghost-drift/src/skill-bundle/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: ghost-drift -description: Run deterministic Ghost checks, emit advisory review packets, compare root fingerprint bundles or direct fingerprint files, and record drift stance. Use for "check for drift", "review this PR", "verify generated UI", "compare fingerprints", or "accept this divergence". -license: Apache-2.0 -metadata: - homepage: https://github.com/block/ghost - cli: ghost-drift ---- - -# Ghost Drift — Check And Review - -Ghost Drift consumes the repo-local root fingerprint bundle: - -```text -.ghost/ - resources.yml - map.md - survey.json - patterns.yml - checks.yml (optional) - intent.md (optional) - decisions/*.yml (optional) -``` - -The rule is simple: - -- `ghost-drift check` is deterministic and blocking. -- `ghost-drift review` is advisory and evidence-routed through patterns and survey evidence. -- `ghost-drift review --include-memory` also includes accepted product-experience decisions. -- `ghost-drift compare` compares root bundles or direct fingerprint markdown files. -- `ack`, `track`, and `diverge` record intentional drift for direct tracked fingerprints. - -## CLI Verbs - -| Verb | Purpose | -|---|---| -| `ghost-drift check --base ` | Route changed files through `.ghost/map.md`, apply active `.ghost/checks.yml`, and exit nonzero on failures. | -| `ghost-drift check --diff --format json` | Check a saved unified diff and emit stable JSON. | -| `ghost-drift review --base ` | Emit an advisory review prompt packet grounded in patterns, survey, optional intent, checks, and diff. | -| `ghost-drift review --base --include-memory` | Add accepted `.ghost/decisions/*.yml` to the advisory packet. | -| `ghost-drift compare [...more]` | Pairwise or composite distance over root bundles. | -| `ghost-drift compare ` | Compare direct fingerprint markdown files. | -| `ghost-drift ack` / `track ` / `diverge ` | Record stance in `.ghost-sync.json` for direct tracked fingerprints. | -| `ghost-drift emit skill` | Install this skill bundle. | - -## Review Rule - -Advisory findings are non-blocking unless tied to an active deterministic check. -Every advisory finding should cite: - -- diff location -- `patterns.yml` composition pattern -- survey evidence -- `intent.md` when relevant -- precedent/example -- repair - -## Workflows - -- "Run the gate" → `ghost-drift check --base `. -- "Review this PR for design drift" → run `ghost-drift check`, then use `ghost-drift review` as the evidence packet for advisory critique. -- "Compare these bundles" → run `ghost-drift compare `. -- "Accept this drift" → use `ack`, `track`, or `diverge` for direct tracked fingerprints. - -Authoring `.ghost/` lives in the sibling `ghost-scan` skill. - -## Never - -- Never treat advisory composition judgment as a CI gate. -- Never fail a build on advisory-only findings. -- Never auto-promote an advisory finding into `checks.yml`; a human must curate deterministic gates. -- Never treat proposals or rejected decisions as canonical drift inputs. diff --git a/packages/ghost-fleet/package.json b/packages/ghost-fleet/package.json index a8a5201..7a8b3cc 100644 --- a/packages/ghost-fleet/package.json +++ b/packages/ghost-fleet/package.json @@ -43,9 +43,8 @@ "build": "rm -rf dist && tsc --build --force && cp -r src/skill-bundle dist/skill-bundle" }, "dependencies": { - "@ghost/core": "workspace:*", + "@anarchitecture/ghost": "workspace:*", "cac": "^6.7.14", - "ghost-scan": "workspace:*", "yaml": "^2.8.3", "zod": "^4.3.6" } diff --git a/packages/ghost-fleet/src/cli.ts b/packages/ghost-fleet/src/cli.ts index f2481c9..c1662ed 100644 --- a/packages/ghost-fleet/src/cli.ts +++ b/packages/ghost-fleet/src/cli.ts @@ -2,14 +2,14 @@ import { readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { loadSkillBundle } from "@ghost/core"; +import { loadSkillBundle } from "@anarchitecture/ghost/core"; import { cac } from "cac"; import { loadMembers, summarizeMember, writeFleetView } from "./core/index.js"; /** * The skill bundle's source files live in `src/skill-bundle/` as real * markdown and are copied verbatim into `dist/skill-bundle/` by the - * package build step. This loader points the shared `@ghost/core` + * package build step. This loader points the shared Ghost skill loader * walker at that built directory at runtime. */ const SKILL_BUNDLE_ROOT = fileURLToPath( @@ -24,7 +24,7 @@ const DEFAULT_SKILL_OUT = ".claude/skills/ghost-fleet"; * Three deterministic verbs: * - `members ` — list registered members + freshness signal * - `view ` — emit fleet.md + fleet.json into `/reports/` - * - `emit skill` — install the agentskills.io bundle into a host agent + * - `emit skill` — install the fleet agentskills.io bundle into a host agent * * Tracks-graph extraction, temporal aggregation, and group-by axis stacking * are scoped out of this milestone — see the per-verb deferral notes in diff --git a/packages/ghost-fleet/src/core/compute.ts b/packages/ghost-fleet/src/core/compute.ts index 0c99acb..161dd3b 100644 --- a/packages/ghost-fleet/src/core/compute.ts +++ b/packages/ghost-fleet/src/core/compute.ts @@ -1,4 +1,4 @@ -import { compareFingerprints } from "@ghost/core"; +import { compareFingerprints } from "@anarchitecture/ghost/core"; import type { FleetGroupingsComputed, FleetMember, diff --git a/packages/ghost-fleet/src/core/members.ts b/packages/ghost-fleet/src/core/members.ts index 9af2229..544c3ba 100644 --- a/packages/ghost-fleet/src/core/members.ts +++ b/packages/ghost-fleet/src/core/members.ts @@ -7,8 +7,11 @@ import { type MapFrontmatter, MapFrontmatterSchema, type MapScope, -} from "@ghost/core"; -import { FINGERPRINT_FILENAME, loadFingerprint } from "ghost-scan"; +} from "@anarchitecture/ghost/core"; +import { + FINGERPRINT_FILENAME, + loadFingerprint, +} from "@anarchitecture/ghost/scan"; import { parse as parseYaml } from "yaml"; import { FLEET_MEMBERS_DIRNAME } from "./schema.js"; import type { FleetMember, MemberSummary } from "./types.js"; diff --git a/packages/ghost-fleet/src/core/types.ts b/packages/ghost-fleet/src/core/types.ts index 68260e1..bc7a3dc 100644 --- a/packages/ghost-fleet/src/core/types.ts +++ b/packages/ghost-fleet/src/core/types.ts @@ -6,7 +6,11 @@ * over them, and what the deterministic artifacts look like on disk. */ -import type { Fingerprint, MapFrontmatter, MapScope } from "@ghost/core"; +import type { + Fingerprint, + MapFrontmatter, + MapScope, +} from "@anarchitecture/ghost/core"; import type { FleetDistance, FleetTrackEdge } from "./schema.js"; /** @@ -14,7 +18,7 @@ import type { FleetDistance, FleetTrackEdge } from "./schema.js"; * * Three states keep the surface small: * • "ok" — the file exists and parses; we don't run the full linter - * here (that's `ghost-scan lint`). + * here (that's `ghost lint`). * • "missing" — the file is absent from the member directory. * • "error" — the file is present but fails to load/parse. */ diff --git a/packages/ghost-fleet/tsconfig.json b/packages/ghost-fleet/tsconfig.json index 40cbcb6..f4e64d2 100644 --- a/packages/ghost-fleet/tsconfig.json +++ b/packages/ghost-fleet/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": "./src" }, "include": ["src"], - "references": [{ "path": "../ghost-core" }, { "path": "../ghost-scan" }] + "references": [{ "path": "../ghost" }] } diff --git a/packages/ghost-scan/package.json b/packages/ghost-scan/package.json index 6cbdd57..422c474 100644 --- a/packages/ghost-scan/package.json +++ b/packages/ghost-scan/package.json @@ -1,6 +1,7 @@ { "name": "ghost-scan", "version": "0.0.0", + "private": true, "description": "Author and validate the root .ghost design-memory bundle (resources, map, survey, patterns, checks, intent)", "license": "Apache-2.0", "author": "Block, Inc.", @@ -38,10 +39,6 @@ "files": [ "dist" ], - "publishConfig": { - "access": "public", - "provenance": true - }, "scripts": { "build": "rm -rf dist && tsc --build --force && cp -r src/skill-bundle dist/skill-bundle", "prepublishOnly": "pnpm build" diff --git a/packages/ghost/CHANGELOG.md b/packages/ghost/CHANGELOG.md new file mode 100644 index 0000000..6632f3a --- /dev/null +++ b/packages/ghost/CHANGELOG.md @@ -0,0 +1,6 @@ +# @anarchitecture/ghost + +## 0.0.0 + +Source version for the unified Ghost package. The first public publish is +expected to be cut by Changesets as `0.1.0`. diff --git a/packages/ghost/README.md b/packages/ghost/README.md new file mode 100644 index 0000000..a7d6ed7 --- /dev/null +++ b/packages/ghost/README.md @@ -0,0 +1,67 @@ +# @anarchitecture/ghost + +**Unified Ghost CLI for repo-local design memory.** + +Ghost creates and maintains a root `.ghost/` fingerprint bundle, checks diffs +against deterministic gates, emits advisory review packets, compares bundles, +and records intentional drift. It ships one CLI: `ghost`. + +## Install + +```bash +npm install -D @anarchitecture/ghost +npx ghost --help +``` + +## Use + +```bash +ghost init --with-intent +ghost scan --format json +ghost inventory +ghost lint .ghost +ghost verify .ghost --root . +ghost survey summarize .ghost/survey.json +ghost survey patterns .ghost/survey.json -o .ghost/patterns.yml +ghost check --base main +ghost review --base main --include-memory +ghost compare a/.ghost b/.ghost +ghost ack +ghost track path/to/new-tracked.fingerprint.md +ghost diverge typography --reason "Product deliberately uses an editorial scale" +ghost emit review-command +ghost emit context-bundle +ghost skill install +``` + +Zero config for every verb. No API key is required. `OPENAI_API_KEY` / +`VOYAGE_API_KEY` are optional and only used by semantic embedding helpers when a +host opts in. + +## Library + +```ts +import { compare, runGhostDriftCheck } from "@anarchitecture/ghost/drift"; +import { + initFingerprintPackage, + lintFingerprintPackage, + verifyFingerprintPackage, +} from "@anarchitecture/ghost/scan"; +import { compareFingerprints } from "@anarchitecture/ghost/core"; +``` + +## BYOA + +Ghost is bring-your-own-agent. The CLI performs deterministic work: inventory, +lint, verify, compare, check, and handoff packet generation. The installed +`ghost` skill teaches your host agent how to scan, map, survey, codify patterns, +review drift, verify generated UI, remediate, and capture product-experience +memory. + +```bash +ghost skill install +``` + +## License + +Apache-2.0 diff --git a/packages/ghost-drift/package.json b/packages/ghost/package.json similarity index 52% rename from packages/ghost-drift/package.json rename to packages/ghost/package.json index e49a49b..c391706 100644 --- a/packages/ghost-drift/package.json +++ b/packages/ghost/package.json @@ -1,7 +1,7 @@ { - "name": "ghost-drift", - "version": "0.2.0", - "description": "Deterministic Ghost design checks, advisory review packets, root bundle comparison, and drift stance tracking", + "name": "@anarchitecture/ghost", + "version": "0.0.0", + "description": "Unified Ghost CLI for repo-local product-experience memory, scan workflows, deterministic checks, advisory review, and drift tracking", "license": "Apache-2.0", "author": "Block, Inc.", "repository": { @@ -22,16 +22,38 @@ "cli" ], "type": "module", - "main": "./dist/core/index.js", - "types": "./dist/core/index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "bin": { - "ghost-drift": "./dist/bin.js" + "ghost": "./dist/bin.js" + }, + "imports": { + "#ghost-core": { + "types": "./src/ghost-core/index.ts", + "import": "./dist/ghost-core/index.js" + }, + "#scan": { + "types": "./src/scan/index.ts", + "import": "./dist/scan/index.js" + } }, "exports": { ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/ghost-core/index.d.ts", + "import": "./dist/ghost-core/index.js" + }, + "./drift": { "types": "./dist/core/index.d.ts", "import": "./dist/core/index.js" }, + "./scan": { + "types": "./dist/scan/index.d.ts", + "import": "./dist/scan/index.js" + }, "./cli": { "types": "./dist/cli.d.ts", "import": "./dist/cli.js" @@ -49,10 +71,9 @@ "prepublishOnly": "pnpm build" }, "dependencies": { - "@ghost/core": "workspace:*", "cac": "^6.7.14", - "ghost-scan": "workspace:*", "jiti": "^2.4.0", - "yaml": "^2.8.3" + "yaml": "^2.8.3", + "zod": "^4.3.6" } } diff --git a/packages/ghost-drift/src/bin.ts b/packages/ghost/src/bin.ts similarity index 100% rename from packages/ghost-drift/src/bin.ts rename to packages/ghost/src/bin.ts diff --git a/packages/ghost-drift/src/cli.ts b/packages/ghost/src/cli.ts similarity index 86% rename from packages/ghost-drift/src/cli.ts rename to packages/ghost/src/cli.ts index 9c458c9..6c1a34d 100644 --- a/packages/ghost-drift/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -1,18 +1,17 @@ import { execFile } from "node:child_process"; import { type Dirent, readFileSync } from "node:fs"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { cac } from "cac"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { GHOST_DECISIONS_DIRNAME, type GhostDecisionDocument, lintGhostDecision, - loadSkillBundle, -} from "@ghost/core"; -import { cac } from "cac"; -import { formatSemanticDiff, resolveFingerprintPackage } from "ghost-scan"; -import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +} from "#ghost-core"; +import { formatSemanticDiff, resolveFingerprintPackage } from "#scan"; import { loadComparableFingerprint } from "./comparable-fingerprint.js"; import { compare, @@ -32,22 +31,15 @@ import { registerDivergeCommand, registerTrackCommand, } from "./evolution-commands.js"; +import { registerScanCommands } from "./scan-commands.js"; +import { registerSkillCommand } from "./skill-command.js"; -/** - * The skill bundle's source files live in `src/skill-bundle/` as real - * markdown and are copied verbatim into `dist/skill-bundle/` by the - * package build step. This loader points the shared `@ghost/core` - * walker at that built directory at runtime. - */ -const SKILL_BUNDLE_ROOT = fileURLToPath( - new URL("./skill-bundle", import.meta.url), -); - -const DEFAULT_SKILL_OUT = ".claude/skills/ghost-drift"; const execFileAsync = promisify(execFile); export function buildCli(): ReturnType { - const cli = cac("ghost-drift"); + const cli = cac("ghost"); + + registerScanCommands(cli); // --- compare --- cli @@ -133,6 +125,7 @@ export function buildCli(): ReturnType { registerAckCommand(cli); registerTrackCommand(cli); registerDivergeCommand(cli); + registerSkillCommand(cli); // --- check --- cli @@ -258,51 +251,6 @@ export function buildCli(): ReturnType { } }); - // --- emit (skill only) --- - cli - .command( - "emit ", - "Emit the ghost-drift agentskills.io bundle (kind: skill).", - ) - .option( - "-o, --out ", - `Output directory (default: ${DEFAULT_SKILL_OUT})`, - ) - .action(async (kind: string, opts) => { - try { - if (kind !== "skill") { - console.error( - `Error: unknown emit kind '${kind}'. Supported: skill.`, - ); - process.exit(2); - return; - } - - const outDir = resolve( - process.cwd(), - (opts.out as string | undefined) ?? DEFAULT_SKILL_OUT, - ); - const bundle = loadSkillBundle(SKILL_BUNDLE_ROOT); - const written: string[] = []; - for (const file of bundle) { - const outPath = resolve(outDir, file.path); - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, file.content, "utf-8"); - written.push(file.path); - } - process.stdout.write( - `Wrote ${written.length} file${written.length === 1 ? "" : "s"} to ${outDir}:\n`, - ); - for (const f of written) process.stdout.write(` ${f}\n`); - process.exit(0); - } catch (err) { - console.error( - `Error: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(2); - } - }); - cli.help(); cli.version(readPackageVersion()); diff --git a/packages/ghost-drift/src/comparable-fingerprint.ts b/packages/ghost/src/comparable-fingerprint.ts similarity index 97% rename from packages/ghost-drift/src/comparable-fingerprint.ts rename to packages/ghost/src/comparable-fingerprint.ts index 7a1cf20..6b9ae7b 100644 --- a/packages/ghost-drift/src/comparable-fingerprint.ts +++ b/packages/ghost/src/comparable-fingerprint.ts @@ -1,13 +1,13 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; import { computeEmbedding, type Fingerprint, type GhostPatternsDocument, type Survey, -} from "@ghost/core"; -import { loadFingerprint, resolveFingerprintPackage } from "ghost-scan"; -import { parse as parseYaml } from "yaml"; +} from "#ghost-core"; +import { loadFingerprint, resolveFingerprintPackage } from "#scan"; export async function loadComparableFingerprint( path: string, diff --git a/packages/ghost-drift/src/core/check.ts b/packages/ghost/src/core/check.ts similarity index 99% rename from packages/ghost-drift/src/core/check.ts rename to packages/ghost/src/core/check.ts index 63f89a4..66c4f0f 100644 --- a/packages/ghost-drift/src/core/check.ts +++ b/packages/ghost/src/core/check.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; import { promisify } from "node:util"; +import { parse as parseYaml } from "yaml"; import { type GhostCheck, type GhostChecksDocument, @@ -10,9 +11,8 @@ import { MapFrontmatterSchema, type MapScope, routeGhostChecksForPath, -} from "@ghost/core"; -import { resolveFingerprintPackage } from "ghost-scan"; -import { parse as parseYaml } from "yaml"; +} from "#ghost-core"; +import { resolveFingerprintPackage } from "#scan"; const execFileAsync = promisify(execFile); diff --git a/packages/ghost-drift/src/core/compare.ts b/packages/ghost/src/core/compare.ts similarity index 93% rename from packages/ghost-drift/src/core/compare.ts rename to packages/ghost/src/core/compare.ts index c1b5589..09b15fc 100644 --- a/packages/ghost-drift/src/core/compare.ts +++ b/packages/ghost/src/core/compare.ts @@ -6,10 +6,10 @@ import type { FingerprintHistoryEntry, SyncManifest, TemporalComparison, -} from "@ghost/core"; -import { compareFingerprints } from "@ghost/core"; -import type { SemanticDiff } from "ghost-scan"; -import { diffFingerprints } from "ghost-scan"; +} from "#ghost-core"; +import { compareFingerprints } from "#ghost-core"; +import type { SemanticDiff } from "#scan"; +import { diffFingerprints } from "#scan"; import { compareComposite } from "./evolution/composite.js"; import { computeTemporalComparison } from "./evolution/temporal.js"; diff --git a/packages/ghost-drift/src/core/config.ts b/packages/ghost/src/core/config.ts similarity index 96% rename from packages/ghost-drift/src/core/config.ts rename to packages/ghost/src/core/config.ts index 225a9a8..1294bdf 100644 --- a/packages/ghost-drift/src/core/config.ts +++ b/packages/ghost/src/core/config.ts @@ -1,8 +1,8 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import type { GhostConfig, Target } from "@ghost/core"; -import { resolveTarget } from "@ghost/core"; import { createJiti } from "jiti"; +import type { GhostConfig, Target } from "#ghost-core"; +import { resolveTarget } from "#ghost-core"; export { resolveTarget }; diff --git a/packages/ghost-drift/src/core/evolution/composite.ts b/packages/ghost/src/core/evolution/composite.ts similarity index 98% rename from packages/ghost-drift/src/core/evolution/composite.ts rename to packages/ghost/src/core/evolution/composite.ts index bc62739..a84f6dc 100644 --- a/packages/ghost-drift/src/core/evolution/composite.ts +++ b/packages/ghost/src/core/evolution/composite.ts @@ -3,8 +3,8 @@ import type { CompositeComparison, CompositeMember, CompositePair, -} from "@ghost/core"; -import { compareFingerprints, embeddingDistance } from "@ghost/core"; +} from "#ghost-core"; +import { compareFingerprints, embeddingDistance } from "#ghost-core"; export interface CompositeClusterOptions { cluster?: boolean | { maxK?: number }; diff --git a/packages/ghost-drift/src/core/evolution/emit.ts b/packages/ghost/src/core/evolution/emit.ts similarity index 91% rename from packages/ghost-drift/src/core/evolution/emit.ts rename to packages/ghost/src/core/evolution/emit.ts index cf2258d..55fd937 100644 --- a/packages/ghost-drift/src/core/evolution/emit.ts +++ b/packages/ghost/src/core/evolution/emit.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; -import type { Fingerprint } from "@ghost/core"; -import { resolveFingerprintPackage, serializeFingerprint } from "ghost-scan"; +import type { Fingerprint } from "#ghost-core"; +import { resolveFingerprintPackage, serializeFingerprint } from "#scan"; /** * Write a fingerprint as the publishable design-language prior inside the diff --git a/packages/ghost-drift/src/core/evolution/history.ts b/packages/ghost/src/core/evolution/history.ts similarity index 96% rename from packages/ghost-drift/src/core/evolution/history.ts rename to packages/ghost/src/core/evolution/history.ts index 7b39fb1..ee907c7 100644 --- a/packages/ghost-drift/src/core/evolution/history.ts +++ b/packages/ghost/src/core/evolution/history.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import type { FingerprintHistoryEntry } from "@ghost/core"; +import type { FingerprintHistoryEntry } from "#ghost-core"; const GHOST_DIR = ".ghost"; const HISTORY_FILE = "history.jsonl"; diff --git a/packages/ghost-drift/src/core/evolution/index.ts b/packages/ghost/src/core/evolution/index.ts similarity index 89% rename from packages/ghost-drift/src/core/evolution/index.ts rename to packages/ghost/src/core/evolution/index.ts index dbd607b..fe7766b 100644 --- a/packages/ghost-drift/src/core/evolution/index.ts +++ b/packages/ghost/src/core/evolution/index.ts @@ -1,4 +1,4 @@ -export { computeDriftVectors, DIMENSION_RANGES } from "@ghost/core"; +export { computeDriftVectors, DIMENSION_RANGES } from "#ghost-core"; export type { CompositeClusterOptions } from "./composite.js"; export { compareComposite } from "./composite.js"; export { emitFingerprint } from "./emit.js"; diff --git a/packages/ghost-drift/src/core/evolution/sync.ts b/packages/ghost/src/core/evolution/sync.ts similarity index 98% rename from packages/ghost-drift/src/core/evolution/sync.ts rename to packages/ghost/src/core/evolution/sync.ts index 9f2712c..e79e7cd 100644 --- a/packages/ghost-drift/src/core/evolution/sync.ts +++ b/packages/ghost/src/core/evolution/sync.ts @@ -8,8 +8,8 @@ import type { FingerprintComparison, SyncManifest, Target, -} from "@ghost/core"; -import { compareFingerprints } from "@ghost/core"; +} from "#ghost-core"; +import { compareFingerprints } from "#ghost-core"; const SYNC_FILENAME = ".ghost-sync.json"; diff --git a/packages/ghost-drift/src/core/evolution/temporal.ts b/packages/ghost/src/core/evolution/temporal.ts similarity index 97% rename from packages/ghost-drift/src/core/evolution/temporal.ts rename to packages/ghost/src/core/evolution/temporal.ts index a8cacf6..13036f7 100644 --- a/packages/ghost-drift/src/core/evolution/temporal.ts +++ b/packages/ghost/src/core/evolution/temporal.ts @@ -4,8 +4,8 @@ import type { FingerprintHistoryEntry, SyncManifest, TemporalComparison, -} from "@ghost/core"; -import { compareFingerprints, computeDriftVectors } from "@ghost/core"; +} from "#ghost-core"; +import { compareFingerprints, computeDriftVectors } from "#ghost-core"; import { checkBounds } from "./sync.js"; /** diff --git a/packages/ghost-drift/src/core/evolution/tracking.ts b/packages/ghost/src/core/evolution/tracking.ts similarity index 92% rename from packages/ghost-drift/src/core/evolution/tracking.ts rename to packages/ghost/src/core/evolution/tracking.ts index 0f0f198..129e98a 100644 --- a/packages/ghost-drift/src/core/evolution/tracking.ts +++ b/packages/ghost/src/core/evolution/tracking.ts @@ -1,12 +1,12 @@ import { resolve } from "node:path"; -import type { Fingerprint, Target } from "@ghost/core"; -import { resolveTarget } from "@ghost/core"; +import type { Fingerprint, Target } from "#ghost-core"; +import { resolveTarget } from "#ghost-core"; import { FINGERPRINT_FILENAME, loadFingerprint, parseFingerprint, resolveFingerprintPackage, -} from "ghost-scan"; +} from "#scan"; /** * Resolve a Target to a Fingerprint. @@ -46,7 +46,7 @@ export async function resolveTrackedFingerprint( default: throw new Error( - `Cannot resolve tracked fingerprint from target type "${target.type}". Generate one first by running the fingerprint recipe in your host agent (install with "ghost-drift emit skill").`, + `Cannot resolve tracked fingerprint from target type "${target.type}". Generate one first by running the fingerprint recipe in your host agent (install with "ghost skill install").`, ); } } diff --git a/packages/ghost-drift/src/core/index.ts b/packages/ghost/src/core/index.ts similarity index 98% rename from packages/ghost-drift/src/core/index.ts rename to packages/ghost/src/core/index.ts index a6f691f..cf50753 100644 --- a/packages/ghost-drift/src/core/index.ts +++ b/packages/ghost/src/core/index.ts @@ -49,7 +49,7 @@ export type { TokenCategory, TokenFormat, ValueDrift, -} from "@ghost/core"; +} from "#ghost-core"; export { compareFingerprints, computeEmbedding, @@ -57,7 +57,7 @@ export { describeFingerprint, embeddingDistance, inferSemanticRole, -} from "@ghost/core"; +} from "#ghost-core"; export type { GhostDriftChangedFile, GhostDriftChangedLine, diff --git a/packages/ghost-drift/src/core/reporters/composite.ts b/packages/ghost/src/core/reporters/composite.ts similarity index 97% rename from packages/ghost-drift/src/core/reporters/composite.ts rename to packages/ghost/src/core/reporters/composite.ts index a481244..a6c2920 100644 --- a/packages/ghost-drift/src/core/reporters/composite.ts +++ b/packages/ghost/src/core/reporters/composite.ts @@ -1,4 +1,4 @@ -import type { CompositeComparison } from "@ghost/core"; +import type { CompositeComparison } from "#ghost-core"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; diff --git a/packages/ghost-drift/src/core/reporters/fingerprint.ts b/packages/ghost/src/core/reporters/fingerprint.ts similarity index 98% rename from packages/ghost-drift/src/core/reporters/fingerprint.ts rename to packages/ghost/src/core/reporters/fingerprint.ts index a215595..1c03553 100644 --- a/packages/ghost-drift/src/core/reporters/fingerprint.ts +++ b/packages/ghost/src/core/reporters/fingerprint.ts @@ -1,4 +1,4 @@ -import type { Fingerprint, FingerprintComparison } from "@ghost/core"; +import type { Fingerprint, FingerprintComparison } from "#ghost-core"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; diff --git a/packages/ghost-drift/src/core/reporters/temporal.ts b/packages/ghost/src/core/reporters/temporal.ts similarity index 98% rename from packages/ghost-drift/src/core/reporters/temporal.ts rename to packages/ghost/src/core/reporters/temporal.ts index 1ae063e..a2ba837 100644 --- a/packages/ghost-drift/src/core/reporters/temporal.ts +++ b/packages/ghost/src/core/reporters/temporal.ts @@ -1,4 +1,4 @@ -import type { TemporalComparison } from "@ghost/core"; +import type { TemporalComparison } from "#ghost-core"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; diff --git a/packages/ghost-drift/src/core/scope-resolver.ts b/packages/ghost/src/core/scope-resolver.ts similarity index 98% rename from packages/ghost-drift/src/core/scope-resolver.ts rename to packages/ghost/src/core/scope-resolver.ts index 1eef74b..958ecf0 100644 --- a/packages/ghost-drift/src/core/scope-resolver.ts +++ b/packages/ghost/src/core/scope-resolver.ts @@ -1,15 +1,15 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { isAbsolute, join, relative, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; import { getEffectiveMapScopes, MAP_FILENAME, type MapFrontmatter, MapFrontmatterSchema, type MapScope, -} from "@ghost/core"; -import { FINGERPRINT_FILENAME } from "ghost-scan"; -import { parse as parseYaml } from "yaml"; +} from "#ghost-core"; +import { FINGERPRINT_FILENAME } from "#scan"; const FINGERPRINTS_DIRNAME = "fingerprints"; diff --git a/packages/ghost-drift/src/evolution-commands.ts b/packages/ghost/src/evolution-commands.ts similarity index 98% rename from packages/ghost-drift/src/evolution-commands.ts rename to packages/ghost/src/evolution-commands.ts index c6db067..10689d8 100644 --- a/packages/ghost-drift/src/evolution-commands.ts +++ b/packages/ghost/src/evolution-commands.ts @@ -1,5 +1,5 @@ import type { CAC } from "cac"; -import { loadFingerprint, resolveFingerprintPackage } from "ghost-scan"; +import { loadFingerprint, resolveFingerprintPackage } from "#scan"; import type { DimensionStance, Target } from "./core/index.js"; import { acknowledge, diff --git a/packages/ghost/src/ghost-core/checks/index.ts b/packages/ghost/src/ghost-core/checks/index.ts new file mode 100644 index 0000000..6ec7d7f --- /dev/null +++ b/packages/ghost/src/ghost-core/checks/index.ts @@ -0,0 +1,30 @@ +export { lintGhostChecks } from "./lint.js"; +export { + matchesGhostPath, + normalizeGhostPath, + routeGhostChecksForPath, + routeGhostPathToScopes, +} from "./routing.js"; +export { + GhostCheckSchema, + GhostChecksSchema, +} from "./schema.js"; +export type { + GhostCheck, + GhostCheckAppliesTo, + GhostCheckDetector, + GhostCheckDetectorType, + GhostCheckEvidence, + GhostCheckSeverity, + GhostCheckStatus, + GhostChecksDocument, + GhostChecksLintIssue, + GhostChecksLintOptions, + GhostChecksLintReport, + GhostChecksLintSeverity, + RoutedGhostCheck, +} from "./types.js"; +export { + GHOST_CHECKS_FILENAME, + GHOST_CHECKS_SCHEMA, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/checks/lint.ts b/packages/ghost/src/ghost-core/checks/lint.ts new file mode 100644 index 0000000..3760bd2 --- /dev/null +++ b/packages/ghost/src/ghost-core/checks/lint.ts @@ -0,0 +1,205 @@ +import { getEffectiveMapScopes } from "../map/index.js"; +import { GhostChecksSchema } from "./schema.js"; +import type { + GhostCheck, + GhostChecksDocument, + GhostChecksLintIssue, + GhostChecksLintOptions, + GhostChecksLintReport, +} from "./types.js"; + +const SUPPORT_FLOOR = 0.85; + +export function lintGhostChecks( + input: unknown, + options: GhostChecksLintOptions = {}, +): GhostChecksLintReport { + const issues: GhostChecksLintIssue[] = []; + const result = GhostChecksSchema.safeParse(input); + if (!result.success) { + for (const issue of result.error.issues) { + issues.push({ + severity: "error", + rule: `schema/${issue.code}`, + message: issue.message, + path: issue.path.length ? issue.path.join(".") : undefined, + }); + } + return finalize(issues); + } + + const doc = result.data as GhostChecksDocument; + checkDuplicateIds(doc.checks, issues); + doc.checks.forEach((check, index) => { + checkOne(check, index, options, issues); + }); + + return finalize(issues); +} + +function checkDuplicateIds( + checks: GhostCheck[], + issues: GhostChecksLintIssue[], +): void { + const seen = new Map(); + checks.forEach((check, index) => { + const previous = seen.get(check.id); + if (previous !== undefined) { + issues.push({ + severity: "error", + rule: "duplicate-check-id", + message: `check id '${check.id}' is duplicated (also at checks[${previous}])`, + path: `checks[${index}].id`, + }); + } else { + seen.set(check.id, index); + } + }); +} + +function checkOne( + check: GhostCheck, + index: number, + options: GhostChecksLintOptions, + issues: GhostChecksLintIssue[], +): void { + const path = `checks[${index}]`; + checkDetector(check, path, issues); + + if (check.status === "disabled") return; + + if (!check.applies_to?.paths?.length && !check.applies_to?.scopes?.length) { + issues.push({ + severity: check.status === "active" ? "error" : "warning", + rule: "check-scope-missing", + message: + "Checks must declare applies_to.paths or applies_to.scopes so routing is deterministic.", + path: `${path}.applies_to`, + }); + } + + if (options.map && check.applies_to?.scopes?.length) { + const scopeIds = new Set( + getEffectiveMapScopes(options.map).map((scope) => scope.id), + ); + check.applies_to.scopes.forEach((scope, scopeIndex) => { + if (scopeIds.has(scope)) return; + issues.push({ + severity: "error", + rule: "check-scope-unknown", + message: `Check references unknown map scope '${scope}'.`, + path: `${path}.applies_to.scopes[${scopeIndex}]`, + }); + }); + } + + if (!check.evidence) { + issues.push({ + severity: check.status === "active" ? "error" : "warning", + rule: "check-evidence-missing", + message: + "Checks must include evidence with support, observed_count, and examples before they can be trusted.", + path: `${path}.evidence`, + }); + return; + } + + if (typeof check.evidence.support !== "number") { + issues.push({ + severity: check.status === "active" ? "error" : "warning", + rule: "check-support-missing", + message: "Check evidence must include support.", + path: `${path}.evidence.support`, + }); + } else if (check.evidence.support < SUPPORT_FLOOR) { + issues.push({ + severity: "warning", + rule: "check-support-low", + message: `Check support ${check.evidence.support.toFixed(2)} is below ${SUPPORT_FLOOR}; promote only if the curator intentionally accepts noise.`, + path: `${path}.evidence.support`, + }); + } + + if (typeof check.evidence.observed_count !== "number") { + issues.push({ + severity: check.status === "active" ? "error" : "warning", + rule: "check-observed-count-missing", + message: "Check evidence must include observed_count.", + path: `${path}.evidence.observed_count`, + }); + } + + if (!check.evidence.examples?.length) { + issues.push({ + severity: check.status === "active" ? "error" : "warning", + rule: "check-examples-missing", + message: "Check evidence must cite at least one precedent example.", + path: `${path}.evidence.examples`, + }); + } +} + +function checkDetector( + check: GhostCheck, + path: string, + issues: GhostChecksLintIssue[], +): void { + const { detector } = check; + if ( + detector.type === "forbidden-regex" || + detector.type === "required-regex" + ) { + if (!detector.pattern) { + issues.push({ + severity: "error", + rule: "check-detector-pattern-missing", + message: `${detector.type} detectors must include pattern.`, + path: `${path}.detector.pattern`, + }); + return; + } + compileRegex(detector.pattern, `${path}.detector.pattern`, issues); + return; + } + + if (!detector.pattern && !detector.value) { + issues.push({ + severity: "error", + rule: "check-detector-value-missing", + message: `${detector.type} detectors must include pattern or value.`, + path: `${path}.detector`, + }); + return; + } + if (detector.pattern) { + compileRegex(detector.pattern, `${path}.detector.pattern`, issues); + } +} + +function compileRegex( + pattern: string, + path: string, + issues: GhostChecksLintIssue[], +): void { + try { + new RegExp(pattern); + } catch (err) { + issues.push({ + severity: "error", + rule: "check-detector-pattern-invalid", + message: `Detector pattern is not a valid JavaScript regular expression: ${ + err instanceof Error ? err.message : String(err) + }`, + path, + }); + } +} + +function finalize(issues: GhostChecksLintIssue[]): GhostChecksLintReport { + return { + issues, + errors: issues.filter((issue) => issue.severity === "error").length, + warnings: issues.filter((issue) => issue.severity === "warning").length, + info: issues.filter((issue) => issue.severity === "info").length, + }; +} diff --git a/packages/ghost/src/ghost-core/checks/routing.ts b/packages/ghost/src/ghost-core/checks/routing.ts new file mode 100644 index 0000000..a30e915 --- /dev/null +++ b/packages/ghost/src/ghost-core/checks/routing.ts @@ -0,0 +1,84 @@ +import { + getEffectiveMapScopes, + type MapFrontmatter, + type MapScope, +} from "../map/index.js"; +import type { GhostCheck, RoutedGhostCheck } from "./types.js"; + +export function normalizeGhostPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +export function matchesGhostPath(path: string, scopePath: string): boolean { + const changedPath = normalizeGhostPath(path); + const pattern = normalizeGhostPath(scopePath); + if (pattern.includes("*")) { + return globToRegExp(pattern).test(changedPath); + } + + const normalized = pattern.replace(/\/$/, ""); + return changedPath === normalized || changedPath.startsWith(`${normalized}/`); +} + +export function routeGhostPathToScopes( + map: Pick, + changedPath: string, +): MapScope[] { + const scopes = getEffectiveMapScopes(map).sort(compareScopeSpecificity); + return scopes.filter((scope) => + scope.paths.some((pattern) => matchesGhostPath(changedPath, pattern)), + ); +} + +export function routeGhostChecksForPath( + checks: GhostCheck[], + map: Pick, + changedPath: string, +): RoutedGhostCheck[] { + const matchedScopes = routeGhostPathToScopes(map, changedPath); + return checks + .filter((check) => check.status === "active") + .flatMap((check) => { + const applies = check.applies_to; + if (!applies) return [{ check, matched_scopes: matchedScopes }]; + + const pathMatched = + !applies.paths?.length || + applies.paths.some((pattern) => matchesGhostPath(changedPath, pattern)); + const scopeMatched = + !applies.scopes?.length || + matchedScopes.some((scope) => applies.scopes?.includes(scope.id)); + + return pathMatched && scopeMatched + ? [{ check, matched_scopes: matchedScopes }] + : []; + }); +} + +function compareScopeSpecificity(a: MapScope, b: MapScope): number { + const aMax = Math.max(...a.paths.map((path) => path.length)); + const bMax = Math.max(...b.paths.map((path) => path.length)); + return bMax - aMax || a.id.localeCompare(b.id); +} + +function globToRegExp(glob: string): RegExp { + let out = "^"; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + const next = glob[i + 1]; + if (char === "*" && next === "*") { + out += ".*"; + i += 1; + } else if (char === "*") { + out += "[^/]*"; + } else { + out += escapeRegExp(char); + } + } + out += "$"; + return new RegExp(out); +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} diff --git a/packages/ghost/src/ghost-core/checks/schema.ts b/packages/ghost/src/ghost-core/checks/schema.ts new file mode 100644 index 0000000..1438781 --- /dev/null +++ b/packages/ghost/src/ghost-core/checks/schema.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { GHOST_CHECKS_SCHEMA } from "./types.js"; + +const GhostCheckStatusSchema = z.enum(["active", "proposed", "disabled"]); +const GhostCheckSeveritySchema = z.enum(["critical", "serious", "nit"]); + +const GhostCheckAppliesToSchema = z + .object({ + scopes: z.array(z.string().min(1)).optional(), + paths: z.array(z.string().min(1)).optional(), + surface_types: z.array(z.string().min(1)).optional(), + pattern_ids: z.array(z.string().min(1)).optional(), + }) + .strict(); + +const GhostCheckDetectorSchema = z + .object({ + type: z.enum([ + "forbidden-regex", + "required-regex", + "banned-import", + "banned-component", + "required-token", + ]), + pattern: z.string().min(1).optional(), + value: z.string().min(1).optional(), + contexts: z.array(z.string().min(1)).optional(), + }) + .strict(); + +const GhostCheckEvidenceExampleSchema = z.union([ + z.string().min(1), + z + .object({ + path: z.string().min(1), + note: z.string().min(1).optional(), + }) + .strict(), +]); + +const GhostCheckEvidenceSchema = z + .object({ + support: z.number().min(0).max(1).optional(), + observed_count: z.number().int().nonnegative().optional(), + examples: z.array(GhostCheckEvidenceExampleSchema).optional(), + }) + .strict(); + +export const GhostCheckSchema = z + .object({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }), + title: z.string().min(1), + status: GhostCheckStatusSchema, + severity: GhostCheckSeveritySchema, + applies_to: GhostCheckAppliesToSchema.optional(), + detector: GhostCheckDetectorSchema, + evidence: GhostCheckEvidenceSchema.optional(), + repair: z.string().min(1).optional(), + }) + .strict(); + +export const GhostChecksSchema = z + .object({ + schema: z.literal(GHOST_CHECKS_SCHEMA), + id: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }), + checks: z.array(GhostCheckSchema), + }) + .strict(); diff --git a/packages/ghost/src/ghost-core/checks/types.ts b/packages/ghost/src/ghost-core/checks/types.ts new file mode 100644 index 0000000..417340d --- /dev/null +++ b/packages/ghost/src/ghost-core/checks/types.ts @@ -0,0 +1,76 @@ +import type { MapFrontmatter, MapScope } from "../map/index.js"; + +export const GHOST_CHECKS_SCHEMA = "ghost.checks/v1" as const; +export const GHOST_CHECKS_FILENAME = "checks.yml" as const; + +export type GhostCheckStatus = "active" | "proposed" | "disabled"; +export type GhostCheckSeverity = "critical" | "serious" | "nit"; + +export type GhostCheckDetectorType = + | "forbidden-regex" + | "required-regex" + | "banned-import" + | "banned-component" + | "required-token"; + +export interface GhostCheckAppliesTo { + scopes?: string[]; + paths?: string[]; + surface_types?: string[]; + pattern_ids?: string[]; +} + +export interface GhostCheckDetector { + type: GhostCheckDetectorType; + pattern?: string; + value?: string; + contexts?: string[]; +} + +export interface GhostCheckEvidence { + support?: number; + observed_count?: number; + examples?: Array; +} + +export interface GhostCheck { + id: string; + title: string; + status: GhostCheckStatus; + severity: GhostCheckSeverity; + applies_to?: GhostCheckAppliesTo; + detector: GhostCheckDetector; + evidence?: GhostCheckEvidence; + repair?: string; +} + +export interface GhostChecksDocument { + schema: typeof GHOST_CHECKS_SCHEMA; + id: string; + checks: GhostCheck[]; +} + +export type GhostChecksLintSeverity = "error" | "warning" | "info"; + +export interface GhostChecksLintIssue { + severity: GhostChecksLintSeverity; + rule: string; + message: string; + path?: string; +} + +export interface GhostChecksLintReport { + issues: GhostChecksLintIssue[]; + errors: number; + warnings: number; + info: number; +} + +export interface GhostChecksLintOptions { + map?: Pick; +} + +export interface RoutedGhostCheck { + check: GhostCheck; + matched_scopes: MapScope[]; +} diff --git a/packages/ghost/src/ghost-core/decision-vocabulary.ts b/packages/ghost/src/ghost-core/decision-vocabulary.ts new file mode 100644 index 0000000..d1e9629 --- /dev/null +++ b/packages/ghost/src/ghost-core/decision-vocabulary.ts @@ -0,0 +1,282 @@ +/** + * Canonical decision-dimension vocabulary. + * + * Free-form `decisions[].dimension` slugs are great for authoring but bad + * for fleet aggregation: ghost-ui's `color-strategy` and a hypothetical + * Cash app's `color-system` describe the same axis under different names, + * and N-way overlap on incidentally-shared labels is not a basis for + * cross-system distance. + * + * The fix is a small controlled vocabulary. Fingerprint authors pick from this list + * first; non-canonical slugs are still permitted (the schema allows any + * string), but the recommended pattern is to pair them with a + * `dimension_kind` that maps to a canonical slug. Lint warns when a + * non-canonical dimension has no canonical kind. Fleet-rollup primitives + * group by `dimension_kind` (or by `dimension` when it's already + * canonical) so the decision-overlap distance axis becomes meaningful. + * + * The list below started from the actual decisions produced by fingerprinting + * ghost-ui, then absorbed dogfood learnings where generated UI needed a + * first-class place for task-shaped composition rather than treating every + * answer as a generic card stack. + */ +export const CANONICAL_DECISION_DIMENSIONS = [ + "color-strategy", + "surface-hierarchy", + "shape-language", + "typography-voice", + "spatial-system", + "density", + "motion", + "elevation", + "theming-architecture", + "interactive-patterns", + "token-architecture", + "font-sourcing", + "composition-patterns", +] as const; + +export type CanonicalDecisionDimension = + (typeof CANONICAL_DECISION_DIMENSIONS)[number]; + +const CANONICAL_SET: ReadonlySet = new Set( + CANONICAL_DECISION_DIMENSIONS, +); + +/** + * Direct synonyms — common slug variants we've observed or expect, mapped + * to the canonical dimension. Lookup is exact-match (post-normalization). + */ +const SYNONYMS: Readonly> = { + // color-strategy + "color-system": "color-strategy", + "color-philosophy": "color-strategy", + "color-approach": "color-strategy", + "palette-strategy": "color-strategy", + "palette-system": "color-strategy", + "hue-strategy": "color-strategy", + // surface-hierarchy + "surface-vocabulary": "surface-hierarchy", + "surface-system": "surface-hierarchy", + "background-hierarchy": "surface-hierarchy", + "background-system": "surface-hierarchy", + // shape-language + "radius-philosophy": "shape-language", + "radius-strategy": "shape-language", + "corner-treatment": "shape-language", + "corner-radii": "shape-language", + geometry: "shape-language", + // typography-voice + "type-voice": "typography-voice", + "type-stack": "typography-voice", + "type-hierarchy": "typography-voice", + "typographic-voice": "typography-voice", + "typography-system": "typography-voice", + // spatial-system + spacing: "spatial-system", + "spacing-scale": "spatial-system", + "spacing-system": "spatial-system", + "layout-rhythm": "spatial-system", + // density + compactness: "density", + "control-density": "density", + // motion + animation: "motion", + "motion-language": "motion", + "motion-system": "motion", + "animation-philosophy": "motion", + // elevation + "shadow-system": "elevation", + "shadow-vocabulary": "elevation", + "depth-language": "elevation", + // theming-architecture + theming: "theming-architecture", + "theme-architecture": "theming-architecture", + "theme-system": "theming-architecture", + themeability: "theming-architecture", + // interactive-patterns + "interaction-patterns": "interactive-patterns", + "focus-treatment": "interactive-patterns", + "hover-system": "interactive-patterns", + "interaction-design": "interactive-patterns", + // token-architecture + "token-system": "token-architecture", + "token-cascade": "token-architecture", + "token-layering": "token-architecture", + // font-sourcing + "font-stack": "font-sourcing", + "font-strategy": "font-sourcing", + "font-loading": "font-sourcing", + "font-bundling": "font-sourcing", + // composition-patterns + "composition-shape": "composition-patterns", + "composition-shapes": "composition-patterns", + "response-shape": "composition-patterns", + "response-shapes": "composition-patterns", + "output-shape": "composition-patterns", + "output-shapes": "composition-patterns", + "layout-patterns": "composition-patterns", + "exemplar-shapes": "composition-patterns", +}; + +/** + * Token-level affinity — when a slug has no direct synonym, score it by + * how strongly its dash-separated tokens evoke each canonical dimension. + * The token "color" alone is a strong signal for color-strategy; "shadow" + * is strong for elevation. Used by `closestCanonical` as a fallback. + * + * Each entry is `[token, dimension]`. A token may map to multiple + * dimensions (e.g. "font" hints both font-sourcing and typography-voice); + * the scorer sums signals across dimensions and returns the strongest. + */ +const TOKEN_HINTS: ReadonlyArray< + readonly [string, CanonicalDecisionDimension] +> = [ + ["color", "color-strategy"], + ["palette", "color-strategy"], + ["hue", "color-strategy"], + ["chroma", "color-strategy"], + ["surface", "surface-hierarchy"], + ["background", "surface-hierarchy"], + ["bg", "surface-hierarchy"], + ["radius", "shape-language"], + ["radii", "shape-language"], + ["corner", "shape-language"], + ["shape", "shape-language"], + ["pill", "shape-language"], + ["typography", "typography-voice"], + ["type", "typography-voice"], + ["typographic", "typography-voice"], + ["heading", "typography-voice"], + ["spacing", "spatial-system"], + ["space", "spatial-system"], + ["spatial", "spatial-system"], + ["layout", "spatial-system"], + ["rhythm", "spatial-system"], + ["density", "density"], + ["compact", "density"], + ["motion", "motion"], + ["animation", "motion"], + ["transition", "motion"], + ["shadow", "elevation"], + ["elevation", "elevation"], + ["depth", "elevation"], + ["theme", "theming-architecture"], + ["theming", "theming-architecture"], + ["themeable", "theming-architecture"], + ["interaction", "interactive-patterns"], + ["interactive", "interactive-patterns"], + ["focus", "interactive-patterns"], + ["hover", "interactive-patterns"], + ["token", "token-architecture"], + ["alias", "token-architecture"], + ["cascade", "token-architecture"], + ["font", "font-sourcing"], + ["typeface", "font-sourcing"], + ["composition", "composition-patterns"], + ["response", "composition-patterns"], + ["output", "composition-patterns"], + ["article", "composition-patterns"], + ["tracker", "composition-patterns"], + ["comparison", "composition-patterns"], +]; + +/** + * Normalize a dimension slug for lookup: trim, lowercase, collapse + * separators (`_`, ` `, repeated `-`) into single dashes. + */ +function normalize(slug: string): string { + return slug + .trim() + .toLowerCase() + .replace(/[_\s]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Returns true when `slug` is in the canonical vocabulary (after + * normalization). Use to gate fleet-aggregation paths that require + * commensurable dimension labels across members. + */ +export function isCanonicalDimension( + slug: string, +): slug is CanonicalDecisionDimension { + return CANONICAL_SET.has(normalize(slug)); +} + +/** + * Suggest the closest canonical dimension for a free-form slug. + * + * Resolution order: + * 1. Exact canonical match (after normalization). + * 2. Direct synonym lookup. + * 3. Token-affinity scoring across `TOKEN_HINTS` — wins when a single + * dimension scores strictly higher than all others. + * 4. `null` when there's no clear winner. Callers should treat null as + * "this slug is genuinely novel; lint warns and the fingerprint keeps it + * long-tail." + * + * Pure / deterministic. No I/O. + */ +export function closestCanonical( + slug: string, +): CanonicalDecisionDimension | null { + if (!slug) return null; + const norm = normalize(slug); + if (!norm) return null; + + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + + const synonym = SYNONYMS[norm]; + if (synonym) return synonym; + + const tokens = norm.split("-").filter(Boolean); + if (tokens.length === 0) return null; + + const scores = new Map(); + for (const token of tokens) { + for (const [hint, dim] of TOKEN_HINTS) { + if (hint === token) { + scores.set(dim, (scores.get(dim) ?? 0) + 1); + } + } + } + if (scores.size === 0) return null; + + let best: CanonicalDecisionDimension | null = null; + let bestScore = 0; + let tied = false; + for (const [dim, score] of scores) { + if (score > bestScore) { + best = dim; + bestScore = score; + tied = false; + } else if (score === bestScore) { + tied = true; + } + } + return tied ? null : best; +} + +/** + * Resolve a decision's effective canonical dimension for fleet rollup: + * prefer an explicit `dimension_kind` (when it's canonical), otherwise + * fall back to the slug if it's canonical, otherwise null. + * + * The fleet aggregator groups decisions by this resolved value; null + * means the decision lives in the long tail and is reported per-member, + * not aggregated. + */ +export function resolveDecisionKind(decision: { + dimension: string; + dimension_kind?: string; +}): CanonicalDecisionDimension | null { + if (decision.dimension_kind) { + const norm = normalize(decision.dimension_kind); + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + } + const norm = normalize(decision.dimension); + if (CANONICAL_SET.has(norm)) return norm as CanonicalDecisionDimension; + return null; +} diff --git a/packages/ghost/src/ghost-core/embedding/colors.ts b/packages/ghost/src/ghost-core/embedding/colors.ts new file mode 100644 index 0000000..1e8b6e8 --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/colors.ts @@ -0,0 +1,335 @@ +import type { SemanticColor } from "../types.js"; + +// Parse hex color to RGB +function hexToRgb(hex: string): [number, number, number] | null { + const cleaned = hex.replace("#", ""); + let r: number; + let g: number; + let b: number; + + if (cleaned.length === 3) { + r = Number.parseInt(cleaned[0] + cleaned[0], 16); + g = Number.parseInt(cleaned[1] + cleaned[1], 16); + b = Number.parseInt(cleaned[2] + cleaned[2], 16); + } else if (cleaned.length === 6) { + r = Number.parseInt(cleaned.slice(0, 2), 16); + g = Number.parseInt(cleaned.slice(2, 4), 16); + b = Number.parseInt(cleaned.slice(4, 6), 16); + } else { + return null; + } + + return [r, g, b]; +} + +// Parse rgb()/rgba() to RGB +function parseRgbFunction(value: string): [number, number, number] | null { + const match = value.match(/rgba?\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +// Parse hsl()/hsla() to RGB +function parseHslFunction(value: string): [number, number, number] | null { + const match = value.match( + /hsla?\(\s*([\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?/, + ); + if (!match) return null; + + let h = Number(match[1]) % 360; + if (h < 0) h += 360; + const s = Math.min(Number(match[2]), 100) / 100; + const l = Math.min(Number(match[3]), 100) / 100; + + // HSL to RGB conversion + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + + let r1: number; + let g1: number; + let b1: number; + + if (h < 60) { + [r1, g1, b1] = [c, x, 0]; + } else if (h < 120) { + [r1, g1, b1] = [x, c, 0]; + } else if (h < 180) { + [r1, g1, b1] = [0, c, x]; + } else if (h < 240) { + [r1, g1, b1] = [0, x, c]; + } else if (h < 300) { + [r1, g1, b1] = [x, 0, c]; + } else { + [r1, g1, b1] = [c, 0, x]; + } + + return [ + Math.round((r1 + m) * 255), + Math.round((g1 + m) * 255), + Math.round((b1 + m) * 255), + ]; +} + +// Common CSS named colors (top 20 most used in real projects) +const CSS_NAMED_COLORS: Record = { + white: [255, 255, 255], + black: [0, 0, 0], + red: [255, 0, 0], + green: [0, 128, 0], + blue: [0, 0, 255], + yellow: [255, 255, 0], + orange: [255, 165, 0], + purple: [128, 0, 128], + pink: [255, 192, 203], + gray: [128, 128, 128], + grey: [128, 128, 128], + navy: [0, 0, 128], + teal: [0, 128, 128], + coral: [255, 127, 80], + salmon: [250, 128, 114], + tomato: [255, 99, 71], + gold: [255, 215, 0], + silver: [192, 192, 192], + maroon: [128, 0, 0], + aqua: [0, 255, 255], + cyan: [0, 255, 255], + lime: [0, 255, 0], + indigo: [75, 0, 130], + violet: [238, 130, 238], + crimson: [220, 20, 60], + magenta: [255, 0, 255], + turquoise: [64, 224, 208], + ivory: [255, 255, 240], + beige: [245, 245, 220], + khaki: [240, 230, 140], +}; + +// CSS system color defaults (mapped to sensible RGB values) +const SYSTEM_COLORS: Record = { + canvas: [255, 255, 255], + canvastext: [0, 0, 0], + linktext: [0, 0, 238], + visitedtext: [85, 26, 139], + activetext: [255, 0, 0], + buttonface: [240, 240, 240], + buttontext: [0, 0, 0], + buttonborder: [118, 118, 118], + field: [255, 255, 255], + fieldtext: [0, 0, 0], + highlight: [0, 120, 215], + highlighttext: [255, 255, 255], + graytext: [109, 109, 109], + mark: [255, 255, 0], + marktext: [0, 0, 0], +}; + +// Convert sRGB to linear RGB +function linearize(c: number): number { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +} + +// Convert linear RGB to OKLCH via OKLab +// Simplified implementation based on the OKLCH spec +function rgbToOklch(r: number, g: number, b: number): [number, number, number] { + const lr = linearize(r); + const lg = linearize(g); + const lb = linearize(b); + + // sRGB to LMS (using OKLab matrix) + const l = Math.cbrt( + 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, + ); + const m = Math.cbrt( + 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, + ); + const s = Math.cbrt( + 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, + ); + + // LMS to OKLab + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bVal = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + + // OKLab to OKLCH + const C = Math.sqrt(a * a + bVal * bVal); + let H = (Math.atan2(bVal, a) * 180) / Math.PI; + if (H < 0) H += 360; + + return [ + Math.round(L * 1000) / 1000, + Math.round(C * 1000) / 1000, + Math.round(H * 10) / 10, + ]; +} + +// Parse color-mix() in OKLCH space +function parseColorMix(value: string): [number, number, number] | null { + const match = value.match( + /color-mix\(\s*in\s+oklch\s*,\s*(.+?)\s+(\d+)%\s*,\s*(.+?)(?:\s+(\d+)%)?\s*\)/, + ); + if (!match) return null; + + const color1 = parseColorToOklch(match[1]); + const color2 = parseColorToOklch(match[3]); + if (!color1 || !color2) return null; + + const pct1 = Number(match[2]) / 100; + const pct2 = match[4] ? Number(match[4]) / 100 : 1 - pct1; + + // Normalize percentages + const total = pct1 + pct2; + const w1 = pct1 / total; + const w2 = pct2 / total; + + // Interpolate hue via shortest arc + const h1 = color1[2]; + const h2 = color2[2]; + let hDiff = h2 - h1; + if (hDiff > 180) hDiff -= 360; + if (hDiff < -180) hDiff += 360; + const hue = (((h1 + w2 * hDiff) % 360) + 360) % 360; + + return [ + Math.round((color1[0] * w1 + color2[0] * w2) * 1000) / 1000, + Math.round((color1[1] * w1 + color2[1] * w2) * 1000) / 1000, + Math.round(hue * 10) / 10, + ]; +} + +export function parseColorToOklch( + value: string, +): [number, number, number] | null { + const trimmed = value.trim().toLowerCase(); + + // Skip CSS variables and transparent + if ( + trimmed.startsWith("var(") || + trimmed === "transparent" || + trimmed === "currentcolor" + ) { + return null; + } + + // Try hex + if (trimmed.startsWith("#")) { + const rgb = hexToRgb(trimmed); + if (rgb) return rgbToOklch(...rgb); + } + + // Try rgb()/rgba() + if (trimmed.startsWith("rgb")) { + const rgb = parseRgbFunction(trimmed); + if (rgb) return rgbToOklch(...rgb); + } + + // Try hsl()/hsla() + if (trimmed.startsWith("hsl")) { + const rgb = parseHslFunction(trimmed); + if (rgb) return rgbToOklch(...rgb); + } + + // Try oklch() directly — handle both decimal and percentage lightness + const oklchMatch = trimmed.match( + /oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)/, + ); + if (oklchMatch) { + let L = Number(oklchMatch[1]); + if (oklchMatch[2] === "%") L /= 100; + return [L, Number(oklchMatch[3]), Number(oklchMatch[4])]; + } + + // Try color-mix(in oklch, ...) + if (trimmed.startsWith("color-mix(")) { + return parseColorMix(trimmed); + } + + // Try CSS system colors + const systemRgb = SYSTEM_COLORS[trimmed]; + if (systemRgb) return rgbToOklch(...systemRgb); + + // Try CSS named colors + const namedRgb = CSS_NAMED_COLORS[trimmed]; + if (namedRgb) return rgbToOklch(...namedRgb); + + return null; +} + +export function colorToSemanticColor( + role: string, + value: string, +): SemanticColor { + const oklch = parseColorToOklch(value); + return { role, value, oklch: oklch ?? undefined }; +} + +/** + * Resolve a color's oklch tuple, computing on-the-fly from `value` if the + * field is missing. Defensive backstop for palette comparisons — without + * this, hex-only colors land in the "unmatched" branch and contribute + * distance 1 even when both sides have the same hex. + * + * `loadFingerprint` (in ghost) already backfills oklch on read; + * this fallback covers third-party producers that emit hex-only. + */ +export function resolveColorOklch( + c: SemanticColor, +): [number, number, number] | null { + if (c.oklch && c.oklch.length === 3) return c.oklch; + return parseColorToOklch(c.value); +} + +export function classifySaturation( + colors: SemanticColor[], +): "muted" | "vibrant" | "mixed" { + const chromas = colors.map((c) => c.oklch?.[1] ?? 0).filter((c) => c > 0); + + if (chromas.length === 0) return "muted"; + + const avg = chromas.reduce((a, b) => a + b, 0) / chromas.length; + if (avg > 0.15) return "vibrant"; + if (avg < 0.05) return "muted"; + return "mixed"; +} + +export function classifyContrast( + colors: SemanticColor[], +): "high" | "moderate" | "low" { + const lightnesses = colors + .map((c) => c.oklch?.[0] ?? 0.5) + .filter((_, _i, arr) => arr.length > 1); + + if (lightnesses.length < 2) return "moderate"; + + const min = Math.min(...lightnesses); + const max = Math.max(...lightnesses); + const range = max - min; + + if (range > 0.7) return "high"; + if (range < 0.3) return "low"; + return "moderate"; +} + +/** + * Continuous saturation score (0-1) for embedding use. + * Avoids the lossy categorical→numeric mapping. + */ +export function saturationScore(colors: SemanticColor[]): number { + const chromas = colors.map((c) => c.oklch?.[1] ?? 0).filter((c) => c > 0); + if (chromas.length === 0) return 0; + const avg = chromas.reduce((a, b) => a + b, 0) / chromas.length; + return Math.min(avg / 0.25, 1); +} + +/** + * Continuous contrast score (0-1) for embedding use. + * Based on lightness range of the palette. + */ +export function contrastScore(colors: SemanticColor[]): number { + const lightnesses = colors.map((c) => c.oklch?.[0] ?? 0.5); + if (lightnesses.length < 2) return 0.5; + const range = Math.max(...lightnesses) - Math.min(...lightnesses); + return Math.min(range / 0.9, 1); +} diff --git a/packages/ghost/src/ghost-core/embedding/compare.ts b/packages/ghost/src/ghost-core/embedding/compare.ts new file mode 100644 index 0000000..e2fbdeb --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/compare.ts @@ -0,0 +1,591 @@ +import type { + DimensionDelta, + Fingerprint, + FingerprintComparison, +} from "../types.js"; +import { resolveColorOklch } from "./colors.js"; +import { computeDriftVectors } from "./vector.js"; + +export interface CompareOptions { + includeVectors?: boolean; +} + +const WEIGHTS: Record = { + palette: 0.35, + spacing: 0.25, + typography: 0.25, + surfaces: 0.15, +}; + +/** Redistributed weights when both fingerprints have design decisions */ +const WEIGHTS_WITH_DECISIONS: Record = { + decisions: 0.15, + palette: 0.3, + spacing: 0.2, + typography: 0.2, + surfaces: 0.15, +}; + +export function compareFingerprints( + source: Fingerprint, + target: Fingerprint, + options?: CompareOptions, +): FingerprintComparison { + const dimensions: Record = {}; + + // Compare decisions when both fingerprints have them. + // Decisions only contribute to the weighted distance when both sides have + // embeddings — otherwise we record a qualitative delta without a scalar + // that would pollute the number. + const bothHaveDecisions = + (source.decisions?.length ?? 0) > 0 && (target.decisions?.length ?? 0) > 0; + const bothEmbedded = + bothHaveDecisions && + (source.decisions ?? []).every((d) => Array.isArray(d.embedding)) && + (target.decisions ?? []).every((d) => Array.isArray(d.embedding)); + + if (bothHaveDecisions) { + dimensions.decisions = compareDecisions(source, target, bothEmbedded); + } + + dimensions.palette = comparePalette(source, target); + dimensions.spacing = compareSpacing(source, target); + dimensions.typography = compareTypography(source, target); + dimensions.surfaces = compareSurfaces(source, target); + + // Only use decision-inclusive weights when decisions are actually scored + const weights = bothEmbedded ? WEIGHTS_WITH_DECISIONS : WEIGHTS; + + // Weighted overall distance + let distance = 0; + for (const [key, weight] of Object.entries(weights)) { + distance += (dimensions[key]?.distance ?? 0) * weight; + } + + const summary = buildSummary(dimensions, distance); + + const result: FingerprintComparison = { + source, + target, + distance, + dimensions, + summary, + }; + + if (options?.includeVectors) { + result.vectors = computeDriftVectors(source, target); + } + + return result; +} + +function comparePalette(a: Fingerprint, b: Fingerprint): DimensionDelta { + const distances: number[] = []; + + // Compare dominant colors by role, then by position for unmatched + const aByRole = new Map(a.palette.dominant.map((c) => [c.role, c])); + const bByRole = new Map(b.palette.dominant.map((c) => [c.role, c])); + const allDominantRoles = new Set([...aByRole.keys(), ...bByRole.keys()]); + const matchedA = new Set(); + const matchedB = new Set(); + + // First pass: match by role name + for (const role of allDominantRoles) { + const ca = aByRole.get(role); + const cb = bByRole.get(role); + if (!ca || !cb) continue; + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + matchedA.add(role); + matchedB.add(role); + } else if (ca.value === cb.value) { + // Both hex-only on a non-parseable value — but the values match. + // Treat as identical rather than falling through to "unmatched". + distances.push(0); + matchedA.add(role); + matchedB.add(role); + } + } + + // Second pass: unmatched colors count as missing + const unmatchedA = a.palette.dominant.filter((c) => !matchedA.has(c.role)); + const unmatchedB = b.palette.dominant.filter((c) => !matchedB.has(c.role)); + const unmatchedCount = Math.max(unmatchedA.length, unmatchedB.length); + for (let i = 0; i < unmatchedCount; i++) { + const ca = unmatchedA[i]; + const cb = unmatchedB[i]; + if (!ca || !cb) { + distances.push(1); + continue; + } + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + } else if (ca.value === cb.value) { + distances.push(0); + } else { + distances.push(1); + } + } + + // Compare semantic role coverage + const aRoles = new Set(a.palette.semantic.map((c) => c.role)); + const bRoles = new Set(b.palette.semantic.map((c) => c.role)); + const allRoles = new Set([...aRoles, ...bRoles]); + const sharedRoles = [...allRoles].filter( + (r) => aRoles.has(r) && bRoles.has(r), + ); + const roleCoverage = + allRoles.size > 0 ? 1 - sharedRoles.length / allRoles.size : 0; + distances.push(roleCoverage); + + // Compare qualitative + if (a.palette.saturationProfile !== b.palette.saturationProfile) + distances.push(0.5); + if (a.palette.contrast !== b.palette.contrast) distances.push(0.5); + + // Compare semantic colors that exist in both + for (const role of sharedRoles) { + const ca = a.palette.semantic.find((c) => c.role === role); + const cb = b.palette.semantic.find((c) => c.role === role); + if (!ca || !cb) continue; + const oa = resolveColorOklch(ca); + const ob = resolveColorOklch(cb); + if (oa && ob) { + distances.push(oklchDistance(oa, ob)); + } else if (ca.value === cb.value) { + distances.push(0); + } + } + + const distance = avg(distances); + const description = describePaletteChange(a, b, distance); + + return { dimension: "palette", distance, description }; +} + +function compareSpacing(a: Fingerprint, b: Fingerprint): DimensionDelta { + const distances: number[] = []; + + // Scale similarity (Jaccard-like) + const aSet = new Set(a.spacing.scale); + const bSet = new Set(b.spacing.scale); + const union = new Set([...aSet, ...bSet]); + const intersection = [...union].filter((v) => aSet.has(v) && bSet.has(v)); + distances.push(union.size > 0 ? 1 - intersection.length / union.size : 0); + + // Regularity delta + distances.push(Math.abs(a.spacing.regularity - b.spacing.regularity)); + + // Base unit match + if (a.spacing.baseUnit && b.spacing.baseUnit) { + distances.push(a.spacing.baseUnit === b.spacing.baseUnit ? 0 : 0.5); + } + + const distance = avg(distances); + return { + dimension: "spacing", + distance, + description: + distance < 0.1 + ? "Spacing scales are nearly identical" + : distance < 0.3 + ? "Minor spacing differences" + : "Significant spacing divergence", + }; +} + +function compareTypography(a: Fingerprint, b: Fingerprint): DimensionDelta { + const distances: number[] = []; + + // Family match — fuzzy comparison + distances.push( + 1 - fontListSimilarity(a.typography.families, b.typography.families), + ); + + // Size ramp similarity + const aRamp = new Set(a.typography.sizeRamp); + const bRamp = new Set(b.typography.sizeRamp); + const rampUnion = new Set([...aRamp, ...bRamp]); + const rampIntersection = [...rampUnion].filter( + (v) => aRamp.has(v) && bRamp.has(v), + ); + distances.push( + rampUnion.size > 0 ? 1 - rampIntersection.length / rampUnion.size : 0, + ); + + // Line height pattern + if (a.typography.lineHeightPattern !== b.typography.lineHeightPattern) + distances.push(0.3); + + const distance = avg(distances); + return { + dimension: "typography", + distance, + description: + distance < 0.1 + ? "Typography systems match" + : distance < 0.3 + ? "Minor typographic differences" + : "Different typographic language", + }; +} + +function compareSurfaces(a: Fingerprint, b: Fingerprint): DimensionDelta { + const distances: number[] = []; + + // Border radii overlap + const aRadii = new Set(a.surfaces.borderRadii); + const bRadii = new Set(b.surfaces.borderRadii); + const radiiUnion = new Set([...aRadii, ...bRadii]); + const radiiIntersection = [...radiiUnion].filter( + (v) => aRadii.has(v) && bRadii.has(v), + ); + distances.push( + radiiUnion.size > 0 ? 1 - radiiIntersection.length / radiiUnion.size : 0, + ); + + // Shadow complexity + if (a.surfaces.shadowComplexity !== b.surfaces.shadowComplexity) + distances.push(0.5); + + // Border usage + if (a.surfaces.borderUsage !== b.surfaces.borderUsage) distances.push(0.3); + + const distance = avg(distances); + return { + dimension: "surfaces", + distance, + description: + distance < 0.1 + ? "Surface treatments align" + : distance < 0.3 + ? "Minor surface differences" + : "Distinct surface language", + }; +} + +// --- Decision matching --- + +/** Minimum cosine similarity to consider two decisions "the same dimension". */ +const DECISION_MATCH_THRESHOLD = 0.75; + +/** + * Compare design decisions between two fingerprints. + * + * When `bothEmbedded` is true: match decisions pairwise by cosine similarity + * of their embeddings. Distance blends unmatched coverage with the cosine + * distance of matched pairs. Deterministic and paraphrase-robust. + * + * When `bothEmbedded` is false: record a qualitative delta but return distance 0 + * so decisions don't pollute the weighted scalar. Callers exclude this dimension + * from the weighted distance (see `WEIGHTS` vs `WEIGHTS_WITH_DECISIONS`). + */ +function compareDecisions( + a: Fingerprint, + b: Fingerprint, + bothEmbedded: boolean, +): DimensionDelta { + const aDecs = a.decisions ?? []; + const bDecs = b.decisions ?? []; + + if (!bothEmbedded) { + return { + dimension: "decisions", + distance: 0, + description: `Decisions present (${aDecs.length} vs ${bDecs.length}) but embeddings missing — not scored`, + }; + } + + // Greedy one-to-one match: for each decision in A, find the best unmatched + // decision in B above threshold. Stable and O(n*m), which is fine for + // fingerprints with ~5–15 decisions. + const matchedB = new Set(); + const matchedCosines: number[] = []; + + for (const da of aDecs) { + let bestJ = -1; + let bestCos = DECISION_MATCH_THRESHOLD; + for (let j = 0; j < bDecs.length; j++) { + if (matchedB.has(j)) continue; + const cos = cosineSimilarity( + da.embedding as number[], + bDecs[j].embedding as number[], + ); + if (cos > bestCos) { + bestCos = cos; + bestJ = j; + } + } + if (bestJ >= 0) { + matchedB.add(bestJ); + matchedCosines.push(bestCos); + } + } + + const matchCount = matchedCosines.length; + const totalDecs = aDecs.length + bDecs.length; + + // Coverage: fraction of decisions that went unmatched (normalised across both sides). + const coverageDistance = totalDecs > 0 ? 1 - (2 * matchCount) / totalDecs : 0; + + // Agreement: mean cosine distance across matched pairs. + const agreementDistance = + matchCount > 0 + ? matchedCosines.reduce((sum, cos) => sum + (1 - cos), 0) / matchCount + : 1; + + const distance = coverageDistance * 0.4 + agreementDistance * 0.6; + + let description: string; + if (distance < 0.1) description = "Design decisions align closely"; + else if (distance < 0.3) + description = "Minor differences in design decisions"; + else if (distance < 0.5) + description = "Moderate divergence in design philosophy"; + else description = "Fundamentally different design decisions"; + + return { dimension: "decisions", distance, description }; +} + +/** Cosine similarity between two equal-length vectors. Returns 0 for zero-norm. */ +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length || a.length === 0) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom > 0 ? dot / denom : 0; +} + +// --- Font matching --- + +const FONT_SUFFIXES = /\b(variable|var|vf|pro|new|next|display|text|mono)\b/gi; + +/** Normalize font family name for fuzzy comparison. + * + * `FONT_SUFFIXES` intentionally omits a leading `\s*` — combining it with + * `\b` and alternation gives CodeQL's polynomial-redos check an ambiguous + * split. The trailing `.replace(/\s+/g, " ").trim()` folds any whitespace + * the suffix strip left behind, so the result is equivalent. + */ +function normalizeFontFamily(name: string): string { + return name + .replace(/['"]/g, "") + .replace(FONT_SUFFIXES, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +/** Levenshtein distance between two strings */ +function levenshtein(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0), + ); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +// Font category lookup for common fonts +const FONT_CATEGORIES: Record = { + // Sans-serif + inter: "sans-serif", + arial: "sans-serif", + helvetica: "sans-serif", + roboto: "sans-serif", + "open sans": "sans-serif", + lato: "sans-serif", + nunito: "sans-serif", + poppins: "sans-serif", + montserrat: "sans-serif", + raleway: "sans-serif", + ubuntu: "sans-serif", + manrope: "sans-serif", + geist: "sans-serif", + "dm sans": "sans-serif", + "plus jakarta sans": "sans-serif", + "source sans": "sans-serif", + "work sans": "sans-serif", + "hk grotesk": "sans-serif", + "cash sans": "sans-serif", + "sf pro": "sans-serif", + "system-ui": "sans-serif", + "sans-serif": "sans-serif", + // Serif + georgia: "serif", + "times new roman": "serif", + garamond: "serif", + "playfair display": "serif", + merriweather: "serif", + lora: "serif", + "source serif": "serif", + "dm serif": "serif", + serif: "serif", + // Monospace + "jetbrains mono": "monospace", + "fira code": "monospace", + "source code": "monospace", + "geist mono": "monospace", + "dm mono": "monospace", + "ibm plex mono": "monospace", + "sf mono": "monospace", + menlo: "monospace", + consolas: "monospace", + monaco: "monospace", + "courier new": "monospace", + monospace: "monospace", + // Display + playfair: "display", + "bebas neue": "display", + // Apple system fonts + "san francisco": "sans-serif", + "sf compact": "sans-serif", + "new york": "serif", + system: "sans-serif", +}; + +function getFontCategory(normalizedName: string): string | null { + // Exact match + if (FONT_CATEGORIES[normalizedName]) return FONT_CATEGORIES[normalizedName]; + // Partial match: check if any known font is a prefix + for (const [font, cat] of Object.entries(FONT_CATEGORIES)) { + if (normalizedName.startsWith(font) || font.startsWith(normalizedName)) { + return cat; + } + } + return null; +} + +/** + * Compute similarity between two font names (0 = no match, 1 = identical). + * Uses normalization, Levenshtein distance, and category fallback. + */ +function fontSimilarity(a: string, b: string): number { + const normA = normalizeFontFamily(a); + const normB = normalizeFontFamily(b); + + // Exact match after normalization + if (normA === normB) return 1.0; + + // Levenshtein-based similarity + const maxLen = Math.max(normA.length, normB.length); + if (maxLen === 0) return 1.0; + const dist = levenshtein(normA, normB); + const similarity = 1 - dist / maxLen; + + // If names are very similar (>= 0.7), use that score + if (similarity >= 0.7) return similarity; + + // Category fallback: same category = 0.3 floor + const catA = getFontCategory(normA); + const catB = getFontCategory(normB); + if (catA && catB && catA === catB) return Math.max(similarity, 0.3); + + return similarity; +} + +/** + * Compute font list similarity using best-match pairing. + * Each font in list A is matched to its best counterpart in list B. + */ +function fontListSimilarity(aFonts: string[], bFonts: string[]): number { + if (aFonts.length === 0 && bFonts.length === 0) return 1; + if (aFonts.length === 0 || bFonts.length === 0) return 0; + + // For each font in A, find best match in B + let totalSim = 0; + for (const fa of aFonts) { + let bestSim = 0; + for (const fb of bFonts) { + bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); + } + totalSim += bestSim; + } + // Symmetric: also match B→A and average + let totalSimReverse = 0; + for (const fb of bFonts) { + let bestSim = 0; + for (const fa of aFonts) { + bestSim = Math.max(bestSim, fontSimilarity(fa, fb)); + } + totalSimReverse += bestSim; + } + + const avgA = totalSim / aFonts.length; + const avgB = totalSimReverse / bFonts.length; + return (avgA + avgB) / 2; +} + +// --- Helpers --- + +function oklchDistance( + a: [number, number, number], + b: [number, number, number], +): number { + // Weighted OKLCH distance (lightness matters most, then chroma, then hue) + const dL = Math.abs(a[0] - b[0]); // 0-1 + const dC = Math.abs(a[1] - b[1]); // 0-0.4 typical + const dH = Math.min(Math.abs(a[2] - b[2]), 360 - Math.abs(a[2] - b[2])) / 180; // normalized + + return Math.min(dL * 0.5 + dC * 2 + dH * 0.3, 1); +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function describePaletteChange( + _a: Fingerprint, + _b: Fingerprint, + distance: number, +): string { + if (distance < 0.1) return "Color palettes are nearly identical"; + if (distance < 0.3) return "Minor palette variations"; + return "Significant palette divergence"; +} + +function buildSummary( + dimensions: Record, + distance: number, +): string { + if (distance < 0.05) return "These projects share the same design language."; + if (distance < 0.15) + return "Minor design differences — likely the same system with small customizations."; + if (distance < 0.3) + return "Moderate divergence — shared foundation but notable differences."; + if (distance < 0.5) + return "Significant divergence — different design decisions across multiple dimensions."; + + // Identify the biggest divergence + const sorted = Object.entries(dimensions).sort( + ([, a], [, b]) => b.distance - a.distance, + ); + const biggest = sorted[0]; + if (biggest) { + return `Fundamentally different design languages. Largest gap: ${biggest[0]} (${(biggest[1].distance * 100).toFixed(0)}%).`; + } + return "Fundamentally different design languages."; +} + +export { embeddingDistance } from "./embedding.js"; diff --git a/packages/ghost/src/ghost-core/embedding/describe.ts b/packages/ghost/src/ghost-core/embedding/describe.ts new file mode 100644 index 0000000..02044a5 --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/describe.ts @@ -0,0 +1,145 @@ +import type { Fingerprint } from "../types.js"; + +/** + * Render a Fingerprint as a standardized natural language description. + * This text is fed to embedding models to produce semantic vectors. + * + * The description is structured to emphasize design-relevant signals + * and minimize noise from identifiers or timestamps. + */ +export function describeFingerprint(fp: Fingerprint): string { + const sections: string[] = []; + + // Observation (Layer 1) — prepend when available for richer semantic embedding + if (fp.observation) { + sections.push(fp.observation.summary); + } + + // Design decisions (Layer 2) + if (fp.decisions && fp.decisions.length > 0) { + const decisionText = fp.decisions + .map((d) => `${d.dimension}: ${d.decision}`) + .join(". "); + sections.push(`${decisionText}.`); + } + + // Values (Layer 3) + sections.push(describePalette(fp)); + sections.push(describeSpacing(fp)); + sections.push(describeTypography(fp)); + sections.push(describeSurfaces(fp)); + + return sections.filter(Boolean).join(" "); +} + +function describePalette(fp: Fingerprint): string { + const parts: string[] = []; + + const { palette } = fp; + + if (palette.dominant.length > 0) { + const colors = palette.dominant + .map((c) => { + const oklch = c.oklch + ? `oklch(${c.oklch[0]}, ${c.oklch[1]}, ${c.oklch[2]})` + : c.value; + return `${c.role}: ${oklch}`; + }) + .join(", "); + parts.push(`Dominant colors: ${colors}.`); + } + + if (palette.semantic.length > 0) { + const roles = palette.semantic + .map((c) => { + const oklch = c.oklch + ? `oklch(${c.oklch[0]}, ${c.oklch[1]}, ${c.oklch[2]})` + : c.value; + return `${c.role}: ${oklch}`; + }) + .join(", "); + parts.push(`Semantic colors: ${roles}.`); + } + + if (palette.neutrals.count > 0) { + parts.push(`${palette.neutrals.count}-step neutral gray ramp.`); + } + + parts.push( + `${palette.saturationProfile} saturation profile, ${palette.contrast} contrast.`, + ); + + return parts.join(" "); +} + +function describeSpacing(fp: Fingerprint): string { + const { spacing } = fp; + const parts: string[] = []; + + if (spacing.scale.length > 0) { + parts.push(`Spacing scale: ${spacing.scale.join(", ")}px.`); + } else { + parts.push("No spacing scale detected."); + } + + if (spacing.baseUnit) { + parts.push(`Base unit: ${spacing.baseUnit}px.`); + } + + const regularity = + spacing.regularity > 0.8 + ? "highly regular" + : spacing.regularity > 0.4 + ? "moderately regular" + : "irregular"; + parts.push(`Scale is ${regularity}.`); + + return parts.join(" "); +} + +function describeTypography(fp: Fingerprint): string { + const { typography } = fp; + const parts: string[] = []; + + if (typography.families.length > 0) { + parts.push(`Font families: ${typography.families.join(", ")}.`); + } + + if (typography.sizeRamp.length > 0) { + const min = typography.sizeRamp[0]; + const max = typography.sizeRamp[typography.sizeRamp.length - 1]; + parts.push( + `Type scale: ${typography.sizeRamp.length} sizes from ${min}px to ${max}px.`, + ); + } + + const weightEntries = Object.entries(typography.weightDistribution); + if (weightEntries.length > 0) { + const weights = weightEntries + .map(([w, count]) => `${w} (${count}x)`) + .join(", "); + parts.push(`Font weights: ${weights}.`); + } + + parts.push(`Line height: ${typography.lineHeightPattern}.`); + + return parts.join(" "); +} + +function describeSurfaces(fp: Fingerprint): string { + const { surfaces } = fp; + const parts: string[] = []; + + if (surfaces.borderRadii.length > 0) { + parts.push( + `Border radii: ${surfaces.borderRadii.map((r) => `${r}px`).join(", ")}.`, + ); + } else { + parts.push("No border radii detected."); + } + + parts.push(`Shadow complexity: ${surfaces.shadowComplexity}.`); + parts.push(`Border usage: ${surfaces.borderUsage}.`); + + return parts.join(" "); +} diff --git a/packages/ghost/src/ghost-core/embedding/embed-api.ts b/packages/ghost/src/ghost-core/embedding/embed-api.ts new file mode 100644 index 0000000..2247689 --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/embed-api.ts @@ -0,0 +1,120 @@ +import type { EmbeddingConfig, Fingerprint } from "../types.js"; +import { describeFingerprint } from "./describe.js"; + +/** + * Generate a semantic embedding for a fingerprint using an external API. + * + * Converts the structured fingerprint into a natural language description, + * then sends it to an embedding model. The resulting vector captures semantic + * similarity — two projects using `bg-slate-900` and `--color-gray-900: #0f172a` + * will land nearby because the model understands they express the same intent. + * + * Supported providers: + * - openai: Uses text-embedding-3-small (default). Set OPENAI_API_KEY env var. + * - voyage: Uses voyage-3 (default). Set VOYAGE_API_KEY env var. + */ +export async function computeSemanticEmbedding( + fingerprint: Fingerprint, + config: EmbeddingConfig, +): Promise { + const text = describeFingerprint(fingerprint); + const [vec] = await embedTexts([text], config); + return vec; +} + +/** + * Embed a batch of texts in one API call. + * + * Returns one vector per input in the same order. Used to embed design + * decisions at fingerprint authoring time so compare can match them by cosine similarity + * without making API calls during comparison. + */ +export async function embedTexts( + texts: string[], + config: EmbeddingConfig, +): Promise { + if (texts.length === 0) return []; + + switch (config.provider) { + case "openai": + return embedViaOpenAI(texts, config); + case "voyage": + return embedViaVoyage(texts, config); + default: + throw new Error(`Unknown embedding provider: ${config.provider}`); + } +} + +async function embedViaOpenAI( + texts: string[], + config: EmbeddingConfig, +): Promise { + const apiKey = config.apiKey ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error( + "OpenAI API key required for embeddings. Set OPENAI_API_KEY env var or embedding.apiKey in config.", + ); + } + + const model = config.model ?? "text-embedding-3-small"; + + const response = await fetch("https://api.openai.com/v1/embeddings", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ input: texts, model }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`OpenAI embedding API error (${response.status}): ${body}`); + } + + const data = (await response.json()) as { + data: { embedding: number[]; index: number }[]; + }; + + // OpenAI returns results with `index` matching input position + const ordered = new Array(texts.length); + for (const item of data.data) { + ordered[item.index] = item.embedding; + } + return ordered; +} + +async function embedViaVoyage( + texts: string[], + config: EmbeddingConfig, +): Promise { + const apiKey = config.apiKey ?? process.env.VOYAGE_API_KEY; + if (!apiKey) { + throw new Error( + "Voyage API key required for embeddings. Set VOYAGE_API_KEY env var or embedding.apiKey in config.", + ); + } + + const model = config.model ?? "voyage-3"; + + const response = await fetch("https://api.voyageai.com/v1/embeddings", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ input: texts, model }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Voyage embedding API error (${response.status}): ${body}`); + } + + const data = (await response.json()) as { + data: { embedding: number[]; index?: number }[]; + }; + + // Voyage preserves input order in `data[]` + return data.data.map((d) => d.embedding); +} diff --git a/packages/ghost/src/ghost-core/embedding/embedding.ts b/packages/ghost/src/ghost-core/embedding/embedding.ts new file mode 100644 index 0000000..eb756d2 --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/embedding.ts @@ -0,0 +1,333 @@ +import type { Fingerprint } from "../types.js"; +import { contrastScore, saturationScore } from "./colors.js"; + +type FingerprintInput = Omit; + +// Fixed embedding size for comparability +const EMBEDDING_SIZE = 49; + +// Normalization constants — centralized for discoverability and tuning +const NORM = { + // Log-base for count normalization (count → log2(count+1) / log2(base)) + spacingCountLogBase: 32, + // Linear divisors + spacingValueMax: 100, + spacingSpreadMax: 50, + baseUnitMax: 32, + radiusMinMax: 64, + radiusMaxPill: 100, + radiusSpread: 64, + radiusMedian: 64, + sizeRampMax: 100, + familyCountMax: 5, + sizeRampCountMax: 10, + weightCountMax: 6, + sizeRangeRatioMax: 10, + radiiCountMax: 5, + stepRatioMax: 4, + spacingRangeRatioMax: 50, + semanticCountMax: 10, + neutralCountMax: 10, + neutralDensityMax: 20, + borderTokenCountMax: 10, +} as const; + +/** Logarithmic normalization: preserves ordering, avoids ceiling effects */ +function logNorm(count: number, logBase: number): number { + return Math.min(Math.log2(count + 1) / Math.log2(logBase), 1); +} + +/** + * Compute a deterministic numeric embedding from a structured fingerprint. + * This ensures fingerprints from different sources (LLM, registry, extraction) + * produce comparable vectors. + * + * Dimensions (49 total): + * [0-11] Palette: dominant colors OKLCH (up to 4 colors x 3 channels) + * [12-17] Palette: neutral ramp features (count, has neutrals, ramp density, lightness min/max/range) + * [18-20] Palette: qualitative (saturation profile, contrast, semantic count) + * [21-30] Spacing: scale features (count, min, max, regularity, base unit, median, spread, step ratio, density, range ratio) + * [31-40] Typography: families count, size ramp features, weight distribution, line height, weight spread, ramp range + * [41-48] Surfaces: radii features, shadow complexity, border usage, radii spread, radii median, max radius + */ +export function computeEmbedding(fingerprint: FingerprintInput): number[] { + const vec: number[] = new Array(EMBEDDING_SIZE).fill(0); + let i = 0; + + // --- Palette: dominant colors (12 dims) --- + const dominantSlots = 4; + for (let s = 0; s < dominantSlots; s++) { + const color = fingerprint.palette.dominant[s]; + if (color?.oklch) { + vec[i++] = color.oklch[0]; // L (0-1) + vec[i++] = color.oklch[1]; // C (0-0.4 typical) + vec[i++] = color.oklch[2] / 360; // H normalized to 0-1 + } else { + i += 3; + } + } + + // --- Palette: neutral ramp (6 dims) --- + const neutralCount = fingerprint.palette.neutrals.count; + vec[i++] = Math.min(neutralCount / NORM.neutralCountMax, 1); + vec[i++] = neutralCount > 0 ? 1 : 0; + vec[i++] = Math.min(neutralCount / NORM.neutralDensityMax, 1); + + // Estimate lightness range from neutral steps using semantic colors as proxy + const neutralLightnesses = fingerprint.palette.semantic + .filter( + (c) => + c.oklch && + (c.role.startsWith("surface") || + c.role.startsWith("text") || + c.role === "muted"), + ) + .map((c) => c.oklch?.[0]) + .filter((v): v is number => v != null); + if (neutralLightnesses.length >= 2) { + vec[i++] = Math.min(...neutralLightnesses); + vec[i++] = Math.max(...neutralLightnesses); + vec[i++] = + Math.max(...neutralLightnesses) - Math.min(...neutralLightnesses); + } else { + i += 3; + } + + // --- Palette: qualitative (3 dims) — continuous scoring --- + const allSemanticAndDominant = [ + ...fingerprint.palette.semantic, + ...fingerprint.palette.dominant, + ]; + vec[i++] = saturationScore(allSemanticAndDominant); + vec[i++] = contrastScore(allSemanticAndDominant); + vec[i++] = Math.min( + fingerprint.palette.semantic.length / NORM.semanticCountMax, + 1, + ); + + // --- Spacing (10 dims) --- + const spacing = fingerprint.spacing; + vec[i++] = logNorm(spacing.scale.length, NORM.spacingCountLogBase); + vec[i++] = + spacing.scale.length > 0 + ? Math.min(spacing.scale[0] / NORM.spacingValueMax, 1) + : 0; + vec[i++] = + spacing.scale.length > 0 + ? Math.min( + spacing.scale[spacing.scale.length - 1] / NORM.spacingValueMax, + 1, + ) + : 0; + vec[i++] = spacing.regularity; + vec[i++] = spacing.baseUnit + ? Math.min(spacing.baseUnit / NORM.baseUnitMax, 1) + : 0; + // Median value + const spacingMid = + spacing.scale.length > 0 + ? spacing.scale[Math.floor(spacing.scale.length / 2)] / + NORM.spacingValueMax + : 0; + vec[i++] = Math.min(spacingMid, 1); + // Spread (stddev-like): how varied is the scale? + if (spacing.scale.length >= 2) { + const mean = + spacing.scale.reduce((a, b) => a + b, 0) / spacing.scale.length; + const variance = + spacing.scale.reduce((sum, v) => sum + (v - mean) ** 2, 0) / + spacing.scale.length; + vec[i++] = Math.min(Math.sqrt(variance) / NORM.spacingSpreadMax, 1); + } else { + vec[i++] = 0; + } + // Step ratio: ratio between consecutive values (geometric vs linear) + if (spacing.scale.length >= 3) { + const ratios: number[] = []; + for (let s = 1; s < spacing.scale.length; s++) { + if (spacing.scale[s - 1] > 0) { + ratios.push(spacing.scale[s] / spacing.scale[s - 1]); + } + } + const avgRatio = + ratios.length > 0 ? ratios.reduce((a, b) => a + b, 0) / ratios.length : 1; + vec[i++] = Math.min(avgRatio / NORM.stepRatioMax, 1); + } else { + vec[i++] = 0; + } + // Density: values per unit range + if (spacing.scale.length >= 2) { + const range = spacing.scale[spacing.scale.length - 1] - spacing.scale[0]; + vec[i++] = range > 0 ? Math.min(spacing.scale.length / range, 1) : 0; + } else { + vec[i++] = 0; + } + // Range ratio: max/min + if (spacing.scale.length >= 2 && spacing.scale[0] > 0) { + vec[i++] = Math.min( + spacing.scale[spacing.scale.length - 1] / + spacing.scale[0] / + NORM.spacingRangeRatioMax, + 1, + ); + } else { + vec[i++] = 0; + } + + // --- Typography (10 dims) --- + const typo = fingerprint.typography; + vec[i++] = Math.min(typo.families.length / NORM.familyCountMax, 1); + vec[i++] = Math.min(typo.sizeRamp.length / NORM.sizeRampCountMax, 1); + // Size range + vec[i++] = + typo.sizeRamp.length > 0 + ? Math.min(typo.sizeRamp[0] / NORM.sizeRampMax, 1) + : 0; + vec[i++] = + typo.sizeRamp.length > 0 + ? Math.min(typo.sizeRamp[typo.sizeRamp.length - 1] / NORM.sizeRampMax, 1) + : 0; + // Weight distribution entropy + const weights = Object.values(typo.weightDistribution); + const totalWeights = weights.reduce((a, b) => a + b, 0); + vec[i++] = + totalWeights > 0 + ? -weights.reduce((ent, w) => { + const p = w / totalWeights; + return p > 0 ? ent + p * Math.log2(p) : ent; + }, 0) / Math.log2(Math.max(weights.length, 2)) + : 0; + // Line height + vec[i++] = + typo.lineHeightPattern === "tight" + ? 0 + : typo.lineHeightPattern === "normal" + ? 0.5 + : 1; + // Weight count: how many distinct weights are used + vec[i++] = Math.min( + Object.keys(typo.weightDistribution).length / NORM.weightCountMax, + 1, + ); + // Weight spread: range of weights used (100-900 scale) + const weightKeys = Object.keys(typo.weightDistribution).map(Number); + if (weightKeys.length >= 2) { + vec[i++] = (Math.max(...weightKeys) - Math.min(...weightKeys)) / 800; + } else { + vec[i++] = 0; + } + // Size ramp range ratio + if (typo.sizeRamp.length >= 2 && typo.sizeRamp[0] > 0) { + vec[i++] = Math.min( + typo.sizeRamp[typo.sizeRamp.length - 1] / + typo.sizeRamp[0] / + NORM.sizeRangeRatioMax, + 1, + ); + } else { + vec[i++] = 0; + } + // Size ramp median + if (typo.sizeRamp.length > 0) { + vec[i++] = Math.min( + typo.sizeRamp[Math.floor(typo.sizeRamp.length / 2)] / NORM.sizeRampMax, + 1, + ); + } else { + vec[i++] = 0; + } + + // --- Surfaces (8 dims) --- + const surfaces = fingerprint.surfaces; + vec[i++] = Math.min(surfaces.borderRadii.length / NORM.radiiCountMax, 1); + vec[i++] = + surfaces.borderRadii.length > 0 + ? Math.min(surfaces.borderRadii[0] / NORM.radiusMinMax, 1) + : 0; + vec[i++] = + surfaces.borderRadii.length > 0 + ? Math.min( + surfaces.borderRadii[surfaces.borderRadii.length - 1] / + NORM.radiusMinMax, + 1, + ) + : 0; + // shadowComplexity: deliberate-none → 0, subtle → 0.5, layered → 1. + // (Phase 4b renamed `none` to `deliberate-none`; the embedding axis is + // unchanged.) + vec[i++] = + surfaces.shadowComplexity === "layered" + ? 1 + : surfaces.shadowComplexity === "subtle" + ? 0.5 + : 0; + // Border usage — use continuous score if borderTokenCount available, else categorical fallback + if (surfaces.borderTokenCount !== undefined) { + vec[i++] = Math.min( + surfaces.borderTokenCount / NORM.borderTokenCountMax, + 1, + ); + } else { + vec[i++] = + surfaces.borderUsage === "heavy" + ? 1 + : surfaces.borderUsage === "moderate" + ? 0.5 + : 0; + } + // Radii spread: range of border radii + if (surfaces.borderRadii.length >= 2) { + vec[i++] = Math.min( + (surfaces.borderRadii[surfaces.borderRadii.length - 1] - + surfaces.borderRadii[0]) / + NORM.radiusSpread, + 1, + ); + } else { + vec[i++] = 0; + } + // Radii median + if (surfaces.borderRadii.length > 0) { + vec[i++] = Math.min( + surfaces.borderRadii[Math.floor(surfaces.borderRadii.length / 2)] / + NORM.radiusMedian, + 1, + ); + } else { + vec[i++] = 0; + } + // Max radius (signals "pill" shapes — high max radius is distinctive) + if (surfaces.borderRadii.length > 0) { + vec[i++] = Math.min( + surfaces.borderRadii[surfaces.borderRadii.length - 1] / + NORM.radiusMaxPill, + 1, + ); + } else { + vec[i++] = 0; + } + + return vec; +} + +/** + * Cosine similarity between two embedding vectors (0 = identical, 1 = orthogonal) + */ +export function embeddingDistance(a: number[], b: number[]): number { + const len = Math.min(a.length, b.length); + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < len; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + const magnitude = Math.sqrt(normA) * Math.sqrt(normB); + if (magnitude === 0) return 1; + + // Convert similarity (1 = identical) to distance (0 = identical) + return 1 - dotProduct / magnitude; +} diff --git a/packages/ghost/src/ghost-core/embedding/index.ts b/packages/ghost/src/ghost-core/embedding/index.ts new file mode 100644 index 0000000..1d0b14d --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/index.ts @@ -0,0 +1,17 @@ +export { + classifyContrast, + classifySaturation, + colorToSemanticColor, + contrastScore, + parseColorToOklch, + resolveColorOklch, + saturationScore, +} from "./colors.js"; +export type { CompareOptions } from "./compare.js"; +export { compareFingerprints } from "./compare.js"; +export { describeFingerprint } from "./describe.js"; +export { computeSemanticEmbedding, embedTexts } from "./embed-api.js"; +export { computeEmbedding, embeddingDistance } from "./embedding.js"; +export type { RoleCandidate } from "./semantic-roles.js"; +export { inferSemanticRole } from "./semantic-roles.js"; +export { computeDriftVectors, DIMENSION_RANGES } from "./vector.js"; diff --git a/packages/ghost/src/ghost-core/embedding/semantic-roles.ts b/packages/ghost/src/ghost-core/embedding/semantic-roles.ts new file mode 100644 index 0000000..614703d --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/semantic-roles.ts @@ -0,0 +1,160 @@ +import { parseColorToOklch } from "./colors.js"; + +export interface RoleCandidate { + role: string; + confidence: number; // 0-1 +} + +// Exact token name → semantic role mapping (shadcn + common conventions) +const EXACT_ROLES: Record = { + // Surface/background tokens + "--background-default": "surface", + "--background-alt": "surface-alt", + "--background-accent": "accent", + "--background": "surface", + "--bg": "surface", + "--bg-accent": "accent", + // shadcn conventions + "--primary": "primary", + "--primary-foreground": "primary-foreground", + "--secondary": "secondary", + "--secondary-foreground": "secondary-foreground", + "--accent": "accent", + "--accent-foreground": "accent-foreground", + "--muted": "muted", + "--muted-foreground": "muted-foreground", + "--destructive": "destructive", + "--destructive-foreground": "destructive-foreground", + "--card": "surface", + "--card-foreground": "text", + "--popover": "surface-alt", + "--popover-foreground": "text", + "--foreground": "text", + "--input": "border", + "--ring": "ring", + // Text tokens + "--text-default": "text", + "--text-muted": "text-muted", + "--text-inverse": "text-inverse", + "--text-danger": "danger", + // Border tokens + "--border-default": "border", + "--border-strong": "border-strong", + "--border": "border", + // Brand tokens + "--brand": "primary", + "--brand-primary": "primary", + "--brand-secondary": "secondary", +}; + +// Pattern-based rules: regex → role derivation +const PATTERN_RULES: [ + RegExp, + (match: RegExpMatchArray, name: string) => string, +][] = [ + // Primary/brand + [/--(?:color-)?primary(?:-|$)/, () => "primary"], + [/--(?:color-)?brand(?:-|$)/, () => "primary"], + // Surface/background + [ + /--(?:bg|background|surface)(?:-(.+))?$/, + (m) => (m[1] ? `surface-${m[1]}` : "surface"), + ], + // Text/foreground + [ + /--(?:text|fg|foreground)(?:-(.+))?$/, + (m) => (m[1] ? `text-${m[1]}` : "text"), + ], + // Border/stroke + [ + /--(?:border|stroke|outline)(?:-(.+))?$/, + (m) => (m[1] ? `border-${m[1]}` : "border"), + ], + // Semantic states + [/--(?:color-)?(?:error|danger|destructive)/, () => "destructive"], + [/--(?:color-)?(?:warning|caution|alert)/, () => "warning"], + [/--(?:color-)?(?:success|positive|valid)/, () => "success"], + [/--(?:color-)?(?:info|notice|informative)/, () => "info"], + // Accent/highlight + [/--(?:color-)?(?:accent|highlight)/, () => "accent"], + // Muted/subtle + [/--(?:color-)?(?:muted|subtle|disabled)/, () => "muted"], + // Secondary + [/--(?:color-)?secondary/, () => "secondary"], + // Ring/focus + [/--(?:color-)?(?:ring|focus|outline)/, () => "ring"], + // Generic color- prefix: use the suffix as role + [/--color-(.+)/, (m) => m[1]], + // MUI-style: --mui-palette-- + [/--mui-palette-(\w+)-/, (m) => m[1]], + // Chakra-style: --chakra-colors-- + [/--chakra-colors-(\w+)-/, (m) => m[1]], +]; + +// Semantic keywords that appear in token names +const SEMANTIC_KEYWORDS: Record = { + primary: "primary", + secondary: "secondary", + accent: "accent", + brand: "primary", + destructive: "destructive", + danger: "destructive", + error: "destructive", + warning: "warning", + caution: "warning", + success: "success", + positive: "success", + info: "info", + muted: "muted", + subtle: "muted", + disabled: "muted", + background: "surface", + surface: "surface", + foreground: "text", + text: "text", + border: "border", + ring: "ring", + focus: "ring", +}; + +/** + * Infer the semantic role of a design token from its name and value. + * Uses a layered approach: exact match → pattern match → keyword extraction → value heuristic. + */ +export function inferSemanticRole( + tokenName: string, + tokenValue?: string, +): RoleCandidate | null { + // Layer 1: Exact match (confidence 1.0) + const exact = EXACT_ROLES[tokenName]; + if (exact) return { role: exact, confidence: 1.0 }; + + // Layer 2: Pattern match (confidence 0.9) + for (const [pattern, derive] of PATTERN_RULES) { + const match = tokenName.match(pattern); + if (match) return { role: derive(match, tokenName), confidence: 0.9 }; + } + + // Layer 3: Keyword extraction (confidence 0.7) + const parts = tokenName.replace(/^--/, "").split(/[-_]/); + for (const part of parts) { + const role = SEMANTIC_KEYWORDS[part.toLowerCase()]; + if (role) return { role, confidence: 0.7 }; + } + + // Layer 4: Value-based heuristic (confidence 0.6) + if (tokenValue) { + const oklch = parseColorToOklch(tokenValue); + if (oklch) { + const [L, C] = oklch; + // Near-white → likely surface + if (L > 0.9 && C < 0.02) return { role: "surface", confidence: 0.6 }; + // Near-black → likely text + if (L < 0.15 && C < 0.02) return { role: "text", confidence: 0.6 }; + // High chroma → likely a dominant/brand color + if (C > 0.15) return { role: "dominant", confidence: 0.6 }; + } + } + + return null; +} diff --git a/packages/ghost/src/ghost-core/embedding/vector.ts b/packages/ghost/src/ghost-core/embedding/vector.ts new file mode 100644 index 0000000..f384074 --- /dev/null +++ b/packages/ghost/src/ghost-core/embedding/vector.ts @@ -0,0 +1,43 @@ +import type { DriftVector, Fingerprint } from "../types.js"; + +/** + * Embedding dimension ranges per design dimension. + * Mirrors the layout in embedding/embedding.ts. + */ +export const DIMENSION_RANGES: Record = { + palette: [0, 21], // dominant (0-11) + neutrals (12-17) + qualitative (18-20) + spacing: [21, 31], + typography: [31, 41], + surfaces: [41, 49], +}; + +/** + * Compute per-dimension drift vectors from two fingerprints' embeddings. + * Each vector captures the direction and magnitude of change in embedding space + * for a specific design dimension. + */ +export function computeDriftVectors( + source: Fingerprint, + target: Fingerprint, +): DriftVector[] { + const vectors: DriftVector[] = []; + + for (const [dimension, [start, end]] of Object.entries(DIMENSION_RANGES)) { + const delta: number[] = []; + let sumSq = 0; + + for (let i = start; i < end; i++) { + const d = (target.embedding[i] ?? 0) - (source.embedding[i] ?? 0); + delta.push(d); + sumSq += d * d; + } + + vectors.push({ + dimension, + magnitude: Math.sqrt(sumSq), + embeddingDelta: delta, + }); + } + + return vectors; +} diff --git a/packages/ghost/src/ghost-core/fingerprint-package.ts b/packages/ghost/src/ghost-core/fingerprint-package.ts new file mode 100644 index 0000000..5e947a4 --- /dev/null +++ b/packages/ghost/src/ghost-core/fingerprint-package.ts @@ -0,0 +1,21 @@ +export const FINGERPRINT_PACKAGE_DIR = ".ghost" as const; +export const RESOURCES_FILENAME = "resources.yml" as const; +export const PATTERNS_FILENAME = "patterns.yml" as const; +export const INTENT_FILENAME = "intent.md" as const; +export const FINGERPRINT_FILENAME = "fingerprint.md" as const; +export const DECISIONS_DIRNAME = "decisions" as const; +export const PROPOSALS_DIRNAME = "proposals" as const; + +export interface FingerprintPackagePaths { + dir: string; + resources: string; + map: string; + survey: string; + patterns: string; + /** Legacy direct markdown path; not part of the canonical root bundle. */ + fingerprint: string; + checks: string; + intent: string; + decisions: string; + proposals: string; +} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts new file mode 100644 index 0000000..c0cda36 --- /dev/null +++ b/packages/ghost/src/ghost-core/index.ts @@ -0,0 +1,308 @@ +// --- Embedding primitives --- + +export type { + GhostCheck, + GhostCheckAppliesTo, + GhostCheckDetector, + GhostCheckDetectorType, + GhostCheckEvidence, + GhostCheckSeverity, + GhostCheckStatus, + GhostChecksDocument, + GhostChecksLintIssue, + GhostChecksLintOptions, + GhostChecksLintReport, + GhostChecksLintSeverity, + RoutedGhostCheck, +} from "./checks/index.js"; +// --- Checks (ghost.checks/v1) --- +export { + GHOST_CHECKS_FILENAME, + GHOST_CHECKS_SCHEMA, + GhostCheckSchema, + GhostChecksSchema, + lintGhostChecks, + matchesGhostPath, + normalizeGhostPath, + routeGhostChecksForPath, + routeGhostPathToScopes, +} from "./checks/index.js"; +// --- Decision vocabulary (controlled list for fleet aggregation) --- +export { + CANONICAL_DECISION_DIMENSIONS, + type CanonicalDecisionDimension, + closestCanonical, + isCanonicalDimension, + resolveDecisionKind, +} from "./decision-vocabulary.js"; +export type { CompareOptions, RoleCandidate } from "./embedding/index.js"; +export { + classifyContrast, + classifySaturation, + colorToSemanticColor, + compareFingerprints, + computeDriftVectors, + computeEmbedding, + computeSemanticEmbedding, + contrastScore, + DIMENSION_RANGES, + describeFingerprint, + embeddingDistance, + embedTexts, + inferSemanticRole, + parseColorToOklch, + saturationScore, +} from "./embedding/index.js"; +// --- Map (ghost.map/v2) --- +export { + DECISIONS_DIRNAME, + FINGERPRINT_FILENAME, + FINGERPRINT_PACKAGE_DIR, + type FingerprintPackagePaths, + INTENT_FILENAME, + PATTERNS_FILENAME, + PROPOSALS_DIRNAME, + RESOURCES_FILENAME, +} from "./fingerprint-package.js"; +// --- Map (ghost.map/v2) --- +export { + type GitInfo, + getEffectiveMapScopes, + type InventoryOutput, + type LanguageHistogramEntry, + MAP_FILENAME, + type MapFeatureArea, + type MapFrontmatter, + MapFrontmatterSchema, + type MapScope, + MapScopeSchema, + REQUIRED_BODY_SECTIONS, + type RequiredBodySection, + slugifyScopeId, + type TopLevelEntry, +} from "./map/index.js"; +// --- Memory (ghost.decision/v1 + ghost.proposal/v1) --- +export type { + GhostDecisionDocument, + GhostDecisionStatus, + GhostExperienceEvidence, + GhostExperienceScope, + GhostMemoryLintIssue, + GhostMemoryLintReport, + GhostMemoryLintSeverity, + GhostProposalAction, + GhostProposalDocument, + GhostProposalKind, + GhostProposalStatus, + GhostProposalTarget, +} from "./memory/index.js"; +export { + GHOST_DECISION_SCHEMA, + GHOST_DECISIONS_DIRNAME, + GHOST_PROPOSAL_SCHEMA, + GHOST_PROPOSALS_DIRNAME, + GhostDecisionSchema, + GhostExperienceEvidenceSchema, + GhostExperienceScopeSchema, + GhostProposalActionSchema, + GhostProposalSchema, + lintGhostDecision, + lintGhostProposal, +} from "./memory/index.js"; +// --- Patterns (ghost.patterns/v1) --- +export type { + GhostCompositionAnatomy, + GhostCompositionPattern, + GhostPatternEvidence, + GhostPatternsDocument, + GhostPatternsLintIssue, + GhostPatternsLintReport, + GhostPatternsLintSeverity, + GhostSurfaceTypePattern, +} from "./patterns/index.js"; +export { + GHOST_PATTERNS_FILENAME, + GHOST_PATTERNS_SCHEMA, + GhostCompositionAnatomySchema, + GhostCompositionPatternSchema, + GhostPatternEvidenceSchema, + GhostPatternsSchema, + GhostSurfaceTypePatternSchema, + lintGhostPatterns, +} from "./patterns/index.js"; +// --- Perceptual prior (drift severity calibration) --- +export { + computeCheckSeverity, + DEFAULT_MATCH, + DEFAULT_TOLERANCE, + escalateForPresence, + escalateTier, + PERCEPTUAL_TIER, + type PerceptualTier, + resolveMatchShape, + resolveTolerance, + TIER_SEVERITY, + tierForCanonical, +} from "./perceptual-prior.js"; +// --- Resources (ghost.resources/v1) --- +export type { + GhostResourceRef, + GhostResourcesDocument, + GhostResourcesLintIssue, + GhostResourcesLintReport, + GhostResourcesLintSeverity, + GhostSurfaceResource, +} from "./resources/index.js"; +export { + GHOST_RESOURCES_FILENAME, + GHOST_RESOURCES_SCHEMA, + GhostResourceRefSchema, + GhostResourcesSchema, + GhostSurfaceResourceSchema, + lintGhostResources, +} from "./resources/index.js"; +// --- Skill bundle loader --- +export type { SkillBundleFile } from "./skill-bundle-loader.js"; +export { loadSkillBundle } from "./skill-bundle-loader.js"; +// --- Survey (ghost.survey/v2) --- +export { + type BreakpointSpec, + type ColorSpec, + ColorSpecSchema, + type ComponentEvidenceSummary, + type ComponentRow, + ComponentRowSchema, + type CountSummary, + catalogSurveyValues, + componentRowId, + formatSurveyCatalogMarkdown, + formatSurveySummaryMarkdown, + type LayoutPrimitiveSpec, + lintSurvey, + type MotionSpec, + mergeSurveys, + type RadiusSpec, + RECOMMENDED_VALUE_KINDS, + type RecommendedValueKind, + type Resolution, + ResolutionSchema, + type ResolutionSummary, + type RowBase, + recomputeSurveyIds, + type ScalarUnit, + type ShadowSpec, + type SpacingSpec, + SURVEY_FILENAME, + type Survey, + type SurveyCatalogCounts, + type SurveyCatalogKind, + type SurveyCatalogOptions, + type SurveyCatalogValue, + type SurveyComponentsSummary, + type SurveyLintIssue, + type SurveyLintReport, + type SurveyLintSeverity, + SurveySchema, + type SurveySource, + SurveySourceSchema, + type SurveySourceSummary, + type SurveySummary, + type SurveySummaryBudget, + type SurveySummaryCounts, + type SurveySummaryOptions, + type SurveyTokensSummary, + type SurveyUiSurfacesSummary, + type SurveyValueCatalog, + type SurveyValuesSummary, + summarizeSurvey, + type TokenEvidenceSummary, + type TokenRow, + TokenRowSchema, + type TypographySpec, + tokenRowId, + type UiSurfaceClassification, + UiSurfaceClassificationSchema, + type UiSurfaceComposition, + UiSurfaceCompositionSchema, + type UiSurfaceDensity, + type UiSurfaceEvidenceSummary, + type UiSurfaceGroupSummary, + type UiSurfaceKind, + UiSurfaceKindSchema, + type UiSurfaceLayoutShape, + type UiSurfaceRenderability, + UiSurfaceRenderabilitySchema, + type UiSurfaceRow, + UiSurfaceRowSchema, + type UiSurfaceSignals, + UiSurfaceSignalsSchema, + type UnknownSpec, + uiSurfaceRowId, + type ValueEvidenceSummary, + type ValueKindSummary, + type ValueRow, + ValueRowSchema, + type ValueSpec, + ValueSpecSchema, + valueRowId, +} from "./survey/index.js"; +// --- Target resolution --- +export { resolveTarget } from "./target-resolver.js"; + +// --- Shared types --- +export type { + Check, + CheckKind, + CheckMatchShape, + ColorRamp, + ComponentMeta, + CompositeCluster, + CompositeComparison, + CompositeMember, + CompositePair, + CSSToken, + CSSVarsMap, + DesignDecision, + DesignObservation, + DetectedFormat, + DimensionAck, + DimensionDelta, + DimensionStance, + DivergenceClass, + DriftSeverity, + DriftVector, + DriftVelocity, + EmbeddingConfig, + EnrichedComparison, + EnrichedFingerprint, + ExtractedFile, + ExtractedMaterial, + Extractor, + ExtractorOptions, + Fingerprint, + FingerprintComparison, + FingerprintHistoryEntry, + FingerprintReferences, + FontDescriptor, + GhostConfig, + NormalizedToken, + Registry, + RegistryFile, + RegistryItem, + RegistryItemType, + ResolvedRegistry, + RuleSeverity, + SampledFile, + SampledMaterial, + SemanticColor, + SourceInfo, + StructureDrift, + SyncManifest, + Target, + TargetOptions, + TargetType, + TemporalComparison, + TokenCategory, + TokenFormat, + ValueDrift, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/map/index.ts b/packages/ghost/src/ghost-core/map/index.ts new file mode 100644 index 0000000..207c597 --- /dev/null +++ b/packages/ghost/src/ghost-core/map/index.ts @@ -0,0 +1,27 @@ +/** + * Public surface for `ghost.map/v2` schema and types. + * + * Map authoring (`inventory`, `lint`) lives in `ghost` (the tool + * that owns the recipe). The schema/types live here so any ghost tool that + * reads `map.md` can do so via `@anarchitecture/ghost/core` without depending on the + * authoring CLI. + */ + +export { + type MapFrontmatter, + MapFrontmatterSchema, + type MapScope, + MapScopeSchema, + REQUIRED_BODY_SECTIONS, + type RequiredBodySection, +} from "./schema.js"; +export type { MapFeatureArea } from "./scopes.js"; +export { getEffectiveMapScopes, slugifyScopeId } from "./scopes.js"; +export type { + GitInfo, + InventoryOutput, + LanguageHistogramEntry, + TopLevelEntry, +} from "./types.js"; + +export const MAP_FILENAME = "map.md"; diff --git a/packages/ghost/src/ghost-core/map/schema.ts b/packages/ghost/src/ghost-core/map/schema.ts new file mode 100644 index 0000000..28b64ea --- /dev/null +++ b/packages/ghost/src/ghost-core/map/schema.ts @@ -0,0 +1,235 @@ +import { z } from "zod"; + +/** + * Platform values accepted by `ghost.map/v2`. Real repos may straddle + * multiple platforms — `platform:` accepts either a single value or an + * array (see `PlatformValueSchema`). The legacy `mixed` enum value stays + * for backcompat but the array form is preferred for clarity. + */ +const PlatformEnum = z.enum([ + "web", + "ios", + "android", + "desktop", + "flutter", + "mixed", + "other", +]); + +const PlatformValueSchema = z.union([ + PlatformEnum, + z.array(PlatformEnum).min(1), +]); + +/** + * Build-system values accepted by `ghost.map/v2`. As with `platform`, the + * field accepts either a single value or an array — real repos run mixes + * like Yarn + SPM + Gradle + Style Dictionary at once. + * + * The enum was extended in Phase 4b to cover token-pipeline tooling + * (`style-dictionary`) and JVM/native build systems that show up in real + * monorepos but didn't fit any earlier value (`bazel`, `maven`, `sbt`, + * `cmake`). `cargo` was already present before 4b. + * + * Phase 5b adds JS bundlers and meta-build coordinators: real consumer + * repos use `vite` as the build with `pnpm`/`yarn` for dependencies, and + * monorepos increasingly run `nx` or `turbo` on top. Without these the + * recipe was forced to drop signal into prose; an array like + * `[pnpm, vite, nx, style-dictionary]` is now expressible. + */ +const BuildSystemEnum = z.enum([ + "gradle", + "bazel", + "xcode", + "pnpm", + "npm", + "yarn", + "cargo", + "go", + "maven", + "sbt", + "cmake", + "style-dictionary", + // JS bundlers + "vite", + "webpack", + "parcel", + "rollup", + "turbopack", + "esbuild", + // Meta-build coordinators + "nx", + "turbo", + "mixed", + "other", +]); + +const BuildSystemValueSchema = z.union([ + BuildSystemEnum, + z.array(BuildSystemEnum).min(1), +]); + +const SourceRoleSchema = z.enum(["primary", "resolver"]); + +const RenderStrategySchema = z.enum([ + "browser", + "storybook", + "docs", + "native-screenshot", + "static-source", + "mixed", + "unknown", +]); + +const MapSubjectSchema = z.object({ + id: z.string().min(1), + target: z.string().min(1), +}); + +const MapSourceSchema = z.object({ + id: z.string().min(1).optional(), + role: SourceRoleSchema, + target: z.string().min(1), + resolves: z.array(z.string().min(1)).optional(), + paths: z.array(z.string().min(1)).optional(), +}); + +const SlugIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }); + +export const MapScopeSchema = z.object({ + id: SlugIdSchema, + name: z.string().min(1).optional(), + kind: z.string().min(1), + paths: z.array(z.string().min(1)).min(1), + parent: SlugIdSchema.optional(), + sub_areas: z.array(z.string().min(1)).optional(), +}); + +/** + * Zod schema for `ghost.map/v2` frontmatter. + * + * The body section (Identity / Topology / Conventions) is checked separately + * by the linter — this schema only covers the YAML machine layer. + */ +export const MapFrontmatterSchema = z.object({ + schema: z.literal("ghost.map/v2"), + id: SlugIdSchema, + repo: z.string().min(1), + /** + * Optional explicit subject for multi-source scans. `id` remains the + * canonical map id; `subject` states what this fingerprint is about. + */ + subject: MapSubjectSchema.optional(), + /** + * Optional scan source graph. The primary source supplies usage/salience; + * resolver sources supply concrete meaning for symbols imported from + * upstream packages. + */ + sources: z.array(MapSourceSchema).optional(), + mapped_at: z.iso.datetime({ offset: true }).or( + z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: "mapped_at must be ISO date (YYYY-MM-DD) or full datetime", + }), + ), + platform: PlatformValueSchema, + languages: z + .array( + z.object({ + name: z.string().min(1), + files: z.number().int().nonnegative(), + share: z.number().min(0).max(1), + }), + ) + .min(1), + build_system: BuildSystemValueSchema, + package_manifests: z.array(z.string().min(1)).min(1), + composition: z.object({ + frameworks: z.array( + z.object({ + name: z.string().min(1), + version: z.string().min(1).optional(), + }), + ), + rendering: z.string().min(1), + styling: z.array(z.string().min(1)).min(1), + navigation: z.string().min(1).optional(), + }), + registry: z + .object({ + path: z.string().min(1), + components: z.number().int().nonnegative(), + }) + .nullable() + .optional(), + design_system: z.object({ + paths: z.array(z.string().min(1)), + /** + * Files that resolve a token end-to-end — the source-of-truth layer. + * Optional in 4b: a design system may have only derived artifacts + * checked in (rare but real for upstream-token consumers). + */ + entry_files: z.array(z.string().min(1)).optional(), + /** + * Build artifacts other tools may consume (e.g. `dist/colors.ts` + * generated from `tokens/colors.json`). Optional. Distinct from + * `entry_files` so drift can point at the right reference layer. + */ + derived_files: z.array(z.string().min(1)).optional(), + /** + * How the design system sources its tokens. + * - `inline`: the system declares its own tokens in-tree + * - `external`: tokens are pulled from another package (`upstream`) + * - `mixed`: both inline and external token sources coexist + */ + token_source: z.enum(["inline", "external", "mixed"]).optional(), + /** + * Reference(s) to the upstream token source(s) when `token_source` is + * `external` or `mixed`. Free-form strings: npm package names, SPM + * module refs, relative paths to sibling packages, etc. + * + * Accepts either a single string or an array of strings. Real + * consumers often pull from multiple upstream packages (a token + * package + a component package + an icon package + a glue package); + * the array form keeps the structured signal instead of forcing the + * recipe to pack them into prose. + */ + upstream: z + .union([z.string().min(1), z.array(z.string().min(1)).min(1)]) + .optional(), + status: z.enum(["active", "mixed", "unclear"]), + }), + surface_sources: z.object({ + include: z.array(z.string().min(1)), + exclude: z.array(z.string().min(1)), + render_strategy: RenderStrategySchema, + coverage_gaps: z.array(z.string().min(1)).optional(), + }), + feature_areas: z + .array( + z.object({ + name: z.string().min(1), + paths: z.array(z.string().min(1)).min(1), + sub_areas: z.array(z.string().min(1)).optional(), + }), + ) + .min(1), + scopes: z.array(MapScopeSchema).optional(), + orientation_files: z.array(z.string().min(1)).min(1), +}); + +export type MapFrontmatter = z.infer; +export type MapScope = z.infer; + +/** Required body sections in canonical order. */ +export const REQUIRED_BODY_SECTIONS = [ + "Identity", + "Topology", + "Conventions", +] as const; +export type RequiredBodySection = (typeof REQUIRED_BODY_SECTIONS)[number]; diff --git a/packages/ghost/src/ghost-core/map/scopes.ts b/packages/ghost/src/ghost-core/map/scopes.ts new file mode 100644 index 0000000..26c694a --- /dev/null +++ b/packages/ghost/src/ghost-core/map/scopes.ts @@ -0,0 +1,49 @@ +import type { MapFrontmatter, MapScope } from "./schema.js"; + +export type MapFeatureArea = MapFrontmatter["feature_areas"][number]; + +/** + * Slugify a human feature-area name into the scope id shape accepted by + * `ghost.map/v2`. Existing slug ids stay unchanged. + */ +export function slugifyScopeId(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+/, "") + .replace(/-+/g, "-") + .replace(/-$/g, ""); + return slug.length > 0 ? slug : "scope"; +} + +/** + * Return the product-surface scopes that govern scoped fingerprints. + * + * New maps can declare `scopes[]` directly. Older maps derive the same + * effective shape from `feature_areas[]`, preserving existing scan recipes + * and fleet manifests. + */ +export function getEffectiveMapScopes( + map: Pick, +): MapScope[] { + if (map.scopes && map.scopes.length > 0) { + return map.scopes.map(cloneScope); + } + + return map.feature_areas.map((area) => ({ + id: slugifyScopeId(area.name), + name: area.name, + kind: "feature-area", + paths: [...area.paths], + ...(area.sub_areas ? { sub_areas: [...area.sub_areas] } : {}), + })); +} + +function cloneScope(scope: MapScope): MapScope { + return { + ...scope, + paths: [...scope.paths], + ...(scope.sub_areas ? { sub_areas: [...scope.sub_areas] } : {}), + }; +} diff --git a/packages/ghost/src/ghost-core/map/types.ts b/packages/ghost/src/ghost-core/map/types.ts new file mode 100644 index 0000000..f761a5f --- /dev/null +++ b/packages/ghost/src/ghost-core/map/types.ts @@ -0,0 +1,65 @@ +/** + * Shared types for `ghost.map/v2`. + * + * The inventory shape is the deterministic facts the CLI emits; the recipe + * synthesizes the final `map.md` from these signals plus its own reads. + */ + +/** Single language-extension survey. */ +export interface LanguageHistogramEntry { + /** Canonical language name (lowercase). */ + name: string; + /** Number of files matching this language's extensions. */ + files: number; +} + +/** A top-level entry under the inventoried path. */ +export interface TopLevelEntry { + /** Path relative to the inventoried root, with a trailing slash for dirs. */ + path: string; + /** Whether this entry is a directory or file. */ + kind: "dir" | "file"; + /** Direct child count (immediate children only). Files have child_count: 0. */ + child_count: number; +} + +/** Best-effort git information. Either field may be null when unavailable. */ +export interface GitInfo { + /** Configured remote URL for `origin` — null when not a git repo. */ + remote: string | null; + /** Default branch (origin/HEAD target, falling back to current branch). */ + default_branch: string | null; +} + +/** Full output shape of `ghost map inventory`. */ +export interface InventoryOutput { + /** Resolved absolute path that was inventoried. */ + root: string; + /** Coarse hints derived from manifest presence (e.g. "android" if Gradle, "ios" if podspec). */ + platform_hints: string[]; + /** + * Coarse hints derived from manifest presence for the build system + * (e.g. `gradle` if `settings.gradle*`, `style-dictionary` if a sibling + * `style-dictionary.config.*` is found). Informational — the recipe + * authors the authoritative `build_system` value in `map.md`. + */ + build_system_hints: string[]; + /** Files-per-language histogram, sorted desc by `files`. Top 20. */ + language_histogram: LanguageHistogramEntry[]; + /** + * Canonical package manifests. Root entries are basenames + * (`package.json`); workspace-expanded entries are POSIX relative paths + * (`packages/foo/package.json`). Sorted lexicographically, deduped. + */ + package_manifests: string[]; + /** Candidate config files matched anywhere under root (relative paths, sorted). */ + candidate_config_files: string[]; + /** registry.json files matched anywhere under root. */ + registry_files: string[]; + /** Top-level (one level deep) directory tree. */ + top_level_tree: TopLevelEntry[]; + /** Best-effort git remote URL. */ + git_remote: string | null; + /** Best-effort git default branch. */ + git_default_branch: string | null; +} diff --git a/packages/ghost/src/ghost-core/memory/index.ts b/packages/ghost/src/ghost-core/memory/index.ts new file mode 100644 index 0000000..1f73302 --- /dev/null +++ b/packages/ghost/src/ghost-core/memory/index.ts @@ -0,0 +1,28 @@ +export { lintGhostDecision, lintGhostProposal } from "./lint.js"; +export { + GhostDecisionSchema, + GhostExperienceEvidenceSchema, + GhostExperienceScopeSchema, + GhostProposalActionSchema, + GhostProposalSchema, +} from "./schema.js"; +export type { + GhostDecisionDocument, + GhostDecisionStatus, + GhostExperienceEvidence, + GhostExperienceScope, + GhostMemoryLintIssue, + GhostMemoryLintReport, + GhostMemoryLintSeverity, + GhostProposalAction, + GhostProposalDocument, + GhostProposalKind, + GhostProposalStatus, + GhostProposalTarget, +} from "./types.js"; +export { + GHOST_DECISION_SCHEMA, + GHOST_DECISIONS_DIRNAME, + GHOST_PROPOSAL_SCHEMA, + GHOST_PROPOSALS_DIRNAME, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/memory/lint.ts b/packages/ghost/src/ghost-core/memory/lint.ts new file mode 100644 index 0000000..7e79a07 --- /dev/null +++ b/packages/ghost/src/ghost-core/memory/lint.ts @@ -0,0 +1,42 @@ +import type { ZodIssue } from "zod"; +import { GhostDecisionSchema, GhostProposalSchema } from "./schema.js"; +import type { GhostMemoryLintIssue, GhostMemoryLintReport } from "./types.js"; + +export function lintGhostDecision(input: unknown): GhostMemoryLintReport { + const result = GhostDecisionSchema.safeParse(input); + if (!result.success) return finalize(zodIssues(result.error.issues)); + return finalize([]); +} + +export function lintGhostProposal(input: unknown): GhostMemoryLintReport { + const result = GhostProposalSchema.safeParse(input); + if (!result.success) return finalize(zodIssues(result.error.issues)); + return finalize([]); +} + +function zodIssues(issues: ZodIssue[]): GhostMemoryLintIssue[] { + return issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: formatZodPath(issue.path), + })); +} + +function formatZodPath(path: ZodIssue["path"]): string | undefined { + if (path.length === 0) return undefined; + return path.reduce((formatted, segment) => { + if (typeof segment === "number") return `${formatted}[${segment}]`; + const key = String(segment); + return formatted ? `${formatted}.${key}` : key; + }, ""); +} + +function finalize(issues: GhostMemoryLintIssue[]): GhostMemoryLintReport { + return { + issues, + errors: issues.filter((issue) => issue.severity === "error").length, + warnings: issues.filter((issue) => issue.severity === "warning").length, + info: issues.filter((issue) => issue.severity === "info").length, + }; +} diff --git a/packages/ghost/src/ghost-core/memory/schema.ts b/packages/ghost/src/ghost-core/memory/schema.ts new file mode 100644 index 0000000..eb9c24c --- /dev/null +++ b/packages/ghost/src/ghost-core/memory/schema.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { GHOST_DECISION_SCHEMA, GHOST_PROPOSAL_SCHEMA } from "./types.js"; + +const SlugIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }); + +const NonEmptyStringArraySchema = z.array(z.string().min(1)).min(1); + +export const GhostExperienceScopeSchema = z + .object({ + roles: NonEmptyStringArraySchema.optional(), + scopes: NonEmptyStringArraySchema.optional(), + surface_types: NonEmptyStringArraySchema.optional(), + pattern_ids: NonEmptyStringArraySchema.optional(), + paths: NonEmptyStringArraySchema.optional(), + }) + .strict(); + +export const GhostExperienceEvidenceSchema = z + .object({ + path: z.string().min(1).optional(), + survey_surface_id: z.string().min(1).optional(), + locator: z.string().min(1).optional(), + note: z.string().min(1).optional(), + }) + .strict() + .refine( + (evidence) => + Boolean( + evidence.path || + evidence.survey_surface_id || + evidence.locator || + evidence.note, + ), + { + message: + "evidence must include at least one of path, survey_surface_id, locator, or note", + }, + ); + +export const GhostDecisionSchema = z + .object({ + schema: z.literal(GHOST_DECISION_SCHEMA), + id: SlugIdSchema, + status: z.enum(["accepted", "rejected", "superseded"]), + title: z.string().min(1), + claim: z.string().min(1), + rationale: z.string().min(1), + scope: GhostExperienceScopeSchema.optional(), + evidence: z.array(GhostExperienceEvidenceSchema).min(1), + decided_at: z.string().datetime({ offset: true }), + }) + .strict(); + +export const GhostProposalActionSchema = z + .object({ + target: z.enum(["decisions", "patterns", "checks", "intent"]), + summary: z.string().min(1), + }) + .strict(); + +export const GhostProposalSchema = z + .object({ + schema: z.literal(GHOST_PROPOSAL_SCHEMA), + id: SlugIdSchema, + status: z.enum(["open", "accepted", "rejected", "superseded"]), + kind: z.enum(["decision", "pattern", "check", "intent"]), + title: z.string().min(1), + claim: z.string().min(1), + rationale: z.string().min(1), + scope: GhostExperienceScopeSchema.optional(), + evidence: z.array(GhostExperienceEvidenceSchema).min(1), + proposed_action: GhostProposalActionSchema, + }) + .strict(); diff --git a/packages/ghost/src/ghost-core/memory/types.ts b/packages/ghost/src/ghost-core/memory/types.ts new file mode 100644 index 0000000..09c91c8 --- /dev/null +++ b/packages/ghost/src/ghost-core/memory/types.ts @@ -0,0 +1,79 @@ +export const GHOST_DECISION_SCHEMA = "ghost.decision/v1" as const; +export const GHOST_PROPOSAL_SCHEMA = "ghost.proposal/v1" as const; + +export const GHOST_DECISIONS_DIRNAME = "decisions" as const; +export const GHOST_PROPOSALS_DIRNAME = "proposals" as const; + +export type GhostDecisionStatus = "accepted" | "rejected" | "superseded"; +export type GhostProposalStatus = + | "open" + | "accepted" + | "rejected" + | "superseded"; +export type GhostProposalKind = "decision" | "pattern" | "check" | "intent"; +export type GhostProposalTarget = + | "decisions" + | "patterns" + | "checks" + | "intent"; + +export interface GhostExperienceScope { + roles?: string[]; + scopes?: string[]; + surface_types?: string[]; + pattern_ids?: string[]; + paths?: string[]; +} + +export interface GhostExperienceEvidence { + path?: string; + survey_surface_id?: string; + locator?: string; + note?: string; +} + +export interface GhostDecisionDocument { + schema: typeof GHOST_DECISION_SCHEMA; + id: string; + status: GhostDecisionStatus; + title: string; + claim: string; + rationale: string; + scope?: GhostExperienceScope; + evidence: GhostExperienceEvidence[]; + decided_at: string; +} + +export interface GhostProposalAction { + target: GhostProposalTarget; + summary: string; +} + +export interface GhostProposalDocument { + schema: typeof GHOST_PROPOSAL_SCHEMA; + id: string; + status: GhostProposalStatus; + kind: GhostProposalKind; + title: string; + claim: string; + rationale: string; + scope?: GhostExperienceScope; + evidence: GhostExperienceEvidence[]; + proposed_action: GhostProposalAction; +} + +export type GhostMemoryLintSeverity = "error" | "warning" | "info"; + +export interface GhostMemoryLintIssue { + severity: GhostMemoryLintSeverity; + rule: string; + message: string; + path?: string; +} + +export interface GhostMemoryLintReport { + issues: GhostMemoryLintIssue[]; + errors: number; + warnings: number; + info: number; +} diff --git a/packages/ghost/src/ghost-core/patterns/index.ts b/packages/ghost/src/ghost-core/patterns/index.ts new file mode 100644 index 0000000..a12a2d3 --- /dev/null +++ b/packages/ghost/src/ghost-core/patterns/index.ts @@ -0,0 +1,22 @@ +export { lintGhostPatterns } from "./lint.js"; +export { + GhostCompositionAnatomySchema, + GhostCompositionPatternSchema, + GhostPatternEvidenceSchema, + GhostPatternsSchema, + GhostSurfaceTypePatternSchema, +} from "./schema.js"; +export type { + GhostCompositionAnatomy, + GhostCompositionPattern, + GhostPatternEvidence, + GhostPatternsDocument, + GhostPatternsLintIssue, + GhostPatternsLintReport, + GhostPatternsLintSeverity, + GhostSurfaceTypePattern, +} from "./types.js"; +export { + GHOST_PATTERNS_FILENAME, + GHOST_PATTERNS_SCHEMA, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/patterns/lint.ts b/packages/ghost/src/ghost-core/patterns/lint.ts new file mode 100644 index 0000000..b2090e5 --- /dev/null +++ b/packages/ghost/src/ghost-core/patterns/lint.ts @@ -0,0 +1,140 @@ +import type { ZodIssue } from "zod"; +import { GhostPatternsSchema } from "./schema.js"; +import type { + GhostPatternsDocument, + GhostPatternsLintIssue, + GhostPatternsLintReport, +} from "./types.js"; + +export function lintGhostPatterns(input: unknown): GhostPatternsLintReport { + const issues: GhostPatternsLintIssue[] = []; + const result = GhostPatternsSchema.safeParse(input); + if (!result.success) { + issues.push(...zodIssues(result.error.issues)); + return finalize(issues); + } + + const doc = result.data as GhostPatternsDocument; + checkDuplicateIds(doc, issues); + checkReferences(doc, issues); + doc.composition_patterns.forEach((pattern, index) => { + if (!pattern.evidence?.length) { + issues.push({ + severity: "warning", + rule: "pattern-evidence-missing", + message: + "composition patterns should cite survey-backed evidence before they guide review.", + path: `composition_patterns[${index}].evidence`, + }); + } + }); + + return finalize(issues); +} + +function checkDuplicateIds( + doc: GhostPatternsDocument, + issues: GhostPatternsLintIssue[], +): void { + const seenSurfaceTypes = new Map(); + doc.surface_types.forEach((surfaceType, index) => { + const previous = seenSurfaceTypes.get(surfaceType.id); + if (previous !== undefined) { + issues.push({ + severity: "error", + rule: "surface-type-id-duplicate", + message: `surface type id '${surfaceType.id}' is duplicated (also at surface_types[${previous}])`, + path: `surface_types[${index}].id`, + }); + } else { + seenSurfaceTypes.set(surfaceType.id, index); + } + }); + + const seenPatterns = new Map(); + doc.composition_patterns.forEach((pattern, index) => { + const previous = seenPatterns.get(pattern.id); + if (previous !== undefined) { + issues.push({ + severity: "error", + rule: "composition-pattern-id-duplicate", + message: `composition pattern id '${pattern.id}' is duplicated (also at composition_patterns[${previous}])`, + path: `composition_patterns[${index}].id`, + }); + } else { + seenPatterns.set(pattern.id, index); + } + }); +} + +function checkReferences( + doc: GhostPatternsDocument, + issues: GhostPatternsLintIssue[], +): void { + const surfaceTypeIds = new Set( + doc.surface_types.map((surfaceType) => surfaceType.id), + ); + const patternIds = new Set( + doc.composition_patterns.map((pattern) => pattern.id), + ); + + doc.surface_types.forEach((surfaceType, index) => { + surfaceType.preferred_patterns?.forEach((patternId, patternIndex) => { + if (patternIds.has(patternId)) return; + issues.push({ + severity: "error", + rule: "surface-type-pattern-unknown", + message: `surface type '${surfaceType.id}' references unknown preferred pattern '${patternId}'.`, + path: `surface_types[${index}].preferred_patterns[${patternIndex}]`, + }); + }); + surfaceType.discouraged_patterns?.forEach((patternId, patternIndex) => { + if (patternIds.has(patternId)) return; + issues.push({ + severity: "error", + rule: "surface-type-pattern-unknown", + message: `surface type '${surfaceType.id}' references unknown discouraged pattern '${patternId}'.`, + path: `surface_types[${index}].discouraged_patterns[${patternIndex}]`, + }); + }); + }); + + doc.composition_patterns.forEach((pattern, index) => { + pattern.surface_types?.forEach((surfaceTypeId, surfaceTypeIndex) => { + if (surfaceTypeIds.has(surfaceTypeId)) return; + issues.push({ + severity: "error", + rule: "composition-pattern-surface-type-unknown", + message: `composition pattern '${pattern.id}' references unknown surface type '${surfaceTypeId}'.`, + path: `composition_patterns[${index}].surface_types[${surfaceTypeIndex}]`, + }); + }); + }); +} + +function zodIssues(issues: ZodIssue[]): GhostPatternsLintIssue[] { + return issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: formatZodPath(issue.path), + })); +} + +function formatZodPath(path: ZodIssue["path"]): string | undefined { + if (path.length === 0) return undefined; + return path.reduce((formatted, segment) => { + if (typeof segment === "number") return `${formatted}[${segment}]`; + const key = String(segment); + return formatted ? `${formatted}.${key}` : key; + }, ""); +} + +function finalize(issues: GhostPatternsLintIssue[]): GhostPatternsLintReport { + return { + issues, + errors: issues.filter((issue) => issue.severity === "error").length, + warnings: issues.filter((issue) => issue.severity === "warning").length, + info: issues.filter((issue) => issue.severity === "info").length, + }; +} diff --git a/packages/ghost/src/ghost-core/patterns/schema.ts b/packages/ghost/src/ghost-core/patterns/schema.ts new file mode 100644 index 0000000..14926b7 --- /dev/null +++ b/packages/ghost/src/ghost-core/patterns/schema.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { GHOST_PATTERNS_SCHEMA } from "./types.js"; + +const SlugIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }); + +export const GhostPatternEvidenceSchema = z + .object({ + surface_id: z.string().min(1).optional(), + path: z.string().min(1).optional(), + locator: z.string().min(1).optional(), + note: z.string().min(1).optional(), + }) + .strict(); + +export const GhostSurfaceTypePatternSchema = z + .object({ + id: SlugIdSchema, + title: z.string().min(1).optional(), + description: z.string().min(1).optional(), + signals: z.array(z.string().min(1)).optional(), + preferred_patterns: z.array(SlugIdSchema).optional(), + discouraged_patterns: z.array(SlugIdSchema).optional(), + evidence: z.array(GhostPatternEvidenceSchema).optional(), + }) + .strict(); + +export const GhostCompositionAnatomySchema = z + .object({ + ordered: z.array(z.string().min(1)).optional(), + required: z.array(z.string().min(1)).optional(), + optional: z.array(z.string().min(1)).optional(), + forbidden: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export const GhostCompositionPatternSchema = z + .object({ + id: SlugIdSchema, + title: z.string().min(1).optional(), + intent: z.string().min(1).optional(), + surface_types: z.array(SlugIdSchema).optional(), + frequency: z.number().int().nonnegative().optional(), + confidence: z.number().min(0).max(1).optional(), + anatomy: GhostCompositionAnatomySchema.optional(), + traits: z + .record(z.string(), z.union([z.string(), z.array(z.string())])) + .optional(), + variants: z.array(z.string().min(1)).optional(), + anti_patterns: z.array(z.string().min(1)).optional(), + evidence: z.array(GhostPatternEvidenceSchema).optional(), + advisory: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export const GhostPatternsSchema = z + .object({ + schema: z.literal(GHOST_PATTERNS_SCHEMA), + id: SlugIdSchema, + surface_types: z.array(GhostSurfaceTypePatternSchema), + composition_patterns: z.array(GhostCompositionPatternSchema), + advisory: z + .object({ + review_expectations: z.array(z.string().min(1)).optional(), + }) + .strict() + .optional(), + }) + .strict(); diff --git a/packages/ghost/src/ghost-core/patterns/types.ts b/packages/ghost/src/ghost-core/patterns/types.ts new file mode 100644 index 0000000..7685af5 --- /dev/null +++ b/packages/ghost/src/ghost-core/patterns/types.ts @@ -0,0 +1,67 @@ +export const GHOST_PATTERNS_SCHEMA = "ghost.patterns/v1" as const; +export const GHOST_PATTERNS_FILENAME = "patterns.yml" as const; + +export interface GhostPatternEvidence { + surface_id?: string; + path?: string; + locator?: string; + note?: string; +} + +export interface GhostSurfaceTypePattern { + id: string; + title?: string; + description?: string; + signals?: string[]; + preferred_patterns?: string[]; + discouraged_patterns?: string[]; + evidence?: GhostPatternEvidence[]; +} + +export interface GhostCompositionAnatomy { + ordered?: string[]; + required?: string[]; + optional?: string[]; + forbidden?: string[]; +} + +export interface GhostCompositionPattern { + id: string; + title?: string; + intent?: string; + surface_types?: string[]; + frequency?: number; + confidence?: number; + anatomy?: GhostCompositionAnatomy; + traits?: Record; + variants?: string[]; + anti_patterns?: string[]; + evidence?: GhostPatternEvidence[]; + advisory?: string[]; +} + +export interface GhostPatternsDocument { + schema: typeof GHOST_PATTERNS_SCHEMA; + id: string; + surface_types: GhostSurfaceTypePattern[]; + composition_patterns: GhostCompositionPattern[]; + advisory?: { + review_expectations?: string[]; + }; +} + +export type GhostPatternsLintSeverity = "error" | "warning" | "info"; + +export interface GhostPatternsLintIssue { + severity: GhostPatternsLintSeverity; + rule: string; + message: string; + path?: string; +} + +export interface GhostPatternsLintReport { + issues: GhostPatternsLintIssue[]; + errors: number; + warnings: number; + info: number; +} diff --git a/packages/ghost/src/ghost-core/perceptual-prior.ts b/packages/ghost/src/ghost-core/perceptual-prior.ts new file mode 100644 index 0000000..90fddba --- /dev/null +++ b/packages/ghost/src/ghost-core/perceptual-prior.ts @@ -0,0 +1,223 @@ +/** + * Ghost's perceptual prior — the opinionated stance that drift severity + * should track *how loudly a change registers visually*, not just whether + * it deviates from a recorded value. + * + * Three perceptual tiers: + * + * - **loud**: visible at first glance, no inspection required. Color + * and typeface family are loud — a new color or font is the change + * everyone notices. + * - **structural**: visible on inspection or interaction. Radius + * philosophy (pill vs. boxy), elevation vocabulary, focus treatment. + * Pill among boxes screams; the wrong shadow on a flat system jars. + * - **rhythmic**: visible only as a system property. Spacing scale + * adherence, density, motion duration. Individual deviations are + * nearly imperceptible — the rhythm matters in aggregate. + * + * Two cross-cutting checks: + * + * 1. **Match shape** is per-`CheckKind`: color is `exact`, spacing is + * `band`, type-size is `percent`, radius/shadow are `structural`. + * Defaults are sensible; per-check overrides remain available. + * 2. **Presence/absence escalation**: when survey count for a + * guarded phenomenon is ≤ `presence_floor`, escalate the check one + * tier. Sparsity is the design decision — adding to a silent pattern + * is louder than tweaking a populated one. + * + * Tier membership is a position: projects can override per-check severity + * but cannot remap a dimension's tier. The tiers are the product. + */ + +import type { CanonicalDecisionDimension } from "./decision-vocabulary.js"; +import type { + Check, + CheckKind, + CheckMatchShape, + DriftSeverity, +} from "./types.js"; + +// --- Tier table --------------------------------------------------------- + +export type PerceptualTier = "loud" | "structural" | "rhythmic"; + +/** + * Maps each canonical dimension to its perceptual tier. The mapping is a + * position, not configuration — see module docstring. + * + * Notes on a few placements: + * - `typography-voice` is structural at the dimension level; a foreign + * font *family* is loud (handled by `CheckKind: "type-family"`), while + * size-detail drift is rhythmic (handled by `CheckKind: "type-size"`). + * Per-rule kind escalation handles that split. + * - `interactive-patterns` is structural — focus rings register on + * interaction, not at first glance. + * - `composition-patterns` is structural — article, tracker, + * comparison, and card shapes change hierarchy and scanning behavior. + * - `theming-architecture` and `token-architecture` are rhythmic — + * they're plumbing, perceptible only via downstream symptoms. + */ +export const PERCEPTUAL_TIER: Readonly< + Record +> = { + "color-strategy": "loud", + "font-sourcing": "loud", + "typography-voice": "structural", + "shape-language": "structural", + elevation: "structural", + "surface-hierarchy": "structural", + "interactive-patterns": "structural", + "spatial-system": "rhythmic", + density: "rhythmic", + motion: "rhythmic", + "theming-architecture": "rhythmic", + "token-architecture": "rhythmic", + "composition-patterns": "structural", +}; + +/** + * Per-tier default severity for emitted reviewer checks. The emitter writes + * the resolved severity into the slash command so the reader sees a flat + * Critical / Serious / Nit grouping rather than a per-dimension layout. + */ +export const TIER_SEVERITY: Readonly> = { + loud: "critical", + structural: "serious", + rhythmic: "nit", +}; + +// --- Match shape and tolerance defaults -------------------------------- + +/** + * Default match shape per check kind. Color demands exact equality (any + * non-allowed hex is drift). Spacing tolerates a small absolute band + * because 7px-vs-8px is invisible. Type size uses a percentage band + * because 14→15px is invisible but 14→24px is loud. Radius and shadow + * are structural — pill vs. non-pill matters more than 999 vs. 998. + */ +export const DEFAULT_MATCH: Readonly> = { + color: "exact", + radius: "structural", + spacing: "band", + "type-size": "percent", + "type-family": "exact", + "type-weight": "exact", + shadow: "structural", + motion: "exact", +}; + +/** + * Default tolerance for each match shape. Absent for `exact` and + * `structural` (no tolerance applies). Used when a check selects a match + * shape but doesn't specify a tolerance. + */ +export const DEFAULT_TOLERANCE: Readonly< + Record +> = { + exact: undefined, + structural: undefined, + band: 2, // ±2 in source unit (typically px) + percent: 0.1, // ±10% relative +}; + +// --- Severity computation ---------------------------------------------- + +const TIER_ORDER: PerceptualTier[] = ["rhythmic", "structural", "loud"]; + +/** + * Escalate a tier one step toward `loud`. `loud` saturates — escalating + * a loud check against an absent dimension is still critical. + */ +export function escalateTier(tier: PerceptualTier): PerceptualTier { + const idx = TIER_ORDER.indexOf(tier); + if (idx < 0) return tier; + return TIER_ORDER[Math.min(idx + 1, TIER_ORDER.length - 1)] as PerceptualTier; +} + +/** + * Resolve a canonical dimension to its perceptual tier. Returns + * `structural` for unknown / non-canonical inputs — the conservative + * default. The emitter / lint should warn on non-canonical checks so + * they're caught at authoring time. + */ +export function tierForCanonical( + canonical: string | undefined, +): PerceptualTier { + if (!canonical) return "structural"; + const tier = (PERCEPTUAL_TIER as Record)[ + canonical + ]; + return tier ?? "structural"; +} + +/** + * Apply presence/absence escalation: when `surveyCount <= presenceFloor`, + * the dimension is silent (or near-silent) in the project, so any check + * guarding it is one tier louder than its base. + * + * `presenceFloor` defaults to 0 — only completely-absent guarded patterns + * trigger escalation by default. Checks that want softer escalation + * (motion in a system with 1–2 structural transitions, say) can set a + * higher floor. + */ +export function escalateForPresence( + base: PerceptualTier, + surveyCount: number, + presenceFloor = 0, +): PerceptualTier { + if (surveyCount <= presenceFloor) return escalateTier(base); + return base; +} + +/** + * Compute the final severity for a check, given its canonical dimension + * and the survey count for the guarded pattern in the current fingerprint. + * + * Resolution order: + * 1. Explicit `check.severity` wins outright. + * 2. Otherwise, base tier from `check.canonical` → `tierForCanonical`. + * 3. Apply presence/absence escalation against `check.presence_floor` + * (default 0) and the supplied `surveyCount`. + * 4. Map tier → severity via `TIER_SEVERITY`. + * + * Pure / deterministic. + */ +export function computeCheckSeverity( + check: Pick, + surveyCount: number, +): DriftSeverity { + if (check.severity) return check.severity; + const baseTier = tierForCanonical(check.canonical); + const finalTier = escalateForPresence( + baseTier, + surveyCount, + check.presence_floor ?? 0, + ); + return TIER_SEVERITY[finalTier]; +} + +/** + * Compute the final match shape for a check. Explicit `check.match` wins; + * otherwise the default for the check's kind. Returns `exact` when neither + * is set — the most conservative shape. + */ +export function resolveMatchShape( + check: Pick, +): CheckMatchShape { + if (check.match) return check.match; + if (check.kind) return DEFAULT_MATCH[check.kind]; + return "exact"; +} + +/** + * Compute the final tolerance for a check. Explicit `check.tolerance` wins; + * otherwise the default for the resolved match shape. Returns `undefined` + * for exact/structural matches, where tolerance doesn't apply. + */ +export function resolveTolerance( + check: Pick, +): number | undefined { + if (check.tolerance !== undefined) return check.tolerance; + const shape = resolveMatchShape(check); + return DEFAULT_TOLERANCE[shape]; +} diff --git a/packages/ghost/src/ghost-core/resources/index.ts b/packages/ghost/src/ghost-core/resources/index.ts new file mode 100644 index 0000000..54a611a --- /dev/null +++ b/packages/ghost/src/ghost-core/resources/index.ts @@ -0,0 +1,18 @@ +export { lintGhostResources } from "./lint.js"; +export { + GhostResourceRefSchema, + GhostResourcesSchema, + GhostSurfaceResourceSchema, +} from "./schema.js"; +export type { + GhostResourceRef, + GhostResourcesDocument, + GhostResourcesLintIssue, + GhostResourcesLintReport, + GhostResourcesLintSeverity, + GhostSurfaceResource, +} from "./types.js"; +export { + GHOST_RESOURCES_FILENAME, + GHOST_RESOURCES_SCHEMA, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/resources/lint.ts b/packages/ghost/src/ghost-core/resources/lint.ts new file mode 100644 index 0000000..66555c3 --- /dev/null +++ b/packages/ghost/src/ghost-core/resources/lint.ts @@ -0,0 +1,90 @@ +import type { ZodIssue } from "zod"; +import { GhostResourcesSchema } from "./schema.js"; +import type { + GhostResourcesDocument, + GhostResourcesLintIssue, + GhostResourcesLintReport, +} from "./types.js"; + +export function lintGhostResources(input: unknown): GhostResourcesLintReport { + const issues: GhostResourcesLintIssue[] = []; + const result = GhostResourcesSchema.safeParse(input); + if (!result.success) { + issues.push(...zodIssues(result.error.issues)); + return finalize(issues); + } + + const doc = result.data as GhostResourcesDocument; + checkDuplicateIds(doc, issues); + if (!doc.include?.length) { + issues.push({ + severity: "info", + rule: "resources-include-empty", + message: + "resources.yml has no include globs; scanners will fall back to map.md surface sources.", + path: "include", + }); + } + + return finalize(issues); +} + +function checkDuplicateIds( + doc: GhostResourcesDocument, + issues: GhostResourcesLintIssue[], +): void { + const seen = new Map(); + const groups = [ + ["design_system", doc.design_system], + ["surfaces", doc.surfaces], + ["screenshots", doc.screenshots], + ["docs", doc.docs], + ["resolvers", doc.resolvers], + ["upstreams", doc.upstreams], + ] as const; + + if (doc.primary.id) seen.set(doc.primary.id, "primary.id"); + for (const [group, refs] of groups) { + refs?.forEach((ref, index) => { + if (!ref.id) return; + const previous = seen.get(ref.id); + if (previous) { + issues.push({ + severity: "error", + rule: "resource-id-duplicate", + message: `resource id '${ref.id}' is duplicated (also at ${previous})`, + path: `${group}[${index}].id`, + }); + } else { + seen.set(ref.id, `${group}[${index}].id`); + } + }); + } +} + +function zodIssues(issues: ZodIssue[]): GhostResourcesLintIssue[] { + return issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: formatZodPath(issue.path), + })); +} + +function formatZodPath(path: ZodIssue["path"]): string | undefined { + if (path.length === 0) return undefined; + return path.reduce((formatted, segment) => { + if (typeof segment === "number") return `${formatted}[${segment}]`; + const key = String(segment); + return formatted ? `${formatted}.${key}` : key; + }, ""); +} + +function finalize(issues: GhostResourcesLintIssue[]): GhostResourcesLintReport { + return { + issues, + errors: issues.filter((issue) => issue.severity === "error").length, + warnings: issues.filter((issue) => issue.severity === "warning").length, + info: issues.filter((issue) => issue.severity === "info").length, + }; +} diff --git a/packages/ghost/src/ghost-core/resources/schema.ts b/packages/ghost/src/ghost-core/resources/schema.ts new file mode 100644 index 0000000..c912064 --- /dev/null +++ b/packages/ghost/src/ghost-core/resources/schema.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { GHOST_RESOURCES_SCHEMA } from "./types.js"; + +const SlugIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9._-]*$/, { + message: + "id must be a slug (lowercase alphanumeric plus . _ -, leading alphanumeric)", + }); + +export const GhostResourceRefSchema = z + .object({ + id: SlugIdSchema.optional(), + target: z.string().min(1), + kind: z.string().min(1).optional(), + paths: z.array(z.string().min(1)).optional(), + note: z.string().min(1).optional(), + }) + .strict(); + +export const GhostSurfaceResourceSchema = z + .object({ + id: SlugIdSchema.optional(), + name: z.string().min(1).optional(), + kind: z.string().min(1).optional(), + target: z.string().min(1).optional(), + locator: z.string().min(1).optional(), + paths: z.array(z.string().min(1)).optional(), + note: z.string().min(1).optional(), + }) + .strict(); + +export const GhostResourcesSchema = z + .object({ + schema: z.literal(GHOST_RESOURCES_SCHEMA), + id: SlugIdSchema, + primary: GhostResourceRefSchema, + design_system: z.array(GhostResourceRefSchema).optional(), + surfaces: z.array(GhostSurfaceResourceSchema).optional(), + screenshots: z.array(GhostResourceRefSchema).optional(), + docs: z.array(GhostResourceRefSchema).optional(), + resolvers: z.array(GhostResourceRefSchema).optional(), + upstreams: z.array(GhostResourceRefSchema).optional(), + include: z.array(z.string().min(1)).optional(), + exclude: z.array(z.string().min(1)).optional(), + }) + .strict(); diff --git a/packages/ghost/src/ghost-core/resources/types.ts b/packages/ghost/src/ghost-core/resources/types.ts new file mode 100644 index 0000000..b36c0e0 --- /dev/null +++ b/packages/ghost/src/ghost-core/resources/types.ts @@ -0,0 +1,50 @@ +export const GHOST_RESOURCES_SCHEMA = "ghost.resources/v1" as const; +export const GHOST_RESOURCES_FILENAME = "resources.yml" as const; + +export interface GhostResourceRef { + id?: string; + target: string; + kind?: string; + paths?: string[]; + note?: string; +} + +export interface GhostSurfaceResource { + id?: string; + name?: string; + kind?: string; + target?: string; + locator?: string; + paths?: string[]; + note?: string; +} + +export interface GhostResourcesDocument { + schema: typeof GHOST_RESOURCES_SCHEMA; + id: string; + primary: GhostResourceRef; + design_system?: GhostResourceRef[]; + surfaces?: GhostSurfaceResource[]; + screenshots?: GhostResourceRef[]; + docs?: GhostResourceRef[]; + resolvers?: GhostResourceRef[]; + upstreams?: GhostResourceRef[]; + include?: string[]; + exclude?: string[]; +} + +export type GhostResourcesLintSeverity = "error" | "warning" | "info"; + +export interface GhostResourcesLintIssue { + severity: GhostResourcesLintSeverity; + rule: string; + message: string; + path?: string; +} + +export interface GhostResourcesLintReport { + issues: GhostResourcesLintIssue[]; + errors: number; + warnings: number; + info: number; +} diff --git a/packages/ghost/src/ghost-core/skill-bundle-loader.ts b/packages/ghost/src/ghost-core/skill-bundle-loader.ts new file mode 100644 index 0000000..33745aa --- /dev/null +++ b/packages/ghost/src/ghost-core/skill-bundle-loader.ts @@ -0,0 +1,56 @@ +/** + * Generic loader for an agentskills.io-compatible skill bundle. + * + * Each tool in the Ghost monorepo (`ghost`, `ghost`, …) ships + * its own skill bundle as real markdown under `src/skill-bundle/` and copies + * it verbatim to `dist/skill-bundle/` at build time. This loader walks any + * given root directory and returns a flat, deterministically ordered list of + * files so the unified `ghost skill install` verb can write them into a target + * project. + * + * Spec: https://agentskills.io/specification + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +export interface SkillBundleFile { + /** Path relative to the skill root (e.g. "SKILL.md", "references/schema.md"). */ + path: string; + content: string; +} + +function walk(dir: string, root: string, out: SkillBundleFile[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const absolute = join(dir, entry.name); + if (entry.isDirectory()) { + walk(absolute, root, out); + continue; + } + if (!entry.isFile()) continue; + out.push({ + path: relative(root, absolute), + content: readFileSync(absolute, "utf-8"), + }); + } +} + +/** + * Load a skill bundle from disk. + * + * `bundleRoot` should point to a directory containing at least a `SKILL.md` + * file at the top level (typically `dist/skill-bundle/` after the host + * package's build step has copied the markdown sources from `src/`). + * + * The returned list is sorted with `SKILL.md` first, then alphabetically. + */ +export function loadSkillBundle(bundleRoot: string): SkillBundleFile[] { + const out: SkillBundleFile[] = []; + walk(bundleRoot, bundleRoot, out); + out.sort((a, b) => { + if (a.path === "SKILL.md") return -1; + if (b.path === "SKILL.md") return 1; + return a.path.localeCompare(b.path); + }); + return out; +} diff --git a/packages/ghost/src/ghost-core/survey/catalog-format.ts b/packages/ghost/src/ghost-core/survey/catalog-format.ts new file mode 100644 index 0000000..d74626c --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/catalog-format.ts @@ -0,0 +1,85 @@ +import type { + SurveyCatalogKind, + SurveyCatalogValue, + SurveyValueCatalog, +} from "./catalog-types.js"; + +export function formatSurveyCatalogMarkdown( + catalog: SurveyValueCatalog, +): string { + const lines: string[] = []; + + lines.push("# Survey Value Catalog"); + lines.push(""); + lines.push( + `Rows: ${catalog.counts.rows} value row(s), ${catalog.counts.values} unique value(s), ${catalog.counts.total_occurrences} occurrence(s)`, + ); + if (catalog.filter?.kind) + lines.push(`Filter: kind \`${catalog.filter.kind}\``); + lines.push(""); + + for (const kind of catalog.kinds) appendKind(lines, kind); + if (catalog.kinds.length === 0) lines.push("No values matched."); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function appendKind(lines: string[], kind: SurveyCatalogKind): void { + lines.push( + `## ${kind.kind} (${kind.values.length} values, ${kind.rows} rows, ${kind.occurrences} occurrences, ${kind.files_count} file hits)`, + ); + for (const value of kind.values) lines.push(formatValue(value)); + lines.push(""); +} + +function formatValue(value: SurveyCatalogValue): string { + const extras = [ + value.ids.length ? `ids ${formatInlineList(value.ids)}` : undefined, + value.raws.length ? `raw ${formatInlineList(value.raws)}` : undefined, + value.usage ? `usage ${formatUsage(value.usage)}` : undefined, + value.role_hypotheses?.length + ? `roles ${value.role_hypotheses.join(",")}` + : undefined, + value.specs?.length ? `spec ${formatSpec(value.specs[0])}` : undefined, + value.sources.length + ? `sources ${formatInlineList(value.sources)}` + : undefined, + value.resolution_statuses?.length + ? `resolution ${value.resolution_statuses.join(",")}` + : undefined, + ].filter(Boolean); + return `- \`${value.value}\` (${value.occurrences}x, ${value.files_count} files, ${value.rows} rows${extras.length ? `; ${extras.join("; ")}` : ""})`; +} + +function formatInlineList(values: string[]): string { + return values.map((value) => `\`${value}\``).join(", "); +} + +function formatUsage(usage: Record): string { + return Object.entries(usage) + .map(([key, value]) => `${key}:${value}`) + .join(","); +} + +function formatSpec(spec: unknown): string { + const text = stableJson(spec); + return text.length > 160 ? `${text.slice(0, 157)}...` : text; +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, sortJson(child)]), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/ghost/src/ghost-core/survey/catalog-types.ts b/packages/ghost/src/ghost-core/survey/catalog-types.ts new file mode 100644 index 0000000..97ce250 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/catalog-types.ts @@ -0,0 +1,44 @@ +import type { ValueSpec } from "./types.js"; + +export interface SurveyCatalogOptions { + kind?: string; +} + +export interface SurveyValueCatalog { + schema: "ghost.survey.catalog/v1"; + source_schema: "ghost.survey/v2"; + filter?: { + kind?: string; + }; + counts: SurveyCatalogCounts; + kinds: SurveyCatalogKind[]; +} + +export interface SurveyCatalogCounts { + kinds: number; + values: number; + rows: number; + total_occurrences: number; +} + +export interface SurveyCatalogKind { + kind: string; + values: SurveyCatalogValue[]; + rows: number; + occurrences: number; + files_count: number; +} + +export interface SurveyCatalogValue { + value: string; + rows: number; + occurrences: number; + files_count: number; + ids: string[]; + raws: string[]; + usage?: Record; + role_hypotheses?: string[]; + specs?: ValueSpec[]; + sources: string[]; + resolution_statuses?: string[]; +} diff --git a/packages/ghost/src/ghost-core/survey/catalog.ts b/packages/ghost/src/ghost-core/survey/catalog.ts new file mode 100644 index 0000000..12212b6 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/catalog.ts @@ -0,0 +1,204 @@ +import { RECOMMENDED_VALUE_KINDS } from "./schema.js"; + +export { formatSurveyCatalogMarkdown } from "./catalog-format.js"; + +import type { + SurveyCatalogKind, + SurveyCatalogOptions, + SurveyCatalogValue, + SurveyValueCatalog, +} from "./catalog-types.js"; + +export type { + SurveyCatalogCounts, + SurveyCatalogKind, + SurveyCatalogOptions, + SurveyCatalogValue, + SurveyValueCatalog, +} from "./catalog-types.js"; + +import type { Survey, SurveySource, ValueRow, ValueSpec } from "./types.js"; + +interface MutableCatalogValue { + value: string; + rows: number; + occurrences: number; + files_count: number; + ids: Set; + raws: Set; + usage: Map; + role_hypotheses: Set; + specs: Map; + sources: Set; + resolution_statuses: Set; +} + +export function catalogSurveyValues( + survey: Survey, + options: SurveyCatalogOptions = {}, +): SurveyValueCatalog { + const rows = options.kind + ? survey.values.filter((row) => row.kind === options.kind) + : survey.values; + const kinds = orderedKinds(rows).map((kind) => + catalogKind( + kind, + rows.filter((row) => row.kind === kind), + ), + ); + const values = kinds.flatMap((kind) => kind.values); + + return { + schema: "ghost.survey.catalog/v1", + source_schema: survey.schema, + ...(options.kind ? { filter: { kind: options.kind } } : {}), + counts: { + kinds: kinds.length, + values: values.length, + rows: rows.length, + total_occurrences: sum(rows.map((row) => row.occurrences)), + }, + kinds, + }; +} + +function catalogKind(kind: string, rows: ValueRow[]): SurveyCatalogKind { + const grouped = new Map(); + for (const row of rows) { + const current = grouped.get(row.value) ?? createValue(row.value); + current.rows += 1; + current.occurrences += row.occurrences; + current.files_count += row.files_count; + current.ids.add(row.id); + if (row.raw) current.raws.add(row.raw); + if (row.role_hypothesis) current.role_hypotheses.add(row.role_hypothesis); + if (row.spec) current.specs.set(stableJson(row.spec), row.spec); + current.sources.add(sourceLabel(row.source)); + if (row.resolution?.status) { + current.resolution_statuses.add(row.resolution.status); + } + for (const [usage, count] of Object.entries(row.usage ?? {})) { + current.usage.set(usage, (current.usage.get(usage) ?? 0) + count); + } + grouped.set(row.value, current); + } + + const values = [...grouped.values()].map(finalizeValue).sort(sortValues); + return { + kind, + values, + rows: rows.length, + occurrences: sum(rows.map((row) => row.occurrences)), + files_count: sum(rows.map((row) => row.files_count)), + }; +} + +function createValue(value: string): MutableCatalogValue { + return { + value, + rows: 0, + occurrences: 0, + files_count: 0, + ids: new Set(), + raws: new Set(), + usage: new Map(), + role_hypotheses: new Set(), + specs: new Map(), + sources: new Set(), + resolution_statuses: new Set(), + }; +} + +function finalizeValue(value: MutableCatalogValue): SurveyCatalogValue { + return pruneUndefined({ + value: value.value, + rows: value.rows, + occurrences: value.occurrences, + files_count: value.files_count, + ids: [...value.ids].sort(compareStrings), + raws: [...value.raws].sort(compareStrings), + usage: value.usage.size ? sortedRecord(value.usage) : undefined, + role_hypotheses: sortedOptional(value.role_hypotheses), + specs: value.specs.size + ? [...value.specs.entries()] + .sort(([a], [b]) => compareStrings(a, b)) + .map(([, spec]) => spec) + : undefined, + sources: [...value.sources].sort(compareStrings), + resolution_statuses: sortedOptional(value.resolution_statuses), + }); +} + +function orderedKinds(rows: ValueRow[]): string[] { + const present = new Set(rows.map((row) => row.kind)); + const recommended = RECOMMENDED_VALUE_KINDS.filter((kind) => + present.has(kind), + ); + const extras = [...present] + .filter((kind) => !RECOMMENDED_VALUE_KINDS.includes(kind)) + .sort(compareStrings); + return [...recommended, ...extras]; +} + +function sortValues(a: SurveyCatalogValue, b: SurveyCatalogValue): number { + return ( + compareNumbers(b.occurrences, a.occurrences) || + compareNumbers(b.files_count, a.files_count) || + compareNumbers(b.rows, a.rows) || + compareStrings(a.value, b.value) + ); +} + +function sortedRecord(values: Map): Record { + return Object.fromEntries( + [...values.entries()].sort( + ([aKey, aValue], [bKey, bValue]) => + compareNumbers(bValue, aValue) || compareStrings(aKey, bKey), + ), + ); +} + +function sortedOptional(values: Set): string[] | undefined { + return values.size ? [...values].sort(compareStrings) : undefined; +} + +function sourceLabel(source: SurveySource): string { + return source.id ?? source.target; +} + +function sum(values: number[]): number { + return values.reduce((total, value) => total + value, 0); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => compareStrings(a, b)) + .map(([key, child]) => [key, sortJson(child)]), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function compareNumbers(a: number, b: number): number { + return a === b ? 0 : a < b ? -1 : 1; +} + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b); +} + +function pruneUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value; +} diff --git a/packages/ghost/src/ghost-core/survey/fix-ids.ts b/packages/ghost/src/ghost-core/survey/fix-ids.ts new file mode 100644 index 0000000..88cd2c3 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/fix-ids.ts @@ -0,0 +1,55 @@ +import { + componentRowId, + tokenRowId, + uiSurfaceRowId, + valueRowId, +} from "./id.js"; +import type { + ComponentRow, + Survey, + TokenRow, + UiSurfaceRow, + ValueRow, +} from "./types.js"; + +/** + * Recompute every row's `id` from its content fields, producing a new + * survey with deterministic IDs. + * + * Authoring flow: an agent writes survey rows with `id: ""` (or any + * placeholder), then calls `recomputeSurveyIds` to populate them, then + * runs `lintSurvey` to validate. This avoids forcing the agent to compute + * SHA-256 hashes by hand for every row, while keeping the survey + * schema's strict id requirement. + * + * The function is pure — input survey is unchanged. + */ +export function recomputeSurveyIds(survey: Survey): Survey { + return { + ...survey, + values: survey.values.map( + (row): ValueRow => ({ + ...row, + id: valueRowId(row.source, row.kind, row.value, row.raw), + }), + ), + tokens: survey.tokens.map( + (row): TokenRow => ({ + ...row, + id: tokenRowId(row.source, row.name), + }), + ), + components: survey.components.map( + (row): ComponentRow => ({ + ...row, + id: componentRowId(row.source, row.name), + }), + ), + ui_surfaces: survey.ui_surfaces.map( + (row): UiSurfaceRow => ({ + ...row, + id: uiSurfaceRowId(row.source, row.name, row.kind, row.locator), + }), + ), + }; +} diff --git a/packages/ghost/src/ghost-core/survey/id.ts b/packages/ghost/src/ghost-core/survey/id.ts new file mode 100644 index 0000000..1816136 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/id.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import type { SurveySource } from "./types.js"; + +/** + * Deterministic ID generation for survey rows. + * + * Two scans of the same `(target, commit)` over the same source content + * must produce identical IDs so that re-merging is idempotent and git + * diffs over `survey.json` show only meaningful changes. Scans of + * different commits or different targets produce distinct IDs so that + * fleet-wide merges preserve every observation. + * + * IDs are 16-hex-char (8-byte) prefixes of SHA-256. At ~10^6 rows in the + * universe of all scans this gives collision probability under 2^-32. + */ + +const ID_LENGTH = 16; + +const VALUE_TAG = "value"; +const TOKEN_TAG = "token"; +const COMPONENT_TAG = "component"; +const UI_SURFACE_TAG = "ui_surface"; + +function digest(...parts: (string | undefined)[]): string { + const hash = createHash("sha256"); + for (const part of parts) { + hash.update(part ?? ""); + hash.update("\x00"); + } + return hash.digest("hex").slice(0, ID_LENGTH); +} + +function sourceKey(source: SurveySource): [string, string] { + return [source.target, source.commit ?? ""]; +} + +export function valueRowId( + source: SurveySource, + kind: string, + value: string, + raw: string, +): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, VALUE_TAG, kind, value, raw); +} + +export function tokenRowId(source: SurveySource, name: string): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, TOKEN_TAG, name); +} + +export function componentRowId(source: SurveySource, name: string): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, COMPONENT_TAG, name); +} + +export function uiSurfaceRowId( + source: SurveySource, + name: string, + kind: string, + locator: string, +): string { + const [target, commit] = sourceKey(source); + return digest(target, commit, UI_SURFACE_TAG, name, kind, locator); +} diff --git a/packages/ghost/src/ghost-core/survey/index.ts b/packages/ghost/src/ghost-core/survey/index.ts new file mode 100644 index 0000000..6282d4a --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/index.ts @@ -0,0 +1,97 @@ +/** + * Public surface for `ghost.survey/v2` — types, schemas, ID generation, + * lint, and merge. Consumed by `ghost` and any future ghost + * tool that operates on survey data. + */ + +export { + catalogSurveyValues, + formatSurveyCatalogMarkdown, + type SurveyCatalogCounts, + type SurveyCatalogKind, + type SurveyCatalogOptions, + type SurveyCatalogValue, + type SurveyValueCatalog, +} from "./catalog.js"; +export { recomputeSurveyIds } from "./fix-ids.js"; +export { + componentRowId, + tokenRowId, + uiSurfaceRowId, + valueRowId, +} from "./id.js"; +export { + lintSurvey, + SURVEY_FILENAME, + type SurveyLintIssue, + type SurveyLintReport, + type SurveyLintSeverity, +} from "./lint.js"; +export { mergeSurveys } from "./merge.js"; +export { + ColorSpecSchema, + ComponentRowSchema, + RECOMMENDED_VALUE_KINDS, + ResolutionSchema, + SurveySchema, + SurveySourceSchema, + TokenRowSchema, + UiSurfaceClassificationSchema, + UiSurfaceCompositionSchema, + UiSurfaceKindSchema, + UiSurfaceRenderabilitySchema, + UiSurfaceRowSchema, + UiSurfaceSignalsSchema, + ValueRowSchema, + ValueSpecSchema, +} from "./schema.js"; +export { + type ComponentEvidenceSummary, + type CountSummary, + formatSurveySummaryMarkdown, + type ResolutionSummary, + type SurveyComponentsSummary, + type SurveySourceSummary, + type SurveySummary, + type SurveySummaryBudget, + type SurveySummaryCounts, + type SurveySummaryOptions, + type SurveyTokensSummary, + type SurveyUiSurfacesSummary, + type SurveyValuesSummary, + summarizeSurvey, + type TokenEvidenceSummary, + type UiSurfaceEvidenceSummary, + type UiSurfaceGroupSummary, + type ValueEvidenceSummary, + type ValueKindSummary, +} from "./summary.js"; +export type { + BreakpointSpec, + ColorSpec, + ComponentRow, + LayoutPrimitiveSpec, + MotionSpec, + RadiusSpec, + RecommendedValueKind, + Resolution, + RowBase, + ScalarUnit, + ShadowSpec, + SpacingSpec, + Survey, + SurveySource, + TokenRow, + TypographySpec, + UiSurfaceClassification, + UiSurfaceComposition, + UiSurfaceDensity, + UiSurfaceKind, + UiSurfaceLayoutShape, + UiSurfaceRenderability, + UiSurfaceRow, + UiSurfaceSignals, + UnknownSpec, + ValueRow, + ValueSpec, +} from "./types.js"; diff --git a/packages/ghost/src/ghost-core/survey/lint.ts b/packages/ghost/src/ghost-core/survey/lint.ts new file mode 100644 index 0000000..e971a88 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/lint.ts @@ -0,0 +1,250 @@ +import type { ZodIssue } from "zod"; +import { + componentRowId, + tokenRowId, + uiSurfaceRowId, + valueRowId, +} from "./id.js"; +import { RECOMMENDED_VALUE_KINDS, SurveySchema } from "./schema.js"; +import type { Survey } from "./types.js"; + +export type SurveyLintSeverity = "error" | "warning" | "info"; + +export interface SurveyLintIssue { + severity: SurveyLintSeverity; + rule: string; + message: string; + /** Dotted path within the survey (e.g. `values[3].id`). */ + path?: string; +} + +export interface SurveyLintReport { + issues: SurveyLintIssue[]; + errors: number; + warnings: number; + info: number; +} + +export const SURVEY_FILENAME = "survey.json"; + +/** + * Lint a parsed survey object against `ghost.survey/v2`. + * + * Errors: schema violations (missing fields, wrong types, bad enum values). + * Warnings: unknown value kinds (open-enum policy), ID mismatches (a row's + * recorded `id` doesn't match what the deterministic generator would + * produce for its content), and scan coverage gaps. + * Errors: duplicate IDs within the same survey. + */ +export function lintSurvey(input: unknown): SurveyLintReport { + const issues: SurveyLintIssue[] = []; + + const result = SurveySchema.safeParse(input); + if (!result.success) { + for (const issue of zodIssues(result.error.issues)) { + issues.push(issue); + } + return finalize(issues); + } + + const survey = result.data as Survey; + + checkSourceGraph(survey, issues); + checkUiSurfaceCoverage(survey, issues); + + // Open-enum kind warnings. + survey.values.forEach((row, idx) => { + if (!RECOMMENDED_VALUE_KINDS.includes(row.kind)) { + issues.push({ + severity: "warning", + rule: "value-kind-unknown", + message: `value row uses non-recommended kind '${row.kind}' — accepted, but cross-fleet tooling may not canonicalize it`, + path: `values[${idx}].kind`, + }); + } + }); + + survey.values.forEach((row, idx) => { + checkResolution(row.resolution, `values[${idx}].resolution`, issues); + }); + survey.tokens.forEach((row, idx) => { + checkResolution(row.resolution, `tokens[${idx}].resolution`, issues); + }); + + // Deterministic-ID checks: each row's recorded id must match what the + // generator would produce for its content. Catches scanners that mint + // IDs incorrectly and breaks idempotent merge if not enforced. + survey.values.forEach((row, idx) => { + const expected = valueRowId(row.source, row.kind, row.value, row.raw); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}' — re-derive via valueRowId(...) to keep merges idempotent`, + path: `values[${idx}].id`, + }); + } + }); + survey.tokens.forEach((row, idx) => { + const expected = tokenRowId(row.source, row.name); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `tokens[${idx}].id`, + }); + } + }); + survey.components.forEach((row, idx) => { + const expected = componentRowId(row.source, row.name); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `components[${idx}].id`, + }); + } + }); + survey.ui_surfaces.forEach((row, idx) => { + const expected = uiSurfaceRowId( + row.source, + row.name, + row.kind, + row.locator, + ); + if (row.id !== expected) { + issues.push({ + severity: "warning", + rule: "id-mismatch", + message: `id '${row.id}' does not match generator output '${expected}'`, + path: `ui_surfaces[${idx}].id`, + }); + } + }); + + // Duplicate-id checks within a single section. (Cross-section duplicates + // are fine since IDs include a section tag.) Within-survey duplicates + // mean the scanner emitted two rows with the same content, which the + // recorder should have merged. + for (const section of [ + "values", + "tokens", + "components", + "ui_surfaces", + ] as const) { + const seen = new Map(); + survey[section].forEach((row, idx) => { + const prev = seen.get(row.id); + if (prev !== undefined) { + issues.push({ + severity: "error", + rule: "duplicate-id", + message: `duplicate id '${row.id}' in ${section} (also at ${section}[${prev}])`, + path: `${section}[${idx}].id`, + }); + } else { + seen.set(row.id, idx); + } + }); + } + + return finalize(issues); +} + +function checkUiSurfaceCoverage( + survey: Survey, + issues: SurveyLintIssue[], +): void { + if (survey.ui_surfaces.length > 0) return; + issues.push({ + severity: "warning", + rule: "ui-surfaces-empty", + message: + "survey.ui_surfaces is empty; this is only acceptable when map.md declares surface_sources.render_strategy: unknown and the scan notes the coverage gap.", + path: "ui_surfaces", + }); +} + +function checkSourceGraph(survey: Survey, issues: SurveyLintIssue[]): void { + const hasRoles = survey.sources.some((source) => source.role); + if (!hasRoles) return; + + const primaryCount = survey.sources.filter( + (source) => source.role === "primary", + ).length; + if (primaryCount !== 1) { + issues.push({ + severity: "warning", + rule: "source-graph-primary-count", + message: + "survey.sources should include exactly one primary source when source roles are used.", + path: "sources", + }); + } +} + +function checkResolution( + resolution: Survey["values"][number]["resolution"] | undefined, + path: string, + issues: SurveyLintIssue[], +): void { + if (!resolution) return; + if ( + resolution.status === "resolved" && + !resolution.source_id && + !resolution.target + ) { + issues.push({ + severity: "warning", + rule: "resolution-source-missing", + message: + "resolved rows should name a resolver via `source_id` or `target`.", + path, + }); + } + if ( + resolution.status !== "resolved" && + !resolution.symbol && + !resolution.message + ) { + issues.push({ + severity: "info", + rule: "resolution-unresolved-context-missing", + message: + "unresolved rows should include `symbol` or `message` so the fingerprint can surface coverage gaps.", + path, + }); + } +} + +function zodIssues(issues: ZodIssue[]): SurveyLintIssue[] { + return issues.map((issue) => ({ + severity: "error" as const, + rule: `schema/${issue.code}`, + message: issue.message, + path: formatZodPath(issue.path), + })); +} + +function formatZodPath(path: ZodIssue["path"]): string | undefined { + if (path.length === 0) return undefined; + return path.reduce((formatted, segment) => { + if (typeof segment === "number") return `${formatted}[${segment}]`; + const key = String(segment); + return formatted ? `${formatted}.${key}` : key; + }, ""); +} + +function finalize(issues: SurveyLintIssue[]): SurveyLintReport { + let errors = 0; + let warnings = 0; + let info = 0; + for (const issue of issues) { + if (issue.severity === "error") errors++; + else if (issue.severity === "warning") warnings++; + else info++; + } + return { issues, errors, warnings, info }; +} diff --git a/packages/ghost/src/ghost-core/survey/merge.ts b/packages/ghost/src/ghost-core/survey/merge.ts new file mode 100644 index 0000000..0689098 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/merge.ts @@ -0,0 +1,76 @@ +import type { + ComponentRow, + RowBase, + Survey, + SurveySource, + TokenRow, + UiSurfaceRow, + ValueRow, +} from "./types.js"; + +/** + * Merge N surveys into one. Concat semantics with id-based dedup. + * + * Two scans of the same `(target, commit)` produce rows with identical + * IDs by construction — those rows are deduplicated to one (first wins). + * Two scans of different commits or different targets produce distinct + * IDs, so all observations survive. + * + * `sources` becomes the union of input sources, also deduped on + * `(id, role, target, commit)` so source-graph roles survive merges. + * + * Idempotent: `mergeSurveys(b)` == `b`. Commutative on the rowset (order + * within sections may differ from input order but content is identical). + */ +export function mergeSurveys(...surveys: Survey[]): Survey { + if (surveys.length === 0) { + throw new Error("mergeSurveys requires at least one input survey"); + } + return { + schema: "ghost.survey/v2", + sources: dedupSources(surveys.flatMap((b) => b.sources)), + values: dedupRows(surveys.flatMap((b) => b.values)), + tokens: dedupRows(surveys.flatMap((b) => b.tokens)), + components: dedupRows(surveys.flatMap((b) => b.components)), + ui_surfaces: dedupRows(surveys.flatMap((b) => b.ui_surfaces)), + }; +} + +function dedupRows(rows: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const row of rows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + out.push(row); + } + return out; +} + +function dedupSources(sources: SurveySource[]): SurveySource[] { + const seen = new Set(); + const out: SurveySource[] = []; + for (const source of sources) { + const key = [ + source.id ?? "", + source.role ?? "", + source.target, + source.commit ?? "", + ].join("\x00"); + if (seen.has(key)) continue; + seen.add(key); + out.push(source); + } + return out; +} + +// Type re-exports kept narrow so consumers don't have to import from `types.js` +// just to use `mergeSurveys` results. +export type { + ComponentRow, + Survey, + SurveySource, + TokenRow, + UiSurfaceRow, + ValueRow, +}; diff --git a/packages/ghost/src/ghost-core/survey/schema.ts b/packages/ghost/src/ghost-core/survey/schema.ts new file mode 100644 index 0000000..9c0426f --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/schema.ts @@ -0,0 +1,250 @@ +import { z } from "zod"; + +/** + * Zod schemas for `ghost.survey/v2`. + * + * The `kind` field on value rows is intentionally open (a plain string). + * The validator does not reject unknown kinds — instead the lint step + * surfaces them as warnings so downstream tooling can canonicalize without + * blocking new scanners that emit experimental kinds. + */ + +const SurveySourceSchema = z.object({ + id: z.string().min(1).optional(), + role: z.enum(["primary", "resolver"]).optional(), + target: z.string().min(1), + commit: z.string().optional(), + scanned_at: z.string().min(1), + scanner_version: z.string().optional(), + resolves: z.array(z.string().min(1)).optional(), +}); + +const ResolutionSchema = z.object({ + status: z.enum(["resolved", "unresolved-external", "unresolved-local"]), + source_id: z.string().min(1).optional(), + target: z.string().min(1).optional(), + symbol: z.string().min(1).optional(), + chain: z.array(z.string().min(1)).optional(), + message: z.string().min(1).optional(), +}); + +const ScalarUnitSchema = z.object({ + scalar: z.number(), + unit: z.string().min(1), +}); + +const ColorSpecSchema = z.object({ + space: z.enum(["srgb", "p3", "rec2020", "lab", "oklch", "unknown"]), + hex: z.string().optional(), + rgb: z + .object({ + r: z.number(), + g: z.number(), + b: z.number(), + a: z.number().optional(), + }) + .optional(), + hsl: z + .object({ + h: z.number(), + s: z.number(), + l: z.number(), + a: z.number().optional(), + }) + .optional(), +}); + +const TypographySpecSchema = z.object({ + family: z.string().optional(), + weight: z.union([z.string(), z.number()]).optional(), + size: ScalarUnitSchema.optional(), + line_height: z.union([ScalarUnitSchema, z.string()]).optional(), + letter_spacing: ScalarUnitSchema.optional(), +}); + +const ShadowSpecSchema = z.object({ + offset_x: ScalarUnitSchema.optional(), + offset_y: ScalarUnitSchema.optional(), + blur: ScalarUnitSchema.optional(), + spread: ScalarUnitSchema.optional(), + color: z.string().optional(), + inset: z.boolean().optional(), +}); + +const MotionSpecSchema = z.object({ + duration_ms: z.number().optional(), + easing: z.string().optional(), +}); + +const LayoutPrimitiveSpecSchema = z.object({ + kind: z.string().min(1), + scalar: z.number().optional(), + unit: z.string().optional(), + raw: z.string().optional(), +}); + +const BreakpointSpecSchema = ScalarUnitSchema.extend({ + label: z.string().optional(), +}); + +/** + * Spec is open: any of the recommended specs, OR a generic record for + * unknown kinds. We don't bind kind→spec strictly here — the lint step + * surfaces mismatches as warnings so experimental scanners can iterate + * without schema changes. + */ +const ValueSpecSchema = z.union([ + ColorSpecSchema, + TypographySpecSchema, + ShadowSpecSchema, + MotionSpecSchema, + LayoutPrimitiveSpecSchema, + BreakpointSpecSchema, + ScalarUnitSchema, + z.record(z.string(), z.unknown()), +]); + +const RowBaseSchema = z.object({ + id: z.string().min(1), + source: SurveySourceSchema, +}); + +const ValueRowSchema = RowBaseSchema.extend({ + kind: z.string().min(1), + value: z.string().min(1), + raw: z.string(), + spec: ValueSpecSchema.optional(), + occurrences: z.number().int().nonnegative(), + files_count: z.number().int().nonnegative(), + usage: z.record(z.string(), z.number().int().nonnegative()).optional(), + role_hypothesis: z.string().optional(), + resolution: ResolutionSchema.optional(), +}); + +const TokenRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + alias_chain: z.array(z.string()), + resolved_value: z.string().min(1), + by_theme: z.record(z.string(), z.string()).optional(), + occurrences: z.number().int().nonnegative(), + resolution: ResolutionSchema.optional(), +}); + +const ComponentRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + discovered_via: z.string().min(1), + variants: z.array(z.string()).optional(), + sizes: z.array(z.string()).optional(), +}); + +const UiSurfaceKindSchema = z.enum([ + "route", + "story", + "screen", + "fixture", + "doc-example", + "screenshot", + "source", +]); + +const UiSurfaceRenderabilitySchema = z.enum([ + "rendered", + "screenshot", + "source-only", + "unknown", +]); + +const UiSurfaceClassificationSchema = z + .object({ + intent: z.string().min(1).optional(), + surface_type: z.string().min(1).optional(), + density: z + .enum(["compressed", "standard", "breathing", "unknown"]) + .optional(), + layout_shape: z + .enum([ + "article", + "tracker", + "comparison", + "card", + "control-surface", + "flow", + "navigation", + "unknown", + ]) + .optional(), + confidence: z.number().min(0).max(1).optional(), + }) + .strict(); + +const UiSurfaceSignalsSchema = z + .object({ + dominant_components: z.array(z.string().min(1)).optional(), + layout_patterns: z.array(z.string().min(1)).optional(), + breakpoint_behavior: z.array(z.string().min(1)).optional(), + value_refs: z.array(z.string().min(1)).optional(), + notes: z.array(z.string().min(1)).optional(), + }) + .strict(); + +const UiSurfaceCompositionSchema = z + .object({ + anatomy: z.array(z.string().min(1)).optional(), + primary_region: z.string().min(1).optional(), + action_placement: z.array(z.string().min(1)).optional(), + navigation_context: z.string().min(1).optional(), + responsive_behavior: z.array(z.string().min(1)).optional(), + confidence: z.number().min(0).max(1).optional(), + }) + .strict(); + +const UiSurfaceRowSchema = RowBaseSchema.extend({ + name: z.string().min(1), + kind: UiSurfaceKindSchema, + locator: z.string().min(1), + renderability: UiSurfaceRenderabilitySchema, + files: z.array(z.string().min(1)), + classification: UiSurfaceClassificationSchema.optional(), + composition: UiSurfaceCompositionSchema.optional(), + signals: UiSurfaceSignalsSchema, +}); + +export const SurveySchema = z.object({ + schema: z.literal("ghost.survey/v2"), + sources: z.array(SurveySourceSchema).min(1), + values: z.array(ValueRowSchema), + tokens: z.array(TokenRowSchema), + components: z.array(ComponentRowSchema), + ui_surfaces: z.array(UiSurfaceRowSchema), +}); + +export { + ColorSpecSchema, + ComponentRowSchema, + ResolutionSchema, + SurveySourceSchema, + TokenRowSchema, + UiSurfaceClassificationSchema, + UiSurfaceCompositionSchema, + UiSurfaceKindSchema, + UiSurfaceRenderabilitySchema, + UiSurfaceRowSchema, + UiSurfaceSignalsSchema, + ValueRowSchema, + ValueSpecSchema, +}; + +/** + * Recommended value kinds. Used only by the lint step to surface unknown + * kinds as warnings — the schema accepts any string for `kind`. + */ +export const RECOMMENDED_VALUE_KINDS: readonly string[] = [ + "color", + "spacing", + "typography", + "radius", + "shadow", + "breakpoint", + "motion", + "layout-primitive", +]; diff --git a/packages/ghost/src/ghost-core/survey/summary-budget.ts b/packages/ghost/src/ghost-core/survey/summary-budget.ts new file mode 100644 index 0000000..84a1216 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/summary-budget.ts @@ -0,0 +1,55 @@ +import type { BudgetLimits, SurveySummaryBudget } from "./summary-types.js"; + +export const BUDGET_LIMITS: Record = { + compact: { + valuesPerKind: 6, + tokens: 20, + components: 20, + surfaces: 8, + arbitraryValues: 6, + unresolvedValues: 6, + tokenFamilies: 8, + tokenAliasDepths: 6, + themedTokens: 10, + unresolvedTokens: 6, + componentSources: 8, + surfaceGroups: 8, + groupExamples: 2, + signalItems: 3, + resolutionChain: 4, + }, + standard: { + valuesPerKind: 12, + tokens: 40, + components: 40, + surfaces: 12, + arbitraryValues: 12, + unresolvedValues: 12, + tokenFamilies: 12, + tokenAliasDepths: 8, + themedTokens: 20, + unresolvedTokens: 12, + componentSources: 12, + surfaceGroups: 12, + groupExamples: 3, + signalItems: 5, + resolutionChain: 6, + }, + full: { + valuesPerKind: 24, + tokens: 80, + components: 80, + surfaces: 24, + arbitraryValues: 24, + unresolvedValues: 24, + tokenFamilies: 20, + tokenAliasDepths: 12, + themedTokens: 40, + unresolvedTokens: 24, + componentSources: 20, + surfaceGroups: 20, + groupExamples: 4, + signalItems: 8, + resolutionChain: 10, + }, +}; diff --git a/packages/ghost/src/ghost-core/survey/summary-format.ts b/packages/ghost/src/ghost-core/survey/summary-format.ts new file mode 100644 index 0000000..a67665f --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/summary-format.ts @@ -0,0 +1,259 @@ +import type { + ComponentEvidenceSummary, + CountSummary, + ResolutionSummary, + SurveySummary, + TokenEvidenceSummary, + UiSurfaceEvidenceSummary, + ValueEvidenceSummary, +} from "./summary-types.js"; + +export function formatSurveySummaryMarkdown(summary: SurveySummary): string { + const lines: string[] = []; + + lines.push("# Survey Summary"); + lines.push(""); + lines.push(`Budget: \`${summary.budget}\``); + lines.push( + `Rows: ${summary.counts.total_rows} total (${summary.counts.values} values, ${summary.counts.tokens} tokens, ${summary.counts.components} components, ${summary.counts.ui_surfaces} UI surfaces)`, + ); + lines.push(""); + + appendSources(lines, summary); + appendValues(lines, summary); + appendTokens(lines, summary); + appendComponents(lines, summary); + appendSurfaces(lines, summary); + + return `${lines.join("\n").trimEnd()}\n`; +} + +function appendSources(lines: string[], summary: SurveySummary): void { + lines.push("## Sources"); + for (const source of summary.sources) { + const labels = [ + source.id ? `id=${source.id}` : undefined, + source.role ? `role=${source.role}` : undefined, + source.commit ? `commit=${source.commit}` : undefined, + source.resolves?.length + ? `resolves=${source.resolves.join(",")}` + : undefined, + ].filter(Boolean); + lines.push( + `- ${source.target}${labels.length ? ` (${labels.join("; ")})` : ""}`, + ); + } + lines.push(""); +} + +function appendValues(lines: string[], summary: SurveySummary): void { + lines.push("## Values"); + lines.push(`Total value occurrences: ${summary.values.total_occurrences}`); + for (const kind of summary.values.kinds) { + lines.push(""); + lines.push( + `### ${kind.kind} (${kind.rows} rows, ${kind.occurrences} occurrences, ${kind.files_count} file hits)`, + ); + appendValueRows(lines, kind.top); + if (kind.omitted > 0) lines.push(`- ... ${kind.omitted} more row(s)`); + } + if (summary.values.arbitrary_or_raw.length > 0) { + lines.push(""); + lines.push("### Arbitrary Or Raw Exceptions"); + appendValueRows(lines, summary.values.arbitrary_or_raw); + } + if (summary.values.unresolved.length > 0) { + lines.push(""); + lines.push("### Unresolved Values"); + appendValueRows(lines, summary.values.unresolved); + } + lines.push(""); +} + +function appendTokens(lines: string[], summary: SurveySummary): void { + lines.push("## Tokens"); + lines.push(`Total token occurrences: ${summary.tokens.total_occurrences}`); + if (summary.tokens.families.length > 0) { + lines.push(""); + lines.push("Families:"); + appendCountRows(lines, summary.tokens.families); + } + if (summary.tokens.alias_depths.length > 0) { + lines.push(""); + lines.push("Alias depths:"); + appendCountRows(lines, summary.tokens.alias_depths); + } + if (summary.tokens.top.length > 0) { + lines.push(""); + lines.push("Top tokens:"); + appendTokenRows(lines, summary.tokens.top); + } + if (summary.tokens.semantic_or_themed.length > 0) { + lines.push(""); + lines.push("Semantic or themed tokens:"); + appendTokenRows(lines, summary.tokens.semantic_or_themed); + } + if (summary.tokens.unresolved.length > 0) { + lines.push(""); + lines.push("Unresolved tokens:"); + appendTokenRows(lines, summary.tokens.unresolved); + } + lines.push(""); +} + +function appendComponents(lines: string[], summary: SurveySummary): void { + lines.push("## Components"); + lines.push( + `${summary.components.top.length + summary.components.omitted} component row(s); ${summary.components.with_variants} with variants, ${summary.components.with_sizes} with sizes.`, + ); + if (summary.components.discovered_via.length > 0) { + lines.push(""); + lines.push("Discovered via:"); + appendCountRows(lines, summary.components.discovered_via); + } + if (summary.components.top.length > 0) { + lines.push(""); + appendComponentRows(lines, summary.components.top); + if (summary.components.omitted > 0) { + lines.push(`- ... ${summary.components.omitted} more component row(s)`); + } + } + lines.push(""); +} + +function appendSurfaces(lines: string[], summary: SurveySummary): void { + lines.push("## UI Surfaces"); + if (summary.ui_surfaces.groups.length > 0) { + lines.push(""); + lines.push("Groups:"); + for (const group of summary.ui_surfaces.groups) { + lines.push(`- ${group.key}: ${group.count}`); + for (const example of group.examples) { + lines.push(` - ${formatSurfaceRow(example)}`); + } + } + } + if (summary.ui_surfaces.surfaces.length > 0) { + lines.push(""); + lines.push("Representative surfaces:"); + appendSurfaceRows(lines, summary.ui_surfaces.surfaces); + if (summary.ui_surfaces.omitted > 0) { + lines.push(`- ... ${summary.ui_surfaces.omitted} more surface row(s)`); + } + } +} + +function appendValueRows(lines: string[], rows: ValueEvidenceSummary[]): void { + for (const row of rows) { + const extras = [ + row.raw !== row.value ? `raw \`${row.raw}\`` : undefined, + row.role_hypothesis ? `role ${row.role_hypothesis}` : undefined, + row.usage ? `usage ${formatUsage(row.usage)}` : undefined, + row.resolution + ? `resolution ${formatResolution(row.resolution)}` + : undefined, + row.source ? `source ${row.source}` : undefined, + ].filter(Boolean); + lines.push( + `- \`${row.id}\` ${row.kind} \`${row.value}\` (${row.occurrences}x, ${row.files_count} files${extras.length ? `; ${extras.join("; ")}` : ""})`, + ); + } +} + +function appendTokenRows(lines: string[], rows: TokenEvidenceSummary[]): void { + for (const row of rows) { + const extras = [ + `depth ${row.alias_depth}`, + row.alias_chain?.length + ? `chain ${row.alias_chain.join(" -> ")}` + : undefined, + row.by_theme + ? `themes ${Object.keys(row.by_theme).sort(compareStrings).join(",")}` + : undefined, + row.resolution + ? `resolution ${formatResolution(row.resolution)}` + : undefined, + row.source ? `source ${row.source}` : undefined, + ].filter(Boolean); + lines.push( + `- \`${row.id}\` \`${row.name}\` -> \`${row.resolved_value}\` (${row.occurrences}x; ${extras.join("; ")})`, + ); + } +} + +function appendComponentRows( + lines: string[], + rows: ComponentEvidenceSummary[], +): void { + for (const row of rows) { + const extras = [ + row.variants?.length ? `variants ${row.variants.join(",")}` : undefined, + row.sizes?.length ? `sizes ${row.sizes.join(",")}` : undefined, + row.source ? `source ${row.source}` : undefined, + ].filter(Boolean); + lines.push( + `- \`${row.id}\` ${row.name} (${row.discovered_via}${extras.length ? `; ${extras.join("; ")}` : ""})`, + ); + } +} + +function appendSurfaceRows( + lines: string[], + rows: UiSurfaceEvidenceSummary[], +): void { + for (const row of rows) lines.push(`- ${formatSurfaceRow(row)}`); +} + +function appendCountRows(lines: string[], rows: CountSummary[]): void { + for (const row of rows) { + const occurrences = + row.occurrences !== undefined && row.occurrences !== row.count + ? `, ${row.occurrences} occurrences` + : ""; + lines.push(`- ${row.name}: ${row.count}${occurrences}`); + } +} + +function formatSurfaceRow(row: UiSurfaceEvidenceSummary): string { + const c = row.classification; + const tags = [c?.layout_shape, c?.density, c?.surface_type, c?.intent].filter( + Boolean, + ); + const signals = [ + row.signals.layout_patterns?.length + ? `patterns ${row.signals.layout_patterns.join(",")}` + : undefined, + row.signals.dominant_components?.length + ? `components ${row.signals.dominant_components.join(",")}` + : undefined, + row.signals.value_refs?.length + ? `value_refs ${row.signals.value_refs.join(",")}` + : undefined, + row.signals.notes?.length + ? `notes ${row.signals.notes.join(" | ")}` + : undefined, + row.source ? `source ${row.source}` : undefined, + ].filter(Boolean); + return `\`${row.id}\` ${row.name} (${row.kind} ${row.locator}; ${row.renderability}; ${row.files_count} files${tags.length ? `; ${tags.join(", ")}` : ""}${signals.length ? `; ${signals.join("; ")}` : ""})`; +} + +function formatUsage(usage: Record): string { + return Object.entries(usage) + .map(([key, value]) => `${key}:${value}`) + .join(","); +} + +function formatResolution(resolution: ResolutionSummary): string { + const parts = [ + resolution.status, + resolution.source_id, + resolution.symbol, + resolution.chain?.length ? resolution.chain.join(" -> ") : undefined, + resolution.message, + ].filter(Boolean); + return parts.join("/"); +} + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b); +} diff --git a/packages/ghost/src/ghost-core/survey/summary-types.ts b/packages/ghost/src/ghost-core/survey/summary-types.ts new file mode 100644 index 0000000..338dd4e --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/summary-types.ts @@ -0,0 +1,169 @@ +import type { + ComponentRow, + Resolution, + SurveySource, + UiSurfaceClassification, + UiSurfaceRow, + UiSurfaceSignals, +} from "./types.js"; + +export type SurveySummaryBudget = "compact" | "standard" | "full"; + +export interface SurveySummaryOptions { + budget?: SurveySummaryBudget; +} + +export interface SurveySummary { + schema: "ghost.survey.summary/v1"; + source_schema: "ghost.survey/v2"; + budget: SurveySummaryBudget; + counts: SurveySummaryCounts; + sources: SurveySourceSummary[]; + values: SurveyValuesSummary; + tokens: SurveyTokensSummary; + components: SurveyComponentsSummary; + ui_surfaces: SurveyUiSurfacesSummary; +} + +export interface SurveySummaryCounts { + sources: number; + values: number; + tokens: number; + components: number; + ui_surfaces: number; + total_rows: number; +} + +export interface SurveySourceSummary { + id?: string; + role?: SurveySource["role"]; + target: string; + commit?: string; + scanned_at: string; + scanner_version?: string; + resolves?: string[]; +} + +export interface SurveyValuesSummary { + total_occurrences: number; + kinds: ValueKindSummary[]; + arbitrary_or_raw: ValueEvidenceSummary[]; + unresolved: ValueEvidenceSummary[]; +} + +export interface ValueKindSummary { + kind: string; + rows: number; + occurrences: number; + files_count: number; + top: ValueEvidenceSummary[]; + omitted: number; +} + +export interface ValueEvidenceSummary { + id: string; + kind: string; + value: string; + raw: string; + occurrences: number; + files_count: number; + usage?: Record; + role_hypothesis?: string; + source?: string; + resolution?: ResolutionSummary; +} + +export interface ResolutionSummary { + status: Resolution["status"]; + source_id?: string; + target?: string; + symbol?: string; + chain?: string[]; + message?: string; +} + +export interface SurveyTokensSummary { + total_occurrences: number; + families: CountSummary[]; + alias_depths: CountSummary[]; + top: TokenEvidenceSummary[]; + semantic_or_themed: TokenEvidenceSummary[]; + unresolved: TokenEvidenceSummary[]; +} + +export interface CountSummary { + name: string; + count: number; + occurrences?: number; +} + +export interface TokenEvidenceSummary { + id: string; + name: string; + resolved_value: string; + occurrences: number; + alias_depth: number; + alias_chain?: string[]; + by_theme?: Record; + source?: string; + resolution?: ResolutionSummary; +} + +export interface SurveyComponentsSummary { + discovered_via: CountSummary[]; + with_variants: number; + with_sizes: number; + top: ComponentEvidenceSummary[]; + omitted: number; +} + +export interface ComponentEvidenceSummary { + id: string; + name: string; + discovered_via: ComponentRow["discovered_via"]; + variants?: string[]; + sizes?: string[]; + source?: string; +} + +export interface SurveyUiSurfacesSummary { + groups: UiSurfaceGroupSummary[]; + surfaces: UiSurfaceEvidenceSummary[]; + omitted: number; +} + +export interface UiSurfaceGroupSummary { + key: string; + count: number; + examples: UiSurfaceEvidenceSummary[]; +} + +export interface UiSurfaceEvidenceSummary { + id: string; + name: string; + kind: UiSurfaceRow["kind"]; + locator: string; + renderability: UiSurfaceRow["renderability"]; + files_count: number; + classification?: UiSurfaceClassification; + signals: UiSurfaceSignals; + source?: string; +} + +export interface BudgetLimits { + valuesPerKind: number; + tokens: number; + components: number; + surfaces: number; + arbitraryValues: number; + unresolvedValues: number; + tokenFamilies: number; + tokenAliasDepths: number; + themedTokens: number; + unresolvedTokens: number; + componentSources: number; + surfaceGroups: number; + groupExamples: number; + signalItems: number; + resolutionChain: number; +} diff --git a/packages/ghost/src/ghost-core/survey/summary.ts b/packages/ghost/src/ghost-core/survey/summary.ts new file mode 100644 index 0000000..b6ad011 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/summary.ts @@ -0,0 +1,472 @@ +import { RECOMMENDED_VALUE_KINDS } from "./schema.js"; +import { BUDGET_LIMITS } from "./summary-budget.js"; + +export { formatSurveySummaryMarkdown } from "./summary-format.js"; + +import type { + BudgetLimits, + ComponentEvidenceSummary, + CountSummary, + ResolutionSummary, + SurveyComponentsSummary, + SurveySourceSummary, + SurveySummary, + SurveySummaryOptions, + SurveyTokensSummary, + SurveyUiSurfacesSummary, + SurveyValuesSummary, + TokenEvidenceSummary, + UiSurfaceEvidenceSummary, + UiSurfaceGroupSummary, + ValueEvidenceSummary, + ValueKindSummary, +} from "./summary-types.js"; + +export type { + ComponentEvidenceSummary, + CountSummary, + ResolutionSummary, + SurveyComponentsSummary, + SurveySourceSummary, + SurveySummary, + SurveySummaryBudget, + SurveySummaryCounts, + SurveySummaryOptions, + SurveyTokensSummary, + SurveyUiSurfacesSummary, + SurveyValuesSummary, + TokenEvidenceSummary, + UiSurfaceEvidenceSummary, + UiSurfaceGroupSummary, + ValueEvidenceSummary, + ValueKindSummary, +} from "./summary-types.js"; + +import type { + ComponentRow, + Resolution, + Survey, + SurveySource, + TokenRow, + UiSurfaceRow, + UiSurfaceSignals, + ValueRow, +} from "./types.js"; + +const SEMANTIC_TOKEN_PATTERN = + /(?:^|[-_:./])(?:background|foreground|surface|primary|secondary|accent|muted|border|input|ring|focus|success|warning|error|danger|destructive|info|brand|text)(?:$|[-_:./])/i; + +export function summarizeSurvey( + survey: Survey, + options: SurveySummaryOptions = {}, +): SurveySummary { + const budget = options.budget ?? "standard"; + const limits = BUDGET_LIMITS[budget]; + + return { + schema: "ghost.survey.summary/v1", + source_schema: survey.schema, + budget, + counts: { + sources: survey.sources.length, + values: survey.values.length, + tokens: survey.tokens.length, + components: survey.components.length, + ui_surfaces: survey.ui_surfaces.length, + total_rows: + survey.values.length + + survey.tokens.length + + survey.components.length + + survey.ui_surfaces.length, + }, + sources: survey.sources.map(summarizeSource), + values: summarizeValues(survey.values, limits), + tokens: summarizeTokens(survey.tokens, limits), + components: summarizeComponents(survey.components, limits), + ui_surfaces: summarizeUiSurfaces(survey.ui_surfaces, limits), + }; +} + +function summarizeValues( + rows: ValueRow[], + limits: BudgetLimits, +): SurveyValuesSummary { + const sortedRows = sortValueRows(rows); + return { + total_occurrences: sum(rows.map((row) => row.occurrences)), + kinds: orderedKinds(rows).map((kind) => + summarizeValueKind(kind, rows, limits), + ), + arbitrary_or_raw: sortedRows + .filter(isArbitraryOrRawValue) + .slice(0, limits.arbitraryValues) + .map((row) => summarizeValueRow(row, limits)), + unresolved: sortedRows + .filter((row) => row.resolution?.status?.startsWith("unresolved")) + .slice(0, limits.unresolvedValues) + .map((row) => summarizeValueRow(row, limits)), + }; +} + +function summarizeValueKind( + kind: string, + rows: ValueRow[], + limits: BudgetLimits, +): ValueKindSummary { + const kindRows = sortValueRows(rows.filter((row) => row.kind === kind)); + return { + kind, + rows: kindRows.length, + occurrences: sum(kindRows.map((row) => row.occurrences)), + files_count: sum(kindRows.map((row) => row.files_count)), + top: kindRows + .slice(0, limits.valuesPerKind) + .map((row) => summarizeValueRow(row, limits)), + omitted: Math.max(0, kindRows.length - limits.valuesPerKind), + }; +} + +function summarizeTokens( + rows: TokenRow[], + limits: BudgetLimits, +): SurveyTokensSummary { + const sortedRows = sortTokenRows(rows); + return { + total_occurrences: sum(rows.map((row) => row.occurrences)), + families: countBy( + rows, + (row) => tokenFamily(row.name), + (row) => row.occurrences, + ).slice(0, limits.tokenFamilies), + alias_depths: countBy( + rows, + (row) => String(row.alias_chain.length), + (row) => row.occurrences, + ).slice(0, limits.tokenAliasDepths), + top: sortedRows + .slice(0, limits.tokens) + .map((row) => summarizeTokenRow(row, limits)), + semantic_or_themed: sortedRows + .filter((row) => row.by_theme || SEMANTIC_TOKEN_PATTERN.test(row.name)) + .slice(0, limits.themedTokens) + .map((row) => summarizeTokenRow(row, limits)), + unresolved: sortedRows + .filter((row) => row.resolution?.status?.startsWith("unresolved")) + .slice(0, limits.unresolvedTokens) + .map((row) => summarizeTokenRow(row, limits)), + }; +} + +function summarizeComponents( + rows: ComponentRow[], + limits: BudgetLimits, +): SurveyComponentsSummary { + const sortedRows = sortComponentRows(rows); + return { + discovered_via: countBy(rows, (row) => row.discovered_via).slice( + 0, + limits.componentSources, + ), + with_variants: rows.filter((row) => row.variants?.length).length, + with_sizes: rows.filter((row) => row.sizes?.length).length, + top: sortedRows + .slice(0, limits.components) + .map((row) => summarizeComponentRow(row, limits)), + omitted: Math.max(0, rows.length - limits.components), + }; +} + +function summarizeUiSurfaces( + rows: UiSurfaceRow[], + limits: BudgetLimits, +): SurveyUiSurfacesSummary { + const sortedRows = sortUiSurfaceRows(rows); + return { + groups: countBy(rows, surfaceGroupKey) + .slice(0, limits.surfaceGroups) + .map( + (group): UiSurfaceGroupSummary => ({ + key: group.name, + count: group.count, + examples: sortedRows + .filter((row) => surfaceGroupKey(row) === group.name) + .slice(0, limits.groupExamples) + .map((row) => summarizeUiSurfaceRow(row, limits)), + }), + ), + surfaces: sortedRows + .slice(0, limits.surfaces) + .map((row) => summarizeUiSurfaceRow(row, limits)), + omitted: Math.max(0, rows.length - limits.surfaces), + }; +} + +function summarizeSource(source: SurveySource): SurveySourceSummary { + return { + id: source.id, + role: source.role, + target: source.target, + commit: source.commit, + scanned_at: source.scanned_at, + scanner_version: source.scanner_version, + resolves: source.resolves, + }; +} + +function summarizeValueRow( + row: ValueRow, + limits: BudgetLimits, +): ValueEvidenceSummary { + return pruneUndefined({ + id: row.id, + kind: row.kind, + value: row.value, + raw: row.raw, + occurrences: row.occurrences, + files_count: row.files_count, + usage: row.usage ? topUsage(row.usage, limits.signalItems) : undefined, + role_hypothesis: row.role_hypothesis, + source: sourceLabel(row.source), + resolution: row.resolution + ? summarizeResolution(row.resolution, limits) + : undefined, + }); +} + +function summarizeTokenRow( + row: TokenRow, + limits: BudgetLimits, +): TokenEvidenceSummary { + return pruneUndefined({ + id: row.id, + name: row.name, + resolved_value: row.resolved_value, + occurrences: row.occurrences, + alias_depth: row.alias_chain.length, + alias_chain: + row.alias_chain.length > 0 + ? row.alias_chain.slice(0, limits.resolutionChain) + : undefined, + by_theme: row.by_theme, + source: sourceLabel(row.source), + resolution: row.resolution + ? summarizeResolution(row.resolution, limits) + : undefined, + }); +} + +function summarizeComponentRow( + row: ComponentRow, + limits: BudgetLimits, +): ComponentEvidenceSummary { + return pruneUndefined({ + id: row.id, + name: row.name, + discovered_via: row.discovered_via, + variants: row.variants?.slice(0, limits.signalItems), + sizes: row.sizes?.slice(0, limits.signalItems), + source: sourceLabel(row.source), + }); +} + +function summarizeUiSurfaceRow( + row: UiSurfaceRow, + limits: BudgetLimits, +): UiSurfaceEvidenceSummary { + return pruneUndefined({ + id: row.id, + name: row.name, + kind: row.kind, + locator: row.locator, + renderability: row.renderability, + files_count: row.files.length, + classification: row.classification, + signals: summarizeSignals(row.signals, limits), + source: sourceLabel(row.source), + }); +} + +function summarizeSignals( + signals: UiSurfaceSignals, + limits: BudgetLimits, +): UiSurfaceSignals { + return pruneUndefined({ + dominant_components: signals.dominant_components?.slice( + 0, + limits.signalItems, + ), + layout_patterns: signals.layout_patterns?.slice(0, limits.signalItems), + breakpoint_behavior: signals.breakpoint_behavior?.slice( + 0, + limits.signalItems, + ), + value_refs: signals.value_refs?.slice(0, limits.signalItems), + notes: signals.notes?.slice(0, limits.signalItems), + }); +} + +function summarizeResolution( + resolution: Resolution, + limits: BudgetLimits, +): ResolutionSummary { + return pruneUndefined({ + status: resolution.status, + source_id: resolution.source_id, + target: resolution.target, + symbol: resolution.symbol, + chain: resolution.chain?.slice(0, limits.resolutionChain), + message: resolution.message, + }); +} + +function orderedKinds(rows: ValueRow[]): string[] { + const present = new Set(rows.map((row) => row.kind)); + const recommended = RECOMMENDED_VALUE_KINDS.filter((kind) => + present.has(kind), + ); + const extras = [...present] + .filter((kind) => !RECOMMENDED_VALUE_KINDS.includes(kind)) + .sort(compareStrings); + return [...recommended, ...extras]; +} + +function sortValueRows(rows: ValueRow[]): ValueRow[] { + return [...rows].sort( + (a, b) => + compareNumbers(b.occurrences, a.occurrences) || + compareNumbers(b.files_count, a.files_count) || + compareStrings(a.value, b.value) || + compareStrings(a.raw, b.raw) || + compareStrings(a.id, b.id), + ); +} + +function sortTokenRows(rows: TokenRow[]): TokenRow[] { + return [...rows].sort( + (a, b) => + compareNumbers(b.occurrences, a.occurrences) || + compareNumbers(b.alias_chain.length, a.alias_chain.length) || + compareStrings(a.name, b.name) || + compareStrings(a.id, b.id), + ); +} + +function sortComponentRows(rows: ComponentRow[]): ComponentRow[] { + return [...rows].sort( + (a, b) => + compareStrings(a.discovered_via, b.discovered_via) || + compareStrings(a.name, b.name) || + compareStrings(a.id, b.id), + ); +} + +function sortUiSurfaceRows(rows: UiSurfaceRow[]): UiSurfaceRow[] { + return [...rows].sort( + (a, b) => + compareStrings(surfaceGroupKey(a), surfaceGroupKey(b)) || + compareStrings(a.name, b.name) || + compareStrings(a.locator, b.locator) || + compareStrings(a.id, b.id), + ); +} + +function countBy( + rows: T[], + keyFor: (row: T) => string, + occurrencesFor: (row: T) => number = () => 1, +): CountSummary[] { + const counts = new Map(); + for (const row of rows) { + const name = keyFor(row) || "unknown"; + const existing = counts.get(name) ?? { count: 0, occurrences: 0 }; + existing.count += 1; + existing.occurrences += occurrencesFor(row); + counts.set(name, existing); + } + return [...counts.entries()] + .map(([name, value]) => ({ + name, + count: value.count, + occurrences: value.occurrences, + })) + .sort( + (a, b) => + compareNumbers(b.count, a.count) || + compareNumbers(b.occurrences ?? 0, a.occurrences ?? 0) || + compareStrings(a.name, b.name), + ); +} + +function topUsage( + usage: Record, + limit: number, +): Record { + return Object.fromEntries( + Object.entries(usage) + .sort( + ([aKey, aValue], [bKey, bValue]) => + compareNumbers(bValue, aValue) || compareStrings(aKey, bKey), + ) + .slice(0, limit), + ); +} + +function tokenFamily(name: string): string { + const parts = name + .replace(/^--/, "") + .split(/[-_:./[\]\s]+/) + .filter(Boolean); + if (parts.length === 0) return "unknown"; + const first = parts[0].toLowerCase(); + if ( + ["color", "colors", "font", "text", "spacing", "space", "radius"].includes( + first, + ) && + parts[1] + ) { + return `${first}/${parts[1].toLowerCase()}`; + } + return first; +} + +function surfaceGroupKey(row: UiSurfaceRow): string { + const c = row.classification; + return [ + c?.layout_shape ?? "unknown-shape", + c?.density ?? "unknown-density", + c?.surface_type ?? c?.intent ?? row.kind, + ].join(" / "); +} + +function isArbitraryOrRawValue(row: ValueRow): boolean { + const usageKeys = Object.keys(row.usage ?? {}); + return ( + /\[[^\]]+\]/.test(row.raw) || + /\b(?:calc|clamp|min|max)\(/.test(row.raw) || + usageKeys.some((key) => /arbitrary|inline|literal/i.test(key)) || + (row.raw.startsWith("var(") && !row.resolution) + ); +} + +function sourceLabel(source: SurveySource): string | undefined { + return source.id ?? source.target; +} + +function sum(values: number[]): number { + return values.reduce((total, value) => total + value, 0); +} + +function compareNumbers(a: number, b: number): number { + return a === b ? 0 : a < b ? -1 : 1; +} + +function compareStrings(a: string, b: string): number { + return a.localeCompare(b); +} + +function pruneUndefined>(value: T): T { + for (const key of Object.keys(value)) { + if (value[key] === undefined) delete value[key]; + } + return value; +} diff --git a/packages/ghost/src/ghost-core/survey/types.ts b/packages/ghost/src/ghost-core/survey/types.ts new file mode 100644 index 0000000..18cdc63 --- /dev/null +++ b/packages/ghost/src/ghost-core/survey/types.ts @@ -0,0 +1,289 @@ +/** + * Types for `ghost.survey/v2` — the observed evidence scan artifact. + * + * A survey is the middle artifact in a scan: produced after the map + * (`map.md`) and before fingerprint synthesis (`fingerprint.md`). It + * catalogues every concrete design value and implemented UI surface the + * agent observed in a target, with structured specs and per-row + * deterministic IDs. + * + * Merge semantics are concat-with-id-dedup. Two scans of the same target at + * the same commit produce identical IDs, so re-merging is idempotent. Two + * scans of different commits (or different targets) produce different IDs, + * so cross-survey merges preserve every observation as its own row. + */ + +/** Where a scan came from. Denormalized onto every row in the survey. */ +export interface SurveySource { + /** Stable source id within the scan source graph (`cash-ios`, `arcade-ios`, …). */ + id?: string; + /** + * Role this source played in the scan. `primary` supplies usage/salience; + * `resolver` supplies concrete meaning for imported symbols. + */ + role?: "primary" | "resolver"; + /** Target string the scan was pointed at — `github:owner/repo`, `./path`, etc. */ + target: string; + /** Git commit sha at scan time, when knowable. */ + commit?: string; + /** ISO 8601 timestamp the scan started. */ + scanned_at: string; + /** Version of the scanner that produced this row. */ + scanner_version?: string; + /** Design dimensions this source can resolve (`color`, `spacing`, …). */ + resolves?: string[]; +} + +/** Fields every row carries regardless of section. */ +export interface RowBase { + /** Deterministic hash of `(source.target, source.commit, kind-tag, content fields)`. */ + id: string; + /** Source attribution. Denormalized so rows survive merges with their origin. */ + source: SurveySource; +} + +// --- Value rows ---------------------------------------------------------- + +/** + * Recommended value kinds. The survey schema treats `kind` as an open + * string — scanners may emit additional kinds (e.g. `z-index`, `opacity`, + * `cursor`, `gradient`, `iconography`) and validators warn rather than + * reject. The recommended set covers the common cross-fleet vocabulary. + */ +export type RecommendedValueKind = + | "color" + | "spacing" + | "typography" + | "radius" + | "shadow" + | "breakpoint" + | "motion" + | "layout-primitive"; + +export interface ColorSpec { + space: "srgb" | "p3" | "rec2020" | "lab" | "oklch" | "unknown"; + hex?: string; + rgb?: { r: number; g: number; b: number; a?: number }; + hsl?: { h: number; s: number; l: number; a?: number }; +} + +export interface ScalarUnit { + scalar: number; + unit: string; +} + +export interface SpacingSpec extends ScalarUnit {} +export interface RadiusSpec extends ScalarUnit {} +export interface BreakpointSpec extends ScalarUnit { + label?: string; +} + +export interface TypographySpec { + family?: string; + weight?: string | number; + size?: ScalarUnit; + line_height?: ScalarUnit | string; + letter_spacing?: ScalarUnit; +} + +export interface ShadowSpec { + offset_x?: ScalarUnit; + offset_y?: ScalarUnit; + blur?: ScalarUnit; + spread?: ScalarUnit; + color?: string; + inset?: boolean; +} + +export interface MotionSpec { + duration_ms?: number; + easing?: string; +} + +export interface LayoutPrimitiveSpec { + /** Sub-kind: `max-width`, `container-padding`, `grid-track`, `gutter`, etc. Open. */ + kind: string; + scalar?: number; + unit?: string; + raw?: string; +} + +/** Fall-through for unknown / open-enum kinds. */ +export type UnknownSpec = Record; + +export type ValueSpec = + | ColorSpec + | SpacingSpec + | TypographySpec + | RadiusSpec + | ShadowSpec + | BreakpointSpec + | MotionSpec + | LayoutPrimitiveSpec + | UnknownSpec; + +export interface Resolution { + /** Whether this row resolved to a concrete value, or why it did not. */ + status: "resolved" | "unresolved-external" | "unresolved-local"; + /** Source id from survey.sources[] / map.sources[] that performed resolution. */ + source_id?: string; + /** Resolver target, useful when the source id is unavailable. */ + target?: string; + /** Symbol in the resolver source (`ArcadeColor.background`, `--color-bg`, …). */ + symbol?: string; + /** Full symbolic chain followed during resolution. */ + chain?: string[]; + /** Human-readable note for unavailable resolver packages or partial coverage. */ + message?: string; +} + +export interface ValueRow extends RowBase { + /** One of `RecommendedValueKind` or an extension kind. Open string. */ + kind: string; + /** Canonical string form (`#f97316`, `8px`, `Inter`). */ + value: string; + /** As-it-appeared in source (`#F97316`, `bg-orange-500`, `var(--brand)`). */ + raw: string; + /** Structured spec per kind. */ + spec?: ValueSpec; + /** Total observed count of this value within this scan. */ + occurrences: number; + /** Distinct files that contained this value. */ + files_count: number; + /** Usage breakdown by context (`className`, `css_var`, `inline_style`, etc.). */ + usage?: Record; + /** Agent-assigned role guess (`brand-primary`, `surface-elevated`). */ + role_hypothesis?: string; + /** Provenance for symbolic values resolved through another source. */ + resolution?: Resolution; +} + +// --- Token rows --------------------------------------------------------- + +export interface TokenRow extends RowBase { + /** Token name as declared in source — e.g. `--color-brand-primary`. */ + name: string; + /** + * Resolution chain from this token to its terminal value. Empty array + * means the token is a leaf (defined inline as a literal). Length > 0 + * means each step indirected through another named token. + */ + alias_chain: string[]; + /** End-of-chain literal value. */ + resolved_value: string; + /** Per-theme variants when the token resolves differently across themes. */ + by_theme?: Record; + /** Total observed usage count of this token within the scan. */ + occurrences: number; + /** Provenance for symbolic tokens resolved through another source. */ + resolution?: Resolution; +} + +// --- Component rows ----------------------------------------------------- + +export interface ComponentRow extends RowBase { + name: string; + /** Where the component was discovered — `registry.json`, `heuristic`, etc. */ + discovered_via: string; + variants?: string[]; + sizes?: string[]; +} + +// --- UI surface rows ------------------------------------------------------ + +export type UiSurfaceKind = + | "route" + | "story" + | "screen" + | "fixture" + | "doc-example" + | "screenshot" + | "source"; + +export type UiSurfaceRenderability = + | "rendered" + | "screenshot" + | "source-only" + | "unknown"; + +export type UiSurfaceDensity = + | "compressed" + | "standard" + | "breathing" + | "unknown"; + +export type UiSurfaceLayoutShape = + | "article" + | "tracker" + | "comparison" + | "card" + | "control-surface" + | "flow" + | "navigation" + | "unknown"; + +export interface UiSurfaceClassification { + /** Open tag: what the surface is trying to do (`configure`, `onboard`, …). */ + intent?: string; + /** Open tag: product-specific surface type (`settings`, `checkout`, …). */ + surface_type?: string; + density?: UiSurfaceDensity; + layout_shape?: UiSurfaceLayoutShape; + /** Confidence in the optional classifier tags, not in the observed facts. */ + confidence?: number; +} + +export interface UiSurfaceSignals { + /** Component names that materially shape this surface. */ + dominant_components?: string[]; + /** Observed composition facts (`sectioned-form`, `left-nav`, …). */ + layout_patterns?: string[]; + /** Observed breakpoint behavior, when available. */ + breakpoint_behavior?: string[]; + /** IDs of value rows that are visibly load-bearing for this surface. */ + value_refs?: string[]; + /** Short factual notes; rationale belongs in patterns.yml or intent.md, not here. */ + notes?: string[]; +} + +export interface UiSurfaceComposition { + /** Ordered factual anatomy (`shell`, `compact-header`, `filter-row`, `table`). */ + anatomy?: string[]; + /** Dominant region carrying the surface's work (`table`, `form`, `canvas`). */ + primary_region?: string; + /** Where actions live relative to objects or regions. */ + action_placement?: string[]; + /** Navigation relationship (`persistent-shell`, `local-tabs`, `none`). */ + navigation_context?: string; + /** Factual responsive behavior observed for this surface. */ + responsive_behavior?: string[]; + /** Confidence in the observed composition facts, not in interpretation. */ + confidence?: number; +} + +export interface UiSurfaceRow extends RowBase { + name: string; + kind: UiSurfaceKind; + /** Route path, story id, screenshot path, fixture id, or source locator. */ + locator: string; + renderability: UiSurfaceRenderability; + files: string[]; + classification?: UiSurfaceClassification; + composition?: UiSurfaceComposition; + signals: UiSurfaceSignals; +} + +// --- Survey -------------------------------------------------------------- + +export interface Survey { + schema: "ghost.survey/v2"; + /** + * Source(s) the survey came from. Always an array — pre-merge surveys + * have length 1, merged surveys have N entries (one per source scan). + */ + sources: SurveySource[]; + values: ValueRow[]; + tokens: TokenRow[]; + components: ComponentRow[]; + ui_surfaces: UiSurfaceRow[]; +} diff --git a/packages/ghost/src/ghost-core/target-resolver.ts b/packages/ghost/src/ghost-core/target-resolver.ts new file mode 100644 index 0000000..350ab4a --- /dev/null +++ b/packages/ghost/src/ghost-core/target-resolver.ts @@ -0,0 +1,70 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Target } from "./types.js"; + +/** + * Resolve a target string into a typed Target. + * + * Explicit prefixes (recommended): + * github:owner/repo → GitHub clone + * npm:package-name → npm pack + * figma:file-url → Figma API + * + * Unambiguous patterns (no prefix needed): + * /absolute/path → local path + * ./relative/path → local path + * ../tracked/path → local path + * https://... → URL + * + * Ambiguous inputs without a prefix will throw an error + * with a suggestion to use a prefix. + */ +export function resolveTarget(input: string): Target { + // Explicit prefixes — unambiguous, preferred + const prefixMatch = input.match(/^(github|npm|figma|path|url):(.+)$/); + if (prefixMatch) { + const [, prefix, value] = prefixMatch; + return { type: prefix as Target["type"], value }; + } + + // Unambiguous: absolute or relative paths + if ( + input.startsWith("/") || + input.startsWith("./") || + input.startsWith("../") + ) { + return { type: "path", value: input }; + } + + // Unambiguous: exists as local path + if (existsSync(resolve(process.cwd(), input))) { + return { type: "path", value: input }; + } + + // Unambiguous: URLs + if (input.startsWith("http://") || input.startsWith("https://")) { + if (input.includes("figma.com")) { + return { type: "figma", value: input }; + } + return { type: "url", value: input }; + } + + // Unambiguous: npm scoped packages (@scope/name) + if (input.startsWith("@") && input.includes("/")) { + return { type: "npm", value: input }; + } + + // Ambiguous — require a prefix + const suggestions: string[] = []; + if (input.includes("/")) { + suggestions.push(` github:${input} (GitHub repo)`); + suggestions.push(` path:${input} (local path)`); + } else { + suggestions.push(` npm:${input} (npm package)`); + suggestions.push(` github:owner/${input} (GitHub repo)`); + } + + throw new Error( + `Ambiguous target "${input}". Use an explicit prefix:\n${suggestions.join("\n")}`, + ); +} diff --git a/packages/ghost/src/ghost-core/types.ts b/packages/ghost/src/ghost-core/types.ts new file mode 100644 index 0000000..3c2ad4e --- /dev/null +++ b/packages/ghost/src/ghost-core/types.ts @@ -0,0 +1,634 @@ +// --- Target --- + +export type TargetType = + | "path" + | "url" + | "registry" + | "npm" + | "github" + | "figma" + | "doc-site"; + +export interface TargetOptions { + branch?: string; + crawlDepth?: number; + figmaToken?: string; +} + +export interface Target { + type: TargetType; + value: string; + name?: string; + options?: TargetOptions; +} + +// --- Registry types (mirrors shadcn registry schema) --- + +export type RegistryItemType = + | "registry:ui" + | "registry:style" + | "registry:lib" + | "registry:base" + | "registry:font" + | "registry:block" + | "registry:component" + | "registry:hook" + | "registry:theme" + | "registry:file" + | "registry:page" + | "registry:item"; + +export interface FontDescriptor { + family: string; + provider: string; + import: string; + variable: string; + weight?: string[]; + subsets?: string[]; + selector?: string; + dependency?: string; +} + +export interface CSSVarsMap { + theme?: Record; + light?: Record; + dark?: Record; +} + +export interface Registry { + $schema?: string; + name: string; + homepage?: string; + items: RegistryItem[]; +} + +export interface RegistryItem { + name: string; + type: RegistryItemType; + dependencies?: string[]; + devDependencies?: string[]; + registryDependencies?: string[]; + files: RegistryFile[]; + categories?: string[]; + // v4 fields + font?: FontDescriptor; + cssVars?: CSSVarsMap; + css?: string; + meta?: Record; + title?: string; + description?: string; + author?: string; +} + +export interface ComponentMeta { + name: string; + description?: string; + categories: string[]; + exports: string[]; + variants: { name: string; options: string[] }[]; + dataSlots: string[]; + dependencies: string[]; + registryDependencies: string[]; +} + +export interface RegistryFile { + path: string; + content?: string; + type: string; + target: string; +} + +export interface ResolvedRegistry { + name: string; + homepage?: string; + items: RegistryItem[]; + tokens: CSSToken[]; +} + +// --- Token types --- + +export type TokenCategory = + | "background" + | "border" + | "text" + | "shadow" + | "radius" + | "spacing" + | "typography" + | "animation" + | "color" + | "font" + | "font-face" + | "chart" + | "sidebar" + | "other"; + +export interface CSSToken { + name: string; + value: string; + resolvedValue?: string; + selector: string; + category: TokenCategory; +} + +// --- Format detection --- + +export type TokenFormat = + | "css-custom-properties" + | "tailwind-config" + | "style-dictionary" + | "w3c-design-tokens" + | "shadcn-registry" + | "figma-variables" + | "unknown"; + +export interface DetectedFormat { + format: TokenFormat; + confidence: number; + evidence: string; + files: string[]; +} + +export interface NormalizedToken extends CSSToken { + originalFormat: TokenFormat; + sourceFile?: string; +} + +// --- Config types --- + +export type RuleSeverity = "error" | "warn" | "off"; + +export interface GhostConfig { + targets?: Target[]; + tracks?: Target; + rules: Record; + ignore: string[]; + embedding?: EmbeddingConfig; + extractors?: string[]; +} + +// --- Fingerprint types --- + +export interface SemanticColor { + role: string; + value: string; + oklch?: [number, number, number]; +} + +export interface ColorRamp { + steps: string[]; + count: number; +} + +// --- Check types (reviewer drift checks; perceptual-prior-aware) --- + +/** + * Perceptual severity for a drift violation. Calibrated to how loudly a + * change registers visually, not to engineering hygiene. See + * `perceptual-prior.ts` for the tier table that drives defaults. + * + * Distinct from `RuleSeverity` (`"error" | "warn" | "off"`) which is the + * config-level severity for `GhostConfig.rules`. The two never mix — + * `DriftSeverity` is for emitted reviewer checks; `RuleSeverity` gates lint + * configuration. + */ +export type DriftSeverity = "critical" | "serious" | "nit"; + +/** + * How a check's pattern is matched against violators. Color is exact; + * spacing tolerates small absolute drift; type-size tolerates relative + * drift; radius/shadow care about structural shape (pill vs. non-pill), + * not exact px. + */ +export type CheckMatchShape = "exact" | "band" | "percent" | "structural"; + +/** + * The dimension-of-value a check guards. Used to look up default match + * shape and tolerance. Distinct from canonical dimension because one + * canonical dimension (e.g. `typography-voice`) can host multiple check + * kinds (family, weight, size). + */ +export type CheckKind = + | "color" + | "radius" + | "spacing" + | "type-size" + | "type-family" + | "type-weight" + | "shadow" + | "motion"; + +export interface Check { + /** Stable id, slug-style. Used as anchor in emitted reviewer + diff. */ + id: string; + /** + * Canonical dimension this check belongs to. Drives perceptual-tier + * lookup. Optional — non-canonical checks are emitted but don't roll up + * at fleet aggregation. + */ + canonical?: string; + /** What kind of value the check guards. Drives default match shape. */ + kind?: CheckKind; + /** One-line summary the reviewer surfaces alongside violations. */ + summary?: string; + /** Regex (or fixed string) the reviewer greps for. */ + pattern: string; + /** + * Repo-relative filesystem scopes used by `verify-fingerprint` when checking + * calibrated `observed_count` values. + */ + paths?: string[]; + /** + * Reviewer/generator guidance for where the pattern usually appears. + * Open vocabulary; common values: `className`, `css_var`, + * `inline_style`, `import`. + */ + contexts?: string[]; + /** + * Optional explicit severity override. When absent, the emitter computes + * severity from `canonical` (perceptual tier), `observed_count`, and + * `presence_floor` (escalation against the survey). + */ + severity?: DriftSeverity; + /** Optional explicit match-shape override. */ + match?: CheckMatchShape; + /** Tolerance for `band` (px) or `percent` (0–1). Override of default. */ + tolerance?: number; + /** + * Survey-count threshold below which severity escalates one tier. The + * default is `0` — only when the guarded phenomenon is wholly absent + * does adding to it cross a presence boundary. Set to `2` (or higher) + * for cases like motion where a couple of structural transitions don't + * count as "this system uses motion." + */ + presence_floor?: number; + /** + * Observed count for the phenomenon this check guards, taken from the + * survey or a documented grep. When present, the review emitter + * uses this count for `presence_floor` escalation instead of falling + * back to coarse frontmatter-derived proxies. + */ + observed_count?: number; + /** + * Surveyor-computed support score: fraction of observed cases that + * already conform to this check. Used by the human curator to triage — + * <0.85 typically indicates the check isn't yet load-bearing in the + * codebase. Consumed at lint time as a soft warning. + */ + support?: number; +} + +export interface FingerprintReferences { + /** Source-of-truth spec/token/theme files worth opening during generation or drift review. */ + specs?: string[]; + /** Component directories, registries, or local libraries worth using before inventing UI. */ + components?: string[]; + /** Canonical examples, docs, or registry exemplars that show fingerprint in practice. */ + examples?: string[]; +} + +// --- Observation & decision types (three-layer fingerprint) --- + +export interface DesignObservation { + /** Holistic summary of the design language */ + summary: string; + /** Personality traits (e.g. "utilitarian", "restrained", "playful") */ + personality: string[]; + /** Closest well-known design languages for reference */ + resembles: string[]; +} + +export interface DesignDecision { + /** Freeform dimension name — LLM chooses what's relevant (e.g. "color-strategy", "motion", "density") */ + dimension: string; + /** + * Optional canonical dimension this decision rolls up under. When present, + * fleet-aggregation primitives group by this value. When absent, they + * fall back to `dimension` if it happens to be canonical, otherwise the + * decision is treated as long-tail. + * + * Authoring rule (see `closestCanonical` in `@anarchitecture/ghost/core`): when + * `dimension` itself is one of `CANONICAL_DECISION_DIMENSIONS`, omit + * `dimension_kind`. Set it only when you've chosen a project-flavored + * slug that's better described by an existing canonical dimension. + */ + dimension_kind?: string; + /** The decision stated abstractly, implementation-agnostic */ + decision: string; + /** Evidence from the source code supporting this decision */ + evidence: string[]; + /** + * Semantic embedding of `${dimension}: ${decision}`. + * Computed at fingerprint authoring time when an embedding provider is configured, + * and used by compareDecisions for paraphrase-robust matching. + * + * Runtime-only. `fingerprint.md` no longer stores decision embeddings. + */ + embedding?: number[]; +} + +export interface Fingerprint { + id: string; + source: "registry" | "extraction" | "llm" | "unknown"; + timestamp: string; + /** When fingerprinted from multiple sources, lists what was combined */ + sources?: string[]; + + // --- Three-layer model: observation → decisions → values --- + + /** Layer 1: Holistic read of the design language */ + observation?: DesignObservation; + /** Body-owned signature moves that make this design language recognizable. */ + signature?: string; + /** Direct pointers to living sources agents should read; map.md stays scan-only. */ + references?: FingerprintReferences; + /** Layer 2: Abstract design decisions, implementation-agnostic */ + decisions?: DesignDecision[]; + /** + * Human-promoted review checks — grep-friendly, severity computed + * by the perceptual prior at emit time. Coexists with `decisions[]` + * while fingerprint prose remains the primary generation surface. + */ + checks?: Check[]; + + // --- Layer 3: Concrete values --- + + palette: { + dominant: SemanticColor[]; + neutrals: ColorRamp; + semantic: SemanticColor[]; + saturationProfile: "muted" | "vibrant" | "mixed"; + contrast: "high" | "moderate" | "low"; + }; + + spacing: { + scale: number[]; + regularity: number; + baseUnit: number | null; + }; + + typography: { + families: string[]; + sizeRamp: number[]; + weightDistribution: Record; + lineHeightPattern: "tight" | "normal" | "loose"; + }; + + surfaces: { + borderRadii: number[]; + shadowComplexity: "deliberate-none" | "subtle" | "layered"; + borderUsage: "minimal" | "moderate" | "heavy"; + borderTokenCount?: number; + }; + + embedding: number[]; +} + +// --- Sampled material (LLM-first pipeline) --- + +export interface SampledFile { + path: string; + content: string; + reason: string; + /** Which source this file came from (multi-source fingerprinting) */ + sourceLabel?: string; +} + +export interface SourceInfo { + label: string; + targetType: TargetType; + fileCount: number; + sampledCount: number; +} + +export interface SampledMaterial { + files: SampledFile[]; + metadata: { + totalFiles: number; + sampledFiles: number; + targetType: TargetType; + /** When fingerprinted from multiple sources, per-source breakdown */ + sources?: SourceInfo[]; + packageJson?: { + name?: string; + dependencies?: Record; + devDependencies?: Record; + }; + packageSwift?: { + name?: string; + dependencies?: string[]; + }; + }; +} + +// --- AI enrichment types --- + +export interface EnrichedFingerprint extends Fingerprint { + detectedFormats?: DetectedFormat[]; + targetType: TargetType; +} + +export type DivergenceClass = + | "accidental-drift" + | "intentional-variant" + | "evolution-lag" + | "incompatible"; + +export interface EnrichedComparison extends FingerprintComparison { + classification: DivergenceClass; + explanations: Record; +} + +// --- Extractor types --- + +export interface ExtractedFile { + path: string; + content: string; + type: + | "css" + | "scss" + | "tailwind-config" + | "component" + | "config" + | "json-tokens" + | "style-dictionary" + | "w3c-tokens" + | "figma-variables" + | "documentation" + | "swift" + | "xcassets" + | "xcconfig" + | "other"; +} + +export interface ExtractedMaterial { + styleFiles: ExtractedFile[]; + componentFiles: ExtractedFile[]; + configFiles: ExtractedFile[]; + metadata: { + framework: string | null; + componentLibrary: string | null; + tokenCount: number; + componentCount: number; + targetType?: TargetType; + detectedFormats?: DetectedFormat[]; + sourceUrl?: string; + }; +} + +export interface ExtractorOptions { + ignore?: string[]; + maxFiles?: number; + componentDir?: string; + styleEntry?: string; +} + +export interface Extractor { + name: string; + detect: (cwd: string) => Promise; + extract: ( + cwd: string, + options?: ExtractorOptions, + ) => Promise; +} + +// --- Embedding config (used by the semantic-roles helper in embed-api.ts) --- + +export interface EmbeddingConfig { + provider: "openai" | "voyage"; + model?: string; + apiKey?: string; +} + +// --- History types --- + +export interface FingerprintHistoryEntry { + fingerprint: Fingerprint; + trackedRef?: Target; + comparisonToTracked?: { + distance: number; + dimensions: Record; + }; +} + +// --- Sync / acknowledgment types --- + +export type DimensionStance = + | "aligned" + | "accepted" + | "diverging" + | "reconverging"; + +export interface DimensionAck { + distance: number; + stance: DimensionStance; + ackedAt: string; + reason?: string; + tolerance?: number; + divergedAt?: string; +} + +export interface SyncManifest { + tracks: Target; + ackedAt: string; + trackedFingerprintId: string; + localFingerprintId: string; + dimensions: Record; + overallDistance: number; +} + +// --- Comparison types --- + +export interface DimensionDelta { + dimension: string; + distance: number; + description: string; +} + +export interface FingerprintComparison { + source: Fingerprint; + target: Fingerprint; + distance: number; + dimensions: Record; + summary: string; + vectors?: DriftVector[]; +} + +// --- Temporal / drift vector types --- + +export interface DriftVector { + dimension: string; + magnitude: number; + embeddingDelta: number[]; +} + +export interface DriftVelocity { + dimension: string; + rate: number; + direction: "converging" | "diverging" | "stable"; + windowDays: number; +} + +export interface TemporalComparison extends FingerprintComparison { + velocity: DriftVelocity[]; + daysSinceAck: number | null; + exceedsAckedBounds: boolean; + exceedingDimensions: string[]; + trajectory: "converging" | "diverging" | "stable" | "oscillating"; +} + +// --- Composite types (N≥3 fingerprint comparison) --- + +export interface CompositeMember { + id: string; + fingerprint: Fingerprint; + trackedRef?: Target; + distanceToTracked?: number; +} + +export interface CompositePair { + a: string; + b: string; + distance: number; + dimensions: Record; +} + +export interface CompositeCluster { + memberIds: string[]; + centroid: number[]; +} + +export interface CompositeComparison { + members: CompositeMember[]; + pairwise: CompositePair[]; + centroid: number[]; + spread: number; + clusters?: CompositeCluster[]; +} + +// --- Drift report types --- + +export interface ValueDrift { + token: string; + rule: string; + severity: RuleSeverity; + message: string; + fingerprintValue?: string; + implementationValue?: string; + selector?: string; + file?: string; + line?: number; + suggestion?: string; +} + +export interface StructureDrift { + component: string; + rule: string; + severity: RuleSeverity; + message: string; + diff?: string; + linesAdded: number; + linesRemoved: number; + fingerprintFile?: string; + implementationFile?: string; +} diff --git a/packages/ghost/src/index.ts b/packages/ghost/src/index.ts new file mode 100644 index 0000000..b73c915 --- /dev/null +++ b/packages/ghost/src/index.ts @@ -0,0 +1,4 @@ +export * as drift from "./core/index.js"; +export * from "./core/index.js"; +export * as core from "./ghost-core/index.js"; +export * as scan from "./scan/index.js"; diff --git a/packages/ghost/src/scan-commands.ts b/packages/ghost/src/scan-commands.ts new file mode 100644 index 0000000..10e8985 --- /dev/null +++ b/packages/ghost/src/scan-commands.ts @@ -0,0 +1,977 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { CAC } from "cac"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { + catalogSurveyValues, + formatSurveyCatalogMarkdown, + formatSurveySummaryMarkdown, + type GhostPatternsDocument, + lintGhostChecks, + lintGhostPatterns, + lintGhostResources, + lintSurvey, + mergeSurveys, + recomputeSurveyIds, + type Survey, + type SurveyLintReport, + type SurveySummaryBudget, + summarizeSurvey, +} from "#ghost-core"; +import { + diffFingerprints, + formatLayout, + formatSemanticDiff, + formatVerifyFingerprintReport, + initFingerprintPackage, + inventory, + layoutFingerprint, + lintFingerprint, + lintFingerprintPackage, + lintMap, + loadFingerprint, + resolveFingerprintPackage, + scanStatus, + verifyFingerprintPackage, +} from "./scan/index.js"; +import { registerEmitCommand } from "./scan-emit-command.js"; + +/** + * Register scan and fingerprint-bundle commands on the unified Ghost CLI. + * + * Verbs author and validate the root `.ghost/` fingerprint bundle: + * `lint` (schema check, auto-detects file kind), `verify` (cross-artifact + * fidelity), `describe` (section ranges + token estimates for intent or direct + * fingerprint markdown), `diff` (structural prose-level diff between direct + * fingerprint files), `emit` (derive review-command, context-bundle, or skill + * artifacts), and `survey` operations for deterministic `ghost.survey/v2` + * merge, ID repair, bounded summary output, derived value catalogs, and + * operational pattern synthesis. + * + * Embedding-based comparison lives in `ghost compare`. `diff` here is + * text/structural — what decisions and palette roles changed — not + * vector distance. + */ +export function registerScanCommands(cli: CAC): void { + // --- lint --- + cli + .command( + "lint [file]", + "Validate a root fingerprint bundle, resources.yml, map.md, survey.json, patterns.yml, checks.yml, or markdown — defaults to .ghost", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (path: string | undefined, opts) => { + try { + const target = resolveFingerprintPackage(path, process.cwd()).dir; + let report: ReturnType; + if (path === undefined || (await isDirectory(target))) { + report = await lintFingerprintPackage(path, process.cwd()); + writeLintReport(report, opts.format); + process.exit(report.errors > 0 ? 1 : 0); + return; + } + + const fileTarget = resolve(process.cwd(), path ?? target); + const raw = await readFile(fileTarget, "utf-8"); + const kind = detectFileKind(fileTarget, raw); + + report = + kind === "survey" + ? lintSurveyFile(raw) + : kind === "map" + ? lintMap(raw) + : kind === "resources" + ? lintResourcesFile(raw) + : kind === "patterns" + ? lintPatternsFile(raw) + : kind === "checks" + ? lintChecksFile(raw) + : lintFingerprint(raw); + + if (kind === "fingerprint" && hasExtends(raw) && report.errors === 0) { + try { + await loadFingerprint(fileTarget, { noEmbeddingBackfill: true }); + } catch (err) { + report = appendLintError( + report, + "extends-resolution", + err instanceof Error ? err.message : String(err), + "extends", + ); + } + } + + writeLintReport(report, opts.format); + + process.exit(report.errors > 0 ? 1 : 0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- init --- + cli + .command( + "init [dir]", + "Create a root .ghost fingerprint bundle skeleton (resources.yml, map.md, survey.json, patterns.yml, checks.yml)", + ) + .option( + "--with-intent", + "Also create optional intent.md for human-authored or human-approved intent", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (dirArg: string | undefined, opts) => { + try { + const paths = await initFingerprintPackage(dirArg, process.cwd(), { + withIntent: Boolean(opts.withIntent), + }); + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(paths, null, 2)}\n`); + } else { + process.stdout.write( + `Initialized fingerprint package: ${paths.dir}\n`, + ); + process.stdout.write(` resources.yml: ${paths.resources}\n`); + process.stdout.write(` map.md: ${paths.map}\n`); + process.stdout.write(` survey.json: ${paths.survey}\n`); + process.stdout.write(` patterns.yml: ${paths.patterns}\n`); + process.stdout.write(` checks.yml: ${paths.checks}\n`); + if (opts.withIntent) { + process.stdout.write(` intent.md: ${paths.intent}\n`); + } + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- verify --- + cli + .command( + "verify [dir]", + "Verify a root fingerprint bundle: resources are reachable, patterns are survey-backed, and checks reference known patterns.", + ) + .option( + "--root ", + "Optional target root used to resolve resources.yml local paths (default: cwd)", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (dirArg: string | undefined, opts) => { + try { + if (opts.format !== "cli" && opts.format !== "json") { + console.error("Error: --format must be 'cli' or 'json'"); + process.exit(2); + return; + } + + const report = await verifyFingerprintPackage(dirArg, process.cwd(), { + root: opts.root ? resolve(process.cwd(), opts.root) : undefined, + }); + + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + process.stdout.write(formatVerifyFingerprintReport(report)); + } + + process.exit(report.errors > 0 ? 1 : 0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- scan --- + cli + .command( + "scan [dir]", + "Report which root fingerprint bundle stages have produced artifacts: resources.yml, map.md, survey.json, patterns.yml, and optional checks.yml/intent.md.", + ) + .option( + "--include-scopes", + "Also report per-scope survey and fingerprint artifacts under modules// and fingerprints/.md", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (dirArg: string | undefined, opts) => { + try { + const dir = resolveFingerprintPackage(dirArg, process.cwd()).dir; + const status = await scanStatus(dir, { + includeScopes: Boolean(opts.includeScopes), + }); + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + } else { + const fmt = (state: string) => + state === "present" ? "present" : "missing"; + process.stdout.write(`scan dir: ${status.dir}\n\n`); + process.stdout.write( + ` resources (resources.yml): ${fmt(status.resources.state)}\n`, + ); + process.stdout.write( + ` map (map.md): ${fmt(status.map.state)}\n`, + ); + process.stdout.write( + ` survey (survey.json): ${fmt(status.survey.state)}\n`, + ); + process.stdout.write( + ` patterns (patterns.yml): ${fmt(status.patterns.state)}\n`, + ); + process.stdout.write( + ` checks (checks.yml): ${fmt(status.checks.state)}\n\n`, + ); + process.stdout.write( + ` intent (intent.md): ${fmt(status.intent.state)}\n\n`, + ); + if (status.recommended_next) { + process.stdout.write( + `next: run the ${status.recommended_next} stage\n`, + ); + } else { + process.stdout.write("next: scan complete — all stages present\n"); + } + if (status.scope_error) { + process.stdout.write(`\nscopes: error — ${status.scope_error}\n`); + } else if (status.scopes) { + process.stdout.write("\nscopes:\n"); + if (status.scopes.length === 0) { + process.stdout.write(" none\n"); + } else { + for (const scope of status.scopes) { + process.stdout.write( + ` ${scope.id}: survey ${scope.survey.state}, fingerprint ${scope.fingerprint.state}\n`, + ); + } + } + } + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- inventory --- + cli + .command( + "inventory [path]", + "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).", + ) + .action(async (path: string | undefined) => { + try { + const target = resolve(process.cwd(), path ?? "."); + const out = inventory(target); + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + process.exit(0); + } catch (err) { + process.stderr.write( + `Error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(2); + } + }); + + // --- describe --- + cli + .command( + "describe [fingerprint]", + "Print a section map of intent.md or a direct fingerprint markdown file (line ranges + token estimates).", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (path: string | undefined, opts) => { + try { + const target = path + ? resolve(process.cwd(), path) + : resolveFingerprintPackage(undefined, process.cwd()).intent; + const raw = await readFile(target, "utf-8"); + const layout = layoutFingerprint(raw); + if (opts.format === "json") { + process.stdout.write( + `${JSON.stringify({ path: target, ...layout }, null, 2)}\n`, + ); + } else { + process.stdout.write(`${formatLayout(layout, target)}\n`); + } + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- diff --- + cli + .command( + "diff ", + "Structural diff between two fingerprint.md files — what decisions, palette roles, and tokens changed (text-level, NOT embedding distance; for that, use `ghost compare`).", + ) + .option("--format ", "Output format: cli or json", { default: "cli" }) + .action(async (a: string, b: string, opts) => { + try { + const [{ fingerprint: exprA }, { fingerprint: exprB }] = + await Promise.all([ + loadFingerprint(resolve(process.cwd(), a), { + noEmbeddingBackfill: true, + }), + loadFingerprint(resolve(process.cwd(), b), { + noEmbeddingBackfill: true, + }), + ]); + const diff = diffFingerprints(exprA, exprB); + if (opts.format === "json") { + process.stdout.write(`${JSON.stringify(diff, null, 2)}\n`); + } else { + process.stdout.write(formatSemanticDiff(diff)); + } + process.exit(diff.unchanged ? 0 : 1); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + // --- survey --- + cli + .command( + "survey [...surveys]", + "Operate on ghost.survey/v2 files. Ops: merge, fix-ids, summarize, catalog, patterns.", + ) + .option( + "-o, --out ", + "Write the result to this path (default: stdout)", + ) + .option( + "--format ", + "Output format: summarize/catalog use markdown or json; patterns use yaml, json, or markdown", + ) + .option( + "--kind ", + "survey catalog filter: include only this value kind", + ) + .option( + "--budget ", + "survey summarize budget: compact, standard, full", + { + default: "standard", + }, + ) + .action(async (op: string, surveys: string[], opts) => { + try { + if ( + op !== "merge" && + op !== "fix-ids" && + op !== "summarize" && + op !== "catalog" && + op !== "patterns" + ) { + console.error( + `Error: unknown survey op '${op}'. Supported: merge, fix-ids, summarize, catalog, patterns`, + ); + process.exit(2); + return; + } + if (!Array.isArray(surveys) || surveys.length === 0) { + console.error(`Error: survey ${op} requires at least one input file`); + process.exit(2); + return; + } + if (op === "fix-ids" && surveys.length !== 1) { + console.error("Error: survey fix-ids takes exactly one input file"); + process.exit(2); + return; + } + if (op === "summarize" && surveys.length !== 1) { + console.error("Error: survey summarize takes exactly one input file"); + process.exit(2); + return; + } + if ((op === "catalog" || op === "patterns") && surveys.length !== 1) { + console.error(`Error: survey ${op} takes exactly one input file`); + process.exit(2); + return; + } + const format = defaultSurveyFormat(op, opts.format); + if (op === "summarize" || op === "catalog") { + if (format !== "markdown" && format !== "json") { + console.error( + `Error: survey ${op} --format must be 'markdown' or 'json'`, + ); + process.exit(2); + return; + } + } + if (op === "patterns") { + if (format !== "yaml" && format !== "json" && format !== "markdown") { + console.error( + "Error: survey patterns --format must be 'yaml', 'json', or 'markdown'", + ); + process.exit(2); + return; + } + } + if (op === "summarize") { + if (!isSurveySummaryBudget(opts.budget)) { + console.error( + "Error: survey summarize --budget must be 'compact', 'standard', or 'full'", + ); + process.exit(2); + return; + } + } + if (opts.kind && op !== "catalog") { + console.error("Error: --kind is only supported for survey catalog"); + process.exit(2); + return; + } + + const parsed: Survey[] = []; + for (const path of surveys) { + const target = resolve(process.cwd(), path); + const raw = await readFile(target, "utf-8"); + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + console.error( + `Error: ${target} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + return; + } + if ( + op === "merge" || + op === "summarize" || + op === "catalog" || + op === "patterns" + ) { + const report = lintSurvey(json); + if (report.errors > 0) { + console.error( + `Error: ${target} failed survey lint with ${report.errors} error(s); fix before ${surveyVerbName(op)}`, + ); + for (const issue of report.issues) { + if (issue.severity !== "error") continue; + const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; + console.error( + ` [${issue.rule}] ${issue.message}${pathSuffix}`, + ); + } + process.exit(1); + return; + } + } + parsed.push(json as Survey); + } + + let out: string; + if (op === "summarize") { + const summary = summarizeSurvey(parsed[0], { + budget: opts.budget as SurveySummaryBudget, + }); + out = + format === "json" + ? `${JSON.stringify(summary, null, 2)}\n` + : formatSurveySummaryMarkdown(summary); + } else if (op === "catalog") { + const catalog = catalogSurveyValues(parsed[0], { + kind: typeof opts.kind === "string" ? opts.kind : undefined, + }); + out = + format === "json" + ? `${JSON.stringify(catalog, null, 2)}\n` + : formatSurveyCatalogMarkdown(catalog); + } else if (op === "patterns") { + const patterns = summarizeSurveyPatterns(parsed[0]); + out = formatPatternsOutput(patterns, format); + } else { + const result = + op === "merge" + ? mergeSurveys(...parsed) + : recomputeSurveyIds(parsed[0]); + out = `${JSON.stringify(result, null, 2)}\n`; + } + + if (opts.out) { + const outPath = resolve(process.cwd(), opts.out); + await writeFile(outPath, out, "utf-8"); + } else { + process.stdout.write(out); + } + + process.exit(0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); + + registerEmitCommand(cli); +} + +/** + * Decide whether a file is a bundle artifact. JSON paths/contents route to + * the survey linter; markdown with `schema: ghost.map/v2` in frontmatter + * routes to the map linter; YAML schemas route to resources/patterns/checks; + * everything else stays on the direct fingerprint markdown path. + */ +function detectFileKind( + path: string, + raw: string, +): "survey" | "map" | "fingerprint" | "checks" | "resources" | "patterns" { + if (path.toLowerCase().endsWith(".json")) return "survey"; + if (path.toLowerCase().endsWith("resources.yml")) return "resources"; + if (path.toLowerCase().endsWith("resources.yaml")) return "resources"; + if (path.toLowerCase().endsWith("patterns.yml")) return "patterns"; + if (path.toLowerCase().endsWith("patterns.yaml")) return "patterns"; + if (path.toLowerCase().endsWith(".yml")) return "checks"; + if (path.toLowerCase().endsWith(".yaml")) return "checks"; + if (raw.trimStart().startsWith("{")) return "survey"; + if (/^\s*schema:\s*ghost\.resources\/v1\b/m.test(raw)) return "resources"; + if (/^\s*schema:\s*ghost\.patterns\/v1\b/m.test(raw)) return "patterns"; + if (/^\s*schema:\s*ghost\.checks\/v1\b/m.test(raw)) return "checks"; + // Cheap markdown frontmatter sniff for `schema: ghost.map/v2`. We don't + // parse YAML here; the linter does the heavy lift. + const fmEnd = raw.indexOf("\n---", 3); + if (raw.startsWith("---") && fmEnd > 0) { + const fm = raw.slice(0, fmEnd); + if (/\bschema:\s*ghost\.map\/v2\b/.test(fm)) return "map"; + } + if (path.toLowerCase().endsWith("map.md")) return "map"; + return "fingerprint"; +} + +function lintSurveyFile(raw: string): SurveyLintReport { + let json: unknown; + try { + json = JSON.parse(raw); + } catch (err) { + return { + issues: [ + { + severity: "error", + rule: "survey-not-json", + message: `survey file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + errors: 1, + warnings: 0, + info: 0, + }; + } + return lintSurvey(json); +} + +function lintChecksFile(raw: string): ReturnType { + try { + return lintGhostChecks(parseYaml(raw)); + } catch (err) { + return { + issues: [ + { + severity: "error", + rule: "checks-not-yaml", + message: `checks file is not valid YAML: ${ + err instanceof Error ? err.message : String(err) + }`, + }, + ], + errors: 1, + warnings: 0, + info: 0, + }; + } +} + +function lintResourcesFile(raw: string): ReturnType { + try { + return lintGhostResources(parseYaml(raw)); + } catch (err) { + return { + issues: [ + { + severity: "error", + rule: "resources-not-yaml", + message: `resources file is not valid YAML: ${ + err instanceof Error ? err.message : String(err) + }`, + }, + ], + errors: 1, + warnings: 0, + info: 0, + }; + } +} + +function lintPatternsFile(raw: string): ReturnType { + try { + return lintGhostPatterns(parseYaml(raw)); + } catch (err) { + return { + issues: [ + { + severity: "error", + rule: "patterns-not-yaml", + message: `patterns file is not valid YAML: ${ + err instanceof Error ? err.message : String(err) + }`, + }, + ], + errors: 1, + warnings: 0, + info: 0, + }; + } +} + +function writeLintReport( + report: ReturnType, + format: unknown, +): void { + if (format === "json") { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + for (const issue of report.issues) { + const prefix = + issue.severity === "error" + ? "ERROR" + : issue.severity === "warning" + ? "WARN " + : "INFO "; + const pathSuffix = issue.path ? ` @ ${issue.path}` : ""; + process.stdout.write( + `${prefix} [${issue.rule}] ${issue.message}${pathSuffix}\n`, + ); + } + process.stdout.write( + `\n${report.errors} error(s), ${report.warnings} warning(s), ${report.info} info\n`, + ); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +function hasExtends(raw: string): boolean { + try { + const frontmatter = raw.match(/^---\n([\s\S]*?)\n---/)?.[1]; + if (!frontmatter) return false; + const parsed = parseYaml(frontmatter); + return Boolean( + parsed && + typeof parsed === "object" && + typeof (parsed as Record).extends === "string", + ); + } catch { + return false; + } +} + +function appendLintError( + report: ReturnType, + rule: string, + message: string, + path?: string, +): ReturnType { + const issues = [ + ...report.issues, + { severity: "error" as const, rule, message, ...(path ? { path } : {}) }, + ]; + return { + issues, + errors: report.errors + 1, + warnings: report.warnings, + info: report.info, + }; +} + +function isSurveySummaryBudget(value: unknown): value is SurveySummaryBudget { + return value === "compact" || value === "standard" || value === "full"; +} + +function surveyVerbName(op: string): string { + if (op === "merge") return "merging"; + if (op === "summarize") return "summarizing"; + if (op === "catalog") return "cataloging"; + if (op === "patterns") return "summarizing patterns"; + return op; +} + +function defaultSurveyFormat(op: string, format: unknown): string { + if (typeof format === "string") return format; + return op === "patterns" ? "yaml" : "markdown"; +} + +function formatPatternsOutput( + patterns: GhostPatternsDocument, + format: string, +): string { + if (format === "json") return `${JSON.stringify(patterns, null, 2)}\n`; + if (format === "markdown") return formatSurveyPatternsMarkdown(patterns); + return stringifyYaml(patterns); +} + +function summarizeSurveyPatterns(survey: Survey): GhostPatternsDocument { + const surfaceTypes = new Map(); + const layoutPatterns = new Map(); + + for (const surface of survey.ui_surfaces) { + const label = surface.locator || surface.name; + const classification = surface.classification; + if (classification?.surface_type) { + addPattern(surfaceTypes, classification.surface_type, label); + } + for (const pattern of surface.signals?.layout_patterns ?? []) { + addPattern(layoutPatterns, pattern, label, surface); + } + } + + const surfaceTypeRows = topPatterns(surfaceTypes).map((entry) => ({ + id: slug(entry.value), + title: entry.value, + signals: entry.examples, + preferred_patterns: preferredPatternsForSurfaceType(entry.value, survey), + evidence: evidenceForSurfaceType(entry.value, survey), + })); + const surfaceTypeIds = new Set(surfaceTypeRows.map((row) => row.id)); + + return { + schema: "ghost.patterns/v1", + id: slug(survey.sources[0]?.id ?? "survey-patterns"), + surface_types: surfaceTypeRows, + composition_patterns: topPatterns(layoutPatterns).map((entry) => ({ + id: slug(entry.value), + title: entry.value, + surface_types: surfaceTypesForPattern(entry.value, survey).filter((id) => + surfaceTypeIds.has(id), + ), + frequency: entry.count, + confidence: + survey.ui_surfaces.length > 0 + ? Number( + Math.min(1, entry.count / survey.ui_surfaces.length).toFixed(2), + ) + : 0, + anatomy: { + ordered: anatomyForPattern(entry.value, survey), + }, + traits: traitsForPattern(entry.value, survey), + evidence: entry.evidence, + advisory: [ + "Use as advisory composition evidence; deterministic enforcement belongs in checks.yml.", + ], + })), + advisory: { + review_expectations: [ + "Identify the surface type before judging composition.", + "Cite matching composition_patterns[].evidence and survey.ui_surfaces evidence for advisory findings.", + "Treat intent.md as human authority when present.", + ], + }, + }; +} + +interface PatternAccumulator { + count: number; + examples: string[]; + evidence: Array<{ surface_id?: string; locator?: string; path?: string }>; +} + +function addPattern( + map: Map, + value: string, + example: string, + surface?: Survey["ui_surfaces"][number], +): void { + const current = map.get(value) ?? { count: 0, examples: [], evidence: [] }; + current.count += 1; + if (!current.examples.includes(example) && current.examples.length < 5) { + current.examples.push(example); + } + if (surface && current.evidence.length < 5) { + current.evidence.push({ + surface_id: surface.id, + locator: surface.locator, + ...(surface.files[0] ? { path: surface.files[0] } : {}), + }); + } + map.set(value, current); +} + +function topPatterns(map: Map): Array<{ + value: string; + count: number; + examples: string[]; + evidence: Array<{ surface_id?: string; locator?: string; path?: string }>; +}> { + return [...map.entries()] + .map(([value, accumulator]) => ({ + value, + count: accumulator.count, + examples: accumulator.examples, + evidence: accumulator.evidence, + })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); +} + +function formatSurveyPatternsMarkdown(summary: GhostPatternsDocument): string { + const lines = [ + "# Survey Patterns", + "", + `Schema: ${summary.schema}`, + `Surface types: ${summary.surface_types.length}`, + `Composition patterns: ${summary.composition_patterns.length}`, + "", + ]; + appendPatternSection( + lines, + "Surface Types", + summary.surface_types.map((surfaceType) => ({ + value: surfaceType.id, + count: surfaceType.evidence?.length ?? 0, + examples: surfaceType.signals ?? [], + })), + ); + appendPatternSection( + lines, + "Composition Patterns", + summary.composition_patterns.map((pattern) => ({ + value: pattern.id, + count: pattern.frequency ?? 0, + examples: + pattern.evidence?.map((entry) => entry.locator ?? entry.path ?? "") ?? + [], + })), + ); + return `${lines.join("\n")}\n`; +} + +function appendPatternSection( + lines: string[], + title: string, + rows: Array<{ value: string; count: number; examples: string[] }>, +): void { + lines.push(`## ${title}`, ""); + if (rows.length === 0) { + lines.push("- none", ""); + return; + } + for (const row of rows) { + lines.push(`- ${row.value}: ${row.count} (${row.examples.join(", ")})`); + } + lines.push(""); +} + +function preferredPatternsForSurfaceType( + surfaceType: string, + survey: Survey, +): string[] { + const counts = new Map(); + for (const surface of survey.ui_surfaces) { + if (surface.classification?.surface_type !== surfaceType) continue; + for (const pattern of surface.signals?.layout_patterns ?? []) { + counts.set(slug(pattern), (counts.get(slug(pattern)) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 5) + .map(([id]) => id); +} + +function evidenceForSurfaceType( + surfaceType: string, + survey: Survey, +): Array<{ surface_id: string; locator: string; path?: string }> { + return survey.ui_surfaces + .filter((surface) => surface.classification?.surface_type === surfaceType) + .slice(0, 5) + .map((surface) => ({ + surface_id: surface.id, + locator: surface.locator, + ...(surface.files[0] ? { path: surface.files[0] } : {}), + })); +} + +function surfaceTypesForPattern(pattern: string, survey: Survey): string[] { + const types = new Set(); + for (const surface of survey.ui_surfaces) { + if (!surface.signals?.layout_patterns?.includes(pattern)) continue; + const surfaceType = surface.classification?.surface_type; + if (surfaceType) types.add(slug(surfaceType)); + } + return [...types].sort(); +} + +function anatomyForPattern(pattern: string, survey: Survey): string[] { + const counts = new Map(); + for (const surface of survey.ui_surfaces) { + if (!surface.signals?.layout_patterns?.includes(pattern)) continue; + for (const item of surface.composition?.anatomy ?? []) { + counts.set(item, (counts.get(item) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([item]) => item); +} + +function traitsForPattern( + pattern: string, + survey: Survey, +): Record { + const densities = new Set(); + const layoutShapes = new Set(); + const components = new Set(); + for (const surface of survey.ui_surfaces) { + if (!surface.signals?.layout_patterns?.includes(pattern)) continue; + if (surface.classification?.density) { + densities.add(surface.classification.density); + } + if (surface.classification?.layout_shape) { + layoutShapes.add(surface.classification.layout_shape); + } + for (const component of surface.signals?.dominant_components ?? []) { + components.add(component); + } + } + return { + density: [...densities].sort(), + layout_shape: [...layoutShapes].sort(), + dominant_components: [...components].sort().slice(0, 8), + source_signal: [pattern], + }; +} + +function slug(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") || "pattern" + ); +} diff --git a/packages/ghost/src/scan-emit-command.ts b/packages/ghost/src/scan-emit-command.ts new file mode 100644 index 0000000..35b0f46 --- /dev/null +++ b/packages/ghost/src/scan-emit-command.ts @@ -0,0 +1,163 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { CAC } from "cac"; +import { + emitReviewCommand, + loadFingerprint, + resolveFingerprintPackage, + writeContextBundle, + writePackageContextBundle, +} from "./scan/index.js"; + +const DEFAULT_REVIEW_OUT = ".claude/commands/design-review.md"; +const DEFAULT_CONTEXT_OUT = "ghost-context"; + +export const SUPPORTED_KINDS = ["review-command", "context-bundle"] as const; +export type EmitKind = (typeof SUPPORTED_KINDS)[number]; + +export type ParseEmitKindResult = + | { ok: true; kind: EmitKind } + | { ok: false; error: string }; + +/** + * Validate the positional emit kind against the supported set. + * Exported for unit testing. + */ +export function parseEmitKind(raw: string): ParseEmitKindResult { + if ((SUPPORTED_KINDS as readonly string[]).includes(raw)) { + return { ok: true, kind: raw as EmitKind }; + } + return { + ok: false, + error: `unknown emit kind '${raw}'. Supported: ${SUPPORTED_KINDS.join(", ")}`, + }; +} + +export function registerEmitCommand(cli: CAC): void { + cli + .command( + "emit ", + `Emit a derived artifact from the fingerprint package (kinds: ${SUPPORTED_KINDS.join(", ")})`, + ) + .option( + "-f, --fingerprint ", + "Source legacy direct fingerprint markdown file (required for review-command; optional legacy mode for context-bundle)", + ) + .option( + "-o, --out ", + `Output path (review-command → ${DEFAULT_REVIEW_OUT}; context-bundle → ${DEFAULT_CONTEXT_OUT}/)`, + ) + .option( + "--stdout", + "Write to stdout instead of a file (review-command only)", + ) + // context-bundle flags: + .option( + "--no-tokens", + "Skip tokens.css output (legacy direct fingerprint context-bundle)", + ) + .option("--readme", "Include README.md (context-bundle)") + .option("--prompt-only", "Emit only prompt.md (context-bundle)") + .option( + "--name ", + "Override the skill name (default: package or fingerprint id) (context-bundle)", + ) + .action(async (kind: string, opts) => { + try { + const parsed = parseEmitKind(kind); + if (!parsed.ok) { + console.error(`Error: ${parsed.error}`); + process.exit(2); + return; + } + + const explicitFingerprint = typeof opts.fingerprint === "string"; + + if (parsed.kind === "review-command") { + const fingerprintPath = resolve( + process.cwd(), + opts.fingerprint ?? + resolveFingerprintPackage(undefined, process.cwd()).fingerprint, + ); + const loaded = await loadFingerprint(fingerprintPath, { + noEmbeddingBackfill: true, + }); + const content = emitReviewCommand({ + fingerprint: loaded.fingerprint, + }); + + if (opts.stdout) { + process.stdout.write(content); + process.exit(0); + return; + } + + const outPath = resolve( + process.cwd(), + opts.out ?? DEFAULT_REVIEW_OUT, + ); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, content, "utf-8"); + console.log(`Wrote ${outPath}`); + process.exit(0); + return; + } + + // kind === "context-bundle" + const outDir = resolve( + process.cwd(), + (opts.out as string | undefined) ?? DEFAULT_CONTEXT_OUT, + ); + + if (!explicitFingerprint) { + const result = await writePackageContextBundle( + resolveFingerprintPackage(undefined, process.cwd()), + { + outDir, + readme: Boolean(opts.readme), + promptOnly: Boolean(opts.promptOnly), + name: opts.name as string | undefined, + }, + ); + + process.stdout.write( + `Wrote ${result.files.length} file${ + result.files.length === 1 ? "" : "s" + } to ${result.outDir}:\n`, + ); + for (const f of result.files) { + process.stdout.write(` ${f}\n`); + } + process.exit(0); + return; + } + + const fingerprintPath = resolve(process.cwd(), opts.fingerprint); + const { fingerprint } = await loadFingerprint(fingerprintPath); + const result = await writeContextBundle(fingerprint, { + outDir, + tokens: opts.tokens !== false, + readme: Boolean(opts.readme), + promptOnly: Boolean(opts.promptOnly), + name: opts.name as string | undefined, + sourcePath: fingerprintPath, + }); + + process.stdout.write( + `Wrote ${result.files.length} file${ + result.files.length === 1 ? "" : "s" + } to ${result.outDir}:\n`, + ); + for (const f of result.files) { + process.stdout.write(` ${f}\n`); + } + process.exit(0); + return; + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }); +} diff --git a/packages/ghost/src/scan/body.ts b/packages/ghost/src/scan/body.ts new file mode 100644 index 0000000..6926986 --- /dev/null +++ b/packages/ghost/src/scan/body.ts @@ -0,0 +1,116 @@ +import type { DesignDecision } from "#ghost-core"; + +/** + * Structured read of a fingerprint.md body. The body is authoritative for + * prose — # Character, # Signature, and per-dimension rationale under # Decisions. + * Frontmatter carries the machine index and token digest; body evidence is + * parsed from each `### dimension` block and joined in by `applyBody`. + */ +export interface BodyData { + /** From `# Character` — authoritative source for DesignObservation.summary */ + character?: string; + /** From `# Signature` — recognizable output posture and dominant moves */ + signature?: string; + /** From `# Decisions` `### slug` blocks — dimension + prose rationale + evidence */ + decisions?: DesignDecision[]; +} + +type Section = { heading: string; level: number; body: string }; + +/** + * Split a markdown string into sections at exactly the requested heading level. + * Deeper headings (e.g. `##`, `###` when level=1) stay inside the section body; + * shallower headings end the section. Content before the first matching heading + * is discarded. + */ +function sectionsAt(md: string, level: number): Section[] { + const lines = md.split("\n"); + const out: Section[] = []; + let current: Section | null = null; + const buf: string[] = []; + const flush = () => { + if (current) { + current.body = buf.join("\n").trim(); + out.push(current); + buf.length = 0; + } + }; + for (const line of lines) { + const m = /^(#{1,6})\s+(.*?)\s*$/.exec(line); + if (m && m[1].length === level) { + flush(); + current = { heading: m[2], level, body: "" }; + } else if (m && m[1].length < level) { + flush(); + current = null; + } else if (current) { + buf.push(line); + } + } + flush(); + return out; +} + +/** Pull bullet items (`- foo`, `* foo`) from a block of markdown. */ +function parseBullets(block: string): string[] { + return block + .split("\n") + .map((l) => l.match(/^\s*[-*]\s+(.*)$/)?.[1]) + .filter((x): x is string => !!x && x.length > 0) + .map((s) => s.replace(/\s+$/, "")); +} + +function slug(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Parse a `### Dimension\nprose…\n**Evidence:**\n- …` block. + * + * Schema 5: evidence lives in the body as a `**Evidence:**` bullet list + * following the rationale prose. Backtick fencing used for citation + * formatting is stripped so the serialized value matches the in-memory + * one (round-trip safe). + */ +function parseDecision(sec: Section): DesignDecision { + const evidenceRe = /\*\*Evidence:\*\*\s*([\s\S]*)$/i; + const match = sec.body.match(evidenceRe); + const prose = sec.body.replace(evidenceRe, "").trim(); + const evidence = match ? parseBullets(match[1]).map(unfence) : []; + return { + dimension: slug(sec.heading), + decision: prose, + evidence, + }; +} + +/** Remove surrounding backticks (citation fencing) added by the writer. */ +function unfence(s: string): string { + const trimmed = s.trim(); + if (trimmed.length >= 2 && trimmed.startsWith("`") && trimmed.endsWith("`")) { + return trimmed.slice(1, -1).replace(/\\`/g, "`"); + } + return trimmed; +} + +/** Parse a markdown body into structured BodyData. */ +export function parseBody(md: string): BodyData { + const out: BodyData = {}; + + for (const sec of sectionsAt(md, 1)) { + const h = sec.heading.toLowerCase(); + if (h.startsWith("character")) { + out.character = sec.body; + } else if (h.startsWith("signature")) { + out.signature = sec.body; + } else if (h.startsWith("decisions")) { + const blocks = sectionsAt(sec.body, 3); + if (blocks.length) out.decisions = blocks.map(parseDecision); + } + // Other H1 sections are ignored. + } + return out; +} diff --git a/packages/ghost/src/scan/compose.ts b/packages/ghost/src/scan/compose.ts new file mode 100644 index 0000000..ad3b250 --- /dev/null +++ b/packages/ghost/src/scan/compose.ts @@ -0,0 +1,103 @@ +import type { DesignDecision, Fingerprint } from "#ghost-core"; + +/** + * Merge an overlay fingerprint on top of a base fingerprint. Precedence rules: + * + * • Scalars / arrays → overlay replaces when present, else base + * • decisions → merged by `dimension` slug; overlay wins per-dim, + * base-only decisions are preserved + * • palette.dominant/semantic → merged by `role`; overlay wins per-role, + * base-only roles preserved + * + * This mirrors the intent of declaring "this fingerprint is based on that one, + * with these specific changes" — untouched base decisions remain, while + * overrides swap in cleanly. + */ +export function mergeFingerprint( + base: Fingerprint, + overlay: Partial, +): Fingerprint { + const merged: Fingerprint = { + ...base, + ...stripUndefined(overlay), + }; + + if (base.decisions || overlay.decisions) { + merged.decisions = mergeByKey( + base.decisions ?? [], + overlay.decisions ?? [], + (d) => d.dimension, + ); + } + + if (base.palette || overlay.palette) { + const basePalette = base.palette; + const overlayPalette = overlay.palette; + merged.palette = { + ...(basePalette ?? emptyPalette()), + ...(overlayPalette ?? {}), + dominant: mergeByKey( + basePalette?.dominant ?? [], + overlayPalette?.dominant ?? [], + (c) => c.role, + ), + semantic: mergeByKey( + basePalette?.semantic ?? [], + overlayPalette?.semantic ?? [], + (c) => c.role, + ), + // neutrals / saturationProfile / contrast: overlay replaces if present + neutrals: overlayPalette?.neutrals ?? + basePalette?.neutrals ?? { steps: [], count: 0 }, + saturationProfile: + overlayPalette?.saturationProfile ?? + basePalette?.saturationProfile ?? + "muted", + contrast: overlayPalette?.contrast ?? basePalette?.contrast ?? "moderate", + }; + } + + return merged; +} + +function mergeByKey(base: T[], overlay: T[], key: (item: T) => string): T[] { + const overlayByKey = new Map(overlay.map((item) => [key(item), item])); + const out: T[] = []; + const seen = new Set(); + + // Base order first, with overlay overrides slotted in place + for (const item of base) { + const k = key(item); + seen.add(k); + const override = overlayByKey.get(k); + out.push(override ?? item); + } + // Overlay-only entries appended at the end + for (const item of overlay) { + const k = key(item); + if (!seen.has(k)) out.push(item); + } + return out; +} + +function stripUndefined(obj: T): Partial { + const out: Partial = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) (out as Record)[k] = v; + } + return out; +} + +function emptyPalette(): Fingerprint["palette"] { + return { + dominant: [], + neutrals: { steps: [], count: 0 }, + semantic: [], + saturationProfile: "muted", + contrast: "moderate", + }; +} + +// Re-export the decision type so callers writing their own merges don't +// need to reach into ../types. +export type { DesignDecision }; diff --git a/packages/ghost/src/scan/constants.ts b/packages/ghost/src/scan/constants.ts new file mode 100644 index 0000000..44c3b55 --- /dev/null +++ b/packages/ghost/src/scan/constants.ts @@ -0,0 +1,29 @@ +/** Canonical directory for the Ghost fingerprint package. */ +export const FINGERPRINT_PACKAGE_DIR = ".ghost"; + +/** Canonical filename for scan resource references. */ +export const RESOURCES_FILENAME = "resources.yml"; + +/** Canonical filename for operational composition grammar. */ +export const PATTERNS_FILENAME = "patterns.yml"; + +/** Optional filename for human-authored or human-approved intent. */ +export const INTENT_FILENAME = "intent.md"; + +/** Legacy direct fingerprint filename. Not part of the root package shape. */ +export const FINGERPRINT_FILENAME = "fingerprint.md"; + +/** Directory containing scoped fingerprint overlays. */ +export const FINGERPRINTS_DIRNAME = "fingerprints"; + +/** Directory containing per-scope survey artifacts. */ +export const SCOPE_SURVEYS_DIRNAME = "modules"; + +/** Canonical filename for human-promoted deterministic gates. */ +export const CHECKS_FILENAME = "checks.yml"; + +/** Optional directory containing accepted/rejected product-experience decisions. */ +export const DECISIONS_DIRNAME = "decisions"; + +/** Optional directory containing candidate product-experience memory changes. */ +export const PROPOSALS_DIRNAME = "proposals"; diff --git a/packages/ghost/src/scan/context/checks.ts b/packages/ghost/src/scan/context/checks.ts new file mode 100644 index 0000000..6622c50 --- /dev/null +++ b/packages/ghost/src/scan/context/checks.ts @@ -0,0 +1,86 @@ +import type { Check, DriftSeverity, Fingerprint } from "#ghost-core"; +import { + computeCheckSeverity, + resolveMatchShape, + resolveTolerance, +} from "#ghost-core"; + +export interface ResolvedCheck { + check: Check; + surveyCount: number; + severity: DriftSeverity; + match: string; + tolerance: number | undefined; +} + +const SEVERITY_ORDER: Record = { + critical: 0, + serious: 1, + nit: 2, +}; + +export function resolveFingerprintChecks(fp: Fingerprint): ResolvedCheck[] { + return (fp.checks ?? []).map((check) => { + const surveyCount = surveyCountForCheck(check, fp); + return { + check, + surveyCount, + severity: computeCheckSeverity(check, surveyCount), + match: resolveMatchShape(check), + tolerance: resolveTolerance(check), + }; + }); +} + +export function bySeverityThenId(a: ResolvedCheck, b: ResolvedCheck): number { + return ( + SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || + a.check.id.localeCompare(b.check.id) + ); +} + +/** + * Use check-authored observed counts when present. Otherwise fall back to a + * coarse proxy for survey-count per canonical dimension, derived from the + * structured frontmatter fields. v0 fingerprints don't carry the survey + * directly; the proxy keeps presence-floor escalation deterministic until + * the check author supplies `observed_count`. + */ +export function surveyCountForCheck(check: Check, fp: Fingerprint): number { + if (typeof check.observed_count === "number") return check.observed_count; + + switch (check.canonical) { + case "color-strategy": + return ( + fp.palette.dominant.length + + fp.palette.neutrals.count + + fp.palette.semantic.length + ); + case "surface-hierarchy": + return fp.palette.semantic.length + fp.palette.dominant.length; + case "shape-language": + return fp.surfaces.borderRadii.length; + case "elevation": + return fp.surfaces.shadowComplexity === "deliberate-none" + ? 0 + : fp.surfaces.shadowComplexity === "subtle" + ? 2 + : 5; + case "spatial-system": + case "density": + return fp.spacing.scale.length; + case "typography-voice": + return fp.typography.sizeRamp.length; + case "font-sourcing": + return fp.typography.families.length; + case "motion": + // Motion isn't in structured fields; default to a count above + // typical floors so escalation only happens via explicit author + // hint (check.presence_floor: 2+). + return 100; + default: + // Unknown canonical -> leave room above floor 0 so escalation + // doesn't fire incorrectly, but author can override via floor. + return 100; + } +} diff --git a/packages/ghost/src/scan/context/index.ts b/packages/ghost/src/scan/context/index.ts new file mode 100644 index 0000000..f7d965c --- /dev/null +++ b/packages/ghost/src/scan/context/index.ts @@ -0,0 +1,11 @@ +export type { WritePackageContextOptions } from "./package-writer.js"; +export { writePackageContextBundle } from "./package-writer.js"; +export type { EmitReviewInput } from "./review-command.js"; +export { emitReviewCommand } from "./review-command.js"; +export { buildTokensCss } from "./tokens-css.js"; +export type { + ContextFormat, + WriteContextOptions, + WriteContextResult, +} from "./writer.js"; +export { buildSkillMd, writeContextBundle } from "./writer.js"; diff --git a/packages/ghost/src/scan/context/package-writer.ts b/packages/ghost/src/scan/context/package-writer.ts new file mode 100644 index 0000000..28b2fff --- /dev/null +++ b/packages/ghost/src/scan/context/package-writer.ts @@ -0,0 +1,276 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { + formatSurveySummaryMarkdown, + lintSurvey, + type Survey, + summarizeSurvey, +} from "#ghost-core"; +import type { FingerprintPackagePaths } from "../fingerprint-package.js"; +import type { WriteContextResult } from "./writer.js"; + +export interface WritePackageContextOptions { + outDir: string; + /** Override the skill/package name. Default: resources.yml id. */ + name?: string; + /** Emit only prompt.md. Default: false. */ + promptOnly?: boolean; + /** Include README.md. Default: false. */ + readme?: boolean; +} + +interface PackageContext { + name: string; + resources: string; + map: string; + surveySummary: string; + patterns: string; + checks?: string; + intent?: string; +} + +export async function writePackageContextBundle( + paths: FingerprintPackagePaths, + options: WritePackageContextOptions, +): Promise { + const context = await loadPackageContext(paths, options.name); + await mkdir(options.outDir, { recursive: true }); + const files: string[] = []; + + const promptPath = join(options.outDir, "prompt.md"); + await writeFile(promptPath, buildPackagePromptMd(context), "utf-8"); + files.push(promptPath); + + if (options.promptOnly) { + return { outDir: options.outDir, files }; + } + + const skillPath = join(options.outDir, "SKILL.md"); + await writeFile(skillPath, buildPackageSkillMd(context), "utf-8"); + files.push(skillPath); + + await writeContextFile( + options.outDir, + files, + "resources.yml", + context.resources, + ); + await writeContextFile(options.outDir, files, "map.md", context.map); + await writeContextFile( + options.outDir, + files, + "survey-summary.md", + context.surveySummary, + ); + await writeContextFile( + options.outDir, + files, + "patterns.yml", + context.patterns, + ); + if (context.checks) { + await writeContextFile(options.outDir, files, "checks.yml", context.checks); + } + if (context.intent) { + await writeContextFile(options.outDir, files, "intent.md", context.intent); + } + if (options.readme) { + await writeContextFile( + options.outDir, + files, + "README.md", + buildPackageReadmeMd(context), + ); + } + + return { outDir: options.outDir, files }; +} + +async function loadPackageContext( + paths: FingerprintPackagePaths, + nameOverride?: string, +): Promise { + const [resources, map, surveyRaw, patterns, checks, intent] = + await Promise.all([ + readFile(paths.resources, "utf-8"), + readFile(paths.map, "utf-8"), + readFile(paths.survey, "utf-8"), + readFile(paths.patterns, "utf-8"), + readOptional(paths.checks), + readOptional(paths.intent), + ]); + + const survey = parseSurvey(surveyRaw); + const report = lintSurvey(survey); + if (report.errors > 0) { + throw new Error( + `survey.json failed lint with ${report.errors} error(s); fix before emitting a context bundle.`, + ); + } + + return { + name: sanitizeName(nameOverride ?? inferPackageName(resources, map)), + resources, + map, + surveySummary: formatSurveySummaryMarkdown( + summarizeSurvey(survey, { budget: "compact" }), + ), + patterns, + checks, + intent, + }; +} + +function parseSurvey(raw: string): Survey { + try { + return JSON.parse(raw) as Survey; + } catch (err) { + throw new Error( + `survey.json is not valid JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +async function readOptional(path: string): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + return undefined; + } +} + +async function writeContextFile( + outDir: string, + files: string[], + name: string, + content: string, +): Promise { + const outPath = join(outDir, name); + await writeFile(outPath, ensureTrailingNewline(content), "utf-8"); + files.push(outPath); +} + +function inferPackageName(resources: string, map: string): string { + const fromResources = parseYamlSafe(resources); + if (isRecord(fromResources) && typeof fromResources.id === "string") { + return fromResources.id; + } + + const frontmatter = map.match(/^---\n([\s\S]*?)\n---/)?.[1]; + if (frontmatter) { + const fromMap = parseYamlSafe(frontmatter); + if (isRecord(fromMap) && typeof fromMap.id === "string") { + return fromMap.id; + } + } + + return "ghost-package"; +} + +function parseYamlSafe(raw: string): unknown { + try { + return parseYaml(raw); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function sanitizeName(value: string): string { + const name = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return name || "ghost-package"; +} + +function buildPackageSkillMd(context: PackageContext): string { + return `--- +name: ${context.name} +description: Use this root Ghost fingerprint package to preserve design identity during UI generation and review. +user-invocable: true +--- + +This skill grounds work in the **${context.name}** root Ghost fingerprint package. + +Read the files in this order: + +1. \`intent.md\` when present - human-approved direction. +2. \`map.md\` - topology, scopes, surface families, and routing. +3. \`patterns.yml\` - operational composition grammar backed by survey evidence. +4. \`checks.yml\` when present - deterministic gates or proposed gates. +5. \`survey-summary.md\` - compact evidence digest from \`survey.json\`. +6. \`resources.yml\` - what counted as source evidence. + +When generating or reviewing UI, identify the map scope and surface type first, +then apply matching composition patterns. Treat survey rows as evidence, not +taste. Treat checks as gates only when their status is \`active\`; proposed +checks are advisory until promoted by a human. +`; +} + +function buildPackagePromptMd(context: PackageContext): string { + const parts = [ + `You are working inside the **${context.name}** design language as captured by a root Ghost fingerprint package.`, + ]; + + if (context.intent?.trim()) { + parts.push(`# Intent\n\n${context.intent.trim()}`); + } + + parts.push(`# Use The Package + +- Start with \`map.md\` to route the task to scopes, surface families, and examples. +- Use \`patterns.yml\` for composition decisions; cite evidence when reviewing. +- Use \`checks.yml\` for deterministic gates. Only \`active\` checks block. +- Use \`survey-summary.md\` for observed tokens, values, components, and surfaces. +- Use \`resources.yml\` to understand what evidence was included or excluded. +- Do not invent tokens, components, or surface patterns when the package provides an observed option.`); + + parts.push(`# Package Files + +- \`resources.yml\` +- \`map.md\` +- \`survey-summary.md\` +- \`patterns.yml\` +${context.checks ? "- `checks.yml`\n" : ""}${context.intent ? "- `intent.md`\n" : ""}`); + + parts.push(`# Review Posture + +Before calling drift, classify the changed file by \`map.md\` scope. For UI +generation, preserve matching \`patterns.yml\` anatomy and prefer values from +the survey's token/value digest. If a divergence is intentional, name it in the +package rather than hiding it in generated UI.`); + + return `${parts.join("\n\n")}\n`; +} + +function buildPackageReadmeMd(context: PackageContext): string { + return `# ${context.name} context bundle + +Generated by \`ghost emit context-bundle\` from a root Ghost +fingerprint package. + +## Files + +- \`SKILL.md\` - agent skill manifest. +- \`prompt.md\` - portable prompt distilled from the package. +- \`resources.yml\` - evidence sources. +- \`map.md\` - topology and routing. +- \`survey-summary.md\` - compact survey evidence. +- \`patterns.yml\` - composition grammar. +${context.checks ? "- `checks.yml` - deterministic gates and proposed gates.\n" : ""}${context.intent ? "- `intent.md` - human-approved direction.\n" : ""} +The full \`survey.json\` stays in the source package by default because it can +be large; regenerate this bundle when the survey changes. +`; +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith("\n") ? value : `${value}\n`; +} diff --git a/packages/ghost/src/scan/context/review-command.ts b/packages/ghost/src/scan/context/review-command.ts new file mode 100644 index 0000000..76741ab --- /dev/null +++ b/packages/ghost/src/scan/context/review-command.ts @@ -0,0 +1,498 @@ +import type { DriftSeverity, Fingerprint } from "#ghost-core"; +import { tierForCanonical } from "#ghost-core"; +import { type ResolvedCheck, resolveFingerprintChecks } from "./checks.js"; + +export interface EmitReviewInput { + fingerprint: Fingerprint; +} + +/** + * Emit a project-fitted drift-review slash command from a fingerprint. + * + * Produces a single Markdown file styled after Rams' `/rams` slash command + * — role prompt, per-dimension check tables, output template, guidelines — + * populated with this fingerprint's actual palette, radii, spacing, and + * typography values. Default output path: `.claude/commands/design-review.md`. + * + * Scope is drift-only: off-palette hex, off-ramp spacing, non-canonical + * radii and weights. Universal accessibility checks are out of scope — + * those belong in Rams or a sibling a11y skill. + * + * Two emission paths: + * - **Checks-driven** (legacy programmatic input): when `fingerprint.checks[]` + * is non-empty, group checks by computed perceptual severity and render a + * Critical / Serious / Nit layout. + * - **Structured-fallback**: for normal fingerprint.md inputs, + * emit the original palette/radius/spacing/typography sections + * derived from frontmatter alone. Preserved verbatim so existing + * fingerprints keep working through the v0 transition. + * + * Pure: deterministic over the same fingerprint. The fingerprint is + * expected to be the unioned result of `loadFingerprint` — body prose + * (Character summary, per-decision rationale) is already folded into + * `observation.summary` and `decisions[].decision`. + */ +export function emitReviewCommand(input: EmitReviewInput): string { + const { fingerprint: fp } = input; + + if (fp.checks && fp.checks.length > 0) { + return emitChecksDriven(fp); + } + + return emitStructuredFallback(fp); +} + +// --- Checks-driven path ------------------------------------------------- + +/** + * Render a checks[]-driven slash command. Groups checks by computed + * severity, renders one block per check with pattern + match shape, then + * closes with a calibration footer that explains *why* + * severities landed where they did. The calibration footer is what makes + * Ghost's reviewer legibly different from a generic linter — the prior + * is visible, not opaque. + */ +function emitChecksDriven(fp: Fingerprint): string { + const id = fp.id; + const personality = (fp.observation?.personality ?? []).join(", "); + const cousins = (fp.observation?.resembles ?? []).join(", "); + const character = fp.observation?.summary?.trim() ?? ""; + + const resolved = resolveFingerprintChecks(fp); + + const grouped: Record = { + critical: [], + serious: [], + nit: [], + }; + for (const r of resolved) grouped[r.severity].push(r); + + const sections: string[] = []; + if (grouped.critical.length) { + sections.push(renderSeverityBlock("Critical", grouped.critical)); + } + if (grouped.serious.length) { + sections.push(renderSeverityBlock("Serious", grouped.serious)); + } + if (grouped.nit.length) { + sections.push(renderSeverityBlock("Nit", grouped.nit)); + } + + const parts = [ + frontmatter(id), + header(id, personality, cousins, character), + modeSection(), + ...sections, + outputTemplate(id), + guidelines(), + calibrationFooter(fp, resolved), + ]; + return `${parts.filter(Boolean).join("\n\n").trim()}\n`; +} + +function renderSeverityBlock(label: string, items: ResolvedCheck[]): string { + const lines: string[] = [`## ${label} (${items.length})`]; + for (const item of items) { + lines.push("", renderCheck(item)); + } + return lines.join("\n"); +} + +function renderCheck(item: ResolvedCheck): string { + const { check, match, tolerance } = item; + const heading = check.canonical + ? `### \`${check.id}\` — ${check.canonical}` + : `### \`${check.id}\``; + const lines: string[] = [heading]; + if (check.summary) lines.push("", check.summary); + lines.push("", `**Pattern:** \`${check.pattern}\``); + + const matchLine = + tolerance !== undefined + ? `**Match:** \`${match}\` (tolerance: \`${tolerance}\`)` + : `**Match:** \`${match}\``; + lines.push(matchLine); + + if (check.paths?.length) { + const where = check.paths.map((e) => `\`${e}\``).join(", "); + lines.push(`**Paths:** ${where}`); + } + if (check.contexts?.length) { + const where = check.contexts.map((e) => `\`${e}\``).join(", "); + lines.push(`**Contexts:** ${where}`); + } + if (typeof check.observed_count === "number") { + lines.push(`**Observed count:** ${check.observed_count}`); + } + if (typeof check.support === "number") { + lines.push(`**Support:** ${(check.support * 100).toFixed(0)}%`); + } + return lines.join("\n"); +} + +function calibrationFooter(fp: Fingerprint, resolved: ResolvedCheck[]): string { + const tierCounts = { loud: 0, structural: 0, rhythmic: 0 }; + const finalCounts: Record = { + critical: 0, + serious: 0, + nit: 0, + }; + const escalated: string[] = []; + + for (const r of resolved) { + const baseTier = tierForCanonical(r.check.canonical); + tierCounts[baseTier]++; + finalCounts[r.severity]++; + const finalTierFromSeverity = + r.severity === "critical" + ? "loud" + : r.severity === "serious" + ? "structural" + : "rhythmic"; + if ( + finalTierFromSeverity !== baseTier && + r.check.severity === undefined // not a manual override + ) { + const floor = r.check.presence_floor ?? 0; + escalated.push(`\`${r.check.id}\` (${r.surveyCount} ≤ ${floor})`); + } + } + + const lines: string[] = [ + "## How this reviewer was calibrated", + "", + `Severity grouping reflects perceptual weight, not arithmetic. After overrides and presence-floor escalation, \`${fp.id}\` has ${finalCounts.critical} critical, ${finalCounts.serious} serious, and ${finalCounts.nit} nit checks. Base prior before escalation: ${tierCounts.loud} loud-tier, ${tierCounts.structural} structural-tier, and ${tierCounts.rhythmic} rhythmic-tier.`, + ]; + if (escalated.length) { + lines.push( + "", + `**Presence-floor escalation triggered for:** ${escalated.join(", ")}. These guarded patterns are absent or near-silent in the survey, so adding to them lands one perceptual tier louder than the base dimension.`, + ); + } + lines.push( + "", + "Color and font-family checks are loud (critical) by default. Shape, elevation, surface, and interactive-pattern checks are structural (serious). Spacing, density, motion-detail, and theming checks are rhythmic (nit).", + "", + `Generated from a fingerprint object (${(fp.checks ?? []).length} legacy checks). Re-run \`ghost emit review-command\` after package updates.`, + ); + return lines.join("\n"); +} + +// --- Structured-fallback path (legacy) --------------------------------- + +function emitStructuredFallback(fp: Fingerprint): string { + const id = fp.id; + const personality = (fp.observation?.personality ?? []).join(", "); + const cousins = (fp.observation?.resembles ?? []).join(", "); + const character = fp.observation?.summary?.trim() ?? ""; + + const parts = [ + frontmatter(id), + header(id, personality, cousins, character), + modeSection(), + structuredFallbackNotice(), + paletteSection(fp), + radiusSection(fp), + spacingSection(fp), + typographySection(fp), + otherDimensions(fp), + outputTemplate(id), + guidelines(), + footer(fp), + ]; + return `${parts.filter(Boolean).join("\n\n").trim()}\n`; +} + +function structuredFallbackNotice(): string { + return `## Calibration note + +This fingerprint has no embedded checks, so this command uses a coarse token fallback from palette, spacing, typography, and surfaces. Treat findings as lower-confidence than \`ghost check\`; promote enforceable rules in \`.ghost/checks.yml\` when a pattern should become a gate.`; +} + +function frontmatter(id: string): string { + return `--- +description: Drift review for ${id} — fitted to this fingerprint's design language +---`; +} + +function header( + id: string, + personality: string, + cousins: string, + character: string, +): string { + const taste = personality + ? `This system reads as *${personality}*${cousins ? ` — closest cousins: ${cousins}` : ""}.` + : ""; + const lines = [`# ${id} drift review`, ""]; + lines.push( + `You are a drift reviewer for the **${id}** design language. ${taste}`.trim(), + ); + if (character) lines.push("", character); + lines.push( + "", + "Your job: check code for **drift** from the values below — hardcoded hexes, off-ramp spacing, typography outside the scale, radii outside the set. You are **not** checking accessibility or universal design rules; use `/rams` or a dedicated a11y skill for that.", + ); + return lines.join("\n"); +} + +function modeSection(): string { + return `## Mode + +If \`$ARGUMENTS\` is provided, analyze that specific file. +If \`$ARGUMENTS\` is empty, ask the user which file(s) to review, or offer to scan recently changed components.`; +} + +// --- Palette ------------------------------------------------------------ + +const TRUE_SEMANTIC_ROLES = new Set([ + "danger", + "success", + "info", + "warning", + "error", +]); + +function paletteSection(fp: Fingerprint): string { + const allowed = allowedPalette(fp); + const allowedList = allowed.map((h) => `\`${h}\``).join(", "); + const dominant = fp.palette.dominant + .map((c) => `\`${c.value}\` (${c.role})`) + .join(", "); + const neutrals = fp.palette.neutrals.steps.map((h) => `\`${h}\``).join(", "); + const semantic = fp.palette.semantic.filter((c) => + TRUE_SEMANTIC_ROLES.has(c.role), + ); + const rationale = findRationale(fp, ["color-strategy", "palette", "color"]); + + const lines: string[] = []; + lines.push("## 1. Palette drift"); + if (rationale) lines.push("", `> ${rationale}`); + lines.push( + "", + `**Allowed colors** (${allowed.length} total — prefer semantic tokens over raw hex):`, + "", + `- Dominant: ${dominant}`, + `- Neutrals (ramp): ${neutrals}`, + ); + if (semantic.length) { + const sem = semantic.map((c) => `\`${c.value}\` (${c.role})`).join(", "); + lines.push(`- Semantic hues: ${sem}`); + } + lines.push( + "", + "### Critical", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + `| Off-palette hex in JSX/CSS | ${truncateList(allowedList, 120)} | Any \`#[0-9a-fA-F]{3,8}\` literal not in the allowed list |`, + "| Tailwind arbitrary color | use semantic tokens | `bg-[#...]`, `text-[#...]`, `border-[#...]` with arbitrary hex |", + ); + if (semantic.length) { + lines.push( + "| Named Tailwind color for semantic role | use semantic token | `text-red-500`, `bg-green-600`, etc. when a matching semantic token exists |", + "", + "### Serious", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + ); + for (const c of semantic) { + lines.push( + `| ${c.role} must use the semantic token | \`${c.value}\` | Raw \`${c.value}\` or near-equivalent hardcoded; prefer the \`${c.role}\` token |`, + ); + } + } + return lines.join("\n"); +} + +function allowedPalette(fp: Fingerprint): string[] { + const all = [ + ...fp.palette.dominant.map((c) => c.value), + ...fp.palette.neutrals.steps, + ...fp.palette.semantic.map((c) => c.value), + ]; + return [...new Set(all.map((h) => h.toLowerCase()))]; +} + +// --- Radius ------------------------------------------------------------- + +function radiusSection(fp: Fingerprint): string { + const radii = fp.surfaces.borderRadii; + if (!radii?.length) return ""; + const labeled = radii.map((r) => (r >= 999 ? "999px (pill)" : `${r}px`)); + const allowedList = labeled.map((r) => `\`${r}\``).join(", "); + const rationale = findRationale(fp, ["shape-language", "shape", "radius"]); + const hasPill = radii.some((r) => r >= 999); + + const lines: string[] = ["## 2. Shape language (radius)"]; + if (rationale) lines.push("", `> ${rationale}`); + lines.push( + "", + `**Allowed radii**: ${allowedList}`, + "", + "### Critical", + "", + "| Check | Allowed | What to look for |", + "|-------|---------|------------------|", + `| Custom radius value | ${allowedList} | \`rounded-[Npx]\`, \`border-radius: Npx\`, or \`--radius: Npx\` outside the set |`, + ); + if (hasPill) { + lines.push( + "| Interactive element not pill | `rounded-full` / `rounded-pill` | `