From ed1ec814c78819c3d87f62911d444b26362f9334 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 00:02:03 -0400 Subject: [PATCH 1/7] refactor: remove dead survey module (ghost.survey/v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey was pre-graph raw-signal capture with no live CLI command — its dispatch in fingerprint-commands was all dead _-prefixed code, reachable only as a vestigial .json lint kind. Deleted: ghost-core/survey/, the survey exports, the dead survey-pattern dispatch (~300 lines in fingerprint-commands), the .json/survey file-kind, an orphan ghost-core/fingerprint-package.ts, and 6 survey tests. Also clears ~150 'surface' false-positives (ui_surfaces color vocab) ahead of the surface->node unification. --- packages/ghost/src/fingerprint-commands.ts | 317 +----------- .../src/ghost-core/fingerprint-package.ts | 26 - packages/ghost/src/ghost-core/index.ts | 94 ---- .../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 ----------- packages/ghost/src/scan/file-kind.ts | 54 +- .../ghost/src/scan/fingerprint-package.ts | 3 - .../test/ghost-core/survey-catalog.test.ts | 119 ----- .../test/ghost-core/survey-fix-ids.test.ts | 102 ---- .../ghost/test/ghost-core/survey-id.test.ts | 145 ------ .../ghost/test/ghost-core/survey-lint.test.ts | 351 ------------- .../test/ghost-core/survey-merge.test.ts | 206 -------- .../test/ghost-core/survey-summary.test.ts | 229 --------- scripts/check-file-sizes.mjs | 2 +- 26 files changed, 17 insertions(+), 4001 deletions(-) delete mode 100644 packages/ghost/src/ghost-core/fingerprint-package.ts delete mode 100644 packages/ghost/src/ghost-core/survey/catalog-format.ts delete mode 100644 packages/ghost/src/ghost-core/survey/catalog-types.ts delete mode 100644 packages/ghost/src/ghost-core/survey/catalog.ts delete mode 100644 packages/ghost/src/ghost-core/survey/fix-ids.ts delete mode 100644 packages/ghost/src/ghost-core/survey/id.ts delete mode 100644 packages/ghost/src/ghost-core/survey/index.ts delete mode 100644 packages/ghost/src/ghost-core/survey/lint.ts delete mode 100644 packages/ghost/src/ghost-core/survey/merge.ts delete mode 100644 packages/ghost/src/ghost-core/survey/schema.ts delete mode 100644 packages/ghost/src/ghost-core/survey/summary-budget.ts delete mode 100644 packages/ghost/src/ghost-core/survey/summary-format.ts delete mode 100644 packages/ghost/src/ghost-core/survey/summary-types.ts delete mode 100644 packages/ghost/src/ghost-core/survey/summary.ts delete mode 100644 packages/ghost/src/ghost-core/survey/types.ts delete mode 100644 packages/ghost/test/ghost-core/survey-catalog.test.ts delete mode 100644 packages/ghost/test/ghost-core/survey-fix-ids.test.ts delete mode 100644 packages/ghost/test/ghost-core/survey-id.test.ts delete mode 100644 packages/ghost/test/ghost-core/survey-lint.test.ts delete mode 100644 packages/ghost/test/ghost-core/survey-merge.test.ts delete mode 100644 packages/ghost/test/ghost-core/survey-summary.test.ts diff --git a/packages/ghost/src/fingerprint-commands.ts b/packages/ghost/src/fingerprint-commands.ts index 215b25b3..56a3d589 100644 --- a/packages/ghost/src/fingerprint-commands.ts +++ b/packages/ghost/src/fingerprint-commands.ts @@ -1,12 +1,6 @@ import { readFile, stat } from "node:fs/promises"; import { resolve } from "node:path"; import type { CAC } from "cac"; -import { stringify as stringifyYaml } from "yaml"; -import type { - GhostPatternsDocument, - Survey, - SurveySummaryBudget, -} from "#ghost-core"; import { type LintReport, lintFingerprintPackage, @@ -19,14 +13,9 @@ import { resolveGhostDirDefault, scanStatus, signals } from "./scan/index.js"; /** * Register fingerprint package commands on the unified Ghost CLI. * - * Verbs author and validate the root `.ghost/` fingerprint package: - * `lint` (schema check, auto-detects file kind), `verify` (cross-artifact - * fidelity), `describe` (section ranges + token estimates for direct - * fingerprint markdown), `diff` (structural intent-level diff between direct - * fingerprint files), `emit` (derive review-command artifacts), and `survey` - * operations for deterministic `ghost.survey/v1` - * merge, ID repair, bounded summary output, derived value catalogs, and - * operational pattern synthesis. + * Verbs author and validate the root `.ghost/` fingerprint package: `validate` + * (artifact shape + node-graph integrity), `scan` (node/surface contribution), + * and `signals` (raw repo signals for authoring). */ export function registerFingerprintCommands(cli: CAC): void { // --- validate (shape pass + graph pass) --- @@ -187,303 +176,3 @@ async function isDirectory(path: string): Promise { return false; } } - -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 checks belong in validate.yml.", - ], - })), - advisory: { - review_expectations: surveyPatternReviewExpectations(survey), - }, - }; -} - -function surveyPatternReviewExpectations(survey: Survey): string[] { - if (survey.ui_surfaces.length === 0) { - return [ - "No UI surface evidence is present; do not infer product composition patterns from values, tokens, or components alone.", - "Use survey values, tokens, and components as implementation vocabulary until implemented product surfaces are observed.", - "Treat intent.yml, inventory.yml, and composition.yml as canonical authoring facets.", - ]; - } - - const hasProductSurface = survey.ui_surfaces.some((surface) => - isProductSurfaceKind(surface.kind), - ); - if (!hasProductSurface) { - return [ - "Treat story, fixture, and doc-example rows as component demonstration evidence, not product composition authority.", - "Cite matching composition_patterns[].evidence and survey.ui_surfaces evidence for advisory findings.", - "Treat intent.yml, inventory.yml, and composition.yml as canonical authoring facets.", - ]; - } - - return [ - "Identify the surface type before assessing composition.", - "Cite matching composition_patterns[].evidence and survey.ui_surfaces evidence for advisory findings.", - "Treat intent.yml, inventory.yml, and composition.yml as canonical authoring facets.", - ]; -} - -function isProductSurfaceKind(kind: string): boolean { - return ( - kind === "route" || - kind === "screen" || - kind === "screenshot" || - kind === "source" - ); -} - -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/ghost-core/fingerprint-package.ts b/packages/ghost/src/ghost-core/fingerprint-package.ts deleted file mode 100644 index 294b48ab..00000000 --- a/packages/ghost/src/ghost-core/fingerprint-package.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 FINGERPRINT_YML_FILENAME = "fingerprint.yml" as const; -export const FINGERPRINT_MANIFEST_FILENAME = "manifest.yml" as const; -export const FINGERPRINT_INTENT_FILENAME = "intent.yml" as const; -export const FINGERPRINT_INVENTORY_FILENAME = "inventory.yml" as const; -export const FINGERPRINT_COMPOSITION_FILENAME = "composition.yml" as const; -export const FINGERPRINT_FILENAME = "fingerprint.md" as const; - -export interface FingerprintPackagePaths { - dir: string; - packageDir: string; - manifest: string; - intent: string; - inventory: string; - composition: string; - fingerprintYml: string; - resources: string; - map: string; - survey: string; - patterns: string; - /** Legacy direct markdown path; not part of the canonical root bundle. */ - fingerprint: string; - checks: string; -} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 36efff89..accc68d3 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -19,18 +19,6 @@ export { selectChecksForSurfaces, } from "./check/index.js"; // --- Fingerprint package filenames --- -export { - FINGERPRINT_COMPOSITION_FILENAME, - FINGERPRINT_FILENAME, - FINGERPRINT_INTENT_FILENAME, - FINGERPRINT_INVENTORY_FILENAME, - FINGERPRINT_MANIFEST_FILENAME, - FINGERPRINT_PACKAGE_DIR, - FINGERPRINT_YML_FILENAME, - type FingerprintPackagePaths, - PATTERNS_FILENAME, - RESOURCES_FILENAME, -} from "./fingerprint-package.js"; // --- Graph (in-memory fingerprint node graph) --- export { type AssembleGraphInput, @@ -141,85 +129,3 @@ export { lintGhostSurfaces, type SurfaceMenuEntry, } from "./surfaces/index.js"; -// --- Survey (ghost.survey/v1) --- -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"; diff --git a/packages/ghost/src/ghost-core/survey/catalog-format.ts b/packages/ghost/src/ghost-core/survey/catalog-format.ts deleted file mode 100644 index d74626c3..00000000 --- a/packages/ghost/src/ghost-core/survey/catalog-format.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index e0ba11e8..00000000 --- a/packages/ghost/src/ghost-core/survey/catalog-types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ValueSpec } from "./types.js"; - -export interface SurveyCatalogOptions { - kind?: string; -} - -export interface SurveyValueCatalog { - schema: "ghost.survey.catalog/v1"; - source_schema: "ghost.survey/v1"; - 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 deleted file mode 100644 index 12212b6b..00000000 --- a/packages/ghost/src/ghost-core/survey/catalog.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 88cd2c36..00000000 --- a/packages/ghost/src/ghost-core/survey/fix-ids.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 1816136e..00000000 --- a/packages/ghost/src/ghost-core/survey/id.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index b83af8dc..00000000 --- a/packages/ghost/src/ghost-core/survey/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Public surface for `ghost.survey/v1` — 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 deleted file mode 100644 index fd209d2a..00000000 --- a/packages/ghost/src/ghost-core/survey/lint.ts +++ /dev/null @@ -1,250 +0,0 @@ -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/v1`. - * - * 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 deleted file mode 100644 index bf36705d..00000000 --- a/packages/ghost/src/ghost-core/survey/merge.ts +++ /dev/null @@ -1,76 +0,0 @@ -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/v1", - 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 deleted file mode 100644 index fdc885ba..00000000 --- a/packages/ghost/src/ghost-core/survey/schema.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { z } from "zod"; - -/** - * Zod schemas for `ghost.survey/v1`. - * - * 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/v1"), - 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 deleted file mode 100644 index 84a1216d..00000000 --- a/packages/ghost/src/ghost-core/survey/summary-budget.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index a67665fa..00000000 --- a/packages/ghost/src/ghost-core/survey/summary-format.ts +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index bdd7367e..00000000 --- a/packages/ghost/src/ghost-core/survey/summary-types.ts +++ /dev/null @@ -1,169 +0,0 @@ -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/v1"; - 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 deleted file mode 100644 index b6ad0113..00000000 --- a/packages/ghost/src/ghost-core/survey/summary.ts +++ /dev/null @@ -1,472 +0,0 @@ -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 deleted file mode 100644 index 2a2f4c39..00000000 --- a/packages/ghost/src/ghost-core/survey/types.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Types for `ghost.survey/v1` — 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/v1"; - /** - * 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/scan/file-kind.ts b/packages/ghost/src/scan/file-kind.ts index 9ef1f26f..252e2bd6 100644 --- a/packages/ghost/src/scan/file-kind.ts +++ b/packages/ghost/src/scan/file-kind.ts @@ -6,13 +6,10 @@ import { lintGhostPatterns, lintGhostResources, lintGhostSurfaces, - lintSurvey, - type SurveyLintReport, } from "#ghost-core"; import type { LintReport } from "./lint.js"; export type DetectedFileKind = - | "survey" | "fingerprint-manifest" | "resources" | "patterns" @@ -30,7 +27,6 @@ export type DetectedFileKind = export function detectFileKind(path: string, raw: string): DetectedFileKind { const lowerPath = path.toLowerCase(); const filename = lowerPath.split(/[\\/]/).pop() ?? lowerPath; - if (lowerPath.endsWith(".json")) return "survey"; if (filename === "manifest.yml") { return "fingerprint-manifest"; } @@ -52,7 +48,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (filename.endsWith(".md") && /(^|[\\/])nodes[\\/]/.test(lowerPath)) { return "node"; } - if (raw.trimStart().startsWith("{")) return "survey"; if (/^\s*schema:\s*ghost\.fingerprint-package\/v1\b/m.test(raw)) { return "fingerprint-manifest"; } @@ -66,42 +61,19 @@ export function lintDetectedFileKind( kind: DetectedFileKind, raw: string, ): LintReport { - return kind === "survey" - ? lintSurveyFile(raw) - : kind === "fingerprint-manifest" - ? lintFingerprintManifestFile(raw) - : kind === "resources" - ? lintResourcesFile(raw) - : kind === "patterns" - ? lintPatternsFile(raw) - : kind === "surfaces" - ? lintSurfacesFile(raw) - : kind === "check" - ? lintGhostCheck(raw) - : kind === "node" - ? lintGhostNode(raw) - : lintUnsupportedFile(); -} - -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); + return kind === "fingerprint-manifest" + ? lintFingerprintManifestFile(raw) + : kind === "resources" + ? lintResourcesFile(raw) + : kind === "patterns" + ? lintPatternsFile(raw) + : kind === "surfaces" + ? lintSurfacesFile(raw) + : kind === "check" + ? lintGhostCheck(raw) + : kind === "node" + ? lintGhostNode(raw) + : lintUnsupportedFile(); } function lintFingerprintManifestFile(raw: string): LintReport { diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index e0174919..b08c6576 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -6,7 +6,6 @@ import { type GhostGraph, type GhostSurfacesDocument, lintGraph, - SURVEY_FILENAME, } from "#ghost-core"; import { isExistingPathError, isMissingPathError } from "../internal/fs.js"; import { @@ -39,7 +38,6 @@ export interface FingerprintPackagePaths { /** The `nodes/` directory holding `ghost.node/v1` markdown nodes. */ nodes: string; resources: string; - survey: string; patterns: string; /** Legacy facet paths — used only to detect legacy packages for migration. */ intent: string; @@ -81,7 +79,6 @@ export function resolveFingerprintPackage( surfaces: join(packageDir, GHOST_SURFACES_YML_FILENAME), nodes: join(packageDir, "nodes"), resources: join(dir, RESOURCES_FILENAME), - survey: join(dir, SURVEY_FILENAME), patterns: join(dir, PATTERNS_FILENAME), intent: join(packageDir, FINGERPRINT_INTENT_FILENAME), inventory: join(packageDir, FINGERPRINT_INVENTORY_FILENAME), diff --git a/packages/ghost/test/ghost-core/survey-catalog.test.ts b/packages/ghost/test/ghost-core/survey-catalog.test.ts deleted file mode 100644 index 765f63ba..00000000 --- a/packages/ghost/test/ghost-core/survey-catalog.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - catalogSurveyValues, - formatSurveyCatalogMarkdown, - type Survey, - type SurveySource, - type ValueRow, - valueRowId, -} from "#ghost-core"; - -const SOURCE: SurveySource = { - id: "app", - role: "primary", - target: "github:block/ghost", - commit: "abc123", - scanned_at: "2026-05-04T12:00:00Z", -}; - -function valueRow( - kind: string, - value: string, - raw: string, - occurrences: number, - overrides: Partial = {}, -): ValueRow { - return { - id: valueRowId(SOURCE, kind, value, raw), - source: SOURCE, - kind, - value, - raw, - occurrences, - files_count: Math.max(1, Math.floor(occurrences / 2)), - ...overrides, - }; -} - -function survey(): Survey { - return { - schema: "ghost.survey/v1", - sources: [SOURCE], - values: [ - valueRow("color", "#111111", "#111111", 4, { - usage: { css_var: 4 }, - role_hypothesis: "foreground", - }), - valueRow("color", "#111111", "text-foreground", 8, { - usage: { className: 8 }, - spec: { space: "srgb", hex: "#111111" }, - }), - valueRow("spacing", "8px", "p-2", 12, { - spec: { scalar: 8, unit: "px" }, - }), - valueRow("spacing", "16px", "p-4", 6, { - spec: { scalar: 16, unit: "px" }, - }), - valueRow("z-index", "10", "z-10", 1), - ], - tokens: [], - components: [], - ui_surfaces: [], - }; -} - -describe("catalogSurveyValues", () => { - it("aggregates duplicate values deterministically", () => { - const catalog = catalogSurveyValues(survey()); - - expect(catalog.schema).toBe("ghost.survey.catalog/v1"); - expect(catalog.counts).toEqual({ - kinds: 3, - values: 4, - rows: 5, - total_occurrences: 31, - }); - expect(catalog.kinds.map((kind) => kind.kind)).toEqual([ - "color", - "spacing", - "z-index", - ]); - - const color = catalog.kinds[0].values[0]; - expect(color).toMatchObject({ - value: "#111111", - rows: 2, - occurrences: 12, - files_count: 6, - raws: ["#111111", "text-foreground"], - usage: { className: 8, css_var: 4 }, - role_hypotheses: ["foreground"], - sources: ["app"], - }); - expect(color.ids).toEqual([...color.ids].sort()); - }); - - it("filters by kind without mutating ordering inside the kind", () => { - const catalog = catalogSurveyValues(survey(), { kind: "spacing" }); - - expect(catalog.filter).toEqual({ kind: "spacing" }); - expect(catalog.kinds.map((kind) => kind.kind)).toEqual(["spacing"]); - expect(catalog.kinds[0].values.map((value) => value.value)).toEqual([ - "8px", - "16px", - ]); - }); -}); - -describe("formatSurveyCatalogMarkdown", () => { - it("renders a compact value enum/spec view", () => { - const markdown = formatSurveyCatalogMarkdown(catalogSurveyValues(survey())); - - expect(markdown).toContain("# Survey Value Catalog"); - expect(markdown).toContain("## color"); - expect(markdown).toContain("`#111111`"); - expect(markdown).toContain("usage className:8,css_var:4"); - expect(markdown).toContain('spec {"hex":"#111111","space":"srgb"}'); - expect(markdown.length).toBeLessThan(8000); - }); -}); diff --git a/packages/ghost/test/ghost-core/survey-fix-ids.test.ts b/packages/ghost/test/ghost-core/survey-fix-ids.test.ts deleted file mode 100644 index bd2d17fc..00000000 --- a/packages/ghost/test/ghost-core/survey-fix-ids.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - recomputeSurveyIds, - type Survey, - type SurveySource, - tokenRowId, - uiSurfaceRowId, - valueRowId, -} from "#ghost-core"; - -const SOURCE: SurveySource = { - target: "github:block/ghost", - commit: "abc123", - scanned_at: "2026-04-29T12:00:00Z", -}; - -function survey(): Survey { - return { - schema: "ghost.survey/v1", - sources: [SOURCE], - values: [ - { - id: "", - source: SOURCE, - kind: "color", - value: "#f97316", - raw: "#f97316", - occurrences: 1, - files_count: 1, - }, - { - id: "wrong-id", - source: SOURCE, - kind: "spacing", - value: "8", - raw: "8px", - occurrences: 1, - files_count: 1, - }, - ], - tokens: [ - { - id: "", - source: SOURCE, - name: "--brand-primary", - alias_chain: [], - resolved_value: "#f97316", - occurrences: 1, - }, - ], - components: [], - ui_surfaces: [ - { - id: "", - source: SOURCE, - name: "Settings", - kind: "route", - locator: "/settings", - renderability: "source-only", - files: ["src/routes/settings.tsx"], - signals: { - dominant_components: ["Button", "Input"], - layout_patterns: ["sectioned-form"], - }, - }, - ], - }; -} - -describe("recomputeSurveyIds", () => { - it("populates empty IDs with deterministic hashes", () => { - const fixed = recomputeSurveyIds(survey()); - expect(fixed.values[0].id).toBe( - valueRowId(SOURCE, "color", "#f97316", "#f97316"), - ); - expect(fixed.tokens[0].id).toBe(tokenRowId(SOURCE, "--brand-primary")); - expect(fixed.ui_surfaces[0].id).toBe( - uiSurfaceRowId(SOURCE, "Settings", "route", "/settings"), - ); - }); - - it("overwrites incorrect IDs with the correct deterministic hash", () => { - const fixed = recomputeSurveyIds(survey()); - expect(fixed.values[1].id).toBe(valueRowId(SOURCE, "spacing", "8", "8px")); - expect(fixed.values[1].id).not.toBe("wrong-id"); - }); - - it("does not mutate the input survey", () => { - const input = survey(); - recomputeSurveyIds(input); - expect(input.values[0].id).toBe(""); - expect(input.values[1].id).toBe("wrong-id"); - }); - - it("is idempotent — running twice yields the same result", () => { - const once = recomputeSurveyIds(survey()); - const twice = recomputeSurveyIds(once); - expect(twice.values).toEqual(once.values); - expect(twice.tokens).toEqual(once.tokens); - expect(twice.ui_surfaces).toEqual(once.ui_surfaces); - }); -}); diff --git a/packages/ghost/test/ghost-core/survey-id.test.ts b/packages/ghost/test/ghost-core/survey-id.test.ts deleted file mode 100644 index a1521b32..00000000 --- a/packages/ghost/test/ghost-core/survey-id.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - componentRowId, - type SurveySource, - tokenRowId, - uiSurfaceRowId, - valueRowId, -} from "#ghost-core"; - -const SOURCE_A: SurveySource = { - target: "github:block/ghost", - commit: "abc123", - scanned_at: "2026-04-29T12:00:00Z", -}; - -const SOURCE_A_OTHER_TIME: SurveySource = { - ...SOURCE_A, - scanned_at: "2099-12-31T00:00:00Z", // different time, same target+commit -}; - -const SOURCE_B_DIFFERENT_COMMIT: SurveySource = { - ...SOURCE_A, - commit: "def456", -}; - -const SOURCE_C_DIFFERENT_TARGET: SurveySource = { - target: "github:block/other", - commit: "abc123", - scanned_at: "2026-04-29T12:00:00Z", -}; - -describe("valueRowId", () => { - it("produces stable hex IDs", () => { - const id = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - expect(id).toMatch(/^[0-9a-f]{16}$/); - }); - - it("is deterministic — same inputs give same ID", () => { - const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - const id2 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - expect(id1).toBe(id2); - }); - - it("ignores scanned_at and scanner_version — same target+commit+content gives same ID", () => { - const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - const id2 = valueRowId( - SOURCE_A_OTHER_TIME, - "color", - "#f97316", - "bg-orange-500", - ); - expect(id1).toBe(id2); - }); - - it("differs across commits", () => { - const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - const id2 = valueRowId( - SOURCE_B_DIFFERENT_COMMIT, - "color", - "#f97316", - "bg-orange-500", - ); - expect(id1).not.toBe(id2); - }); - - it("differs across targets", () => { - const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - const id2 = valueRowId( - SOURCE_C_DIFFERENT_TARGET, - "color", - "#f97316", - "bg-orange-500", - ); - expect(id1).not.toBe(id2); - }); - - it("differs across kinds", () => { - const colorId = valueRowId(SOURCE_A, "color", "8", "8px"); - const spacingId = valueRowId(SOURCE_A, "spacing", "8", "8px"); - expect(colorId).not.toBe(spacingId); - }); - - it("differs when raw form differs but value matches", () => { - const id1 = valueRowId(SOURCE_A, "color", "#f97316", "bg-orange-500"); - const id2 = valueRowId(SOURCE_A, "color", "#f97316", "var(--brand)"); - expect(id1).not.toBe(id2); - }); -}); - -describe("section-tagged IDs are non-colliding", () => { - it("token vs value with same name does not collide", () => { - const tokenId = tokenRowId(SOURCE_A, "Button"); - const valueId = valueRowId(SOURCE_A, "color", "Button", "Button"); - expect(tokenId).not.toBe(valueId); - }); - - it("token vs component with same name does not collide", () => { - const tokenId = tokenRowId(SOURCE_A, "Button"); - const componentId = componentRowId(SOURCE_A, "Button"); - expect(tokenId).not.toBe(componentId); - }); - - it("component vs UI surface with same name does not collide", () => { - const componentId = componentRowId(SOURCE_A, "Settings"); - const surfaceId = uiSurfaceRowId( - SOURCE_A, - "Settings", - "route", - "/settings", - ); - expect(componentId).not.toBe(surfaceId); - }); -}); - -describe("token / component IDs", () => { - it("are deterministic", () => { - expect(tokenRowId(SOURCE_A, "--color-brand-primary")).toBe( - tokenRowId(SOURCE_A, "--color-brand-primary"), - ); - expect(componentRowId(SOURCE_A, "Button")).toBe( - componentRowId(SOURCE_A, "Button"), - ); - expect(uiSurfaceRowId(SOURCE_A, "Settings", "route", "/settings")).toBe( - uiSurfaceRowId(SOURCE_A, "Settings", "route", "/settings"), - ); - }); - - it("differ across names within a section", () => { - expect(tokenRowId(SOURCE_A, "--brand")).not.toBe( - tokenRowId(SOURCE_A, "--accent"), - ); - expect(uiSurfaceRowId(SOURCE_A, "Settings", "route", "/settings")).not.toBe( - uiSurfaceRowId(SOURCE_A, "Home", "route", "/settings"), - ); - }); - - it("UI surface IDs differ across kind and locator", () => { - expect(uiSurfaceRowId(SOURCE_A, "Settings", "route", "/settings")).not.toBe( - uiSurfaceRowId(SOURCE_A, "Settings", "story", "/settings"), - ); - expect(uiSurfaceRowId(SOURCE_A, "Settings", "route", "/settings")).not.toBe( - uiSurfaceRowId(SOURCE_A, "Settings", "route", "/account"), - ); - }); -}); diff --git a/packages/ghost/test/ghost-core/survey-lint.test.ts b/packages/ghost/test/ghost-core/survey-lint.test.ts deleted file mode 100644 index d6443810..00000000 --- a/packages/ghost/test/ghost-core/survey-lint.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - Survey, - SurveySource, - TokenRow, - UiSurfaceRow, - ValueRow, -} from "#ghost-core"; -import { - lintSurvey, - tokenRowId, - uiSurfaceRowId, - valueRowId, -} from "#ghost-core"; - -const SOURCE: SurveySource = { - target: "github:block/ghost", - commit: "abc123", - scanned_at: "2026-04-29T12:00:00Z", - scanner_version: "0.1.0", -}; - -const RESOLVER_SOURCE: SurveySource = { - id: "design-tokens", - role: "resolver", - target: "github:block/design-tokens", - commit: "def456", - scanned_at: "2026-04-29T12:00:00Z", - scanner_version: "0.1.0", - resolves: ["color"], -}; - -function makeValueRow( - kind: string, - value: string, - raw: string, - overrides: Partial<{ - occurrences: number; - files_count: number; - role_hypothesis: string; - source: SurveySource; - resolution: ValueRow["resolution"]; - }> = {}, -) { - const source = overrides.source ?? SOURCE; - return { - id: valueRowId(source, kind, value, raw), - source, - kind, - value, - raw, - occurrences: overrides.occurrences ?? 1, - files_count: overrides.files_count ?? 1, - role_hypothesis: overrides.role_hypothesis, - resolution: overrides.resolution, - }; -} - -function makeTokenRow( - source: SurveySource, - name: string, - resolvedValue: string, - resolution?: TokenRow["resolution"], -): TokenRow { - return { - id: tokenRowId(source, name), - source, - name, - alias_chain: [], - resolved_value: resolvedValue, - occurrences: 1, - resolution, - }; -} - -function makeUiSurfaceRow(overrides: Partial = {}): UiSurfaceRow { - const source = overrides.source ?? SOURCE; - const name = overrides.name ?? "Settings account"; - const kind = overrides.kind ?? "route"; - const locator = overrides.locator ?? "/settings/account"; - return { - id: overrides.id ?? uiSurfaceRowId(source, name, kind, locator), - source, - name, - kind, - locator, - renderability: overrides.renderability ?? "source-only", - files: overrides.files ?? ["src/routes/settings/account.tsx"], - classification: overrides.classification ?? { - intent: "configure", - surface_type: "settings", - density: "standard", - layout_shape: "control-surface", - confidence: 0.82, - }, - signals: overrides.signals ?? { - dominant_components: ["Input", "Button"], - layout_patterns: ["sectioned-form", "persistent-actions"], - breakpoint_behavior: ["single-column mobile"], - notes: ["Compact controls sit inside sectioned settings groups."], - }, - }; -} - -function makeSurvey( - values: ReturnType[] = [], - tokens: TokenRow[] = [], - sources: SurveySource[] = [SOURCE], - uiSurfaces: UiSurfaceRow[] = [makeUiSurfaceRow()], -): Survey { - return { - schema: "ghost.survey/v1", - sources, - values, - tokens, - components: [], - ui_surfaces: uiSurfaces, - }; -} - -describe("lintSurvey", () => { - it("accepts an empty well-formed survey", () => { - const report = lintSurvey(makeSurvey()); - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("accepts a survey with recommended-kind value rows", () => { - const survey = makeSurvey([ - makeValueRow("color", "#f97316", "bg-orange-500", { - occurrences: 47, - files_count: 12, - }), - makeValueRow("spacing", "8", "8px", { - occurrences: 312, - files_count: 89, - }), - ]); - const report = lintSurvey(survey); - expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - }); - - it("rejects missing schema field", () => { - const survey = makeSurvey() as Record; - delete survey.schema; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - expect(report.issues.some((i) => i.rule.startsWith("schema/"))).toBe(true); - }); - - it("rejects the old ghost.bucket/v1 schema", () => { - const survey: unknown = { - ...makeSurvey(), - schema: "ghost.bucket/v1", - }; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - expect(report.issues.some((i) => i.rule.startsWith("schema/"))).toBe(true); - }); - - it("rejects negative occurrences", () => { - const row = makeValueRow("color", "#f97316", "#f97316"); - const report = lintSurvey(makeSurvey([{ ...row, occurrences: -1 }])); - expect(report.errors).toBeGreaterThan(0); - }); - - it("warns on unknown value kinds without rejecting", () => { - const survey = makeSurvey([ - makeValueRow("z-index", "10", "z-10"), // not in recommended set - ]); - const report = lintSurvey(survey); - expect(report.errors).toBe(0); - expect(report.warnings).toBeGreaterThan(0); - expect(report.issues.some((i) => i.rule === "value-kind-unknown")).toBe( - true, - ); - }); - - it("warns when a row's id does not match the deterministic generator", () => { - const survey = makeSurvey([ - { - ...makeValueRow("color", "#f97316", "#f97316"), - id: "deadbeefdeadbeef", // hand-rolled, not from generator - }, - ]); - const report = lintSurvey(survey); - expect(report.warnings).toBeGreaterThan(0); - expect(report.issues.some((i) => i.rule === "id-mismatch")).toBe(true); - }); - - it("flags duplicate IDs within a section as errors", () => { - const row = makeValueRow("color", "#f97316", "#f97316"); - const report = lintSurvey(makeSurvey([row, { ...row }])); // same ID, two rows - expect(report.errors).toBeGreaterThan(0); - expect(report.issues.some((i) => i.rule === "duplicate-id")).toBe(true); - }); - - it("flags duplicate UI surface IDs within the ui_surfaces section", () => { - const row = makeUiSurfaceRow(); - const report = lintSurvey(makeSurvey([], [], [SOURCE], [row, { ...row }])); - expect(report.errors).toBeGreaterThan(0); - expect( - report.issues.some( - (i) => i.rule === "duplicate-id" && i.path === "ui_surfaces[1].id", - ), - ).toBe(true); - }); - - it("requires the ui_surfaces section", () => { - const survey = makeSurvey() as unknown as Record; - delete survey.ui_surfaces; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - expect(report.issues.some((i) => i.path === "ui_surfaces")).toBe(true); - }); - - it("warns when ui_surfaces is empty", () => { - const report = lintSurvey(makeSurvey([], [], [SOURCE], [])); - expect(report.errors).toBe(0); - expect(report.issues.some((i) => i.rule === "ui-surfaces-empty")).toBe( - true, - ); - }); - - it("rejects invalid UI surface enum values and confidence bounds", () => { - const survey: unknown = { - ...makeSurvey(), - ui_surfaces: [ - { - ...makeUiSurfaceRow(), - kind: "page", - classification: { - density: "roomy", - layout_shape: "control-surface", - confidence: 1.5, - }, - }, - ], - }; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - expect( - report.issues.some((i) => i.path?.startsWith("ui_surfaces[0]")), - ).toBe(true); - }); - - it("requires a UI surface locator", () => { - const survey: unknown = { - ...makeSurvey(), - ui_surfaces: [{ ...makeUiSurfaceRow(), locator: "" }], - }; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - expect(report.issues.some((i) => i.path === "ui_surfaces[0].locator")).toBe( - true, - ); - }); - - it("accepts factual composition observations on UI surfaces", () => { - const report = lintSurvey( - makeSurvey( - [], - [], - [SOURCE], - [ - makeUiSurfaceRow({ - composition: { - anatomy: ["shell", "compact-header", "sectioned-form"], - primary_region: "form", - action_placement: ["footer", "section-local"], - navigation_context: "persistent-shell", - responsive_behavior: ["mobile stacks sections vertically"], - confidence: 0.74, - }, - }), - ], - ), - ); - - expect(report.errors).toBe(0); - }); - - it("rejects sources array with no entries", () => { - const survey: unknown = { - ...makeSurvey(), - sources: [], - }; - const report = lintSurvey(survey); - expect(report.errors).toBeGreaterThan(0); - }); - - it("accepts source roles and resolution provenance", () => { - const primary: SurveySource = { - ...SOURCE, - id: "cash-ios", - role: "primary", - target: "github:squareup/cash-ios", - }; - const row = makeValueRow("color", "#ffffff", "CashTheme.color.bg", { - source: primary, - resolution: { - status: "resolved", - source_id: "arcade-ios-package", - target: "github:squareup/arcade-ios-package", - symbol: "ArcadeColor.background", - chain: ["CashTheme.color.bg", "ArcadeColor.background"], - }, - }); - const report = lintSurvey( - makeSurvey([row], [], [primary, RESOLVER_SOURCE]), - ); - expect(report.errors).toBe(0); - expect(report.issues).toEqual([]); - }); - - it("warns when source roles omit a primary source", () => { - const report = lintSurvey(makeSurvey([], [], [RESOLVER_SOURCE])); - expect(report.errors).toBe(0); - expect( - report.issues.some((i) => i.rule === "source-graph-primary-count"), - ).toBe(true); - }); - - it("accepts unresolved external token provenance", () => { - const primary: SurveySource = { - ...SOURCE, - id: "cash-ios", - role: "primary", - }; - const token = makeTokenRow( - primary, - "CashTheme.color.bg", - "CashTheme.color.bg", - { - status: "unresolved-external", - source_id: "arcade-ios-package", - symbol: "ArcadeColor.background", - }, - ); - const report = lintSurvey( - makeSurvey([], [token], [primary, RESOLVER_SOURCE]), - ); - expect(report.errors).toBe(0); - expect( - report.issues.some( - (i) => i.rule === "resolution-unresolved-context-missing", - ), - ).toBe(false); - }); -}); diff --git a/packages/ghost/test/ghost-core/survey-merge.test.ts b/packages/ghost/test/ghost-core/survey-merge.test.ts deleted file mode 100644 index f6fea2b9..00000000 --- a/packages/ghost/test/ghost-core/survey-merge.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - Survey, - SurveySource, - TokenRow, - UiSurfaceRow, - ValueRow, -} from "#ghost-core"; -import { - mergeSurveys, - tokenRowId, - uiSurfaceRowId, - valueRowId, -} from "#ghost-core"; - -const SOURCE_A: SurveySource = { - target: "github:block/ghost", - commit: "abc123", - scanned_at: "2026-04-29T12:00:00Z", -}; - -const SOURCE_B: SurveySource = { - target: "github:block/other", - commit: "def456", - scanned_at: "2026-04-29T12:00:00Z", -}; - -function valueRow( - source: SurveySource, - kind: string, - value: string, - raw: string, - occurrences = 1, -): ValueRow { - return { - id: valueRowId(source, kind, value, raw), - source, - kind, - value, - raw, - occurrences, - files_count: 1, - }; -} - -function tokenRow( - source: SurveySource, - name: string, - resolved: string, -): TokenRow { - return { - id: tokenRowId(source, name), - source, - name, - alias_chain: [], - resolved_value: resolved, - occurrences: 1, - }; -} - -function uiSurfaceRow( - source: SurveySource, - name: string, - kind: UiSurfaceRow["kind"], - locator: string, -): UiSurfaceRow { - return { - id: uiSurfaceRowId(source, name, kind, locator), - source, - name, - kind, - locator, - renderability: "source-only", - files: [`src/${name.toLowerCase()}.tsx`], - classification: { - intent: "configure", - surface_type: "settings", - density: "standard", - layout_shape: "control-surface", - confidence: 0.8, - }, - signals: { - dominant_components: ["Button"], - layout_patterns: ["sectioned-form"], - }, - }; -} - -function makeSurvey( - source: SurveySource, - values: ValueRow[] = [], - tokens: TokenRow[] = [], - uiSurfaces: UiSurfaceRow[] = [], -): Survey { - return { - schema: "ghost.survey/v1", - sources: [source], - values, - tokens, - components: [], - ui_surfaces: uiSurfaces, - }; -} - -describe("mergeSurveys", () => { - it("merging a single survey returns equivalent rowset", () => { - const a = makeSurvey(SOURCE_A, [ - valueRow(SOURCE_A, "color", "#f97316", "#f97316"), - ]); - const merged = mergeSurveys(a); - expect(merged.values).toEqual(a.values); - expect(merged.sources).toEqual([SOURCE_A]); - }); - - it("is idempotent — merging the same survey twice yields the same rowset", () => { - const a = makeSurvey(SOURCE_A, [ - valueRow(SOURCE_A, "color", "#f97316", "#f97316"), - valueRow(SOURCE_A, "spacing", "8", "8px"), - ]); - const once = mergeSurveys(a); - const twice = mergeSurveys(a, a); - expect(twice.values).toEqual(once.values); - expect(twice.sources).toEqual(once.sources); - }); - - it("preserves rows with distinct IDs across different sources", () => { - const a = makeSurvey(SOURCE_A, [ - valueRow(SOURCE_A, "color", "#f97316", "#f97316"), - ]); - const b = makeSurvey(SOURCE_B, [ - valueRow(SOURCE_B, "color", "#f97316", "#f97316"), - ]); - const merged = mergeSurveys(a, b); - expect(merged.values).toHaveLength(2); - expect(merged.sources).toEqual([SOURCE_A, SOURCE_B]); - }); - - it("dedupes rows with identical IDs (same source + same content)", () => { - const row = valueRow(SOURCE_A, "color", "#f97316", "#f97316"); - const a = makeSurvey(SOURCE_A, [row]); - const b = makeSurvey(SOURCE_A, [row]); // same source, same content -> same ID - const merged = mergeSurveys(a, b); - expect(merged.values).toHaveLength(1); - expect(merged.sources).toHaveLength(1); - }); - - it("preserves distinct source-graph roles for the same target", () => { - const primary: SurveySource = { - ...SOURCE_A, - id: "cash-ios", - role: "primary", - }; - const resolver: SurveySource = { - ...SOURCE_A, - id: "arcade-ios-package", - role: "resolver", - resolves: ["color"], - }; - const merged = mergeSurveys(makeSurvey(primary), makeSurvey(resolver)); - expect(merged.sources).toEqual([primary, resolver]); - }); - - it("preserves tokens and components independently", () => { - const a = makeSurvey( - SOURCE_A, - [], - [tokenRow(SOURCE_A, "--brand-primary", "#f97316")], - ); - const b = makeSurvey( - SOURCE_B, - [], - [tokenRow(SOURCE_B, "--brand-primary", "#0000ff")], - ); - const merged = mergeSurveys(a, b); - expect(merged.tokens).toHaveLength(2); - // Same token name, different sources, distinct IDs — both survive. - expect(merged.tokens.map((t) => t.resolved_value).sort()).toEqual([ - "#0000ff", - "#f97316", - ]); - }); - - it("preserves and dedupes UI surface rows", () => { - const settings = uiSurfaceRow(SOURCE_A, "Settings", "route", "/settings"); - const a = makeSurvey(SOURCE_A, [], [], [settings]); - const b = makeSurvey(SOURCE_A, [], [], [settings]); - const c = makeSurvey( - SOURCE_B, - [], - [], - [uiSurfaceRow(SOURCE_B, "Settings", "route", "/settings")], - ); - const merged = mergeSurveys(a, b, c); - expect(merged.ui_surfaces).toHaveLength(2); - }); - - it("throws when given zero surveys", () => { - expect(() => mergeSurveys()).toThrow(/at least one/); - }); - - it("schema field on the merged survey is ghost.survey/v1", () => { - const a = makeSurvey(SOURCE_A); - const merged = mergeSurveys(a); - expect(merged.schema).toBe("ghost.survey/v1"); - }); -}); diff --git a/packages/ghost/test/ghost-core/survey-summary.test.ts b/packages/ghost/test/ghost-core/survey-summary.test.ts deleted file mode 100644 index 36a2f1ae..00000000 --- a/packages/ghost/test/ghost-core/survey-summary.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - ComponentRow, - Survey, - SurveySource, - TokenRow, - UiSurfaceRow, - ValueRow, -} from "#ghost-core"; -import { - componentRowId, - formatSurveySummaryMarkdown, - summarizeSurvey, - tokenRowId, - uiSurfaceRowId, - valueRowId, -} from "#ghost-core"; - -const SOURCE: SurveySource = { - id: "app", - role: "primary", - target: "github:example/portal", - commit: "abc123", - scanned_at: "2026-05-04T12:00:00Z", - scanner_version: "0.2.0", -}; - -function valueRow( - kind: string, - value: string, - raw: string, - occurrences: number, - overrides: Partial = {}, -): ValueRow { - return { - id: valueRowId(SOURCE, kind, value, raw), - source: SOURCE, - kind, - value, - raw, - occurrences, - files_count: Math.max(1, Math.floor(occurrences / 3)), - ...overrides, - }; -} - -function tokenRow( - name: string, - resolvedValue: string, - occurrences: number, - overrides: Partial = {}, -): TokenRow { - return { - id: tokenRowId(SOURCE, name), - source: SOURCE, - name, - alias_chain: [], - resolved_value: resolvedValue, - occurrences, - ...overrides, - }; -} - -function componentRow(name: string, index: number): ComponentRow { - return { - id: componentRowId(SOURCE, name), - source: SOURCE, - name, - discovered_via: index % 2 === 0 ? "registry.json" : "barrel-export", - variants: index % 3 === 0 ? ["default", "secondary"] : undefined, - sizes: index % 4 === 0 ? ["sm", "md"] : undefined, - }; -} - -function uiSurfaceRow(index: number): UiSurfaceRow { - const name = `Surface ${index}`; - const kind = "route"; - const locator = `/surface-${index}`; - return { - id: uiSurfaceRowId(SOURCE, name, kind, locator), - source: SOURCE, - name, - kind, - locator, - renderability: "source-only", - files: [`src/routes/surface-${index}.tsx`], - classification: { - intent: index % 2 === 0 ? "configure" : "review", - surface_type: index % 2 === 0 ? "settings" : "audit", - density: index % 2 === 0 ? "compressed" : "standard", - layout_shape: index % 2 === 0 ? "control-surface" : "tracker", - confidence: 0.9, - }, - signals: { - dominant_components: ["Button", "Input", "Table"], - layout_patterns: - index % 2 === 0 - ? ["sectioned-form", "persistent-actions"] - : ["data-table", "status-row"], - value_refs: [], - notes: [`Observed surface ${index}.`], - }, - }; -} - -function survey(): Survey { - return { - schema: "ghost.survey/v1", - sources: [SOURCE], - values: [ - valueRow("color", "#222222", "#222222", 30, { - role_hypothesis: "foreground", - usage: { className: 20, css_var: 10 }, - }), - valueRow("color", "#111111", "text-[#111111]", 20, { - usage: { arbitrary_class: 20 }, - }), - valueRow("spacing", "8px", "p-2", 40), - valueRow("spacing", "999px", "p-[999px]", 3, { - usage: { arbitrary_class: 3 }, - }), - valueRow("typography", "Inter", "font-inter", 14, { - spec: { family: "Inter" }, - }), - valueRow("radius", "4px", "rounded", 8, { - resolution: { - status: "unresolved-local", - symbol: "--radius-base", - message: "Local alias was not resolved.", - }, - }), - valueRow("z-index", "10", "z-10", 2), - ], - tokens: [ - tokenRow("--color-background", "#ffffff", 50, { - alias_chain: ["--color-white"], - by_theme: { light: "#ffffff", dark: "#111111" }, - }), - tokenRow("--spacing-card-padding", "16px", 20, { - alias_chain: ["--spacing-4", "--base-spacing"], - }), - tokenRow("--external-danger", "danger-token", 5, { - resolution: { - status: "unresolved-external", - source_id: "design-tokens", - symbol: "danger-token", - }, - }), - ], - components: Array.from({ length: 25 }, (_, index) => - componentRow(`Component${String(index).padStart(2, "0")}`, index), - ), - ui_surfaces: Array.from({ length: 10 }, (_, index) => uiSurfaceRow(index)), - }; -} - -describe("summarizeSurvey", () => { - it("builds a bounded deterministic digest with row ids", () => { - const summary = summarizeSurvey(survey(), { budget: "compact" }); - - expect(summary.schema).toBe("ghost.survey.summary/v1"); - expect(summary.counts).toEqual({ - sources: 1, - values: 7, - tokens: 3, - components: 25, - ui_surfaces: 10, - total_rows: 45, - }); - - const colors = summary.values.kinds.find((kind) => kind.kind === "color"); - expect(colors?.top.map((row) => row.value)).toEqual(["#222222", "#111111"]); - expect(colors?.top[0].id).toBe( - valueRowId(SOURCE, "color", "#222222", "#222222"), - ); - - expect(summary.values.arbitrary_or_raw.map((row) => row.raw)).toEqual([ - "text-[#111111]", - "p-[999px]", - ]); - expect(summary.values.unresolved).toHaveLength(1); - expect(summary.values.unresolved[0].resolution?.status).toBe( - "unresolved-local", - ); - - expect(summary.tokens.families[0]).toMatchObject({ - name: "color/background", - count: 1, - occurrences: 50, - }); - expect(summary.tokens.semantic_or_themed[0].name).toBe( - "--color-background", - ); - expect(summary.tokens.unresolved[0].resolution?.status).toBe( - "unresolved-external", - ); - - expect(summary.components.top).toHaveLength(20); - expect(summary.components.omitted).toBe(5); - expect(summary.ui_surfaces.surfaces).toHaveLength(8); - expect(summary.ui_surfaces.omitted).toBe(2); - expect(summary.ui_surfaces.groups[0].examples.length).toBeLessThanOrEqual( - 2, - ); - }); - - it("expands row caps when the full budget is requested", () => { - const summary = summarizeSurvey(survey(), { budget: "full" }); - - expect(summary.components.top).toHaveLength(25); - expect(summary.components.omitted).toBe(0); - expect(summary.ui_surfaces.surfaces).toHaveLength(10); - expect(summary.ui_surfaces.omitted).toBe(0); - }); -}); - -describe("formatSurveySummaryMarkdown", () => { - it("renders agent-readable Markdown without dumping the full survey", () => { - const summary = summarizeSurvey(survey(), { budget: "compact" }); - const markdown = formatSurveySummaryMarkdown(summary); - - expect(markdown).toContain("# Survey Summary"); - expect(markdown).toContain("Budget: `compact`"); - expect(markdown).toContain("## Values"); - expect(markdown).toContain("`text-[#111111]`"); - expect(markdown).toContain("## UI Surfaces"); - expect(markdown.length).toBeLessThan(12_000); - }); -}); diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index b6d4c8a4..65b038ae 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -13,7 +13,7 @@ const EXCEPTIONS = { "packages/ghost/src/fingerprint-commands.ts": { limit: 1135, justification: - "Fingerprint package command registry — temporarily holds package lifecycle, survey/cache, scan readiness, and adapter-neutral package-dir routing until command groups are split further", + "Fingerprint package command registry — holds package lifecycle, validate, scan, and adapter-neutral package-dir routing", }, "packages/ghost/src/scan/inventory.ts": { limit: 1120, From 7a0044239b1ebaced5323d9147d3b993fc14845d Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:02:16 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20described=20nodes=20=E2=80=94=20col?= =?UTF-8?q?lapse=20surface-as-content=20into=20the=20node=20catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery is now name + description, like tool selection. 'description' is a first-class node field (the retrieval payload); 'gather' with no arg builds the catalog from graph nodes (buildGraphMenu) — node id + description — plus any spine-only tree positions. Node frontmatter is passthrough: free-form keys (audience, stage) ride along untouched, gated on nothing. Surface-as-a-content-concept dissolves: the composition-edge vocabulary (composes/governed-by) is deleted (lateral composition is node 'relates'), the surface menu builder is replaced by the node catalog, and surfaces.yml is demoted to an optional terse spine file (id + parent + description) that folds into the node id space. Skill (capture.md, SKILL.md) documents description as the retrieval payload. All green: 113 tests, full check. --- .changeset/described-nodes.md | 5 ++ apps/docs/src/generated/cli-manifest.json | 2 +- packages/ghost/src/gather-command.ts | 23 ++++---- .../ghost/src/ghost-core/graph/assemble.ts | 1 + packages/ghost/src/ghost-core/graph/index.ts | 1 + packages/ghost/src/ghost-core/graph/menu.ts | 54 +++++++++++++++++++ packages/ghost/src/ghost-core/graph/types.ts | 2 + packages/ghost/src/ghost-core/index.ts | 9 ++-- packages/ghost/src/ghost-core/node/schema.ts | 6 ++- .../ghost/src/ghost-core/node/serialize.ts | 1 + packages/ghost/src/ghost-core/node/types.ts | 8 +++ .../ghost/src/ghost-core/surfaces/index.ts | 4 -- .../ghost/src/ghost-core/surfaces/lint.ts | 35 ------------ .../ghost/src/ghost-core/surfaces/menu.ts | 44 --------------- .../ghost/src/ghost-core/surfaces/schema.ts | 26 +++------ .../ghost/src/ghost-core/surfaces/types.ts | 37 ++++--------- .../src/scan/fingerprint-package-layers.ts | 3 ++ packages/ghost/src/skill-bundle/SKILL.md | 15 +++--- .../src/skill-bundle/references/capture.md | 10 +++- .../ghost/test/ghost-core/node-schema.test.ts | 11 +++- .../test/ghost-core/surfaces-lint.test.ts | 38 +------------ .../test/ghost-core/surfaces-schema.test.ts | 26 +++------ 22 files changed, 149 insertions(+), 212 deletions(-) create mode 100644 .changeset/described-nodes.md create mode 100644 packages/ghost/src/ghost-core/graph/menu.ts delete mode 100644 packages/ghost/src/ghost-core/surfaces/menu.ts diff --git a/.changeset/described-nodes.md b/.changeset/described-nodes.md new file mode 100644 index 00000000..60fbe52a --- /dev/null +++ b/.changeset/described-nodes.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": minor +--- + +Make `description` a first-class node field — the retrieval payload an agent matches a task against, the way a tool is selected by name + description. `ghost gather` with no argument now lists nodes by id + description (the catalog), built from the graph rather than a separate surface menu. Node frontmatter is now passthrough: free-form descriptive keys (`audience`, `stage`, …) are allowed and ride along untouched. The surface composition-edge vocabulary (`composes`/`governed-by`) is removed — lateral composition lives on node `relates`; `surfaces.yml` is now an optional terse spine file (id + parent + optional description) that folds into the node id space, not a distinct content concept. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index f573c52e..f68280d7 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T03:43:13.793Z", + "generatedAt": "2026-06-28T13:00:23.219Z", "tools": [ { "tool": "ghost", diff --git a/packages/ghost/src/gather-command.ts b/packages/ghost/src/gather-command.ts index ebf986b0..db2e2dc0 100644 --- a/packages/ghost/src/gather-command.ts +++ b/packages/ghost/src/gather-command.ts @@ -1,11 +1,11 @@ import type { CAC } from "cac"; import { - buildSurfaceMenu, + buildGraphMenu, GHOST_GRAPH_ROOT_ID, + type GraphMenuEntry, type GraphSlice, type GraphSliceProvenance, resolveGraphSlice, - type SurfaceMenuEntry, } from "#ghost-core"; import { resolveFingerprintPackage } from "./fingerprint.js"; import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; @@ -42,13 +42,13 @@ export function registerGatherCommand(cli: CAC): void { const paths = resolveFingerprintPackage(opts.package, process.cwd()); const loaded = await loadFingerprintPackage(paths); - const menu = buildSurfaceMenu(loaded.surfaces); + const menu = buildGraphMenu(loaded.graph); - // The agent names the surface (it analyzed the prompt + diff). Ghost - // does not infer surfaces from repo paths. + // The agent names the node (it analyzed the prompt + diff). Ghost + // does not infer the anchor from repo paths. const surface = surfaceArg; - // No surface named, or an unknown one: return the menu, never the tree. + // No node named, or an unknown one: return the menu, never the tree. const known = new Set(menu.map((entry) => entry.id)); if (!surface || !known.has(surface)) { if (opts.format === "json") { @@ -84,19 +84,19 @@ export function registerGatherCommand(cli: CAC): void { } function formatMenuMarkdown( - menu: SurfaceMenuEntry[], + menu: GraphMenuEntry[], unknown: string | undefined, ): string { - const lines: string[] = ["# Ghost Surfaces"]; + const lines: string[] = ["# Ghost Nodes"]; if (unknown) { lines.push( "", - `Surface \`${unknown}\` is not declared. Pick one of the surfaces below.`, + `Node \`${unknown}\` is not in this package. Pick one of the nodes below.`, ); } else { lines.push( "", - "No surface selected. Match the ask to one of these surfaces, then run `ghost gather `.", + "No node selected. Match the ask to one of these nodes, then run `ghost gather `.", ); } lines.push(""); @@ -105,9 +105,6 @@ function formatMenuMarkdown( entry.parent === entry.id ? "" : ` (under \`${entry.parent}\`)`; lines.push(`- \`${entry.id}\`${parent}`); if (entry.description) lines.push(` - ${entry.description}`); - for (const edge of entry.edges) { - lines.push(` - ${edge.kind} → \`${edge.to}\``); - } } return `${lines.join("\n")}\n`; } diff --git a/packages/ghost/src/ghost-core/graph/assemble.ts b/packages/ghost/src/ghost-core/graph/assemble.ts index 309a7287..20e2c2b0 100644 --- a/packages/ghost/src/ghost-core/graph/assemble.ts +++ b/packages/ghost/src/ghost-core/graph/assemble.ts @@ -39,6 +39,7 @@ export function assembleGraph(input: AssembleGraphInput): GhostGraph { const fm = doc.frontmatter; nodes.set(fm.id, { id: fm.id, + ...(fm.description !== undefined ? { description: fm.description } : {}), ...(fm.under !== undefined ? { under: fm.under } : {}), relates: fm.relates ?? [], ...(fm.incarnation !== undefined ? { incarnation: fm.incarnation } : {}), diff --git a/packages/ghost/src/ghost-core/graph/index.ts b/packages/ghost/src/ghost-core/graph/index.ts index a24cec95..426a83ce 100644 --- a/packages/ghost/src/ghost-core/graph/index.ts +++ b/packages/ghost/src/ghost-core/graph/index.ts @@ -15,6 +15,7 @@ export { type GraphLintSeverity, lintGraph, } from "./lint.js"; +export { buildGraphMenu, type GraphMenuEntry } from "./menu.js"; export { type GraphSlice, type GraphSliceNode, diff --git a/packages/ghost/src/ghost-core/graph/menu.ts b/packages/ghost/src/ghost-core/graph/menu.ts new file mode 100644 index 00000000..bed23a64 --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/menu.ts @@ -0,0 +1,54 @@ +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; + +/** + * One entry in the gather catalog: a node an agent can anchor a task at, + * presented as `id` + `description` — the retrieval payload (the same shape a + * tool catalog uses for selection). The agent matches a natural-language ask + * against these and picks; Ghost does no NLP. + */ +export interface GraphMenuEntry { + id: string; + description?: string; + /** The containment parent, for orientation (the implicit root for top nodes). */ + parent: string; +} + +/** + * Build the gather catalog: every local node, with its description, plus the + * implicit `core` root. Sorted by id for stable output. Inherited + * (extended-package) nodes are excluded — the menu lists what this package + * offers to anchor at. Callers may further narrow (e.g. only described nodes) + * for large graphs; this returns the full local catalog. + */ +export function buildGraphMenu(graph: GhostGraph): GraphMenuEntry[] { + const entries: GraphMenuEntry[] = [ + { + id: GHOST_GRAPH_ROOT_ID, + description: "The product-wide root; true everywhere.", + parent: GHOST_GRAPH_ROOT_ID, + }, + ]; + + const seen = new Set([GHOST_GRAPH_ROOT_ID]); + + for (const node of graph.nodes.values()) { + if (node.id === GHOST_GRAPH_ROOT_ID) continue; + if (node.origin === "inherited") continue; + seen.add(node.id); + entries.push({ + id: node.id, + ...(node.description ? { description: node.description } : {}), + parent: node.under ?? GHOST_GRAPH_ROOT_ID, + }); + } + + // Tree positions declared only in the spine file (surfaces.yml) — no node of + // their own yet — are still anchorable. Include them as bare entries. + for (const [id, parent] of graph.parents) { + if (seen.has(id)) continue; + seen.add(id); + entries.push({ id, parent }); + } + + return entries.sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/packages/ghost/src/ghost-core/graph/types.ts b/packages/ghost/src/ghost-core/graph/types.ts index ac8a2a4f..76d9e23b 100644 --- a/packages/ghost/src/ghost-core/graph/types.ts +++ b/packages/ghost/src/ghost-core/graph/types.ts @@ -20,6 +20,8 @@ export type GhostGraphNodeOrigin = "node-file" | "inherited"; */ export interface GhostGraphNode { id: string; + /** One-line "what this is / when to gather it" — the retrieval payload. */ + description?: string; under?: string; relates: GhostNodeRelation[]; incarnation?: string; diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index accc68d3..499dc708 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -24,6 +24,7 @@ export { type AssembleGraphInput, ancestorChain, assembleGraph, + buildGraphMenu, GHOST_GRAPH_ROOT_ID, type GhostGraph, type GhostGraphNode, @@ -31,6 +32,7 @@ export { type GraphLintIssue, type GraphLintReport, type GraphLintSeverity, + type GraphMenuEntry, type GraphSlice, type GraphSliceNode, type GraphSliceProvenance, @@ -111,21 +113,16 @@ export type { // --- Skill bundle loader --- export type { SkillBundleFile } from "./skill-bundle-loader.js"; export { loadSkillBundle } from "./skill-bundle-loader.js"; -// --- Surfaces (ghost.surfaces/v1) --- +// --- Surfaces (ghost.surfaces/v1) — the optional terse spine file --- export { - buildSurfaceMenu, - GHOST_SURFACE_EDGE_KINDS, GHOST_SURFACE_ROOT_ID, GHOST_SURFACES_SCHEMA, GHOST_SURFACES_YML_FILENAME, type GhostSurface, - type GhostSurfaceEdge, - type GhostSurfaceEdgeKind, type GhostSurfacesDocument, type GhostSurfacesLintIssue, type GhostSurfacesLintReport, type GhostSurfacesLintSeverity, GhostSurfacesSchema, lintGhostSurfaces, - type SurfaceMenuEntry, } from "./surfaces/index.js"; diff --git a/packages/ghost/src/ghost-core/node/schema.ts b/packages/ghost/src/ghost-core/node/schema.ts index 631915f3..60e3cbdc 100644 --- a/packages/ghost/src/ghost-core/node/schema.ts +++ b/packages/ghost/src/ghost-core/node/schema.ts @@ -48,10 +48,14 @@ const NodeRelationSchema = z export const GhostNodeFrontmatterSchema = z .object({ id: NodeIdSchema, + description: z.string().min(1).optional(), under: NodeRefSchema.optional(), relates: z.array(NodeRelationSchema).optional(), incarnation: z.string().min(1).optional(), }) - .strict(); + // Passthrough, not strict: authors may add free-form descriptive keys + // (e.g. `audience`, `stage`) that describe what the node is. Ghost does not + // gate on them — they ride along as part of the node's descriptive surface. + .passthrough(); export { NodeIdSchema, NodeRefSchema }; diff --git a/packages/ghost/src/ghost-core/node/serialize.ts b/packages/ghost/src/ghost-core/node/serialize.ts index 3679376a..fdeb2643 100644 --- a/packages/ghost/src/ghost-core/node/serialize.ts +++ b/packages/ghost/src/ghost-core/node/serialize.ts @@ -9,6 +9,7 @@ import type { GhostNodeDocument, GhostNodeFrontmatter } from "./types.js"; export function serializeNode(node: GhostNodeDocument): string { const fm = node.frontmatter; const ordered: Record = { id: fm.id }; + if (fm.description !== undefined) ordered.description = fm.description; if (fm.under !== undefined) ordered.under = fm.under; if (fm.relates !== undefined) { ordered.relates = fm.relates.map((relation) => { diff --git a/packages/ghost/src/ghost-core/node/types.ts b/packages/ghost/src/ghost-core/node/types.ts index f82c7db4..89bf6a22 100644 --- a/packages/ghost/src/ghost-core/node/types.ts +++ b/packages/ghost/src/ghost-core/node/types.ts @@ -33,6 +33,14 @@ export interface GhostNodeRelation { export interface GhostNodeFrontmatter { /** Unique, addressable id within the package. */ id: string; + /** + * One-line statement of what this node is and when to gather it — the + * retrieval payload. Together with `id` it is how an agent selects a node, + * exactly like a tool's name + description. The body is the node's + * "implementation"; the description is what makes it discoverable. Optional, + * but strongly encouraged on any node worth anchoring a task at. + */ + description?: string; /** * The single containment parent (the tree + the cascade). Absent means a * top-level node under the implicit `core` root. The tree lives only here; diff --git a/packages/ghost/src/ghost-core/surfaces/index.ts b/packages/ghost/src/ghost-core/surfaces/index.ts index e34b683d..cf79d30c 100644 --- a/packages/ghost/src/ghost-core/surfaces/index.ts +++ b/packages/ghost/src/ghost-core/surfaces/index.ts @@ -6,16 +6,12 @@ */ export { lintGhostSurfaces } from "./lint.js"; -export { buildSurfaceMenu, type SurfaceMenuEntry } from "./menu.js"; export { GhostSurfacesSchema } from "./schema.js"; export { - GHOST_SURFACE_EDGE_KINDS, GHOST_SURFACE_ROOT_ID, GHOST_SURFACES_SCHEMA, GHOST_SURFACES_YML_FILENAME, type GhostSurface, - type GhostSurfaceEdge, - type GhostSurfaceEdgeKind, type GhostSurfacesDocument, type GhostSurfacesLintIssue, type GhostSurfacesLintReport, diff --git a/packages/ghost/src/ghost-core/surfaces/lint.ts b/packages/ghost/src/ghost-core/surfaces/lint.ts index 4bb940e8..940e5329 100644 --- a/packages/ghost/src/ghost-core/surfaces/lint.ts +++ b/packages/ghost/src/ghost-core/surfaces/lint.ts @@ -34,7 +34,6 @@ export function lintGhostSurfaces(input: unknown): GhostSurfacesLintReport { checkReservedCore(doc, issues); checkParentRefs(doc, knownIds, issues); checkParentCycles(doc, issues); - checkEdgeRefs(doc, ids, issues); checkNearMissIds(doc, ids, issues); return finalize(issues); @@ -124,27 +123,6 @@ function checkParentCycles( }); } -function checkEdgeRefs( - doc: GhostSurfacesDocument, - ids: Set, - issues: GhostSurfacesLintIssue[], -): void { - // Edge targets must be declared surfaces. Unlike `parent`, edges do not get - // the implicit-`core` exemption: an edge must point at a real surface. - doc.surfaces.forEach((surface, index) => { - surface.edges?.forEach((edge, edgeIndex) => { - if (!ids.has(edge.to)) { - issues.push({ - severity: "error", - rule: "surface-edge-unknown", - message: `edge '${edge.kind}' target '${edge.to}' does not match any surface id`, - path: `surfaces[${index}].edges[${edgeIndex}].to`, - }); - } - }); - }); -} - function checkNearMissIds( doc: GhostSurfacesDocument, ids: Set, @@ -164,19 +142,6 @@ function checkNearMissIds( }); } } - surface.edges?.forEach((edge, edgeIndex) => { - if (!ids.has(edge.to)) { - const near = nearest(edge.to, candidates); - if (near) { - issues.push({ - severity: "warning", - rule: "surface-id-near-miss", - message: `edge target '${edge.to}' is unknown; did you mean '${near}'?`, - path: `surfaces[${index}].edges[${edgeIndex}].to`, - }); - } - } - }); }); } diff --git a/packages/ghost/src/ghost-core/surfaces/menu.ts b/packages/ghost/src/ghost-core/surfaces/menu.ts deleted file mode 100644 index 3b054707..00000000 --- a/packages/ghost/src/ghost-core/surfaces/menu.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - GHOST_SURFACE_ROOT_ID, - type GhostSurfaceEdge, - type GhostSurfacesDocument, -} from "./types.js"; - -export interface SurfaceMenuEntry { - id: string; - description?: string; - parent: string; - edges: GhostSurfaceEdge[]; -} - -/** - * The deterministic list of surfaces with their authored descriptions, for a - * host agent to match a natural-language ask against. Ghost does no NLP — it - * hands over a labeled menu and lets the agent pick. - * - * Always includes the implicit `core` root (described generically if not - * declared). Sorted by id for stable output. - */ -export function buildSurfaceMenu( - surfaces: GhostSurfacesDocument | undefined, -): SurfaceMenuEntry[] { - const entries = new Map(); - - entries.set(GHOST_SURFACE_ROOT_ID, { - id: GHOST_SURFACE_ROOT_ID, - description: "The product-wide surface; true everywhere.", - parent: GHOST_SURFACE_ROOT_ID, - edges: [], - }); - - for (const surface of surfaces?.surfaces ?? []) { - entries.set(surface.id, { - id: surface.id, - ...(surface.description ? { description: surface.description } : {}), - parent: surface.parent ?? GHOST_SURFACE_ROOT_ID, - edges: surface.edges ?? [], - }); - } - - return [...entries.values()].sort((a, b) => a.id.localeCompare(b.id)); -} diff --git a/packages/ghost/src/ghost-core/surfaces/schema.ts b/packages/ghost/src/ghost-core/surfaces/schema.ts index 14da7498..a03d370b 100644 --- a/packages/ghost/src/ghost-core/surfaces/schema.ts +++ b/packages/ghost/src/ghost-core/surfaces/schema.ts @@ -1,11 +1,10 @@ import { z } from "zod"; -import { GHOST_SURFACE_EDGE_KINDS, GHOST_SURFACES_SCHEMA } from "./types.js"; +import { GHOST_SURFACES_SCHEMA } from "./types.js"; /** - * Flat slug for surface ids. Note the dot is deliberately excluded: dotted ids - * (`email.marketing`) are banned because the dot would pretend to be a `parent` - * link, creating a second, conflicting source of truth for the tree. The tree - * lives only in `parent` (see docs/ideas/surface-schema.md). + * Flat slug for surface ids. The dot is excluded: a dotted id (`email.marketing`) + * would pretend to be a `parent` link, creating a second source of truth for the + * tree. Containment lives only in `parent`. */ const SurfaceIdSchema = z .string() @@ -15,29 +14,18 @@ const SurfaceIdSchema = z "surface id must be a flat slug (lowercase alphanumeric plus _ -, no dots; the tree lives in parent)", }); -const SurfaceEdgeSchema = z - .object({ - kind: z.enum(GHOST_SURFACE_EDGE_KINDS), - to: SurfaceIdSchema, - }) - .strict(); - const SurfaceSchema = z .object({ id: SurfaceIdSchema, description: z.string().min(1).optional(), parent: SurfaceIdSchema.optional(), - edges: z.array(SurfaceEdgeSchema).optional(), }) .strict(); /** - * Zod schema for `surfaces.yml` (`ghost.surfaces/v1`). - * - * This validates each node in isolation. Graph-level rules that need the whole - * document — parent references an existing id, no cycles, edge `to` exists, - * reserved `core`, near-miss ids — are deferred to Phase 2 lint, because Zod - * cannot see the rest of the tree from a single node. + * Zod schema for `surfaces.yml` (`ghost.surfaces/v1`) — the optional terse spine + * file. Validates each position in isolation; graph-level rules (parent exists, + * no cycles) are covered by the node-graph lint after the fold. */ export const GhostSurfacesSchema = z .object({ diff --git a/packages/ghost/src/ghost-core/surfaces/types.ts b/packages/ghost/src/ghost-core/surfaces/types.ts index f298364a..2a31aebc 100644 --- a/packages/ghost/src/ghost-core/surfaces/types.ts +++ b/packages/ghost/src/ghost-core/surfaces/types.ts @@ -1,40 +1,25 @@ export const GHOST_SURFACES_SCHEMA = "ghost.surfaces/v1" as const; export const GHOST_SURFACES_YML_FILENAME = "surfaces.yml" as const; -/** - * The fixed, Ghost-owned edge vocabulary for the composition graph. - * - * Closed by design (see docs/ideas/surface-schema.md): an open vocabulary would - * make Ghost a general-purpose graph database and lose the interface-composition - * focus. Edges express how interface surfaces relate; richer consumers extend - * edges consumer-side, never by opening this set. This is a code constant, never - * package data. - */ -export const GHOST_SURFACE_EDGE_KINDS = ["composes", "governed-by"] as const; -export type GhostSurfaceEdgeKind = (typeof GHOST_SURFACE_EDGE_KINDS)[number]; - -/** The implicit root surface every surface ultimately descends from. */ +/** The implicit root every node ultimately descends from. */ export const GHOST_SURFACE_ROOT_ID = "core" as const; -export interface GhostSurfaceEdge { - kind: GhostSurfaceEdgeKind; - to: string; -} - +/** + * `surfaces.yml` is an optional terse spine file: a place to declare bare tree + * positions (id + parent) in one file rather than as bodyless node files. It + * folds into the same node id space at load time — a position that needs + * guidance is simply a node with that id. Lateral composition lives on node + * `relates`, never here (the old surface edge vocabulary is gone). + */ export interface GhostSurface { id: string; description?: string; /** - * The single containment parent. Absent means a top-level surface under the - * implicit `core` root. This is the only place containment is expressed; the - * id never encodes hierarchy (see GhostSurfacesSchema id rules). + * The single containment parent. Absent means a top-level position under the + * implicit `core` root. Containment lives only here; the id never encodes + * hierarchy (see GhostSurfacesSchema id rules). */ parent?: string; - /** - * Typed composition edges to other surfaces (the Layer 3 composition graph). - * Edges never imply containment and never cascade. - */ - edges?: GhostSurfaceEdge[]; } export interface GhostSurfacesDocument { diff --git a/packages/ghost/src/scan/fingerprint-package-layers.ts b/packages/ghost/src/scan/fingerprint-package-layers.ts index 365496ab..860051a9 100644 --- a/packages/ghost/src/scan/fingerprint-package-layers.ts +++ b/packages/ghost/src/scan/fingerprint-package-layers.ts @@ -91,6 +91,9 @@ async function loadInheritedNodes( if (node.origin === "inherited") continue; // no transitive extends in v1 out.push({ id: `${id}:${node.id}`, + ...(node.description !== undefined + ? { description: node.description } + : {}), relates: [], ...(node.incarnation !== undefined ? { incarnation: node.incarnation } diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 91755983..52a7c6a9 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -27,18 +27,21 @@ markdown rules an agent evaluates. Ghost is not a lifecycle manager, proposal sy design-system registry, or screenshot archive. The fingerprint is a graph of **nodes**. A node is a markdown file: -frontmatter (`id`, `under`, `relates`, `incarnation`) + a prose body. -**Intent + inventory + composition** are the authoring lenses the body is +frontmatter (`id`, `description`, `under`, `relates`, `incarnation`) + a prose +body. **Intent + inventory + composition** are the authoring lenses the body is written through — they guide what to capture, they are not fields or node types: - intent — the why and the stance. - inventory — the materials and pointers to implementation the agent can inspect. - composition — the patterns that make the surface feel intentional. -`under` places a node so it is inherited downward (`core` is the implicit root -that reaches every surface); `relates` links nodes laterally; `incarnation` tags -a medium-bound expression (essence is untagged). See -[references/capture.md](references/capture.md) for the full node shape. +`description` is the retrieval payload — a one-line "what this is / when to +gather it" (like a tool's name + description); `ghost gather` with no argument +lists nodes by id + description for the agent to match against. `under` places a +node so it is inherited downward (`core` is the implicit root that reaches every +surface); `relates` links nodes laterally; `incarnation` tags a medium-bound +expression (essence is untagged). Free-form keys (`audience`, …) pass through. +See [references/capture.md](references/capture.md) for the full node shape. Checks and review validate output; they are not generation input. diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index 8d1c408b..0827298e 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -35,17 +35,25 @@ folds together; `ghost gather ` traverses it. ```markdown --- id: checkout-trust # required: unique, stable -under: checkout # optional: parent surface/node — inherited downward +description: Trust at the payment moment. # the retrieval payload (see below) +under: checkout # optional: parent — inherited downward relates: # optional: lateral links - to: core-trust as: reinforces # reinforces | contrasts | variant incarnation: web # optional: email | billboard | voice | … (omit = essence) +# free-form keys (audience, stage, …) are allowed and pass through untouched --- Near the moment of payment, reduce felt risk. Proximity of reassurance to the action beats completeness… ``` +- **`description`** is how an agent finds the node — a one-line "what this is and + when to gather it," exactly like a tool's name + description. `ghost gather` + with no argument lists nodes by id + description; the agent matches the ask + against those and names one. The body is the node's "implementation"; the + description is what makes it discoverable. Write one on any node worth + anchoring a task at. - **`under`** places the node — a node inherits everything it sits under. The brand soul lives at `core` (implicit root), so `core`-placed nodes reach every surface. diff --git a/packages/ghost/test/ghost-core/node-schema.test.ts b/packages/ghost/test/ghost-core/node-schema.test.ts index 5ea3c89a..bbf5f7b3 100644 --- a/packages/ghost/test/ghost-core/node-schema.test.ts +++ b/packages/ghost/test/ghost-core/node-schema.test.ts @@ -77,8 +77,15 @@ describe("ghost.node/v1 schema", () => { ); }); - it("rejects unknown frontmatter keys (strict)", () => { - expect(lintGhostNode(node("id: a\nsurface: checkout")).errors).toBe(1); + it("passes through free-form descriptive keys (e.g. audience)", () => { + // Authors may add descriptive keys; Ghost does not gate on them. + expect(lintGhostNode(node("id: a\naudience: enterprise")).errors).toBe(0); + }); + + it("accepts a description (the retrieval payload)", () => { + expect( + lintGhostNode(node("id: email\ndescription: Lifecycle email.")).errors, + ).toBe(0); }); it("round-trips through serialize/parse", () => { diff --git a/packages/ghost/test/ghost-core/surfaces-lint.test.ts b/packages/ghost/test/ghost-core/surfaces-lint.test.ts index 8860a515..7a2725b9 100644 --- a/packages/ghost/test/ghost-core/surfaces-lint.test.ts +++ b/packages/ghost/test/ghost-core/surfaces-lint.test.ts @@ -13,20 +13,13 @@ function rules(report: { issues: { rule: string }[] }): string[] { } describe("lintGhostSurfaces", () => { - it("passes a valid tree with cross-linked edges", () => { + it("passes a valid tree (id + parent + description)", () => { const report = lintGhostSurfaces( doc([ { id: "core", description: "True everywhere." }, { id: "email", parent: "core" }, { id: "email-marketing", parent: "email" }, - { - id: "checkout", - parent: "core", - edges: [ - { kind: "composes", to: "email" }, - { kind: "governed-by", to: "email-marketing" }, - ], - }, + { id: "checkout", parent: "core" }, ]), ); @@ -87,33 +80,6 @@ describe("lintGhostSurfaces", () => { expect(rules(report)).toContain("surface-parent-cycle"); }); - it("errors on an edge target that matches no surface", () => { - const report = lintGhostSurfaces( - doc([{ id: "checkout", edges: [{ kind: "composes", to: "nope" }] }]), - ); - - expect(rules(report)).toContain("surface-edge-unknown"); - }); - - it("allows an edge cycle (edges may form a graph)", () => { - const report = lintGhostSurfaces( - doc([ - { id: "a", parent: "core", edges: [{ kind: "composes", to: "b" }] }, - { id: "b", parent: "core", edges: [{ kind: "composes", to: "a" }] }, - ]), - ); - - expect(report.errors).toBe(0); - }); - - it("does not exempt edge targets with the implicit core (edges must point at declared surfaces)", () => { - const report = lintGhostSurfaces( - doc([{ id: "checkout", edges: [{ kind: "governed-by", to: "core" }] }]), - ); - - expect(rules(report)).toContain("surface-edge-unknown"); - }); - it("errors on duplicate ids", () => { const report = lintGhostSurfaces( doc([ diff --git a/packages/ghost/test/ghost-core/surfaces-schema.test.ts b/packages/ghost/test/ghost-core/surfaces-schema.test.ts index a42dbdf0..13d39dd6 100644 --- a/packages/ghost/test/ghost-core/surfaces-schema.test.ts +++ b/packages/ghost/test/ghost-core/surfaces-schema.test.ts @@ -18,21 +18,14 @@ describe("ghost.surfaces/v1", () => { }); }); - it("accepts a realistic tree with typed composition edges", () => { + it("accepts a realistic tree (id + parent + optional description)", () => { const result = GhostSurfacesSchema.safeParse({ schema: GHOST_SURFACES_SCHEMA, surfaces: [ { id: "core", description: "True everywhere." }, { id: "email", description: "Lifecycle email.", parent: "core" }, { id: "email-marketing", parent: "email" }, - { - id: "checkout", - parent: "core", - edges: [ - { kind: "composes", to: "payments" }, - { kind: "governed-by", to: "consent" }, - ], - }, + { id: "checkout", parent: "core" }, ], }); @@ -59,11 +52,11 @@ describe("ghost.surfaces/v1", () => { expect(result.success).toBe(false); }); - it("rejects an unknown edge kind", () => { + it("rejects an unknown surface key (strict; edges are gone)", () => { const result = GhostSurfacesSchema.safeParse({ schema: GHOST_SURFACES_SCHEMA, surfaces: [ - { id: "checkout", edges: [{ kind: "see-also", to: "payments" }] }, + { id: "checkout", edges: [{ kind: "composes", to: "payments" }] }, ], }); @@ -80,15 +73,12 @@ describe("ghost.surfaces/v1", () => { expect(result.success).toBe(false); }); - it("accepts an edge `to` that does not exist as a surface", () => { - // INTENTIONAL: dangling-reference detection is a Phase 2 lint concern, not a - // schema concern. Zod validates a node in isolation and cannot see the rest - // of the tree. Do not "fix" this at the schema layer — it belongs in lint. + it("accepts a parent that does not exist as a surface", () => { + // INTENTIONAL: dangling-reference detection is a lint concern, not a schema + // concern. Zod validates a position in isolation and cannot see the tree. const result = GhostSurfacesSchema.safeParse({ schema: GHOST_SURFACES_SCHEMA, - surfaces: [ - { id: "checkout", edges: [{ kind: "composes", to: "nonexistent" }] }, - ], + surfaces: [{ id: "checkout", parent: "nonexistent" }], }); expect(result.success).toBe(true); From 902f7ca61f111a0cc5a936438328f2b08192f002 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:15:53 -0400 Subject: [PATCH 3/7] refactor: remove resources.yml/patterns.yml + dead yml residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the pre-graph standalone artifacts ghost.resources/v1 and ghost.patterns/v1 (modules, file-kinds, lint fns, path fields, test) — never part of the node/graph model. Prune dead constants (RESOURCES/PATTERNS/ FINGERPRINT_YML/FINGERPRINT/FINGERPRINTS_DIRNAME/SCOPE_SURVEYS/CHECKS_FILENAME), all dead public re-exports. Scrub the skill bundle of facet-era residue: delete voice.md + patterns.md, rewrite schema.md and recall.md to the node model, and remove every 'facet' reference across the bundle. Remaining schemas are exactly the graph model: node, check, surfaces, fingerprint-package, advisory-review. The intent/inventory/composition filename constants stay only for legacy-package detection -> migrate guidance. All green: 109 tests, full check, install bundle 10 files. --- apps/docs/src/generated/cli-manifest.json | 2 +- install/manifest.json | 4 +- packages/ghost/src/fingerprint.ts | 10 -- packages/ghost/src/ghost-core/index.ts | 38 ----- .../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/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 ------- packages/ghost/src/scan/constants.ts | 26 +--- packages/ghost/src/scan/file-kind.ts | 54 ++----- .../ghost/src/scan/fingerprint-package.ts | 6 - packages/ghost/src/skill-bundle/SKILL.md | 6 +- .../references/authoring-scenarios.md | 51 +++---- .../src/skill-bundle/references/critique.md | 6 +- .../src/skill-bundle/references/patterns.md | 84 ----------- .../src/skill-bundle/references/recall.md | 20 +-- .../src/skill-bundle/references/schema.md | 100 ++++++++----- .../src/skill-bundle/references/verify.md | 2 +- .../src/skill-bundle/references/voice.md | 46 ------ .../ghost-core/resources-patterns.test.ts | 79 ---------- 24 files changed, 124 insertions(+), 919 deletions(-) delete mode 100644 packages/ghost/src/ghost-core/patterns/index.ts delete mode 100644 packages/ghost/src/ghost-core/patterns/lint.ts delete mode 100644 packages/ghost/src/ghost-core/patterns/schema.ts delete mode 100644 packages/ghost/src/ghost-core/patterns/types.ts delete mode 100644 packages/ghost/src/ghost-core/resources/index.ts delete mode 100644 packages/ghost/src/ghost-core/resources/lint.ts delete mode 100644 packages/ghost/src/ghost-core/resources/schema.ts delete mode 100644 packages/ghost/src/ghost-core/resources/types.ts delete mode 100644 packages/ghost/src/skill-bundle/references/patterns.md delete mode 100644 packages/ghost/src/skill-bundle/references/voice.md delete mode 100644 packages/ghost/test/ghost-core/resources-patterns.test.ts diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index f68280d7..8a9a5f0a 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T13:00:23.219Z", + "generatedAt": "2026-06-28T13:14:25.161Z", "tools": [ { "tool": "ghost", diff --git a/install/manifest.json b/install/manifest.json index 38410f00..a5c27e6f 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -12,12 +12,10 @@ "references/brief.md", "references/capture.md", "references/critique.md", - "references/patterns.md", "references/recall.md", "references/remediate.md", "references/review.md", "references/schema.md", - "references/verify.md", - "references/voice.md" + "references/verify.md" ] } diff --git a/packages/ghost/src/fingerprint.ts b/packages/ghost/src/fingerprint.ts index 107f0890..3f46d57f 100644 --- a/packages/ghost/src/fingerprint.ts +++ b/packages/ghost/src/fingerprint.ts @@ -1,16 +1,6 @@ export { - CHECKS_FILENAME, - FINGERPRINT_COMPOSITION_FILENAME, - FINGERPRINT_FILENAME, - FINGERPRINT_INTENT_FILENAME, - FINGERPRINT_INVENTORY_FILENAME, FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_PACKAGE_DIR, - FINGERPRINT_YML_FILENAME, - FINGERPRINTS_DIRNAME, - PATTERNS_FILENAME, - RESOURCES_FILENAME, - SCOPE_SURVEYS_DIRNAME, } from "./scan/constants.js"; export type { FingerprintPackagePaths, diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 499dc708..3daa70b3 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -65,44 +65,6 @@ export { GHOST_FINGERPRINT_PACKAGE_SCHEMA, GhostFingerprintPackageManifestSchema, } from "./package-manifest.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"; -// --- 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"; // --- Inventory scan output types --- export type { GitInfo, diff --git a/packages/ghost/src/ghost-core/patterns/index.ts b/packages/ghost/src/ghost-core/patterns/index.ts deleted file mode 100644 index a12a2d37..00000000 --- a/packages/ghost/src/ghost-core/patterns/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index b2090e56..00000000 --- a/packages/ghost/src/ghost-core/patterns/lint.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index 14926b7a..00000000 --- a/packages/ghost/src/ghost-core/patterns/schema.ts +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 7685af5e..00000000 --- a/packages/ghost/src/ghost-core/patterns/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -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/resources/index.ts b/packages/ghost/src/ghost-core/resources/index.ts deleted file mode 100644 index 54a611ab..00000000 --- a/packages/ghost/src/ghost-core/resources/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 66555c3c..00000000 --- a/packages/ghost/src/ghost-core/resources/lint.ts +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index c912064d..00000000 --- a/packages/ghost/src/ghost-core/resources/schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index b36c0e0b..00000000 --- a/packages/ghost/src/ghost-core/resources/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/scan/constants.ts b/packages/ghost/src/scan/constants.ts index a69bf53d..161b6be5 100644 --- a/packages/ghost/src/scan/constants.ts +++ b/packages/ghost/src/scan/constants.ts @@ -1,31 +1,13 @@ /** 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"; - -/** Canonical product-surface composition artifact. */ -export const FINGERPRINT_YML_FILENAME = "fingerprint.yml"; - /** Portable fingerprint package manifest filename. */ export const FINGERPRINT_MANIFEST_FILENAME = "manifest.yml"; -/** Core portable fingerprint facet filenames. */ +/** + * Legacy facet filenames — retained only so the loader can detect a + * pre-graph package and guide the user to `ghost migrate`. + */ export const FINGERPRINT_INTENT_FILENAME = "intent.yml"; export const FINGERPRINT_INVENTORY_FILENAME = "inventory.yml"; export const FINGERPRINT_COMPOSITION_FILENAME = "composition.yml"; - -/** 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 = "validate.yml"; diff --git a/packages/ghost/src/scan/file-kind.ts b/packages/ghost/src/scan/file-kind.ts index 252e2bd6..539f58bc 100644 --- a/packages/ghost/src/scan/file-kind.ts +++ b/packages/ghost/src/scan/file-kind.ts @@ -3,26 +3,22 @@ import { GhostFingerprintPackageManifestSchema, lintGhostCheck, lintGhostNode, - lintGhostPatterns, - lintGhostResources, lintGhostSurfaces, } from "#ghost-core"; import type { LintReport } from "./lint.js"; export type DetectedFileKind = | "fingerprint-manifest" - | "resources" - | "patterns" | "surfaces" | "check" | "node" | "unsupported"; /** - * Decide whether a file is a bundle artifact. JSON paths/contents route to - * the survey linter; YAML schemas and canonical package filenames route to - * their artifact linters. Unknown YAML remains unsupported instead of being - * guessed as `validate.yml`. + * Decide whether a file is a bundle artifact. Canonical filenames and YAML + * `schema:` markers route to their artifact linters; markdown under `nodes/` + * or `checks/` routes to the node / check linter. Unknown files remain + * unsupported instead of being guessed at. */ export function detectFileKind(path: string, raw: string): DetectedFileKind { const lowerPath = path.toLowerCase(); @@ -33,10 +29,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (filename === "manifest.yaml") { return "fingerprint-manifest"; } - if (filename === "resources.yml") return "resources"; - if (filename === "resources.yaml") return "resources"; - if (filename === "patterns.yml") return "patterns"; - if (filename === "patterns.yaml") return "patterns"; if (filename === "surfaces.yml") return "surfaces"; if (filename === "surfaces.yaml") return "surfaces"; // A markdown check lives under a `checks/` directory. Detected by location so @@ -51,8 +43,6 @@ export function detectFileKind(path: string, raw: string): DetectedFileKind { if (/^\s*schema:\s*ghost\.fingerprint-package\/v1\b/m.test(raw)) { return "fingerprint-manifest"; } - 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\.surfaces\/v1\b/m.test(raw)) return "surfaces"; return "unsupported"; } @@ -63,17 +53,13 @@ export function lintDetectedFileKind( ): LintReport { return kind === "fingerprint-manifest" ? lintFingerprintManifestFile(raw) - : kind === "resources" - ? lintResourcesFile(raw) - : kind === "patterns" - ? lintPatternsFile(raw) - : kind === "surfaces" - ? lintSurfacesFile(raw) - : kind === "check" - ? lintGhostCheck(raw) - : kind === "node" - ? lintGhostNode(raw) - : lintUnsupportedFile(); + : kind === "surfaces" + ? lintSurfacesFile(raw) + : kind === "check" + ? lintGhostCheck(raw) + : kind === "node" + ? lintGhostNode(raw) + : lintUnsupportedFile(); } function lintFingerprintManifestFile(raw: string): LintReport { @@ -112,22 +98,6 @@ function zodLintReport(result: { }; } -function lintResourcesFile(raw: string): LintReport { - try { - return lintGhostResources(parseYaml(raw)); - } catch (err) { - return yamlErrorReport("resources-not-yaml", "resources file", err); - } -} - -function lintPatternsFile(raw: string): LintReport { - try { - return lintGhostPatterns(parseYaml(raw)); - } catch (err) { - return yamlErrorReport("patterns-not-yaml", "patterns file", err); - } -} - function lintSurfacesFile(raw: string): LintReport { try { return lintGhostSurfaces(parseYaml(raw)); @@ -143,7 +113,7 @@ function lintUnsupportedFile(): LintReport { severity: "error", rule: "unsupported-artifact", message: - "File is not a recognized Ghost artifact. Use manifest.yml, surfaces.yml, resources.yml, patterns.yml, a checks/*.md check, or a nodes/*.md node.", + "File is not a recognized Ghost artifact. Use manifest.yml, surfaces.yml, a checks/*.md check, or a nodes/*.md node.", }, ], errors: 1, diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index b08c6576..2d4a58cf 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -14,8 +14,6 @@ import { FINGERPRINT_INVENTORY_FILENAME, FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_PACKAGE_DIR, - PATTERNS_FILENAME, - RESOURCES_FILENAME, } from "./constants.js"; import { lintFingerprintPackageManifest, @@ -37,8 +35,6 @@ export interface FingerprintPackagePaths { surfaces: string; /** The `nodes/` directory holding `ghost.node/v1` markdown nodes. */ nodes: string; - resources: string; - patterns: string; /** Legacy facet paths — used only to detect legacy packages for migration. */ intent: string; inventory: string; @@ -78,8 +74,6 @@ export function resolveFingerprintPackage( manifest: join(packageDir, FINGERPRINT_MANIFEST_FILENAME), surfaces: join(packageDir, GHOST_SURFACES_YML_FILENAME), nodes: join(packageDir, "nodes"), - resources: join(dir, RESOURCES_FILENAME), - patterns: join(dir, PATTERNS_FILENAME), intent: join(packageDir, FINGERPRINT_INTENT_FILENAME), inventory: join(packageDir, FINGERPRINT_INVENTORY_FILENAME), composition: join(packageDir, FINGERPRINT_COMPOSITION_FILENAME), diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 52a7c6a9..3f97a616 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -89,8 +89,6 @@ Inherited nodes are read-only and flow into gather/validate like local ones. - Collaborative authoring scenarios: follow [references/authoring-scenarios.md](references/authoring-scenarios.md). - Fingerprint capture: follow [references/capture.md](references/capture.md). -- Author fingerprint patterns: follow [references/patterns.md](references/patterns.md). -- Capture voice and language: follow [references/voice.md](references/voice.md). - Recall surface-composition context: follow [references/recall.md](references/recall.md). - Shape a pre-generation brief: follow [references/brief.md](references/brief.md). - Critique generated or changed work: follow [references/critique.md](references/critique.md). @@ -101,11 +99,11 @@ Inherited nodes are read-only and flow into gather/validate like local ones. When the user asks to set up a fingerprint with `auto-draft`, treat that as an agent authoring mode, not a Ghost CLI command. Follow the auto-draft branch in the capture and authoring-scenarios recipes: scan first, draft the smallest -evidence-backed facet entries, then ask the human to curate the claims. +evidence-backed node drafts, then ask the human to curate the claims. ## Always -- Treat checked-in Ghost package facet files as the source of truth. +- Treat checked-in Ghost package nodes as the source of truth. - Generate from intent, inventory, and composition. - Name touched surfaces to `ghost checks --surface`; the agent evaluates the markdown checks it governs. - Use local evidence as provisional when the fingerprint is silent. diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index ec497bcf..0fc69c19 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -4,7 +4,7 @@ description: Choose the right human-agent workflow for authoring Ghost fingerpri handoffs: - label: Inspect fingerprint contribution command: ghost scan --format json - prompt: Classify this repo's fingerprint authoring scenario and summarize absent facets. + prompt: Classify this repo's fingerprint authoring scenario and summarize node/surface contribution. --- # Recipe: Collaborative Fingerprint Authoring @@ -17,12 +17,12 @@ stories, and UI libraries provide evidence. Agent synthesis is draft work until the human curates it and ordinary Git review accepts it. `auto-draft` is an optional skill mode for reducing blank-page cost. It scans -first and writes starter facet edits, but those edits are still draft work +first and writes starter node edits, but those edits are still draft work until the human curates them and Git review accepts them. ## 1. Classify The Scenario -Choose the nearest scenario before writing fingerprint facets: +Choose the nearest scenario before writing nodes: | Scenario | Default authoring posture | | --- | --- | @@ -55,7 +55,7 @@ Ask only high-leverage questions that change the fingerprint: - Where do trust, density, pacing, accessibility, recovery, or disclosure matter most? - Are there surfaces where the same UI decision should be assessed differently? -Use human-authored or human-approved answers in `intent.yml`. Do not treat +Capture human-authored or human-approved answers as nodes. Do not treat unapproved notes as canonical. When auto-draft is requested, move the interview after the starter draft and @@ -74,31 +74,25 @@ Optional signals: ghost signals . ``` -Treat signals as scratch evidence. They can support curated entries in -`inventory.yml`, but it does not establish surface-composition guidance by -itself. +Treat signals as scratch evidence. They can support curated node bodies, but +raw signals do not establish surface-composition guidance by themselves. In auto-draft mode, always gather signals before drafting, then inspect the -high-signal files they point to. Signal facts may seed `inventory.yml`; scan -frequency and raw signals do not establish surface-composition guidance. +high-signal files they point to. Signal facts may seed a node's inventory +content; scan frequency and raw signals do not establish guidance. -## 4. Draft The Core Facets +## 4. Draft The Nodes -Write the smallest useful durable content: +Write the smallest useful set of `nodes/*.md`, each a purpose-coherent prose +body with a one-line `description`, placed with `under` and linked with +`relates` where a relationship carries meaning. Write each body through the +intent / inventory / composition lenses — the why, the material (with pointers +to implementation), and how it is assembled. These are lenses, not fields. -- `intent.yml`: product summary, audience, situations, principles, contracts, - anti-goals, and tradeoffs. -- `inventory.yml`: building blocks, source links, and curated - exemplars the agent can inspect or use. -- `composition.yml`: patterns, layouts, structures, flows, states, content, - behavior, and visual arrangements. - -Label uncertain reasoning in the working notes as provisional. Prefer a few -high-confidence claims with evidence over a broad catalog. - -In auto-draft mode, write directly to the facet files rather than a -separate proposal artifact. Keep entries sparse, cite concrete files or -exemplars where possible, and leave ambiguous product meaning for curation. +Label uncertain reasoning as provisional. Prefer a few high-confidence nodes +with evidence over a broad catalog. In auto-draft mode, write nodes directly +(sparse, citing concrete files where possible) and leave ambiguous product +meaning for curation. ## 5. Curate With The Human @@ -113,7 +107,7 @@ important claims: - convert into a deterministic check Only add checks when the rule can be enforced deterministically. Subjective -composition critique belongs in `composition.yml` or advisory review, not in a +composition critique belongs in a node body or advisory review, not in a blocking gate. ## 6. Decide Surfaces @@ -135,7 +129,7 @@ Place local obligations on the surface that owns them. ## 7. Validate And Ratify -Validate before calling facets useful: +Validate before calling the fingerprint useful: ```bash ghost validate .ghost @@ -143,12 +137,11 @@ ghost check --base HEAD ``` Use ordinary Git review as the approval boundary. Uncommitted or unmerged -fingerprint edits are drafts; checked-in Ghost package facet files are the -canonical package. +fingerprint edits are drafts; checked-in nodes are the canonical package. ## Never -- Never copy raw inventory into canonical facets without curation. +- Never copy raw signals into canonical nodes without curation. - Never claim scan frequency is product authority. - Never create surfaces just to mirror directory structure. - Never turn advisory composition critique into a deterministic gate. diff --git a/packages/ghost/src/skill-bundle/references/critique.md b/packages/ghost/src/skill-bundle/references/critique.md index 3928de49..fca33b16 100644 --- a/packages/ghost/src/skill-bundle/references/critique.md +++ b/packages/ghost/src/skill-bundle/references/critique.md @@ -1,6 +1,6 @@ --- name: critique -description: Critique generated or changed UI using Ghost fingerprint facets. +description: Critique generated or changed UI using Ghost fingerprint nodes. --- # Recipe: Critique Generated Work @@ -12,11 +12,11 @@ description: Critique generated or changed UI using Ghost fingerprint facets. 5. Lead with actionable findings. Cite diff locations, fingerprint refs, inventory exemplars, active checks, selected-context gaps, and repairs where relevant. -When fingerprint facets are silent, you may use nearby product surfaces, local +When fingerprint nodes are silent, you may use nearby product surfaces, local components, token and copy conventions. Label that reasoning as provisional and non-Ghost-backed. Do not make advisory taste critique sound blocking unless an active check backs -it. If fingerprint grounding or facet coverage is missing or contradictory, +it. If fingerprint grounding or node coverage is missing or contradictory, name that as `missing-fingerprint` or `experience-gap`; edit the Ghost package only when the user asks you to. diff --git a/packages/ghost/src/skill-bundle/references/patterns.md b/packages/ghost/src/skill-bundle/references/patterns.md deleted file mode 100644 index 78b90329..00000000 --- a/packages/ghost/src/skill-bundle/references/patterns.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: patterns -description: Author surface-composition patterns inside .ghost/composition.yml. -handoffs: - - label: Verify fingerprint package - command: ghost validate .ghost - prompt: Verify the root fingerprint package ---- - -# Recipe: Author Fingerprint Patterns - -**Goal:** write useful `patterns[]` entries in `.ghost/composition.yml`. - -Patterns are durable surface-composition guidance. They may describe rules, -layouts, structures, flows, states, content, behavior, or visual arrangements. -They are not a raw inventory of everything the repo does. - -## When To Add A Pattern - -Add a pattern when it helps a future agent choose or review: - -- a repeated layout or surface structure -- a content or disclosure convention -- a behavior or recovery flow -- a visual treatment tied to product meaning -- a restraint rule that preserves hierarchy, density, trust, or pacing - -A strong pattern usually does one of three jobs: - -- selection: it tells the agent what structure, flow, state, content, behavior, - or visual treatment to choose -- restraint: it tells the agent which tempting default to avoid -- review: it gives a future reviewer something observable to check - -Do not add a pattern just because a value or component exists. Put raw -observations in scratch notes; use `ghost signals` when raw repo facts help -find candidate evidence. - -## Shape - -```yaml -patterns: - - id: resource-index-stays-tabular - kind: structure - pattern: Resource index views stay tabular when comparison is the task. - applies_to: - surface_types: [resource-index] - paths: [src/orders] - guidance: - - Preserve row density and sortable columns. - - Avoid decorative card grids for primary comparison views. - evidence: - - path: src/orders/index.tsx -``` - -Allowed `kind` values: - -- `visual` -- `behavior` -- `content` -- `rule` -- `layout` -- `structure` -- `flow` -- `state` - -## Authoring Rules - -- Use stable slugs. -- Keep the pattern actionable for generation and review. -- Cite paths, locators, or notes as evidence. -- Put obligations that affect failure, disclosure, recovery, or trust in - `intent.experience_contracts`, not only `composition.patterns`. -- Put broad surface intent in `intent.principles`. -- Add `check_refs` only when a deterministic check exists in `validate.yml`. - -## Validate - -```bash -ghost validate .ghost -``` - -If a pattern is speculative, do not add it as canonical composition. Leave it in -scratch notes or ask the user whether to edit `composition.yml`. diff --git a/packages/ghost/src/skill-bundle/references/recall.md b/packages/ghost/src/skill-bundle/references/recall.md index febaf872..06d1b593 100644 --- a/packages/ghost/src/skill-bundle/references/recall.md +++ b/packages/ghost/src/skill-bundle/references/recall.md @@ -1,21 +1,23 @@ --- name: recall -description: Recall applicable Ghost fingerprint facets for a task or file path. +description: Recall the applicable Ghost fingerprint nodes for a task. --- # Recipe: Recall Ghost Fingerprint -1. Read checked-in `intent.yml`, `inventory.yml`, and `composition.yml` entries. -2. Select relevant intent, inventory exemplars, composition patterns, and active - checks. -3. Summarize only fingerprint refs that apply to the task. +1. Run `ghost gather` (no argument) to list nodes by id + description. +2. Match the task to one or more nodes by their descriptions; name the node. +3. Run `ghost gather ` to compose its slice (own body + inherited + ancestors + one-hop `relates`), filtered by `--as ` when the + work targets a specific medium. Return: -- Applicable fingerprint refs and short claims. -- Inventory exemplars to inspect when generation or review needs a concrete anchor. +- The gathered nodes and their short claims (cite by node id). +- Related nodes worth inspecting as concrete anchors. - Active checks that may affect the work. - Any gaps where local evidence must carry the reasoning. -If the fingerprint is silent, say that plainly and continue with provisional -local reasoning when safe. Fingerprint edits are ordinary Git-reviewed edits. +If the fingerprint is silent on the task, say that plainly and continue with +provisional local reasoning when safe. Fingerprint edits are ordinary +Git-reviewed edits. diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 1062ad87..a5c1cca1 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -1,52 +1,86 @@ -# Portable Fingerprint Package Schema Reference +--- +name: schema +description: The Ghost fingerprint package shape — nodes, the spine, checks, and extends. +--- + +# Ghost Fingerprint Package Reference Canonical package: ```text .ghost/ - manifest.yml ghost.fingerprint-package/v1 - intent.yml core surface intent - inventory.yml core material and source links - composition.yml core patterns - surfaces.yml optional ghost.surfaces/v1 coordinate space - checks/*.md optional ghost.check/v1 markdown checks - validate.yml optional ghost.validate/v1 gates + manifest.yml ghost.fingerprint-package/v1 — id + optional extends + nodes/*.md ghost.node/v1 — the design expression (the unit) + surfaces.yml optional ghost.surfaces/v1 — a terse spine (id + parent) + checks/*.md optional ghost.check/v1 — agent-evaluated output checks +``` + +Git is the approval boundary: checked-in files are canonical; uncommitted or +unmerged edits are draft work. One contract per package; the contract carries no +paths and infers nothing from repo location. + +## Nodes + +A node is the unit — a markdown file with frontmatter + a prose body: + +```yaml +--- +id: checkout-trust # required, unique +description: Trust at the payment moment. # the retrieval payload +under: checkout # optional parent (inherited downward) +relates: # optional lateral links + - to: core-trust + as: reinforces # reinforces | contrasts | variant +incarnation: web # optional: email | billboard | voice | … (omit = essence) +# free-form keys (audience, stage, …) pass through untouched +--- +Prose design expression. Intent / inventory / composition are authoring +lenses, not fields. ``` -Git is the approval boundary: checked-in Ghost package facet files are -canonical, and uncommitted or unmerged edits are draft work. +`description` is how an agent selects a node (like a tool's name + description). +`under` places the node so it is inherited downward (`core` is the implicit root that +reaches everywhere). `relates` links nodes laterally. `incarnation` tags a +medium-bound expression. The tree lives only in `under`/`surfaces.yml`, never in +the id and never inferred from a path. + +## The spine (optional) -`surfaces.yml` declares the coordinate space — the surfaces a fingerprint's -nodes are placed on (`surface:`) and the containment tree (`parent`) plus typed -composition edges. The contract carries no paths and infers nothing from repo -location. One contract per package; surfaces are the only locality. +`surfaces.yml` is a terse place to declare bare tree positions (id + parent + +optional description) in one file instead of as bodyless node files. It folds +into the same node id space — a position that needs guidance is just a node with +that id. -`ghost gather ` composes a surface's slice (own nodes + inherited -ancestors + edge contributions). With no surface, `gather` returns the surface -menu for the host agent to match against. The agent names the surface from the -prompt and its own repo analysis; Ghost never infers a surface from a path. +```yaml +schema: ghost.surfaces/v1 +surfaces: + - id: checkout + parent: core + description: The purchase flow. +``` -`manifest.yml`: +## Manifest + extends ```yaml schema: ghost.fingerprint-package/v1 -id: local +id: acme-checkout +extends: + brand: ../brand/.ghost # inherit another contract's nodes, by identity ``` -Facet files are raw YAML. Ghost assembles them into an internal -`ghost.fingerprint/v1` document. +A `brand:core-trust` ref in `under`/`relates` resolves into the extended +package's nodes (read-only). Reference is by identity (the `extends` key), never +by path. -Use these typed refs: +## Gather -- `intent.situation:` -- `intent.principle:` -- `intent.experience_contract:` -- `inventory.exemplar:` -- `composition.pattern:` -- `validate.check:` +`ghost gather ` composes a node's slice: its own body + inherited +ancestors + one-hop `relates`, filtered by `--as `. With no +argument, `gather` lists nodes by id + description for the agent to match the ask +against. The agent names the node; Ghost never infers it from a path. -`inventory.sources[].kind` may be `registry`, `file`, `url`, or `package`. +## Checks -`validate.yml` remains deterministic only. Ref-backed -checks are preferred; missing or unresolved derivation refs lint as warnings. -Inventory refs can support a check but do not establish surface guidance alone. +`checks/*.md` are `ghost.check/v1` markdown, placed by `surface:` frontmatter +(unplaced = core = everywhere), routed to touched nodes. They validate generated +output; they are not generation input. Keep them deterministic. diff --git a/packages/ghost/src/skill-bundle/references/verify.md b/packages/ghost/src/skill-bundle/references/verify.md index 767ea82e..e3f0b5e9 100644 --- a/packages/ghost/src/skill-bundle/references/verify.md +++ b/packages/ghost/src/skill-bundle/references/verify.md @@ -22,7 +22,7 @@ Report: - Active-check failures and repairs. - Advisory surface-composition drift with citations. - Missing or unreachable evidence and exemplar paths. -- Provisional local reasoning where fingerprint facets are silent. +- Provisional local reasoning where fingerprint nodes are silent. - Any fingerprint edits the user requested. Fingerprint edits should be validated before handoff. Implementation-only work diff --git a/packages/ghost/src/skill-bundle/references/voice.md b/packages/ghost/src/skill-bundle/references/voice.md deleted file mode 100644 index e56767e8..00000000 --- a/packages/ghost/src/skill-bundle/references/voice.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: voice -description: Capture voice and language guidance into existing Ghost fingerprint facets. ---- - -# Recipe: Capture Voice And Language - -Language maps onto the existing facets; do not invent new schema. Voice and -language flow through `intent.yml` (tone, voice principles, wording contracts), -`inventory.yml` (copy material and writing-standard sources), -`composition.yml` (copy patterns), and `validate.yml` (the detectable subset). - -1. Inventory the user-facing strings: i18n catalogs, error components, - notifications, empty states, onboarding copy. Record durable locations in - `inventory.building_blocks` and strong examples as `inventory.exemplars`. -2. Check `inventory.sources` for a declared writing-standards source. Read it - when present. If the team maintains standards elsewhere, propose adding a - `sources` entry pointing at them instead of copying their content in. -3. Draft the smallest evidence-backed entries: - - Tone words into `intent.summary.tone`. - - Voice rules with rationale into `intent.principles`. - - Surfaces with non-negotiable exact wording into - `intent.experience_contracts`. - - Copy shapes into `composition.patterns` with `kind: content`, including - `anti_patterns` observed in the repo. - - Place each entry in the surface it belongs to so selective context - assembly surfaces it for copy work on that surface and omits it - elsewhere. Brand-wide voice lives in the root surface and cascades down. -4. Promote only the mechanically detectable subset into - `validate.yml`: - - Absolute rules (banned phrases, required boilerplate) become - `forbidden-regex` or `required-regex` checks with `status: active`. - - Recommendations become `status: proposed` so `ghost review` surfaces - them without blocking. - - Contextual guidance stays in composition only. - - Give each check a `derivation` ref back to the intent or composition - entry it enforces. -5. Validate with `ghost validate`, then hand - the draft to the human to curate. Fingerprint edits stay ordinary - uncommitted draft work until Git review accepts them. - -When reviewing copy changes, cite the diff location and the relevant -`intent.principle`, `intent.experience_contract`, or `composition.pattern` refs. -Tone and register findings are advisory unless an active check backs them. -When voice facets are silent, proceed from nearby copy in the repo and label -that reasoning as provisional and non-Ghost-backed. diff --git a/packages/ghost/test/ghost-core/resources-patterns.test.ts b/packages/ghost/test/ghost-core/resources-patterns.test.ts deleted file mode 100644 index c72a100c..00000000 --- a/packages/ghost/test/ghost-core/resources-patterns.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { lintGhostPatterns, lintGhostResources } from "#ghost-core"; - -describe("ghost.resources/v1", () => { - it("accepts a root resource ledger", () => { - const report = lintGhostResources({ - schema: "ghost.resources/v1", - id: "local", - primary: { target: ".", paths: ["."] }, - design_system: [{ id: "ui", target: "../ghost-ui", paths: ["src"] }], - surfaces: [{ id: "settings", locator: "/settings", paths: ["src"] }], - include: ["src/**"], - exclude: ["**/node_modules/**"], - }); - - expect(report.errors).toBe(0); - }); - - it("rejects duplicate resource ids", () => { - const report = lintGhostResources({ - schema: "ghost.resources/v1", - id: "local", - primary: { id: "ui", target: "." }, - design_system: [{ id: "ui", target: "../ghost-ui" }], - }); - - expect(report.errors).toBeGreaterThan(0); - expect( - report.issues.some((issue) => issue.rule === "resource-id-duplicate"), - ).toBe(true); - }); -}); - -describe("ghost.patterns/v1", () => { - it("accepts surface types and composition patterns", () => { - const report = lintGhostPatterns({ - schema: "ghost.patterns/v1", - id: "local", - surface_types: [ - { - id: "settings", - preferred_patterns: ["sectioned-form"], - evidence: [{ surface_id: "surface_1" }], - }, - ], - composition_patterns: [ - { - id: "sectioned-form", - surface_types: ["settings"], - frequency: 3, - confidence: 0.8, - anatomy: { - ordered: ["shell", "header", "sections", "actions"], - required: ["sections"], - }, - evidence: [{ surface_id: "surface_1", locator: "/settings" }], - }, - ], - }); - - expect(report.errors).toBe(0); - }); - - it("rejects unknown pattern references", () => { - const report = lintGhostPatterns({ - schema: "ghost.patterns/v1", - id: "local", - surface_types: [{ id: "settings", preferred_patterns: ["missing"] }], - composition_patterns: [], - }); - - expect(report.errors).toBeGreaterThan(0); - expect( - report.issues.some( - (issue) => issue.rule === "surface-type-pattern-unknown", - ), - ).toBe(true); - }); -}); From 923637aadbbd1ee63164b7a9482d7840596a9606 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:24:58 -0400 Subject: [PATCH 4/7] refactor: prune dead modules and orphan exports (sprawl sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete orphan scan/schema.ts (198-line survey-era color/palette zod schema, zero importers) and scan/package-config.ts (normalizeReferenceInput, dead since --reference was dropped). Remove dead exports: getCommandDiscoveryForCommand, fingerprintPackageDisplayPath, and the LintOptions interface + its re-export — none had any caller. Remaining 'dead-looking' exports (GHOST_*_SCHEMA identity constants, getCommandDiscoveryMetadata, resolveGitRoot) are intentional public API / reserved surface and stay. All green: 109 tests, full check. --- apps/docs/src/generated/cli-manifest.json | 2 +- packages/ghost/src/command-discovery.ts | 7 - packages/ghost/src/fingerprint.ts | 8 +- packages/ghost/src/scan/index.ts | 1 - packages/ghost/src/scan/lint.ts | 7 - packages/ghost/src/scan/package-config.ts | 91 ---------- packages/ghost/src/scan/package-paths.ts | 10 -- packages/ghost/src/scan/schema.ts | 198 ---------------------- 8 files changed, 2 insertions(+), 322 deletions(-) delete mode 100644 packages/ghost/src/scan/package-config.ts delete mode 100644 packages/ghost/src/scan/schema.ts diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 8a9a5f0a..c1c7f3f8 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T13:14:25.161Z", + "generatedAt": "2026-06-28T13:24:33.939Z", "tools": [ { "tool": "ghost", diff --git a/packages/ghost/src/command-discovery.ts b/packages/ghost/src/command-discovery.ts index d91306f0..d07b7222 100644 --- a/packages/ghost/src/command-discovery.ts +++ b/packages/ghost/src/command-discovery.ts @@ -112,13 +112,6 @@ export function getCommandDiscoveryMetadata(): CommandDiscoveryMetadata[] { return COMMAND_METADATA.map((entry) => ({ ...entry })); } -export function getCommandDiscoveryForCommand( - name: string, -): CommandDiscoveryMetadata | undefined { - const entry = METADATA_BY_NAME.get(name); - return entry ? { ...entry } : undefined; -} - export function formatGhostHelp( cli: CAC, sections: HelpSection[], diff --git a/packages/ghost/src/fingerprint.ts b/packages/ghost/src/fingerprint.ts index 3f46d57f..5a7ed793 100644 --- a/packages/ghost/src/fingerprint.ts +++ b/packages/ghost/src/fingerprint.ts @@ -12,10 +12,4 @@ export { loadFingerprintPackage, resolveFingerprintPackage, } from "./scan/fingerprint-package.js"; -export type { - LintIssue, - LintOptions, - LintReport, - LintSeverity, -} from "./scan/lint.js"; -export { normalizeReferenceInput } from "./scan/package-config.js"; +export type { LintIssue, LintReport, LintSeverity } from "./scan/lint.js"; diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index 00e34ba1..5adc7dd8 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -22,7 +22,6 @@ export { migrateLegacyPackage, } from "./migrate-legacy.js"; export { - fingerprintPackageDisplayPath, GHOST_PACKAGE_DIR_ENV, normalizeGhostDir, resolveGhostDirDefault, diff --git a/packages/ghost/src/scan/lint.ts b/packages/ghost/src/scan/lint.ts index fe00579d..6a598a40 100644 --- a/packages/ghost/src/scan/lint.ts +++ b/packages/ghost/src/scan/lint.ts @@ -14,10 +14,3 @@ export interface LintReport { warnings: number; info: number; } - -export interface LintOptions { - /** Treat this set of rules as errors instead of their default severity. */ - strict?: string[]; - /** Silence these rules entirely. */ - off?: string[]; -} diff --git a/packages/ghost/src/scan/package-config.ts b/packages/ghost/src/scan/package-config.ts deleted file mode 100644 index a373665d..00000000 --- a/packages/ghost/src/scan/package-config.ts +++ /dev/null @@ -1,91 +0,0 @@ -export interface ReferenceInventoryInput { - id: string; - source: string; - fingerprint?: string; -} - -export function normalizeReferenceInput( - reference: string, -): ReferenceInventoryInput { - const normalized = reference.replace(/\\/g, "/").replace(/\/+$/, ""); - const explicitRegistry = normalized.startsWith("registry:"); - const isLegacyFingerprint = /(^|\/)fingerprint\.ya?ml$/i.test(normalized); - const isPackageManifest = /(^|\/)manifest\.ya?ml$/i.test(normalized); - const isFingerprint = isLegacyFingerprint || isPackageManifest; - const baseReference = isPackageManifest - ? normalized.replace(/\/manifest\.ya?ml$/i, "") - : isLegacyFingerprint - ? normalized.replace(/\/fingerprint\.ya?ml$/i, "") - : normalized; - const ghostIndex = baseReference.lastIndexOf("/.ghost"); - const sourcePath = - ghostIndex >= 0 - ? baseReference.slice(0, ghostIndex) - : isFingerprint - ? baseReference - : normalized; - const registrySource = inferRegistrySource(normalized, sourcePath); - const source = registrySource - ? registrySource - : normalized.startsWith("npm:") - ? normalized - : normalized.startsWith("workspace:") - ? `workspace:${sourcePath.replace(/^workspace:/, "")}` - : normalized.startsWith("@") - ? `npm:${normalized}` - : `workspace:${sourcePath}`; - const fingerprintBase = normalized.replace(/^workspace:/, ""); - const fingerprint = isFingerprint - ? fingerprintBase - : ghostIndex >= 0 - ? `${fingerprintBase}/manifest.yml` - : undefined; - const referenceIdSource = - source.startsWith("registry:") && - (explicitRegistry || /(^|\/)registry\.json$/i.test(normalized)) - ? source - .slice("registry:".length) - .replace(/\/public\/r\/registry\.json$/i, "") - .replace(/\/r\/registry\.json$/i, "") - .replace(/\/registry\.json$/i, "") - : sourcePath; - return { - id: inferReferenceId(referenceIdSource), - source, - ...(fingerprint ? { fingerprint } : {}), - }; -} - -function inferRegistrySource( - normalized: string, - sourcePath: string, -): string | undefined { - if (normalized.startsWith("registry:")) return normalized; - if (/\/r\/registry\.json$/i.test(normalized)) { - return `registry:${normalized}`; - } - if (/(^|\/)registry\.json$/i.test(normalized)) { - return `registry:${normalized}`; - } - if (inferReferenceId(sourcePath) === "ghost-ui") { - return `registry:${sourcePath}/public/r/registry.json`; - } - return undefined; -} - -function inferReferenceId(source: string): string { - const npmName = source.match(/(?:^npm:)?(@[^/]+\/[^/]+|[^/:]+)$/)?.[1]; - const pathName = source - .replace(/^workspace:/, "") - .replace(/^registry:/, "") - .split("/") - .filter(Boolean) - .at(-1); - const id = (npmName ?? pathName ?? "reference") - .replace(/^@/, "") - .replace(/\//g, "-") - .replace(/[^a-zA-Z0-9._-]+/g, "-") - .replace(/^-|-$/g, "") - .toLowerCase(); - return id || "reference"; -} diff --git a/packages/ghost/src/scan/package-paths.ts b/packages/ghost/src/scan/package-paths.ts index f9608151..6467d2ed 100644 --- a/packages/ghost/src/scan/package-paths.ts +++ b/packages/ghost/src/scan/package-paths.ts @@ -69,13 +69,3 @@ export function resolveGhostDirDefault( : env[GHOST_PACKAGE_DIR_ENV], ); } - -export function fingerprintPackageDisplayPath( - relativeRoot: string, - ghostDir = FINGERPRINT_PACKAGE_DIR, -): string { - const normalizedGhostDir = normalizeGhostDir(ghostDir); - return relativeRoot === "." - ? normalizedGhostDir - : `${relativeRoot}/${normalizedGhostDir}`; -} diff --git a/packages/ghost/src/scan/schema.ts b/packages/ghost/src/scan/schema.ts deleted file mode 100644 index 957801e7..00000000 --- a/packages/ghost/src/scan/schema.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { z } from "zod"; - -const SemanticColorSchema = z.object({ - role: z.string(), - value: z.string(), - oklch: z.tuple([z.number(), z.number(), z.number()]).optional(), -}); - -const ColorRampSchema = z.object({ - steps: z.array(z.string()), - count: z.number(), -}); - -const PaletteSchema = z.object({ - dominant: z.array(SemanticColorSchema), - neutrals: ColorRampSchema, - semantic: z.array(SemanticColorSchema), - saturationProfile: z.enum(["muted", "vibrant", "mixed"]), - contrast: z.enum(["high", "moderate", "low"]), -}); - -const SpacingSchema = z.object({ - scale: z.array(z.number()), - regularity: z.number(), - baseUnit: z.number().nullable(), -}); - -const TypographySchema = z.object({ - families: z.array(z.string()), - sizeRamp: z.array(z.number()), - weightDistribution: z.record(z.string(), z.number()), - lineHeightPattern: z.enum(["tight", "normal", "loose"]), -}); - -const SurfacesSchema = z.object({ - borderRadii: z.array(z.number()), - /** - * Shadow vocabulary expressed as an explicit choice, not an absence. - * `deliberate-none` means "this design language deliberately ships no - * shadows" (Material 3 with elevated surface tints, brutalist UIs); - * `subtle` is a single-tier shadow scale; `layered` is multi-tier. - * - * Phase 4b renamed the prior `none` value to `deliberate-none` so the - * choice reads as a positive design stance rather than as "we forgot." - */ - shadowComplexity: z.enum(["deliberate-none", "subtle", "layered"]), - borderUsage: z.enum(["minimal", "moderate", "heavy"]), - borderTokenCount: z.number().optional(), -}); - -/** - * Frontmatter observation: short machine-tags only. The Character - * paragraph (summary) lives in the body. - */ -const DesignObservationSchema = z - .object({ - personality: z.array(z.string()).optional(), - resembles: z.array(z.string()).optional(), - }) - .strict(); - -/** - * Frontmatter decision: dimension slug + optional kind only. Both the intent - * rationale AND the evidence bullets live in the body under `### dimension` - * → `**Evidence:**`. Evidence in frontmatter is rejected by the strict schema. - * - * `dimension_kind` is the optional canonical-vocabulary mapping used by - * fleet aggregation. See `CANONICAL_DECISION_DIMENSIONS` in `@anarchitecture/ghost/core` - * and the soft `non-canonical-dimension` lint rule for guidance. - */ -const DesignDecisionSchema = z - .object({ - dimension: z.string(), - dimension_kind: z.string().optional(), - }) - .strict(); - -const FingerprintReferencesSchema = z - .object({ - specs: z.array(z.string()).optional(), - components: z.array(z.string()).optional(), - examples: z.array(z.string()).optional(), - }) - .strict(); - -/** - * Schema for the YAML frontmatter in a fingerprint.md file. Covers the - * machine-layer of Fingerprint plus fingerprint-level metadata. - * - * Note: narrative intent fields (observation.summary, - * decisions[].decision) are NOT allowed here — they belong in the body. - * `.strict()` on nested schemas enforces this. - * - * `metadata` is a loose key-value bag for LLM-authored extensions - * (e.g. `tone: "magazine"`) that don't fit the strict structural - * blocks. Opaque to comparisons. - */ -export const FrontmatterSchema = z - .object({ - // meta - name: z.string().optional(), - slug: z.string().optional(), - generator: z.string().optional(), - generated: z.string().optional(), - confidence: z.number().optional(), - /** Relative path to a base fingerprint.md to inherit from. */ - extends: z.string().optional(), - /** Loose passthrough bag for LLM-authored extensions. Opaque to readers. */ - metadata: z.record(z.string(), z.unknown()).optional(), - - // fingerprint — required - id: z.string(), - source: z.enum(["registry", "extraction", "llm", "unknown"]), - timestamp: z.string(), - sources: z.array(z.string()).optional(), - references: FingerprintReferencesSchema.optional(), - - // fingerprint — narrative tags (optional; intent lives in body) - observation: DesignObservationSchema.optional(), - decisions: z.array(DesignDecisionSchema).optional(), - - // fingerprint — structured (required) - palette: PaletteSchema, - spacing: SpacingSchema, - typography: TypographySchema, - surfaces: SurfacesSchema, - }) - .strict(); - -/** - * Relaxed schema for files that declare `extends:`. Children may omit any - * fingerprint field they're inheriting from the base fingerprint. The merged result - * is re-validated against the strict FrontmatterSchema. - */ -export const PartialFrontmatterSchema = z - .object({ - name: z.string().optional(), - slug: z.string().optional(), - generator: z.string().optional(), - generated: z.string().optional(), - confidence: z.number().optional(), - extends: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - - id: z.string().optional(), - source: z.enum(["registry", "extraction", "llm", "unknown"]).optional(), - timestamp: z.string().optional(), - sources: z.array(z.string()).optional(), - references: FingerprintReferencesSchema.optional(), - - observation: DesignObservationSchema.optional(), - decisions: z.array(DesignDecisionSchema).optional(), - - palette: PaletteSchema.optional(), - spacing: SpacingSchema.optional(), - typography: TypographySchema.optional(), - surfaces: SurfacesSchema.optional(), - }) - .strict(); - -export type FrontmatterShape = z.infer; - -/** - * Export the frontmatter schema as a JSON Schema document. - * - * Used to (a) publish schemas/fingerprint.schema.json for IDE autocomplete - * in .md files, and (b) back `ghost fingerprint schema` output. - */ -export function toJsonSchema(): Record { - return z.toJSONSchema(FrontmatterSchema) as Record; -} - -/** - * Parse a frontmatter object with schema validation. Throws a readable - * error that lists every invalid path and the expected type. Unlike - * zod's default message, this surfaces the first ~5 issues inline so the - * user can fix them in one pass. - */ -export function validateFrontmatter( - raw: unknown, - options: { partial?: boolean } = {}, -): FrontmatterShape { - const schema = options.partial ? PartialFrontmatterSchema : FrontmatterSchema; - const result = schema.safeParse(raw); - if (result.success) return result.data as FrontmatterShape; - - const issues = result.error.issues.slice(0, 5).map((iss) => { - const path = iss.path.length ? iss.path.join(".") : "(root)"; - return ` • ${path}: ${iss.message}`; - }); - const more = - result.error.issues.length > 5 - ? `\n … and ${result.error.issues.length - 5} more` - : ""; - throw new Error( - `Invalid fingerprint frontmatter:\n${issues.join("\n")}${more}`, - ); -} From a66e0cbef0a59eb3cf2e4a014e72a831da867e1a Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:29:59 -0400 Subject: [PATCH 5/7] docs: prune superseded idea notes (40 -> 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two eras of phase plans had accumulated. Delete the pre-graph surface-model cutover family (coordinate-space, surface-schema, surface-binding, contract-and-binding, ghost-layers, reset, implementation-plan, phase-{1..8}-plan, phase-7b-*, polish-*, guided-migration) — superseded by the graph reset. Delete the shipped graph-era execution plans (one-road, graph-implementation-plan, phase-{1-node-schema..7-cross-package}) — the implementation is in code and git history is the record. Delete parked-survey-module (survey was since deleted). Keep the durable set: fingerprint-first-architecture (settled), context-graph (the model), scenarios-worked (worked reference), contract-storage + compare-drift-fleet-rethink (open/parked), ghost-ui. Rewrite the README arc to match and fix the one broken cross-link. --- docs/ideas/README.md | 233 +++------------- docs/ideas/context-graph.md | 10 +- docs/ideas/contract-and-binding.md | 212 --------------- docs/ideas/coordinate-space.md | 323 ----------------------- docs/ideas/ghost-layers.md | 136 ---------- docs/ideas/graph-implementation-plan.md | 228 ---------------- docs/ideas/guided-migration.md | 48 ---- docs/ideas/implementation-plan.md | 239 ----------------- docs/ideas/one-road.md | 227 ---------------- docs/ideas/parked-survey-module.md | 63 ----- docs/ideas/phase-1-node-schema.md | 203 -------------- docs/ideas/phase-1-plan.md | 227 ---------------- docs/ideas/phase-2-loader-fold.md | 148 ----------- docs/ideas/phase-2-plan.md | 182 ------------- docs/ideas/phase-3-gather-graph.md | 221 ---------------- docs/ideas/phase-3-plan.md | 205 -------------- docs/ideas/phase-4-checks-graph.md | 158 ----------- docs/ideas/phase-4-plan.md | 154 ----------- docs/ideas/phase-5-authoring.md | 168 ------------ docs/ideas/phase-5-plan.md | 157 ----------- docs/ideas/phase-6-facet-removal.md | 177 ------------- docs/ideas/phase-6-plan.md | 139 ---------- docs/ideas/phase-7-cross-package.md | 195 -------------- docs/ideas/phase-7-plan.md | 169 ------------ docs/ideas/phase-7b-cut3-plan.md | 129 --------- docs/ideas/phase-7b-cut4-plan.md | 120 --------- docs/ideas/phase-7b-grounded-checks.md | 102 ------- docs/ideas/phase-7b-plan.md | 125 --------- docs/ideas/phase-8-plan.md | 127 --------- docs/ideas/polish-cut-c-plan.md | 108 -------- docs/ideas/polish-cut-d-plan.md | 79 ------ docs/ideas/polish-roadmap.md | 118 --------- docs/ideas/reset.md | 178 ------------- docs/ideas/surface-binding.md | 210 --------------- docs/ideas/surface-schema.md | 337 ------------------------ 35 files changed, 42 insertions(+), 5813 deletions(-) delete mode 100644 docs/ideas/contract-and-binding.md delete mode 100644 docs/ideas/coordinate-space.md delete mode 100644 docs/ideas/ghost-layers.md delete mode 100644 docs/ideas/graph-implementation-plan.md delete mode 100644 docs/ideas/guided-migration.md delete mode 100644 docs/ideas/implementation-plan.md delete mode 100644 docs/ideas/one-road.md delete mode 100644 docs/ideas/parked-survey-module.md delete mode 100644 docs/ideas/phase-1-node-schema.md delete mode 100644 docs/ideas/phase-1-plan.md delete mode 100644 docs/ideas/phase-2-loader-fold.md delete mode 100644 docs/ideas/phase-2-plan.md delete mode 100644 docs/ideas/phase-3-gather-graph.md delete mode 100644 docs/ideas/phase-3-plan.md delete mode 100644 docs/ideas/phase-4-checks-graph.md delete mode 100644 docs/ideas/phase-4-plan.md delete mode 100644 docs/ideas/phase-5-authoring.md delete mode 100644 docs/ideas/phase-5-plan.md delete mode 100644 docs/ideas/phase-6-facet-removal.md delete mode 100644 docs/ideas/phase-6-plan.md delete mode 100644 docs/ideas/phase-7-cross-package.md delete mode 100644 docs/ideas/phase-7-plan.md delete mode 100644 docs/ideas/phase-7b-cut3-plan.md delete mode 100644 docs/ideas/phase-7b-cut4-plan.md delete mode 100644 docs/ideas/phase-7b-grounded-checks.md delete mode 100644 docs/ideas/phase-7b-plan.md delete mode 100644 docs/ideas/phase-8-plan.md delete mode 100644 docs/ideas/polish-cut-c-plan.md delete mode 100644 docs/ideas/polish-cut-d-plan.md delete mode 100644 docs/ideas/polish-roadmap.md delete mode 100644 docs/ideas/reset.md delete mode 100644 docs/ideas/surface-binding.md delete mode 100644 docs/ideas/surface-schema.md diff --git a/docs/ideas/README.md b/docs/ideas/README.md index 76a8d358..fcf53822 100644 --- a/docs/ideas/README.md +++ b/docs/ideas/README.md @@ -1,209 +1,50 @@ # Ideas -This folder is for live, non-authoritative exploration that should not be lost -to chat history but is not ready to become public docs or a changeset. +Live, non-authoritative exploration that should not be lost to chat history but +is not yet public docs. Notes are subordinate to `../purposes.md` (one model, +many projections). -The one public doc one level up is `../purposes.md` (one model, many -projections). Older format / loop / adapter / fleet docs were deleted in a -focus pass: they described the pre-redesign Relay-routing and -`topology`/`applies_to` model that `coordinate-space.md` replaces. +This folder is pruned in focus passes: notes that only describe superseded +models, shipped execution plans, or removed commands are deleted — the code and +git history are the record. What remains is either *settled architecture*, +*durable reference*, or *open/parked exploration*. -## The settled center +## Settled -- `fingerprint-first-architecture.md` records the settled product center: - Ghost is fingerprint-first, and drift is one governance workflow over the - portable `.ghost/` package. Everything below is subordinate to it. +- `fingerprint-first-architecture.md` — the product center: Ghost is + fingerprint-first; the durable artifact is the checked-in `.ghost/` package. + Everything else is tooling for or around that contract. -## The reset arc (read in order) +## The model (what shipped) -These notes form one continuous thread from "I overcomplicated this" to a -buildable Layer 2 design. They agree; read them as a sequence. +Ghost is a **curated graph of described nodes**. The full design and its +prior-art lineage live here: -- `../purposes.md` — one model, many projections. The artifact never bends to - serve a consumer. -- `ghost-layers.md` — the five layers Ghost actually has (description, map, - selection, governance, comparison), with each piece of code assigned to a - layer and each leak named. -- `contract-and-binding.md` — the portable-contract vs repo-binding split. - (Now mostly subsumed: the split falls out of `coordinate-space.md` for free.) -- `reset.md` — the stop-circling note. Fixes purpose, goals, layers, and - separation of concerns, and schedules a single first move with everything - else parked. -- `coordinate-space.md` — the clean-room design for Layer 2 (the first cut). A - surface is an author-named group with an optional description; topology is a - two axes — a strict containment tree (Layer 2) plus a typed composition graph - over it (Layer 3); resolution is BYOA (Ghost emits a described menu, the agent - matches); delete list covers `inventory.topology`, smeared `applies_to`, and - `ghost.map/v1`. -- `surface-schema.md` — the first concrete extraction. Proposes - `ghost.surfaces/v1` as a new `surfaces.yml` facet expressing both the - containment `parent` and typed composition `edges`, plus a field-by-field - migration off `topology` / `applies_to` / `surface_type` / `scope` to a - `surface:` placement pointer. Settles closed `edge_kinds`, flat ids, and - explicit placement (no silent global default); the one remaining fork — the - repo binding as scoped ownership — is reframed for its own note. -- `surface-binding.md` — the second concrete extraction. Settles `ghost.binding/v1`: - the contract carries no paths, the binding owns all path matching, with - directory location as the default binding and an explicit `.ghost.bind.yml` as - the escape hatch. Path / prompt / diff all resolve to a surface id through one - resolver; nesting is reframed from data-merge to binding (retiring Leak E). - Records that this is the least proof-validated layer, so it ships smallest-first. -- `implementation-plan.md` — sequences the hard-cutover (breaking, no parallel - model) build in dependency order across eight phases: schema → lint → - placement → delete `ghost.map/v1` → resolver/menu → migration command → - binding → command/skill/docs reconciliation. Marks Phase 3 (removing node - coordinate fields) as the breaking line, with additive Phases 1–2 landed first. -- `phase-1-plan.md` — execution spec for Phase 1: the additive - `ghost-core/surfaces/` module (`ghost.surfaces/v1` schema + types + index + - tests), mirroring the `fingerprint/` module. Bans dotted ids at the schema - layer; defers all graph-level validation (cycles, dangling refs) to Phase 2. - **Shipped** (`cb2b7c4`). -- `phase-2-plan.md` — execution spec for Phase 2: `lintGhostSurfaces` graph - validation (parent refs, tree/no-cycle, edge refs, reserved `core`, duplicate - and near-miss ids) plus `ghost lint` dispatch for `surfaces.yml`. Edge cycles - are allowed; only `parent` is tree-constrained. Still additive. **Shipped** - (`f6b7941`). -- `phase-3-plan.md` — execution spec for Phase 3, **the breaking line**: remove - `topology` / `applies_to` / `surface_type` / `scope` from the canonical - fingerprint and replace with a single `surface:` placement per node, validated - against `surfaces.yml`. Deliberately leaves `check.applies_to` for Phase 4/7 - (it is coupled to map routing). First phase of the major release. **Shipped** - (`6140cd8`). -- `phase-4-plan.md` — execution spec for Phase 4: delete the `ghost.map/v1` - coordinate/routing layer (dormant since Phase 3). Separates the routing layer - (delete) from the inventory-output types incidentally housed in `map/types.ts` - (relocate, not delete). Leaves `check` routing on `applies_to.paths` alone; - surface-based routing is deferred to Phase 7. **Shipped** (`2c22a8c`), with - `ghost-fleet` pulled out of the workspace. -- `phase-5-plan.md` — execution spec for Phase 5, the first **additive** phase: - a surfaces loader (reads `surfaces.yml` into the package model — deferred - since Phase 1), a deterministic slice resolver (own + cascaded ancestors + - typed-edge contributions), a menu emitter, and the new `gather` command - (relay's desire done right). Ambiguity returns the menu, never the whole tree. - Prompt road only; path/diff road is Phase 7. **Shipped** (`5ee6cc0`). -- `phase-6-plan.md` — execution spec for Phase 6: a `ghost migrate` command that - transforms a legacy `.ghost/` (raw YAML, since the schema now rejects legacy - fields) into the surface model — `surfaces.yml` from old `topology.scopes`, - single-scope nodes placed via `surface:`, legacy coordinate fields removed. - Report-don't-guess: ambiguous/unplaceable nodes are surfaced for human review, - never auto-placed. Additive; nothing in this repo needs it (dogfood `.ghost/` - was already removed). **Shipped** (`4f57b73`). -- `phase-7-plan.md` — execution spec for Phase 7, the largest and least - proof-validated cut: `ghost.binding/v1` (`.ghost.bind.yml`), path→surface and - diff→surfaces resolution wired into `gather --path`, `check`, and `review`, - and the retirement of the `child-wins-by-id` merge (Leak E) — nesting becomes - binding, not data-merge. Directory-default binding with an explicit escape - hatch; in-repo `contract: .` only (external references deferred). Flags the - core structural tension (merge → binding-resolution) to resolve before - touching consumers. **Phase 7a shipped** (`37eb562`): the binding + path road - (`ghost.binding/v1`, `resolvePathToSurface`, `gather --path`). The diff road - and merge retirement are reframed into `phase-7b-grounded-checks.md`. -- `phase-7b-grounded-checks.md` — the governance (Layer 4) model, settled after - seeing how checks are really authored: Ghost does **not** run checks. Checks - are markdown rules an agent evaluates; Ghost deterministically **routes** a - diff to the surfaces it touches (via 7a binding) and **grounds** every flag in - that surface's `gather` slice (principles/contracts = why, patterns/exemplars = - what to change). Ghost owns routing + grounding, never the check engine. The - legacy `ghost.validate/v1` detector becomes legacy. Open: check placement, - grounding emit shape, and the still-owed `child-wins-by-id` merge retirement. -- `phase-7b-plan.md` — execution spec for 7b in four ordered cuts: (1) retire - the `child-wins-by-id` merge (Leak E) — independent, riskiest, done first; - (2) define `ghost.check/v1` as markdown + frontmatter with a `surface:`; - (3) surface-routed check relevance (a diff selects the checks governing its - surfaces and ancestors, reusing the Phase 5 cascade); (4) fingerprint - grounding via `review`. `ghost.validate/v1`'s detector kept parseable but no - longer the governance path; full removal deferred. **Cuts 1 & 2 shipped** - (`8b81d76`, `3d042d2`). -- `phase-7b-cut3-plan.md` — execution spec for Cut 3: surface-routed check - relevance. `selectChecksForSurfaces` selects markdown checks governing a diff's - touched surfaces and ancestors (reusing the slice cascade); a checks-dir loader - reads `checks/*.md`; a new additive command prints the relevant checks per - surface. Adds surface routing *beside* the legacy path-glob detector router - rather than replacing it. Grounding deferred to Cut 4. **Shipped** (`b6a8c93`). -- `phase-7b-cut4-plan.md` — execution spec for Cut 4, the final governance cut: - fingerprint grounding. `groundSurface` projects a surface's slice into *why* - (principles/contracts) + *what to change* (patterns/exemplars with paths), - inherited from ancestors like context is. Attached to the Cut 3 `ghost checks` - command (the surface-native path) rather than the legacy `review` packet, so a - flagged check can be grounded in the fingerprint. Ghost still never runs the - check; `review`/`validate/v1` left for a later cut. **Shipped** (`431b20a`) — - Phase 7b complete. -- `phase-8-plan.md` — execution spec for Phase 8, the final phase: delete the - absorbed/dead commands (`relay`, `stack`, `survey`, `diff`, `describe`) and the - relay-only `context/` modules, update the skill bundle to teach surfaces, - regenerate the manifest, fill in the major changeset. Surfaces two - entanglements: `relay` and `review` share `context/` machinery (partition, - don't delete wholesale), and `survey` is a command *and* a module (delete the - command surface only). `review` / `emit` / `validate-v1` / the survey module - left for later cuts. **Shipped** (`c12f8f1`) — the cutover (Phases 1–8) is - complete. -- `polish-roadmap.md` — sequences the four deferred post-cutover cuts. Key - finding: they are not independent. `review`/`emit` sit on both `validate.yml` - and the dormant Job 2 entrypoint, so **Cut A** (move `review`/`emit` onto - `gather`+`checks`) is the keystone that unblocks **Cut B** (delete the dormant - entrypoint) and **Cut C** (`validate/v1` positioning). **Cut D** (external - contract references in bindings) is independent. The `ghost-core/survey` module - removal is held back as a deeper, separate excavation. +- `context-graph.md` — the core model: nodes + `under`/`relates` links + the + `incarnation` tag; OKF as substrate prior-art; `description` as the + tool-style retrieval payload; the conformance invariants. The canonical + reference for what Ghost *is*. +- `scenarios-worked.md` — five worked fingerprints (dashboard, monorepo, + marketing, voice-first app, one-brand superset) that stress-tested the model. + Durable reference for how the shape behaves across mediums. -## Independent, still live +## Open / parked exploration -- `ghost-ui.md` explores additive registry metadata for the private Ghost UI - reference package. Orthogonal to the coordinate redesign. -- `guided-migration.md` explores a future host-agent workflow for migrating one - fingerprint toward another. Layer 5 (comparison); untouched by the redesign. +- `contract-storage.md` — the on-disk organization fork (now largely subsumed by + `context-graph.md`: storage is a free projection over the schema). Kept for the + reasoning. +- `compare-drift-fleet-rethink.md` — **parked.** Concepts held, implementations + removed (they rested on the abandoned quantified-design-system model). Records + the intent and the trigger to rebuild them graph-native. -## Conventions +## Independent + +- `ghost-ui.md` — additive registry metadata for the private Ghost UI package. -- One file per idea, kebab-case slug. -- Add frontmatter with `status: exploring`, `status: deferred`, or - `status: settled`. -- Keep idea notes explicitly subordinate to the current fingerprint package - model. -- Delete notes that only describe superseded package splits, removed commands, - or dead routing/coordinate models after their useful decisions are folded - into current docs. -- `polish-cut-c-plan.md` — execution spec for Cut C, escalated to full removal: - one check format. Deletes `ghost.validate/v1`, `validate.yml`, the `ghost - check` detector gate, and the `./govern` export; rescues `parseUnifiedDiff` - into a neutral module first; preserves the `drift` stance ledger (cleanly - separable from the detector gate). Markdown `ghost.check/v1` becomes the single - check format. -- `polish-cut-d-plan.md` — execution spec for Cut D: external contract references - in bindings. A `.ghost.bind.yml` `contract:` accepts `.` (in-repo) or an npm - package name resolved from `node_modules`; `ghost verify` checks the external - contract resolves and its bound surfaces exist. Resolution + validation only; - external fingerprint loading for grounding is deferred. -- `parked-survey-module.md` — a deliberate decision **not** to act: the - `ghost.survey/v1` module is isolated, works, and is unexposed, so it stays - parked. Removal is an excavation (compare/perceptual-prior may depend on survey - evidence), not a deletion — surfaced only if a concrete reason appears. -- `one-road.md` — a provocation turned decision: remove the binding - (`ghost.binding/v1`, path→surface, Cut D contract resolution) and drive - everything from the prompt. The agent already analyzes the whole repo, so it - states the touched surfaces; Ghost stops inferring intent from location. Four - outcomes collapse into one flow (prompt → menu → `gather `). - `checks`/`review` take agent-stated `--surface`; external contracts via - `gather --package`. Surface engine + nested-package discovery untouched. - Supersedes `surface-binding.md` / Phase 7a / `polish-cut-d-plan.md`. -- `contract-storage.md` — open exploration: the unexamined fork is **facet-first - vs. surface-first** storage, not "one giant yml." Storage is a projection too; - the loader (`assembleFingerprint`) is the only structural boundary that moves, - and the model + every read consumer are untouched. Surface-first colocates each - concept (a surface = a directory), makes `surface:` implicit-by-location - (inside the contract, not the repo), and mirrors the cascade with `core/` as - the cross-cutting home. Lands after one-road. Not decided. +## Conventions -- `context-graph.md` — the reframe that subsumes the storage question: Ghost is - a **curated, opinionated context graph** queried by traversal, not a - file/bucket layout. The substrate (markdown + frontmatter folding into a graph) - is an **OKF-family** convergence we adopt; our deliberate divergences — **typed - links (`under` / `relates`) and the `medium` tag** — are the value. The whole - vocabulary is three nouns (node, link, medium), two link kinds, one tag; - `intent`/`inventory`/`composition` are how the body is written, not types. - See `scenarios-worked.md` for these as fully fleshed-out fingerprints (real - node files, bodies, links, `gather` packets). Includes the full conformance - schema. See `graph-implementation-plan.md` for the sequenced build (grounded in - the current code: the loader seam, `resolveSurfaceSlice` = gather, - `surfaces.yml` = the tree). Includes five - stress-test scenarios (dashboard, monorepo, marketing, voice super app, and one - brand spanning all of them). Downstream of one-road; not decided. +- One file per idea, kebab-case slug, `status:` frontmatter + (`exploring` / `settled` / `parked`). +- Idea notes are subordinate to the fingerprint-package model. +- Delete notes that only describe superseded models, shipped plans, or removed + commands — git is the record. diff --git a/docs/ideas/context-graph.md b/docs/ideas/context-graph.md index e244652c..c044d1d1 100644 --- a/docs/ideas/context-graph.md +++ b/docs/ideas/context-graph.md @@ -4,11 +4,11 @@ status: exploring # The context graph: Ghost as a curated, opinionated graph for generation -This note records a shift in how we frame Ghost's model. It is downstream of -`one-road.md` (remove the binding + nesting) and `contract-storage.md` -(facet-first vs surface-first storage), and it reframes both: the real shape of -the problem is a **curated, opinionated context graph**, and the right context -for an agent to generate an interaction is found by **traversing** it. +This note records the shift in how we frame Ghost's model: the real shape of the +problem is a **curated, opinionated context graph**, and the right context for an +agent to generate an interaction is found by **traversing** it. (It grew out of +two earlier explorations — removing the path binding/nesting, and the +storage-layout fork — both now shipped or subsumed; see git history.) It composes with the build order already set: **one-road first**, storage and anything here after. Nothing here is committed to code. diff --git a/docs/ideas/contract-and-binding.md b/docs/ideas/contract-and-binding.md deleted file mode 100644 index 7022681f..00000000 --- a/docs/ideas/contract-and-binding.md +++ /dev/null @@ -1,212 +0,0 @@ ---- -status: exploring ---- - -# Contract and binding: the two durable artifacts - -> **Mostly subsumed.** The contract/binding split this note proposes now falls -> out of `coordinate-space.md` for free: designing the coordinate space -> medium-agnostically produces the portable-contract-vs-repo-binding split -> without a separate decision. The contract half is designed in -> `surface-schema.md` (`ghost.surfaces/v1`); the binding half in -> `surface-binding.md` (`ghost.binding/v1`). Keep this note for the *sort* -> (which piece goes where) and the artifact rationale; treat those two as the -> live design. - -This note is subordinate to `fingerprint-first-architecture.md` (settled) and a -sibling to `ghost-layers.md` (exploring). It changes neither. The layers note -asks, of each file, *"which operation is this?"* and answers with five layers. -This note asks a different question along a different axis: - -> Of the durable, checked-in thing Ghost produces, **which job is it doing — -> describing a portable surface language, or binding one repo to that -> language?** - -It exists because of a confession, not a refactor: Ghost started as a repo-first -composition guard and was later stretched into a portable, possibly non-UI brand -contract. Both are good. The pain is that **one `.ghost/` artifact was asked to -be both at once.** This note names the seam between those two jobs so every -existing feature gets a home instead of getting cut. - -## The one-line diagnosis - -There are two durable artifacts hiding inside one folder. - -- **The contract** is repo-agnostic. It is Square's surface language: brand - intent, the surfaces it spans (email, web, product, pos, voice), the - coordinate space those surfaces live in, and the patterns that make each feel - intentional. It can be published, versioned, mounted over MCP, and consumed by - many repos — or by no repo at all. It need not be about UI. -- **The binding** is repo-native. It is a thin statement that *this* working - tree is an instance of *that* contract, and that these paths realize these - surfaces. It is where path-first resolution, drift, and checks live, because - those are inherently operations on a working tree. - -Every recent contradiction is this seam showing through. "How does Relay work -from the repo root?" is the seam: the contract answers *from the prompt -coordinate*; the binding answers *from the path*. The question felt -irreconcilable because two artifacts were answering it at once. - -## How this composes with the five layers - -`ghost-layers.md` slices Ghost by **operation** (Description, Map, Selection, -Governance, Comparison). This note slices the same code by **durable artifact**. -They are orthogonal axes over the same files, and they agree: - -| Layer (operation) | Contract (portable) | Binding (repo) | -| --- | --- | --- | -| 1 Description | Owns it. Brand intent/inventory/composition, scoped by surface. | References it. Adds none. | -| 2 Map | Owns it. The coordinate space *is* the contract's spine. | Maps paths → coordinates. | -| 3 Selection | Coordinate → contract slice (prompt-first). | Path → coordinate → slice (path-first). | -| 4 Governance | Declares checks against surfaces. | Runs checks/drift against a real diff. | -| 5 Comparison | The unit compared. | Not involved. | - -The crucial agreement: **Leak A in the layers note (the map trapped inside the -description) is the contract's spine that the binding has been impersonating with -filesystem paths.** Extracting the map (their highest-leverage cut) and splitting -contract from binding (this note's cut) are the same surgery seen from two -angles. Do one and the other falls out. - -## The shape, made concrete - -Contract — portable, lives anywhere, knows nothing about git: - -```text -square-brand/ # an npm package, an MCP resource, a folder - manifest.yml # ghost.contract/v1 (proposed) - map.yml # Layer 2: dimensions + surfaces (the spine) - core/ # true everywhere: brand intent, tokens, voice - intent.yml - inventory.yml - composition.yml - validate.yml - surfaces/ - email/lifecycle/ - intent.yml - inventory.yml - composition.yml - validate.yml - web/public/ ... - product/dashboard/ ... -``` - -Binding — repo-native, tiny, points rather than redefines: - -```text -apps/email-svc/.ghost.bind.yml -``` - -```yaml -schema: ghost.binding/v1 -contract: square-brand # path, npm name, or MCP id -surface: email/lifecycle # a coordinate into the contract map -paths: [apps/email-svc/src] -``` - -A monorepo opened at the root has several bindings, each naming a different -surface of the same contract. Resolution unifies: - -```text -prompt → host extracts coordinate → contract slice (no path needed) -path → binding → coordinate → same contract slice (path is evidence) -``` - -Both roads arrive at one coordinate. The contract returns `core + that surface` -and nothing else. When the coordinate is unknown, the contract returns the -**surface menu**, never the whole tree — which is the structural cure for the -brand-mixing global fallback. - -## The sort: every current piece gets a home - -The point of the exercise. **Contract** = belongs to the portable artifact. -**Binding** = belongs to the repo instance. **Kill** = remove; it serves neither -cleanly and survives only as legacy or as a duplicate of something better placed. - -| Piece | Home | Note | -| --- | --- | --- | -| `intent` / `inventory.building_blocks` / `composition` | **Contract** | Layer 1 core. Re-scoped from one flat bag to per-surface. Survives intact. | -| `inventory.topology` (scopes, surface_types) | **Contract** (as the map) | Leak A. Becomes `map.yml`, the contract spine — not a property of inventory. | -| `applies_to` smeared across nodes | **Contract** (resolved by map) | Leak A. A node's surface is its location in the tree, not a repeated tag. | -| `intent.situations` | **Contract** | Half-built coordinates (moment + surface_type). Folded into the map / surface nodes. | -| `validate.yml` checks (the *declaration*) | **Contract** | Surfaces declare their obligations. | -| `ghost check` / `review` / drift run against a diff | **Binding** | Inherently needs a working tree. Path-first. Stays repo-side. | -| `ack` / `track` / `diverge` (stance in `.ghost-sync.json`) | **Binding** | Stance is a repo-local relationship to a contract version. | -| Path → coordinate mapping (new `.ghost.bind.yml`) | **Binding** | The thin pointer. Replaces topology-as-path-matcher. | -| `relay gather` selection engine | **Both, unified** | One resolver: prompt→coordinate and path→binding→coordinate meet here. | -| `relay-config` `sources` / `request_resolvers` / stack resolvers | **Kill** | The *second* routing system. Collapses into map + binding. This is the core duplication. | -| `inventory.topology.scopes` as a runtime path-matcher | **Kill** | Path matching moves to the binding; the map keeps only the vocabulary. | -| `global fallback` (silent whole-graph dump) | **Kill** | Replaced by the explicit surface menu + "ask which surface." | -| `CAPS` truncation | **Kill** | Leak D. With a real map, the surface region is the budget. | -| nesting merge as ownership (`child-wins-by-id`) | **Binding** (as sugar) | Leak E. Demote to authoring convenience; ownership is git/CODEOWNERS. | -| `survey` / `ghost.survey/v1` | **Kill** | Legacy long tail. No home in either artifact. | -| `map.md` / `resources.yml` / `patterns.yml` / direct `fingerprint.md` | **Kill** | Migration museum. One canonical shape, no legacy formats. | -| `ghost diff` / `ghost describe` | **Kill** | Serve the dead direct-markdown path. | -| `signals` | **Binding** | Repo reconnaissance for authoring. Inherently working-tree-bound. | -| `compare` / `embedding/*` / `ghost-fleet` | **Contract** (consumer) | Layer 5. Compares contracts. Already clean; hold the line. | -| `ghost-ui` registry + MCP | **Contract** (delivery) | A way to ship a contract as a consumable resource. | - -If this sort feels right, the relief is real: **nothing built was wasted.** Most -pieces move or get re-scoped; the Kill column is legacy and duplication, not -capability. - -## What this buys (the relief, stated plainly) - -- The portable brand bundle has no idea git exists. Shippable, reusable across - many repos, works in the no-source-tree / MCP case. This is Job B, finally - freed from Job A's working-tree assumptions. -- The binding is tiny — it points, it does not redefine. The monorepo-root case - stops being a contradiction: many bindings, one contract, one coordinate space. -- "Non-UI composition" stops being scary scope creep. It is just a surface in the - contract that no binding maps to UI paths. The contract is allowed to describe - things no repo consumes. -- Net complexity goes **down**: one clarifying split (contract vs binding) - replaces three colliding concepts (topology vs situations vs relay resolvers). - -## The honest cost - -- One new idea — `manifest` gains "contract vs binding," and the skill must teach - *"are you authoring the brand, or binding a repo to it?"* That is one more - concept than today, but it replaces three that fight. -- Authoring asks "is this brand-universal or surface-specific?" That cost is real - — but it is the exact decision that prevents brand mixing, so it is a feature - with a price, not pure overhead. -- The contract↔binding reference (by path, npm, or MCP id) needs a resolution - contract. That is genuinely new surface area and the first thing to prototype. - -## The forks worth arguing before any code - -1. **Does the binding live in `.ghost.bind.yml`, or stays the contract embeddable - in-repo for the common single-repo case?** Many repos *are* their own - contract. The split must not tax the simple case: a lone repo should be able - to inline its contract and skip the binding entirely. -2. **Partial cross-cuts.** Email+web-but-not-product guidance does not fit a - strict surface tree. Medium-level intermediate surfaces absorb most of it; - genuinely diagonal sharing forces the tree toward a DAG. Pressure-test before - committing. -3. **Who owns the coordinate vocabulary** — `map.yml` as source of truth, or does - it derive from the surface tree itself? Lean: the tree *is* the vocabulary; - `map.yml` only adds aliases and descriptions. One source of truth (this is the - layers note's Leak C resolved). -4. **Versioning the reference.** A binding pins a contract version; `ack`/`track` - already model stance toward a moving reference. Does binding reuse that - machinery or get its own? - -## Not a plan - -This note assigns the two artifacts and sorts the pieces. It schedules no moves, -changes no schema, and renames no command today. Concrete extraction — the -contract manifest, `map.yml`, the binding schema, the unified resolver — should -each be proposed in its own note and linked back here for the artifact rationale, -exactly as `ghost-layers.md` asks for its layer rationale. - -Contracts to keep stable while sorting: `ghost.fingerprint/v1`, -`ghost.validate/v1`, `ghost.fingerprint-package/v1`, `ghost.relay-config/v1`, -`ghost.relay-request/v1`, `ghost.relay.gather/v2`, `ghost.check-report/v1`. - -## Read-back - -This note is successful if it converts a feeling into a list. You are not -serving too many purposes; you are serving **two** purposes with **one** -artifact. Name the two artifacts, sort each piece into Contract / Binding / Kill, -and the bundled mess becomes a portable contract plus a thin repo binding — with -nothing you built thrown away, only sorted. diff --git a/docs/ideas/coordinate-space.md b/docs/ideas/coordinate-space.md deleted file mode 100644 index 131fd532..00000000 --- a/docs/ideas/coordinate-space.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -status: exploring ---- - -# The coordinate space (Layer 2), designed clean - -This note is subordinate to `fingerprint-first-architecture.md` (settled) and is -the first cut named by `reset.md`. It supersedes the Layer 2 framing in -`ghost-layers.md` and the "map" framing in `contract-and-binding.md`: both -correctly located the leak; neither had the design. This note has the design. - -It was written **clean-room**. The shape below was derived from Ghost's purpose, -the five layers, and a working session about real outcomes — deliberately -*without* reading the two existing coordinate implementations (`ghost.map/v1` -and `inventory.topology`). Those are read only in the final section, to confirm -what gets deleted. Nothing here is back-formed from what exists. - -## What stays constant - -This redesign touches **one layer only**. Held fixed: - -- **Layer 1 (Description):** intent / inventory / composition. Their *content* - does not change. (Their coordinate *annotations* do — see below.) -- **Layer 4 (Governance):** checks, drift, `ack` / `track` / `diverge`. -- **Layer 5 (Comparison):** compare, fleet, embeddings. - -The four-facet artifact and the projection rule from `purposes.md` are -untouched. This is a greenfield of the coordinate space, not the project. - -## The one-line definition - -> A **surface** is an author-named group, with an optional description, that -> holds a slice of the fingerprint and may contain sub-surfaces. - -That is the entire concept. Ghost ships the *mechanism* for authored groups. It -does **not** ship a taxonomy. `email`, `flyer`, `menu`, `settings`, `checkout`, -`modules/billing` are all author data, never Ghost vocabulary. The system has no -opinion about what surfaces exist — only that each is named, optionally -described, nestable, and the home of some rules and context. - -This is the point-1 fix from the reset session: the coordinate space is not -back-formed from tags the description happened to need. It is its own thing — -a vocabulary of *groups*, owned by authors, that the description is *placed -into*. - -## The four outcomes this must serve - -The design is validated against four real outcomes, not abstractions: - -1. **In-repo UI work (existing or new).** A builder prompts an agent to work on - UI. Ghost supplies the right slice before the agent builds. Result beats no - Ghost. -2. **Non-visual builder → PR gate.** A builder ships a feature; Ghost runs PR - checks as a governance gate against the right slice. -3. **Customer brand generation (no repo).** A customer prompts a product to make - a flyer / menu / sticker / email for their brand. Ghost — already compiled - for that brand — drives the generation context and self-heals. *No path, no - diff, no repo exists.* -4. **Portable brand package.** Internal teams maintain one brand fingerprint - centrally; it cascades to all systems; everyone edits one package so nothing - diverges. - -The unifying observation: **all four ask the same question — "the right slice, -at the right time."** They differ only in *how the slice is named*: a path, a -prompt, an explicit surface, a package id. That difference is **medium, not -model.** Outcome 3 is the forcing function: it has the least medium (no path, no -repo), so the coordinate space must be designed from *it* and treat path / diff -/ prompt as conveniences layered on top. No medium is privileged. - -## The topology: two axes, two layers - -> **Amended** after a real non-UI proof case. The original framing here was -> "strict tree + cascade + rare explicit edges," which collapsed two distinct -> things into one. A composition-heavy case (a typed graph of units of several -> kinds resolved for a pathless request) showed the explicit edges are **not** a rare -> exception — they are a first-class structure that belongs to a *different -> layer*. The correction below makes the design stronger: it confirms Layer 2 -> (the map) and Layer 3 (selection) are genuinely separate, with different -> shapes. - -There are **two axes**, and they must not be conflated: - -1. **Containment — where a node lives and who owns it. This is Layer 2, and it - is a strict tree.** -2. **Composition — what combines to answer a request. This is Layer 3, and it is - a typed reference graph laid over the containment tree.** - -### Containment is a strict tree (Layer 2) - -Every node — every principle, exemplar, pattern, contract, check — has exactly -**one home surface**. One parent. The path is the identity. Storage, ownership, -and the menu are all this one tree. Trees lay out deterministically and read at a -glance; that legibility *is* the predictability goal (`reset.md` goal #4). - -**Sharing down the tree is resolved by altitude, not by multi-parent.** When a -rule applies to several surfaces that share an ancestor, it is placed at the -**lowest common ancestor** and **cascades down**. The brand-wide color rule lives -in `core` and flows everywhere. An email-wide voice lives in `email` and flows to -`email/marketing` and `email/reminder`. Cascade handles the common overlap -without giving any node two parents. Most "lives in two places" is really "lives -higher up." - -This kills the old leak. `applies_to` smeared across nodes (Leak A / Leak E) was -an implicit DAG: every node carrying `applies_to: [a, b]` is a node with two -parents. Placement + cascade replaces it. Inheritance returns, but disciplined: -*down the containment tree only.* No mixins, no priority weights, no -union-merge-by-id — just "ancestors contribute to descendants," the most -predictable inheritance there is. - -### Composition is a typed graph (Layer 3) - -The containment tree answers "where does this live." It does **not** answer "what -combines to serve this request." That second question is composition, and its -shape is a **typed reference graph** over the tree: a node of one kind references -nodes of other kinds by typed edge. - -In the simple UI case this graph is nearly invisible — a surface's slice is -mostly its own subtree plus cascaded ancestors, and composition collapses back -toward the tree. **But in the composition-heavy case the typed edges are the -primary structure, not a rare exception.** A request resolves to a unit that -references units of several other kinds, none of which are its parent or child. -That is not "a tidy tree with a few overlay lines" — it is a graph whose edges -are the point, and the tree underneath is only telling you where each referenced -node is stored. - -The discipline that keeps this from becoming a hairball is **typing**: edges are -not free-form "see also" links; each edge has a kind, and the legible set of edge -kinds is small and authored. The org-chart-plus-dotted-lines intuition still -holds — the difference the proof case revealed is that the dotted lines are -**typed and load-bearing**, and they belong to selection (Layer 3), not to the -map (Layer 2). - -### Why the split matters - -Collapsing composition into "rare tree edges" over-fit the in-repo UI case -(outcomes 1 & 2, where path → subtree does most of the work) and under-served the -no-repo composition case (outcomes 3 & 4, where a prompt resolves a typed -composition with no target path). The four outcomes are equals; the topology must -serve the composition case as a first-class shape, not an exception. Keeping the -tree for containment and a typed graph for composition serves all four without -bending either. - -Both axes satisfy `reset.md` goal #4 and the "model does not bend" rule in -`purposes.md`: the containment tree is dumb and predictable, and the composition -graph stays legible because its edges are typed and few. Neither axis introduces -mixins, priority weights, or union-merge-by-id. - -## Grouping is placement, not tags - -The point-1 coupling fix, made concrete: - -> A node's surface is **where it is stored**, not a property it carries. You -> *place* an exemplar into `email/marketing`. You do not stamp -> `surface_type: email` onto it. - -This is how Layer 1 content stays constant while its *coordinate annotations* are -removed. The grouping moves from smeared per-node fields -(`applies_to` / `surface_type` / `scope`) to **storage location in the surface -tree** plus an authored surface manifest. The description stops influencing the -coordinate space, because the description no longer carries coordinates at all. - -## The description is the keystone - -A surface carries an **optional description** authored in natural language: -*"a module is a self-contained sub-product; billing and payouts are modules."* - -This single field is what lets the system stay taxonomy-free and still resolve -fluid, author-invented vocabulary. The reasoning: - -- `email` resolves on its name alone — self-evident. -- `modules` is meaningless to a matching agent until an author *describes* it. -- The description is the bridge between author vocabulary and natural-language - asks. - -Descriptions are **optional but agent-draftable**. An agent can draft a -surface's description *from the content already grouped under it*; a human -approves it via git (git stays the approval boundary). The authoring burden is -"review a draft," not "write from scratch." Present a description when resolution -would otherwise be ambiguous; skip it when the name is self-evident. - -## Resolution is BYOA: Ghost emits a menu, the agent matches - -The resolution model, medium-agnostic: - -``` -any evidence ──> agent matches against the described menu ──> Ghost returns -(path|prompt| core + that - explicit|pkg-id) surface's slice -``` - -Division of labor (this is the BYOA boundary from -`fingerprint-first-architecture.md`): - -- **Ghost (deterministic, no LLM):** stores the surface tree; on request emits - the **menu** — surfaces with their descriptions and shapes. Once a coordinate - is chosen, deterministically returns `core + that surface's slice` (the slice = - the surface's own nodes + everything cascaded from its ancestors + any explicit - shared edges). Ghost does zero NLP. -- **Host agent (inference):** already holds the prompt. Reads the described menu, - picks the surface. Path / diff / explicit name / package id are *additional - evidence* it may use, never requirements. - -**Ambiguity returns the menu, never the whole tree.** When evidence does not -resolve to a surface, Ghost returns the **surface menu** and asks which one — -it never dumps the whole fingerprint. This is the structural cure for the -global-fallback brand-mixing failure: in outcome 3, mixing a customer's flyer -voice into their email is *the* failure mode, and a menu-instead-of-dump makes it -impossible. (`purposes.md` leaks #1 and #2, resolved.) - -## How the four outcomes resolve - -| Outcome | Evidence the agent uses | Resolves to | -| --- | --- | --- | -| 1 In-repo UI, existing file | path → surface | that surface's slice, before building | -| 1 In-repo UI, new work | prompt → surface (or menu) | chosen surface's slice | -| 2 PR gate | diff paths → surface(s) | checks for those surfaces, against the diff | -| 3 Customer flyer (no repo) | prompt → surface | `core + flyer`, never `email` | -| 4 Portable brand | package id → tree | the whole tree as a consumable resource | - -One model. The medium is just an adapter on the front. **This is also the -contract/binding split (`contract-and-binding.md`) falling out for free:** the -surface tree + the description it holds *is* the portable contract (outcomes 3 & -4, no repo needed); path→surface and diff→surface are the repo conveniences -(outcomes 1 & 2). We did not have to decide contract-vs-binding to design the -coordinate — designing it medium-agnostically produced the split, exactly as the -layers note predicted. - -## What a surface needs (the shape, in prose) - -Stated as obligations, not a schema (schema is a follow-on note): - -- **id / name** — the author's chosen label, slug-shaped. -- **description** — optional, natural language, agent-draftable. Present when the - name is not self-evident. -- **parent** — at most one (strict containment tree). Absent = top-level under - `core`. -- **slice** — the nodes placed in this surface. Placement, not tags. -- **edges** — typed references to nodes in other surfaces (the composition graph, - Layer 3). Each edge has a kind from a small authored set; the menu shows them. - Sparse in UI cases, primary structure in composition-heavy cases. - -Resolution against a surface yields: its own slice + cascaded ancestor slices + -typed-edge contributions. `core` is the root every surface inherits. - -## What each layer asks of the coordinate space - -Confirming the design serves all consumers (the layer rule, `reset.md`): - -- **Selection (3):** "evidence → which surface → its composed slice." Served by - the menu + deterministic resolution of the typed composition graph (the - surface's subtree, its cascaded ancestors, and its typed edges). No NLP in - Ghost. **This is where the composition graph lives** — Layer 2 stores, Layer 3 - composes. -- **Governance (4):** "this diff touches which surfaces → run their checks." - Served by path→surface mapping over the containment tree. -- **Comparison (5):** "compare these surfaces / whole trees." Served by the - containment tree being a clean, portable structure. - -A new purpose still gets a new layer, never a new field on intent / inventory / -composition. - -## The delete list - -Only now, after the clean design exists, do we name what it replaces. These are -the back-formed coordinate systems the design above supersedes. (Read at this -point, not before, to avoid anchoring.) - -| Dead thing | Why it dies | Replaced by | -| --- | --- | --- | -| `inventory.topology` (scopes, surface_types) inside the fingerprint | Coordinate space trapped in the description (Leak A) | The surface tree, a Layer 2 artifact | -| `applies_to` on principles / contracts / patterns | Smeared tags = implicit DAG (Leak A/E) | Placement + cascade from ancestors | -| `surface_type` / `scope` on exemplars and situations | Same: nodes self-tagging coordinates | Placement (storage location) | -| `ghost.map/v1` / `map.md` (`ghost-core/map/`) | A *prior, richer* coordinate attempt, but repo-structure-shaped and medium-coupled (path/build-system/render-strategy baked in) | The medium-agnostic surface tree | -| `child-wins-by-id` union merge as ownership (Leak E) | Ownership is git/CODEOWNERS; merge did a governance job | Cascade down the containment tree | -| `global-fallback` whole-tree dump | Brand-mixing failure | Menu-instead-of-dump on ambiguity | - -The crucial honesty for the sadness that started all this: **the description core -is untouched, and the two dead coordinate systems are being unified into one -clean thing — not thrown away into a void.** This is a teardown of one layer -inside a frame that protects the three layers that work. Greenfield where it's -earned; foundation kept where it's solid. - -## Decisions locked in this note - -1. A surface = author-named group + optional description + sub-surfaces. Ghost - ships the mechanism, never a taxonomy. -2. Grouping is by placement (storage location), not tags. Layer 1 content stays - constant; its coordinate annotations are removed. -3. Topology has two axes. **Containment** (Layer 2) is a strict tree: one home - per node, cascade-from-ancestors for overlap, no silent multi-parent. - **Composition** (Layer 3) is a typed reference graph over the tree: nodes - reference nodes of other kinds by typed edge. The edges are first-class, not - rare exceptions — in composition-heavy cases they are the primary structure. -4. Resolution is BYOA: Ghost emits a described menu deterministically; the agent - matches; Ghost returns `core + surface slice`. Ghost does no NLP. -5. Ambiguity returns the menu, never the whole tree. (Brand-mixing cure.) -6. Descriptions are optional but agent-draftable, human-approved via git. -7. Medium-agnostic: path / prompt / explicit name / package id are evidence, not - privileged selectors. Designed from the no-repo case (outcome 3). - -## Not a plan - -This note designs the coordinate space and names what it replaces. It schedules -no moves, writes no schema, renames no command. Concrete extraction — the surface -schema, the slice resolver, the menu emitter, the migration off `topology` / -`applies_to` / `map.md` — should each be proposed in its own note and linked back -here for the design rationale, exactly as `reset.md` asks. - -Contracts to keep stable while this is explored: `ghost.fingerprint/v1`, -`ghost.validate/v1`, `ghost.fingerprint-package/v1`, `ghost.check-report/v1`. - -## Read-back - -This note succeeds if: - -- The coordinate space is defined without a taxonomy and without privileging any - medium. -- All four outcomes resolve through one model. -- The point-1 coupling is fixed: the description no longer carries coordinates. -- Containment is a clean tree a person can hold in their head, with cascade for - overlap; composition is a typed graph over it, legible because its edges are - typed and few. The two axes are not conflated. -- Nothing in Layer 1, 4, or 5 had to change to make Layer 2 right. diff --git a/docs/ideas/ghost-layers.md b/docs/ideas/ghost-layers.md deleted file mode 100644 index 51ae6dde..00000000 --- a/docs/ideas/ghost-layers.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -status: exploring ---- - -# Ghost layers: a triage map - -This note is subordinate to `fingerprint-first-architecture.md` (settled). It -does not change that decision. It applies it. The settled memo says the -fingerprint is the durable *descriptive* artifact and everything else is a tool -that consumes, validates, governs, or compares it. This note takes that one -sentence and asks, file by file, **which layer is this, and is it where it -belongs?** - -It is a triage map, not a rewrite plan. The goal is to make the size of the -thing feel smaller by giving every piece a home. - -## The one-line diagnosis - -The descriptive center is clean. The operational rings leaked into its shape. - -Intent / inventory / composition is genuinely *one surface seen through three -angles*. It survived every refactor (`bd1ced5`, `f393720`, `7ecd13c`) because it -is coherent. The pain is not there. The pain is that **selection, routing, -merge, governance, and comparison** are operations *on* the fingerprint that -pressed back *into* its structure, because the fingerprint was the only durable -thing to hang them on. - -That is a hopeful diagnosis. The rot is in a ring, not the core. - -## The five layers - -| Layer | Name | One line | Owns | -| --- | --- | --- | --- | -| 1 | **Description** | What the surface is. | intent, inventory, composition | -| 2 | **Map** | The coordinate space the surface lives in. | dimensions, scopes, surface types | -| 3 | **Selection** | Path or prompt to a narrow view of 1. | relay gather, routing, request resolution | -| 4 | **Governance** | Whether a change stays faithful, and who owns what. | checks, drift, ownership | -| 5 | **Comparison** | Read-only analytics across many fingerprints. | distance, cohorts, fleet | - -The discipline rule that comes from this map: - -> A new purpose gets a new layer, never a new field on intent / inventory / -> composition. - -Most of the recent agony was the question "does this go in the fingerprint?" -having no answer. With the layers named, the question becomes "which layer is -this?" — and that almost always has an obvious answer. - -## Triage: current code to layer - -| Code | Layer | State | -| --- | --- | --- | -| `ghost-core/fingerprint/{schema,types,lint}.ts` (intent, composition, inventory minus topology) | 1 | **Clean.** The center. Leave it alone. | -| `inventory.topology` (scopes, surface_types) | 2 trapped in 1 | **Leak A.** A coordinate space living inside a description file. | -| `applies_to` on principles / contracts / patterns / exemplars / situations / checks | 2 trapped in 1 | **Leak A.** The same coordinate space smeared across every node. | -| `context/graph.ts` (`Applicability`, `buildScopes`, `matchScopes`, `pathsOverlap`) | 2 | Map logic, but reconstructed at runtime from the smeared fields above. | -| `fingerprint/lint.ts` (`checkTopologyRefs`, `fingerprint-surface-type-unknown`) | 2 | Already enforces the map vocabulary. This is the source of truth to protect. | -| `context/entrypoint.ts` (`buildContextEntrypoint`, `CAPS`, `relevanceScore`) | 3 | **Leak D.** `CAPS` is a truncation crutch from having no real map to narrow with. | -| `context/selection-reasons.ts` (`directSelectionReasons`, `expandOneHopWithReasons`, `globalFallbackRefs`) | 3 | Selection. Correct layer. One-hop expansion is not exclude-aware yet. | -| `context/selected-context.ts` (`SelectedContextGap`, hits, omissions) | 3 | Selection output contract. Correct layer. | -| `relay.ts`, `relay-command.ts`, `relay-runtime-helpers.ts` | 3 | Selection runtime. Correct layer. | -| `context/request-resolution.ts`, `relay-request.ts`, `request-stack-document.ts` | 3 | Prompt to view. Correct layer. Has its own selector matcher (see Leak C). | -| `context/relay-config*.ts`, `default-relay-config.ts`, `projection.ts`, `relay-context.ts`, `relay-modes.ts` | 3 | Selection plumbing. Correct layer. | -| proposed `relay.yml` / `routes` facet | 3 | **Leak B.** Wants a filename and a name Layer 3 already used. | -| `ghost-core/checks/{schema,lint,routing,types}.ts`, `validate.yml` | 4 | Governance gates. Correct layer. | -| check / review / ack / track / diverge commands | 4 | Drift governance. Correct layer. | -| `scan/fingerprint-stack.ts` (`mergeFingerprints`, `mergeById`, `mergeStrings`, `child-wins-by-id`) | 4 wearing a 1 costume | **Leak E.** A merge algorithm doing an ownership job. | -| `ghost-core/embedding/*`, `compare` command, `packages/ghost-fleet` | 5 | **Clean consumer.** Reads description, never writes back. Hold this line. | - -## The named leaks - -**Leak A — the map is trapped inside the description.** `inventory.topology` -plus every node's `applies_to` is a coordinate system masquerading as a property -of the surface. It is Layer 2 living inside Layer 1. This is the highest-leverage -cut because Layers 3, 4, and 5 all query it: fix it once, three consumers get -cleaner. Extracting an explicit surface map is the one structural change worth -making first. - -**Leak B — `relay.yml` collision.** `relay-config-loader.ts` already discovers -`.ghost/relay.yml` and hard-throws unless it validates as -`ghost.relay-config/v1`. The proposed routing facet wants the same path. Two -Layer 3 things fighting for one filename, and "Relay" already names the -subsystem. Resolution: name the facet for what it does (the map / routing), not -"relay." - -**Leak C — duplicate vocabulary.** A new `selectors` block would re-declare -`surface_type`, which `inventory.topology.surface_types` already owns with -*error*-level lint enforcement. Two sources of truth for one Layer 2 concept. -Resolution: the map owns the vocabulary; selection references it. Only genuinely -new axes (e.g. `medium`) are new. - -**Leak D — `CAPS`.** Hardcoded truncation (`intent: 6, composition: 6, ...`) in -selection is a crutch from when there was no good map to narrow with. With a real -Layer 2, the region is the budget. Keep caps only on the global-fallback path. - -**Leak E — nesting as ownership.** `mergeFingerprints` (union-by-id, child-wins) -performs a governance/ownership job with a data-model mechanism. It couples -failure domains across teams (a root edit can break a leaf's gather), allows -silent overrides, and supports union-only inheritance. Ownership is a git / -CODEOWNERS concern. Resolution: demote nesting to authoring sugar; let -governance live in Layer 4. - -## What is already clean (the reassurance) - -- **Layer 1** is coherent and battle-tested. You feel no pain here, and that is - the signal that the model is right. -- **Layer 5** (compare, fleet, embeddings) is already a well-behaved consumer. -- **Layer 4 checks/drift** is correctly separated; only nesting leaks. - -You did not lose the plot. The code drifted from a doc you already ratified. The -fix is alignment, not reinvention. - -## Why this happened (and why it is fine) - -You could not have drawn these boundaries up front. You had to build the bundled -version to discover where it wanted to split. Every collision in this exploration -— the `relay.yml` clash, the double vocabulary, the union merge — was not bad -design surfacing. It was a seam becoming visible enough to cut along. The mess is -the map you could not have drawn before you made it. - -## Not a plan - -This note assigns homes; it does not schedule moves. Any actual extraction -(surface map, routing facet name, nesting demotion) should be proposed in its own -note and linked back here for the layer rationale. Nothing here changes a schema, -a command, or a contract today. - -Contracts that exist and should be kept stable while triaging: `ghost.fingerprint/v1`, -`ghost.validate/v1`, `ghost.fingerprint-package/v1`, `ghost.relay-config/v1`, -`ghost.relay-request/v1`, `ghost.relay.gather/v2`, `ghost.check-report/v1`. - -## Read-back - -This note is successful if a contributor can take any file in -`packages/ghost/src` and answer two questions without guessing: which layer does -this serve, and is it currently living in that layer or leaking into another. diff --git a/docs/ideas/graph-implementation-plan.md b/docs/ideas/graph-implementation-plan.md deleted file mode 100644 index 292aa77f..00000000 --- a/docs/ideas/graph-implementation-plan.md +++ /dev/null @@ -1,228 +0,0 @@ ---- -status: exploring ---- - -# Implementation plan: the context-graph model in code - -Turns `context-graph.md` + `scenarios-worked.md` into a sequenced build. Grounded -in the **actual** current code, not a greenfield sketch. Read those two notes -first for the model; this note is the *how*. - -## The load-bearing code fact (verified) - -The current code already has the shape's bones: - -``` -files ──(loadFingerprintPackage → assembleFingerprint)──▶ GhostFingerprintDocument ──▶ everything - ▲ the ONE structural seam -``` - -- `GhostFingerprintDocument` (ghost-core/fingerprint/types.ts) — the in-memory graph. -- `resolveSurfaceSlice` (ghost-core/surfaces/resolve.ts) — **this is `gather`**: walks - the ancestor chain + one-hop typed edges, already tracks `SliceProvenance` - ("own" / "ancestor" / "edge"). -- `surfaces.yml` (ghost-core/surfaces/types.ts) — already the tree: `parent` - (= our `under`), typed `edges` (= our `relates`, closed vocab - `composes`/`governed-by`), implicit `core` root. -- Checks already route separately (check/route.ts, selectChecksForSurfaces, - groundSurface). - -**Every read consumer works on the in-memory object and never reads files.** So -the model change is contained to: the node shape, the loader, and the writers — -exactly as `contract-storage.md` predicted. - -## Concept → code mapping - -| Model | Today | Change | -| --- | --- | --- | -| node | typed YAML sub-objects (principle/situation/pattern/exemplar) in 3 facet files | **markdown file: frontmatter + prose body** | -| `under` | `GhostSurface.parent` + `core` root | keep; rename surface→node later | -| `relates` | `GhostSurfaceEdge` (2 kinds) | keep; widen vocab + add a qualifier | -| relationship-node | (none — only edges) | **new: a node whose body is the relationship** | -| `medium` | (none) | **new: optional frontmatter tag** | -| `gather` | `resolveSurfaceSlice` | extend with medium filter; otherwise reuse | -| checks | check/route.ts (markdown already) | add `medium` + `when` frontmatter | -| in-memory graph | `GhostFingerprintDocument` | keep shape; nodes carry body + medium | - -## The three real gaps (everything else is rename/extend) - -1. **Node bodies become markdown.** Today intent/inventory/composition are - separate YAML files with typed schemas per node. New: one node = one markdown - file; intent/inventory/composition are **body headings**, not files or types. - The loader stops parsing typed facet objects and starts parsing - frontmatter+body nodes. -2. **`medium` tag.** New optional frontmatter field; threads through gather - (filter), checks (scoping), and lint (root must be medium-agnostic). -3. **Relationship-nodes.** The OKF "joins" borrow: a node that *is* a - relationship, with endpoints in frontmatter and rationale in the body. - -## Sequencing — each phase green, each shippable - -### Phase 0 — one-road (prerequisite, already planned) - -Build `one-road.md` first. Removes the binding + nesting, frees the path -helpers, makes `checks`/`review` take agent-stated nodes. **Do not start the -graph work until one-road lands** — it touches the same command surface and the -loader's neighbours. No overlap if sequenced; double-work if not. - -### Phase 1 — the node model (schema + types, no loader yet) - -The keystone, done in isolation so it can be reviewed before anything depends on -it. - -- Define the **node frontmatter schema**: `id` (required), `under?`, `relates?` - (with optional qualifier), `medium?`, plus body. One schema for *all* nodes — - the role (principle/pattern/exemplar) is inferred from body headings, not a - typed kind. -- Define the **relationship-node**: same envelope, frontmatter carries - `relates: [a, b]` with no `under`; body is the rationale. -- Add `medium` as an open string enum (`any` | known media | custom). -- Define the new in-memory shape: a flat `nodes: GhostNode[]` + the existing - tree, instead of `intent/inventory/composition` typed buckets. Keep a - `GhostFingerprintDocument` *facade* if it reduces consumer churn. -- Unit tests on the schema only. No I/O. - -### Phase 2 — the loader (the one hard change) - -Rewrite `loadFingerprintPackage` / `assembleFingerprint` as a **fold over node -files**: - -1. discover node markdown files in the package (glob; layout-free), -2. parse each (frontmatter + body) — reuse `scan/frontmatter.ts`, `scan/body.ts`, -3. resolve `under`/`relates` refs (local + `package#ref` — defer cross-package - to Phase 6; local first), -4. derive inverses, assemble the graph. - -Keep the output assignable to the consumer-facing document shape so -`resolveSurfaceSlice` and friends compile unchanged. **This phase is where the -"many projections" promise is paid: file layout is now free.** - -### Phase 3 — gather + medium - -- Extend `resolveSurfaceSlice` (→ rename `gatherNode` eventually) with an - optional `medium` filter: a node is included if its medium is `any`/absent or - matches the requested medium. Cascade + one-hop edges unchanged. -- Pull relationship-nodes into the slice when either endpoint is in scope - (they're just nodes with two `relates`). -- `gather [--medium m]` at the CLI. -- Provenance already exists — extend it with `medium` and `relationship-node` - reasons so `trace` stays structural. - -### Phase 4 — checks on the graph - -- Checks are already markdown. Add `medium` (scope) and `when: review|runtime` - to check frontmatter. -- `selectChecksForSurfaces` → route by `under` + medium. A check `under` a node - applies to it and descendants; medium narrows it. -- `when: runtime` is *parsed and routed* now; runtime *execution* is out of - scope (Scenario D future) — just don't drop it on the floor. - -### Phase 5 — authoring: init, migrate, the skill - -- `init` scaffolds a `core` node + 1–2 example nodes with the - intent/inventory/composition body template (Style-Dictionary default). -- `migrate` gains facet→node re-filing: read today's typed YAML nodes, emit - markdown nodes (carry `surface:`→`under`, fold typed fields into body - headings). -- **The authoring skill** (first-class, not afterthought — OKF's reference-agent - lesson): discover nodes, propose placement + links, weave links into prose, - follow the anti-over-linking discipline. Lint guards it. - -### Phase 6 — cross-package refs (B, E) - -- Implement `package#ref` resolution: a `relates`/`under` target in another - installed contract (`consumes` in manifest). Located via the surviving path - helpers + node_modules resolution. -- This unlocks the fleet (E) and shared-brand (B). Until now everything is - single-package. - -### Phase 7 — compare / drift on the graph - -- `compare` = graph diff (mostly reuses comparable-fingerprint machinery on the - new node set). -- `drift` highest purpose: compare **siblings of a shared intent** — nodes that - `relates` to the same parent node (E's "have these two expressions of clarity - drifted?"). New, but small once the graph exists. - -### Phase 8 — lint as the guardian - -Throughout, `lint` proves the three invariants (it becomes *the* thing holding a -free-layout graph together): - -1. **Identity** — every node has a unique `id`. -2. **Resolvable links** — every `under`/`relates` resolves (tolerant: dangling = - warn "not yet written", per OKF; hard-fail only on a missing/duplicate root). -3. **One medium-agnostic root** — exactly one node with no `under`, and it is - `medium: any`/absent. - -Plus the authoring-discipline checks (no self-links, no over-linking). - -## What gets deleted / folded - -- The per-facet typed schemas (`intent.principle`, `composition.pattern`, …) - collapse into one node schema. The typed sub-object types in - `ghost-core/fingerprint/types.ts` either go away or become *body-parsing - helpers*, not storage types. -- `survey/`, `patterns/`, `resources/` legacy modules: assess for removal once - nodes are markdown (much of their schema work is subsumed). -- Three fixed facet files (`intent.yml`/`inventory.yml`/`composition.yml`) stop - being the canonical input. `migrate` reads them; nothing else does. - -## What does NOT change - -- The seam (`files → loader → document → consumers`). -- `resolveSurfaceSlice`'s traversal logic (cascade + one-hop edges + provenance). -- Checks routing *concept* (markdown, route by placement). -- `--package` / `GHOST_PACKAGE_DIR` direct addressing. -- compare/drift's underlying comparison math. - -## The machinery ring (assume it, OKF-confirmed) - -OKF ships format **and** machinery; the format alone is inert. Their repo is -mostly an authorship agent (`reference_agent/`: tools + prompt), plus -parse/validate (`document.py`), an index/menu (`index.py`), an auto-summarizer -(`synthesizer.py`), a **visual viewer** (`viewer/`), and tests. Assume Ghost has -the same ring — and we already have most of it, specialized further for design. - -| OKF machinery | Ghost equivalent | Status | -| --- | --- | --- | -| reference_agent (authorship) | the **ghost skill** (discover nodes, propose links, weave prose, anti-over-linking discipline) | exists; reshape for graph (Phase 5) | -| `document.py` parse/validate | `scan/frontmatter.ts`, `scan/body.ts`, node schema | exists; extend (Phase 1–2) | -| §9 conformance | `lint` — the three invariants, tolerant | exists; refocus (Phase 8) | -| `index.py` menu | `gather` (no-arg) menu / `buildSurfaceMenu` | exists | -| `synthesizer.py` summaries | scan-status / contribution | exists, partial | -| **`viewer/` visual graph** | **— gap —** a visual render of the graph (tree, links, relationship-nodes) | **future; fits the "observable" goal** | -| sources / web ingestion | (skip — we are authored/editorial, not extractive) | deliberately out | - -Two takeaways: **(1) the authoring skill is first-class** (OKF's largest -component — nobody hand-authors a linked graph), and **(2) a viewer is a real -future item** — arguably more valuable for a *design* fingerprint than a data -catalog, and it serves Ghost's portable/extensible/**observable** goal. - -## Open decisions that gate the build - -1. **The rename — SETTLED.** Graph unit is **`node`** (machinery-only vocabulary, - never user-facing). "surface" retired from both layers — `node` replaces it in - code; the design prose + ids replace it for users. Wide but mechanical rename - of `surface*` symbols (`GhostSurface`, `resolveSurfaceSlice`, `surfaces.yml`, - `selectChecksForSurfaces`). -2. **Node body — SETTLED: free markdown, always.** intent/inventory/composition - are **ephemeral authorship guidance** (skill prompts + maybe an `init` nudge), - with **zero presence in schema/loader/graph/lint**. No conventional headings, - no body schema. Nothing to build for them. -3. **One file per node vs. grouped files.** Loader is layout-free (Phase 2), so - this is a *default-scaffold* taste call for `init`, not a parser constraint. - Leaning one-file-per-node (one node = one concept). -4. **Keep `GhostFingerprintDocument` facade or rename to `GhostGraph`?** Facade - reduces consumer churn; rename is honest. Lean facade during transition, - rename at the end. -5. **`relates` qualifier vocabulary** — `contrasts`/`reinforces`/`variant` - (+ `governs`, `expresses` seen in scenarios). Closed set, like edges today. - Decide the starting set. - -## Build order, one line - -**one-road → node schema → loader fold → gather+medium → checks → authoring -(init/migrate/skill) → cross-package → compare/drift → lint-as-guardian.** -Each phase green; the node-schema and loader phases are the only hard ones; the -rest is extend-or-rename of code that already exists. diff --git a/docs/ideas/guided-migration.md b/docs/ideas/guided-migration.md deleted file mode 100644 index 4355181c..00000000 --- a/docs/ideas/guided-migration.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -status: exploring ---- - -# Guided migration: drifting a fingerprint toward another - -> Fingerprint-first context: migration treats distance as a governance signal -> between two fingerprints. The migration target is another approved contract, -> not drift for its own sake. - -An agentic loop where repo A consciously migrates toward repo B's visual direction, driven by fingerprint distance + vector as the signal. - -## The observation - -Embedding distance is a *tier selector*, not a progress bar. Closing 0.6 → 0.3 is usually easier than 0.3 → 0.05: the first half removes obviously wrong answers (different font family, different base unit, different radii language); the second half means reconciling deliberate choices that both sides consider correct. The last 0.05 is where local surface character lives and often shouldn't go to zero at all. - -## Distance tiers - -| Distance | Meaning | Mode | -|---|---|---| -| < 0.15 | Accidental drift | **Reconcile** — token renames, no philosophy work | -| 0.15 – 0.3 | Deliberate variance on shared foundation | **Negotiate** — diff decisions, `track` or `diverge` each gap | -| 0.3 – 0.5 | Different decisions on similar kind of system | **Track decisions first, tokens follow** — chasing the scalar alone will mimic without migrating | -| > 0.5 | Different design languages | **Question the premise** — often not a migration | - -## What the scalar hides - -- **Shape.** Distance 0.3 spread across four dims is uniform drift; 0.3 concentrated in palette is a single PR. Same headline, opposite plans. `computeDriftVectors` already exposes the per-dim direction. -- **Irreducible distance.** Some gap is intentional (accessibility floor, CJK glyph support, dense-data use case). Target isn't `d = 0`; it's `d ≤ floor + ε` where `floor = Σ(diverged_dims × weight)`. Floor is computed from the manifest, not guessed. - -## What would be built - -A `migrate.md` skill recipe alongside profile / review / verify / generate / discover. Loop shape: - -1. `track B` — snapshot starting per-dim distances into `.ghost-sync.json`. -2. Pick the steepest dim from `computeDriftVectors`. -3. Host agent translates that delta into code edits, respecting the tier (reconcile vs negotiate vs track). -4. Re-profile → `ack` → repeat. -5. Stop when `d ≤ floor + ε`, or agent hits a dim the user marks `diverging`. - -No CLI primitive is missing. All the interpretation work lives in the recipe. - -## Open questions - -- **Ordering within a tier.** Vector-first (fast, risks mimicry), decisions-first (correct, slow), or interleaved (each `ack` commits one dim + the decisions that justify it). Current lean: interleaved. -- **Detecting when the scalar is lying.** A local fingerprint can descend the vector gradient without importing decisions, landing at low `d` but not actually looking like B. Candidate: don't declare success until *both* the machine dims and the decisions dim are inside tolerance. -- **Diverge budget up front.** Should `track` accept `--diverge ,` so the floor is known before the loop runs, instead of discovered mid-migration? -- **Symmetry.** `checkBounds` already flags `reconverging` when a diverging dim has closed to < 50% of acked distance. Guided migration is the deliberate form of that — same bookkeeping, inverted intent. Worth thinking about whether the two should share a verb. diff --git a/docs/ideas/implementation-plan.md b/docs/ideas/implementation-plan.md deleted file mode 100644 index 738ad73d..00000000 --- a/docs/ideas/implementation-plan.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -status: exploring ---- - -# Implementation plan: the surface-model cutover - -This note sequences the implementation of the surface model designed across -`reset.md`, `coordinate-space.md`, `surface-schema.md`, and `surface-binding.md`. -It is subordinate to `fingerprint-first-architecture.md` (settled). It schedules -work; it does not redesign anything. - -## Stance: hard cutover, breaking change - -Decided: **one hard breaking change, no parallel old/new model.** `topology`, -`applies_to`, `surface_type`/`scope` on nodes, `ghost.map/v1`, and -nesting-as-merge are removed, not deprecated-alongside. Rationale: - -- Ghost is pre-1.0 (`0.18.0`). One major bump is the cheapest moment to break. -- Carrying both models during a migration window is its own sprawl — the exact - thing the reset exists to end. A clean break is less total work than a bridge. -- One published package (`@anarchitecture/ghost`); a single `major` changeset - covers the whole cutover. - -The cost this accepts: any existing `.ghost/` package with `topology` / -`applies_to` / `surface_type` / `scope` must migrate. We provide a one-shot -migration command (Phase 6) and accept that old fingerprints fail `lint` loudly -until migrated. No silent compatibility shim. - -## Blast radius (measured) - -- ~38 `src` files touch `topology` / `applies_to` / `surface_type` / `scope`. -- ~16 `src` files touch `ghost.map/v1` (the entire `ghost-core/map/` module - plus its consumers in `scan`, `checks`, `core`). -- ~20 test files reference dead fields or the dead `relay` / `survey` surface. -- 19 CLI commands; several (`relay`, `survey`, `diff`, `describe`, `stack`) are - on the delete list from earlier notes and should be reconfirmed against the - new model rather than ported. - -This is large but bounded, and concentrated: `fingerprint/{schema,types,lint}`, -`scan/fingerprint-stack`, `context/*`, and `checks/*` carry most of it. - -## Sequencing principle - -Each phase is one PR-sized cut, lands green (`pnpm check` + `pnpm test`), and is -committed before the next starts. Both gates run automatically on the -pre-commit hook (`lefthook.yml`), so a phase cannot be committed red — there is -no per-phase choice about which suite to run, and no `--no-verify` split to keep -clean. No two phases open at once — the same discipline that kept the design -notes clean. The order is **dependency-driven**: -schema before lint before loader before consumers before resolver before -binding. Nothing downstream is touched until its upstream lands. - -## Phases - -### Phase 0 — freeze and baseline - -- Tag the current green state on the branch (pre-cutover reference). -- Snapshot the current public-export surface (`test/public-exports.test.ts`) so - the breaking diff is explicit and reviewable. -- Write the `major` changeset stub now, filled in as phases land. - -No behavior change. This phase makes the break auditable. - -### Phase 1 — `ghost.surfaces/v1` schema module - -- New module `ghost-core/surfaces/` (`schema.ts`, `types.ts`, `index.ts`), - mirroring the `fingerprint/` module layout. -- Zod for `surfaces.yml`: `surfaces[]` with `id` (flat slug, no dots), optional - `description`, optional single `parent`, optional `edges[]` (`kind` from the - fixed Ghost-owned set, `to` an existing id). `edge_kinds` is a code constant, - not package data. -- Export from `@anarchitecture/ghost/core`. -- Tests: schema accepts the valid shape, rejects dotted ids, parent arrays, - unknown edge kinds. - -Depends on: nothing. Pure addition. Lands without touching existing behavior. - -### Phase 2 — surfaces lint + tree/graph validation - -- `ghost-core/surfaces/lint.ts`: parent references exist, no cycles (tree), - single parent, edge `to` references exist, edge `kind` is known, near-miss id - warnings, `core` reserved as implicit root. -- Composition edges may form a graph (cross-links allowed); only `parent` is - tree-constrained. -- Wire into `ghost lint` for `surfaces.yml`. -- Tests: each lint rule, valid graph passes, cyclic parent fails. - -Depends on: Phase 1. - -### Phase 3 — placement on description nodes - -- Remove `applies_to` from principles / patterns / experience_contracts; remove - `surface_type` / `scope` from exemplars and situations; remove - `topology` from `inventory`. -- Add a single optional `surface: ` placement key to each placeable node. -- Lint: placement references an existing surface; un-placed node **warns and - teaches** (never silent `core`); near-miss id warns. -- Update `fingerprint/{schema,types,lint}.ts` and `checks/schema.ts` (drop - `check.applies_to`, add `check.surface`). -- Tests: placement validation; the warn-not-error behavior; removed fields now - rejected by `.strict()`. - -Depends on: Phase 1–2. **This is the breaking core** — the schema no longer -accepts the old coordinate fields. - -### Phase 4 — delete `ghost.map/v1` - -- Remove `ghost-core/map/` entirely and its consumers' map dependencies in - `scan/` (`fingerprint-package`, `inventory`, `file-kind`, `lint-map`, - `scan-status`, `fingerprint-stack`), `checks/` (`routing`, `lint`, `types`), - and `core/` (`check`, `scope-resolver`). -- Replace map-derived scope resolution with surface resolution from Phase 1–3. -- Remove `map.md` handling and `MAP_FILENAME`. -- Tests: delete `map-scopes.test.ts` and `scope-resolver.test.ts` map paths; - retarget any still-valid assertions to surfaces. - -Depends on: Phase 3 (surfaces must own scope resolution before map is removed). - -### Phase 5 — slice resolver + menu emitter, as the new gather command (Layer 3, prompt road) - -- Resolver: given a surface id, compose its slice = own placed nodes + cascaded - ancestor nodes + typed-edge contributions. Deterministic, no LLM. -- Menu emitter: surfaces + descriptions for the host agent to match against. -- Ambiguity returns the menu, never a whole-tree dump. -- **Ship this as the new context-gathering command** (working name `gather` / - `select`): relay's *desire* done right (see "Command fate"). Do not build it - on the old relay-config / request-resolution plumbing. -- Replace the old `context/entrypoint.ts` `CAPS` truncation and - `globalFallbackRefs` with surface-scoped composition. -- Tests: cascade correctness, edge contribution, menu shape, ambiguity → menu. - -Depends on: Phase 3–4. This is where selection stops improvising around a -missing map. - -### Phase 6 — migration command - -- `ghost migrate` (or `ghost surfaces init --from-legacy`): one-shot transform - of an existing `.ghost/` — derive `surfaces.yml` from old `topology.scopes`, - rewrite node `applies_to` / `surface_type` / `scope` into `surface:` - placement. Best-effort, prints what it could not place for human review. -- Migrate this repo's own dogfood `.ghost/` with it (the worked example in - `surface-schema.md`), and commit the migrated package. -- Tests: a legacy fixture migrates to a valid `surfaces.yml` + placed nodes. - -Depends on: Phase 1–5. The migrator needs the target shape to exist. - -### Phase 7 — `ghost.binding/v1` (path road + diff road) - -- New `ghost-core/binding/` schema + loader for `.ghost.bind.yml`. -- Reframe nested-package resolution from data-merge to binding: nearest binding - along a path names the surface; explicit `.ghost.bind.yml` overrides - directory-implied binding at its level. -- Wire path → surface and diff → surfaces into `check` / `review` (Layer 4) and - the path road of selection (Layer 3). -- Retire `child-wins-by-id` merge (`scan/fingerprint-stack.ts`) — Leak E. -- Ship smallest first: directory-default binding + in-repo `contract: .`; defer - external contract references. -- Tests: path resolves to nearest binding; diff → union of surfaces; no-binding - path → `core` when a root contract exists. - -Depends on: Phase 1–6. Lands last because it is the **least proof-validated** -layer (`surface-binding.md` caution) and depends on everything above. - -### Phase 8 — command + skill + docs reconciliation - -- Delete the absorbed/dead commands per "Command fate" — `relay`, `stack`, - `survey`, `diff`, `describe` — and remove the relay-config loader, - request-resolution, and request-stack modules in `context/`. This is execution, - not decision. -- Update the skill bundle references to teach placement + surfaces (the - `voice.md` fix was a preview of this). -- Regenerate the CLI manifest (`pnpm dump:cli-help`). -- Fill in the `major` changeset. -- Tests: `cli.test.ts`, `public-exports.test.ts` updated to the new surface. - -Depends on: Phase 1–7. - -## What lands when (the cutover line) - -Phases 1–2 are **additive and safe** — they can land without breaking anything. -**Phase 3 is the breaking line**: once node coordinate fields are removed, old -fingerprints fail lint. Everything from Phase 3 on is part of the single major -release. Plan to land 1–2 first to de-risk, then 3–8 as the breaking sequence. - -## Command fate: the desire-survives test (settled) - -Decided by one rule: **a command's desire survives if the new model serves it; the -command's implementation survives only if it already is that.** Relay the desire -("give an agent the right narrow context at the right time, traceably") is the -whole point of the coordinate space and is realized correctly by the Phase 5 -resolver. Relay the implementation (`relay-config`, `request_resolvers`, -`sources`, `ghost.relay-request/v1`, the selector-routing facet) is the second -routing system on the delete list. So the *desire* lives on under a truer name; -the *mechanism* dies. - -Applying the test to the whole delete list: - -| Command | Desire survives as | Implementation | -| --- | --- | --- | -| `relay` | Phase 5 slice resolver + menu emitter | **deleted** (absorbed into a new `gather` / `select` command) | -| `stack` | path → surface (Phase 7 binding) | **deleted** (absorbed) | -| `survey` | nothing in the new model | **deleted** | -| `diff` | the dead direct-markdown path | **deleted** | -| `describe` | the dead direct-markdown path | **deleted** | - -Consequence for sequencing: **Phase 5 ships the new context-gathering command** -(working name `gather` or `select`) as relay's desire done right, and **Phase 8 -deletes `relay` / `stack` / `survey` / `diff` / `describe` as execution, not -decision.** The relay config loader, request-resolution, and request-stack -modules in `context/` are removed with `relay`. - -## Open decisions for planning - -1. **One mega-PR vs. phased merges to the branch.** Recommendation: phased - commits on `reset-coordinate-space` (as we have been), squash-reviewed as one - major release. Keeps each cut reviewable without shipping a half-cut model. -2. **`ghost migrate` permanence.** Is the migrator a permanent command or a - one-release transitional tool removed in the next major? Recommendation: - transitional, documented as such. -3. **New command name.** `gather` vs. `select` vs. keeping `gather` as a verb on - a renamed noun. Cosmetic; decide at Phase 5. - -## Not the work itself - -This note sequences the cutover. It writes no code. Each phase becomes its own -commit (or small PR) on the branch, lands green, and is checked off here as it -completes. Implementation starts at Phase 0. - -## Read-back - -This plan succeeds if: - -- The cutover is one breaking major, not a compatibility bridge. -- Phases are dependency-ordered: schema → lint → placement → map-delete → - resolver → migrate → binding → commands. -- The breaking line (Phase 3) is explicit, with safe additive work (1–2) landed - first. -- Every phase lands green and committed before the next begins. -- The least-validated layer (binding) lands last. diff --git a/docs/ideas/one-road.md b/docs/ideas/one-road.md deleted file mode 100644 index 1d3fec04..00000000 --- a/docs/ideas/one-road.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -status: exploring ---- - -# One road: remove the binding and nesting, drive everything from the prompt - -A decision, not a hedge. Ghost keeps the one thing only it can do — deterministically -compose the curated slice for a *named surface* — and drops everything that tried -to infer intent or context from repo location: the **binding** (`ghost.binding/v1`, -path→surface, Phase 7a + Cut D) **and nesting itself** (stacks, cross-package -discovery, `--all`/`--scope`/`--path`). One contract per package; surfaces are -the only locality. - -## The case - -- The agent never has only a path. It has the prompt **and** its own whole-repo - analysis — strictly more than a path glob. Binding had Ghost doing, badly, a - job the agent already does better (deciding what a change is about). -- The binding is the last "second source of truth that can drift from reality" — - the same pattern the reset killed in the merge (Leak E), the map, and relay. -- The determinism the binding protected — routing with no LLM — has had nothing - to protect since Cut C: checks are markdown, always agent-evaluated. There is - no no-agent path left to guard. -- Removing it **unifies all four outcomes into one flow**: prompt (+ the agent's - repo/diff analysis) → match the surface menu → `gather ` → slice. The - repo case becomes a special case of the brand case; the contract is portable by - default, not "the clean half of a split." - -## The single thing we give up (named honestly) - -Deterministic, prompt-free path→surface routing: "this file changed → these -checks always run, with no agent in the loop." That belongs to eslint/CI, not Ghost, -and post-Cut-C Ghost no longer offers it anyway. The *capability* people wanted -from it — run the right checks on a diff — survives: the agent names the touched -surfaces (it already analyzed the diff) and asks Ghost for those. - -External-contract use (Cut D) also survives via the **desire-survives test**: use -`gather --package node_modules/@scope/brand/.ghost ` to compose from an -installed brand package. The agent points at the package; no binding-side -resolution needed. Mechanism dies, capability stays. - -## Nesting goes too (the correction) - -An earlier draft of this note kept "nested-package discovery." That was wrong. -Nesting only ever meant two things: **merge** (federated child fingerprints, -killed in 7b Cut 1) and **binding** (nested `.ghost/` = path→surface, killed -here). Once both are gone, **nesting has no meaning left** — keeping discovery, -stacks, and `--all` is scaffolding for a concept that no longer exists. - -**Decision: one contract per package.** A repo's `.ghost/` is the contract. -A monorepo with genuinely independent products runs Ghost per-package (or points -`--package` at each) — those are parallel standalone contracts, not a nested -hierarchy. No stacks, no merge, no chain, no cross-package discovery. - -So this cut also removes the **stack machinery** and the nesting commands: - -- `loadFingerprintStackForPath`, `groupFingerprintStacksForPaths`, - `discoverFingerprintStack`, `buildFingerprintStack`, - `fingerprintStackToPackageContext`, `GhostFingerprintStack*` types, - `lintAllFingerprintStacks`, `verifyAllFingerprintStacks`, - `discoverGhostPackages`, `initScopedFingerprintPackage`. -- `lint --all`, `verify --all`, `scan --include-nested`, `emit --path`, - `init --scope`. - -## What stays untouched (the engine) - -Surfaces, the containment tree, cascade, typed edges, `gather `, the -surface menu, `ghost.check/v1`, `selectChecksForSurfaces`, grounding, -`resolveSurfaceSlice`. The core model does not move. - -**Load-bearing helpers in `fingerprint-stack.ts` survive** (they are not nesting): -`resolveGitRoot`, `normalizeGhostDir`, `resolveGhostDirDefault`, -`GHOST_PACKAGE_DIR_ENV`, `fingerprintPackageDisplayPath`. Move them to a neutral -home (e.g. `scan/package-paths.ts`) before deleting the rest of the file. - -**`--package` and `GHOST_PACKAGE_DIR` survive** — "use exactly this `.ghost/` -dir" is direct addressing, not nesting. This is how a monorepo targets one of its -independent contracts. - -## The new command shapes - -- **`gather `** — unchanged. **Drop `gather --path`.** -- **`gather`** (no surface) — unchanged: returns the menu for the agent to match. -- **`checks --surface `** — replaces `checks --diff`. The agent passes the - surfaces it already determined the change touches (comma-separated, or repeated - flag). Ghost routes + grounds for those surfaces. **Drop diff parsing + - path→surface from `checks`.** -- **`review --surface `** (+ `--diff` kept *only* as the patch to embed in - the packet, not to resolve surfaces from). The agent supplies the surfaces; the - diff is included verbatim for the reviewer. **Drop path→surface from `review`.** - -Rationale: a diff no longer *implies* surfaces (that was the binding's job). -The agent — which read the diff — states the surfaces. Ghost stops guessing. - -## Surgical removal plan (sequenced, each step green) - -### Step 0 — rescue the load-bearing path helpers FIRST (ordering fix) - -Pressure-test finding: `scan/binding-discovery.ts` and `scan/verify-package.ts` -both `import { resolveGitRoot } from "./fingerprint-stack.js"` — i.e. modules -deleted in Steps 2–3 depend on helpers the old plan didn't move until Step 4. -Deleting before moving creates a fragile window. So move the helpers **before any -deletion**, and every later step stays trivially green. - -- Create `scan/package-paths.ts` and move the five survivors out of - `fingerprint-stack.ts`: `resolveGitRoot`, `normalizeGhostDir`, - `resolveGhostDirDefault`, `GHOST_PACKAGE_DIR_ENV`, `fingerprintPackageDisplayPath`. -- Repoint **every** importer to the new home: `fingerprint-commands.ts`, - `verify-package.ts`, `binding-discovery.ts` (harmless — it dies in Step 3, but - keep the build green in between), `init-command.ts`, `scan-emit-command.ts`, - `monorepo-init-command.ts`, and the `scan/index.ts` re-exports. -- **`scan/index.ts` keeps these five re-exported** (now from `package-paths.ts`). - They are live public exports — do not drop them when the stack re-exports go. - -### Step 1 — reshape the consumers off path-resolution (before deleting it) - -Do this first so nothing imports the binding when we delete it. - -- **`gather-command.ts`**: remove `--path`, `discoverBindingsForPath`, - `resolvePathToSurface`. `gather` takes a surface arg or returns the menu. Done. -- **`checks-command.ts`**: replace `--diff` + diff→surface resolution with - `--surface `. Parse the id list, `selectChecksForSurfaces` + `groundSurface` - over them. Keep `--package`, `--format`, `--no-grounding`. Drop - `parseUnifiedDiff`, `discoverBindingsForPath`, `resolvePathToSurface`. -- **`review-packet.ts`**: `buildReviewPacket` takes `surfaces: string[]` instead - of resolving from the diff; keep the diff purely as embedded text. Drop the - binding imports + `parseUnifiedDiff`-for-resolution (diff text still included). -- **`cli.ts`**: update `review` to accept `--surface`; keep `--diff` as embed-only. - -> Nit (don't trip): the `item.path` field in `checks-command.ts:157` and -> `review-packet.ts:273` is a **display** field on grounding items, not -> path→surface resolution. Drop `parseUnifiedDiff` and the binding resolution; -> **keep `item.path`** — it's unrelated and survives. - -### Step 2 — delete the binding verify + file-kind dispatch - -- **`scan/verify-package.ts`**: delete `verifyBindingContract` / - `readContractSurfaceIds` and the `resolveContractDir` import. Verify goes back - to fingerprint evidence/exemplars only. -- **`scan/file-kind.ts`**: remove the `binding` kind, `.ghost.bind.yml` - detection, the `ghost.binding/v1` schema match, the dispatch branch, and - `lintBindingFile`. - -### Step 3 — delete the binding modules - -- `ghost-core/binding/` (schema, lint, types, resolve, contract-ref, index). -- `scan/binding-discovery.ts`, `scan/contract-resolver.ts`. -- Remove all binding/contract re-exports from `ghost-core/index.ts` and - `scan/index.ts`. - -### Step 4 — tear down nesting (the correction) - -Helpers are already rescued (Step 0), so `fingerprint-stack.ts` deletes cleanly. - -- **Delete the rest of `fingerprint-stack.ts`:** stack types, `discoverGhostPackages`, - `discoverFingerprintStack`, `loadFingerprintStackForPath`, - `groupFingerprintStacksForPaths`, `buildFingerprintStack`, - `loadFingerprintStackLayer`, `fingerprintStackToPackageContext`, - `lintAllFingerprintStacks`, `verifyAllFingerprintStacks`, - `initScopedFingerprintPackage`. (The file disappears entirely once the five - helpers are gone.) -- **`fingerprint.ts`:** drops imports of `initScopedFingerprintPackage`, - `lintAllFingerprintStacks`, `verifyAllFingerprintStacks` (lines 39–41). Missed - by the earlier draft — it is a real consumer of three deleted functions and - will break the build if skipped. -- **`fingerprint-commands.ts`:** remove `lint --all`, `verify --all`, - `scan --include-nested`, `nestedPackageStatus`. `lint`/`verify`/`scan` operate - on the single resolved package (or `--package`). -- **`scan-emit-command.ts`:** remove `--path` and the stack path; `emit` runs on - the resolved package or `--package`. -- **`init-command.ts`:** remove `init --scope`. -- **`monorepo-init-command.ts`:** this command exists only to scaffold nested - packages via `initScopedFingerprintPackage` — confirm whether the whole command - dies (likely) or just the scoped path. It also imports the surviving - `normalizeGhostDir`, so do not delete the file wholesale without repointing - that import (handled in Step 0) and checking for any non-nesting use. -- Remove the **stack** re-exports from `scan/index.ts` (the five path helpers - stay — see Step 0). - -### Step 5 — docs, skill, migrate note, changeset - -- **`migrate-legacy.ts`**: the `paths-not-migrated` note currently says - "path→surface binding is not part of placement." Reword to "paths are not part - of the surface model" (drop the binding reference). -- **Skill bundle / `schema.md`**: remove `.ghost.bind.yml` and binding/contract - guidance; teach the single flow (prompt → menu → `gather `; agent - names touched surfaces for `checks`/`review`; external contract via - `gather --package`). -- Mark `surface-binding.md`, `phase-7-plan.md`/`7a`, `polish-cut-d-plan.md` - superseded with a one-line header pointing here. -- `major` changeset: removes `ghost.binding/v1`, `.ghost.bind.yml`, - `gather --path`, `checks --diff`, `lint --all`, `verify --all`, - `scan --include-nested`, `emit --path`, `init --scope`, and nested-package - stacks. `checks`/`review` take `--surface`; one contract per package. - -## Tests - -- Delete `binding-resolve`, `binding-schema`, `contract-ref`, `contract-resolver` - test files. -- `cli.test.ts`: replace `gather --path` and `checks --diff` cases with - `checks --surface`; rework the `review ... mixed diff` case to pass `--surface`; - drop the external-contract verify case (or move it to a `gather --package` case). -- `surfaces-*`, `check-route`, `surfaces-ground` are unaffected (they never used - the binding). -- Full `pnpm test` + `pnpm check` green. - -## Scope boundary (what this does NOT do) - -- Does **not** touch the surface model, cascade, gather slice, checks routing - logic, or grounding — only how *which surfaces* is determined (agent-stated, not - path-resolved). -- Does **not** remove `--package` / `GHOST_PACKAGE_DIR` — direct addressing of a - single package survives; it is how a monorepo targets one of its independent - contracts. -- Does **not** add NLP to Ghost — the agent still does all matching; Ghost gains - no understanding, it just stops guessing from paths. - -## Read-back - -One road succeeds if: the binding (`ghost.binding/v1`, path→surface, contract -resolution) **and** all nesting (stacks, merge-era discovery, `--all`, -`--include-nested`, `--path`, `--scope`) are gone; one contract per package; -`gather` takes only a surface or returns the menu; `checks` and `review` take -agent-stated `--surface` ids (diff is embed-only); external contracts and -monorepo sub-contracts are reached via `--package`; the load-bearing path -helpers survive in a neutral home; the surface engine is untouched; and Ghost no -longer infers intent from repo location anywhere. diff --git a/docs/ideas/parked-survey-module.md b/docs/ideas/parked-survey-module.md deleted file mode 100644 index ec9e0ad8..00000000 --- a/docs/ideas/parked-survey-module.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -status: parked ---- - -# Parked: the `ghost.survey/v1` module - -This note records a deliberate decision **not** to act. Survey is isolated, works, -and hurts nothing — so it stays, undocumented in the user-facing surface, until -there is a concrete reason to revisit. This note exists so the reasoning is found, -not rediscovered. - -## What survey is - -`ghost.survey/v1` is a **machine-scan cache** — a `survey.json` a scanner emits -with raw repo observations (sources, value rows, tokens, components, -ui_surfaces). It predates the surface model and is the last surviving piece of -the pre-reset world (same era as `map.md`, `resources.yml`, the old `relay`). It -lives in `packages/ghost/src/ghost-core/survey/` (~14 files). - -The `ghost survey ` **command** was removed in Phase 8. The **module** -remained because other code still imports it. - -## Why it is parked, not removed - -The importers split in two: - -- **Vestigial (mechanical to cut):** `scan/file-kind.ts` routes `.json` to the - survey linter; `scan/fingerprint-package.ts` / `scan/constants.ts` carry a - `survey` path slot; `fingerprint-commands.ts` has leftover refs. These only - *recognize* survey files. -- **Load-bearing (the real question):** `comparable-fingerprint.ts` reads - `survey.json` to build comparison input, and `ghost-core/perceptual-prior.ts` - uses `surveyCount` for presence/absence escalation. So **`ghost compare` may - depend on survey evidence.** - -That makes removal an *excavation*, not a deletion. The open question at its -center: - -> Does `ghost compare` still need survey evidence, or can it compare from the -> fingerprint's own `evidence` / `exemplars` alone? - -Answering it is a change to how comparison works — its own design call, in a -corner of Ghost (compare / perceptual-prior) the surface reset never touched. -Rushing it would either silently degrade `compare` or invent a new -compare-evidence path without a plan. That violates the read-first-then-cut -discipline that held the whole reset together. - -## Stance - -- **Not debt.** Survey is isolated and functional; nothing is blocked. -- **Not exposed.** No user-facing command or doc points at it; it is internal - plumbing only. -- **Surfaced only if a reason appears** — e.g. survey genuinely loses its last - consumer, or comparison is reworked and the evidence-source question comes up - on its own. - -## If it is ever revisited - -First move is a read of `comparable-fingerprint.ts` + `perceptual-prior.ts` to -answer the compare-evidence question. Only then decide whether survey lives -(and is re-justified in the surface world) or is removed (vestigial importers -first, then the load-bearing two, then the module). Do not start by deleting -files. diff --git a/docs/ideas/phase-1-node-schema.md b/docs/ideas/phase-1-node-schema.md deleted file mode 100644 index a5f5280a..00000000 --- a/docs/ideas/phase-1-node-schema.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -status: exploring ---- - -# Phase 1: the node schema (the keystone) - -First build phase after one-road (shipped). Grounded in the current code, not a -greenfield sketch. Read `context-graph.md` (the model) and -`graph-implementation-plan.md` (the sequencing) first; this note is the -execution spec for Phase 1 only. - -## Goal and boundary - -Define the **node** — the single artifact every fingerprint is made of — as a -schema + types + parser, **in isolation**, with no loader and no consumer -rewiring. The phase is done when: - -- a `ghost.node/v1` markdown+frontmatter artifact has a Zod schema and types, -- it parses (reusing the check parser), validates per-node, and round-trips, -- it is unit-tested, -- **nothing else changes** — the existing facet loader, `resolveSurfaceSlice`, - checks, compare all still compile and pass against the old model. - -Phase 1 is additive. The node model lands beside the facet model; the loader -fold (Phase 2) is what switches the system over. This keeps Phase 1 reviewable -and green. - -## What a node is (the conformance envelope) - -A node is one markdown file: YAML frontmatter + prose body. The frontmatter is -the machinery's handle; the body is design expression (written through the -intent/inventory/composition lenses, which are authorship guidance only — never -schema). - -```yaml ---- -# REQUIRED -id: checkout/trust-signals # unique, addressable - -# OPTIONAL (defaults keep small scale invisible) -under: checkout # parent node — the tree + cascade (omitted at root) -relates: # lateral links, typed + optional qualifier - - to: core/trust - as: reinforces # reinforces | contrasts | variant (closed set) -medium: web # any | web | email | billboard | slide | voice | - # generated-screen | (default: manifest medium) ---- -Prose body. The design expression. Intent / inventory / composition are how it -is written, not fields. -``` - -**Valid iff:** has `id`, parses (frontmatter + body), and `under`/`relates` -targets are well-formed refs. Cross-node resolution (does the target exist? one -root? no cycles?) is **Phase 8 lint** — Zod cannot see other nodes, exactly as -`surfaces.yml` already defers graph rules. - -## Decisions locked before writing (from the design thread) - -1. **`node` is machinery vocabulary.** Schema id `ghost.node/v1`, types - `GhostNode*`. Never user-facing prose. -2. **intent/inventory/composition have zero schema footprint.** Free markdown - body. No conventional headings, no body validation. -3. **One node = one concept**, scaffolded one-file-per-node (a Phase 5 `init` - concern; the schema is layout-free). -4. **`relates` qualifier vocabulary (closed):** `reinforces`, `contrasts`, - `variant` to start. `governs`/`projects` are deferred (Scenario D / explicit - projection) — not in the v1 enum. -5. **`medium` is an open enum** (known media + custom string), single-valued for - v1 (multi-valued deferred). - -## The id grammar (permissive schema, opinionated guidance) - -Two existing id rules collide — fingerprint nodes (`SlugIdSchema`) allow dots, -surfaces (`SurfaceIdSchema`) ban them (a dotted id would pretend to be a `parent` -link). The resolution is **not** to pick a stricter grammar. It is to apply the -project philosophy: **conformance is machine-tractability; guidance steers taste; -Git review is the approval boundary — not strict lint.** - -- **The tree is `under`, and only `under`.** An id is just a name and carries no - structural meaning, ever. This is the one principle that actually matters, and - it holds regardless of the id's characters. So the surfaces concern (an id - encoding the tree) is dissolved by *contract*, not by banning characters. -- **Schema is permissive:** an id is a non-empty lowercase slug, unique within - the package. It does not mandate a separator style. - - Charset: `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric plus `.` `_` `-`). - Liberal on purpose — a hand-authored id that uses something other than the - default still validates. -- **Default convention = dashes** (`checkout-trust-signals`). This lives in the - **skill guidance, `init` scaffolding, and agent authoring** — the things that - *emit* ids steer to dashes. A human can hand-author otherwise; Git review is - the check, not an error-level lint rule. -- **No strict style lint.** At most a soft `info` nudge toward the dash - convention — never an error. (Style-Dictionary move: easy default, flexible - underneath.) -- **The worked scenarios' ids become dashed:** `checkout/trust-signals` → - `checkout-trust-signals`, `launch.billboard` → `launch-billboard`. Readable, - flat, no hierarchy mixing. - -Cross-package refs (`@scope/pkg#id`) are **parsed but not resolved** in Phase 1 -(resolution is Phase 6). The grammar should *accept* the `package#` prefix so -the schema doesn't reject valid future refs; resolution is a later phase. - -## Files to add (all additive, under ghost-core/node/) - -``` -ghost-core/node/ - types.ts # GHOST_NODE_SCHEMA, GhostNode, GhostNodeRelation, qualifier enum, - # medium type, lint report types (mirror surfaces/check shape) - schema.ts # Zod: node frontmatter schema + id/ref grammar - parse.ts # parseNode(raw) → { frontmatter, body } reusing parseCheckMarkdown - serialize.ts # serializeNode(node) → markdown (round-trip; needed by migrate/init later) - index.ts # public surface for the module -``` - -Reuse, do not duplicate: -- **`parseCheckMarkdown`** (ghost-core/check/parse.ts) is exactly the - frontmatter+body splitter — lift it to a shared helper or import it directly. -- Mirror the **lint report shape** (`{ issues, errors, warnings, info }`) used by - surfaces/check/fingerprint so the CLI treats all reports uniformly. - -## Schema sketch (ghost.node/v1) - -```ts -export const GHOST_NODE_SCHEMA = "ghost.node/v1" as const; - -const NodeIdSchema = z.string().regex(/^[a-z0-9][a-z0-9._-]*$/, …) // permissive slug -const NodeRefSchema = z.string()… // [#] (pkg accepted, not resolved) - -export const GHOST_NODE_RELATION_KINDS = ["reinforces", "contrasts", "variant"] as const; - -const NodeRelationSchema = z.object({ - to: NodeRefSchema, - as: z.enum(GHOST_NODE_RELATION_KINDS).optional(), // default: untyped relate -}).strict(); - -export const GhostNodeFrontmatterSchema = z.object({ - id: NodeIdSchema, - under: NodeRefSchema.optional(), - relates: z.array(NodeRelationSchema).optional(), - medium: z.string().min(1).optional(), // open enum; lint may warn on unknowns -}).strict(); -``` - -Plus a `parseNode` that returns `{ frontmatter: GhostNodeFrontmatter, body }` -and a thin `lintGhostNode(raw)` that reports per-node (missing id, malformed -ref, unknown qualifier) — graph rules deferred. - -## Tests (Phase 1 scope only) - -A `test/ghost-core/node-schema.test.ts`: -- valid minimal node (id only) parses and validates. -- id grammar (permissive): accepts `core`, `checkout-trust-signals`, and even - `email.marketing` (liberal charset); rejects only genuinely malformed ids — - uppercase, leading separator, empty. No separator-style is an error. -- `relates` qualifier: accepts the three kinds; rejects unknown. -- `under`/`relates` ref grammar: accepts local + `@scope/pkg#id`; rejects - malformed. -- `medium` optional; arbitrary string accepted. -- round-trip: `serializeNode(parseNode(x)) ≈ x` for a representative node. -- body is preserved verbatim (frontmatter stripped, prose intact). - -**No** loader test, **no** gather/checks change — those are later phases. - -## Wiring (minimal, additive) - -- Export the node module from `ghost-core/index.ts` (new `ghost.node/v1` block). -- Do **not** add it to `file-kind.ts` dispatch yet (that routes lint; wiring it - in is Phase 2/8 when the loader and lint actually consume nodes). Keep Phase 1 - free of consumer changes. -- `public-exports.test.ts`: add the node module's presence to the export - assertions only if we expose it on a public subpath now; otherwise defer the - export-surface decision to when a consumer needs it. - -## Explicitly NOT in Phase 1 - -- The loader fold (Phase 2) — nodes still are not read from disk into the graph. -- Removing the facet schemas/types — they stay until Phase 2 switches the loader. -- `medium` in gather/checks (Phase 3/4). -- Cross-package ref *resolution* (Phase 6) — grammar only. -- Graph-level lint: target-exists, one-root, no-cycles (Phase 8). -- The `surface`→`node` rename of existing symbols — that happens as the loader - and consumers move (Phase 2+), not in this additive phase. - -## Open micro-decisions (decide while building, low stakes) - -1. **Lift `parseCheckMarkdown` to a shared `ghost-core/markdown.ts`, or import - from check?** Lean: lift to shared — both checks and nodes are the same - envelope; one splitter. -2. **Default `relates.as` — untyped or required?** Lean optional (OKF's untyped - link is valid; the qualifier is the machinery handle when present). -3. **Should `id` segments cap depth?** Lean no cap; `under` carries hierarchy, - id is just a name. Lint can warn on absurd depth later. - -## Read-back - -Phase 1 succeeds if `ghost.node/v1` exists as schema + types + parser + -serializer, validates a node in isolation (id required, permissive lowercase -slug; the tree lives only in `under`; typed-and-optional `relates`; optional -`medium`; `package#` prefix accepted but unresolved), round-trips, is -unit-tested, and the rest of the system is untouched and green. Dashes are the -emitted convention (skill/init/agent), not a lint rule. The keystone is in place -for the Phase 2 loader fold to read nodes into the existing -`GhostFingerprintDocument` graph. diff --git a/docs/ideas/phase-1-plan.md b/docs/ideas/phase-1-plan.md deleted file mode 100644 index dcef6a8f..00000000 --- a/docs/ideas/phase-1-plan.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -status: exploring ---- - -# Phase 1 plan: the `ghost.surfaces/v1` schema module - -This note is the execution spec for Phase 1 of `implementation-plan.md`. It is -the first line of real code in the surface-model cutover. Phase 1 is **purely -additive**: it introduces a new module and changes no existing behavior, so it -lands green without breaking anything (the breaking line is Phase 3). - -Scope is deliberately narrow: schema + types + a thin module export + tests. -**Lint validation (cycles, dangling refs) is Phase 2, not here.** Phase 1 only -proves the shape parses and the basic Zod constraints hold. - -## Deliverable - -A new module `packages/ghost/src/ghost-core/surfaces/` that mirrors the existing -`ghost-core/fingerprint/` module layout: - -```text -ghost-core/surfaces/ - types.ts # constants, TS interfaces, lint report types - schema.ts # Zod schema for surfaces.yml - index.ts # public re-exports (mirrors fingerprint/index.ts) -``` - -Plus a test file `packages/ghost/test/ghost-core/surfaces-schema.test.ts`, and a -one-line addition to `ghost-core/index.ts` re-exporting the new module under a -`// --- Surfaces (ghost.surfaces/v1) ---` section header (matching the existing -section-comment convention). - -No CLI wiring, no loader, no consumers. Those are later phases. - -## `types.ts` - -Constants and interfaces, following `fingerprint/types.ts` conventions exactly. - -```ts -export const GHOST_SURFACES_SCHEMA = "ghost.surfaces/v1" as const; -export const GHOST_SURFACES_YML_FILENAME = "surfaces.yml" as const; - -// The fixed, Ghost-owned edge vocabulary (surface-schema.md: closed set). -// A code constant, never package data. -export const GHOST_SURFACE_EDGE_KINDS = ["composes", "governed-by"] as const; -export type GhostSurfaceEdgeKind = (typeof GHOST_SURFACE_EDGE_KINDS)[number]; - -export const GHOST_SURFACE_ROOT_ID = "core" as const; - -export interface GhostSurfaceEdge { - kind: GhostSurfaceEdgeKind; - to: string; -} - -export interface GhostSurface { - id: string; - description?: string; - parent?: string; - edges?: GhostSurfaceEdge[]; -} - -export interface GhostSurfacesDocument { - schema: typeof GHOST_SURFACES_SCHEMA; - surfaces: GhostSurface[]; -} - -// Lint report types reuse the fingerprint shape verbatim so Phase 2 and the -// CLI can treat all facet lint reports uniformly. -export type GhostSurfacesLintSeverity = "error" | "warning" | "info"; -export interface GhostSurfacesLintIssue { - severity: GhostSurfacesLintSeverity; - rule: string; - message: string; - path?: string; -} -export interface GhostSurfacesLintReport { - issues: GhostSurfacesLintIssue[]; -} -``` - -## `schema.ts` - -Zod, following `fingerprint/schema.ts` conventions (`.strict()`, slug regex -reused, descriptive error messages). - -```ts -import { z } from "zod"; -import { - GHOST_SURFACE_EDGE_KINDS, - GHOST_SURFACES_SCHEMA, -} from "./types.js"; - -// Flat slug, NO dots-as-hierarchy. surface-schema.md: the tree lives only in -// `parent`; a dotted id would be a second, conflicting source of truth. -const SurfaceIdSchema = z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9_-]*$/, { - message: - "surface id must be a flat slug (lowercase alphanumeric plus _ -, no dots; the tree lives in parent)", - }); - -const SurfaceEdgeSchema = z - .object({ - kind: z.enum(GHOST_SURFACE_EDGE_KINDS), - to: SurfaceIdSchema, - }) - .strict(); - -const SurfaceSchema = z - .object({ - id: SurfaceIdSchema, - description: z.string().min(1).optional(), - parent: SurfaceIdSchema.optional(), - edges: z.array(SurfaceEdgeSchema).optional(), - }) - .strict(); - -export const GhostSurfacesSchema = z - .object({ - schema: z.literal(GHOST_SURFACES_SCHEMA), - surfaces: z.array(SurfaceSchema).optional().default([]), - }) - .strict(); -``` - -Note the **deliberate boundary**: the slug regex *excludes the dot* (`.`), which -is what mechanically bans dotted-id hierarchy at the schema layer. That is a -Phase 1 guarantee. Structural rules that need the whole document — parent -references an existing id, no cycles, edge `to` exists, single root — are -**graph-level checks deferred to Phase 2 lint**, because Zod validates a node in -isolation and cannot see the rest of the tree. - -## `index.ts` - -Mirror `fingerprint/index.ts`: re-export schema, types, and constants. (No lint -export yet — that is Phase 2.) - -```ts -export { GhostSurfacesSchema } from "./schema.js"; -export { - GHOST_SURFACE_EDGE_KINDS, - GHOST_SURFACE_ROOT_ID, - GHOST_SURFACES_SCHEMA, - GHOST_SURFACES_YML_FILENAME, - type GhostSurface, - type GhostSurfaceEdge, - type GhostSurfaceEdgeKind, - type GhostSurfacesDocument, - type GhostSurfacesLintIssue, - type GhostSurfacesLintReport, - type GhostSurfacesLintSeverity, -} from "./types.js"; -``` - -And in `ghost-core/index.ts`, add under a new section comment: - -```ts -// --- Surfaces (ghost.surfaces/v1) --- -export { - GhostSurfacesSchema, - GHOST_SURFACES_SCHEMA, - GHOST_SURFACES_YML_FILENAME, - GHOST_SURFACE_EDGE_KINDS, - GHOST_SURFACE_ROOT_ID, - type GhostSurface, - type GhostSurfaceEdge, - type GhostSurfaceEdgeKind, - type GhostSurfacesDocument, -} from "./surfaces/index.js"; -``` - -## Tests - -`test/ghost-core/surfaces-schema.test.ts`, following -`fingerprint-yml-schema.test.ts` style. Cases: - -- **Accepts** a minimal document (`{ schema, surfaces: [] }`) and defaults - `surfaces` to `[]` when absent. -- **Accepts** a realistic tree: `core`, `email` (parent `core`), - `email-marketing` (parent `email`), `checkout` with two typed edges. -- **Rejects** a dotted id (`email.marketing`) with the slug message. -- **Rejects** a parent given as an array (single parent only — falls out of - `parent` being a scalar; assert the strict parse fails). -- **Rejects** an unknown edge kind (`kind: see-also`). -- **Rejects** an unknown top-level key (`.strict()` guard). -- **Accepts** an edge `to` that does not exist as a surface — and a comment in - the test notes this is intentionally a **Phase 2 lint** concern, not a schema - concern. This documents the schema/lint boundary so a future contributor does - not "fix" it in the wrong layer. - -## Acceptance - -Phase 1 is done when: - -- `pnpm build` and `pnpm typecheck` pass with the new module. -- `pnpm test` passes including the new test file. -- `pnpm check` passes (biome, file-size, terminology, docs, cli-manifest — the - manifest is unchanged because no CLI command was added). -- `@anarchitecture/ghost/core` exports the new symbols (extend - `public-exports.test.ts` only if it asserts core symbols; otherwise leave it). -- Nothing in existing behavior changed: no existing file's logic is edited, only - `ghost-core/index.ts` gains export lines. - -## Out of scope (explicitly) - -- Lint / graph validation (cycles, dangling parent/edge refs, near-miss ids, - reserved `core`) → **Phase 2**. -- Loading `surfaces.yml` from disk, CLI `lint`/`verify` wiring → Phase 2+. -- Node `surface:` placement on description facets → **Phase 3** (the breaking - line). -- Any removal of `topology` / `applies_to` / `ghost.map/v1` → Phase 3–4. - -## Commit - -One commit: `feat(surfaces): add ghost.surfaces/v1 schema module (additive)`. -No changeset yet — Phase 1 ships no user-visible behavior; the `major` changeset -is assembled across the breaking phases (3+) per `implementation-plan.md` -Phase 0. - -## Read-back - -Phase 1 succeeds if a contributor can import `GhostSurfacesSchema` from -`@anarchitecture/ghost/core`, parse a valid `surfaces.yml` shape, see dotted ids -and unknown edge kinds rejected at the schema layer, and understand from the -tests exactly which validation is deferred to Phase 2 lint — all without any -existing behavior changing. diff --git a/docs/ideas/phase-2-loader-fold.md b/docs/ideas/phase-2-loader-fold.md deleted file mode 100644 index 5c7690f6..00000000 --- a/docs/ideas/phase-2-loader-fold.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -status: exploring ---- - -# Phase 2: the loader fold (the hard phase) - -Second build phase after one-road + Phase 1 (node schema, shipped). This is the -one genuinely hard phase: where the system gains an in-memory **node graph** and -the loader learns to produce it. Read `context-graph.md`, -`graph-implementation-plan.md`, and `phase-1-node-schema.md` first. - -## The honest correction (grounded in the code) - -`context-graph.md` claimed the in-memory `GhostFingerprintDocument` and every -read consumer stay unchanged. **Reading the loader, that is too optimistic** and -the plan must say so: - -- The in-memory doc is **richly typed facets**: `intent.principles[]` (each with - `.principle` text, `guidance[]`, `evidence[]`, `check_refs[]`), - `intent.situations[]`, `intent.experience_contracts[]`, - `composition.patterns[]` (`.kind`, `.pattern`), `inventory` building blocks + - exemplars + sources. -- `resolveSurfaceSlice` and `groundSurface` read those **typed fields directly** - (`node.principle`, `entry.node.kind`). -- A Phase-1 **node is prose body + minimal frontmatter** (`id`, `under`, - `relates`, `medium`) — by design it has *no* `.principle` string, `guidance[]`, - or `evidence[]`. - -So a node and a facet entry are different shapes. The fold is not a reshuffle; -it forces the central decision below. What *is* true from `context-graph.md`: -there is a clean seam (`files → loader → in-memory → consumers`), and we can -keep the build green by making Phase 2 **additive** — the node graph lands -*beside* the facet doc; consumer migration is later phases. - -## The node content model: SETTLED — Option A (pure prose) - -A graph node is `{ id, under, relates, medium, body }`. The **body is the -expression**; there are **no** structured node fields. The facet affordances — -`guidance`, `evidence`, `check_refs`, pattern `kind`, the `.principle` / -`.pattern` / `.contract` text slots — are **not** node structure and go away as -the model migrates. This is the cleanest end-state and is truest to -"intent/inventory/composition are authorship lenses, not fields." - -What A means downstream (named honestly, so later phases own it): - -- **gather slice changes shape** (Phase 3): a slice is no longer typed sections - (`principles[]`, `patterns[]`); it is **nodes-by-provenance** — the relevant - nodes and their prose, each tagged own / inherited-from-ancestor / via-edge. -- **checks grounding is reconceived from prose** (Phase 4): `why` / `what` come - from the prose of the nodes on the surface + ancestors, not from `principle` - statements and `exemplar` rows. -- **verify loses evidence/exemplar path-checking** (its own later phase): nodes - have no `evidence` paths. That responsibility either disappears or moves; it is - not a node concern under A. -- **compare/drift is reconceived over prose + topology** (later): no structured - fields to diff; comparison works from the graph shape and node prose - (embeddings already exist for prose-level comparison). - -Phase 2 itself stays additive and green because **nothing reads the graph yet** — -the graph lands beside the facet doc, and consumers migrate one per later phase, -with the facet model deleted last. - -## The one sub-decision: lossy facet→node projection (transition scaffold) - -Existing packages and fixtures are facet-based. To keep the build green and -reuse fixtures, the fold projects facet entries into prose nodes during -transition: each `principle` / `pattern` / `contract` / `situation` / `exemplar` -becomes a node whose `id` is the entry id, `under` is its `surface:` tag (or -`core`), and whose **body is the entry's text** (`principle` / `pattern` / -`contract` string). This projection is **lossy on purpose** (it drops -`evidence` / `guidance` / `check_refs` — exactly the affordances A removes) and -is **explicit transition scaffolding, deleted in the facet-removal phase**. It -is not a permanent bridge. Decision: keep the projection (continuity + test -reuse) and mark it for deletion; do not let any new code depend on its lossy -output as if it were authoritative. - -## Phase 2 scope - -Additive. The facet loader, `resolveSurfaceSlice`, checks, compare are all -untouched and green at the end. - -1. **`GhostGraph` in-memory type** (`ghost-core/graph/`): the resolved graph — - `nodes` (id → `{ id, under, relates, medium, body }`), the `under` tree - (parent edges, root = `core`), and `relates` links. Mirror, don't fight, - `GhostSurfacesDocument` — surfaces already model a tree + typed edges; the - graph is surfaces + placed prose nodes unified. - -2. **`assembleGraph` — the fold.** Build a `GhostGraph` from two sources, unioned: - - **on-disk node files** discovered in the package (see discovery below), and - - **the lossy facet→node projection** above, so every existing package and - test produces a (prose) graph for free and Phase 3 gather can be exercised - against existing fixtures before facets are removed. - -3. **Node discovery (layout, decided minimally).** Per the model, layout is free - and the loader discovers. For Phase 2 pick one default and keep it simple: - nodes are `*.md` files under a `nodes/` directory in the package (mirrors how - `checks/*.md` already works via `loadChecksDir`). Loose-anywhere discovery and - custom layouts are a later refinement; do not over-build discovery now. - -4. **Attach additively:** `LoadedFingerprintPackage.graph?: GhostGraph`. The - existing `fingerprint` (facet doc) and `surfaces` fields stay exactly as they - are. Nothing that reads them changes. - -5. **Tests** (`test/ghost-core/graph-fold.test.ts` + a loader test): the fold - from node files; the lossy facet→node projection; the union; tree resolution - (parent chain, root); `relates` carried through; a package with only facets - still yields a prose graph; a package with node files yields a graph; medium - carried; on-disk node wins over a same-id projection. - -## Explicitly NOT in Phase 2 - -- Switching `gather` to traverse the graph (Phase 3, with `medium`). -- Switching `checks`/grounding to the graph (Phase 4). -- Switching compare/drift (later). -- Removing facet schemas/types/loader (the final phase, once every consumer is - off them). -- Graph-level lint (target-exists, one-root, no-cycles) — Phase 8 lint, though - the fold may surface obvious structural errors as thrown load errors like the - current loader does. -- Cross-package resolution (Phase 6) — the fold resolves within one package. -- The `surface`→`node` rename of existing symbols — happens as consumers move. - -## Open micro-decisions (decide while building) - -1. **Is `core` a real node or an implicit root?** Surfaces treat `core` as the - reserved implicit root. The graph should keep that: `under` omitted ⇒ child of - implicit `core`. Lean: `core` is implicit unless an author writes a `core` - node, in which case that node *is* the root content. -2. **Does the projection dedupe against on-disk nodes by id?** If an author has - written a `checkout-trust` node *and* a facet projects the same id, the - on-disk node wins (authored beats projected). Lean: yes, id-collision → - authored node wins, projection skipped, lint notes it later. -3. **Graph keyed by node-id or by surface?** Both: nodes indexed by id; the - surface tree (from `surfaces.yml` + node `under`) is the traversal spine. - Reconcile `surfaces.yml` (the current explicit tree) with node `under` — for - Phase 2, `surfaces.yml` remains the authoritative tree and nodes attach to it - by their `surface`/`under`; unifying the two is a later cut. - -## Read-back - -Phase 2 succeeds if a `GhostGraph` type exists and `assembleGraph` folds both -on-disk node files and the lossy facet→node projection into one in-memory graph -of **pure-prose nodes** (tree + nodes + links + medium + body), attached -additively to `LoadedFingerprintPackage`, unit-tested, with the entire existing -system (facet loader, gather, checks, compare) untouched and green. The graph is -then in place for Phase 3 to point `gather` at it. Node content model: **A -(pure prose)** — settled; the facet→node projection is explicit transition -scaffolding marked for deletion in the facet-removal phase. diff --git a/docs/ideas/phase-2-plan.md b/docs/ideas/phase-2-plan.md deleted file mode 100644 index c7a5cfa4..00000000 --- a/docs/ideas/phase-2-plan.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -status: exploring ---- - -# Phase 2 plan: surfaces lint + graph validation - -This note is the execution spec for Phase 2 of `implementation-plan.md`. It adds -the graph-level validation that Phase 1 deliberately deferred, plus the CLI -wiring so `ghost lint` recognizes `surfaces.yml`. Phase 2 is **still additive**: -it validates a new file kind and changes no existing facet behavior. The -breaking line is Phase 3. - -## What Phase 1 left for here - -Phase 1's schema validates each node in isolation. It cannot see the whole tree. -Phase 2 adds the document-level checks Zod cannot express: - -- `parent` references an existing surface id; -- the containment graph is a tree (no cycles, no node parenting itself); -- every edge `to` references an existing surface id; -- `core` is reserved as the implicit root (cannot be redeclared with a parent, - cannot be the child of anything); -- duplicate surface ids are an error; -- near-miss ids (a `parent` or edge `to` that is one edit away from a real id) - warn, per `purposes.md` leak #4. - -Composition edges may form a graph, including cross-links and cycles among -edges — **only `parent` is tree-constrained.** Edge cycles are legal; parent -cycles are not. - -## Deliverable - -1. `ghost-core/surfaces/lint.ts` — `lintGhostSurfaces(input: unknown): - GhostSurfacesLintReport`, mirroring `fingerprint/lint.ts`. -2. Export `lintGhostSurfaces` from `surfaces/index.ts` and `ghost-core/index.ts`. -3. CLI wiring in `scan/file-kind.ts`: detect `surfaces.yml` / `surfaces.yaml` - and the `ghost.surfaces/v1` schema literal, and dispatch to the new linter. -4. Tests in `test/ghost-core/surfaces-lint.test.ts` (unit) and an addition to - the file-kind/CLI lint test for dispatch. - -No placement, no disk loader beyond what `ghost lint ` already does, no -removal of legacy fields. Those are Phase 3+. - -## `lint.ts` shape - -Follow `fingerprint/lint.ts` exactly: parse with the schema first, return early -on schema failure, then run document-level checks that accumulate issues. - -```ts -import { GhostSurfacesSchema } from "./schema.js"; -import { - GHOST_SURFACE_ROOT_ID, - type GhostSurfacesDocument, - type GhostSurfacesLintIssue, - type GhostSurfacesLintReport, -} from "./types.js"; - -export function lintGhostSurfaces(input: unknown): GhostSurfacesLintReport { - const result = GhostSurfacesSchema.safeParse(input); - if (!result.success) return finalize(zodIssues(result.error.issues)); - - const doc = result.data as GhostSurfacesDocument; - const issues: GhostSurfacesLintIssue[] = []; - - checkDuplicateIds(doc, issues); // error: duplicate-id - checkReservedCore(doc, issues); // error: surface-core-reserved - checkParentRefs(doc, issues); // error: surface-parent-unknown - checkParentCycles(doc, issues); // error: surface-parent-cycle - checkEdgeRefs(doc, issues); // error: surface-edge-unknown - checkNearMissIds(doc, issues); // warning: surface-id-near-miss - - return finalize(issues); -} -``` - -### Rule details - -- **duplicate-id** (error): two surfaces share an `id`. Reuse the - `checkDuplicateIds` pattern from `fingerprint/lint.ts`. -- **surface-core-reserved** (error): a surface with `id: core` declares a - `parent`, or some surface declares `parent: core`'s... no — `core` is a valid - parent. The rule is narrower: `core` may not itself have a `parent` (it is the - root). Declaring `id: core` is allowed (to describe it); giving it a parent is - the error. -- **surface-parent-unknown** (error): a `parent` value with no matching surface - `id`. `parent: core` is always valid even if `core` is not explicitly declared - (it is the implicit root). -- **surface-parent-cycle** (error): following `parent` links from any surface - must reach the root without revisiting a node. Detect via walk-with-visited-set - per surface, or a single topological pass. Self-parent (`parent === id`) is a - cycle. -- **surface-edge-unknown** (error): an edge `to` with no matching surface `id`. - This is the dangling-ref check Phase 1's schema test documented as deferred. - `to` does **not** get the implicit-`core` exemption — an edge must point at a - declared surface. -- **surface-id-near-miss** (warning): a `parent` or edge `to` that does not match - any id but is within edit distance 1–2 of a real id. Reuse the existing - near-miss helper if `closestCanonical` (in `ghost-core`) generalizes; otherwise - a small local Levenshtein. Warning, not error — teaches without blocking. - -### Severity convention - -Errors for structural breakage (dangling/cyclic/duplicate), warnings for -teach-don't-block (near-miss). This matches the existing facet linters and the -`reset.md` discipline that drafts can warn while curation catches up. - -## CLI wiring (`scan/file-kind.ts`) - -Add a `surfaces` kind to `DetectedFileKind`, detect it, and dispatch: - -- In `detectFileKind`: `if (filename === "surfaces.yml" || filename === - "surfaces.yaml") return "surfaces";` (place alongside the other canonical - filenames), and a schema-literal fallback - `if (/^\s*schema:\s*ghost\.surfaces\/v1\b/m.test(raw)) return "surfaces";` - before the `unsupported-yaml` catch. -- In `lintDetectedFileKind`: add a `kind === "surfaces"` branch calling a - `lintSurfacesFile(raw)` wrapper that `parseYaml`s and calls - `lintGhostSurfaces`, mirroring `lintPatternsFile` / `lintResourcesFile` - (including the yaml-error guard). - -This is the whole CLI surface for Phase 2: `ghost lint path/to/surfaces.yml` -works. Package-level lint that assembles surfaces into the broader report comes -when placement (Phase 3) makes surfaces part of the package model. - -## Tests - -`test/ghost-core/surfaces-lint.test.ts`: - -- valid tree (core + children + cross-linked edges) → no issues; -- `parent` to a nonexistent id → `surface-parent-unknown` error; -- `parent: core` with no explicit `core` surface → valid (implicit root); -- `id: core` with a `parent` → `surface-core-reserved` error; -- a parent cycle (a→b→a) and a self-parent → `surface-parent-cycle` error; -- edge `to` a nonexistent id → `surface-edge-unknown` error (the Phase 1 - deferred case, now caught here); -- edge cycle (a composes b, b composes a) → **no** error (edges may cycle); -- duplicate ids → `duplicate-id` error; -- a `parent` one edit from a real id → `surface-id-near-miss` warning. - -CLI/dispatch test (extend the existing file-kind or cli lint test): a -`surfaces.yml` routes to the surfaces linter and a malformed one reports -structured issues rather than throwing. - -## Acceptance - -- `pnpm build`, `pnpm typecheck`, `pnpm test` (full suite), and `pnpm check` - all green. -- `ghost lint ` validates the file and reports tree/graph issues. -- No existing facet linter behavior changed; `file-kind.ts` only gains a branch. -- `lintGhostSurfaces` exported from `@anarchitecture/ghost/core`. - -## Out of scope (explicitly) - -- Node `surface:` placement on description facets → **Phase 3** (breaking). -- Removing `topology` / `applies_to` / `ghost.map/v1` → Phase 3–4. -- Assembling surfaces into the package-level verify/scan report → Phase 3+, - once placement ties nodes to surfaces. -- The slice resolver / menu → Phase 5. - -## Process notes (learned in Phase 1) - -- The pre-commit hook now runs `just test` alongside `just check` (added to - `lefthook.yml` after Phase 1 surfaced two regressions the check-only hook - missed). The full suite is now an automatic gate; no per-phase choice to run - it. -- The lefthook `format` step re-stages touched files. Keep unrelated changes out - of a commit by staging deliberately and verifying `git diff --cached` before - committing. - -## Commit - -One commit: `feat(surfaces): add ghost.surfaces/v1 lint and CLI dispatch`. -No changeset yet (still no user-visible breaking behavior; `ghost lint` gaining a -recognized file kind is additive — a `minor`-worthy note may be bundled into the -eventual major). - -## Read-back - -Phase 2 succeeds if a contributor can run `ghost lint surfaces.yml` and get -clear, structured errors for a broken tree (dangling/cyclic/duplicate) and -teaching warnings for near-miss ids, with edge cycles correctly allowed and only -`parent` tree-constrained — all without touching existing facet behavior. diff --git a/docs/ideas/phase-3-gather-graph.md b/docs/ideas/phase-3-gather-graph.md deleted file mode 100644 index a47a18d9..00000000 --- a/docs/ideas/phase-3-gather-graph.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -status: exploring ---- - -> **Naming (settled):** the per-node output axis is **`incarnation`** (node -> field) filtered by **`--as`** (gather flag). The fingerprint is disembodied -> intent; a tagged node is that intent *incarnated* in one output (email, -> billboard, voice — voice-safe, unlike render/form/look). `gather launch --as -> email` reads as "gather the launch context **as** an email." Untagged nodes -> are free essence (cascade to every incarnation). - -# Phase 3: point gather at the graph + introduce incarnation (`--as`) - -Third build phase. The **first phase where a consumer reads the graph**, and the -first user-visible shape change. Read `phase-2-loader-fold.md` first; this builds -directly on `GhostGraph` and `assembleGraph`. - -## Goal and boundary - -`ghost gather [--as ]` composes its context packet by -**traversing the graph** (Phase 2), not by reading facet sections. The slice -changes shape from typed facet sections to **nodes-by-provenance** (Option A: -nodes are prose). `incarnation` enters the model as a filter. - -Done when: - -- a new `resolveGraphSlice(graph, id, { incarnation })` returns - nodes-by-provenance, -- `gather` uses it and formats prose nodes (markdown + json), -- `--as` filters the slice by incarnation, -- the surface menu still works (built from the graph/surfaces), -- unit + CLI tests pass; everything else stays green. - -Because the Phase 2 fold projects facets into the graph, **existing fixtures -still produce a graph**, so gather can switch to the graph without authoring any -new node files — the projection carries the old packages through. - -## The mapping is the agent's; the gather is deterministic - -The seam is the **node id**. Above it is fuzzy and LLM-driven; below it is exact -and Ghost-driven. Ghost does zero NLP. - -``` -prompt ──▶ [ LLM: which node(s)? ] ──▶ id(s) ──▶ [ Ghost: gather ] ──▶ packet - the MAPPING (fuzzy, NL) the GATHER (graph traversal) -``` - -- The agent calls `gather --format json` with no id to get the **menu** (the - surfaces with authored descriptions), matches the prompt against it, and picks - the id. That matching is the agent's call — there is no path→surface - lookup (one-road deleted it). -- The agent then calls `gather --as `; Ghost traverses and - returns. Same input → same packet, always. This is what keeps trace / checks / - review explainable. - -## One gather is one region; multiplicity lives in the agent loop - -`gather` takes **one** id and returns **one** packet — but that packet is a whole -connected region (own + cascaded ancestors + one-hop `relates`), never a single -node. For most prompts, one region is the right answer. - -For prompts that touch **disjoint** regions (e.g. "make checkout *and* its -confirmation email reassuring"), the **agent gathers each id separately and -synthesizes** — each call with its own `--as`: - -``` -gather checkout --as web -gather email --as email -``` - -This is deliberate, not a gap: - -- Per-call `--as` is a feature — checkout wants `web`, the receipt wants `email`; - a single merged call would force one incarnation across both (wrong for - cross-channel prompts, Scenario E). -- Merge semantics (dedup shared `core` ancestors, re-base provenance per - requested id) are a rabbit hole we do not need to ship gather. -- The agent already owns the fuzzy mapping; looping N times is the same muscle. - -Note the deliberate asymmetry: `checks`/`review` take `--surface ` (plural, -because they produce one combined gate); `gather` stays **single-id atomic** -(context the agent reasons over region-by-region). Do not pluralize gather. - -## The slice shape change (the heart of Phase 3) - -Today `ResolvedSlice` is four typed arrays (`situations`, `principles`, -`experience_contracts`, `patterns`), each `SliceNode`. Under Option -A there are no typed facets — just prose nodes. So the new slice is **one list of -nodes, each with provenance**: - -```ts -interface GraphSliceNode { - id: string; - body: string; // the prose expression - incarnation?: string; // the node's tag, if any - provenance: - | { kind: "own" } - | { kind: "ancestor"; from: string } - | { kind: "edge"; via: GhostNodeRelationKind; from: string }; -} - -interface GraphSlice { - surface: string; // the requested node/surface id - ancestors: string[]; // chain up to (excl.) core, as today - incarnation?: string; // the --as filter applied, if any - nodes: GraphSliceNode[]; -} -``` - -The composition rules are **the same cascade semantics** that `resolveSurfaceSlice` -already encodes — only the node shape and the traversal source change: - -- **own**: nodes whose containment is the requested id. -- **ancestor**: nodes on each ancestor up to `core` cascade down. -- **edge**: one hop along `relates` — the related node's body is included, tagged - by qualifier (`reinforces`/`contrasts`/`variant`). (Maps the old `composes`/ - `governed-by` surface edges onto the node `relates` model.) - -Reuse `ancestorChain` from `graph/assemble.ts` (already built in Phase 2) instead -of the surfaces-specific `cascade.ts` chain. - -## The incarnation filter (`--as`, the new capability) - -`--as ` filters which nodes appear: - -- A node with **no incarnation** (or `any`) is essence → always included - (it cascades to every incarnation). This is the brand-soul behavior. -- A node tagged `incarnation: ` is included **only** when `--as ` matches. -- A node tagged a **different** incarnation is excluded. -- **No `--as`** → no filtering (every node, regardless of tag). The agent gets - the whole surface; incarnation is opt-in narrowing. - -Default incarnation: Phase 3 keeps it simple — `--as` is the only input; a -manifest default incarnation is a later refinement (note it, don't build it). - -## Files - -``` -ghost-core/graph/ - slice.ts # resolveGraphSlice(graph, id, opts) → GraphSlice (+ types) - index.ts # export it -``` -Update `gather-command.ts` to call `resolveGraphSlice(loaded.graph, …)` and -format prose nodes. Keep `buildSurfaceMenu` as the menu source for now (it reads -surfaces; the graph has the same tree — unifying menu onto the graph is a small -later cleanup, not required here). - -## gather output (markdown + json) - -- **json** is the agent contract: `{ surface, ancestors, incarnation?, nodes: [{ - id, body, incarnation?, provenance }] }`. This *replaces* the old - typed-section json. -- **markdown** is the human preview: a `# Ghost Context: ` header, the - cascade chain, then each node rendered as its id + provenance label + prose - body (trimmed/previewed). Drop the per-facet `## Situations / ## Principles` - sections — there are no facets now; it is one provenance-ordered list (own - first, then ancestors, then edges). - -## Tests - -- `test/ghost-core/graph-slice.test.ts`: own/ancestor/edge provenance from a - hand-built graph; `--as` filter (essence/untagged always in; matching in; - mismatched out; no-filter = all); ancestor cascade depth; edge one-hop only - (no recursion). -- Update the existing gather CLI tests (`gathers a composed slice…`, menu tests) - to the new json shape. The facet→node projection means the existing fixtures - keep working; assertions move from `slice.principles[…].provenance` to - `slice.nodes.find(n => n.id === …).provenance`. - -## Explicitly NOT in Phase 3 - -- Switching `checks`/grounding to the graph (Phase 4). -- Switching compare/drift (later). -- Removing facet schemas/types/loader or `resolveSurfaceSlice` (final phase). - `resolveSurfaceSlice` stays until checks/compare are also off facets; Phase 3 - just stops `gather` from using it. -- Manifest default incarnation, multi-valued incarnation (later). -- Multi-id / merged gather — gather stays single-id; the agent loops (see above). -- Cross-package gather (`@scope/pkg#id`) — Phase 6. -- The `surface`→`node` rename of symbols. - -## Prerequisite rename (do first, in this phase) - -The Phase 1/2 node model used the working name **`medium`**. Phase 3 settles it -as **`incarnation`**. Rename before adding the filter so there is one name in the -tree: - -- `GhostNodeFrontmatter.medium` → `incarnation`; schema key `medium` → - `incarnation` (still optional, open string). -- `GhostGraphNode.medium` → `incarnation`; projection + fold carry it through. -- Update Phase 1/2 tests that assert `medium`. - -This is a mechanical, contained rename (the field is barely consumed yet) and -keeps the model honest before `--as` lands. - -## Open micro-decisions (decide while building) - -1. **Edge mapping.** Phase 2 nodes carry `relates` (`reinforces`/`contrasts`/ - `variant`); legacy surfaces carry `composes`/`governed-by` edges that the - projection does not currently turn into `relates`. For Phase 3, the - projected graph has no `relates` (facets had surface-level edges, not - node-level). Decision: Phase 3 edge contributions come from node `relates` - only; the legacy surface-edge → slice behavior is **not** reproduced through - the graph (it was a surfaces-doc feature). If a fixture relied on - `composes`-edge slice contributions, port it to a `relates` node or accept the - simplification. Flag any test that breaks here as a real semantic decision, - not a bug. -2. **Body preview length in markdown.** Lean: full body in json; in markdown, - the whole body (nodes are short) — revisit only if output is huge. -3. **Provenance ordering.** own → ancestor (nearest first) → edge. Stable + matches - how an agent should weight them. - -## Read-back - -Phase 3 succeeds if `gather` composes its packet by traversing `GhostGraph` and -emits **nodes-by-provenance prose** (json + markdown), with `--as` filtering by -incarnation (essence always in, matching in, mismatched out, absent = all), the -menu intact, gather single-id (multiplicity in the agent loop), tests green, and -checks/compare/facet-loader untouched. This is the first consumer on the graph -and the first taste of the incarnation axis; Phase 4 follows by -routing checks through the same graph. diff --git a/docs/ideas/phase-3-plan.md b/docs/ideas/phase-3-plan.md deleted file mode 100644 index c10f9390..00000000 --- a/docs/ideas/phase-3-plan.md +++ /dev/null @@ -1,205 +0,0 @@ ---- -status: exploring ---- - -# Phase 3 plan: placement on nodes — the breaking line - -This note is the execution spec for Phase 3 of `implementation-plan.md`. **This -is the breaking line.** Phases 1–2 were additive; Phase 3 removes the legacy -coordinate fields from the canonical model and replaces them with a single -`surface:` placement pointer. After this lands, any `.ghost/` carrying -`topology` / `applies_to` / `surface_type` / `scope` fails to parse. This is the -first phase of the major release. - -## What changes, in one sentence - -Description nodes stop carrying coordinates as tags and start declaring a single -home surface by placement; `inventory.topology` is removed entirely. - -## The fields removed (measured against the live schema) - -From `ghost-core/fingerprint/schema.ts`: - -| Node | Field removed | Replaced by | -| --- | --- | --- | -| `inventory.topology` | the whole subtree (`scopes`, `surface_types`) | nothing here — surfaces live in `surfaces.yml` (Phase 1) | -| `inventory.exemplars[]` | `surface_type`, `scope` | `surface: ` | -| `intent.situations[]` | `surface_type` | `surface: ` | -| `intent.principles[]` | `applies_to` | `surface: ` | -| `intent.experience_contracts[]` | `applies_to` | `surface: ` | -| `composition.patterns[]` | `applies_to` | `surface: ` | - -`GhostFingerprintScopeSchema` and `GhostFingerprintTopologySchema` / -`GhostFingerprintTopologyScopeSchema` are deleted. The placement value is a -single `SlugIdSchema` optional field named `surface`. - -## The check coordinate question (scope boundary) - -`validate.yml` checks also carry coordinates: `GhostCheckSchema.applies_to` -(`scopes` / `paths` / `surface_types` / `pattern_ids`) and -`GhostCheckDerivationSchema` (`scopes` / `surface_types`). These are entangled -with **map-based routing** (`checks/routing.ts` consumes `check.applies_to` -against map scopes), which is Phase 4 / Phase 7 territory. - -**Decision: do not touch `check.applies_to` in Phase 3.** Phase 3 is the -*description* facets (intent / inventory / composition). Check placement and the -retirement of map routing move together in Phase 4 (map delete) and Phase 7 -(binding / diff routing), because they are one coupled concern. Keeping them out -of Phase 3 keeps this cut about the description model only, and avoids a -half-migrated routing layer. This is noted explicitly so Phase 3 does not grow. - -## Placement field - -A single optional key on each placeable node: - -```yaml -surface: email-marketing -``` - -- Type: `SlugIdSchema.optional()` (the same slug used elsewhere; dotless not - required here because it references a surface id, which is already dotless). -- Semantics: the node's home surface. Absent is allowed by the schema but - **lint warns and teaches** (never silently global) — the explicit-placement - decision from `surface-schema.md`. -- One value, not an array. Placement is single (a node lives in one place); - cross-surface relevance is handled by ancestor cascade and typed edges, not by - multi-placement. - -## Lint changes (`fingerprint/lint.ts`) - -- Remove `checkTopologyRefs` and all the scope/surface_type ref checking it does - (`checkScopeRefs`, `checkScopeIdRef`, `checkSurfaceTypeRef`, `collectTopology`). -- Add `checkPlacement`: every `surface:` value must resolve against the surfaces - declared in the package's `surfaces.yml`; an un-placed node warns - (`fingerprint-node-unplaced`); a `surface:` with no matching id errors - (`fingerprint-surface-unknown`), with a near-miss warning reusing the - Levenshtein helper added in Phase 2. -- **Cross-facet dependency:** placement validation needs the surface list, which - lives in a sibling file. Mirror how `validate.yml` lint already receives the - assembled fingerprint via options — pass the parsed `surfaces.yml` (or the set - of surface ids) into fingerprint lint as an optional input. When surfaces are - not provided (single-file lint with no package context), placement ref checks - degrade to "warn if obviously malformed" and the existence check is skipped, - matching how validate lint behaves without a fingerprint. - -## Consumers to update (the ripple) - -Measured callers of the removed fields: - -- `context/graph.ts` — the largest ripple, and it is **two subsystems bolted - together**. A full read (379 lines) shows the coordinate removal hits them - completely differently: - - - **Job 1 — the structure/content graph (KEEP, mechanical).** `nodes` (ref, - kind, label, summary, details) and `edges` (built from `check_refs`, - `situation.principles`, etc.). These are built from node *content and refs*, - none of which are coordinate fields. The only coordinate touch is cosmetic: - a few lines that stuff `surface_type` / `scope` into a node's `summary` / - `details` strings. Swap those to read `surface:`. Minimal, mechanical. - - - **Job 2 — the applicability/scope selection machinery (COMPILE-DORMANT, do - NOT reimplement here).** `Applicability { paths, scopes, surfaceTypes }`, - `buildScopes` (reads `topology.scopes`), `matchScopes`, - `nodeMatchesTargets`, `applicabilityFromScope`, `applicabilityFromCheck`. - **This entire subsystem *is* the old coordinate model** — path/scope/ - surface-type matching, exactly what the Phase 5 resolver (placement + - surfaces tree + cascade/edges) and the Phase 7 path→surface binding replace - wholesale. - - The trap to avoid: "map applicability to home surface" would mean - *reimplementing Job 2 against placement* in the breaking phase, only for - Phase 5 to throw it away. That is doing the work twice. **Instead, in Phase 3 - make Job 2 compile-dormant**: remove the dead coordinate reads, let - `appliesTo` / `scopes` go empty (or carry only `surface`), and accept that - path-based selection (`matchScopes` / `nodeMatchesTargets`) goes inert until - Phase 5/7 rebuild it properly against surfaces. Rewrite the selection - subsystem **once**, against the real target, in Phase 5 — not twice. -- `scan/fingerprint-contribution.ts` — counts `topology.scopes` / - `surface_types` toward contribution scoring. Replace with a surfaces.yml - presence/count signal, or drop the topology term from the score. -- `scan/fingerprint-stack.ts` — references coordinate fields during merge; touch - only what the field removal forces (full merge retirement is Phase 7). -- `context/package-context.ts`, `context/package-review-command.ts` — adjust any - rendering that prints surface_type/scope to print `surface:` instead. - -Do **not** expand scope into resolver/menu logic (Phase 5) or map deletion -(Phase 4); make the minimum edits to keep the build green against the new shape. - -## Types (`fingerprint/types.ts`) - -- Remove `GhostFingerprintScope`, `GhostFingerprintTopology`, - `GhostFingerprintTopologyScope` interfaces and their exports from - `fingerprint/index.ts` and `ghost-core/index.ts`. -- Remove `applies_to` / `surface_type` / `scope` from the node interfaces; add - `surface?: string`. -- Remove `topology` from `GhostFingerprintInventory`. - -## Tests - -- Update `fingerprint-yml-schema.test.ts`: the minimal-doc expectation drops - `topology: {}` from the inventory default; assert removed fields now fail - `.strict()` parsing; assert `surface:` is accepted on each node type. -- Update/replace `fingerprint` lint tests that exercised topology refs with - placement tests (unknown surface errors, unplaced warns, near-miss warns). -- Any fixture across the suite that uses the old fields must migrate to - `surface:` or be expected to fail — grep the test tree for the removed field - names and fix each. -- **Expected fallout: the path-based selection tests break, and that is - correct.** Making `graph.ts` Job 2 compile-dormant will break tests that - assert path/scope selection (the `relay.test.ts` and `context-*.test.ts` - family). Do **not** prop these up by reimplementing selection against - placement — that is the throwaway-work trap. Migrate or mark them pending - Phase 5/7, because the path road is rebuilt in Phase 7 and `relay` is deleted - in Phase 8 (the desire-survives decision). Keeping a doomed selection system - alive through Phase 3 is exactly what this plan refuses. -- Full `pnpm test` is the gate (now enforced by the pre-commit hook). - -## Changeset - -Phase 3 is the first user-visible breaking change, so write the major changeset -stub now and grow it through Phases 4–8: - -```markdown ---- -"@anarchitecture/ghost": major ---- - -Replace topology/applies_to/surface_type/scope coordinates with a surfaces.yml -coordinate space and a single `surface:` placement per node. -``` - -## Acceptance - -- `pnpm build`, `pnpm typecheck`, full `pnpm test`, `pnpm check` all green. -- The canonical schema rejects `topology`, `applies_to`, `surface_type`, `scope` - and accepts `surface:` on situations, principles, experience_contracts, - patterns, and exemplars. -- `fingerprint/lint.ts` validates placement against surfaces and warns on - unplaced nodes, with near-miss suggestions. -- No reference to the removed types remains in `src` (grep clean). -- `check.applies_to` is deliberately untouched (Phase 4/7). - -## Out of scope (explicitly) - -- `check.applies_to` / check routing → Phase 4 (map delete) + Phase 7 (binding). -- Deleting `ghost.map/v1` → Phase 4. -- Slice resolver / menu / cascade composition → Phase 5. -- The migration command for existing packages → Phase 6 (Phase 3 just makes the - old shape invalid; the migrator is built later, and this repo no longer has a - dogfood `.ghost/` to migrate). - -## Process notes - -- This is the first phase that breaks things; expect the ripple to surface in - the full test suite, not just typecheck. Lean on `pnpm test`. -- Make the schema/type/lint change first, then fix consumers until green, then - fix tests — compiler and test failures are the worklist. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 3 succeeds if the canonical fingerprint model expresses coordinates only -as a single `surface:` placement validated against `surfaces.yml`, every legacy -coordinate field is gone from schema/types/lint/consumers, checks are left for -Phase 4/7 on purpose, and the whole suite is green with the major changeset -started. diff --git a/docs/ideas/phase-4-checks-graph.md b/docs/ideas/phase-4-checks-graph.md deleted file mode 100644 index ce2a5ef2..00000000 --- a/docs/ideas/phase-4-checks-graph.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -status: exploring ---- - -# Phase 4: route checks through the graph + ground from prose - -Fourth build phase. The **second consumer migration** (after gather): checks -routing and review grounding move onto `GhostGraph`, and grounding is -**reconceived from prose** (Option A) rather than from typed -principles/patterns/exemplars. Read `phase-2-loader-fold.md` and -`phase-3-gather-graph.md` first. - -## Goal and boundary - -- `ghost checks --surface ` selects governing checks by **graph** cascade - (not the surfaces-doc `cascade.ts`). -- Grounding (`why` / `what`) becomes **the graph slice's prose nodes** — there - are no facet `principle`/`pattern`/`exemplar` types to split on anymore. -- `ghost review` consumes the same graph-based routing + grounding. - -Done when checks + review run on the graph, the facet-based `groundSurface` and -the surfaces-`cascade.ts` routing are no longer used by these commands, tests -pass, and the remaining facet consumer (compare) is still green. - -## The grounding reconception (the heart of Phase 4) - -Today grounding is **two typed lists**: - -- `why`: principles + experience contracts (the design intent a finding cites). -- `what`: composition patterns + inventory exemplars (what good looks like). - -Under Option A there are no such types — a node is prose with provenance. So the -honest question: does the why/what split survive? - -**Decision (settled): drop the why/what framing entirely; grounding is the prose -slice by provenance.** why/what is not a structure Ghost extracts — it is a -*quality of well-authored guidance*. A good intent node already says the why -("near payment, reduce felt risk"); a good guideline already gestures at what -good looks like — because the **authoring skill prompted the human** to cover it -(intent/inventory/composition as the ephemeral lenses). So Ghost does not pull -why/what into headers; it hands over the prose, and the why and what live *in* -the prose. The burden of ensuring nodes contain both moves to the authoring -skill (a later phase), which is the correct place for it — authoring-time -guidance, not review-time extraction. The new grounding is: - -```ts -interface GraphGrounding { - surface: string; - nodes: GraphSliceNode[]; // the slice (own + ancestors + one-hop edges) -} -``` - -i.e. **grounding = the gather slice**. A check that fires on a surface is -grounded by that surface's gathered nodes; the agent cites node ids and quotes -prose. This unifies "context for generation" (gather) and "grounding for review" -(checks/review) onto **one resolver** — which is the right simplification: they -were always the same slice, viewed for different purposes. - -Consumers that printed `## Why` / `## What good looks like` now print grounded -nodes by provenance (own → ancestor → edge), each as id + prose. The -`missing-fingerprint` / silent-grounding behavior is unchanged (empty slice = -silent). - -## Routing on the graph - -`selectChecksForSurfaces` currently walks the surfaces-doc parent map -(`buildParentMap` + surfaces `ancestorChain`). Repoint it at the graph: - -- a check's placement is its `surface:` frontmatter (unplaced ⇒ `core`); -- it governs a touched surface when its placement equals that surface (own) or - any **graph** ancestor of it (cascade), using `ancestorChain(graph, id)` from - Phase 2; -- `core` governs every diff (unchanged). - -The routing *logic* is identical — only the ancestry source changes from the -surfaces doc to the graph. Keep `RoutedCheck` / `CheckRelevance` shapes as-is -(they reference surface ids, which the graph still has). - -Note: checks themselves stay `ghost.check/v1` markdown with `surface:` -frontmatter — Phase 4 does not change the check artifact, only how routing finds -ancestors. (Renaming `surface:` to a node ref is a later cleanup, not this -phase.) - -## Files - -``` -ghost-core/graph/ - ground.ts # groundGraph(graph, id, opts?) → GraphGrounding (the slice) - index.ts # export it -ghost-core/check/ - route.ts # selectChecksForSurfaces: walk graph ancestry, not surfaces -``` -Update `checks-command.ts` and `review-packet.ts` to: -- call the graph-based `selectChecksForSurfaces(checks, graph, touched)`, -- call `groundGraph(graph, surface, { incarnation? })` instead of - `groundSurface(...)`, -- format grounded nodes by provenance. - -## Incarnation in checks (small, consistent) - -`checks` and `review` gain an optional `--as ` so grounding is -filtered to the relevant incarnation (same filter as gather). A check itself is -not incarnation-tagged in Phase 4 (check artifact unchanged); only its grounding -slice is filtered. Lean: add `--as` to both commands, pass through to -`groundGraph`. (Optional — can defer if it bloats the phase; routing does not -need it.) - -## Tests - -- `test/ghost-core/graph-ground.test.ts`: grounding = slice nodes by provenance; - silent when empty; incarnation filter applied. -- `test/ghost-core/check-route-graph.test.ts` (or update the existing route - test): own/ancestor cascade via graph ancestry; `core` governs always; - unplaced check ⇒ core. -- Update `cli.test.ts` checks/review assertions from `grounding[].why/what` to - `grounding[].nodes` (or the chosen output shape). The facet→node projection - keeps the fixtures producing grounded nodes. - -## Explicitly NOT in Phase 4 - -- Switching compare/drift to the graph (next). -- Removing facet schemas/types/loader, `resolveSurfaceSlice`, `groundSurface`, - surfaces-`cascade.ts` (final phase — compare still uses facets until then; - delete these once compare is migrated). -- Changing the `ghost.check/v1` artifact (surface frontmatter → node ref is - later). -- Cross-package routing/grounding (Phase 6). -- The `surface`→`node` rename of symbols. - -## Open micro-decisions (decide while building) - -1. **why/what — settled: dropped (see above).** One provenance-ordered prose - node list; no why/what headers, no provenance-derived relabeling. The why and - what live in the prose, ensured by the authoring skill, not by Ghost - extraction. The review prompt text should be reworded to "read the grounded - nodes" rather than "use why then what." -2. **Exemplar paths — DEPRECATE + flag for removal (settled).** Old grounding - surfaced exemplar `path:` (a concrete file to look at). Prose nodes have no - `path`. The facet→node projection stops carrying it now (grounding won't have - it), and the field is flagged for removal with the rest of the facet model in - the final deletion phase. Authors who want to point at a file write the path - in the node body, where the agent reads it as context anyway. -3. **`groundGraph` vs reuse `resolveGraphSlice` directly.** Grounding *is* the - slice — `groundGraph` may be a thin alias (slice + the surface label) rather - than a separate function. Lean: thin wrapper for naming clarity, or have - checks/review call `resolveGraphSlice` directly and drop `groundGraph` - entirely. Decide while wiring; fewer functions is better. - -## Read-back - -Phase 4 succeeds if `checks` and `review` route by graph ancestry and ground -from the **prose graph slice** (no why/what framing — provenance-tagged prose -nodes, with the why and what carried in the prose itself), with the facet-based -`groundSurface` + surfaces routing no longer used by these commands, optional -`--as` filtering grounding, tests green, and compare (the last facet consumer) -untouched. Exemplar `path:` is dropped from grounding and flagged for removal. -After this, only compare/drift remains on facets before the facet model can be -deleted. diff --git a/docs/ideas/phase-4-plan.md b/docs/ideas/phase-4-plan.md deleted file mode 100644 index e81b2ca3..00000000 --- a/docs/ideas/phase-4-plan.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -status: exploring ---- - -# Phase 4 plan: delete `ghost.map/v1` - -This note is the execution spec for Phase 4 of `implementation-plan.md`. It -removes the `map.md` / `ghost.map/v1` coordinate-and-routing layer, which Phase 3 -already made dormant (`mapFromFingerprint` returns empty, check scope grounding -is inert). Phase 4 is the deletion that makes that dormancy permanent. Part of -the major release that Phase 3 began. - -## The key finding: the map module is two things tangled together - -A full read shows `ghost-core/map/` is **not** one concern. It holds: - -1. **The routing/coordinate layer (DELETE).** `MapFrontmatter`, `MapScope`, - `MapFrontmatterSchema`, `getEffectiveMapScopes`, `slugifyScopeId`, - `MAP_FILENAME`, `REQUIRED_BODY_SECTIONS` — the `map.md` schema and the - path→scope routing it feeds. This is the legacy coordinate space the surfaces - model replaces. - -2. **Inventory-output types (RELOCATE, do not delete).** `GitInfo`, - `InventoryOutput`, `LanguageHistogramEntry`, `TopLevelEntry` happen to live in - `map/types.ts` but are **the output shape of `ghost signals` / inventory - scanning** — nothing to do with map routing. `scan/inventory.ts` imports them. - These must survive Phase 4, relocated out of the map module. - -The plan's first job is to separate these two, or Phase 4 deletes types that -inventory scanning still needs. - -## Step 1 — relocate the inventory-output types - -Move `GitInfo`, `InventoryOutput`, `LanguageHistogramEntry`, `TopLevelEntry` -from `ghost-core/map/types.ts` to a non-map home — `ghost-core/scan-types.ts` -(or fold into an existing scan/inventory types module). Update the -`#ghost-core` barrel export and `scan/inventory.ts`'s import. This is a pure -move, no behavior change, and can land first as its own safe sub-commit. - -## Step 2 — delete the routing layer and rewire consumers - -Delete `ghost-core/map/` (schema, scopes, the map half of types, index) and the -`map.md` filename/handling. Then rewire each consumer. Grouped by how Phase 3 -left them: - -### Already dormant — just remove the map plumbing - -- **`scan/fingerprint-stack.ts`** — `mapFromFingerprint` already returns empty - scopes (Phase 3). Remove the function, the `map` field it feeds on the stack - type, and the `MapFrontmatter` import. The `map:` property on - `LoadedCheckPackage` / stack provenance goes too. -- **`ghost-core/checks/lint.ts`** — the `options.map` scope check (`Check - references unknown map scope`) is the last live map consumer in lint. Remove - it and the `getEffectiveMapScopes` import. (The scope/surface_type grounding - was already made dormant in Phase 3.) -- **`ghost-core/checks/types.ts`** — drop the `map?: Pick` - field from the validate-lint options and routed-check types. - -### Live routing to retire (moves to Phase 7 binding) - -- **`ghost-core/checks/routing.ts`** — `routeGhostValidateForPath` / - `routeGhostPathToScopes` are the path→scope→check router. **Path-based check - routing is rebuilt against surfaces/binding in Phase 7.** For Phase 4: keep - the pure path-matching helpers (`matchesGhostPath`, `normalizeGhostPath`, - `globToRegExp`) if any non-map caller needs them, but remove the map-scope - routing. Confirm via grep whether `routeGhostValidateForPath` has any live - caller after `core/check.ts` is rewired (below); if not, delete it. -- **`core/check.ts`** — the `check` / `review` entry. It builds a per-stack - `map` via `mapFromFingerprint` and routes through it. With map gone and the - router retired, **`check` routes by `check.applies_to.paths` directly** - (path-glob against changed files), with no scope layer. This is the dormant - path road becoming a simple path-only router until Phase 7 adds surface - binding. Keep `applies_to.paths` matching; drop scope matching. -- **`core/scope-resolver.ts`** — `resolveFingerprintsForPaths` resolves a - changed path to `fingerprints/.md` via map scopes. **Check - reachability first**: it is exported from `core/index.ts` but grep shows no - live in-repo caller. If genuinely unused, **delete the whole file** (and its - test). If a CLI path reaches it, reduce it to the parent-fallback behavior - (always resolve to the root `fingerprint`) until Phase 7. - -### Lint dispatch and status - -- **`scan/file-kind.ts`** — remove the `map` `DetectedFileKind`, the - `ghost.map/v1` and `map.md` detection branches, and the `lintMap` dispatch. -- **`scan/lint-map.ts`** — delete the file (the `map.md` linter). -- **`fingerprint.ts`** — remove the `lintMap` re-export. -- **`scan/scan-status.ts`** — remove `readMapFrontmatter` / `MAP_FILENAME` map - reading and the map contribution it reports. Confirm scan-status still reports - the remaining facets correctly with no map. -- **`scan/fingerprint-package.ts`** — remove `MAP_FILENAME` from the package - file set (map.md is no longer a package file). - -### Barrel - -- **`ghost-core/index.ts`** — remove all `map/index.js` re-exports (the routing - half), keep the relocated inventory-output type exports from their new home. - -## Step 3 — tests - -- **Delete** `test/ghost-core/map-scopes.test.ts` (77 lines — pure map scope - behavior). -- **`test/scope-resolver.test.ts`** (127 lines) — delete if `scope-resolver` is - deleted; otherwise retarget to the parent-fallback behavior. -- **`test/ghost-core/checks.test.ts`** — remove the remaining `map`/MAP routing - cases (the `routeGhostValidateForPath` and `options.map` tests). The - fingerprint-grounding cases were already migrated in Phase 3. -- **`test/cli.test.ts`** — the dormant "path matched / Matched scopes" relay and - check-routing assertions skipped in Phase 3 stay skipped; remove any that - asserted map files specifically. Re-verify `check` still passes/fails - correctly on `applies_to.paths` alone. -- Full `pnpm test` (hook-enforced) is the gate. - -## Scope boundary (what Phase 4 does NOT do) - -- **Does not build surface-based routing.** Phase 4 leaves `check` routing on - plain `applies_to.paths`. Surface/binding routing is **Phase 7**. Phase 4 is - deletion, not replacement — replacement already happened for placement - (Phase 3) and happens for routing (Phase 7). -- **Does not touch the resolver/menu** (Phase 5) or `relay` deletion (Phase 8). -- The `surveys`/`patterns` legacy schemas keep their own `surface_types` fields - (separate concern, Phase 8 if ever). - -## Changeset - -Fold into the existing major changeset (`surface-coordinate-space.md`) rather -than adding a new one — Phase 4 is part of the same breaking release. Optionally -extend its body to mention `map.md` removal. - -## Acceptance - -- `ghost-core/map/` is gone; no `ghost.map/v1`, `MapFrontmatter`, `MAP_FILENAME`, - `map.md`, or `lintMap` reference remains in `src` (grep clean). -- Inventory-output types survive at their new home; `ghost signals` / inventory - scanning is unaffected. -- `check` / `review` run on `applies_to.paths` with no scope layer and no map. -- `pnpm build`, `pnpm typecheck`, full `pnpm test`, `pnpm check` all green. - -## Process notes - -- **Relocate before delete.** Step 1 (move inventory-output types) lands first - and green; only then start deleting the routing layer, so the compiler tracks - one concern at a time. -- The compiler is the worklist again, like Phase 3 — but smaller and almost - entirely deletions. -- Confirm `scope-resolver` and `routeGhostValidateForPath` reachability with - grep before deleting vs. reducing; do not guess. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 4 succeeds if the map coordinate/routing layer is fully deleted, the -inventory-output types it incidentally housed are preserved at a non-map home, -`check`/`review` route on paths alone pending Phase 7, and the suite is green — -with surface-based routing explicitly deferred, not half-built. diff --git a/docs/ideas/phase-5-authoring.md b/docs/ideas/phase-5-authoring.md deleted file mode 100644 index c4182933..00000000 --- a/docs/ideas/phase-5-authoring.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -status: exploring ---- - -# Phase 5: node authoring (init, migrate, skill) - -Fifth build phase. Where Ghost packages start being **authored as nodes**, not -facets. This is the prerequisite for facet-removal: until `init` and `migrate` -emit nodes, the facet→node projection is load-bearing and cannot be deleted. -Read `phase-2-loader-fold.md`, `phase-3-gather-graph.md`, and -`phase-4-checks-graph.md` first. - -## Goal and boundary - -Make node packages first-class to author: - -- **`init`** scaffolds a node package: `manifest.yml`, `surfaces.yml` (the - spine), and `nodes/*.md` seeds — not the three facet files. -- **`migrate`** gains a facet→node re-filing path: an existing facet package - (or legacy package) is rewritten into `nodes/*.md` + `surfaces.yml`. -- **The authoring skill** (`capture.md` + friends) teaches node authoring: write - prose nodes through the intent/inventory/composition lenses, place with - `under`, link with `relates`, tag with `incarnation`. This is where the - why/what authoring burden (Phase 4) actually lives. - -Done when a freshly `init`-ed package is a node package, `migrate` converts facet -packages to node packages, the skill documents node authoring, and the whole -thing gathers/checks/reviews on the graph. Facet *removal* is the next phase -(this phase makes it possible by ending facet emission). - -## What `init` produces (the new scaffold) — templates, not questions - -Today: `manifest.yml` + `intent.yml`/`inventory.yml`/`composition.yml` (empty -facet files). New: - -```text -.ghost/ - manifest.yml # unchanged: schema + id - surfaces.yml # the spine — `core` is implicit, near-empty is valid - nodes/ - core-voice.md # seed node(s) showing the shape (prose + frontmatter) -``` - -**`init` is template-driven, not an interactive Q&A wizard (SETTLED).** A wizard -fights BYOA — the CLI is the deterministic calculator; the *skill* asks the -human in conversation. `init` deterministically stamps a named template: - -``` -ghost init # the `default` template -ghost init --template # (future) other starters -``` - -- **Template registry seam, built now (one template registered).** A template is - a pure function/record → a set of seed files (a `surfaces.yml` spine + a few - `nodes/*.md` written through the lenses). Structure the code so adding - `marketing` / `voice` / `dashboard` starters later is just registering another - template — no `init` rework. These map onto the worked scenarios (marketing - seeds campaign/email/billboard surfaces with incarnation-tagged nodes; voice - seeds modality/intent-class nodes; etc.). -- **`default` template seeds minimally:** the `surfaces.yml` spine (core - implicit) + one `core`-placed intent node, so a fresh package is - self-explanatory and immediately gatherable. Not a fake fingerprint. -- **`--reference` is DROPPED (SETTLED).** Facet-era plumbing - (`templateInventory(reference)`). Clean house. An author records design - materials by writing an inventory-nature node, guided by the skill. -- **`init` output** (json/cli summary) changes from `intent/inventory/ - composition` paths to `surfaces.yml` + `nodes/` — update `initCommandOutput`. - -## What `migrate` produces (facet → nodes) - -`migrate` currently re-files legacy coordinates into facet files + `surfaces.yml`. -Extend it to **emit nodes**: - -- For each facet entry (principle/pattern/contract/situation/exemplar), write a - `nodes/.md` whose frontmatter is `id` + `under: ` (+ `relates` - from `check_refs`/edges where translatable) and whose **body is the entry's - prose** (principle text / pattern text / etc.). This is the - `projectFacetsToNodes` logic (Phase 2) made *persistent* — the projection - becomes the migration writer. -- Keep `surfaces.yml` emission as-is (the spine). -- **Stop writing facet files.** After migrate, the package has `manifest.yml` + - `surfaces.yml` + `nodes/*.md` and no `intent.yml`/`inventory.yml`/ - `composition.yml`. -- Migration notes flag anything lossy (evidence/check_refs that don't translate - cleanly), consistent with the lossy-projection stance. - -This makes `migrate` the tool that converts *every existing facet package* -(including Ghost's own dogfood packages and fixtures) to nodes — which is what -lets the facet loader + projection be deleted next phase. - -## The authoring skill (the real home of why/what) - -Update `capture.md` (and the bundle) to teach node authoring: - -- A node is a markdown file in `nodes/`: frontmatter (`id`, `under?`, `relates?`, - `incarnation?`) + a prose body. -- The body is written through the **intent / inventory / composition lenses** — - the ephemeral authoring guidance: capture the *why* (intent), the *material* - (inventory, incl. pointers to component code), and the *composition* (patterns). - These are prompts to the author, never fields. -- Place with `under` (the tree / cascade); the brand soul lives at `core`. -- Link laterally with `relates` (`reinforces`/`contrasts`/`variant`) when a - relationship carries rationale; when the rationale is rich, write a - relationship-node (its body explains the tension). -- Tag with `incarnation` only for medium-bound expressions; leave essence - untagged. -- This is where Phase 4's "the why and what live in the prose" is *taught* — - the skill is what ensures grounded nodes actually contain both. - -## Files - -- `init-command.ts` + `initFingerprintPackage`: scaffold surfaces + nodes, drop - facet-file emission, update output shape. -- `scan/fingerprint-package.ts` templates: replace `templateIntent/Inventory/ - Composition` with `templateSurfaces` + `templateNode(s)`. -- `migrate-command.ts` + `scan/migrate-legacy.ts`: add the node-emitting writer - (reuse the projection mapping); stop writing facet files. -- `skill-bundle/references/capture.md` (+ SKILL.md, authoring-scenarios.md, - patterns.md, voice.md as needed): node-authoring guidance. - -## Tests - -- `init` produces `manifest.yml` + `surfaces.yml` + `nodes/*.md`; no facet files; - the result loads and gathers. -- `migrate` converts a facet package to nodes; bodies preserved; surfaces spine - intact; lossy items noted; no facet files remain. -- Skill bundle manifest updated (capture.md changes; install manifest still - matches). -- CLI: init → gather round-trips on the node package. - -## Explicitly NOT in Phase 5 - -- Deleting the facet loader / facet schemas / `projectFacetsToNodes` / - `resolveSurfaceSlice` / `groundSurface` — that is the **facet-removal phase**, - which this unblocks. (The loader keeps reading facets *and* nodes during the - transition so old packages still load until migrated.) -- Cross-package authoring (`@scope/pkg#id`) — Phase 6. -- The `surface`→`node` rename of symbols. -- Multi-node `init` templates / scaffolding wizards — keep `init` minimal. - -## Settled decisions - -1. **`init` is template-driven** (registry seam now, `default` template only; - no Q&A wizard). `default` seeds the spine + one `core` node. -2. **`--reference` dropped.** Clean house; materials become an inventory node. -3. **`migrate` is one-way (no `--keep-facets`).** It rewrites the package into - the node form and removes the facet files. Git history preserves the old - files; keeping both invites two-sources-of-truth drift. The transition loader - still reads any not-yet-migrated package. -4. **Node granularity: file = purpose, not atom (SETTLED).** A node is a - *purpose-coherent, frontmatter-uniform* body of **any length** — 1 or 100 - prose points about that purpose live in one node. The body length is - irrelevant; what forces a second file is a **divergence in the handles**: - a different `under` (placement), a different `incarnation` (medium), or a - genuinely different `relates` role (e.g. a relationship-node that connects two - others). So `core-voice.md` can be three paragraphs (one node); - `launch-email.md` and `launch-billboard.md` are separate *because their - `incarnation` differs*, not because they are different ideas. One node per - file; grouped-files remain a possible later authoring convenience, not now. - -## Read-back - -Phase 5 succeeds if `init` scaffolds a node package (`surfaces.yml` + `nodes/`), -`migrate` rewrites facet/legacy packages into nodes (bodies preserved, spine -intact, lossy items noted, no facet files left), and the skill teaches node -authoring with the intent/inventory/composition lenses as the why/what home — -all gathering/checking/reviewing on the graph, with the facet loader still -reading legacy packages until the facet-removal phase deletes it. diff --git a/docs/ideas/phase-5-plan.md b/docs/ideas/phase-5-plan.md deleted file mode 100644 index b287fe2b..00000000 --- a/docs/ideas/phase-5-plan.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -status: exploring ---- - -# Phase 5 plan: slice resolver + menu, as the new `gather` command - -Execution spec for Phase 5 of `implementation-plan.md`. This is the first -phase that **adds capability** rather than removing it: it rebuilds the dormant -selection road (Job 2 of `context/graph.ts`, inert since Phase 3) on the surface -model, and ships it as a new context-gathering command — relay's *desire* done -right (the "desire-survives" decision in `implementation-plan.md`). - -Layer 3 (Selection), prompt road. The path/diff road is Phase 7. - -## What this builds - -1. **A surfaces loader** — read `surfaces.yml` from a package. **It does not - exist yet**: Phases 1–2 built the schema + lint, but nothing loads the file - from disk. This is the missing first piece. -2. **The slice resolver** — given a surface id, deterministically compose its - slice: own placed nodes + cascaded ancestor nodes + typed-edge contributions. - No LLM. -3. **The menu emitter** — surfaces + descriptions for the host agent to match a - prompt against. Ambiguity returns the menu, never a whole-tree dump. -4. **The `gather` command** — the CLI surface that ties it together. - -## Step 1 — surfaces loader - -Add surfaces to the package model the same way the facets are loaded: - -- Add `surfaces` to `FingerprintPackagePaths` (`surfaces.yml`) in - `scan/fingerprint-package.ts`, and read it in `loadFingerprintPackage` - (optional — absent means a single implicit `core` surface). -- Parse with `GhostSurfacesSchema`; surface a typed `GhostSurfacesDocument` (or - `undefined`) on the loaded package and on `PackageContext`. -- Lint wiring already exists (Phase 2 `file-kind.ts`); this is the *read into the - model* step that Phase 2 deferred. - -## Step 2 — the resolver (the heart) - -A pure function in a new `ghost-core` module (e.g. `surfaces/resolve.ts`) or a -`context/` module — **deterministic, no LLM, no I/O**: - -``` -resolveSurfaceSlice( - surfaces: GhostSurfacesDocument | undefined, - fingerprint: GhostFingerprintDocument, - checks: GhostValidateDocument | undefined, - surfaceId: string, -): ResolvedSlice -``` - -Composition rule, straight from `coordinate-space.md`: - -- **Own nodes** — every fingerprint node whose `surface:` equals `surfaceId`. -- **Cascaded ancestors** — walk `parent` from `surfaceId` to `core`; include - nodes placed on each ancestor. Ancestors contribute to descendants (the only - inheritance, and it is down-the-tree only — no mixins, no priority weights, - per `reset.md`). -- **Typed-edge contributions** — for each edge on the resolved surface(s), - include the target surface's own nodes, tagged by edge kind (`composes`, - `governed-by`) so the consumer knows *why* they are present. Edges do **not** - recurse (one hop) to stay legible; revisit only if a real case needs it. -- **Unplaced nodes** — a node with no `surface:` belongs to `core` for - resolution **only if** the design says so. Per `surface-schema.md`, unplaced - warns; for resolution, treat unplaced as `core`-level (reaches everywhere) so - sparse fingerprints still produce a slice, but lint still nudges placement. - -Output is a structured slice (placed nodes by facet + provenance: own / -ancestor: / edge::), not prose. The host agent renders. - -## Step 3 — the menu - -``` -buildSurfaceMenu(surfaces): SurfaceMenuEntry[] // id, description, parent, edges -``` - -Deterministic list of surfaces with their authored descriptions, for the host -agent to match a natural-language ask against. Ghost does **no NLP**. When the -caller does not name a surface (or names an unknown one), `gather` returns the -menu, never the whole tree — the brand-mixing cure (`coordinate-space.md`, -scenario 3). - -## Step 4 — the `gather` command - -A new command (working name `gather`) that: - -- `ghost gather ` → resolves and emits the slice (markdown or `--format - json`). -- `ghost gather` (no surface) or unknown surface → emits the menu. -- Reads the package via the surfaces loader + existing package context. -- No `--config` / `--request` / `--mode` relay flags. This is the desire - (right context at the right time), not relay's machinery. - -This is **net-new and additive** — it does not modify `relay` (deleted in -Phase 8) and is not built on `relay-config` / `request-resolution` / -`relay-modes`. - -## Step 5 — un-skip the dormant tests, retire Job 2 improvisation - -- The Phase 3 skips (`context-entrypoint`, `context-sandbox`, the `gather`-shaped - `cli` relay cases) tested path-based selection over the old coordinate model. - Their *intent* — "the right nodes come back for a target" — is now served by - surface resolution. **Re-express the still-valid ones against `gather`**; - delete the rest. Do not revive `globalFallbackRefs` / `CAPS` truncation. -- `context/entrypoint.ts` Job 2 (`matchScopes`, `globalFallbackRefs`, `CAPS`) - was made dormant in Phase 3. Phase 5 **replaces** it with surface resolution. - What survives: the graph's *structure/content* half (nodes + typed ref edges, - Job 1) if the menu/slice rendering reuses it; the scope/path matching half is - superseded by surface placement and can be deleted once `gather` stands. - -## Scope boundary (what Phase 5 does NOT do) - -- **No path/diff road.** `gather` takes a surface id (or returns the menu). - Turning a changed file or diff into a surface is **Phase 7** (binding). Do not - build path→surface here. -- **No relay deletion.** `relay` and its `context/relay-*` plumbing stay until - Phase 8; `gather` lives beside them. -- **No agent matching.** Ghost emits the menu; the host agent picks. No NLP, - no embeddings in the core path. -- **No migration command** (Phase 6). - -## Tests - -- Resolver: own-node selection; ancestor cascade (multi-level); edge - contribution with provenance; unplaced→core; a surface with no nodes returns - an empty-but-valid slice. -- Menu: shape (id/description/parent/edges); ordering deterministic. -- Ambiguity: no surface / unknown surface → menu, not tree. -- `gather` CLI: surface → slice (markdown + json); no-surface → menu; absent - `surfaces.yml` → single `core` slice. -- Re-expressed selection tests from the Phase 3 skip set. -- Full `pnpm test` (hook-enforced) green. - -## Changeset - -New `minor` changeset (additive command + exports) — `gather` is new public -surface and does not, by itself, remove anything. The major changeset from -Phase 3–4 still covers the breaking removals. - -## Process notes - -- **Loader first, then pure resolver, then command** — build inward-out so the - resolver can be unit-tested with in-memory docs before any CLI wiring. -- Reuse the surfaces near-miss/levenshtein helper for "unknown surface → did you - mean" in the menu path. -- The resolver is pure and deterministic; keep all I/O in the loader and command. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 5 succeeds if `ghost gather ` returns a deterministic slice -(own + cascaded ancestors + typed edges, with provenance), `ghost gather` with -no/unknown surface returns the described menu instead of the whole tree, the -surfaces loader reads `surfaces.yml` into the package model, and the dormant -selection road is replaced — with the path/diff road explicitly left for -Phase 7. diff --git a/docs/ideas/phase-6-facet-removal.md b/docs/ideas/phase-6-facet-removal.md deleted file mode 100644 index f843ffee..00000000 --- a/docs/ideas/phase-6-facet-removal.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -status: exploring ---- - -# Phase 6: facet removal — the graph is the only model - -Sixth build phase. Delete the facet model now that authoring (Phase 5) emits -nodes and every read consumer (gather, checks, review) is on the graph. After -this, `GhostFingerprintDocument` and the `intent/inventory/composition` schemas -no longer exist; the loader folds **nodes + surfaces** into the graph directly. -Read phases 2–5 first. - -## Goal and boundary - -Remove the facet model end to end: - -- the facet schemas/types/lint (`ghost-core/fingerprint/`), -- the facet layer parsing in the loader (`assembleFingerprint`, `layerRaw`, - `parseLayer`), -- the facet→node projection scaffold (`projectFacetsToNodes`) — its job is done - (it lives on as `migrate`'s writer, not as a load-time bridge), -- the now-dead `resolveSurfaceSlice` / `groundSurface` / `ground.ts` and the - surfaces `cascade.ts` (gather/checks moved to the graph slice in phases 3–4), -- the facet `file-kind` branches (`fingerprint-intent/-inventory/-composition`). - -And **reconceive the commands still facet-shaped**: - -- **`lint` + `verify` → one public `validate` verb (SETTLED).** `validate` is - internal hygiene: "is the fingerprint correct?" It runs two passes and reports - both: a **shape pass** (each artifact well-formed on its own — the old `lint`, - which stays the internal engineering term) and a **graph pass** (the - ghost-specific network holds — links resolve, exactly one root, checks - reference real surfaces, `relates` point at real nodes; later, cross-package - refs). `verify` is absorbed (it *was* the graph pass). `lint` is no longer a - public verb. No separate parent command — `validate` is the parent. A single - `validate ` may short-circuit to the shape pass. `check`/`checks` stay - distinct (public agent checks against generated output — a different concern). - Capability note: the graph pass checks *reference* integrity, not *filesystem - reality* (exemplar paths on disk died with the facet fields, per Option A). -- **`scan`** — today reports facet *contribution* (intent/inventory/composition - counts). Re-aim at node/graph contribution. - -Done when the package model is **manifest + surfaces.yml + nodes/ + checks/** -only, the loader has no facet path, all reads/writes are graph-native, and tests -pass. Legacy facet packages no longer load directly — they must be `migrate`-d -first (Phase 5 made that one command). - -## The load-bearing change: the loader stops parsing facets - -Today `loadFingerprintPackage`: -1. reads intent/inventory/composition/surfaces, -2. `assembleFingerprint(...)` → `GhostFingerprintDocument`, -3. lints it, -4. folds `{ nodeFiles, fingerprint, surfaces }` → graph (fingerprint projected). - -New: -1. reads surfaces + `nodes/*.md`, -2. folds `{ nodeFiles, surfaces }` → graph, -3. lints the **graph** (nodes parse, links resolve, one root). - -`LoadedFingerprintPackage` drops `fingerprint` and `layerRaw`; keeps `manifest`, -`surfaces?`, `graph`. `assembleGraph` drops its `fingerprint` input (projection -gone). This is the moment the in-memory model becomes graph-only. - -### Migration safety - -Legacy facet packages stop loading once the facet parser is gone. That is -acceptable because Phase 5's `migrate` converts them in one command, and the -canonical form is already the node package. **Detect-and-guide:** if the loader -finds `intent.yml`/`inventory.yml`/`composition.yml` and no `nodes/`, fail with -a clear "run `ghost migrate` to convert this legacy package" message rather than -a parse error. (Small, high-value: keeps the cutover humane.) - -## `validate` — shape pass + graph pass - -`validate` is the one hygiene verb. It assembles the package and runs: - -- **shape pass** (internal `lint`): every artifact well-formed on its own — node - frontmatter parses, check frontmatter valid, `surfaces.yml` schema-correct, - `manifest.yml` valid. -- **graph pass** (the old `verify`'s surviving job): the network is correct — - every `under`/`relates` resolves, exactly one root, checks' `surface:` name - real surfaces, no orphan/dangling references. - -One report, both classes of problem, one exit code. The lost capability -(filesystem exemplar-path checking) is gone with the facet fields it operated on -— flag it in the changeset. `verify` and standalone public `lint` are removed. - -## Reconceiving `scan` - -`scan` reports per-facet contribution (intent/inventory/composition counts + -states). Re-aim at the graph: - -- **node contribution**: how many nodes, placed where (surfaces covered), how - many essence vs incarnation-tagged, sparse surfaces (declared but no nodes). -- keep the BYOA next-step guidance, re-pointed at "add nodes for these surfaces." - -`ScanFacet`/`fingerprint-contribution.ts` are rewritten to a node/surface -contribution report. This is the other real build item (not just deletion). - -## Files - -Delete: -- `ghost-core/fingerprint/` (schema, types, lint, index) — the facet model. -- `ghost-core/graph/project-facets.ts` (load-time projection; `migrate` keeps - its own copy of the mapping or imports a shared one — decide while building). -- `ghost-core/surfaces/resolve.ts`, `ground.ts`, `cascade.ts` (dead since - phases 3–4) + their tests (`surfaces-resolve`, `surfaces-ground`). - -Rewrite: -- `scan/fingerprint-package-layers.ts` → node+surfaces loader only. -- `scan/fingerprint-package.ts` → `LoadedFingerprintPackage` drops - `fingerprint`/`layerRaw`. -- `ghost-core/graph/assemble.ts` → drop the `fingerprint` projection input. -- `scan/verify-package.ts` → cross-artifact graph integrity. -- `scan/fingerprint-contribution.ts` → node/surface contribution. -- `scan/file-kind.ts` → drop facet-layer kinds; keep surfaces/check/node/manifest. -- `ghost-core/index.ts`, `fingerprint.ts` → drop facet exports. - -Keep: -- `surfaces/` schema + `buildSurfaceMenu` (the spine is still YAML). -- `node/`, `graph/` (assemble, slice), `check/`. - -## Tests - -- Loader: a node package loads to a graph with no `fingerprint` field; a legacy - facet package fails with the migrate-guidance message. -- `verify`: passes on a clean node package; flags a check referencing a missing - surface / a `relates` to a missing node. -- `scan`: reports node/surface contribution (counts, sparse surfaces) — rewrite - the existing scan assertions. -- Delete facet-model tests (`fingerprint-yml-schema` etc. — confirm which are - pure-facet) and the dead surfaces-resolve/ground tests. -- Migrate Ghost's own dogfood packages / fixtures to nodes (or assert they are - already node packages) so the suite runs without facet packages. - -## Explicitly NOT in Phase 6 - -- Cross-package refs (`@scope/pkg#id`) resolution — next. -- The `surface`→`node` symbol rename. -- Graph-native compare/drift/fleet (parked; their own future effort). -- Re-adding structured evidence/exemplar fields — Option A stands; evidence - lives in prose. - -## Settled decisions - -0. **`emit review-command` is DROPPED.** It is a pre-graph artifact: a frozen - codegen of `.claude/commands/design-review.md` from facet content — a stale - snapshot of what `review --surface` now produces live from the graph. It is - also the heaviest remaining facet consumer (`context/package-context.ts` + - `context/package-review-command.ts`, ~340 lines reading - `fingerprint.intent.summary` / `inventory.building_blocks`). Drop the `emit` - verb and both context modules outright — pure deletion, no port. Clean house. - -1. **One public `validate` verb** = shape pass (internal `lint`) + graph pass - (absorbed `verify`). `lint`/`verify` are not public verbs. `check`/`checks` - stay distinct (agent checks against output). -2. **`projectFacetsToNodes` dies as a load-time bridge.** The facet→node *mapping* - already lives in `migrate-legacy.ts` (`migratedNodeFiles`, Phase 5); delete - the graph copy. Decide while building whether any shared helper is worth - keeping (lean: no, migrate owns it). -3. **Legacy facet package → explicit `ghost migrate` guidance on load** (not a - parse error, not a silent skip). -4. **Test fixtures are updated, not migrated.** Rewrite the fixture helpers to - emit node packages directly (`surfaces.yml` + `nodes/*.md`); delete the - facet-writing helpers. No shelling out to `migrate`; generate node fixtures as - needed. Same for any of the repo's own `.ghost/` packages — regenerate as node - packages. - -## Read-back - -Phase 6 succeeds when the only fingerprint model is the graph (manifest + -surfaces + nodes + checks), the loader folds nodes+surfaces with no facet path, -`verify` and `scan` are reconceived for nodes, the facet schemas/projection/dead -slice+ground are deleted, legacy packages are guided to `migrate`, and the repo's -own packages are node packages. After this, only cross-package resolution and -the symbol rename remain before the graph model is fully consolidated. diff --git a/docs/ideas/phase-6-plan.md b/docs/ideas/phase-6-plan.md deleted file mode 100644 index 30c280e0..00000000 --- a/docs/ideas/phase-6-plan.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -status: exploring ---- - -# Phase 6 plan: the migration command - -Execution spec for Phase 6 of `implementation-plan.md`. A one-shot transform that -moves a legacy `.ghost/` (pre-surface coordinates) onto the surface model: -derive `surfaces.yml` from old `topology.scopes`, rewrite node `applies_to` / -`surface_type` / `scope` into `surface:` placement. Additive, low-risk, no -consumer rewiring. - -## Scope correction from the plan - -The plan's headline sub-task was "migrate this repo's own dogfood `.ghost/`." -**That no longer applies** — this repo's root `.ghost/` was deleted during the -reset cleanup, and `ghost-ui/.ghost/` was already hand-migrated in Phase 3. So -Phase 6 is **only the command + its tests**, for the benefit of *external* users -with legacy packages. There is nothing in this repo left to migrate. - -This also means Phase 6 is purely additive and carries no risk to the build: it -reads legacy YAML and writes new YAML; it changes no runtime path. - -## What it transforms - -A legacy package (pre-`ghost.fingerprint/v1` Phase-3 shape) has, in its raw -facet files: - -- `inventory.yml`: `topology.scopes[] = { id, paths, surface_types }` and a - top-level `topology.surface_types`. -- `inventory.yml` exemplars: `surface_type`, `scope`. -- `intent.yml` situations: `surface_type`. -- `intent.yml` principles / experience_contracts and `composition.yml` - patterns: `applies_to = { scopes, paths, surface_types, situations }`. - -The migrator produces: - -- a new `surfaces.yml` (`ghost.surfaces/v1`) whose surfaces are derived from - `topology.scopes` (one surface per scope id, `parent: core`, description left - for the author or synthesized from the scope id); -- rewritten facet files where each node gains a single `surface:` placement and - drops the legacy coordinate fields. - -## The placement-derivation rule (best-effort, deterministic) - -A node's new `surface:` is chosen from its legacy coordinates, in priority -order: - -1. an explicit single `scope` (exemplars) → that scope id; -2. `applies_to.scopes[0]` if exactly one scope → that scope id; -3. otherwise → unplaced (omit `surface:`), and **record it for human review**. - -`surface_type` does **not** map to placement — surface_type was a cross-cutting -tag, not a containment home, and the surface model has no surface_type concept. -The migrator drops it and notes any node that had *only* a surface_type (no -scope) as needing manual placement. `applies_to.paths` likewise does not map to -a node placement; paths are repo-binding concerns (Phase 7), recorded in the -report, not silently dropped into a surface. - -Ambiguity (multiple scopes on one node) is **not** auto-resolved — the migrator -places nothing and reports it, because guessing would silently mis-place a node -and reintroduce the brand-mixing risk the model exists to prevent. - -## Why raw-YAML, not the parsed model - -The current `GhostFingerprintSchema` **rejects** `topology` / `applies_to` / -`surface_type` / `scope` (Phase 3 made them `.strict()` failures). So a legacy -package no longer parses. The migrator must operate on **raw parsed YAML** -(`yaml.parse` → plain objects), transform, and re-serialize — it cannot use the -package loader. This is the key implementation constraint. - -## Deliverable - -1. A migration function in `scan/` (e.g. `scan/migrate-legacy.ts`): - `migrateLegacyPackage(dir): { surfaces, intent, inventory, composition, - report }` — pure transform over parsed YAML, returns new doc objects plus a - `MigrationReport` of unplaced/ambiguous nodes and dropped fields. No writes. -2. A `ghost migrate [dir]` command wrapping it: reads the legacy facet files, - runs the transform, writes the new `surfaces.yml` and rewritten facets - (guarded by `--force` like `init`, or `--dry-run` to print the plan), and - prints the report. -3. The migrated package must pass `ghost lint` (surfaces graph + placement) — - the migrator's own acceptance check. - -## Command shape - -- `ghost migrate [dir]` (default `./.ghost`). -- `--dry-run` — print the derived `surfaces.yml` and the report; write nothing. -- `--force` — overwrite existing facet files (a legacy package is being - rewritten in place; without `--force`, refuse if files would change, like - `init`). -- `--format ` — the report format. -- Exit non-zero if the migration produced lint errors in the result; exit 0 with - warnings for unplaced/ambiguous nodes (human-review items, not failures). - -## Tests - -- A legacy fixture (the pre-Phase-3 shape — `topology.scopes`, node - `applies_to` / `surface_type` / `scope`) migrates to: - - a valid `surfaces.yml` with one surface per legacy scope; - - facet files where single-scope nodes carry the right `surface:` and legacy - fields are gone; - - a report listing surface_type-only and multi-scope nodes as unplaced. -- The migrated package passes `lintGhostFingerprint` (with the derived surface - ids) and `lintGhostSurfaces`. -- `--dry-run` writes nothing. -- Ambiguous (multi-scope) node → unplaced + reported, never guessed. -- Full `pnpm test` (hook-enforced) green. - -## Scope boundary (what Phase 6 does NOT do) - -- **No path binding.** `applies_to.paths` is reported, not converted — path → - surface binding is Phase 7. -- **No surface descriptions authored.** Surfaces get ids (and maybe a - slug-derived description); rich descriptions are the author's job, possibly - agent-drafted later. -- **No survey/patterns/map migration.** Those legacy schemas are separate; map - is already deleted. Only the three description facets + surfaces. -- Does not touch this repo's packages (none need it). - -## Changeset - -`minor` — `ghost migrate` is a new additive command. - -## Process notes - -- Pure transform first (testable on in-memory parsed YAML), command wrapper - second. -- Reuse `yaml` parse/stringify already used across `scan/`. -- Report-don't-guess is the core discipline: anything the migrator cannot place - unambiguously is surfaced for human review, never auto-placed. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 6 succeeds if `ghost migrate` turns a legacy `.ghost/` into a valid -surface-model package — `surfaces.yml` from old scopes, single-scope nodes -placed, legacy coordinate fields removed — while reporting (never guessing) -every node it could not place unambiguously, and the result passes lint. diff --git a/docs/ideas/phase-7-cross-package.md b/docs/ideas/phase-7-cross-package.md deleted file mode 100644 index b2f03b5e..00000000 --- a/docs/ideas/phase-7-cross-package.md +++ /dev/null @@ -1,195 +0,0 @@ ---- -status: exploring ---- - -# Phase 7: cross-package resolution - -Seventh build phase. Make `#` refs *resolve* — the last real -feature. This is what lets a shared brand contract be consumed across sibling -packages and repos (Scenarios B and E): a product's `core` node `relates` to -`@acme/brand#core-trust`, and gather/validate follow that link into the -installed brand package. Read `context-graph.md` (the scenarios) and phases 2–6 -first. - -## What already exists (parsed, not resolved) - -- The **ref grammar** accepts `#` and `@scope/pkg#` - (`NodeRefSchema`). So cross-package refs already *validate* in node files. -- `lintGraph` and `resolveGraphSlice` **explicitly skip** `#` refs today - ("later phase"). Nothing resolves them. -- There is **no `consumes`** in the manifest and no second-package loading. - -Phase 7 turns those skips into real resolution. - -## The model: a package `extends` others, by identity - -`extends` is cross-package inheritance — the same idea as the within-package -cascade (`under` inherits downward), now across a file boundary. (Note: `extends` -has precedent — the legacy direct `fingerprint.md` had an `extends:` field; this -reclaims it for the graph model.) - -The load-bearing principle: **reference by identity, never by path.** A package -already declares its identity in `manifest.yml` (`id:`). Cross-package refs carry -that identity; *where* the package lives on disk is resolved in one isolated, -swappable layer — never baked into a ref. This mirrors how the rest of Ghost -already separates "what" from "where" (gather names a node id; the binding death -stopped inferring intent from path). An alias-to-a-dir map would re-couple refs -to the file tree — exactly the trap one-road removed for surfaces. - -There is no separate "consumed dependency" concept: inherited nodes are just -*nodes you inherited*, in the same bucket as cascade. This is what dissolves the -namespacing / direct-addressing / cross-package-parent questions below. - -1. **A package declares what it extends — one `extends` map, key = identity, - value = where (for now):** - - ```yaml - # the brand contract's manifest - id: brand - - # the product contract's manifest - id: acme-checkout - extends: - brand: ../brand/.ghost # key `brand` is the identity refs use; value is location - ``` - - No double bookkeeping: the key is the public identity (`brand:core-trust` - references the *key*, never the path); the value is just where to find it - today. The discovery upgrade makes the value optional (omit → Ghost finds the - package whose manifest `id` matches the key); an explicit value stays a valid - override. So refs and the model never change when discovery lands. - -2. **Refs carry identity, with `:` as the qualifier** (Ghost's own lineage — - old typed refs were `intent.principle:foo`, `validate.check:bar`). A ref is - `:`; a bare `` is local: - - ```yaml - under: brand:core # inherit from the `brand` contract's core node - relates: - - to: brand:core-trust - as: reinforces - ``` - - `brand:core-trust` = "the `core-trust` node in the contract that declares - `id: brand`" — stable across moves, repos, and how it's installed. No path in - any ref. - -3. **Location resolution is the `extends` map value** — the path lives in exactly - one place, never in a ref. v1: explicit `id → dir`. Next: discovery makes the - value optional (match by manifest `id`); upgrading the resolver changes **no - ref**. - -4. **The loader resolves extended packages** into the graph as **read-only - inherited nodes**, keyed by their full ref id (`brand:core-trust`), tagged - `origin: "inherited"`. `under`/`relates` `:` refs resolve against - them. - -Cost to name: package `id`s become the public coordinate, so they must be -**stable and meaningful** (`brand`, not `acme-checkout-9f3`) — the same -discipline node ids already follow. - -## Resolution shape (the loader change) - -`assembleGraph` (or a wrapper) gains inherited-package input: - -``` -loadFingerprintPackage(paths): - manifest, surfaces, own nodes → as today - for each id in manifest.extends: - resolve id → dir (resolution map in v1; discovery later) - load that package (one level — no transitive extends in v1) - verify the loaded package's manifest id matches `id` - key its node ids as `id:`, mark origin: "inherited" - union into the graph (inherited nodes never override local) - lintGraph: now `:` refs must resolve to a loaded inherited node -``` - -Key rules: -- **Inherited nodes are read-only context** — they appear in gather slices - (cascade/relates reach them) but a package never *edits* an inherited node. -- **One level of extends in v1** (no transitive `extends` of extends) — keep it - bounded; revisit if a real need appears. -- **Identity mismatch** (resolved package's `id` ≠ the extended id) is an error. -- **Cycles across packages** are an error (validate catches them). -- **Unresolvable id** → validate/load fails with clear guidance - ("`brand` is extended but no package with that id could be resolved"). - -## What resolves where - -- **validate** (graph pass): `:` refs must now resolve to a loaded - inherited node; an unresolved ref is an error (was skipped). A ref whose - package id isn't in `extends` is a distinct, clearer error. -- **gather**: the slice traverses into inherited nodes via `under`/`relates` - (inherit from an extended brand `core`, or pull a related brand node). - Provenance is marked so the agent knows it's inherited from an extended - contract. -- **checks**: routing is unchanged (checks are local), but grounding slices may - now include inherited nodes — fine, same slice resolver. - -## Files - -- `ghost-core/package-manifest.ts`: add optional `extends` map - (`Record`; value optional once discovery lands) to the schema. -- `ghost-core/node/schema.ts`: change `NodeRefSchema` from `#` / - `@scope/pkg#id` to `:` (both slugs); a bare slug stays - local. -- `scan/` resolver: an `id → dir` resolution step (map for v1), isolated so - discovery can replace it later without touching refs. -- `scan/fingerprint-package.ts` / `-layers.ts`: resolve each extended id, load - the package, verify its manifest id matches, pass inherited nodes to - `assembleGraph`. -- `ghost-core/graph/assemble.ts`: accept `inheritedNodes` (ids already the full - `id:` ref, `origin: "inherited"`), union them in (local wins, inherited - never overrides). -- `ghost-core/graph/lint.ts`: `:` refs resolve against inherited - nodes; add `unresolved-cross-package` / `package-not-extended` / - `extends-identity-mismatch` rules; cross-package cycle detection. -- `ghost-core/graph/slice.ts`: stop skipping qualified refs; resolve + tag - inherited provenance. -- `GhostGraphNode.origin`: add `"inherited"`. - -## Tests - -- An extending package with `extends: { brand: ... }` (resolved to a sibling package - whose manifest is `id: brand`) and a `relates: brand:core-trust` resolves; - gather includes the inherited node tagged inherited. -- `under: brand:core` inherits brand context into the extender. -- Unresolved ref (package id not in `extends`) → validate error. -- Unresolvable extended id (no package found) → load/validate error w/ guidance. -- Identity mismatch (resolved package id ≠ extended id) → validate error. -- Cross-package cycle → validate error. -- A package with no `extends` behaves exactly as today (no regression). - -## Explicitly NOT in Phase 7 - -- Transitive extends (extends-of-extends) — one level in v1. -- Editing inherited nodes / write-back — inherited is read-only. -- The `surface`→`node` symbol rename. -- Versioning/compat checks between extender and extended (a future concern; - Git/npm version the extended package). - -## Settled (the identity framing dissolved the earlier open questions) - -Reference by identity (`:`), resolve location separately, inherited -nodes are *just nodes* — so the prior questions fold away: - -1. **Refs are path-free** (`brand:core-trust`); the one path (if any) lives in - the v1 resolution map, replaceable by discovery without touching refs. -2. **Inherited node ids:** the full ref *is* the id (`brand:core-trust`) — no - separate namespace bucket. -3. **Direct cross-package `gather`:** a ref resolves the same whether local or - `id:node`, so `gather` accepts either; no special addressing mode. (The menu - may still default to local surfaces.) -4. **Cross-package `under`/parent:** a node's `under` may point at `id:node` — it - inherits from a node in the extended contract. One tree; some edges cross a - package boundary. Scenario E's product-tree-under-brand-`core` is the natural - case. - -## Read-back - -Phase 7 succeeds when a package can declare `extends`, `#` refs in -`under`/`relates` resolve to read-only inherited nodes loaded from the extended -package, gather traverses into them with inherited provenance, validate catches -unresolved/un-extended/cyclic cross-package refs, and packages with no `extends` -are unaffected. This delivers the Scenario-B/E shared-brand story: one brand -contract, extended by many products, without copy-paste or merge. diff --git a/docs/ideas/phase-7-plan.md b/docs/ideas/phase-7-plan.md deleted file mode 100644 index 2b3acee3..00000000 --- a/docs/ideas/phase-7-plan.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -status: exploring ---- - -# Phase 7 plan: `ghost.binding/v1` — the path road and diff road - -Execution spec for Phase 7 of `implementation-plan.md`, designed by -`surface-binding.md`. This is the **largest and least proof-validated** remaining -cut: it adds the binding (the only thing that turns a filesystem path into a -surface), wires path→surface and diff→surfaces into `check` / `review` and the -path road of selection, and retires the `child-wins-by-id` merge (Leak E). - -Lands last by design — it depends on Phases 1–6 and on a real working tree to -validate against. Ship the **smallest** version: directory-default binding, -in-repo `contract: .`, defer external references. - -## The decisions already settled (from `surface-binding.md`) - -- **Both forms.** Directory location is the default binding (a scoped `.ghost/` - binds its declared surfaces to that subtree); explicit `.ghost.bind.yml` is - the escape hatch when ownership does not match the tree. -- **Precedence is positional.** Nearest binding along a path wins; explicit - overrides directory-implied at the same level; no merge, no weights. -- **`paths:` live on the binding, never the surface.** This is the real home of - the deleted `topology.scopes[].paths`. -- **Open forks resolved:** in-repo `contract: .` first (defer external refs); - unbound path → root `core` if a root contract exists, else the menu; a binding - *references* surface ids, it does not define new ones. - -## The structural tension to resolve first (read before coding) - -A full read of `scan/fingerprint-stack.ts` shows the current model is -**merge-centric**, and this is the heart of the cut: - -- `loadFingerprintStackForPath` walks root→leaf and returns a *stack of layers*. -- `buildFingerprintStack` calls `mergeFingerprints` (`child-wins-by-id` union of - intent/inventory/composition) and `mergeChecks` to produce one merged - fingerprint, then lints the merged result. -- `check`, `review`, `relay`, `scan stack`, `scan emit` all consume - `stack.merged.*`. - -Binding says: **stop merging facets; bind a surface to a subtree instead.** But -the consumers want "a fingerprint + checks for this path." So the rewire is not -"delete merge" — it is **replace `merge layers → one fingerprint` with -`resolve path → binding → surface → composed slice`**, where the slice is the -Phase 5 resolver output, not a union of layer facets. - -This is the load-bearing reframe and the riskiest part. Get the new resolution -primitive right first; then move each consumer onto it. - -## Step 1 — the binding schema + loader - -- New `ghost-core/binding/` (schema, types, index): `ghost.binding/v1` for - `.ghost.bind.yml` — `contract` (string; only `.` supported now), `bindings[]` - = `{ surface, paths[] }`. Zod-validated; lint that surface ids and paths are - well-formed (cross-reference against the contract's surfaces happens at - resolution, not schema). -- File-kind detection + lint dispatch for `.ghost.bind.yml` (mirror the - `surfaces.yml` wiring from Phases 2/5). - -## Step 2 — the path→surface resolver - -A new resolver (e.g. `scan/binding-resolve.ts` or `ghost-core`), deterministic, -no LLM: - -``` -resolvePathToSurface(repoRoot, path, { surfaces, bindings }): { - surface: string | null; // null → no binding and no root core - binding_dir: string; // where the winning binding sits - reason: "explicit" | "directory" | "root-core" | "unbound"; -} -``` - -- Walk root→leaf along the path; collect candidate bindings (directory-implied - from each scoped `.ghost/`'s `surfaces.yml`, and explicit `.ghost.bind.yml`). -- Nearest wins; explicit beats directory-implied at the same level. -- Directory-implied binding: a scoped `.ghost/` binds **its declared surfaces** - to its subtree. When it declares exactly one non-`core` surface, that is the - binding; when several, an explicit `.ghost.bind.yml` is required to - disambiguate (report, don't guess — the migration discipline carries over). -- Unbound path: `core` if a root contract exists, else `null` (caller emits the - menu). - -## Step 3 — wire the roads - -- **Path road (selection / Layer 3):** `gather --path ` resolves the path - to a surface, then composes via the Phase 5 resolver. `gather ` stays - the explicit form. (Adds an option; does not change the prompt road.) -- **Diff road (governance / Layer 4):** in `core/check.ts`, resolve each changed - file to its surface, take the **union of surfaces**, and run those surfaces' - checks against the diff. Today `check` already routes by `applies_to.paths` - (Phase 4); Phase 7 adds the surface dimension: a check on a surface applies to - a changed file when the file binds to that surface (or an ancestor). -- **`review`** consumes the same path→surface resolution for its packet. - -## Step 4 — retire the merge (Leak E) - -- Replace `buildFingerprintStack`'s `mergeFingerprints` / `mergeChecks` with - binding resolution. A "stack for a path" becomes "the root contract + the - binding that owns the path + the composed slice", not a union of layer facets. -- Delete `mergeFingerprints`, `mergeIntent`, `mergeInventory`, - `mergeComposition`, `mergeChecks`, `mergeById`, and the `child-wins-by-id` - provenance. Keep layer *discovery* (root→leaf walk) — it is now binding - discovery, not merge input. -- Update the stack types: `merged` → a resolved-surface result; provenance - describes the winning binding, not a merge. - -## Consumers to rewire (measured) - -All consume `stack.merged.*` today: - -- `core/check.ts` — diff road (Step 3). The biggest behavioral change. -- `review-packet.ts` — path→surface for the review packet. -- `scan-stack-command.ts` — `ghost stack` now inspects bindings, not a merge. -- `scan-emit-command.ts` — emits from the resolved surface, not the merged doc. -- `relay.ts` — **do not rewire; relay is deleted in Phase 8.** Leave it until - then or stub it; do not invest in moving relay onto bindings. - -## Scope boundary (what Phase 7 does NOT do) - -- **No external contract references.** Only `contract: .` (in-repo). npm / - resource-id references and version pinning are a later note (may reuse - `ack` / `track`). -- **No relay rewire** (deleted Phase 8). -- **No new placement semantics** — surfaces and `surface:` placement are - unchanged; this is purely path→surface and the merge retirement. -- The prompt road is unchanged. - -## Tests - -- Binding schema/lint: valid/invalid `.ghost.bind.yml`; well-formed paths. -- Path resolution: nearest binding wins; explicit beats directory-implied; - unbound → `core` with a root contract; unbound → menu without one; multi-surface - directory requires explicit (reported). -- Diff road: changed files → union of surfaces → those surfaces' checks run; - a file bound to a child surface still gets ancestor (`core`) checks via cascade. -- `gather --path` resolves and composes. -- Merge retirement: the deleted merge functions are gone; a nested package binds - rather than merges (a root edit does not alter a leaf's resolved slice; a child - cannot disable an inherited check by merge). -- Re-express / un-skip the Phase 3 path-selection tests that now have a real - home (the path road), where still meaningful. -- Full `pnpm test` (hook-enforced) green. - -## Changeset - -`minor` for the additive `ghost.binding/v1` + `gather --path`; the merge -retirement is internal (the merged-stack output shape changes, but the breaking -coordinate removals are already covered by the Phase 3–4 major changeset). If -the public `check` / `review` JSON shape changes (provenance), note it — that may -warrant folding into the major. - -## Process notes - -- **Resolve the structural tension first** (merge → binding-resolution), as its - own commit if possible: build the path→surface resolver and prove it before - touching consumers. -- Then rewire consumers one at a time, full suite green between each. -- This is the least-validated layer — treat the first in-repo resolution as a - hypothesis. A scoped `.ghost/` fixture under a subtree is the proof case. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 7 succeeds if a filesystem path resolves to a surface through a binding -(directory-default or explicit), `check` / `review` route a diff to the union of -its surfaces' checks, `gather --path` composes a slice for a file, the -`child-wins-by-id` merge is gone (nesting binds, never merges), and the contract -still carries no paths — with external contract references explicitly deferred. diff --git a/docs/ideas/phase-7b-cut3-plan.md b/docs/ideas/phase-7b-cut3-plan.md deleted file mode 100644 index 30972719..00000000 --- a/docs/ideas/phase-7b-cut3-plan.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -status: exploring ---- - -# Phase 7b Cut 3 plan: surface-routed check relevance - -Execution spec for Cut 3 of `phase-7b-plan.md`. This is where the pieces compose: -the 7a binding (path→surface), the Phase 5 cascade (own + ancestors), and the -Cut 2 markdown checks (`ghost.check/v1`) combine into Ghost's first governance -differentiator — **deterministically answering "which checks are relevant to -this diff?"** without an LLM guessing and without Ghost running anything. - -## The core function - -A pure resolver, no I/O, no LLM: - -``` -selectChecksForSurfaces( - checks: GhostCheckDocument[], // markdown checks with surface placement - surfaces: GhostSurfacesDocument | undefined, - touchedSurfaces: string[], // surfaces a diff touched (from binding) -): RoutedCheck[] // check + why (own | ancestor:) -``` - -A check governs a touched surface when its `surface:` equals that surface **or -any ancestor** of it (the same `own + cascade` rule as `resolveSurfaceSlice` — -reuse `ancestorChain`, do not reinvent). An unplaced check (`surface` absent) -governs `core`, so it applies to every diff (brand-wide). Provenance tags each -routed check `own` or `ancestor:` so the consumer knows why it fired. - -This mirrors the slice resolver exactly: a diff's checks are composed the same -way a surface's context is — one cascade mechanism for build and review. - -## The diff road - -``` -diff → changed paths → (7a binding) → touched surfaces (union) → selectChecks → relevant checks -``` - -- Parse the diff to changed paths (existing `parseUnifiedDiff`). -- Resolve each path to a surface via `discoverBindingsForPath` + - `resolvePathToSurface` (7a). Collect the union of touched surfaces. -- `selectChecksForSurfaces` returns the checks governing those surfaces and - ancestors. Ghost emits the set; the agent evaluates each markdown rule. - -## The decision this cut forces: which checks does `check` route? - -Today `core/check.ts` loads `validate.yml` (legacy `ghost.validate/v1` regex -detectors) and routes by `applies_to.paths`. Cut 3 introduces routing for the -**new markdown checks**. They must not be conflated: - -- **`ghost.check/v1` markdown checks** — routed by **surface** (this cut). Ghost - does not run them; it selects and emits them for the agent. -- **`ghost.validate/v1` detectors** — legacy. Keep their existing path-glob - routing working untouched, but they are no longer the governance future. - -**Recommendation:** add surface routing as a *new* path that loads markdown -checks from a `checks/` directory in the package, alongside (not replacing) the -legacy detector path. Do not rip out `routeGhostValidateForPath` yet — deprecate -by addition. A later cut removes `validate/v1` wholesale. - -## Loading markdown checks - -- Add a checks-directory concept to the package: `/checks/*.md`. -- A loader (`scan/`) reads the dir, lints each with `lintGhostCheck`, and returns - `GhostCheckDocument[]` (skipping/erroring on invalid ones per lint). -- Absent `checks/` dir → no markdown checks (the legacy `validate.yml` path is - unaffected). - -## Surfacing it - -Two honest options for where routing shows up; pick the smallest: - -1. **A new command** `ghost checks --diff ` (or `ghost route-checks`) that - prints the relevant markdown checks per touched surface (markdown + json). - Clean, additive, does not disturb `check`. -2. **Extend `check`** to also report routed markdown checks beside the legacy - detector findings. - -**Recommendation:** option 1 — a new, small, additive command. It keeps the -legacy `check` deterministic-detector path untouched and gives the markdown-check -routing its own clean surface. Grounding (Cut 4) then extends this command, not -`check`. - -## Replace vs. keep `routeGhostValidateForPath` - -Keep it for the legacy detector path (Phase 4 left it path-only and it works). -Cut 3 adds surface routing for markdown checks; it does not touch the legacy -router. The plan's "replace `routeGhostValidateForPath`" line is softened to -"add surface routing beside it" — replacing it fully waits for `validate/v1` -removal, so this cut stays additive and green. - -## Tests - -- `selectChecksForSurfaces`: a checkout-touched diff selects checkout + core - checks, excludes email checks; cascade pulls ancestor checks; an unplaced - check applies to every diff; an empty touched set yields only core checks. -- Diff road: a diff touching `apps/checkout/**` (bound to checkout) routes to - checkout + core markdown checks. -- Checks-dir loader: reads + lints `checks/*.md`; ignores non-check markdown. -- The new command: diff → relevant checks per surface (markdown + json). -- Full `pnpm test` (hook-enforced) green. - -## Scope boundary (what Cut 3 does NOT do) - -- **No grounding** — emitting why/what from the fingerprint is Cut 4. -- **No check execution** — Ghost selects and emits; the agent evaluates. -- **No `validate/v1` removal** — legacy detectors and their router stay. -- **No external contract references** (still deferred from 7a). - -## Changeset - -`minor` — the surface-routing resolver, the checks-dir loader, and the new -command are additive. - -## Process notes - -- Pure `selectChecksForSurfaces` first (unit-tested with in-memory docs), then - the checks-dir loader, then the diff road, then the command. -- Reuse `ancestorChain` from the slice resolver — extract/share it rather than - copy. One cascade definition for context and governance. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Cut 3 succeeds if a diff deterministically selects the markdown checks governing -the surfaces it touches and their ancestors — reusing the slice cascade, routing -by surface not path, emitting (never running) the relevant set — with the legacy -detector path left intact and grounding deferred to Cut 4. diff --git a/docs/ideas/phase-7b-cut4-plan.md b/docs/ideas/phase-7b-cut4-plan.md deleted file mode 100644 index 11ed572a..00000000 --- a/docs/ideas/phase-7b-cut4-plan.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -status: exploring ---- - -# Phase 7b Cut 4 plan: fingerprint grounding - -Execution spec for Cut 4 of `phase-7b-plan.md`, the final governance cut. Cut 3 -made Ghost the deterministic relevance filter (a diff → its surfaces → the -checks that govern them). Cut 4 adds the second differentiator: when a surface -is in scope, Ghost emits the **grounding** — the *why* and the *what to change* -drawn from that surface's fingerprint slice. The check finds the problem; the -fingerprint explains and prescribes. - -## What grounding is - -For each touched surface, project its `gather` slice (already built by -`resolveSurfaceSlice`, reused as-is) into a review-shaped grounding: - -- **why** — the surface's principles + experience_contracts (own + inherited), - the design intent a finding can cite. -- **what to change** — the surface's patterns + exemplars (with exemplar - `path`/`title`/`why`), the concrete "what good looks like." - -Grounding inherits the same way context does: a checkout finding is grounded in -checkout's own principles *and* the brand-wide (`core`) ones, because the slice -already includes ancestors. No new traversal — Cut 3 extracted the shared -inheritance into `surfaces/cascade.ts`; the slice resolver already uses it. - -## Where it attaches - -Extend the Cut 3 `ghost checks` output. Today it emits, per diff: -`touched_surfaces` + the routed checks (name/severity/surface/relevance). Cut 4 -adds a `grounding` section keyed by surface: - -``` -checks → routed checks (Cut 3) -grounding → per touched surface: - surface id - why: [{ ref, kind: principle|contract, statement }] - what: [{ ref, kind: pattern|exemplar, statement, path? }] -``` - -markdown + json, same as Cut 3. A finding cites a check (from `checks`) and the -grounding for that check's surface (from `grounding`). - -## Why `checks`, not `review` - -The plan said "built on `review`." On inspection, `review` is the **legacy** -path: it builds a packet from the retired merged-stack/`validate.yml` world. The -new governance surface is the Cut 3 `ghost checks` command, which already -resolves surfaces from a diff. Grounding belongs there — extending the new -command, not reviving the legacy `review` packet. - -**Decision:** attach grounding to `ghost checks` (the surface-native command). -Leave `review` as the legacy advisory packet; its eventual replacement/removal -rides with `validate/v1` deprecation, not this cut. - -## The core function - -A pure projection, no I/O, no LLM: - -``` -groundSurface( - surfaces, fingerprint, surfaceId, -): SurfaceGrounding // { surface, why[], what[] } -``` - -Built by calling `resolveSurfaceSlice(surfaces, fingerprint, surfaceId)` and -mapping its `principles`/`experience_contracts` → why, `patterns`/`exemplars` → -what. Provenance from the slice (own | ancestor) is preserved so the consumer -can show "brand-wide" vs. "checkout-specific" grounding. - -## The emit - -- `ghost checks --diff` gains a `grounding` array (one entry per touched - surface) in both json and markdown. -- A `--no-grounding` flag (or `--checks-only`) keeps the Cut 3 lean output for - callers that only want relevance. Default includes grounding. -- markdown: under each surface, a "Why" list (principles/contracts) and a "What - good looks like" list (patterns + exemplar paths). - -## Tests - -- `groundSurface`: a checkout surface yields checkout principles as why and a - checkout exemplar (with path) as what; ancestor (`core`) principles appear as - inherited why. -- `ghost checks --diff`: the json includes `grounding` keyed by touched surface; - markdown shows why + what per surface. -- `--no-grounding` omits it. -- Empty surface (no nodes) yields an empty-but-valid grounding. -- Full `pnpm test` (hook-enforced) green. - -## Scope boundary (what Cut 4 does NOT do) - -- **No check execution** — Ghost emits checks + grounding; the agent evaluates - and decides what is actually a finding. -- **No `review` rewrite** — the legacy advisory packet stays until `validate/v1` - deprecation. -- **No new fingerprint fields** — grounding is a projection of the existing - slice. -- **No external contract references** (still deferred from 7a). - -## Changeset - -`minor` — grounding on `ghost checks` is additive. - -## Process notes - -- Pure `groundSurface` first (unit-tested with in-memory docs), reusing - `resolveSurfaceSlice`; then wire it into the command's output. -- Reuse the slice's provenance for own-vs-inherited labeling; do not recompute. -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Cut 4 succeeds if `ghost checks --diff` emits, per touched surface, the why -(principles/contracts) and what-to-change (patterns/exemplars with paths) drawn -from that surface's slice — inherited from ancestors like context is — so a -flagged check can be grounded in the fingerprint, with Ghost still never running -the check and `review`/`validate/v1` left for a later cut. diff --git a/docs/ideas/phase-7b-grounded-checks.md b/docs/ideas/phase-7b-grounded-checks.md deleted file mode 100644 index 1a917e53..00000000 --- a/docs/ideas/phase-7b-grounded-checks.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -status: exploring ---- - -# Phase 7b: grounded checks — surface-routed, fingerprint-explained - -This note settles the governance model (Layer 4) after the binding (Phase 7a) -landed the path road. It supersedes the "give Ghost's deterministic detector a -`surface:`" sketch in `phase-7-plan.md`, which a real look at how checks are -actually authored proved wrong. - -## What changed the design - -Checks in practice are **markdown rules an agent evaluates against a diff** -(frontmatter: `name`, `description`, `severity`, `tools`; body: prose -instructions), filtered for relevance and run by a review pipeline. They are not -deterministic regex detectors, and Ghost is not the thing that runs them. - -Three decisions follow: - -1. **Ghost does not run checks.** Drop the deterministic-detector ambition. The - legacy `ghost.validate/v1` regex detector is not the future of governance. -2. **Mimic the established check format** — markdown + frontmatter, - agent-evaluated — so Ghost checks are compatible with the review pipeline that - already exists, not a competing third format. -3. **The differentiator is grounding.** When a check flags something, Ghost - supplies the *why* and the *what to change* from the fingerprint slice. The - check finds the problem; the fingerprint explains and prescribes. - -## The model: check finds, fingerprint grounds - -A check is a markdown rule placed (or mapped) onto a surface. Governance is the -composition of three things Ghost already has or is adding: - -``` -diff path ──(binding, 7a)──▶ surface ──(cascade)──▶ relevant checks - │ - └──(gather slice)──▶ grounding: - principles/contracts = WHY - patterns/exemplars = WHAT to change -``` - -- **Routing (deterministic, Ghost's job):** a changed file resolves to a surface - via the Phase 7a binding; the relevant checks are those governing that surface - *and its ancestors* (the same `own + cascade` rule `gather` uses). This is the - deterministic relevance filter — better than an LLM guessing which checks - matter, because surface placement says so. -- **Evaluation (the agent's job, not Ghost's):** the agent applies the markdown - rule to the diff. Ghost does not execute it. -- **Grounding (Ghost's differentiator):** for a flag on a surface, Ghost hands - over that surface's `gather` slice — the principles/contracts as the *why*, the - patterns/exemplars as the *what good looks like*. A finding becomes "this - violates the checkout surface's `tokenized-ui-color` principle; here is the - principle and an exemplar of doing it right," not a bare rule citation. - -This is the `gather` resolver doing double duty: context for *building* and -grounding for *review*, through one surface cascade. - -## What Ghost owns vs. does not - -- **Owns:** path→surface routing (7a), surface cascade, the check→surface - association, and the grounding slice. Ghost is the deterministic relevance - filter + the fingerprint grounding source. -- **Does not own:** the check evaluation engine, the review pipeline, or the - agent that judges the rule. Ghost emits "these checks apply to this surface, - here is their grounding"; something else runs them. - -## Open design questions (for the 7b build, not settled here) - -1. **Check format + placement.** A Ghost check is markdown + frontmatter; how - does it carry its surface? Frontmatter `surface:` is the natural mirror of - node placement. But for *externally authored* checks Ghost must not edit, the - association may live in a Ghost-side mapping (in the binding, or a small - index) rather than the check file. Decide: placement in-file for Ghost-format - checks, mapping for foreign checks. -2. **The grounding emit.** What exactly does Ghost output for a flagged surface — - the full `gather` slice, or a review-shaped projection (why + exemplar refs + - repair hints)? Likely a `review`-format packet built on the slice. -3. **Replacing `ghost.validate/v1`.** The deterministic detector schema becomes - legacy. Decide whether to keep it as a niche option or deprecate it outright - in favor of markdown checks. The `check` / `review` commands and their JSON - contracts are affected. -4. **The diff road + merge retirement.** Still owed from Phase 7: `check` / - `review` route a diff to the union of its surfaces (now via 7a binding), and - `child-wins-by-id` merge in `fingerprint-stack.ts` is retired (nesting binds, - not merges — Leak E). This is independent of the check-format question and - could land first. - -## Scope note - -7a (binding + path road) is the substrate and is shipped. 7b is a design step -that needs its own plan before code, because it touches the check format, the -`check`/`review` commands, and possibly deprecates `ghost.validate/v1` — too -much to improvise. The merge retirement (open question 4) is the one piece that -is purely internal and format-agnostic; it can be cut on its own whenever. - -## Read-back - -This note is right if governance becomes: Ghost deterministically routes a diff -to the surfaces it touches and their checks (any format), the agent evaluates the -rule, and Ghost grounds every flag in the surface's fingerprint slice (why + -what) — with Ghost owning routing and grounding, never the check engine. diff --git a/docs/ideas/phase-7b-plan.md b/docs/ideas/phase-7b-plan.md deleted file mode 100644 index cad3a08c..00000000 --- a/docs/ideas/phase-7b-plan.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -status: exploring ---- - -# Phase 7b plan: grounded checks (execution) - -Execution spec for the governance model settled in -`phase-7b-grounded-checks.md`. Ghost does not run checks; it **routes** a diff to -the surfaces it touches and **grounds** every flag in that surface's fingerprint -slice. The check format is markdown + frontmatter (agent-evaluated), mirroring -the established `.agents/checks` form — not Ghost's legacy regex detector. - -This is sequenced as four cuts, ordered by independence and risk. Each lands -green on its own; do not bundle. - -## Cut 1 — retire the `child-wins-by-id` merge (Leak E) [independent, do first] - -The one piece with no dependency on the check-format question, and the last owed -item from `phase-7-plan.md`. Pure internal refactor. - -- `scan/fingerprint-stack.ts` still has `mergeFingerprints`, `mergeIntent`, - `mergeInventory`, `mergeComposition`, `mergeBuildingBlocks`, `mergeSummary`, - `mergeChecks`, `mergeById`, `mergeByKey`, `mergeStrings`, and the - `child-wins-by-id` provenance. -- Reframe a "stack for a path" from *merged facets* to *binding resolution*: the - root contract + the binding that owns the path (Phase 7a) + the composed slice - (Phase 5 resolver). Keep layer **discovery** (root→leaf walk); it is now - binding discovery, not merge input. -- Consumers reading `stack.merged.{fingerprint,checks}` — - `core/check.ts`, `review-packet.ts`, `scan-stack-command.ts`, - `scan-emit-command.ts` — move onto the resolved-surface result. `relay.ts` is - **not** rewired (deleted in Phase 8); stub or leave it. -- Tests: a root edit no longer alters a leaf's resolved slice; a child cannot - disable an inherited check by merge; the deleted merge functions are gone. - -This cut may be sizeable (4 consumers). It is the riskiest of the four and the -most independent, so it goes first and alone. - -## Cut 2 — the Ghost check format - -Define `ghost.check/v1` as **markdown + frontmatter**, deliberately -shape-compatible with the established agent-check format: - -- Frontmatter: `name`, `description`, `severity` (`high`|`medium`|`low`), - `tools`, optional `turn-limit`, plus the Ghost addition: **`surface:`** - (placement, the natural mirror of node placement). -- Body: prose instructions for the agent (Purpose / Instructions), unchanged - from the established convention. -- A parser + lint (`ghost-core/check/`): valid frontmatter, known severity, - `surface:` is a flat slug. No detector, no execution — Ghost never runs it. -- File-kind detection for `.md` checks under a checks directory (mirror surfaces - / binding wiring). Decide the on-disk location: a `checks/` dir in the package, - or `.agents/checks/`-compatible — recommend a Ghost `checks/` dir in the - package so it travels with the contract. - -Open sub-decision (decide at build): for **foreign** checks Ghost must not edit -(no `surface:` in their frontmatter), the surface association lives in a -Ghost-side mapping (in the binding, or a small `checks` index), not the file. -Recommend: `surface:` in-file for Ghost-authored checks; a mapping for foreign -ones; same routing for both. - -## Cut 3 — surface-routed relevance - -The deterministic relevance filter — Ghost's first governance differentiator. - -- Given a diff, resolve each changed path → surface (Phase 7a binding), take the - union, and select the checks governing those surfaces **and their ancestors** - (the `own + cascade` rule from the Phase 5 resolver, reused verbatim). -- Replace the legacy `routeGhostValidateForPath` (path-glob over - `applies_to.paths`) with surface routing. `check` reports which checks apply to - which surface for the diff. Ghost emits the relevant set; it does not run them. -- Tests: a checkout-file diff selects checkout + core checks, excludes email - checks; an unbound path falls to core checks; cascade pulls ancestor checks. - -## Cut 4 — fingerprint grounding - -The second differentiator, built on `review`. - -- For each flagged surface, emit the grounding: the surface's `gather` slice - projected to *why* (principles/contracts) + *what to change* - (patterns/exemplars, with exemplar paths). `review` already builds a - fingerprint-grounded packet from a diff — extend it to key grounding by - resolved surface rather than the merged doc. -- Decide the emit shape: a `review`-format packet section per surface — id, - applicable checks, and the grounding slice — markdown + json. -- Tests: a flag on the checkout surface emits checkout's principles as why and a - checkout exemplar as what; grounding cascades from ancestors. - -## Deprecating `ghost.validate/v1` - -The legacy regex detector becomes legacy. Recommendation: **keep it parseable -but stop treating it as the governance path** — `check`/`review` route by -surface and ground by fingerprint; the detector schema is no longer the future. -Full removal (and a check migration) is a later call, not 7b. Note any public -`check-report/v1` / advisory-review JSON shape change for the changeset. - -## Scope boundary (what 7b does NOT do) - -- No check **execution** — Ghost routes and grounds; the agent evaluates. -- No external contract references (still Phase 7a's deferred fork). -- No relay rewire (Phase 8 deletes it). -- Full removal of `ghost.validate/v1` and a check migration are deferred. - -## Changeset - -Per cut: Cut 1 internal (note any `check`/`review` JSON shape change — may fold -into the major). Cuts 2–4 `minor` (new `ghost.check/v1`, surface routing, -grounding emit are additive public surface). - -## Process notes - -- **Cut 1 first and alone** — it is independent and the riskiest; do not - entangle it with the check format. -- Then 2 → 3 → 4 in order (format before routing before grounding). -- Reuse the Phase 5 resolver's cascade for routing (Cut 3) and grounding (Cut 4) - — one mechanism serves build context and review. -- Each cut green through the hook before the next. - -## Read-back - -7b succeeds if the `child-wins-by-id` merge is gone (nesting binds, not merges), -a Ghost check is markdown + frontmatter with a surface, a diff deterministically -selects the checks governing its surfaces and ancestors, and every flag is -grounded in the surface's fingerprint slice — with Ghost owning routing and -grounding and never the check engine. diff --git a/docs/ideas/phase-8-plan.md b/docs/ideas/phase-8-plan.md deleted file mode 100644 index 4c649235..00000000 --- a/docs/ideas/phase-8-plan.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -status: exploring ---- - -# Phase 8 plan: command + skill + docs reconciliation - -Execution spec for the final phase of `implementation-plan.md`. The command -fates were settled long ago (the "desire-survives" test); Phase 8 is **execution, -not decision** — delete the absorbed/dead commands and their relay-only modules, -update the skill bundle to teach surfaces, regenerate the manifest, and fill in -the major changeset. - -## What dies (settled by command fate) - -| Command | Desire now served by | Action | -| --- | --- | --- | -| `relay` | `gather` (Phase 5) | delete command + relay-only `context/` modules | -| `stack` | path→surface binding (Phase 7a) | delete `scan-stack-command.ts` | -| `survey ` | nothing in the new model | delete command surface | -| `diff` | dead direct-markdown path | delete command | -| `describe` | dead direct-markdown path | delete command | -| `emit` (`scan-emit`) | reassess — see below | decide | - -## The entanglement to resolve first (read before deleting) - -A full read shows two snags the plan's one-liner hid: - -1. **`relay` and `review` share `context/` machinery.** `relay.ts` imports the - relay-only modules (`relay-config`, `relay-config-loader`, `relay-context`, - `relay-modes`, `relay-request`, `request-resolution`) **and** the shared ones - (`entrypoint`, `package-context`, `projection`, `selected-context`). - `review-packet.ts` *also* uses `entrypoint` + `selected-context`. So the - deletion set is: **relay-only modules die; the shared context/entrypoint/ - selected-context modules stay** (review still needs them). Do not delete - `context/` wholesale — partition it. - -2. **`survey` is a command *and* a `ghost-core/survey` module** referenced by - `fingerprint-package`, `comparable-fingerprint`, `patterns/lint`, and others. - Command fate kills the **`survey` command surface**, not necessarily the whole - module. **Scope decision:** delete the `survey ` CLI command and its - registration; leave the `ghost-core/survey` schema/types in place if other - modules still import them, and flag full survey-module removal as a separate - follow-up. Deleting the module is a deeper cut than "remove a command." - -## The `emit` / `review` question (decide in this cut) - -- `scan-emit-command.ts` (`emit review-command`) and `review` both build on the - Phase 7b-Cut-1 contract model now. They are **not** on the original delete - list. `review` is the legacy advisory packet flagged for eventual replacement - (Cut 4 note), but it still works on the contract. -- **Recommendation:** keep `review` and `emit` for now (they function on the new - contract), and defer their replacement-by-`gather`/`checks` to a later cut. - Phase 8 deletes only what command fate named (`relay`/`stack`/`survey`/`diff`/ - `describe`). Do not expand scope to `review`/`emit` here. - -## Steps - -1. **Delete the dead command sources + registrations:** - - `relay-command.ts`, `relay.ts`, `scan-stack-command.ts`; remove their - `register*` calls from `cli.ts`. - - Remove the `describe`, `diff`, and `survey ` command blocks from - `fingerprint-commands.ts`. - - Remove the dead entries from `command-discovery.ts` (`stack`, `describe`, - `diff`, `survey`). -2. **Delete the relay-only `context/` modules:** `relay-config.ts`, - `relay-config-loader.ts`, `default-relay-config.ts`, `relay-context.ts`, - `relay-modes.ts`, `relay-request.ts`, `relay-request-input.ts`, - `request-resolution.ts`, `request-stack-document.ts`. Keep `entrypoint.ts`, - `package-context.ts`, `projection.ts`, `selected-context.ts`, - `selection-reasons.ts`, `graph.ts` (review + the resolver still use them). - Verify each "keep" is still imported after the relay deletion; delete any that - become orphaned. -3. **Remove the `./relay` public export** from `package.json` and the - `GHOST_RELAY_*` / relay re-exports from the public surface. This is a breaking - export removal — the major changeset covers it. -4. **Delete the now-skipped relay tests** (`relay.test.ts`, the - `context-entrypoint`/`context-sandbox` skips if they only tested the dead - path) and any `survey`/`diff`/`describe` CLI test cases. -5. **Skill bundle:** update references that still teach the old relay/scope - surface to teach surfaces + placement + `gather`/`checks` (the `voice.md` fix - was the preview). Audit `references/*.md` for `relay`, `scope`, `topology`, - `applies_to` mentions. -6. **Regenerate** `pnpm dump:cli-help`; **fill in** the major changeset body with - the full list of removed commands/exports. - -## Scope boundary (what Phase 8 does NOT do) - -- **No `review` / `emit` removal** — they work on the contract; their - replacement is a later cut. -- **No `ghost-core/survey` module removal** — only the `survey` command surface; - module removal is a flagged follow-up. -- **No `ghost.validate/v1` removal** — the legacy detector deprecation is its own - later cut (7b parking lot). -- **No new behavior** — pure deletion + skill/docs catch-up. - -## Tests - -- `cli.test.ts`: remove dead-command cases; the suite must stay green with the - smaller command set. -- `public-exports.test.ts`: drop `./relay` and the relay exports from the - asserted surface. -- Full `pnpm test` (hook-enforced) green; `pnpm check` manifest in sync. - -## Changeset - -Fold into the existing `major` changeset (the cutover release). List the removed -commands (`relay`, `stack`, `survey`, `diff`, `describe`) and the removed -`./relay` export / `GHOST_RELAY_*` surface. - -## Process notes - -- **Partition `context/` before deleting** — confirm which modules are - relay-only vs. shared with `review`/resolver; the compiler is the worklist for - orphans (Phase 3/4 rhythm). -- Delete sources, then chase compile + test failures to green. -- The skill-bundle audit is prose work — grep for the dead vocabulary, rewrite to - surfaces, mind the terminology guard (it scans shipped text; "cascade"/"layer" - are out of public prose). -- Stage deliberately; the format hook re-stages touched files. - -## Read-back - -Phase 8 succeeds if `relay` / `stack` / `survey` / `diff` / `describe` and the -relay-only `context/` modules are gone, the shared context modules `review` still -needs survive, the `./relay` export is removed, the skill bundle teaches surfaces, -the manifest is regenerated, and the major changeset lists the removals — with -`review`/`emit`/`validate-v1`/the survey module explicitly left for later cuts. diff --git a/docs/ideas/polish-cut-c-plan.md b/docs/ideas/polish-cut-c-plan.md deleted file mode 100644 index 6f81e01e..00000000 --- a/docs/ideas/polish-cut-c-plan.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -status: exploring ---- - -# Polish Cut C plan: collapse to one check format (remove ghost.validate/v1) - -Decision (settled by the user): **two check formats make no sense — default down -to one, the markdown `ghost.check/v1`.** This escalates Cut C from the roadmap's -"keep the deterministic gate" (Option 1) to **full removal of `ghost.validate/v1` -and the `ghost check` deterministic-gate command** (Option 2). - -Governance is now entirely: `ghost checks` (route + ground markdown checks) and -`ghost review` (the advisory packet over the same). The agent evaluates; Ghost -never runs a detector. - -## What dies - -- **`ghost.validate/v1`**: `ghost-core/checks/{schema,types,lint,routing}.ts`, - `GhostValidateSchema`, `GhostCheck`, `routeGhostValidateForPath`, the - `validate.yml` facet and its file-kind/dispatch. -- **`ghost check`** (the deterministic gate) and **`ghost drift ... check`'s** - detector path — `core/check.ts`'s detector evaluation, `runGhostDriftCheck`, - `inline-color-literals`, `gate.ts` if detector-only. -- The **`./govern`** public export and `govern.ts` (it re-exports the check - runner). -- `validate.yml` from `ghost init` scaffolding and the package paths/loader. - -## The two things to rescue first (read before deleting) - -1. **`parseUnifiedDiff` lives in `core/check.ts`** and is imported by the *new* - `review-packet.ts` and `checks-command.ts`. It is generic diff parsing, not - validate logic. **Move it** to a neutral home (e.g. `scan/diff.ts` or - `core/diff.ts`) before deleting `core/check.ts`, and repoint the two callers. -2. **`drift` is two things.** `ghost drift status` / `ghost drift check` operate - on the **stance ledger** (`.ghost-sync.json`, tracked-fingerprint identity) — - that is *not* the detector gate and must survive. Only the - `validate.yml`-detector evaluation inside `core/check.ts` dies. Confirm which - parts of `core/` are detector-only vs. drift-ledger before cutting. - -## Open decision in this cut - -**Does `ghost check` (the command name) survive, repurposed?** Today `check` -runs deterministic detectors against a diff. With detectors gone, the natural -"check a diff" verb is `ghost checks` (markdown routing + grounding). -Recommendation: **delete `ghost check`** (singular, the detector gate) and let -`ghost checks` (plural, the markdown router) be the diff-checking verb. Note the -near-collision in the changeset; it is intentional (the plural replaces the -singular). - -## Steps - -1. **Rescue `parseUnifiedDiff`** to a neutral module; repoint `review-packet` and - `checks-command`; drop it from the `core` public surface if it was exported. -2. **Delete the detector gate:** `ghost check` command block in `cli.ts`, - `core/check.ts`'s detector path, `inline-color-literals.ts`, and any - detector-only helpers in `core/`. Preserve the drift-ledger path - (`drift status` / `drift check` over `.ghost-sync.json`). -3. **Delete `ghost-core/checks/`** (schema, types, lint, routing) and its - `#ghost-core` re-exports. -4. **Remove `validate.yml`** from `ghost init` scaffolding, `FingerprintPackagePaths`, - the loader, file-kind detection + dispatch, scan-status/contribution, and - verify-package. -5. **Remove the `./govern` export** from `package.json` and delete `govern.ts`; - update `public-exports.test.ts`. -6. **Update the skill bundle / docs** to state one check format: markdown - `ghost.check/v1`, routed by surface and grounded by the fingerprint. -7. Regenerate the manifest; fill the major changeset. - -## Scope boundary (what Cut C does NOT do) - -- **Keeps `ghost checks` / `ghost review` / `ghost.check/v1`** — the surviving - governance surface. -- **Keeps `drift` (stance ledger)** — unrelated to detectors. -- **Keeps `ghost-core/survey`** — still its own deferred excavation. -- No new check behavior; this is removal + the diff-parser rescue. - -## Tests - -- Delete `ghost check` / `validate.yml` test cases (`checks.test.ts`, - `checks-grounding.test.ts`, the cli detector cases). -- `public-exports.test.ts`: drop `./govern` and validate exports. -- Confirm `review` / `checks` still parse diffs after the `parseUnifiedDiff` - move. -- `drift status` / `drift check` (ledger) stay green. -- Full `pnpm test` + `pnpm check` green. - -## Changeset - -`major` — removes the `ghost check` command, the `./govern` export, the -`ghost.validate/v1` schema and `validate.yml` facet. Note that `ghost checks` -(markdown) is the single remaining check format. - -## Process notes - -- **Rescue `parseUnifiedDiff` first, as its own step**, so the new commands never - break during the deletion. -- Separate the drift-ledger code from the detector code in `core/` before - cutting — the compiler is the worklist once the gate command is gone. -- This is the largest polish cut (~24 files reference the surface); expect a - Phase-3-style ripple. Delete, then chase to green. -- Mind the terminology guard on changeset/skill prose. - -## Read-back - -Cut C succeeds if `ghost.validate/v1`, `validate.yml`, the `ghost check` -detector gate, and the `./govern` export are gone; `parseUnifiedDiff` survives in -a neutral home so `review`/`checks` still work; the drift stance ledger is -untouched; and Ghost has exactly one check format — markdown `ghost.check/v1`. diff --git a/docs/ideas/polish-cut-d-plan.md b/docs/ideas/polish-cut-d-plan.md deleted file mode 100644 index 556aa3da..00000000 --- a/docs/ideas/polish-cut-d-plan.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -status: exploring ---- - -# Polish Cut D plan: external contract references in bindings - -The last deferred cut. Today a `.ghost.bind.yml` only supports `contract: .` -(the in-repo root contract); lint hard-rejects anything else. Cut D lets a -binding reference an **external contract** — a published brand package — so a -repo can bind its local paths to surfaces defined by `@scope/brand` in -`node_modules`. - -## Scope (from the roadmap, held tight) - -- **npm-name references only.** `contract: @scope/brand` or `contract: brand`. - Arbitrary resource-id resolvers (needing host config) are deferred. -- **No new version machinery.** `ack` / `track` already model stance toward a - moving reference; do not reinvent pinning here. -- The cut is **resolution + validation**, not a new runtime. - -## The finding that bounds it - -The `contract:` field is currently *informational*: lint only checks it is `.`, -and discovery (`readExplicitBinding`) takes the binding's surface ids on faith — -it never cross-checks them against the contract's `surfaces.yml`. And -`gather`/`checks`/`review` operate on the *local* package; composing an external -contract's content already works via `gather --package node_modules//.ghost`. - -So Cut D's real, bounded value is: **resolve the referenced contract and validate -that the bound surfaces exist in it.** Nothing else needs to change. - -## What it builds - -1. **Schema/lint** accept a contract reference: `.` (in-repo) or an npm package - name (`@scope/name` or `name`). Replace the hard `binding-contract-unsupported` - error with: `.` is always fine; an npm-name is fine *syntactically*; - anything else (a path, a URL, a resource id) is still rejected for now. -2. **A contract resolver** (`scan/contract-resolver.ts`): given a reference and a - starting dir, return the contract's `.ghost/` directory. - - `.` → the in-repo contract (root `.ghost/`, the existing behavior). - - npm name → the nearest `node_modules//.ghost/` walking up from the - binding's directory. Returns `null` when unresolved. -3. **Verify integration**: a binding with an external `contract:` is validated — - the referenced package resolves and each bound `surface` exists in that - contract's `surfaces.yml`. Unresolved package or unknown surface → a verify - error (`binding-contract-unresolved` / `binding-surface-unknown`). - -## What it does NOT do - -- **No external fingerprint loading in `gather`/`checks`/`review`.** They stay - local; `--package` already reaches an external package's `.ghost/`. Following a - binding to auto-load an external contract's *content* for grounding is a larger - follow-up, explicitly deferred. -- **No resource-id resolvers, no version pinning, no network fetch.** npm - resolution is filesystem-only (`node_modules`); installing the package is the - host's job. -- The in-repo `contract: .` path is unchanged. - -## Steps - -1. Add an npm-name matcher to the binding schema/lint; relax the contract check - to accept `.` or a valid npm name, reject the rest. -2. Write `resolveContractDir(reference, fromDir, repoRoot)` in `scan/` — `.` and - npm-name resolution, filesystem-only, `null` on miss. -3. In `verify-package` (or a binding verifier), for each `.ghost.bind.yml` with a - non-`.` contract: resolve it, read its `surfaces.yml`, and assert each bound - surface exists; emit verify errors otherwise. -4. Tests: npm-name lint accept/reject; resolver finds `node_modules//.ghost` - and returns null when absent; verify flags an unknown surface / unresolved - package; `contract: .` still works unchanged. -5. Update the binding docstring + skill/schema reference to document external - references. Changeset `minor` (additive). - -## Read-back - -Cut D succeeds if a `.ghost.bind.yml` can declare `contract: @scope/brand`, -Ghost resolves it from `node_modules` and validates the bound surfaces exist in -that contract, the in-repo `.` path is unchanged, and external fingerprint -loading for grounding is explicitly left as a follow-up. diff --git a/docs/ideas/polish-roadmap.md b/docs/ideas/polish-roadmap.md deleted file mode 100644 index 0a062d70..00000000 --- a/docs/ideas/polish-roadmap.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -status: exploring ---- - -# Polish roadmap: the four deferred cuts - -The cutover (Phases 1–8) is complete. Four items were deliberately parked. They -are **not independent** — a full read shows a dependency chain, and doing them in -the wrong order means rewiring the same consumers twice. This note settles the -order and scopes each as its own cut. - -## The dependency finding - -- **`review` / `emit` still depend on two legacy things at once:** the - `validate.yml` checks (`context.checksRaw`) **and** the dormant Job 2 selection - in `context/entrypoint.ts` (`matchScopes`, `globalFallbackRefs`, - `appliesTo.scopes/surfaceTypes` — the path-selection made inert in Phase 3). -- **`validate/v1`** is consumed by `review`, `core/check.ts`, `fingerprint-stack`, - `verify-package`, and the `checks/` module. -- **`survey`** is a large module (`ghost-core/survey/*`) still imported by - `verify-fingerprint`, `comparable-fingerprint`, `patterns/lint`, - `perceptual-prior`, `fingerprint-package`, and `file-kind` — far beyond the - deleted command. -- **External contract references** in bindings are self-contained — they touch - only the binding schema/lint/resolver, nothing the other three need. - -So: **`review`/`emit` sit on top of both `validate` and the dormant entrypoint.** -You cannot cleanly remove `validate/v1` while `review` still emits its checks. -And moving `review`/`emit` onto `gather`/`checks` is what *frees* `validate` and -the dormant entrypoint to be deleted. That dictates the order. - -## The order - -### Cut A — move `review` / `emit` onto `gather` + `checks` (do first) - -The keystone. Until `review` stops consuming `validate.yml` and the Job 2 -entrypoint, neither can be removed. - -- Rebuild `review` on the surface-native path: resolve the diff's surfaces - (Phase 7a binding), select governing markdown checks (Cut 3), and ground them - (Cut 4) — i.e. `review` becomes a formatting wrapper over what `ghost checks` - already computes, plus the diff. Drop `buildContextEntrypoint` / - `buildSelectedContext` (the dormant Job 2 path). -- Reframe `emit review-command` to emit from the surface slice - (`package-review-command.ts` currently builds from the merged/legacy context). -- Decide: keep `review` as a command (advisory packet) or fold it into - `ghost checks --review`. Recommendation: keep `review` as the human-facing - advisory command, reimplemented on the new rails; `emit` stays for the - review-command artifact. -- This is the one with real design in it — the others are deletions. - -### Cut B — delete the dormant Job 2 entrypoint (after A) - -Once `review` no longer calls `buildContextEntrypoint`, the Job 2 selection -machinery (`matchScopes`, `globalFallbackRefs`, `appliesTo` scoring, -`selected-context`, the `graph.ts` applicability half) has **no live caller**. -Delete it. Keep `graph.ts`'s structure/content half only if something still uses -it; otherwise delete `entrypoint.ts`, `selected-context.ts`, `selection-reasons.ts` -too. The compiler is the worklist. - -### Cut C — deprecate / remove `ghost.validate/v1` (after A) - -With `review` off `validate.yml`, the only remaining consumers are `core/check.ts` -(the legacy deterministic gate), `verify-package`, and `fingerprint-stack`. -Decide the end state: - -- **Option 1 (recommended): keep `ghost check` as the deterministic gate**, but - stop treating `validate/v1` as the *governance future* — it coexists with - `ghost.check/v1` markdown checks (deterministic gate vs. agent-evaluated - review). Document the split; remove nothing. -- **Option 2: full removal** — delete `validate/v1`, the `ghost check` command, - `checks/` module, and migrate any deterministic checks to markdown. Bigger, - and loses the only no-LLM gate. Defer unless there is a reason. - -Lead with Option 1 (a docs/positioning cut, not a deletion) and only escalate to -Option 2 if you decide deterministic checks have no place. - -### Cut D — external contract references in bindings (independent, anytime) - -Self-contained; can land before or after the others. Extend `ghost.binding/v1` -`contract:` beyond in-repo `.`: - -- Accept an npm package name or a resource id; resolve the referenced contract's - `surfaces.yml` (npm: from `node_modules`; resource id: a configured resolver). -- Version pinning / stance: `ack` / `track` already model stance toward a moving - reference — reuse, do not reinvent. -- Lint: relax `binding-contract-unsupported`; validate the reference resolves. -- Scope guard: ship npm-name resolution first; defer arbitrary resource-id - resolvers to a follow-up if they need host config. - -## Sequence summary - -``` -A (review/emit → gather/checks) ← keystone; unblocks B and C -├─ B (delete dormant Job 2 entrypoint) -└─ C (validate/v1 positioning, Option 1) -D (external contract refs) ← independent, anytime -``` - -Do **A first**, then B and C (either order), and D whenever. Each is its own -plan + build + green commit — no bundling. - -## What stays out of scope - -- The `ghost-core/survey` module removal is **not** in this roadmap as a near-term - cut: it is imported by `comparable-fingerprint`, `patterns/lint`, - `perceptual-prior`, and `verify-fingerprint` — removing it is a deep, separate - excavation with its own questions (does `compare`/`verify` still need survey - evidence?). **Flag it; do not sequence it here.** It earns its own investigation - note when/if survey truly has no consumer. - -## Read-back - -This roadmap is right if: `review`/`emit` move onto the surface rails first -(Cut A), which frees the dormant Job 2 entrypoint (Cut B) and the `validate/v1` -positioning (Cut C) to follow; external contract references (Cut D) land -independently; and the survey-module removal is explicitly held back as a deeper, -separate excavation rather than rushed in. diff --git a/docs/ideas/reset.md b/docs/ideas/reset.md deleted file mode 100644 index 04616369..00000000 --- a/docs/ideas/reset.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -status: exploring ---- - -# Reset: how to approach Ghost - -This note is subordinate to `fingerprint-first-architecture.md` (settled). It -changes no decision in that memo. It exists for a different reason than the -others in this folder: not to explore a new idea, but to stop circling. - -Three notes — `purposes.md`, `ghost-layers.md`, and `contract-and-binding.md` — -were written from a real and honest discouragement: that Ghost had been -overcomplicated, that it tried to do too much, that the plot was lost. This note -takes those three seriously, reads what they actually concluded, and turns the -feeling into a single approach with a defined purpose, fixed goals, named -layers, and a clean separation of concerns. - -The short version: **you are not lost, and nothing you built was wasted.** The -three notes are not three problems. They are one diagnosis written three times, -and they agree on the first move. This note names that move and the discipline -that keeps it from being undone. - -## The diagnosis the three notes already share - -Read together, the circling notes say one thing: - -- **The descriptive core is right.** Intent / inventory / composition is *one - surface seen through three angles*. It survived every refactor because it is - coherent. `ghost-layers.md` calls this the clean center. `purposes.md` calls - it the model that does not bend. `contract-and-binding.md` calls it the part - that "survives intact." -- **The pain is leakage, not scope.** Selection, routing, merge, and governance - are *operations on* the fingerprint that pressed *into* its shape, because the - fingerprint was the only durable thing to hang them on. `ghost-layers.md` - names this Leak A. `contract-and-binding.md` names it "the map the binding - impersonates with filesystem paths." `purposes.md` names it "merge to - assemble, select to deliver." -- **They converge on one cut.** Extract the coordinate space — `topology` plus - the smeared `applies_to` — out of the description and give it its own home. - The layers note calls this "the one structural change worth making first." The - contract note says doing it *is* the contract/binding split seen from another - angle: "do one and the other falls out." - -That is the whole reset. The mess felt like five colliding concepts. It is one -seam, visible now because the bundled version was built first. The disorientation -is the ordinary feeling of standing right after the hard part, where a mess -turns back into a map. - -## Purpose (one sentence, does not move) - -> Ghost captures the composition of a product surface — the intent behind it, -> the materials it draws from, and the patterns that make it feel intentional — -> as a portable, checked-in contract that humans approve and agents act from. - -This is the `fingerprint-first-architecture.md` sentence, unchanged. Everything -below serves it. If a proposed feature does not serve this sentence, it is not a -Ghost feature; it is a projection, a tool, or scope creep. - -## Goals (what "right" means) - -1. **One durable artifact.** The checked-in fingerprint is the source of truth. - Every consumer reads it through a projection; no consumer changes its shape - or its merge semantics to suit itself. (`purposes.md`, the rule.) -2. **A clean descriptive core.** Intent / inventory / composition stays exactly - what it is. New purposes never become new fields on it. (`ghost-layers.md`, - the discipline rule.) -3. **Operations live in their own layer.** Selection, governance, and comparison - are rings around the core, not properties of it. -4. **The model is dumb on purpose.** Nesting is storage and ownership; - routing plus filtering is selection. No mixins, no priority weights, no - write access to the merge. Predictability is the feature. -5. **Portability is allowed.** The artifact may describe surfaces no repo - consumes (non-UI, multi-surface brand). That is not scope creep; it is the - contract being legitimately bigger than any one binding. - -## The layers (the separation of concerns) - -This adopts the five layers from `ghost-layers.md` verbatim, because they are -already correct. The reset is to *commit* to them as the answer to every "does -this go in the fingerprint?" question. - -| Layer | Name | One line | Owns | -| --- | --- | --- | --- | -| 1 | **Description** | What the surface is. | intent, inventory, composition | -| 2 | **Map** | The coordinate space the surface lives in. | dimensions, scopes, surface types | -| 3 | **Selection** | Path or prompt to a narrow view of layer 1. | relay gather, routing, request resolution | -| 4 | **Governance** | Whether a change stays faithful, and who owns what. | checks, drift, ownership | -| 5 | **Comparison** | Read-only analytics across many fingerprints. | distance, cohorts, fleet | - -The two rules that make these layers load-bearing instead of decorative: - -> **The layer rule.** A new purpose gets a new layer, never a new field on -> intent / inventory / composition. - -> **The projection rule.** A consumer may read through any projection it likes. -> It may not change the *shape* of the fingerprint or its *merge semantics* to -> suit itself. If serving a purpose requires bending the shape, that is a leak -> to fix at the boundary, not a redesign of the artifact. - -Layers 1, 4 (checks/drift), and 5 are already clean per the triage. The work is -entirely in extracting Layer 2 and letting Layer 3 stop improvising around its -absence. - -## The first cut (the only thing this note schedules) - -Everything above is stance. This is the move. Do exactly one thing first: - -> **Extract the map.** Lift the coordinate space — `inventory.topology` plus the -> `applies_to` smeared across nodes — out of the description and make it an -> explicit Layer 2 artifact that selection, governance, and comparison query. - -Why this one, before contract/binding, before any kill list, before renames: - -- It is the leak all three notes independently point at (Leak A / the spine / - the impersonated map). -- It is the highest leverage: Layers 3, 4, and 5 all query the coordinate space, - so fixing it once makes three consumers cleaner. -- It unblocks the bigger question without deciding it early. Once the map is its own - thing, the contract/binding split *falls out* of it rather than being a second - independent surgery. You can decide that question later, from a cleaner base. - -What this first cut explicitly does **not** require: - -- No decision on contract vs binding yet. -- No renames of commands or schemas. -- No removal of `survey`, `diff`, `describe`, or any legacy format yet. -- No new public interface. - -The map extraction gets its own proposal note with concrete schema, linked back -here for rationale. This note only fixes which cut is first and why. - -## What stays parked (so the circling stops) - -These are real and worth doing, but they are *downstream of the first cut* and -must not be started in parallel, because doing them now is what re-creates the -overwhelm: - -- **Contract vs binding** (`contract-and-binding.md`). Revisit *after* the map - exists; the split becomes mostly mechanical once the coordinate space is - extracted. -- **Kill lists** (legacy formats, `survey`, direct-markdown commands). These are - cleanup, not architecture. They do not block the core and can happen anytime. -- **Routing facet naming** (Leak B), **duplicate vocabulary** (Leak C), **CAPS** - (Leak D), **nesting-as-ownership** (Leak E). All resolve more obviously once - Layer 2 is real. Each gets its own note when its turn comes. - -Parking these is not deferral-as-avoidance. It is the direct remedy for the -specific feeling that started this: too many open fronts at once. There is one -front now. - -## How to hold the line (discipline going forward) - -When any future change is proposed, answer two questions before writing code: - -1. **Which layer is this?** If the honest answer is "it adds a field to intent / - inventory / composition," stop — it is almost certainly a leak from another - layer. -2. **Is this the model bending, or a projection reading?** If a consumer needs - the shape or merge to change, fix the boundary, not the artifact. - -If both questions have clean answers, the change is safe. If they don't, the -change is the next leak — write it down, don't build it yet. - -## Read-back - -This note succeeds if it replaces a feeling with a footing: - -- The purpose is one sentence and it has not changed since the settled memo. -- "Did I overcomplicate it?" has an answer: no — five operations leaked into one - file, and they are *sortable*, not wrong. -- "What do I do next?" has exactly one answer: extract the map. One cut, the one - all three notes already agree on. -- Everything else has a home (a layer) or a queue (parked), so nothing has to be - held in the head at once. - -You did not lose the plot. The code drifted from a doc you already ratified, and -three notes you already wrote found the seam. The reset is to believe them, make -the one cut, and let the rest fall out. diff --git a/docs/ideas/surface-binding.md b/docs/ideas/surface-binding.md deleted file mode 100644 index 71251f9e..00000000 --- a/docs/ideas/surface-binding.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -status: exploring ---- - -# Surface binding: connecting a repo to the contract - -This note is subordinate to `fingerprint-first-architecture.md` (settled), -designed by `coordinate-space.md`, and the second concrete cut after -`surface-schema.md`. It settles the one fork that note left open: how a real -working tree declares that it realizes a surface, and where. It builds on the -`ghost.binding/v1` vocabulary first sketched in `contract-and-binding.md`. - -The schema note defined the **portable contract** (`surfaces.yml`, -repo-agnostic, no paths). This note defines the other half of the contract/ -binding split: the **binding** — the thin repo-native statement that *this* -working tree is an instance of that contract, and these paths realize these -surfaces. - -## What stays constant - -- **The contract carries no paths.** `surfaces.yml` stays repo-agnostic. This is - the hard rule from `surface-schema.md` and it is non-negotiable here: the - binding exists precisely so the contract never has to know git exists. -- **The no-repo cases need no binding.** Outcomes 3 & 4 (customer brand - generation, portable brand package) resolve directly from a prompt to a - surface. The binding is **purely additive** for the repo cases (outcomes 1 & - 2). A lone prompt against a contract never touches a binding. -- **Resolution is BYOA.** Ghost resolves path → surface deterministically, no - LLM. The host agent still does any natural-language matching. - -## What the binding is for - -Two outcomes need a working tree connected to the contract: - -1. **In-repo work (outcome 1).** "I'm editing `apps/checkout/page.tsx` → which - surface's slice do I get before I build?" needs **path → surface**. -2. **PR gate (outcome 2).** "This diff touches these files → which surfaces' - checks run?" needs the same resolution over a **diff**. - -Both are the same primitive: **given a path, resolve the surface that owns it, -then compose that surface's slice** (its subtree + cascaded ancestors + typed -edges, per `coordinate-space.md`). The binding is the only thing that turns a -filesystem path into a surface id. Nothing else in the model knows about paths. - -This is also where three layers plug in: Selection (Layer 3) and Governance -(Layer 4) both consume path → surface; the contract/binding seam -(`contract-and-binding.md`) becomes concrete here; and it is the final home for -demoting nesting-as-ownership (Leak E). - -## The shape: directory by default, declaration as escape hatch - -`surface-schema.md` left the sub-fork as nested-package vs. explicit declaration -vs. both. **Decision: both, with directory location as the default binding and an -explicit declaration as the escape hatch.** - -### Directory location is the default binding - -The common case needs zero new ceremony. A scoped `.ghost/` package placed in a -directory **binds the surfaces it declares to that directory's subtree**. This -reuses the nested-package resolution Ghost already has — root-to-leaf discovery -along a path — but reframes what nesting *means*: not a data merge, a **binding**. - -```text -.ghost/ # root contract: surfaces.yml defines the tree -apps/checkout/.ghost/ # binds checkout surfaces to apps/checkout/** -apps/checkout/page.tsx # resolves to the checkout surface by location -``` - -For a path, Ghost walks from root to leaf, finds the nearest binding, and that -binding names the surface. Location *is* ownership. No `paths:` field is needed -in the common case because the directory already says where the binding applies. - -### Explicit declaration when ownership does not match the tree - -Sometimes the surface a path realizes is not where the directory tree would put -it — a flat repo, a surface realized across scattered paths, a monorepo whose -layout predates the surface model. For that, an explicit binding file: - -```text -apps/email-svc/.ghost.bind.yml -``` - -```yaml -schema: ghost.binding/v1 -contract: . # path, npm name, or resource id of the contract -bindings: - - surface: email-lifecycle # a surface id in the contract - paths: [apps/email-svc/src] - - surface: email-marketing - paths: [apps/email-svc/campaigns] -``` - -The explicit form names contract + surface + paths directly. It is the escape -hatch, not the default: most repos never write one. `paths:` lives **here, on -the binding**, never on the surface — this is exactly where the deleted -`topology.scopes[].paths` actually belonged. - -### Precedence - -When both exist, **the nearest binding along the path wins**, and an explicit -`.ghost.bind.yml` at a given level takes precedence over directory-implied -binding at that level. Precedence is positional and deterministic; there is no -merge of competing bindings, no priority weights. (Holds the `reset.md` -no-cascade-fragility line.) - -## Resolution - -One resolver serves both roads, meeting at a surface id: - -```text -prompt → host matches the described menu → surface id ─┐ - ├─→ compose slice -path → nearest binding → surface id ─────────────────┘ -diff → each changed path → binding → surface id(s) → union of slices/checks -``` - -- **Prompt road (no repo):** unchanged from `coordinate-space.md`. No binding - involved. The contract resolves a surface from the described menu. -- **Path road (repo):** walk root→leaf, nearest binding names the surface, - compose its slice. Path is *evidence that resolves to a coordinate*, not a - coordinate itself. -- **Diff road (PR gate):** resolve each changed path to its surface, take the - union, run those surfaces' checks against the diff. A path with no binding - resolves to the root contract's `core` (the diff still gets brand-wide checks). - -When a path resolves to **no surface and there is no root contract**, the result -is the explicit "which surface?" menu, never a whole-tree dump — the same -brand-mixing cure as the prompt road. - -## Nesting is binding, not data-merge (Leak E, resolved) - -`coordinate-space.md` put `child-wins-by-id` union merge on the delete list. -This note is its final home. Nested `.ghost/` packages stop being a data-merge -mechanism (root facets union-merged into child facets by id) and become a -**binding mechanism**: a nested package binds surfaces to its subtree. Ownership -is positional and git/CODEOWNERS-shaped, not a silent field-level override. - -Consequences, all of them improvements: - -- A root edit can no longer silently break a leaf's resolved slice through merge. -- A child can no longer silently disable an inherited critical check via - `status: disabled` in a merge (`purposes.md` leak #3) — checks live on - surfaces in the contract, and the binding only points. -- "Don't nest just because files differ" (`purposes.md` leak #5) becomes - structural: you nest to bind a different surface, not to override data. - -## What this buys - -- The monorepo-root case stops being a contradiction: many bindings, one - contract, one coordinate space. Opening the repo at root and editing a - checkout file resolves to the checkout surface via its binding. -- The portable contract is genuinely shippable: no binding, no git assumptions, - works over npm or a resource id for outcomes 3 & 4. -- Path matching has exactly one home (the binding), so the contract, selection, - and governance never re-grow their own path matchers (the Leak B/C instinct). - -## Open forks (decide before code) - -1. **Contract reference resolution.** `contract: .` (in-repo, the common case) - is trivial. Cross-repo references (npm name, resource id) need a resolution - contract and version pinning. Recommendation: ship in-repo `contract: .` - first; defer external references to their own note. `ack` / `track` already - model stance toward a moving reference and may supply the versioning - machinery. -2. **Implicit vs. explicit root contract for `core` fallback.** When a path has - no binding, does it resolve to root `core`, or to "no surface → menu"? - Recommendation: if a root contract exists, unbound paths resolve to `core` - (brand-wide checks still apply); if none exists, return the menu. -3. **Does a scoped `.ghost/` redeclare surfaces, or only bind existing ones?** - Recommendation: a binding references surface ids that exist in the root - contract; it does not define new surfaces. One source of truth for the tree - (the schema note's flat-id, single-`parent` discipline extends here). - -## What stays stable - -Hold these contracts while binding is explored: `ghost.fingerprint/v1`, -`ghost.validate/v1`, `ghost.fingerprint-package/v1`, `ghost.check-report/v1`, -and the proposed `ghost.surfaces/v1`. `ghost.binding/v1` is new and additive; -absent any binding, the contract behaves exactly as the no-repo case. - -## A caution worth recording - -This is the **least proof-validated layer** of the redesign. The live -composition proof case resolves context from a prompt with no repo binding at -all — it exercises the contract and the prompt road, not the path or diff roads. -So the binding is designed from outcomes 1 & 2 reasoning, not yet from a running -proof. Treat its first implementation as a hypothesis to validate against a real -in-repo case, and prefer the smallest version (directory-default binding, -in-repo `contract: .`, defer external references) until a working tree exercises -it. - -## Not a plan - -This note settles the binding shape (directory-default, explicit escape hatch), -the resolution roads, and the home for Leak E. It writes no Zod, renames no -command, and moves no field today. Implementation — the `ghost.binding/v1` -loader, path→surface resolution, diff→surfaces for the PR gate — is the next -cut, and it sits behind the surface-schema implementation it depends on. - -## Read-back - -This note succeeds if: - -- The contract still carries no paths; the binding owns all path matching. -- The no-repo cases need no binding, and the repo cases add one without changing - the contract. -- Path, prompt, and diff all resolve to a surface id through one resolver. -- Nesting is reframed from data-merge to binding, retiring Leak E. -- The honest caution is recorded: this layer is real but the least validated by - the live proof case, so it ships smallest-first. diff --git a/docs/ideas/surface-schema.md b/docs/ideas/surface-schema.md deleted file mode 100644 index 3b78e0f9..00000000 --- a/docs/ideas/surface-schema.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -status: exploring ---- - -# Surface schema: the first extraction - -This note is subordinate to `fingerprint-first-architecture.md` (settled) and is -the first concrete cut named by `reset.md` and designed by `coordinate-space.md`. -It turns the prose shape in `coordinate-space.md` into a proposed schema, on -disk, with a migration path off `topology` / `applies_to` / `surface_type` / -`scope`. It proposes one new schema; it changes no code in this note. - -The design decisions are settled by `coordinate-space.md` and not reopened here: -a surface is an author-named group; grouping is placement not tags; containment -is a strict tree (Layer 2); composition is a typed reference graph over it -(Layer 3); resolution is BYOA. This note only asks: **what does that look like as -a file an author writes and the CLI validates?** - -## What must be expressed - -From `coordinate-space.md`, a surface needs: - -- **id / name** — author's slug-shaped label. -- **description** — optional, natural language, agent-draftable. -- **parent** — at most one (the containment tree, Layer 2). -- **slice** — the nodes placed in this surface (placement, not tags). -- **edges** — typed references to nodes in other surfaces (the composition - graph, Layer 3). - -The schema must hold both axes without conflating them: parent is containment, -edges are composition. - -## Where surfaces live on disk - -`coordinate-space.md` makes the coordinate space its own Layer 2 artifact, -distinct from the description facets. The on-disk choice should follow that: -**a new facet file, `surfaces.yml`, anchored by the existing `manifest.yml`.** - -```text -.ghost/ - manifest.yml # ghost.fingerprint-package/v1 (unchanged) - surfaces.yml # ghost.surfaces/v1 (new) — the coordinate space - intent.yml # ghost.intent/v1 — description content (unchanged) - inventory.yml # ghost.inventory/v1 — minus topology - composition.yml # ghost.composition/v1 — patterns minus applies_to - validate.yml # ghost.validate/v1 — checks minus applies_to -``` - -Rationale for a new file over extending an existing one: - -- It is a different layer; `purposes.md` says a new purpose gets its own home, - never a new field on a description facet. -- It keeps the description facets' content constant (the point-1 fix) — they - lose only their coordinate annotations. -- It is additive: a package with no `surfaces.yml` has a single implicit `core` - surface and behaves exactly as today. Migration is opt-in. - -New schema literal: `ghost.surfaces/v1`. (Sits alongside the existing facet -literals `ghost.intent/v1`, `ghost.inventory/v1`, `ghost.composition/v1`, -`ghost.validate/v1`.) - -## Proposed shape - -`surfaces.yml`: - -```yaml -schema: ghost.surfaces/v1 - -# Edge kinds are a fixed, Ghost-owned set (see "edge_kinds is closed" below). -# Not authored per-package; listed in the schema, not in surfaces.yml. - -surfaces: - # core is implicit and always present; declare it only to describe it. - - id: core - description: True everywhere. Brand-wide intent, inventory, and patterns. - - - id: email - description: Transactional and lifecycle email. - parent: core - - - id: email-marketing - description: Promotional email; campaign voice and offer framing. - parent: email # the tree lives here, not in the id - - - id: checkout - description: The purchase decision surface. - parent: core - # Composition graph: a typed edge to a peer surface, not a parent. - edges: - - kind: composes - to: payments - - kind: governed-by - to: consent -``` - -Field rules: - -- `id` — a flat, unique, slug-shaped label with **no structural meaning**. Dots - are not allowed as hierarchy: the tree lives only in `parent`, never in the - id. `email-marketing` is a name; `email.marketing` is banned because the dot - would pretend to be a `parent` link. One source of truth for the tree. -- `parent` — optional; absent means a top-level surface under the implicit - `core` root. Exactly one parent (strict tree; no arrays). **This is the only - place containment is expressed.** -- `description` — optional string. Present when the name is not self-evident. -- `edges` — optional; each has `kind` (must be one of the Ghost-owned - `edge_kinds`) and `to` (an existing surface id). Edges are the composition - graph; they never imply containment and never cascade. - -`edge_kinds` are **not** declared per-package. They are a fixed, Ghost-owned set -(see below), so `surfaces.yml` references kinds but never defines them. - -## `edge_kinds` is closed (settled) - -The edge vocabulary is a **closed, Ghost-owned set**, not author-extensible. -This was an open fork; it is now decided, because opening it is the exact thing -that loses the plot. - -An open vocabulary means the *author* defines what an edge means, which means -Ghost has no opinion about edges, which means Ghost is a general-purpose graph -database. That is unbounded scope — the sprawl the reset exists to end. Closing -the set forces Ghost to commit to what edges are *for*, and for a fingerprint- -first, interface-composition tool the answer is small: edges express how -interface surfaces relate. A starting set: - -- `composes` — this surface assembles the referenced surface into its output. -- `governed-by` — this surface must satisfy the referenced surface's - obligations. - -The discipline rule that comes with closing it: - -> If you cannot name an edge kind from the interface-composition domain, it does -> not belong in Ghost. The temptation to add a non-interface edge kind is the -> signal that the work has drifted toward a general world-model graph — which is -> a consumer's job, not Ghost's. - -**The boundary for richer consumers.** A composition-heavy consumer (a typed -unit graph with many relationship kinds) will legitimately want edge kinds Ghost -does not ship. That is expected: such inputs are domain-shaped, Ghost is -interface-shaped. The resolution is that the consumer extends edges *in the -consumer*, not by opening Ghost's set. Ghost's closed set is the interface -vocabulary; anything beyond it is consumer-local extension. This keeps Ghost -small and keeps the consumer free. - -## How nodes attach to surfaces (placement, not tags) - -`coordinate-space.md` says a node's surface is *where it is stored*. Two on-disk -options; this note recommends the first and flags the second as the larger -follow-on: - -1. **Per-surface placement field, minimal change (recommended first cut).** - Description nodes keep living in `intent.yml` / `inventory.yml` / - `composition.yml`, but their coordinate annotations (`applies_to`, - `surface_type`, `scope`) are removed and replaced by a single `surface: ` - placement key. Placement is explicit (see "Placement is explicit" below); an - absent `surface:` is not silently treated as global. This is the smallest - honest step: it deletes the smeared DAG and replaces it with one placement - pointer, without restructuring the facet files. - -2. **Storage-by-location, full model (follow-on note).** Nodes physically live - under the surface they belong to (nested facet files per surface). Truest to - "placement is location," but it restructures the package layout and should be - its own proposal. Do not attempt it in the first cut. - -The first cut keeps the flat facet files and changes annotations to a placement -pointer. That is enough to kill Leak A and validate the tree + graph model -before committing to a layout change. - -## Placement is explicit (settled) - -This was an open fork ("absent `surface:` defaults to `core`"); it is now -decided against the silent default. Defaulting un-placed nodes to `core` quietly -rebuilds global-fallback — a node that reaches every surface is exactly the -brand-mixing failure `coordinate-space.md` exists to cure. So placement is -explicit, made frictionless from three directions rather than enforced by nag: - -1. **Authoring drafts placement.** When a Ghost authoring skill captures a node, - it proposes a `surface:` from what is being captured. The author approves - rather than hand-writes, so most nodes are placed at birth. -2. **Lint catches and teaches.** An un-placed node is flagged — not silently - dropped into `core` — with a message that explains *why* placement matters - (un-placed reaches everywhere; that is brand-mixing) and what to do. Teaching, - not just erroring. -3. **No silent global default.** "Absent = core, move on" is explicitly not the - behavior. - -The un-placed lint is a **warning, not a hard error**, matching the existing -convention that missing derivation refs warn so teams can draft while curation -catches up. A hand-authored fingerprint can have un-placed nodes in draft; lint -surfaces them and teaches the fix, and they never reach production silently -global. - -## The migration (off the dead coordinate systems) - -What `coordinate-space.md` deletes, expressed as concrete field moves: - -| Today (delete) | Becomes | -| --- | --- | -| `inventory.topology.scopes[]` | `surfaces.yml` surfaces with `parent` links | -| `inventory.topology.surface_types[]` | folded into surfaces (a surface *is* the type) | -| `exemplar.surface_type` / `exemplar.scope` | `exemplar.surface: ` placement | -| `principle.applies_to` / `pattern.applies_to` / `contract.applies_to` | node `surface: ` placement + cascade | -| `check.applies_to` | check `surface: ` placement | -| `ghost.map/v1` (`map.md`, `ghost-core/map/`) | `ghost.surfaces/v1` | - -Worked example, from this repo's own dogfood `.ghost/inventory.yml`: - -```yaml -# before — topology + smeared coordinates -topology: - scopes: - - id: docs-site - paths: [docs, README.md, apps/docs] - surface_types: [docs-home, docs-foundation, tool-doc] -exemplars: - - id: public-readme-fingerprint-model - surface_type: docs-home - scope: docs-site -``` - -```yaml -# after — surfaces.yml owns the tree; exemplar just places itself -# surfaces.yml -surfaces: - - id: docs - description: The docs site and public README. - parent: core -exemplars: # in inventory.yml, coordinates gone - - id: public-readme-fingerprint-model - surface: docs -``` - -Note `paths` disappeared from the surface in the no-repo model — path is -*evidence the host maps to a surface*, not part of the surface definition -(`coordinate-space.md`: medium-agnostic, designed from the no-repo case). Path → -surface mapping is a binding/governance concern (Layer 3/4), proposed separately; -the surface itself does not carry repo paths. - -## Validation (lint obligations, not code) - -`ghost lint` on `ghost.surfaces/v1` should enforce: - -- every `parent` references an existing surface id; no cycles (it is a tree); -- exactly one parent per surface (no parent arrays); -- every surface `id` is a flat slug with no dots (dots-as-hierarchy is a lint - error; the tree lives only in `parent`); -- every edge `kind` is one of the fixed Ghost-owned `edge_kinds`; every edge `to` - is an existing surface; -- every node `surface:` placement references an existing surface (warn on - near-miss ids, per `purposes.md` leak #4); -- an un-placed node warns (not errors) and teaches placement — it is never - silently treated as global `core`; -- `core` is reserved as the implicit root. - -Composition edges are explicitly allowed to form a graph (including cross-links); -only `parent` is constrained to a tree. This is the two-axis rule made into lint. - -## What stays stable - -Per `coordinate-space.md` and `reset.md`, hold these contracts while extracting: -`ghost.fingerprint/v1`, `ghost.validate/v1`, `ghost.fingerprint-package/v1`, -`ghost.check-report/v1`. `ghost.surfaces/v1` is new and additive; absent -`surfaces.yml` keeps today's single-`core` behavior. - -Layer 1 content (intent / inventory / composition prose) does not change. Only -coordinate annotations move to a `surface:` placement pointer. - -## Settled in this note - -- **`edge_kinds` is closed and Ghost-owned.** See "`edge_kinds` is closed" - above. Authors reference kinds; they never define them. Richer consumers - extend edges consumer-side, not by opening Ghost's set. -- **IDs are flat; the tree lives only in `parent`.** Dotted ids as hierarchy are - banned (a lint error). One source of truth for containment, killing the Leak C - duplicate-vocabulary risk before it starts. -- **Placement is explicit; no silent global default.** See "Placement is - explicit" above. Authoring drafts placement, lint warns-and-teaches on the - gap, and un-placed never silently means `core`. - -## The repo binding is scoped ownership (reframed) - -An earlier draft called this "where path→surface mapping lives," which made it -sound like an exotic new subsystem. It is not. In a repo, **surfaces are owned -by location** — the `checkout` surface *is* the thing realized under -`apps/checkout/`. That is ordinary scoped ownership, CODEOWNERS- and -directory-shaped, and Ghost already has the mechanism: nested packages. - -The contract/binding split makes this clean: - -- **The portable contract** (`surfaces.yml`) is repo-agnostic and carries **no - paths**. This is what lets the no-repo cases (outcomes 3 & 4) work at all. -- **The repo binding** declares scoped ownership: *this surface is realized by - this scope here.* Path → surface resolution falls out of where the binding - sits in the tree, the way nested-package resolution already works. - -The hard rule that keeps the split honest: **the surface definition must never -carry `paths`.** The moment `surfaces.yml` gains a `paths:` field, the portable -contract is re-coupled to a repo and the no-repo cases break. Paths live on the -binding, never on the surface. This is exactly why `topology.scopes[].paths` is -on the delete list. - -This is a separate note, but it is the *familiar* part (nesting, ownership), not -a new abstraction. The one real sub-fork it must settle: - -> Does scoped ownership live in **nested `.ghost/` packages** (directory -> location = the binding), in an **explicit binding declaration** that names a -> surface and its paths, or **both**? Lean: directory location is the default -> binding; an explicit declaration is the escape hatch when ownership does not -> match the tree. - -## Open forks (decide before code) - -1. **Scoped-ownership binding shape.** Nested-package-as-binding vs. explicit - path declaration vs. both. Its own note (see "The repo binding is scoped - ownership" above); `surfaces.yml` stays repo-agnostic regardless. - -## Not a plan - -This note proposes `ghost.surfaces/v1`, its on-disk home, the placement-pointer -migration, and the lint obligations. It writes no Zod, renames no command, and -moves no field today. Implementation — the schema module, the lint rules, the -`surfaces.yml` loader, the migration of this repo's own `.ghost/` — is the next -cut, proposed in its own note and linked back here. - -## Read-back - -This note succeeds if: - -- A surface is one file (`surfaces.yml`) an author can write and a human can - review in Git. -- The schema expresses both axes: `parent` (containment tree) and `edges` - (typed composition graph), without conflating them. -- The migration off `topology` / `applies_to` / `surface_type` / `scope` is a - concrete field-by-field move, not a rewrite. -- The first cut keeps flat facet files and changes annotations to a `surface:` - pointer — small enough to ship and prove before any layout change. -- Nothing in the stable contracts has to change to add `ghost.surfaces/v1`. From 11fd1b7b99c1f0d9c2e5ac859f7504caa0369e07 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:43:08 -0400 Subject: [PATCH 6/7] refactor: split inventory monolith, group commands/, honest file-size limits Architecture hygiene pass: - Split scan/inventory.ts (1082 lines, one giant function-bag) into a focused scan/inventory/ module: constants, paths, walk, manifests, hints, git, and an index.ts orchestrator. Each concern is now independently readable/testable; largest piece is 243 lines. - Group the 8 root-level command + review-packet files under src/commands/, so the layering reads commands/ (CLI) -> scan/ (I/O) -> ghost-core/ (model). review-packet (domain-ish, the odd one out at root) now lives with its review command. Fixed skill-command's bundle-path URL for the new depth. - Delete all three file-size EXCEPTIONS: two were stale lies (cli.ts granted 580 for 161 actual; fingerprint-commands 1135 for 178) and inventory no longer needs one. Every file now passes the honest 500-line default. All green: 109 tests, full check, zero file-size exceptions. --- apps/docs/src/generated/cli-manifest.json | 2 +- packages/ghost/src/cli.ts | 16 +- .../src/{ => commands}/checks-command.ts | 6 +- .../src/{ => commands}/command-discovery.ts | 0 .../{ => commands}/fingerprint-commands.ts | 6 +- .../src/{ => commands}/gather-command.ts | 4 +- .../ghost/src/{ => commands}/init-command.ts | 4 +- .../src/{ => commands}/migrate-command.ts | 4 +- .../ghost/src/{ => commands}/review-packet.ts | 4 +- .../ghost/src/{ => commands}/skill-command.ts | 3 +- packages/ghost/src/scan/index.ts | 2 +- packages/ghost/src/scan/inventory.ts | 1082 ----------------- .../ghost/src/scan/inventory/constants.ts | 243 ++++ packages/ghost/src/scan/inventory/git.ts | 82 ++ packages/ghost/src/scan/inventory/hints.ts | 147 +++ packages/ghost/src/scan/inventory/index.ts | 55 + .../ghost/src/scan/inventory/manifests.ts | 181 +++ packages/ghost/src/scan/inventory/paths.ts | 48 + packages/ghost/src/scan/inventory/walk.ts | 155 +++ .../ghost/test/terminology-public.test.ts | 7 +- scripts/check-file-sizes.mjs | 18 +- 21 files changed, 943 insertions(+), 1126 deletions(-) rename packages/ghost/src/{ => commands}/checks-command.ts (96%) rename packages/ghost/src/{ => commands}/command-discovery.ts (100%) rename packages/ghost/src/{ => commands}/fingerprint-commands.ts (96%) rename packages/ghost/src/{ => commands}/gather-command.ts (97%) rename packages/ghost/src/{ => commands}/init-command.ts (94%) rename packages/ghost/src/{ => commands}/migrate-command.ts (98%) rename packages/ghost/src/{ => commands}/review-packet.ts (98%) rename packages/ghost/src/{ => commands}/skill-command.ts (96%) delete mode 100644 packages/ghost/src/scan/inventory.ts create mode 100644 packages/ghost/src/scan/inventory/constants.ts create mode 100644 packages/ghost/src/scan/inventory/git.ts create mode 100644 packages/ghost/src/scan/inventory/hints.ts create mode 100644 packages/ghost/src/scan/inventory/index.ts create mode 100644 packages/ghost/src/scan/inventory/manifests.ts create mode 100644 packages/ghost/src/scan/inventory/paths.ts create mode 100644 packages/ghost/src/scan/inventory/walk.ts diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index c1c7f3f8..3bc070d8 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T13:24:33.939Z", + "generatedAt": "2026-06-28T13:42:06.396Z", "tools": [ { "tool": "ghost", diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index d2569028..957239e3 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -5,20 +5,20 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { cac } from "cac"; -import { registerChecksCommand } from "./checks-command.js"; -import { formatGhostHelp } from "./command-discovery.js"; -import { registerFingerprintCommands } from "./fingerprint-commands.js"; -import { registerGatherCommand } from "./gather-command.js"; -import { registerMigrateCommand } from "./migrate-command.js"; +import { registerChecksCommand } from "./commands/checks-command.js"; +import { formatGhostHelp } from "./commands/command-discovery.js"; +import { registerFingerprintCommands } from "./commands/fingerprint-commands.js"; +import { registerGatherCommand } from "./commands/gather-command.js"; +import { registerMigrateCommand } from "./commands/migrate-command.js"; import { buildReviewPacket, formatReviewPacketMarkdown, -} from "./review-packet.js"; -import { registerSkillCommand } from "./skill-command.js"; +} from "./commands/review-packet.js"; +import { registerSkillCommand } from "./commands/skill-command.js"; const execFileAsync = promisify(execFile); -export { getCommandDiscoveryMetadata } from "./command-discovery.js"; +export { getCommandDiscoveryMetadata } from "./commands/command-discovery.js"; export function buildCli(): ReturnType { const cli = cac("ghost"); diff --git a/packages/ghost/src/checks-command.ts b/packages/ghost/src/commands/checks-command.ts similarity index 96% rename from packages/ghost/src/checks-command.ts rename to packages/ghost/src/commands/checks-command.ts index afed5457..487e38cc 100644 --- a/packages/ghost/src/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -5,9 +5,9 @@ import { resolveGraphSlice, selectChecksForSurfaces, } from "#ghost-core"; -import { resolveFingerprintPackage } from "./fingerprint.js"; -import { loadChecksDir } from "./scan/checks-dir.js"; -import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; +import { resolveFingerprintPackage } from "../fingerprint.js"; +import { loadChecksDir } from "../scan/checks-dir.js"; +import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; function parseSurfaceIds(value: unknown): string[] { const raw = Array.isArray(value) ? value : value === undefined ? [] : [value]; diff --git a/packages/ghost/src/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts similarity index 100% rename from packages/ghost/src/command-discovery.ts rename to packages/ghost/src/commands/command-discovery.ts diff --git a/packages/ghost/src/fingerprint-commands.ts b/packages/ghost/src/commands/fingerprint-commands.ts similarity index 96% rename from packages/ghost/src/fingerprint-commands.ts rename to packages/ghost/src/commands/fingerprint-commands.ts index 56a3d589..c9b9e54d 100644 --- a/packages/ghost/src/fingerprint-commands.ts +++ b/packages/ghost/src/commands/fingerprint-commands.ts @@ -5,10 +5,10 @@ import { type LintReport, lintFingerprintPackage, resolveFingerprintPackage, -} from "./fingerprint.js"; +} from "../fingerprint.js"; +import { detectFileKind, lintDetectedFileKind } from "../scan/file-kind.js"; +import { resolveGhostDirDefault, scanStatus, signals } from "../scan/index.js"; import { registerInitCommand } from "./init-command.js"; -import { detectFileKind, lintDetectedFileKind } from "./scan/file-kind.js"; -import { resolveGhostDirDefault, scanStatus, signals } from "./scan/index.js"; /** * Register fingerprint package commands on the unified Ghost CLI. diff --git a/packages/ghost/src/gather-command.ts b/packages/ghost/src/commands/gather-command.ts similarity index 97% rename from packages/ghost/src/gather-command.ts rename to packages/ghost/src/commands/gather-command.ts index db2e2dc0..38efbeab 100644 --- a/packages/ghost/src/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -7,8 +7,8 @@ import { type GraphSliceProvenance, resolveGraphSlice, } from "#ghost-core"; -import { resolveFingerprintPackage } from "./fingerprint.js"; -import { loadFingerprintPackage } from "./scan/fingerprint-package.js"; +import { resolveFingerprintPackage } from "../fingerprint.js"; +import { loadFingerprintPackage } from "../scan/fingerprint-package.js"; export function registerGatherCommand(cli: CAC): void { cli diff --git a/packages/ghost/src/init-command.ts b/packages/ghost/src/commands/init-command.ts similarity index 94% rename from packages/ghost/src/init-command.ts rename to packages/ghost/src/commands/init-command.ts index f2d4a9ea..7b942483 100644 --- a/packages/ghost/src/init-command.ts +++ b/packages/ghost/src/commands/init-command.ts @@ -1,6 +1,6 @@ import type { CAC } from "cac"; -import { initFingerprintPackage } from "./fingerprint.js"; -import { resolveGhostDirDefault } from "./scan/index.js"; +import { initFingerprintPackage } from "../fingerprint.js"; +import { resolveGhostDirDefault } from "../scan/index.js"; export function registerInitCommand(cli: CAC): void { cli diff --git a/packages/ghost/src/migrate-command.ts b/packages/ghost/src/commands/migrate-command.ts similarity index 98% rename from packages/ghost/src/migrate-command.ts rename to packages/ghost/src/commands/migrate-command.ts index 39c472d4..d761cd0e 100644 --- a/packages/ghost/src/migrate-command.ts +++ b/packages/ghost/src/commands/migrate-command.ts @@ -2,14 +2,14 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { CAC } from "cac"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; -import { resolveFingerprintPackage } from "./fingerprint.js"; +import { resolveFingerprintPackage } from "../fingerprint.js"; import { looksLegacy, type MigrationNote, type MigrationResult, migratedNodeFiles, migrateLegacyPackage, -} from "./scan/index.js"; +} from "../scan/index.js"; export function registerMigrateCommand(cli: CAC): void { cli diff --git a/packages/ghost/src/review-packet.ts b/packages/ghost/src/commands/review-packet.ts similarity index 98% rename from packages/ghost/src/review-packet.ts rename to packages/ghost/src/commands/review-packet.ts index 470600da..80a9fab4 100644 --- a/packages/ghost/src/review-packet.ts +++ b/packages/ghost/src/commands/review-packet.ts @@ -4,11 +4,11 @@ import { resolveGraphSlice, selectChecksForSurfaces, } from "#ghost-core"; -import { loadChecksDir } from "./scan/checks-dir.js"; +import { loadChecksDir } from "../scan/checks-dir.js"; import { loadFingerprintPackage, resolveFingerprintPackage, -} from "./scan/fingerprint-package.js"; +} from "../scan/fingerprint-package.js"; const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000; diff --git a/packages/ghost/src/skill-command.ts b/packages/ghost/src/commands/skill-command.ts similarity index 96% rename from packages/ghost/src/skill-command.ts rename to packages/ghost/src/commands/skill-command.ts index c4f5bb61..9ac9c30f 100644 --- a/packages/ghost/src/skill-command.ts +++ b/packages/ghost/src/commands/skill-command.ts @@ -6,8 +6,9 @@ import { fileURLToPath } from "node:url"; import type { CAC } from "cac"; import { loadSkillBundle } from "#ghost-core"; +// The bundle assets are copied to `dist/skill-bundle` (sibling of `commands/`). const SKILL_BUNDLE_ROOT = fileURLToPath( - new URL("./skill-bundle", import.meta.url), + new URL("../skill-bundle", import.meta.url), ); const SUPPORTED_AGENTS = ["claude", "cursor", "codex", "opencode"] as const; diff --git a/packages/ghost/src/scan/index.ts b/packages/ghost/src/scan/index.ts index 5adc7dd8..db6b1812 100644 --- a/packages/ghost/src/scan/index.ts +++ b/packages/ghost/src/scan/index.ts @@ -9,7 +9,7 @@ export type { ScanContributionState, ScanSurfaceCoverage, } from "./fingerprint-contribution.js"; -export { signals } from "./inventory.js"; +export { signals } from "./inventory/index.js"; export type { LegacyPackageInput, MigratedNodeFile, diff --git a/packages/ghost/src/scan/inventory.ts b/packages/ghost/src/scan/inventory.ts deleted file mode 100644 index 05cb6744..00000000 --- a/packages/ghost/src/scan/inventory.ts +++ /dev/null @@ -1,1082 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { type Dirent, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative, resolve, sep } from "node:path"; -import type { - GitInfo, - InventoryOutput, - LanguageHistogramEntry, - TopLevelEntry, -} from "#ghost-core"; - -/** - * Canonical package manifests we scan for at the inventoried root. - * - * These are matched against immediate children of the root only — nested - * manifests live in `language_histogram` / `top_level_tree` instead. - * - * The list aims to be OSS-generalizable across the major language - * ecosystems an agent might encounter. Organization-specific manifests - * (kochiku.yml, .sqiosbuild.json, …) are deliberately omitted. - */ -const PACKAGE_MANIFEST_NAMES = [ - // Node / web - "package.json", - // Rust - "Cargo.toml", - // Python - "pyproject.toml", - "Pipfile", - "setup.py", - // Go - "go.mod", - // Swift / iOS - "Package.swift", - "Package.resolved", - // Flutter / Dart - "pubspec.yaml", - // JVM — Maven - "pom.xml", - // JVM — Gradle - "settings.gradle", - "settings.gradle.kts", - "build.gradle", - "build.gradle.kts", - // JVM — Bazel - "WORKSPACE", - "WORKSPACE.bazel", - "MODULE.bazel", - "BUILD.bazel", - ".bazelversion", - // Ruby - "Gemfile", - "Gemfile.lock", - // Elixir - "mix.exs", - // PHP - "composer.json", -] as const; - -/** - * Regex matchers for less-stable manifest names. Files matching at the root - * are added to `package_manifests`. - */ -const PACKAGE_MANIFEST_PATTERNS: RegExp[] = [ - /\.podspec$/, // CocoaPods (iOS) - /\.gemspec$/, // RubyGems -]; - -/** - * Config files we look for anywhere under the root (depth-limited). - * - * These are weak signals — the host agent reads them to confirm what a repo - * actually is. We collect them so the recipe doesn't need to re-scan. - */ -const CONFIG_FILE_EXACT = new Set([ - "tsconfig.json", - "tokens.css", - "tokens.json", - "colors.xml", - "themes.xml", - "Theme.kt", - "Color.kt", - "Theme.swift", - "registry.json", -]); - -/** Patterns matched against the basename of any file under root. */ -const CONFIG_FILE_PATTERNS: RegExp[] = [ - /^tailwind\.config\.[cm]?[jt]sx?$/, - /^vite\.config\.[cm]?[jt]sx?$/, - /^next\.config\.[cm]?[jt]sx?$/, - /^Color\+.+\.swift$/, - // Style Dictionary token-pipeline config — JS/TS/JSON variants seen in - // real repos. Matched anywhere because monorepos may stash it under - // `tokens/`, `packages/tokens/`, etc. - /^style-dictionary\.config\.[cm]?[jt]sx?$/, - /^style-dictionary\.config\.json$/, - // Other JS bundler configs — surfaced so the recipe can confirm a - // build system without re-globbing. - /^webpack\.config\.[cm]?[jt]sx?$/, - /^rollup\.config\.[cm]?[jt]sx?$/, - /^parcel\.config\.[cm]?[jt]sx?$/, - /^esbuild\.config\.[cm]?[jt]sx?$/, -]; - -/** Basenames that indicate a Style Dictionary token pipeline lives nearby. */ -const STYLE_DICTIONARY_FILES = new Set([ - "style-dictionary.config.js", - "style-dictionary.config.cjs", - "style-dictionary.config.mjs", - "style-dictionary.config.ts", - "style-dictionary.config.json", -]); - -/** - * Build-tool config-file matchers. Each entry maps the tool's hint - * value (drawn from the `build_system` enum) to the basename matcher - * that signals its presence. - * - * The detection is intentionally cheap — config file basename only. - * `package.json:devDependencies` could give finer signal but is the - * recipe's job, not the inventory's. - */ -interface BuildToolMatcher { - hint: string; - matches: (basename: string) => boolean; -} - -const BUILD_TOOL_MATCHERS: BuildToolMatcher[] = [ - // JS bundlers - { - hint: "vite", - matches: (n) => /^vite\.config\.[cm]?[jt]sx?$/.test(n), - }, - { - hint: "webpack", - matches: (n) => /^webpack\.config\.[cm]?[jt]sx?$/.test(n), - }, - { - hint: "rollup", - matches: (n) => /^rollup\.config\.[cm]?[jt]sx?$/.test(n), - }, - { - hint: "parcel", - matches: (n) => /^parcel\.config\.[cm]?[jt]sx?$/.test(n), - }, - { - hint: "esbuild", - matches: (n) => /^esbuild\.config\.[cm]?[jt]sx?$/.test(n), - }, - // Meta-build coordinators - { hint: "nx", matches: (n) => n === "nx.json" }, - { hint: "turbo", matches: (n) => n === "turbo.json" }, -]; - -/** Language-name lookup keyed by lowercase extension (no leading dot). */ -const EXTENSION_TO_LANGUAGE: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - py: "python", - rb: "ruby", - go: "go", - rs: "rust", - java: "java", - kt: "kotlin", - kts: "kotlin", - swift: "swift", - m: "objective-c", - mm: "objective-c", - c: "c", - h: "c", - cpp: "cpp", - cc: "cpp", - hpp: "cpp", - hh: "cpp", - cs: "csharp", - dart: "dart", - scala: "scala", - php: "php", - vue: "vue", - svelte: "svelte", - css: "css", - scss: "scss", - sass: "sass", - less: "less", - html: "html", - xml: "xml", - json: "json", - yaml: "yaml", - yml: "yaml", - toml: "toml", - md: "markdown", - mdx: "markdown", - sh: "shell", - bash: "shell", - zsh: "shell", -}; - -/** - * Directories we don't walk into when computing histograms / configs. - * - * Universal build/cache patterns only — anything organization-specific - * stays out. Bazel symlink directories (`bazel-bin`, `bazel-out`, - * `bazel-testlogs`, `bazel-`) are skipped via the prefix - * check in `walkTree`. - */ -const SKIP_DIRS = new Set([ - // VCS - ".git", - // JS/TS package managers + framework caches - "node_modules", - ".pnpm", - ".yarn", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - // JVM - ".gradle", - // IDE / tooling - ".idea", - ".vscode", - ".fleet", - // Universal output / build directories - "build", - "dist", - "out", - "target", // Rust, Maven, Scala - "coverage", - // Apple - "Pods", - "DerivedData", - ".cxx", - // Python - "__pycache__", - ".pytest_cache", - ".mypy_cache", - ".tox", - "venv", - ".venv", - // Go modules / Composer / generic vendor - "vendor", - // C# / .NET - "bin", - "obj", -]); - -/** - * Directory-name prefixes that indicate a build/output tree. - * - * Distinct from `SKIP_DIRS` because they're not exact names. Notably: - * - Bazel emits `bazel-bin`, `bazel-out`, `bazel-testlogs`, - * `bazel-` symlinks at the workspace root. - * - Frameworks like Astro emit `dist-*` siblings. - */ -const SKIP_DIR_PREFIXES: readonly string[] = ["bazel-", "dist-"]; - -/** - * Directory basenames that signal a token / theme pipeline lives there. - * Matched anywhere in the walked tree (not just at root). Generalizable - * across web, native, and design-token-pipeline repos. - */ -const TOKEN_DIR_BASENAMES = new Set([ - "tokens", - "design-tokens", - "design_tokens", - "theme", - "themes", -]); - -/** - * Path segments that indicate a file lives under a design-system tree. - * Used to score `candidate_config_files` so DS-ancestor matches surface - * before incidental hits in feature folders. - * - * Lowercase comparison — segments are normalized before matching. - */ -const DS_ANCESTOR_SEGMENTS: ReadonlySet = new Set([ - "design-system", - "design_system", - "designsystem", - "tokens", - "design-tokens", - "design_tokens", - "theme", - "themes", - "styles", -]); - -/** Cap how many files we keep in the histogram output. */ -const HISTOGRAM_TOP_N = 20; - -/** - * Run a deterministic inventory pass over the given path. - * - * No LLM calls, no network, no filesystem mutations. Pure reads plus a - * best-effort git invocation. - */ -export function signals(path: string): InventoryOutput { - const root = resolve(path); - - const packageManifests = collectAllManifests(root); - - const walkResult = walkTree(root); - const languageHistogram = topLanguages(walkResult.languageCounts); - const candidateConfigFiles = orderConfigCandidates( - walkResult.configFiles, - root, - ); - const registryFiles = sortRelative(walkResult.registryFiles, root); - const topLevelTree = readTopLevel(root); - const git = readGit(root); - - // Token directories surface as additional config candidates so the - // recipe can find directory-shaped token graphs without a separate scan. - for (const tokenDir of orderConfigCandidates(walkResult.tokenDirs, root)) { - const withSlash = tokenDir.endsWith("/") ? tokenDir : `${tokenDir}/`; - if (!candidateConfigFiles.includes(withSlash)) { - candidateConfigFiles.push(withSlash); - } - } - - const platformHints = derivePlatformHints( - packageManifests, - languageHistogram, - walkResult, - ); - const buildSystemHints = deriveBuildSystemHints(packageManifests, walkResult); - - return { - root, - platform_hints: platformHints, - build_system_hints: buildSystemHints, - language_histogram: languageHistogram, - package_manifests: packageManifests, - candidate_config_files: candidateConfigFiles, - registry_files: registryFiles, - top_level_tree: topLevelTree, - git_remote: git.remote, - git_default_branch: git.default_branch, - }; -} - -interface WalkResult { - languageCounts: Map; - configFiles: string[]; - registryFiles: string[]; - /** Directories whose basename matched a token-pipeline pattern. */ - tokenDirs: string[]; - /** True if any `AndroidManifest.xml` was found under the root. */ - hasAndroidManifest: boolean; - /** True if any `*.xcodeproj/project.pbxproj` was found under the root. */ - hasXcodeProject: boolean; - /** True if any `style-dictionary.config.*` was found under the root. */ - hasStyleDictionary: boolean; - /** - * Set of build-tool hints inferred from config-file presence (vite, - * webpack, rollup, parcel, esbuild, nx, turbo). Drawn from the - * `build_system` enum so the recipe can pass these through verbatim. - */ - buildToolHints: Set; -} - -function walkTree(root: string): WalkResult { - const languageCounts = new Map(); - const configFiles: string[] = []; - const registryFiles: string[] = []; - const tokenDirs: string[] = []; - const buildToolHints = new Set(); - let hasAndroidManifest = false; - let hasXcodeProject = false; - let hasStyleDictionary = false; - - const stack: string[] = [root]; - while (stack.length > 0) { - const dir = stack.pop(); - if (!dir) continue; - - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isSymbolicLink()) continue; - if (entry.isDirectory()) { - if (shouldSkipDir(entry.name)) continue; - if (entry.name.startsWith(".") && entry.name !== ".") continue; - // Token-pipeline directories — record then continue walking so any - // tokens.json / colors.json inside still flows through the file - // matchers below. - if (TOKEN_DIR_BASENAMES.has(entry.name.toLowerCase())) { - tokenDirs.push(full); - } - // *.xcodeproj/project.pbxproj — recognize as an iOS project. - if (entry.name.endsWith(".xcodeproj")) { - try { - statSync(join(full, "project.pbxproj")); - hasXcodeProject = true; - } catch { - // not a real Xcode project — keep walking - } - } - stack.push(full); - continue; - } - if (!entry.isFile()) continue; - - // Language histogram - const ext = extOf(entry.name); - if (ext) { - const lang = EXTENSION_TO_LANGUAGE[ext]; - if (lang) { - languageCounts.set(lang, (languageCounts.get(lang) ?? 0) + 1); - } - } - - // Candidate config files - if (matchesConfig(entry.name)) { - configFiles.push(full); - } - if (entry.name === "registry.json") { - registryFiles.push(full); - } - if (entry.name === "AndroidManifest.xml") { - hasAndroidManifest = true; - } - if (STYLE_DICTIONARY_FILES.has(entry.name)) { - hasStyleDictionary = true; - } - // Build-tool config-file matchers (vite, webpack, …, nx, turbo). - for (const matcher of BUILD_TOOL_MATCHERS) { - if (matcher.matches(entry.name)) { - buildToolHints.add(matcher.hint); - } - } - } - } - - return { - languageCounts, - configFiles, - registryFiles, - tokenDirs, - buildToolHints, - hasAndroidManifest, - hasXcodeProject, - hasStyleDictionary, - }; -} - -function shouldSkipDir(name: string): boolean { - if (SKIP_DIRS.has(name)) return true; - for (const prefix of SKIP_DIR_PREFIXES) { - if (name.startsWith(prefix)) return true; - } - return false; -} - -function matchesConfig(name: string): boolean { - if (CONFIG_FILE_EXACT.has(name)) return true; - for (const pattern of CONFIG_FILE_PATTERNS) { - if (pattern.test(name)) return true; - } - return false; -} - -function extOf(name: string): string | null { - const dot = name.lastIndexOf("."); - if (dot <= 0 || dot === name.length - 1) return null; - return name.slice(dot + 1).toLowerCase(); -} - -function topLanguages(counts: Map): LanguageHistogramEntry[] { - return [...counts.entries()] - .map(([name, files]) => ({ name, files })) - .sort((a, b) => { - if (b.files !== a.files) return b.files - a.files; - return a.name.localeCompare(b.name); - }) - .slice(0, HISTOGRAM_TOP_N); -} - -/** - * Conventional one-level workspace directories scanned in addition to the - * root. Real monorepos place per-app / per-package manifests here; the - * inventory surfaces them so the recipe can see the full workspace shape - * without re-walking the tree. - */ -const CONVENTIONAL_WORKSPACE_DIRS = [ - "apps", - "packages", - "libs", - "common", -] as const; - -/** - * Collect package manifests from the root plus any workspace directories - * we can identify (via `package.json:workspaces` and the conventional - * `apps/`, `packages/`, `libs/`, `common/` layout). - * - * - Root manifests are returned by basename (`package.json`). - * - Nested manifests are returned as POSIX-style relative paths - * (`packages/foo/package.json`) so they're distinguishable. - * - Results are deduped by absolute path, sorted lexicographically. - * - The walk does NOT recurse beyond one level under each scanned - * workspace dir — we're surfacing the obvious shape, not crawling. - */ -function collectAllManifests(root: string): string[] { - const seenAbs = new Set(); - const out: string[] = []; - - // Root manifests first — basename-only for backcompat with existing - // callers and tests. - for (const name of collectManifestBasenames(root)) { - const abs = join(root, name); - if (seenAbs.has(abs)) continue; - seenAbs.add(abs); - out.push(name); - } - - // Workspace directories — both `package.json:workspaces` and the - // conventional `apps/`, `packages/`, `libs/`, `common/` layout. - const workspaceDirs = expandWorkspaceDirs(root); - for (const dir of workspaceDirs) { - const absDir = resolve(root, dir); - // Stay inside `root` — defensive against pathological globs. - if (!isInsideRoot(absDir, root)) continue; - if (shouldSkipDir(basenameOf(absDir))) continue; - let entries: Dirent[]; - try { - entries = readdirSync(absDir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!isManifestName(entry.name)) continue; - const abs = join(absDir, entry.name); - if (seenAbs.has(abs)) continue; - seenAbs.add(abs); - out.push(toPosixRel(root, abs)); - } - } - - return out.sort(); -} - -function collectManifestBasenames(dir: string): string[] { - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - const found: string[] = []; - for (const entry of entries) { - if (!entry.isFile()) continue; - if (isManifestName(entry.name)) found.push(entry.name); - } - return found.sort(); -} - -function isManifestName(name: string): boolean { - if ((PACKAGE_MANIFEST_NAMES as readonly string[]).includes(name)) return true; - for (const pattern of PACKAGE_MANIFEST_PATTERNS) { - if (pattern.test(name)) return true; - } - return false; -} - -/** - * Resolve workspace directories to scan (one level only). Returns POSIX - * relative paths to the inventory root; never the root itself. - * - * Two sources, both honored, results deduped: - * - `package.json:workspaces` (array form OR `{ packages: [] }` form) - * - Conventional `apps/`, `packages/`, `libs/`, `common/` — each - * immediate child of those dirs is a candidate workspace. - * - * Globs in `workspaces` are expanded with a tiny matcher that supports - * the patterns real repos actually use (`packages/*`, `apps/*`, plain - * dir paths). Anything more elaborate (`**`, brace expansion) is - * intentionally not supported — a recipe-level workspace crawl is out - * of scope for inventory. - */ -function expandWorkspaceDirs(root: string): string[] { - const dirs = new Set(); - - // 1. package.json workspaces, if any. - for (const dir of readPackageJsonWorkspaces(root)) { - dirs.add(dir); - } - - // 2. Conventional dirs — apps/*, packages/*, libs/*, common/*. - for (const parent of CONVENTIONAL_WORKSPACE_DIRS) { - const parentAbs = join(root, parent); - let entries: Dirent[]; - try { - entries = readdirSync(parentAbs, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (shouldSkipDir(entry.name)) continue; - if (entry.name.startsWith(".")) continue; - dirs.add(`${parent}/${entry.name}`); - } - } - - return [...dirs].sort(); -} - -function readPackageJsonWorkspaces(root: string): string[] { - const pkgPath = join(root, "package.json"); - let raw: string; - try { - raw = readFileSync(pkgPath, "utf-8"); - } catch { - return []; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!parsed || typeof parsed !== "object") return []; - const workspaces = (parsed as { workspaces?: unknown }).workspaces; - const patterns = normalizeWorkspacePatterns(workspaces); - if (patterns.length === 0) return []; - - const out = new Set(); - for (const pattern of patterns) { - for (const dir of expandWorkspacePattern(root, pattern)) { - out.add(dir); - } - } - return [...out]; -} - -function normalizeWorkspacePatterns(value: unknown): string[] { - // Accept array form (`["packages/*"]`) and object form - // (`{ packages: ["packages/*"] }`). - if (Array.isArray(value)) { - return value.filter((v): v is string => typeof v === "string"); - } - if (value && typeof value === "object") { - const obj = value as { packages?: unknown }; - if (Array.isArray(obj.packages)) { - return obj.packages.filter((v): v is string => typeof v === "string"); - } - } - return []; -} - -/** - * Tiny single-segment glob matcher for workspace patterns. Supports: - * - `packages/*` → every immediate child of `packages/` - * - `apps/*` → ditto - * - `tools/foo` → exact path (no wildcard) - * - * Multi-segment globs (`**`, `*\/*\/...`) are deliberately unsupported — - * the recipe escalates to a real workspace crawler when needed. - */ -function expandWorkspacePattern(root: string, pattern: string): string[] { - const cleaned = pattern.replace(/\\/g, "/").replace(/\/+$/, ""); - if (cleaned.length === 0) return []; - if (!cleaned.includes("*")) { - // Plain path — accept only if it resolves to an existing directory. - const abs = join(root, cleaned); - try { - if (statSync(abs).isDirectory()) return [cleaned]; - } catch { - // missing dir — skip silently - } - return []; - } - - // Single trailing wildcard: `/*` is the only supported shape. - const lastSlash = cleaned.lastIndexOf("/"); - if (lastSlash === -1) return []; - const parent = cleaned.slice(0, lastSlash); - const tail = cleaned.slice(lastSlash + 1); - if (tail !== "*") return []; - - const parentAbs = join(root, parent); - let entries: Dirent[]; - try { - entries = readdirSync(parentAbs, { withFileTypes: true }); - } catch { - return []; - } - const out: string[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (shouldSkipDir(entry.name)) continue; - if (entry.name.startsWith(".")) continue; - out.push(`${parent}/${entry.name}`); - } - return out; -} - -function basenameOf(absPath: string): string { - const idx = absPath.lastIndexOf(sep); - return idx === -1 ? absPath : absPath.slice(idx + 1); -} - -function isInsideRoot(absPath: string, root: string): boolean { - const rel = relative(root, absPath); - return ( - rel.length > 0 && - !rel.startsWith("..") && - !rel.startsWith(`..${sep}`) && - rel !== ".." - ); -} - -function toPosixRel(root: string, abs: string): string { - return relative(root, abs).split(sep).join("/"); -} - -/** - * The closed set of values `platform_hints` may emit. Keeps the field - * tied to `MapFrontmatterSchema`'s `platform` enum so a recipe can pass - * the hint through verbatim. Build-system / language / runtime signals - * (bazel, ruby, python, jvm, …) belong in `build_system_hints` (when - * they're build systems) or are deliberately not surfaced here. - */ -const PLATFORM_ENUM_VALUES: ReadonlySet = new Set([ - "web", - "ios", - "android", - "desktop", - "flutter", - "mixed", - "other", -]); - -/** - * Derive coarse platform hints from manifest presence + the language - * histogram + walk signals. - * - * Manifests give cheap, exact signal (`Package.swift` → ios, - * `pubspec.yaml` → flutter). Histograms cover repos where the manifest - * doesn't exist or doesn't disambiguate (a Bazel monorepo of Swift - * targets is "ios"; a Bazel monorepo of Kotlin targets is "android" - * — the manifest alone can't tell us which, so we lean on a Bazel - * build-system signal plus the language share). - * - * The output is constrained to values in `PLATFORM_ENUM_VALUES` so it - * mirrors `map.md`'s `platform:` enum. Build-system-derived signals - * (`bazel`, `cargo`, …) and language/runtime signals (`ruby`, `python`, - * `rust`, `go`, `jvm`, `php`, `elixir`) are NOT emitted here — bazel - * lives in `build_system_hints` instead, and the rest don't disambiguate - * a UI platform on their own. - * - * Cases this deliberately does not try to disambiguate: - * - Kotlin Multiplatform (Swift + Kotlin balanced) — both `ios` and - * `android` will fire; the recipe is expected to pick `mixed`. - * - React Native (TS dominant + native shells) — surfaces as `web` - * today; future: detect `ios/`, `android/` shell directories. - * - .NET MAUI (C# + XAML) — no special handling. - * - Tauri / Electron (web stack with Rust/native shell) — surfaces - * as `web`; the rust signal is in language_histogram, not here. - * - * The output is a deduped sorted list of *hints*, not a single - * platform — `map.md`'s `platform:` enum is the recipe's call. - */ -function derivePlatformHints( - manifests: string[], - languageHistogram: LanguageHistogramEntry[], - walk: WalkResult, -): string[] { - const hints = new Set(); - - // Manifest-driven hints (cheap, exact). Compare basenames so workspace- - // expanded entries (`packages/foo/package.json`) still match the same - // signal as a root `package.json`. - const basenames = manifests.map((m) => { - const idx = m.lastIndexOf("/"); - return idx === -1 ? m : m.slice(idx + 1); - }); - for (const m of basenames) { - if (m === "package.json") hints.add("web"); - if ( - m === "Package.swift" || - m === "Package.resolved" || - m.endsWith(".podspec") - ) { - hints.add("ios"); - } - if (m === "pubspec.yaml") hints.add("flutter"); - if ( - m === "settings.gradle" || - m === "settings.gradle.kts" || - m === "build.gradle" || - m === "build.gradle.kts" - ) { - hints.add("android"); - } - } - - // Bazel doesn't disambiguate platform on its own — it lives in - // `build_system_hints`. Track its presence locally so the histogram - // pass below can use it as a tiebreaker for swift-on-bazel / - // kotlin-on-bazel monorepos. - const hasBazelBuild = basenames.some( - (m) => - m === "WORKSPACE" || - m === "WORKSPACE.bazel" || - m === "MODULE.bazel" || - m === "BUILD.bazel" || - m === ".bazelversion", - ); - - // Language-histogram-driven hints — only kick in when manifests aren't - // already conclusive. Threshold: language must hold >40% of tracked - // files for its platform to register. - const basenameSet = new Set(basenames); - const totalFiles = languageHistogram.reduce((acc, l) => acc + l.files, 0); - if (totalFiles > 0) { - const share = (langName: string): number => { - const entry = languageHistogram.find((l) => l.name === langName); - return entry ? entry.files / totalFiles : 0; - }; - - const swiftShare = share("swift"); - const kotlinShare = share("kotlin") + share("java"); // Android stack - const dartShare = share("dart"); - - // Swift dominant + iOS-build evidence (SPM, Xcode, Bazel) → ios. - const hasSpm = basenameSet.has("Package.swift"); - if (swiftShare > 0.4 && (hasSpm || walk.hasXcodeProject || hasBazelBuild)) { - hints.add("ios"); - } - - // Kotlin/Java dominant + Gradle + AndroidManifest → android. - const hasGradle = - basenameSet.has("build.gradle") || - basenameSet.has("build.gradle.kts") || - basenameSet.has("settings.gradle") || - basenameSet.has("settings.gradle.kts"); - if ( - kotlinShare > 0.4 && - walk.hasAndroidManifest && - (hasGradle || hasBazelBuild) - ) { - hints.add("android"); - } - - // Dart dominant + pubspec → flutter (covers cases where manifest set - // alone wasn't conclusive — e.g. a workspace with multiple manifests). - if (dartShare > 0.4 && basenameSet.has("pubspec.yaml")) { - hints.add("flutter"); - } - } - - // Multiple distinct platform signals → also tag `mixed` so the recipe - // can pick `platform: mixed` without relitigating the histogram. - const platformish = new Set(["web", "ios", "android", "flutter"]); - const platformHits = [...hints].filter((h) => platformish.has(h)); - if (platformHits.length >= 2) hints.add("mixed"); - - // Defensive: drop anything outside the platform enum. Earlier passes - // only add enum values, but this guards against future regressions — - // build-system / language signals must NOT leak into platform_hints. - for (const hint of [...hints]) { - if (!PLATFORM_ENUM_VALUES.has(hint)) hints.delete(hint); - } - - return [...hints].sort(); -} - -/** - * Derive coarse build-system hints from manifest presence + walk signals. - * - * Informational only — the recipe authors the authoritative - * `build_system` value in `map.md`. The hints exist so the recipe doesn't - * need to re-scan manifests to know what build systems coexist. - * - * Hint values are drawn from the `build_system` enum so the recipe can - * pass them through verbatim when appropriate. - */ -function deriveBuildSystemHints( - manifests: string[], - walk: WalkResult, -): string[] { - const hints = new Set(); - const basenames = manifests.map((m) => { - const idx = m.lastIndexOf("/"); - return idx === -1 ? m : m.slice(idx + 1); - }); - - for (const m of basenames) { - if (m === "package.json") { - // package.json alone can't disambiguate npm/pnpm/yarn — the recipe - // resolves that via lockfile presence. Skip a hint here. - } - if ( - m === "settings.gradle" || - m === "settings.gradle.kts" || - m === "build.gradle" || - m === "build.gradle.kts" - ) { - hints.add("gradle"); - } - if ( - m === "WORKSPACE" || - m === "WORKSPACE.bazel" || - m === "MODULE.bazel" || - m === "BUILD.bazel" || - m === ".bazelversion" - ) { - hints.add("bazel"); - } - if ( - m === "Package.swift" || - m === "Package.resolved" || - m.endsWith(".podspec") - ) { - // SPM is the canonical Swift manifest — `xcode` is the IDE/build - // system, surfaced separately when an .xcodeproj is present. - hints.add("xcode"); - } - if (m === "Cargo.toml") hints.add("cargo"); - if (m === "go.mod") hints.add("go"); - if (m === "pom.xml") hints.add("maven"); - } - - if (walk.hasXcodeProject) hints.add("xcode"); - if (walk.hasStyleDictionary) hints.add("style-dictionary"); - - // JS bundlers and meta-build coordinators (vite, webpack, rollup, - // parcel, esbuild, nx, turbo). Detected during the walk via - // BUILD_TOOL_MATCHERS — pass through verbatim. - for (const hint of walk.buildToolHints) hints.add(hint); - - return [...hints].sort(); -} - -function readTopLevel(root: string): TopLevelEntry[] { - let entries: Dirent[]; - try { - entries = readdirSync(root, { withFileTypes: true }); - } catch { - return []; - } - const out: TopLevelEntry[] = []; - for (const entry of entries) { - if (entry.isDirectory()) { - // Skip universal build/cache directories so the tree summary stays - // signal-rich. Hidden dirs (starting with `.`) are also excluded - // unless they're the canonical Bazel marker (`.bazelversion` is a - // file, not a dir, so this is just consistency with the walker). - if (shouldSkipDir(entry.name)) continue; - if (entry.name.startsWith(".") && entry.name !== ".") continue; - const childPath = join(root, entry.name); - let childCount = 0; - try { - childCount = readdirSync(childPath).length; - } catch { - childCount = 0; - } - out.push({ - path: `${entry.name}/`, - kind: "dir", - child_count: childCount, - }); - continue; - } - if (entry.isFile()) { - out.push({ path: entry.name, kind: "file", child_count: 0 }); - } - } - return out.sort((a, b) => a.path.localeCompare(b.path)); -} - -function readGit(root: string): GitInfo { - if (!isGitRepo(root)) return { remote: null, default_branch: null }; - const remote = tryGit(["config", "--get", "remote.origin.url"], root); - const defaultBranch = - parseDefaultBranch( - tryGit(["symbolic-ref", "refs/remotes/origin/HEAD"], root), - ) ?? tryGit(["rev-parse", "--abbrev-ref", "HEAD"], root); - return { - remote: remote && remote.length > 0 ? remote : null, - default_branch: - defaultBranch && defaultBranch.length > 0 ? defaultBranch : null, - }; -} - -function isGitRepo(root: string): boolean { - try { - return ( - statSync(join(root, ".git")).isDirectory() || - statSync(join(root, ".git")).isFile() - ); - } catch { - return false; - } -} - -function tryGit(args: string[], cwd: string): string | null { - try { - const out = execFileSync("git", args, { - cwd, - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }); - return out.trim(); - } catch { - return null; - } -} - -function parseDefaultBranch(symbolic: string | null): string | null { - if (!symbolic) return null; - // Expect "refs/remotes/origin/" - const prefix = "refs/remotes/origin/"; - if (symbolic.startsWith(prefix)) return symbolic.slice(prefix.length); - return null; -} - -function sortRelative(absPaths: string[], root: string): string[] { - return absPaths - .map((p) => relative(root, p).split(sep).join("/")) - .sort() - .filter((v, i, arr) => arr.indexOf(v) === i); -} - -/** - * Order candidate config files so design-system-anchored matches surface - * before incidental hits in feature folders. Within the same tier, paths - * sort lexicographically so output stays deterministic. - * - * A path's tier is the depth of its first DS-ancestor segment counted - * from the root (lower is better). Files with no DS ancestor land in the - * "no-ancestor" tier last. - */ -function orderConfigCandidates(absPaths: string[], root: string): string[] { - const rels = absPaths - .map((p) => relative(root, p).split(sep).join("/")) - .filter((v, i, arr) => arr.indexOf(v) === i); - - return rels.sort((a, b) => { - const da = dsAncestorDepth(a); - const db = dsAncestorDepth(b); - if (da !== db) { - // Both have an ancestor → shallower wins. One has none → that side - // sorts last (depth = +Infinity). - return da - db; - } - return a.localeCompare(b); - }); -} - -/** - * Return the 1-based depth of the first design-system ancestor segment in - * a relative path, or +Infinity if no segment matches. - * - * `tokens/colors.json` → 1 - * `src/styles/tokens.css` → 2 (matches `styles`) - * `Code/DesignSystem/Theme.kt` → 2 (matches `designsystem`) - * `app/Color+Brand.swift` → +Infinity - */ -function dsAncestorDepth(relPath: string): number { - const segments = relPath.split("/"); - // Walk parent segments only — the basename is the file itself. - for (let i = 0; i < segments.length - 1; i++) { - const norm = segments[i].toLowerCase(); - if (DS_ANCESTOR_SEGMENTS.has(norm)) return i + 1; - } - return Number.POSITIVE_INFINITY; -} diff --git a/packages/ghost/src/scan/inventory/constants.ts b/packages/ghost/src/scan/inventory/constants.ts new file mode 100644 index 00000000..2acfeb1e --- /dev/null +++ b/packages/ghost/src/scan/inventory/constants.ts @@ -0,0 +1,243 @@ +/** + * Static lookup tables for the deterministic repository inventory pass. + * No logic here — just the curated, OSS-generalizable signal vocabulary. + */ + +/** + * Canonical package manifests we scan for at the inventoried root. + * + * Matched against immediate children of the root only — nested manifests + * live in `language_histogram` / `top_level_tree`. The list aims to be + * OSS-generalizable; organization-specific manifests are deliberately omitted. + */ +export const PACKAGE_MANIFEST_NAMES = [ + // Node / web + "package.json", + // Rust + "Cargo.toml", + // Python + "pyproject.toml", + "Pipfile", + "setup.py", + // Go + "go.mod", + // Swift / iOS + "Package.swift", + "Package.resolved", + // Flutter / Dart + "pubspec.yaml", + // JVM — Maven + "pom.xml", + // JVM — Gradle + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + // JVM — Bazel + "WORKSPACE", + "WORKSPACE.bazel", + "MODULE.bazel", + "BUILD.bazel", + ".bazelversion", + // Ruby + "Gemfile", + "Gemfile.lock", + // Elixir + "mix.exs", + // PHP + "composer.json", +] as const; + +/** Regex matchers for less-stable manifest names (added to package_manifests). */ +export const PACKAGE_MANIFEST_PATTERNS: RegExp[] = [ + /\.podspec$/, // CocoaPods (iOS) + /\.gemspec$/, // RubyGems +]; + +/** + * Config files we look for anywhere under the root. Weak signals the host + * agent reads to confirm what a repo actually is. + */ +export const CONFIG_FILE_EXACT = new Set([ + "tsconfig.json", + "tokens.css", + "tokens.json", + "colors.xml", + "themes.xml", + "Theme.kt", + "Color.kt", + "Theme.swift", + "registry.json", +]); + +/** Patterns matched against the basename of any file under root. */ +export const CONFIG_FILE_PATTERNS: RegExp[] = [ + /^tailwind\.config\.[cm]?[jt]sx?$/, + /^vite\.config\.[cm]?[jt]sx?$/, + /^next\.config\.[cm]?[jt]sx?$/, + /^Color\+.+\.swift$/, + /^style-dictionary\.config\.[cm]?[jt]sx?$/, + /^style-dictionary\.config\.json$/, + /^webpack\.config\.[cm]?[jt]sx?$/, + /^rollup\.config\.[cm]?[jt]sx?$/, + /^parcel\.config\.[cm]?[jt]sx?$/, + /^esbuild\.config\.[cm]?[jt]sx?$/, +]; + +/** Basenames that indicate a Style Dictionary token pipeline lives nearby. */ +export const STYLE_DICTIONARY_FILES = new Set([ + "style-dictionary.config.js", + "style-dictionary.config.cjs", + "style-dictionary.config.mjs", + "style-dictionary.config.ts", + "style-dictionary.config.json", +]); + +/** Maps a build-tool hint (from the `build_system` enum) to its basename matcher. */ +export interface BuildToolMatcher { + hint: string; + matches: (basename: string) => boolean; +} + +export const BUILD_TOOL_MATCHERS: BuildToolMatcher[] = [ + { hint: "vite", matches: (n) => /^vite\.config\.[cm]?[jt]sx?$/.test(n) }, + { + hint: "webpack", + matches: (n) => /^webpack\.config\.[cm]?[jt]sx?$/.test(n), + }, + { hint: "rollup", matches: (n) => /^rollup\.config\.[cm]?[jt]sx?$/.test(n) }, + { hint: "parcel", matches: (n) => /^parcel\.config\.[cm]?[jt]sx?$/.test(n) }, + { + hint: "esbuild", + matches: (n) => /^esbuild\.config\.[cm]?[jt]sx?$/.test(n), + }, + { hint: "nx", matches: (n) => n === "nx.json" }, + { hint: "turbo", matches: (n) => n === "turbo.json" }, +]; + +/** Language-name lookup keyed by lowercase extension (no leading dot). */ +export const EXTENSION_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + kt: "kotlin", + kts: "kotlin", + swift: "swift", + m: "objective-c", + mm: "objective-c", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + hpp: "cpp", + hh: "cpp", + cs: "csharp", + dart: "dart", + scala: "scala", + php: "php", + vue: "vue", + svelte: "svelte", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + html: "html", + xml: "xml", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + md: "markdown", + mdx: "markdown", + sh: "shell", + bash: "shell", + zsh: "shell", +}; + +/** Directories we don't walk into when computing histograms / configs. */ +export const SKIP_DIRS = new Set([ + ".git", + "node_modules", + ".pnpm", + ".yarn", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".gradle", + ".idea", + ".vscode", + ".fleet", + "build", + "dist", + "out", + "target", + "coverage", + "Pods", + "DerivedData", + ".cxx", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".tox", + "venv", + ".venv", + "vendor", + "bin", + "obj", +]); + +/** Directory-name prefixes that indicate a build/output tree (bazel-*, dist-*). */ +export const SKIP_DIR_PREFIXES: readonly string[] = ["bazel-", "dist-"]; + +/** Directory basenames that signal a token / theme pipeline lives there. */ +export const TOKEN_DIR_BASENAMES = new Set([ + "tokens", + "design-tokens", + "design_tokens", + "theme", + "themes", +]); + +/** Path segments that indicate a file lives under a design-system tree. */ +export const DS_ANCESTOR_SEGMENTS: ReadonlySet = new Set([ + "design-system", + "design_system", + "designsystem", + "tokens", + "design-tokens", + "design_tokens", + "theme", + "themes", + "styles", +]); + +/** Cap how many files we keep in the histogram output. */ +export const HISTOGRAM_TOP_N = 20; + +/** The closed set of values `platform_hints` may emit (mirrors the map enum). */ +export const PLATFORM_ENUM_VALUES: ReadonlySet = new Set([ + "web", + "ios", + "android", + "desktop", + "flutter", + "mixed", + "other", +]); + +/** Conventional one-level workspace directories scanned in addition to root. */ +export const CONVENTIONAL_WORKSPACE_DIRS = [ + "apps", + "packages", + "libs", + "common", +] as const; diff --git a/packages/ghost/src/scan/inventory/git.ts b/packages/ghost/src/scan/inventory/git.ts new file mode 100644 index 00000000..d5ead26b --- /dev/null +++ b/packages/ghost/src/scan/inventory/git.ts @@ -0,0 +1,82 @@ +import { execFileSync } from "node:child_process"; +import { type Dirent, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { GitInfo, TopLevelEntry } from "#ghost-core"; +import { shouldSkipDir } from "./paths.js"; + +/** A shallow, signal-rich listing of the root's immediate children. */ +export function readTopLevel(root: string): TopLevelEntry[] { + let entries: Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + return []; + } + const out: TopLevelEntry[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + if (shouldSkipDir(entry.name)) continue; + if (entry.name.startsWith(".") && entry.name !== ".") continue; + let childCount = 0; + try { + childCount = readdirSync(join(root, entry.name)).length; + } catch { + childCount = 0; + } + out.push({ + path: `${entry.name}/`, + kind: "dir", + child_count: childCount, + }); + continue; + } + if (entry.isFile()) { + out.push({ path: entry.name, kind: "file", child_count: 0 }); + } + } + return out.sort((a, b) => a.path.localeCompare(b.path)); +} + +/** Best-effort git remote + default branch (no failure on non-repos). */ +export function readGit(root: string): GitInfo { + if (!isGitRepo(root)) return { remote: null, default_branch: null }; + const remote = tryGit(["config", "--get", "remote.origin.url"], root); + const defaultBranch = + parseDefaultBranch( + tryGit(["symbolic-ref", "refs/remotes/origin/HEAD"], root), + ) ?? tryGit(["rev-parse", "--abbrev-ref", "HEAD"], root); + return { + remote: remote && remote.length > 0 ? remote : null, + default_branch: + defaultBranch && defaultBranch.length > 0 ? defaultBranch : null, + }; +} + +function isGitRepo(root: string): boolean { + try { + return ( + statSync(join(root, ".git")).isDirectory() || + statSync(join(root, ".git")).isFile() + ); + } catch { + return false; + } +} + +function tryGit(args: string[], cwd: string): string | null { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +function parseDefaultBranch(symbolic: string | null): string | null { + if (!symbolic) return null; + const prefix = "refs/remotes/origin/"; + return symbolic.startsWith(prefix) ? symbolic.slice(prefix.length) : null; +} diff --git a/packages/ghost/src/scan/inventory/hints.ts b/packages/ghost/src/scan/inventory/hints.ts new file mode 100644 index 00000000..0f0af079 --- /dev/null +++ b/packages/ghost/src/scan/inventory/hints.ts @@ -0,0 +1,147 @@ +import type { LanguageHistogramEntry } from "#ghost-core"; +import { PLATFORM_ENUM_VALUES } from "./constants.js"; +import type { WalkResult } from "./walk.js"; + +function basenames(manifests: string[]): string[] { + return manifests.map((m) => { + const idx = m.lastIndexOf("/"); + return idx === -1 ? m : m.slice(idx + 1); + }); +} + +/** + * Derive coarse platform hints from manifest presence + the language + * histogram + walk signals. Output is constrained to `PLATFORM_ENUM_VALUES` + * (mirrors the map `platform:` enum) — a deduped sorted list of *hints*, not + * a single platform. Build-system / language-runtime signals are not emitted + * here (bazel lives in build-system hints; the rest don't disambiguate a UI + * platform alone). + */ +export function derivePlatformHints( + manifests: string[], + languageHistogram: LanguageHistogramEntry[], + walk: WalkResult, +): string[] { + const hints = new Set(); + const names = basenames(manifests); + + for (const m of names) { + if (m === "package.json") hints.add("web"); + if ( + m === "Package.swift" || + m === "Package.resolved" || + m.endsWith(".podspec") + ) { + hints.add("ios"); + } + if (m === "pubspec.yaml") hints.add("flutter"); + if ( + m === "settings.gradle" || + m === "settings.gradle.kts" || + m === "build.gradle" || + m === "build.gradle.kts" + ) { + hints.add("android"); + } + } + + const hasBazelBuild = names.some( + (m) => + m === "WORKSPACE" || + m === "WORKSPACE.bazel" || + m === "MODULE.bazel" || + m === "BUILD.bazel" || + m === ".bazelversion", + ); + + const nameSet = new Set(names); + const totalFiles = languageHistogram.reduce((acc, l) => acc + l.files, 0); + if (totalFiles > 0) { + const share = (langName: string): number => { + const entry = languageHistogram.find((l) => l.name === langName); + return entry ? entry.files / totalFiles : 0; + }; + + const swiftShare = share("swift"); + const kotlinShare = share("kotlin") + share("java"); + const dartShare = share("dart"); + + const hasSpm = nameSet.has("Package.swift"); + if (swiftShare > 0.4 && (hasSpm || walk.hasXcodeProject || hasBazelBuild)) { + hints.add("ios"); + } + + const hasGradle = + nameSet.has("build.gradle") || + nameSet.has("build.gradle.kts") || + nameSet.has("settings.gradle") || + nameSet.has("settings.gradle.kts"); + if ( + kotlinShare > 0.4 && + walk.hasAndroidManifest && + (hasGradle || hasBazelBuild) + ) { + hints.add("android"); + } + + if (dartShare > 0.4 && nameSet.has("pubspec.yaml")) hints.add("flutter"); + } + + const platformish = new Set(["web", "ios", "android", "flutter"]); + if ([...hints].filter((h) => platformish.has(h)).length >= 2) { + hints.add("mixed"); + } + + for (const hint of [...hints]) { + if (!PLATFORM_ENUM_VALUES.has(hint)) hints.delete(hint); + } + + return [...hints].sort(); +} + +/** + * Derive coarse build-system hints from manifest presence + walk signals. + * Informational only — the recipe authors the authoritative `build_system`. + */ +export function deriveBuildSystemHints( + manifests: string[], + walk: WalkResult, +): string[] { + const hints = new Set(); + + for (const m of basenames(manifests)) { + if ( + m === "settings.gradle" || + m === "settings.gradle.kts" || + m === "build.gradle" || + m === "build.gradle.kts" + ) { + hints.add("gradle"); + } + if ( + m === "WORKSPACE" || + m === "WORKSPACE.bazel" || + m === "MODULE.bazel" || + m === "BUILD.bazel" || + m === ".bazelversion" + ) { + hints.add("bazel"); + } + if ( + m === "Package.swift" || + m === "Package.resolved" || + m.endsWith(".podspec") + ) { + hints.add("xcode"); + } + if (m === "Cargo.toml") hints.add("cargo"); + if (m === "go.mod") hints.add("go"); + if (m === "pom.xml") hints.add("maven"); + } + + if (walk.hasXcodeProject) hints.add("xcode"); + if (walk.hasStyleDictionary) hints.add("style-dictionary"); + for (const hint of walk.buildToolHints) hints.add(hint); + + return [...hints].sort(); +} diff --git a/packages/ghost/src/scan/inventory/index.ts b/packages/ghost/src/scan/inventory/index.ts new file mode 100644 index 00000000..e3613d26 --- /dev/null +++ b/packages/ghost/src/scan/inventory/index.ts @@ -0,0 +1,55 @@ +import { resolve } from "node:path"; +import type { InventoryOutput } from "#ghost-core"; +import { readGit, readTopLevel } from "./git.js"; +import { deriveBuildSystemHints, derivePlatformHints } from "./hints.js"; +import { collectAllManifests } from "./manifests.js"; +import { sortRelative } from "./paths.js"; +import { orderConfigCandidates, topLanguages, walkTree } from "./walk.js"; + +/** + * Run a deterministic inventory pass over the given path. + * + * No LLM calls, no network, no filesystem mutations — pure reads plus a + * best-effort git invocation. The pass fans out into focused collectors + * (walk, manifests, hints, git) and assembles their results here. + */ +export function signals(path: string): InventoryOutput { + const root = resolve(path); + + const packageManifests = collectAllManifests(root); + const walkResult = walkTree(root); + const languageHistogram = topLanguages(walkResult.languageCounts); + + const candidateConfigFiles = orderConfigCandidates( + walkResult.configFiles, + root, + ); + // Token directories surface as additional config candidates so the recipe + // can find directory-shaped token graphs without a separate scan. + for (const tokenDir of orderConfigCandidates(walkResult.tokenDirs, root)) { + const withSlash = tokenDir.endsWith("/") ? tokenDir : `${tokenDir}/`; + if (!candidateConfigFiles.includes(withSlash)) { + candidateConfigFiles.push(withSlash); + } + } + + const registryFiles = sortRelative(walkResult.registryFiles, root); + const git = readGit(root); + + return { + root, + platform_hints: derivePlatformHints( + packageManifests, + languageHistogram, + walkResult, + ), + build_system_hints: deriveBuildSystemHints(packageManifests, walkResult), + language_histogram: languageHistogram, + package_manifests: packageManifests, + candidate_config_files: candidateConfigFiles, + registry_files: registryFiles, + top_level_tree: readTopLevel(root), + git_remote: git.remote, + git_default_branch: git.default_branch, + }; +} diff --git a/packages/ghost/src/scan/inventory/manifests.ts b/packages/ghost/src/scan/inventory/manifests.ts new file mode 100644 index 00000000..ebe00b2d --- /dev/null +++ b/packages/ghost/src/scan/inventory/manifests.ts @@ -0,0 +1,181 @@ +import { type Dirent, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + CONVENTIONAL_WORKSPACE_DIRS, + PACKAGE_MANIFEST_NAMES, + PACKAGE_MANIFEST_PATTERNS, +} from "./constants.js"; +import { + basenameOf, + isInsideRoot, + shouldSkipDir, + toPosixRel, +} from "./paths.js"; + +/** + * Collect package manifests from the root plus identifiable workspace dirs + * (`package.json:workspaces` + the conventional apps/packages/libs/common + * layout). Root manifests are returned by basename; nested ones as POSIX + * relative paths. Deduped by absolute path, sorted. One level deep only. + */ +export function collectAllManifests(root: string): string[] { + const seenAbs = new Set(); + const out: string[] = []; + + for (const name of collectManifestBasenames(root)) { + const abs = join(root, name); + if (seenAbs.has(abs)) continue; + seenAbs.add(abs); + out.push(name); + } + + for (const dir of expandWorkspaceDirs(root)) { + const absDir = resolve(root, dir); + if (!isInsideRoot(absDir, root)) continue; + if (shouldSkipDir(basenameOf(absDir))) continue; + let entries: Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!isManifestName(entry.name)) continue; + const abs = join(absDir, entry.name); + if (seenAbs.has(abs)) continue; + seenAbs.add(abs); + out.push(toPosixRel(root, abs)); + } + } + + return out.sort(); +} + +function collectManifestBasenames(dir: string): string[] { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const found: string[] = []; + for (const entry of entries) { + if (entry.isFile() && isManifestName(entry.name)) found.push(entry.name); + } + return found.sort(); +} + +function isManifestName(name: string): boolean { + if ((PACKAGE_MANIFEST_NAMES as readonly string[]).includes(name)) return true; + for (const pattern of PACKAGE_MANIFEST_PATTERNS) { + if (pattern.test(name)) return true; + } + return false; +} + +/** + * Resolve workspace directories to scan (one level only) — POSIX relative + * paths, never the root itself. Honors `package.json:workspaces` (array or + * `{ packages: [] }`) and the conventional apps/packages/libs/common layout. + */ +function expandWorkspaceDirs(root: string): string[] { + const dirs = new Set(); + + for (const dir of readPackageJsonWorkspaces(root)) dirs.add(dir); + + for (const parent of CONVENTIONAL_WORKSPACE_DIRS) { + let entries: Dirent[]; + try { + entries = readdirSync(join(root, parent), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (shouldSkipDir(entry.name)) continue; + if (entry.name.startsWith(".")) continue; + dirs.add(`${parent}/${entry.name}`); + } + } + + return [...dirs].sort(); +} + +function readPackageJsonWorkspaces(root: string): string[] { + let raw: string; + try { + raw = readFileSync(join(root, "package.json"), "utf-8"); + } catch { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!parsed || typeof parsed !== "object") return []; + const patterns = normalizeWorkspacePatterns( + (parsed as { workspaces?: unknown }).workspaces, + ); + if (patterns.length === 0) return []; + + const out = new Set(); + for (const pattern of patterns) { + for (const dir of expandWorkspacePattern(root, pattern)) out.add(dir); + } + return [...out]; +} + +function normalizeWorkspacePatterns(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((v): v is string => typeof v === "string"); + } + if (value && typeof value === "object") { + const obj = value as { packages?: unknown }; + if (Array.isArray(obj.packages)) { + return obj.packages.filter((v): v is string => typeof v === "string"); + } + } + return []; +} + +/** + * Tiny single-segment glob matcher: `packages/*`, `apps/*`, or an exact + * `tools/foo` path. Multi-segment globs (`**`) are deliberately unsupported. + */ +function expandWorkspacePattern(root: string, pattern: string): string[] { + const cleaned = pattern.replace(/\\/g, "/").replace(/\/+$/, ""); + if (cleaned.length === 0) return []; + if (!cleaned.includes("*")) { + const abs = join(root, cleaned); + try { + if (statSync(abs).isDirectory()) return [cleaned]; + } catch { + // missing dir — skip + } + return []; + } + + const lastSlash = cleaned.lastIndexOf("/"); + if (lastSlash === -1) return []; + const parent = cleaned.slice(0, lastSlash); + const tail = cleaned.slice(lastSlash + 1); + if (tail !== "*") return []; + + let entries: Dirent[]; + try { + entries = readdirSync(join(root, parent), { withFileTypes: true }); + } catch { + return []; + } + const out: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (shouldSkipDir(entry.name)) continue; + if (entry.name.startsWith(".")) continue; + out.push(`${parent}/${entry.name}`); + } + return out; +} diff --git a/packages/ghost/src/scan/inventory/paths.ts b/packages/ghost/src/scan/inventory/paths.ts new file mode 100644 index 00000000..d2b6332e --- /dev/null +++ b/packages/ghost/src/scan/inventory/paths.ts @@ -0,0 +1,48 @@ +import { relative, sep } from "node:path"; +import { SKIP_DIR_PREFIXES, SKIP_DIRS } from "./constants.js"; + +/** Basename of an absolute path, OS-separator aware. */ +export function basenameOf(absPath: string): string { + const idx = absPath.lastIndexOf(sep); + return idx === -1 ? absPath : absPath.slice(idx + 1); +} + +/** True when `absPath` is strictly inside `root` (defensive against bad globs). */ +export function isInsideRoot(absPath: string, root: string): boolean { + const rel = relative(root, absPath); + return ( + rel.length > 0 && + !rel.startsWith("..") && + !rel.startsWith(`..${sep}`) && + rel !== ".." + ); +} + +/** POSIX-style relative path from `root` to `abs`. */ +export function toPosixRel(root: string, abs: string): string { + return relative(root, abs).split(sep).join("/"); +} + +/** Lowercase file extension without the leading dot, or null. */ +export function extOf(name: string): string | null { + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) return null; + return name.slice(dot + 1).toLowerCase(); +} + +/** True when a directory should not be walked (build/cache/vcs dirs). */ +export function shouldSkipDir(name: string): boolean { + if (SKIP_DIRS.has(name)) return true; + for (const prefix of SKIP_DIR_PREFIXES) { + if (name.startsWith(prefix)) return true; + } + return false; +} + +/** Dedupe + sort absolute paths as POSIX-relative-to-root strings. */ +export function sortRelative(absPaths: string[], root: string): string[] { + return absPaths + .map((p) => toPosixRel(root, p)) + .sort() + .filter((v, i, arr) => arr.indexOf(v) === i); +} diff --git a/packages/ghost/src/scan/inventory/walk.ts b/packages/ghost/src/scan/inventory/walk.ts new file mode 100644 index 00000000..790fca01 --- /dev/null +++ b/packages/ghost/src/scan/inventory/walk.ts @@ -0,0 +1,155 @@ +import { type Dirent, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { LanguageHistogramEntry } from "#ghost-core"; +import { + BUILD_TOOL_MATCHERS, + CONFIG_FILE_EXACT, + CONFIG_FILE_PATTERNS, + DS_ANCESTOR_SEGMENTS, + EXTENSION_TO_LANGUAGE, + HISTOGRAM_TOP_N, + STYLE_DICTIONARY_FILES, + TOKEN_DIR_BASENAMES, +} from "./constants.js"; +import { extOf, shouldSkipDir, toPosixRel } from "./paths.js"; + +export interface WalkResult { + languageCounts: Map; + configFiles: string[]; + registryFiles: string[]; + /** Directories whose basename matched a token-pipeline pattern. */ + tokenDirs: string[]; + /** True if any `AndroidManifest.xml` was found under the root. */ + hasAndroidManifest: boolean; + /** True if any `*.xcodeproj/project.pbxproj` was found under the root. */ + hasXcodeProject: boolean; + /** True if any `style-dictionary.config.*` was found under the root. */ + hasStyleDictionary: boolean; + /** Build-tool hints inferred from config-file presence (vite, nx, …). */ + buildToolHints: Set; +} + +/** Single depth-unbounded tree walk collecting every cheap repo signal. */ +export function walkTree(root: string): WalkResult { + const languageCounts = new Map(); + const configFiles: string[] = []; + const registryFiles: string[] = []; + const tokenDirs: string[] = []; + const buildToolHints = new Set(); + let hasAndroidManifest = false; + let hasXcodeProject = false; + let hasStyleDictionary = false; + + const stack: string[] = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + if (!dir) continue; + + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (shouldSkipDir(entry.name)) continue; + if (entry.name.startsWith(".") && entry.name !== ".") continue; + if (TOKEN_DIR_BASENAMES.has(entry.name.toLowerCase())) { + tokenDirs.push(full); + } + if (entry.name.endsWith(".xcodeproj")) { + try { + statSync(join(full, "project.pbxproj")); + hasXcodeProject = true; + } catch { + // not a real Xcode project — keep walking + } + } + stack.push(full); + continue; + } + if (!entry.isFile()) continue; + + const ext = extOf(entry.name); + if (ext) { + const lang = EXTENSION_TO_LANGUAGE[ext]; + if (lang) { + languageCounts.set(lang, (languageCounts.get(lang) ?? 0) + 1); + } + } + + if (matchesConfig(entry.name)) configFiles.push(full); + if (entry.name === "registry.json") registryFiles.push(full); + if (entry.name === "AndroidManifest.xml") hasAndroidManifest = true; + if (STYLE_DICTIONARY_FILES.has(entry.name)) hasStyleDictionary = true; + for (const matcher of BUILD_TOOL_MATCHERS) { + if (matcher.matches(entry.name)) buildToolHints.add(matcher.hint); + } + } + } + + return { + languageCounts, + configFiles, + registryFiles, + tokenDirs, + buildToolHints, + hasAndroidManifest, + hasXcodeProject, + hasStyleDictionary, + }; +} + +function matchesConfig(name: string): boolean { + if (CONFIG_FILE_EXACT.has(name)) return true; + for (const pattern of CONFIG_FILE_PATTERNS) { + if (pattern.test(name)) return true; + } + return false; +} + +/** Top-N languages by file count, ties broken by name. */ +export function topLanguages( + counts: Map, +): LanguageHistogramEntry[] { + return [...counts.entries()] + .map(([name, files]) => ({ name, files })) + .sort((a, b) => { + if (b.files !== a.files) return b.files - a.files; + return a.name.localeCompare(b.name); + }) + .slice(0, HISTOGRAM_TOP_N); +} + +/** + * Order candidate config files so design-system-anchored matches surface + * before incidental hits. Tier = depth of the first DS-ancestor segment; + * ties sort lexicographically for determinism. + */ +export function orderConfigCandidates( + absPaths: string[], + root: string, +): string[] { + const rels = absPaths + .map((p) => toPosixRel(root, p)) + .filter((v, i, arr) => arr.indexOf(v) === i); + + return rels.sort((a, b) => { + const da = dsAncestorDepth(a); + const db = dsAncestorDepth(b); + if (da !== db) return da - db; + return a.localeCompare(b); + }); +} + +function dsAncestorDepth(relPath: string): number { + const segments = relPath.split("/"); + for (let i = 0; i < segments.length - 1; i++) { + if (DS_ANCESTOR_SEGMENTS.has(segments[i].toLowerCase())) return i + 1; + } + return Number.POSITIVE_INFINITY; +} diff --git a/packages/ghost/test/terminology-public.test.ts b/packages/ghost/test/terminology-public.test.ts index 5355aee0..0a586a6c 100644 --- a/packages/ghost/test/terminology-public.test.ts +++ b/packages/ghost/test/terminology-public.test.ts @@ -17,7 +17,9 @@ const PUBLIC_TEXT_ROOTS = [ ".changeset", ] as const; -const EMITTED_TEXT_FILES = ["packages/ghost/src/review-packet.ts"] as const; +const EMITTED_TEXT_FILES = [ + "packages/ghost/src/commands/review-packet.ts", +] as const; const FORBIDDEN_TERMS = [ /\bcascade\b/i, @@ -92,7 +94,8 @@ function isPublicTextFile(path: string): boolean { } function sanitizePublicText(file: string, text: string): string { - if (!file.endsWith("packages/ghost/src/review-packet.ts")) return text; + if (!file.endsWith("packages/ghost/src/commands/review-packet.ts")) + return text; return text .split("\n") .filter( diff --git a/scripts/check-file-sizes.mjs b/scripts/check-file-sizes.mjs index 65b038ae..0c86503d 100644 --- a/scripts/check-file-sizes.mjs +++ b/scripts/check-file-sizes.mjs @@ -4,23 +4,7 @@ import { join, relative } from "node:path"; const DEFAULT_LIMIT = 500; // Add narrowly scoped exceptions here with justification -const EXCEPTIONS = { - "packages/ghost/src/cli.ts": { - limit: 580, - justification: - "Unified CLI command registry — all verbs live together for one public bin", - }, - "packages/ghost/src/fingerprint-commands.ts": { - limit: 1135, - justification: - "Fingerprint package command registry — holds package lifecycle, validate, scan, and adapter-neutral package-dir routing", - }, - "packages/ghost/src/scan/inventory.ts": { - limit: 1120, - justification: - "Deterministic repository inventory collector — intentionally broad because map authoring depends on one cohesive raw signal pass", - }, -}; +const EXCEPTIONS = {}; const DIRS_TO_CHECK = [{ dir: "packages/ghost/src", glob: /\.[jt]sx?$/ }]; From 42e7f6dab5a1581005622a30cea5f4dd784fc31b Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sun, 28 Jun 2026 09:50:42 -0400 Subject: [PATCH 7/7] fix: drop dead deps (jiti, tinyglobby) + clean stale dist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jiti (1.7MB) and tinyglobby were declared runtime deps but imported nowhere in source — Ghost rolls its own tiny workspace-glob matcher. Removed both; runtime deps are now cac + yaml + zod. Also fix the build bug where 'tsc --build' incremental retained deleted modules (relay, compare, context/, core/, old inventory) in dist/ because the build's rm -rf cleared dist but not tsconfig.tsbuildinfo. Now clears both. The published artifact drops from ~1.9MB/777 files to ~397KB/248 files (104KB packed). Updated the release-tarball check's expected-deps list. All green. --- .changeset/drop-dead-deps.md | 5 +++++ packages/ghost/package.json | 5 +---- pnpm-lock.yaml | 21 --------------------- scripts/check-release-tarball.mjs | 4 ---- 4 files changed, 6 insertions(+), 29 deletions(-) create mode 100644 .changeset/drop-dead-deps.md diff --git a/.changeset/drop-dead-deps.md b/.changeset/drop-dead-deps.md new file mode 100644 index 00000000..198fc68a --- /dev/null +++ b/.changeset/drop-dead-deps.md @@ -0,0 +1,5 @@ +--- +"@anarchitecture/ghost": patch +--- + +Drop two unused runtime dependencies (`jiti`, `tinyglobby`) — neither was imported anywhere in source. Ghost now ships three runtime deps (`cac`, `yaml`, `zod`), shrinking the install footprint by ~1.8 MB. Also fix the build to clear `tsconfig.tsbuildinfo` so `dist/` no longer retains deleted modules from incremental builds (the packed package drops from ~1.9 MB / 777 files to ~397 KB / 248 files). diff --git a/packages/ghost/package.json b/packages/ghost/package.json index 5c1e3185..ab670901 100644 --- a/packages/ghost/package.json +++ b/packages/ghost/package.json @@ -51,7 +51,6 @@ "types": "./dist/fingerprint.d.ts", "import": "./dist/fingerprint.js" }, - "./scan": { "types": "./dist/scan/index.d.ts", "import": "./dist/scan/index.js" @@ -69,13 +68,11 @@ "provenance": true }, "scripts": { - "build": "rm -rf dist && tsc --build --force && chmod +x dist/bin.js && node ../../scripts/link-package-bin.mjs && cp -r src/skill-bundle dist/skill-bundle", + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc --build --force && chmod +x dist/bin.js && node ../../scripts/link-package-bin.mjs && cp -r src/skill-bundle dist/skill-bundle", "prepublishOnly": "pnpm build" }, "dependencies": { "cac": "^6.7.14", - "jiti": "^2.4.0", - "tinyglobby": "^0.2.16", "yaml": "^2.8.3", "zod": "^4.3.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 835e0bfe..9bd9cadf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,27 +306,6 @@ importers: packages/ghost: dependencies: - cac: - specifier: ^6.7.14 - version: 6.7.14 - jiti: - specifier: ^2.4.0 - version: 2.6.1 - tinyglobby: - specifier: ^0.2.16 - version: 0.2.16 - yaml: - specifier: ^2.8.3 - version: 2.8.3 - zod: - specifier: ^4.3.6 - version: 4.3.6 - - packages/ghost-fleet: - dependencies: - '@anarchitecture/ghost': - specifier: workspace:* - version: link:../ghost cac: specifier: ^6.7.14 version: 6.7.14 diff --git a/scripts/check-release-tarball.mjs b/scripts/check-release-tarball.mjs index bf03c1b8..08c295d9 100644 --- a/scripts/check-release-tarball.mjs +++ b/scripts/check-release-tarball.mjs @@ -66,10 +66,6 @@ try { "dist/bin.js", "dist/cli.js", "node_modules/cac", - "node_modules/fdir", - "node_modules/jiti", - "node_modules/picomatch", - "node_modules/tinyglobby", "node_modules/yaml", "node_modules/zod", ];