From d8143a726077baea6f1fbe2668a6b321fcc3a97a Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 10 Jul 2026 18:57:50 -0400 Subject: [PATCH 1/2] Refocus fingerprints around covers and foundations --- .changeset/concrete-tiers-recipe.md | 5 + .changeset/cover-node-designer-skeleton.md | 5 + .changeset/remove-glossary-posture.md | 5 + .../validate-warns-undescribed-nodes.md | 5 + apps/docs/src/generated/cli-manifest.json | 10 +- install/manifest.json | 4 +- packages/ghost/src/commands/gather-command.ts | 79 ++-- packages/ghost/src/commands/pull-command.ts | 35 +- packages/ghost/src/commands/pulse-command.ts | 22 +- .../ghost/src/ghost-core/catalog/assemble.ts | 15 - .../ghost/src/ghost-core/catalog/index.ts | 6 +- packages/ghost/src/ghost-core/catalog/menu.ts | 20 +- .../ghost/src/ghost-core/catalog/types.ts | 6 - packages/ghost/src/ghost-core/glossary.ts | 14 - packages/ghost/src/ghost-core/index.ts | 3 - .../ghost/src/ghost-core/package-manifest.ts | 17 + .../{anti-goal.median.md => cliche.median.md} | 0 .../src/init-payloads/median/median-tells.md | 50 +-- .../ghost/src/init-payloads/skeleton/brand.md | 22 + .../skeleton/context.conversation.md | 24 ++ .../skeleton/foundation.color.md | 39 ++ .../skeleton/foundation.composition.md | 30 ++ .../skeleton/foundation.controls.md | 29 ++ .../skeleton/foundation.layout.md | 45 ++ .../skeleton/foundation.motion.md | 34 ++ .../init-payloads/skeleton/foundation.type.md | 44 ++ .../skeleton/foundation.voice.md | 29 ++ .../src/init-payloads/skeleton/glossary.md | 59 ++- .../skeleton/grammar.color-roles.md | 26 -- .../skeleton/grammar.conversation.md | 23 - .../skeleton/grammar.hierarchy.md | 39 -- .../init-payloads/skeleton/grammar.motion.md | 24 -- .../init-payloads/skeleton/grammar.rhythm.md | 23 - .../skeleton/grammar.surfaces.md | 26 -- .../ghost/src/init-payloads/skeleton/index.md | 27 -- .../skeleton/signature.palette.md | 24 -- .../init-payloads/skeleton/signature.shape.md | 19 - .../skeleton/signature.temperature.md | 20 - .../init-payloads/skeleton/signature.type.md | 22 - packages/ghost/src/observability-events.ts | 3 - packages/ghost/src/review/resolve.ts | 14 +- packages/ghost/src/review/review-packet.ts | 21 - packages/ghost/src/scan/check-scaffold.ts | 4 +- .../src/scan/fingerprint-package-lint.ts | 375 +++++++++++++++++ .../src/scan/fingerprint-package-loader.ts | 31 +- .../ghost/src/scan/fingerprint-package.ts | 308 +------------- packages/ghost/src/scan/templates.ts | 60 +-- packages/ghost/src/skill-bundle/SKILL.md | 28 +- .../references/adapting-a-starter.md | 122 +++--- .../references/authoring-scenarios.md | 6 +- .../src/skill-bundle/references/brief.md | 6 +- .../src/skill-bundle/references/capture.md | 37 +- .../skill-bundle/references/concrete-tiers.md | 95 +++++ .../src/skill-bundle/references/recall.md | 18 +- .../src/skill-bundle/references/schema.md | 39 +- .../skill-bundle/references/steering-audit.md | 11 +- .../ghost/src/skill-bundle/references/wild.md | 30 -- packages/ghost/test/cli.test.ts | 397 ++++++++---------- .../ghost/test/fingerprint-package.test.ts | 149 +++---- .../test/ghost-core/catalog-assemble.test.ts | 13 - packages/vessel-light/.ghost/glossary.md | 1 - packages/vessel-light/.ghost/manifest.yml | 1 + packages/vessel-react/fingerprint/glossary.md | 1 - scripts/check-release-tarball.mjs | 2 +- scripts/check-terminology.mjs | 13 + 65 files changed, 1350 insertions(+), 1364 deletions(-) create mode 100644 .changeset/concrete-tiers-recipe.md create mode 100644 .changeset/cover-node-designer-skeleton.md create mode 100644 .changeset/remove-glossary-posture.md create mode 100644 .changeset/validate-warns-undescribed-nodes.md rename packages/ghost/src/init-payloads/median/{anti-goal.median.md => cliche.median.md} (100%) create mode 100644 packages/ghost/src/init-payloads/skeleton/brand.md create mode 100644 packages/ghost/src/init-payloads/skeleton/context.conversation.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.color.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.composition.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.controls.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.layout.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.motion.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.type.md create mode 100644 packages/ghost/src/init-payloads/skeleton/foundation.voice.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.conversation.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.motion.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/index.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/signature.palette.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/signature.shape.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/signature.temperature.md delete mode 100644 packages/ghost/src/init-payloads/skeleton/signature.type.md create mode 100644 packages/ghost/src/scan/fingerprint-package-lint.ts create mode 100644 packages/ghost/src/skill-bundle/references/concrete-tiers.md delete mode 100644 packages/ghost/src/skill-bundle/references/wild.md diff --git a/.changeset/concrete-tiers-recipe.md b/.changeset/concrete-tiers-recipe.md new file mode 100644 index 00000000..b00ae16a --- /dev/null +++ b/.changeset/concrete-tiers-recipe.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Add the concrete-tiers skill recipe: choosing which concrete code tiers (tokens, skeletons, components, exemplars) a fingerprint carries, and justifying absences. diff --git a/.changeset/cover-node-designer-skeleton.md b/.changeset/cover-node-designer-skeleton.md new file mode 100644 index 00000000..eca35d03 --- /dev/null +++ b/.changeset/cover-node-designer-skeleton.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +Adds a manifest `cover` field — gather inlines the named node above the menu, validate enforces it — and re-authors the skeleton starter in designer-native vocabulary: a brand cover, foundation chapters with open questions, context nodes, and a cliche floor paired with checks. diff --git a/.changeset/remove-glossary-posture.md b/.changeset/remove-glossary-posture.md new file mode 100644 index 00000000..072732b4 --- /dev/null +++ b/.changeset/remove-glossary-posture.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": major +--- + +Removes the glossary posture system. Glossary kinds no longer accept a posture key; `ghost gather` drops the `--wild` flag and lists every node; `ghost pull` orders packets as index, concrete nodes, then prose; `ghost review` reports matched material-backed nodes without a separate review-critical section and renames the `unguarded-material` coverage gap to `unchecked-material`; `ghost pulse` drops the wild usage section. Existing fingerprints keep working: an ignored `posture` key in `glossary.md` frontmatter is tolerated, and anti-goal nodes still gather, pull, and match in review through their materials like any node. diff --git a/.changeset/validate-warns-undescribed-nodes.md b/.changeset/validate-warns-undescribed-nodes.md new file mode 100644 index 00000000..9e52e326 --- /dev/null +++ b/.changeset/validate-warns-undescribed-nodes.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +`ghost validate` warns on nodes without a `description` (rule `node-description-missing`), and the `ghost gather` coverage line reports how many nodes lack descriptions, since an undescribed node is invisible to agent selection. diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 99351c47..e6b334a1 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-09T11:06:22.019Z", + "generatedAt": "2026-07-10T20:19:23.863Z", "tools": [ { "tool": "ghost", @@ -117,14 +117,6 @@ "default": "markdown", "takesValue": true, "negated": false - }, - { - "rawName": "--wild", - "name": "wild", - "description": "Include wild-posture nodes in the menu", - "default": null, - "takesValue": false, - "negated": false } ] }, diff --git a/install/manifest.json b/install/manifest.json index 951098a7..0e61dba3 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -13,10 +13,10 @@ "references/blocks.md", "references/brief.md", "references/capture.md", + "references/concrete-tiers.md", "references/recall.md", "references/schema.md", "references/self-check.md", - "references/steering-audit.md", - "references/wild.md" + "references/steering-audit.md" ] } diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index b22af377..349af2ae 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -34,7 +34,6 @@ export function registerGatherCommand(cli: CAC): void { .option("--format ", "Output format: markdown or json", { default: "markdown", }) - .option("--wild", "Include wild-posture nodes in the menu") .action(async (askParts: string[] | undefined, opts) => { try { if (opts.format !== "markdown" && opts.format !== "json") { @@ -46,22 +45,18 @@ export function registerGatherCommand(cli: CAC): void { const ask = normalizeAsk(askParts); const paths = resolveFingerprintPackage(opts.package, process.cwd()); const loaded = await loadFingerprintPackage(paths); - const wildIds = [...loaded.catalog.nodes.values()] - .filter((node) => node.wild) - .map((node) => node.id) - .sort(); - const includeWild = Boolean(opts.wild); - const menu = buildCatalogMenu(loaded.catalog, { includeWild }); - const exposedWildIds = menu - .filter((entry) => entry.wild) - .map((entry) => entry.id); + const coverId = loaded.manifest.cover; + const coverNode = coverId + ? loaded.catalog.nodes.get(coverId) + : undefined; + const menu = buildCatalogMenu(loaded.catalog).filter( + (entry) => entry.id !== coverNode?.id, + ); const kinds = await readMenuKinds(paths.glossary); await appendGhostEvent(paths.packageDir, { event: "gather", ...(ask ? { ask } : {}), menu: menu.map((entry) => entry.id), - wild: includeWild, - wildIds: exposedWildIds, }); // Ghost does no selection. It emits the catalog; the agent reads the @@ -72,12 +67,12 @@ export function registerGatherCommand(cli: CAC): void { { kind: "menu", ...(ask ? { ask } : {}), + ...(coverNode + ? { cover: { id: coverNode.id, body: coverNode.body } } + : {}), coverage: menuCoverage(menu), ...(kinds.length > 0 ? { kinds } : {}), nodes: menu, - ...(wildIds.length > 0 && !includeWild - ? { wildAvailable: wildIds.length } - : {}), }, null, 2, @@ -87,8 +82,9 @@ export function registerGatherCommand(cli: CAC): void { process.stdout.write( formatMenuMarkdown(menu, kinds, { ask, - wildAvailable: includeWild ? 0 : wildIds.length, - wildIncluded: includeWild && exposedWildIds.length > 0, + cover: coverNode + ? { id: coverNode.id, body: coverNode.body } + : undefined, }), ); } @@ -133,25 +129,38 @@ async function readMenuKinds(glossaryPath: string): Promise { interface FormatMenuOptions { ask?: string; - wildAvailable?: number; - wildIncluded?: boolean; + cover?: { + id: string; + body: string; + }; } function menuCoverage(menu: CatalogMenuEntry[]): { nodes: number; concrete: number; - guards: number; + undescribed: number; } { return { nodes: menu.length, concrete: menu.filter((entry) => entry.concrete).length, - guards: menu.filter((entry) => entry.guard).length, + // A node without a description is a bare id the agent cannot select + // against — surface the count where selection happens. + undescribed: menu.filter( + (entry) => !entry.description || entry.description.trim().length === 0, + ).length, }; } function menuCoverageLine(menu: CatalogMenuEntry[]): string { const coverage = menuCoverage(menu); - return `${coverage.nodes} nodes · ${coverage.concrete} carry concrete material · ${coverage.guards} guards`; + const parts = [ + `${coverage.nodes} nodes`, + `${coverage.concrete} carry concrete material`, + ]; + if (coverage.undescribed > 0) { + parts.push(`${coverage.undescribed} lack descriptions`); + } + return parts.join(" · "); } function formatMenuMarkdown( @@ -162,16 +171,22 @@ function formatMenuMarkdown( const lines: string[] = [ options.ask ? `# Ghost Nodes — for: ${options.ask}` : "# Ghost Nodes", "", - "The fingerprint menu. Match the ask against these nodes and read the ones you judge relevant.", - menuCoverageLine(menu), - "", ]; - if (options.wildIncluded) { + if (options.cover) { lines.push( - "Wild nodes are marked `(wild)`: they push past the fingerprint and require explicit open territory in the brief.", + `## Cover — \`${options.cover.id}\``, + "", + options.cover.body, + "", + "---", "", ); } + lines.push( + "The fingerprint menu. Match the ask against these nodes and read the ones you judge relevant.", + menuCoverageLine(menu), + "", + ); if (kinds.length > 0) { lines.push("Kinds:", ""); for (const kind of kinds) { @@ -181,9 +196,7 @@ function formatMenuMarkdown( } for (const entry of menu) { const kind = entry.kind ? ` _(${entry.kind})_` : ""; - const wild = entry.wild ? " _(wild)_" : ""; - const guard = entry.guard ? " _(guard)_" : ""; - lines.push(`- \`${entry.id}\`${kind}${wild}${guard}`); + lines.push(`- \`${entry.id}\`${kind}`); if (entry.description) lines.push(` - ${entry.description}`); if (entry.materials !== undefined) { lines.push(` - materials: ${entry.materials}`); @@ -194,11 +207,5 @@ function formatMenuMarkdown( ); } } - if ((options.wildAvailable ?? 0) > 0) { - lines.push( - "", - `${options.wildAvailable} wild node${options.wildAvailable === 1 ? "" : "s"} available via \`--wild\`.`, - ); - } return `${lines.join("\n")}\n`; } diff --git a/packages/ghost/src/commands/pull-command.ts b/packages/ghost/src/commands/pull-command.ts index c458b28c..b3d06418 100644 --- a/packages/ghost/src/commands/pull-command.ts +++ b/packages/ghost/src/commands/pull-command.ts @@ -91,7 +91,9 @@ export function registerPullCommand(cli: CAC): void { (id) => catalog.nodes.get(id) as GhostCatalogNode, ); const nodes = - opts.order === "given" ? givenNodes : orderPulledNodes(givenNodes); + opts.order === "given" + ? givenNodes + : orderPulledNodes(givenNodes, loaded.manifest.cover); const pulledNodes = await resolvePulledNodes( nodes, paths.packageDir, @@ -100,15 +102,11 @@ export function registerPullCommand(cli: CAC): void { const counts = sumMaterialCounts(pulledNodes); if (opts.events !== false) { - const wildIds = nodes - .filter((node) => node.wild) - .map((node) => node.id); await appendGhostEvent(paths.packageDir, { event: "pull", ids: known, inlinedMaterials: counts.inlined, omittedMaterials: counts.omitted, - ...(wildIds.length > 0 ? { wildIds } : {}), ...(missed.length > 0 ? { missed } : {}), }); } @@ -138,9 +136,6 @@ export function registerPullCommand(cli: CAC): void { : materials.materials.map(formatJsonMaterial), } : {}), - posture: node.posture, - ...(node.wild ? { wild: true as const } : {}), - ...(node.guard ? { guard: true as const } : {}), body: stripSkeletonSections(node.body), })), skeletons: pulledSkeletons(pulledNodes), @@ -179,17 +174,25 @@ async function resolvePulledNodes( ); } -function orderPulledNodes(nodes: GhostCatalogNode[]): GhostCatalogNode[] { +function orderPulledNodes( + nodes: GhostCatalogNode[], + coverId: string | undefined, +): GhostCatalogNode[] { return nodes - .map((node, index) => ({ node, index, bucket: steeringBucket(node) })) + .map((node, index) => ({ + node, + index, + bucket: steeringBucket(node, coverId), + })) .sort((a, b) => a.bucket - b.bucket || a.index - b.index) .map((entry) => entry.node); } -function steeringBucket(node: GhostCatalogNode): number { - if (node.id === "index" || node.slug === "index") return 0; - if (node.guard) return 3; - if (node.wild) return 2; +function steeringBucket( + node: GhostCatalogNode, + coverId: string | undefined, +): number { + if (coverId !== undefined && node.id === coverId) return 0; if (node.concrete) return 1; return 2; } @@ -264,9 +267,7 @@ function formatPullMarkdown(nodes: PulledNode[]): string { const sections: string[] = []; for (const { node, materials } of nodes) { const kind = node.kind ? ` _(${node.kind})_` : ""; - const wild = node.wild ? " _(wild)_" : ""; - const guard = node.guard ? " _(guard · review-critical)_" : ""; - const lines = [`# \`${node.id}\`${kind}${wild}${guard}`]; + const lines = [`# \`${node.id}\`${kind}`]; if (node.description) lines.push("", `> ${node.description}`); lines.push("", stripSkeletonSections(node.body).trim()); if (materials.materials.length > 0) { diff --git a/packages/ghost/src/commands/pulse-command.ts b/packages/ghost/src/commands/pulse-command.ts index c6de451e..2a1ba6d1 100644 --- a/packages/ghost/src/commands/pulse-command.ts +++ b/packages/ghost/src/commands/pulse-command.ts @@ -29,7 +29,7 @@ export function registerPulseCommand(cli: CAC): void { const paths = resolveFingerprintPackage(opts.package, process.cwd()); const loaded = await loadFingerprintPackage(paths); - const menu = buildCatalogMenu(loaded.catalog, { includeWild: true }); + const menu = buildCatalogMenu(loaded.catalog); const events = await readGhostEvents(paths.packageDir); const report = buildPulseReport(events, menu); @@ -82,10 +82,6 @@ type PulseReport = { kinds: KindHitReport[]; coldNodes: string[]; misses: MissReport[]; - wild: { - exposures: number; - pulls: number; - }; concreteness: ConcretenessReport; }; @@ -108,8 +104,6 @@ function buildPulseReport( let gathers = 0; let pulls = 0; - let wildExposures = 0; - let wildPulls = 0; let abandonedGathers = 0; let activeGatherHasPull = false; let sawGather = false; @@ -123,7 +117,6 @@ function buildPulseReport( for (const id of event.menu) { exposureCounts.set(id, (exposureCounts.get(id) ?? 0) + 1); } - wildExposures += event.wildIds?.length ?? 0; continue; } @@ -132,7 +125,6 @@ function buildPulseReport( for (const id of event.ids) { pullCounts.set(id, (pullCounts.get(id) ?? 0) + 1); } - wildPulls += event.wildIds?.length ?? 0; for (const miss of event.missed ?? []) { recordMiss(missCounts, miss); } @@ -212,10 +204,6 @@ function buildPulseReport( .sort( (a, b) => b.count - a.count || a.requested.localeCompare(b.requested), ), - wild: { - exposures: wildExposures, - pulls: wildPulls, - }, concreteness, }; } @@ -323,14 +311,6 @@ function formatPulseMarkdown(report: PulseReport): string { for (const id of report.coldNodes) lines.push(`- \`${id}\``); } - lines.push( - "", - "## Wild usage", - "", - `- Wild exposures: ${report.wild.exposures}`, - `- Wild pulls: ${report.wild.pulls}`, - ); - lines.push("", "## Misses", ""); if (report.misses.length === 0) { lines.push("None."); diff --git a/packages/ghost/src/ghost-core/catalog/assemble.ts b/packages/ghost/src/ghost-core/catalog/assemble.ts index 1435a4b4..73e7744f 100644 --- a/packages/ghost/src/ghost-core/catalog/assemble.ts +++ b/packages/ghost/src/ghost-core/catalog/assemble.ts @@ -22,10 +22,6 @@ export interface PlacedNode { export interface AssembleCatalogInput { /** Local nodes located in the package files. */ placedNodes?: PlacedNode[]; - /** Kinds whose glossary kind declares `posture: wild`. */ - wildKinds?: Iterable; - /** Kinds whose glossary kind declares `posture: guard`. */ - guardKinds?: Iterable; } /** @@ -35,8 +31,6 @@ export interface AssembleCatalogInput { */ export function assembleCatalog(input: AssembleCatalogInput): GhostCatalog { const nodes = new Map(); - const wildKinds = new Set(input.wildKinds ?? []); - const guardKinds = new Set(input.guardKinds ?? []); for (const placed of input.placedNodes ?? []) { const fm = placed.doc.frontmatter; @@ -44,12 +38,6 @@ export function assembleCatalog(input: AssembleCatalogInput): GhostCatalog { materials: fm.materials, body: placed.doc.body, }); - const posture = - placed.kind !== undefined && wildKinds.has(placed.kind) - ? "wild" - : placed.kind !== undefined && guardKinds.has(placed.kind) - ? "guard" - : "steady"; nodes.set(placed.id, { id: placed.id, ...(placed.kind !== undefined ? { kind: placed.kind } : {}), @@ -58,9 +46,6 @@ export function assembleCatalog(input: AssembleCatalogInput): GhostCatalog { ...(fm.materials !== undefined ? { materials: fm.materials } : {}), concrete, hasSkeleton: extractSkeletonSections(placed.doc.body).length > 0, - posture, - ...(posture === "wild" ? { wild: true as const } : {}), - ...(posture === "guard" ? { guard: true as const } : {}), body: placed.doc.body, }); } diff --git a/packages/ghost/src/ghost-core/catalog/index.ts b/packages/ghost/src/ghost-core/catalog/index.ts index ae97f029..01cab19e 100644 --- a/packages/ghost/src/ghost-core/catalog/index.ts +++ b/packages/ghost/src/ghost-core/catalog/index.ts @@ -10,9 +10,5 @@ export { type PlacedNode, } from "./assemble.js"; export { closestIds } from "./closest.js"; -export { - type BuildCatalogMenuOptions, - buildCatalogMenu, - type CatalogMenuEntry, -} from "./menu.js"; +export { buildCatalogMenu, type CatalogMenuEntry } from "./menu.js"; export type { GhostCatalog, GhostCatalogNode } from "./types.js"; diff --git a/packages/ghost/src/ghost-core/catalog/menu.ts b/packages/ghost/src/ghost-core/catalog/menu.ts index 443d7030..94326bc3 100644 --- a/packages/ghost/src/ghost-core/catalog/menu.ts +++ b/packages/ghost/src/ghost-core/catalog/menu.ts @@ -1,10 +1,5 @@ import type { GhostCatalog } from "./types.js"; -export interface BuildCatalogMenuOptions { - /** Include kinds that declare `posture: wild`; default menus omit them. */ - includeWild?: boolean; -} - /** * One entry in the gather menu: a node presented as `id` + `kind` + * `description` — the retrieval payload the agent selects against. The agent @@ -22,12 +17,6 @@ export interface CatalogMenuEntry { concrete: boolean; /** True when this entry includes a Skeleton section. */ hasSkeleton?: true; - /** Consumption posture for this menu entry. */ - posture: "steady" | "wild" | "guard"; - /** True when this entry pushes past the fingerprint rather than conforming to it. */ - wild?: true; - /** True when this entry is review-critical guard posture. */ - guard?: true; } /** @@ -35,14 +24,10 @@ export interface CatalogMenuEntry { * sorted by id for stable output. A flat catalog — no anchor, no hierarchy; * the agent selects from it. */ -export function buildCatalogMenu( - catalog: GhostCatalog, - options: BuildCatalogMenuOptions = {}, -): CatalogMenuEntry[] { +export function buildCatalogMenu(catalog: GhostCatalog): CatalogMenuEntry[] { const entries: CatalogMenuEntry[] = []; for (const node of catalog.nodes.values()) { - if (node.wild && !options.includeWild) continue; entries.push({ id: node.id, ...(node.kind !== undefined ? { kind: node.kind } : {}), @@ -52,9 +37,6 @@ export function buildCatalogMenu( : {}), concrete: node.concrete, ...(node.hasSkeleton ? { hasSkeleton: true as const } : {}), - posture: node.posture, - ...(node.wild ? { wild: true as const } : {}), - ...(node.guard ? { guard: true as const } : {}), }); } diff --git a/packages/ghost/src/ghost-core/catalog/types.ts b/packages/ghost/src/ghost-core/catalog/types.ts index e35afe53..3528c048 100644 --- a/packages/ghost/src/ghost-core/catalog/types.ts +++ b/packages/ghost/src/ghost-core/catalog/types.ts @@ -18,12 +18,6 @@ export interface GhostCatalogNode { concrete: boolean; /** True when the node declares a `## Skeleton` section. */ hasSkeleton: boolean; - /** Consumption posture derived from the node's glossary kind. */ - posture: "steady" | "wild" | "guard"; - /** True when the node's declared kind has `posture: wild` in the glossary. */ - wild?: true; - /** True when the node's declared kind has `posture: guard` in the glossary. */ - guard?: true; body: string; } diff --git a/packages/ghost/src/ghost-core/glossary.ts b/packages/ghost/src/ghost-core/glossary.ts index f695b484..df79d3af 100644 --- a/packages/ghost/src/ghost-core/glossary.ts +++ b/packages/ghost/src/ghost-core/glossary.ts @@ -1,23 +1,12 @@ import { z } from "zod"; import { splitMarkdownFrontmatter } from "./markdown.js"; -export const GhostGlossaryKindPostureSchema = z.enum([ - "steady", - "wild", - "guard", -]); - -export type GhostGlossaryKindPosture = z.infer< - typeof GhostGlossaryKindPostureSchema ->; - export const GhostGlossaryFrontmatterSchema = z .object({ kinds: z.array( z .object({ name: z.string().min(1), - posture: GhostGlossaryKindPostureSchema.default("steady"), }) .passthrough(), ), @@ -26,8 +15,6 @@ export const GhostGlossaryFrontmatterSchema = z export interface GhostGlossaryKind { name: string; - /** Consumption posture for nodes of this kind. */ - posture: GhostGlossaryKindPosture; /** Prose purpose/normative weight for this kind, parsed from its section. */ purpose: string; } @@ -63,7 +50,6 @@ export function parseGlossary(raw: string): GhostGlossaryParseResult { const sections = parseMarkdownSections(normalizedBody); const kinds = result.data.kinds.map((kind) => ({ name: kind.name, - posture: kind.posture, purpose: sections.get(normalizeHeading(kind.name)) ?? "", })); diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index cb06e4e2..d45dd92a 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -4,7 +4,6 @@ export { type AssembleCatalogInput, assembleCatalog, - type BuildCatalogMenuOptions, buildCatalogMenu, type CatalogMenuEntry, closestIds, @@ -39,8 +38,6 @@ export { type GhostGlossaryDocument, GhostGlossaryFrontmatterSchema, type GhostGlossaryKind, - type GhostGlossaryKindPosture, - GhostGlossaryKindPostureSchema, type GhostGlossaryParseResult, parseGlossary, } from "./glossary.js"; diff --git a/packages/ghost/src/ghost-core/package-manifest.ts b/packages/ghost/src/ghost-core/package-manifest.ts index d0f0dcfa..eb0c2f65 100644 --- a/packages/ghost/src/ghost-core/package-manifest.ts +++ b/packages/ghost/src/ghost-core/package-manifest.ts @@ -11,15 +11,32 @@ const SlugIdSchema = z "id must be a lowercase slug (a-z, 0-9, '.', '_', '-')", ); +const NodeIdSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9./_-]*$/, "cover must be a node id"); + /** `manifest.yml` — anchors a `.ghost/` package. */ export const GhostFingerprintPackageManifestSchema = z .object({ schema: z.literal(GHOST_FINGERPRINT_PACKAGE_SCHEMA), id: SlugIdSchema, + /** + * Optional id of the cover node: the one node gather inlines above the menu + * on every invocation. Guaranteed presence for content selection cannot + * retrieve — essence, temperature, brand-level refusals. + */ + cover: NodeIdSchema.optional(), }) .strict(); export interface GhostFingerprintPackageManifest { schema: typeof GHOST_FINGERPRINT_PACKAGE_SCHEMA; id: string; + /** + * Optional id of the cover node: the one node gather inlines above the menu on + * every invocation. Guaranteed presence for content selection cannot retrieve + * — essence, temperature, brand-level refusals. + */ + cover?: string; } diff --git a/packages/ghost/src/init-payloads/median/anti-goal.median.md b/packages/ghost/src/init-payloads/median/cliche.median.md similarity index 100% rename from packages/ghost/src/init-payloads/median/anti-goal.median.md rename to packages/ghost/src/init-payloads/median/cliche.median.md diff --git a/packages/ghost/src/init-payloads/median/median-tells.md b/packages/ghost/src/init-payloads/median/median-tells.md index fd8e8acd..3ad1d3d6 100644 --- a/packages/ghost/src/init-payloads/median/median-tells.md +++ b/packages/ghost/src/init-payloads/median/median-tells.md @@ -3,45 +3,45 @@ name: Median tells description: Flags the measured defaults of unsteered generation, the deterministic floor, and current model-signature tells — hover-lift, default accents, unprompted dark theme, gradient text, contrast, frequency tells, and per-model signatures. severity: high references: - - anti-goal.median > Hover-lift - - anti-goal.median > Indigo accent - - anti-goal.median > Dark theme - - anti-goal.median > Gradients - - anti-goal.median > Glassmorphism - - anti-goal.median > Side-stripe - - anti-goal.median > Cream surface - - anti-goal.median > Chat bubbles - - anti-goal.median > Stock copy - - anti-goal.median > Celebration - - anti-goal.median > Hero metric - - anti-goal.median > Eyebrow kicker + - cliche.median > Hover-lift + - cliche.median > Indigo accent + - cliche.median > Dark theme + - cliche.median > Gradients + - cliche.median > Glassmorphism + - cliche.median > Side-stripe + - cliche.median > Cream surface + - cliche.median > Chat bubbles + - cliche.median > Stock copy + - cliche.median > Celebration + - cliche.median > Hero metric + - cliche.median > Eyebrow kicker --- These flags target the measured convergence patterns of unsteered model generation, the deterministic floor the median node licenses, and tells specific to individual models. Each is mechanically detectable in a diff. -Pruning a rule from `anti-goal.median` orphans its paired reference here — +Pruning a rule from `cliche.median` orphans its paired reference here — `ghost validate` warns; delete the flag and its reference together. Flag `transform` with `translateY` inside a `:hover` rule on cards, buttons, or list items, especially paired with a shadow increase. Hover confirmation in this fingerprint is color and background change, not lift. -(`anti-goal.median > Hover-lift`) +(`cliche.median > Hover-lift`) Flag accent values in the indigo/blue/purple default family (`#4f46e5`, `#6366f1`, `#2563eb`, `#3b82f6`, `#8b5cf6`, and close neighbors) unless the diff shows the user asked for them. They are model defaults, not -palette members. (`anti-goal.median > Indigo accent`) +palette members. (`cliche.median > Indigo accent`) Flag whole-page dark backgrounds when the ask did not request dark mode. Dark surfaces are a declared brand choice or an explicit theme, never an -unprompted default. (`anti-goal.median > Dark theme`) +unprompted default. (`cliche.median > Dark theme`) Flag `linear-gradient` or `radial-gradient` as page or section -backgrounds, and gradient-filled buttons. (`anti-goal.median > Gradients`) +backgrounds, and gradient-filled buttons. (`cliche.median > Gradients`) Flag `backdrop-filter: blur` used for glassmorphism cards. -(`anti-goal.median > Glassmorphism`) +(`cliche.median > Glassmorphism`) Flag `background-clip: text` (with or without the `-webkit-` prefix) paired with a gradient. Emphasis comes from weight or size in a single @@ -49,14 +49,14 @@ solid color. Flag a thick colored border on one side of an element (`border-left` or a `border-l-*` utility at 2px or more in a non-neutral color) while the -other sides stay thin. (`anti-goal.median > Side-stripe`) +other sides stay thin. (`cliche.median > Side-stripe`) Flag warm off-white page backgrounds in the cream/sand/beige band, and token names like `--cream`, `--sand`, `--parchment`, `--linen` introduced -by the diff. (`anti-goal.median > Cream surface`) +by the diff. (`cliche.median > Cream surface`) Flag assistant messages rendered as bubbles with initials-circle avatars. -(`anti-goal.median > Chat bubbles`) +(`cliche.median > Chat bubbles`) Flag emoji used as icons or imagery in interface chrome. Text labels carry meaning. @@ -64,16 +64,16 @@ meaning. Flag stock template copy in headings: "Simple, transparent pricing", "Welcome back", and interchangeable-with-a-competitor phrasing. Recommend copy that states what this product specifically does. -(`anti-goal.median > Stock copy`) +(`cliche.median > Stock copy`) Flag exclamation-marked success copy, confetti language, and celebratory UI ("You did it!", "Awesome!"). Confirmation is quiet and factual. -(`anti-goal.median > Celebration`) +(`cliche.median > Celebration`) Flag the hero-metric template — a big number, small label, and supporting stats as default proof — unless the metric shows real user data. Recommend evidence specific to the product, or nothing. -(`anti-goal.median > Hero metric`) +(`cliche.median > Hero metric`) Deterministic floor — licensed by the median node, verified here, never steered in prose: @@ -95,7 +95,7 @@ Frequency tells — the crime is repetition, not the move (advisory): Flag three or more uppercase, tracked eyebrow kickers above section headings in one page. One named kicker is voice; a kicker on every section is model -grammar. (`anti-goal.median > Eyebrow kicker`) +grammar. (`cliche.median > Eyebrow kicker`) Flag five or more em-dashes in body copy in one view. diff --git a/packages/ghost/src/init-payloads/skeleton/brand.md b/packages/ghost/src/init-payloads/skeleton/brand.md new file mode 100644 index 00000000..f83485d4 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/brand.md @@ -0,0 +1,22 @@ +--- +description: "The brand on one page — always in context; what this brand is, how it feels, and what only it refuses." +--- + +This cover is unwritten. It is the one page always in an agent's context, so +it carries only what cannot be retrieved by task: what this brand is about, +in one paragraph, in the brand's own voice. The temperature its words and +motion share. And the refusals only this brand makes — not generic don'ts +(those live in each foundation's misuse list, and the model's cliches live +in the cliche nodes), but the lines this brand alone draws. + +Until a human writes it, the working stance is: quiet, precise, content +first, decoration never. Treat that as provisional and say so in your +report. + +One test admits a sentence to this page: where would a violation show? In a +single element, it belongs in that element's chapter. In a single view, it +belongs in the composition foundation. Only across the whole body of work — +temperature, density, restraint — it belongs here. + +The budget is one screen. When this page is real, delete every sentence of +scaffolding above — including this one. diff --git a/packages/ghost/src/init-payloads/skeleton/context.conversation.md b/packages/ghost/src/init-payloads/skeleton/context.conversation.md new file mode 100644 index 00000000..c84fcd36 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/context.conversation.md @@ -0,0 +1,24 @@ +--- +description: "AI conversation threads — assistant text on the page surface, compact user turns, collapsed tool calls, one structured prompt input. Read only for chat threads, agent consoles, and prompt composers." +--- + +In this context: AI conversation threads, agent consoles, review assistants, +and prompt composers. Elsewhere, the defaults hold. + +Conversation UI is not chat cosplay. The assistant speaks on the page +surface as plain text: no bubble, no border, no fill. Wrapping assistant +messages in cards makes the system look defensive and wastes density — +hierarchy comes from prose, spacing, and type. + +User turns are compact muted surfaces aligned right. They mark authorship +without turning the thread into alternating balloons. + +Tool calls are operational evidence. Collapse them to a labeled one-line +summary with status; expand only when the user asks, then show mono content +inside the disclosed area. + +The prompt input is one bordered surface. The textarea stays empty of +controls so writing remains the focus; attachments, model choice, and send +live in a single row below it. There is one primary send action — stop and +send are mutually exclusive states of the same action area, never two +competing primaries. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.color.md b/packages/ghost/src/init-payloads/skeleton/foundation.color.md new file mode 100644 index 00000000..94455ea3 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.color.md @@ -0,0 +1,39 @@ +--- +description: "Color — the role system, status meanings, expression rules, and misuse; palette values are an open question. Read before choosing any color." +--- + +## Usage + +Color is assigned by role, not by taste. The roles are the source of truth; +raw color values are implementation detail, never product language. If a +container needs a color, it first needs a role: `background`, `foreground`, +`card`, `popover`, `primary`, `secondary`, `muted`, `accent`, `border`, +`input`, `ring`, and the status roles. + +The status roles — destructive, success, warning, info — exist only when +meaning demands them. Destructive means destructive or error. Success means +success. None of them are brand accents. + +One view does not perform a color palette. If a status color is present, the +rest of the view stays on the base roles. Richness beyond this comes from a +closed expression set (`--expression-*`), used at the volume the situation +allows — a marketing page may turn it up; a settings form stays quiet. + +## Palette + +Open — ask the human; do not freehand. The fixed relationship: a quiet base +spine is the default atmosphere in every medium, and a closed expression set +supplies the rest, its volume set by situation, never by taste. The open +question: what is this brand's base spine, and what are the named hues of +its expression set — how many, and which? When the human answers, restate +this section as the brand's current answer and record the values where your +materials live. Until then, proceed with a quiet provisional spine and label +it provisional. + +## Misuse + +- Status colors never moonlight as atmosphere, in any context. +- Expression never touches what you click. Buttons, inputs, and links stay + on the base roles everywhere. A colored control is a different design + system. +- No one-off hex values. If a color has no role, it has no place. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.composition.md b/packages/ghost/src/init-payloads/skeleton/foundation.composition.md new file mode 100644 index 00000000..4d80389f --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.composition.md @@ -0,0 +1,30 @@ +--- +description: "Composition — how a view is assembled: one focal point, one primary action, actions land last, separation escalates. Read before assembling any view." +--- + +## Usage + +Composition is the layer above layout: layout gives the stacks and gaps; +composition decides what dominates, what recedes, and what comes last. + +Every view has exactly one focal point — one element at the largest scale. +If two elements compete for primacy, demote or delete one; never resolve +the tie by making both big. + +One primary action per view: the action the view exists to complete. +Everything else steps down the control ladder (see the controls +foundation). If two buttons both look primary, the composition failed. + +Actions land last. Calls to action render at the end of reading order — +the user reads, then decides. + +Separation escalates: whitespace first, then a divider, then a container — +in that order, never skipping a step. + +## Misuse + +- Never fake a focal point with decoration. Primacy comes from scale, + position, and space. +- Never promote a second action to primary because something wants + emphasis; step the other one down instead. +- Never separate with a container when whitespace would do. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.controls.md b/packages/ghost/src/init-payloads/skeleton/foundation.controls.md new file mode 100644 index 00000000..a5158b37 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.controls.md @@ -0,0 +1,29 @@ +--- +description: "Controls — the five-rung emphasis ladder, quiet fields, and misuse. Read for any view with actions or inputs." +--- + +## Usage + +The control emphasis ladder is exactly five rungs: primary, secondary, +outline, ghost, link. Primary is for the action the screen exists to +complete; everything else steps down the ladder. How many primaries a view +may hold is a composition rule — see the composition foundation: one. + +Destructive is a meaning, not a rung. It is rare, it names the destructive +act directly, and it is never borrowed for urgency, emphasis, or brand heat. + +Fields are quiet until active: hairline border, muted placeholder, clear +label, and a focus ring when the user engages. The ring is guidance, not +decoration. Errors state facts next to the field that caused them — never +hidden in modals, toasts, or generic banners when the user needs to fix one +input. + +Controls take the control radius; see the layout foundation for the two +radius roles. + +## Misuse + +- Never two competing primary buttons. +- Never destructive styling as attention-getting. +- Never expression color on a control — what you click stays on the base + roles everywhere. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.layout.md b/packages/ghost/src/init-payloads/skeleton/foundation.layout.md new file mode 100644 index 00000000..27ce05ce --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.layout.md @@ -0,0 +1,45 @@ +--- +description: "Layout — stacks with five gap steps, surface roles, three elevation tiers, the two radius roles, and misuse; radius values are an open question. Read before laying anything out." +--- + +## Usage + +All layout is stacks. Rhythm comes from relationships between siblings, not +from isolated margins pasted onto whichever element was last touched. The +gap steps are exactly five: `--gap-xs` through `--gap-xl`. Choose the gap +that states the relationship; do not tune by single pixels to make a +screenshot pass. The default is column, medium gap. Columns are for almost +everything — forms, cards, modal bodies, settings, page sections. Rows are +conditional: controls, metadata lines, paired label/value moments. If a row +wraps awkwardly, it wanted to be a column. + +Surface is the only way an element gets a background, border, radius, or +shadow. The vocabulary is closed: role, padding, radius, border, elevation. +The default surface is flat — no border, no shadow. We do not outline +everything to prove layout exists. + +The elevation tiers are exactly three: card, popover, modal. Elevation +implies hierarchy — a card sits in flow, a popover floats above it, a modal +interrupts the task. Pick the tier that matches the interaction. + +Borders are structural — inputs and overlays — not decorative frames around +ordinary text. Use space, tone, and type hierarchy instead. + +## Radius + +Open — ask the human; do not freehand. The fixed relationship: controls and +surfaces carry different radius roles, and the two never swap. Buttons and +inputs take `--radius-control`; cards and containers take +`--radius-surface`. One radius for what you click, one for what contains. +The open question: how round is a control, and how round is a surface? When +answered, restate this section as the brand's current answer. Until then, +choose provisional values and label them. + +## Misuse + +- No ad-hoc margins between siblings — they hide the rhythm. Change the + stack gap or split the stack. +- No custom shadows because a composition feels flat, and no modal gravity + on a routine card. Component shadows belong to the primitives that own + them. +- Never one radius everywhere by reflex. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.motion.md b/packages/ghost/src/init-payloads/skeleton/foundation.motion.md new file mode 100644 index 00000000..268f53a8 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.motion.md @@ -0,0 +1,34 @@ +--- +description: "Motion — evidence of state change, three durations and one ease, nothing loops, and misuse; the ease's character is an open question shared with voice. Read for any transition or hover treatment." +--- + +## Usage + +Motion is evidence of a state change. It confirms hover, press, reveal, +collapse, entrance, exit, and spatial movement. It does not entertain. + +The entire vocabulary is three duration roles and one ease. Fast is for +hover and press. Normal is for reveals, fades, and small state changes. Slow +is reserved for spatial transitions where the user needs to understand +movement. Prefer opacity and small transforms. + +In editorial and marketing contexts, entrances may be staged — scroll +reveals and section transitions are part of editorial rhythm, still built +from the three durations and the one ease. In product UI the same staging is +decoration. + +## Character + +Open — ask the human; do not freehand. Motion and voice share one +temperature: however warm or cool the brand's words are, its movement +matches. See the voice foundation for the other half of this answer. The +open question here: what character does the one ease carry? When answered, +restate this section. Until then, use a quiet standard ease and label it +provisional. + +## Misuse + +- Nothing loops except explicit loading states. Decorative pulsing, + floating, glowing, and attention-seeking keyframes are off-language. +- No novelty easings because a surface feels static. +- If removing an animation does not reduce comprehension, it was decoration. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.type.md b/packages/ghost/src/init-payloads/skeleton/foundation.type.md new file mode 100644 index 00000000..a082d8ff --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.type.md @@ -0,0 +1,44 @@ +--- +description: "Type — six text variants, seven tones, the one-voice rule, and misuse; the typeface is an open question. Read for any view that contains text." +--- + +## Usage + +Hierarchy is a closed vocabulary, not a size slider. Every piece of text +picks from a named set; anything outside the set is a broken primitive, not +a variant. + +The text variants are exactly six: display, headline, title, body, label, +mono. Variant names are jobs. Display leads a rare editorial moment. +Headline names a section. Title anchors a card, dialog, or compact region. +Body carries reading. Label names structure — field labels, category tags, +metadata, compact status. Mono carries code and machine detail. + +The tones are exactly seven: default, muted, inverse, success, warning, +info, destructive. Tone is part of the message. Hierarchy starts with tone +and weight before size — a secondary note usually wants muted body or label, +not a smaller custom font. If prose needs emphasis, improve the sentence +before adding a style. + +The heading scale is editorial: it exists for pages composed outside the +text variants — heroes and editorial moments. Product UI never mixes the two +vocabularies in one view. A modal title is not a poster. + +Balanced text is for headings and compact statements, never long body copy. + +## Typeface + +Open — ask the human; do not freehand. The fixed relationship: one typeface +is the voice of the interface, everywhere, with a mono partner only for +code, tool detail, and machine output. The open question: what typeface is +this brand's voice, what mono partners it, and what rhythm does the +editorial heading scale carry? When answered, restate this section as the +brand's current answer. Until then, choose a quiet provisional pair and +label it provisional. + +## Misuse + +- No faked hierarchy with arbitrary font sizes. Choose the variant that + matches the job. +- Status tones appear only when the words carry that state meaning. +- Never a second display face to make a view feel branded. diff --git a/packages/ghost/src/init-payloads/skeleton/foundation.voice.md b/packages/ghost/src/init-payloads/skeleton/foundation.voice.md new file mode 100644 index 00000000..7efae7c0 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/foundation.voice.md @@ -0,0 +1,29 @@ +--- +description: "Voice and tone — copy states facts, no performed personality, and misuse; warmth is an open question shared with motion. Read for any copy." +--- + +## Usage + +Copy states what happened, what is possible, or what the user must decide. +It does not perform personality. Confidence comes from precision, not +enthusiasm. + +Voice and motion carry the same temperature: a brand whose copy is warm does +not move coldly, and a brand whose copy is spare does not bounce. When you +set one, check the other. + +## Warmth + +Open — ask the human; do not freehand. The open question: how warm or cool +does this brand sound — and, by the shared temperature, how does it move? +See the motion foundation for the other half. When answered, restate this +section as the brand's current answer. Until then, write plainly and label +the tone provisional. + +## Misuse + +- No exclamation points as a substitute for having something to say. +- No apologizing for the interface ("Oops!"). State the fact and the next + step. +- Never restate this section as answered when it was your own provisional + choice. diff --git a/packages/ghost/src/init-payloads/skeleton/glossary.md b/packages/ghost/src/init-payloads/skeleton/glossary.md index 053ffb08..fcbfe1ed 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -1,44 +1,39 @@ --- kinds: - - name: grammar - - name: signature - - name: register - - name: anti-goal - posture: guard + - name: foundation + - name: context + - name: cliche --- -# grammar +# foundation -The brand's decision logic: closed sets, role vocabularies, and assembly -rules, stated in token roles and never in literal values. Grammar survives an -adaptation unchanged — swap every value and these nodes still hold. Gather -grammar before inventing structure. +The core elements — color, type, controls, layout, motion, voice — plus +composition, the rules for assembling them into a view. Each foundation +node is a chapter: usage rules that hold no matter what the brand values +turn out to be, the brand's open questions (unanswered in this starter, +marked as decisions only a human can make), and the chapter's misuse list. +Follow the usage rules as written. Never fill in an open value and present +it as the brand's. -# signature +# context -The dials: the choices that make a brand this brand. In this starter every -signature node is unanswered — it states the fixed relationship worth -keeping and asks the question only a human can answer. When the human -answers, restate the node as the brand's current answer. Gather signature -before setting any value a dial governs. +Where the defaults bend: a context names a situation — an AI conversation +thread, a data-dense console, a transactional email — and states only what +inverts there. Read a context only when its situation matches the task. +Rules from the wrong context are contamination, not guidance. -# register +# cliche -A condition-scoped contract: a situation — data-dense consoles, editorial -pages, transactional email — where parts of the default rules invert. Each -register names its condition first. Truths from the wrong register are -poison, not context; gather a register only when its condition matches the -task. This starter declares the kind and ships no register nodes: registers -invert answered defaults, and the dials are not answered yet. - -# anti-goal - -What this package refuses, each reject paired with its replacement. -`anti-goal.median` is the model's floor, not the brand's taste. Gather -anti-goals before styling anything greenfield. +The defaults a generative model falls back on when no brand is steering it. +These are nobody's brand. Each entry pairs the tired move with the honest +replacement. Gather before styling anything greenfield; enforced by paired +checks at review. --- -The ghost skill bundle teaches authoring — its `adapting-a-starter` recipe is -the procedure for growing this skeleton into a real fingerprint: answer the -dials, prune the median floor, edit your values, then generate refs and tells. +The cover node (`brand.md`, declared in the manifest) is the one page always +in context: what the brand is, its temperature, and the refusals only it +makes. The ghost skill's `adapting-a-starter` recipe is the procedure for +growing this skeleton into a real fingerprint: write the cover, answer each +chapter's open question, prune the cliches your chapters absorb, then add +materials and refs. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md b/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md deleted file mode 100644 index eb4f679d..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: "Semantic color roles and the rules that govern them — gather before choosing any color; roles not raw values, status is meaning, expression is register-gated and never on controls." ---- - -The token roles are the source of truth. An agent may combine roles, but it -may not author around them. Raw color values are implementation detail, never -product language. - -Author with semantic roles: `background`, `foreground`, `card`, `popover`, -`primary`, `secondary`, `muted`, `accent`, `border`, `input`, `ring`, and the -status roles. If a container needs a color, it first needs a role. - -The status roles — destructive, success, warning, info — exist only when -meaning demands them. Destructive means destructive or error. Success means -success. Warning means warning. Info means information. None of them are -brand accents, and they never moonlight as atmosphere, in any register. - -One view should not perform a color palette. If a status color is present, -let the rest of the view stay on the base roles. Richness beyond this is -register-gated: a closed expression set (`--expression-*`) exists; its size -and members are a dial — see the palette signature — and each register caps -how loud they may be. - -The constant that holds across every register: expression never touches what -you click. Buttons, inputs, and links stay on the base roles everywhere. A -colored control is a different design system. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.conversation.md b/packages/ghost/src/init-payloads/skeleton/grammar.conversation.md deleted file mode 100644 index fc2e1b63..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.conversation.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: "Conversation grammar — plain assistant text, compact user surfaces, collapsed tool calls, one structured prompt input; gather for any AI thread, agent console, review assistant, or prompt composer." ---- - -Conversation UI is not chat cosplay. The assistant speaks on the page surface -as plain text: no bubble, no border, no fill. - -User turns are compact muted surfaces aligned right. They mark authorship -without turning the thread into alternating balloons. - -Assistant hierarchy comes from prose, spacing, and type. Wrapping assistant -messages in cards makes the system look defensive and wastes density. - -Tool calls are operational evidence. Collapse them to a labeled one-line -summary with status. Expand only when the user asks for detail, then show -mono content inside the disclosed area. - -The prompt input is one bordered surface. The textarea region stays empty of -controls so writing remains the focus. Attachments, model choices, secondary -tools, and send live in a single row below it. - -There is one primary send action. Stop and send are mutually exclusive states -of the same action area, not two competing primary buttons. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md b/packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md deleted file mode 100644 index 97f2c62b..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -description: "The closed hierarchy vocabulary — six text variants, seven tones, a five-rung control emphasis ladder, one primary per view — gather for any view that contains text or actions." ---- - -Hierarchy is a closed vocabulary, not a size slider. Every piece of text and -every control picks from a named set; anything outside the set is a broken -primitive, not a variant. - -The text variants are exactly six: display, headline, title, body, label, -mono. Variant names are jobs, not decoration. Display leads a rare editorial -moment. Headline names a section. Title anchors a card, dialog, or compact -region. Body carries reading. Label names structure — category tags, field -labels, bylines, metadata, compact status. Mono carries code and machine -detail. Do not fake hierarchy with arbitrary font sizes; choose the tier that -matches the job, then use tone, weight, and spacing for the rest. - -The tones are exactly seven: default, muted, inverse, success, warning, info, -destructive. Tone is part of the message. Default is the main reading plane. -Muted carries secondary information. Inverse is for dark or primary surfaces. -The four status tones appear only when the words have that state meaning. -Hierarchy starts with tone and weight before size — a secondary note usually -wants muted body or label, not a smaller custom font. If the prose needs -emphasis, improve the sentence before adding a style. - -The control emphasis ladder is exactly five rungs: primary, secondary, -outline, ghost, link. One primary action per view is the rule; everything -else steps down the ladder. Primary is for the action the screen exists to -complete — if two buttons both look primary, the hierarchy failed. -Destructive is a meaning, not a rung: it is rare, it names the destructive -act directly, and it is never borrowed for urgency, emphasis, or brand heat. - -Fields are quiet until they are active: hairline border, muted placeholder, -clear label, and a focused ring when the user engages. The focus ring is -guidance, not decoration. Errors state facts next to the field that caused -them — do not hide field errors in modals, toasts, or generic banners when -the user needs to fix one input. - -Balanced text is for headings and compact statements. Do not balance long -body copy into jagged reading. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.motion.md b/packages/ghost/src/init-payloads/skeleton/grammar.motion.md deleted file mode 100644 index a2d95094..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.motion.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: "Motion doctrine — motion is evidence of state change, never decoration; a closed vocabulary of three duration roles and one ease; gather for any transition, animation, or hover treatment." ---- - -Motion is evidence of a state change. It confirms hover, press, reveal, -collapse, entrance, exit, and spatial movement. It does not entertain. - -The entire vocabulary is three duration roles and one ease. Fast is for hover -and press. Normal is for reveals, fades, and small state changes. Slow is -reserved for spatial transitions where the user needs to understand movement. -Do not introduce novelty easings because a surface feels static; the ease's -character is a brand answer — see the temperature signature. - -Nothing loops except explicit loading states. A spinner may continue because -work continues. Decorative pulsing, floating, glowing, and attention-seeking -keyframes are off-language. - -Prefer opacity and small transform changes. If removing an animation does not -reduce comprehension, the animation was decoration. - -Condition: marketing and editorial surfaces may stage entrances — scroll -reveals and section transitions are part of editorial rhythm, still built -from the three durations and the one ease. In product UI the same staging is -decoration. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md b/packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md deleted file mode 100644 index 172b72ef..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: "Layout rhythm — all layout is stacks with a closed gap step set; gather before laying anything out; never ad-hoc sibling margins." ---- - -All layout is stacks. Rhythm comes from relationships between siblings, not -from isolated margins pasted onto whichever element was last touched. - -The gap steps are exactly five: `--gap-xs`, `--gap-sm`, `--gap-md`, -`--gap-lg`, `--gap-xl`. Choose the gap that states the relationship. Do not -tune by single pixels to make a screenshot pass. - -The default is column, medium gap, stretch alignment, start justification. -That is the ordinary reading rhythm. - -Columns are for almost everything: forms, cards, message lists, modal bodies, -settings, empty states, and page sections. A column lets the user scan. - -Rows are conditional. Use them for controls, metadata lines, compact status, -and paired label/value moments. If a row starts wrapping awkwardly, it -probably wanted to be a column. - -Ad-hoc margins between siblings are forbidden because they hide the rhythm. -When spacing feels wrong, change the stack gap or split the stack. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md b/packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md deleted file mode 100644 index b9b4ce24..00000000 --- a/packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: "Surface roles and the closed elevation set — gather for any card, popover, modal, dialog, scrim, or bordered container; flat is the default, borders are structural, exactly three elevation tiers." ---- - -Surface is the only way an element gets a background, border, radius, or -shadow. If a container needs visual treatment, it first needs a surface role. - -The vocabulary is closed: role, padding, radius, border, and elevation. These -axes create enough range for page flow, cards, popovers, muted blocks, -accents, and dark moments without inventing one-off boxes. - -The default surface is flat: no border, no shadow. We do not outline -everything to prove layout exists. - -The elevation tiers are exactly three: card, popover, modal. Elevation -implies hierarchy — a card sits in the document flow, a popover floats above -the flow, a modal interrupts the task. Pick the tier that matches the -interaction; never write a custom shadow because the composition feels flat, -and never pair a low tier with an interrupting role or give a routine card -modal gravity. Component shadows belong to the primitives that own them; -never borrow them for layout. - -Borders are structural, especially for inputs and overlays. They are not -decorative frames around ordinary text — reject borders as decoration, -especially around assistant text, and use space, tone, and type hierarchy -instead. diff --git a/packages/ghost/src/init-payloads/skeleton/index.md b/packages/ghost/src/init-payloads/skeleton/index.md deleted file mode 100644 index 303364f0..00000000 --- a/packages/ghost/src/init-payloads/skeleton/index.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: "Always read first — the trust tiers of this starter fingerprint, the unanswered dials, and how to work before they are answered." ---- - -This is a skeleton fingerprint: the adaptation-safe law of good interface work, -with every brand decision left explicitly open. It steers an agent away from -the model's median from the first generation, without pretending to be a -brand it is not. - -The corpus carries two trust tiers. Grammar and the median floor are law: -safe to consume verbatim, unchanged by any adaptation — they speak in -token roles and closed sets, never in literal values. Signature nodes are -dials, and in this starter every dial is unanswered: each states the fixed -relationship worth keeping and the question only a human can answer. Do not -freehand a dial's value and present it as brand-backed — ask the human or -flag the gap. - -The reset raises the floor; only the fingerprint escapes the median. - -Unanswered dials do not block work; they mark what your report must flag. -When generating before the dials are answered, define the grammar's token -roles in your own stylesheet with provisional values — the role names are -the contract; the values are placeholders until the dials say otherwise. - -When a requested UI is not covered by a node, compose from the grammar. Do -not invent a new styling system. If the result needs a new pattern, the new -pattern should come from observed drift, not anticipation. diff --git a/packages/ghost/src/init-payloads/skeleton/signature.palette.md b/packages/ghost/src/init-payloads/skeleton/signature.palette.md deleted file mode 100644 index 530d8784..00000000 --- a/packages/ghost/src/init-payloads/skeleton/signature.palette.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: "Gather whenever color beyond the base roles is in question. The palette dial: a quiet base spine plus a closed expression set at situation-gated volume — this dial is unanswered; ask the human or flag the gap, don't freehand it." ---- - -This dial is unanswered — ask the human or flag the gap. Do not freehand a -value and present it as brand-backed. - -The relationship is fixed: one brand at different volumes. A quiet base -spine is the default atmosphere in every medium — calm, legible, and -resistant to novelty — and a closed expression set supplies the atmosphere, -with volume set by situation, never by taste. Two constants outrank any -volume decision: expression never touches what you click — buttons, inputs, -and links stay on the base roles everywhere — and the status roles are not -expression; they keep their meanings everywhere and never moonlight as -atmosphere. The ladder — a quiet spine, a closed hue set, situation-gated -volume — is the part worth keeping. - -The open question: what is this brand's base spine, and what are the named -hues of its closed expression set — how many, and which? When the human -answers, restate this node as "this brand's current answer is …" and record -the values where your materials live. If the task can't wait for an answer, -proceed with a quiet provisional choice, honor the fixed relationship, label -the choice provisional in your report — and never restate this node as -answered. diff --git a/packages/ghost/src/init-payloads/skeleton/signature.shape.md b/packages/ghost/src/init-payloads/skeleton/signature.shape.md deleted file mode 100644 index 3f8ceb2d..00000000 --- a/packages/ghost/src/init-payloads/skeleton/signature.shape.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: "Gather before setting any radius or corner treatment. The shape dial: controls take --radius-control, surfaces take --radius-surface — this dial is unanswered; ask the human or flag the gap, don't freehand it." ---- - -This dial is unanswered — ask the human or flag the gap. Do not freehand a -value and present it as brand-backed. - -The relationship is fixed: controls and surfaces carry different radius -roles, and the two never swap. Buttons and text inputs use -`--radius-control`; cards and other surfaces use `--radius-surface`. One -radius for what you click, one for what contains — that split is the part -worth keeping, whatever the values turn out to be. - -The open question: what are this brand's two radii — how round is a control, -and how round is a surface? When the human answers, restate this node as -"this brand's current answer is …" and record the values where your -materials live. If the task can't wait for an answer, proceed with a quiet -provisional choice, honor the fixed relationship, label the choice -provisional in your report — and never restate this node as answered. diff --git a/packages/ghost/src/init-payloads/skeleton/signature.temperature.md b/packages/ghost/src/init-payloads/skeleton/signature.temperature.md deleted file mode 100644 index 72ffad88..00000000 --- a/packages/ghost/src/init-payloads/skeleton/signature.temperature.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: "Gather for any copy and for motion character. The temperature dial: how the brand sounds and how it moves, one shared temperature — this dial is unanswered; ask the human or flag the gap, don't freehand it." ---- - -This dial is unanswered — ask the human or flag the gap. Do not freehand a -value and present it as brand-backed. - -The relationship is fixed: voice and motion carry the same temperature. Copy -states what happened, what is possible, or what the user must decide — it -does not perform personality — and motion confirms rather than entertains. -The coupling — words and motion sharing one temperature — is the part worth -keeping. - -The open question: where does this brand sit — how warm or cool is its copy, -what does its motion feel like, and what character does its one ease carry? -When the human answers, restate this node as "this brand's current answer -is …" and record the values where your materials live. If the task can't -wait for an answer, proceed with a quiet provisional choice, honor the fixed -relationship, label the choice provisional in your report — and never -restate this node as answered. diff --git a/packages/ghost/src/init-payloads/skeleton/signature.type.md b/packages/ghost/src/init-payloads/skeleton/signature.type.md deleted file mode 100644 index e838028b..00000000 --- a/packages/ghost/src/init-payloads/skeleton/signature.type.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -description: "Gather for any text, and for heroes, landing pages, and editorial moments. The type dial: one voice typeface plus a mono for machine detail, editorial scale kept separate from product text — this dial is unanswered; ask the human or flag the gap, don't freehand it." ---- - -This dial is unanswered — ask the human or flag the gap. Do not freehand a -value and present it as brand-backed. - -The relationship is fixed: one typeface is the voice of the interface, -everywhere, with a mono variant only for code, tool detail, and machine -output. The heading scale is editorial — it exists for pages composed -outside the text variants, heroes and editorial moments — and product UI -never mixes the two vocabularies in one view. A modal title is not a poster. -The one-voice rule and the editorial/product split are the parts worth -keeping. - -The open question: what typeface is this brand's voice, what mono partners -it, and what rhythm does the editorial heading scale carry? When the human -answers, restate this node as "this brand's current answer is …" and record -the values where your materials live. If the task can't wait for an answer, -proceed with a quiet provisional choice, honor the fixed relationship, label -the choice provisional in your report — and never restate this node as -answered. diff --git a/packages/ghost/src/observability-events.ts b/packages/ghost/src/observability-events.ts index d2b492ee..3b988568 100644 --- a/packages/ghost/src/observability-events.ts +++ b/packages/ghost/src/observability-events.ts @@ -10,8 +10,6 @@ export type GatherObservabilityEvent = { ask?: string; menu: string[]; materials?: string[]; - wild?: boolean; - wildIds?: string[]; }; export type PullMiss = { @@ -23,7 +21,6 @@ export type PullObservabilityEvent = { ts: string; event: "pull"; ids: string[]; - wildIds?: string[]; missed?: PullMiss[]; inlinedMaterials?: number; omittedMaterials?: number; diff --git a/packages/ghost/src/review/resolve.ts b/packages/ghost/src/review/resolve.ts index 10c6c909..294d6273 100644 --- a/packages/ghost/src/review/resolve.ts +++ b/packages/ghost/src/review/resolve.ts @@ -22,7 +22,7 @@ export interface OfferedCheck { } export interface CoverageGap { - kind: "unmatched-file" | "unguarded-material"; + kind: "unmatched-file" | "unchecked-material"; detail: string; files?: string[]; nodes?: string[]; @@ -31,7 +31,6 @@ export interface CoverageGap { export interface ReviewResolution { touchedFiles: TouchedFile[]; materialNodes: MatchedMaterialNode[]; - guardNodes: MatchedMaterialNode[]; offeredChecks: OfferedCheck[]; gaps: CoverageGap[]; } @@ -116,15 +115,15 @@ export function resolveReview( }); } - const unguarded = [...touchedMaterialNodes].filter( + const unchecked = [...touchedMaterialNodes].filter( (id) => !referencedMaterialNodes.has(id), ); - if (unguarded.length > 0) { + if (unchecked.length > 0) { gaps.push({ - kind: "unguarded-material", + kind: "unchecked-material", detail: "touched material-backed nodes have no check referencing them — review coverage is missing", - nodes: unguarded, + nodes: unchecked, }); } @@ -137,9 +136,6 @@ export function resolveReview( return { touchedFiles, materialNodes: matchedNodes, - guardNodes: matchedNodes.filter( - (node) => catalog.nodes.get(node.id)?.guard, - ), offeredChecks, gaps, }; diff --git a/packages/ghost/src/review/review-packet.ts b/packages/ghost/src/review/review-packet.ts index 34f72895..0f5ba4c8 100644 --- a/packages/ghost/src/review/review-packet.ts +++ b/packages/ghost/src/review/review-packet.ts @@ -34,7 +34,6 @@ export interface ReviewPacket { fingerprintId: string; touchedFiles: string[]; materialNodes: PacketMaterialNode[]; - guardNodes: PacketMaterialNode[]; checks: PacketCheck[]; gaps: CoverageGap[]; diff: string; @@ -69,10 +68,6 @@ export async function buildReviewPacket( (matched) => materialNodeFromMatch(fingerprint, matched), ); - const guardNodes: PacketMaterialNode[] = resolution.guardNodes.map( - (matched) => materialNodeFromMatch(fingerprint, matched), - ); - const checks: PacketCheck[] = await Promise.all( resolution.offeredChecks.map(async (offered) => { const check = fingerprint.checks.get(offered.id); @@ -103,7 +98,6 @@ export async function buildReviewPacket( fingerprintId: fingerprint.manifest.id, touchedFiles: resolution.touchedFiles.map((file) => file.path), materialNodes, - guardNodes, checks, gaps: resolution.gaps, diff: diffText, @@ -162,21 +156,6 @@ export function formatReviewPacket(packet: ReviewPacket): string { } } - if (packet.guardNodes.length > 0) { - out.push("## Matched guard nodes — review-critical"); - for (const node of packet.guardNodes) { - const kind = node.kind ? ` _(${node.kind})_` : ""; - out.push(`### \`${node.id}\`${kind}`); - if (node.description) out.push(`_${node.description}_`, ""); - out.push(node.prose, ""); - out.push("Matched materials:"); - for (const locator of node.matchedMaterials) out.push(`- \`${locator}\``); - out.push("Files:"); - for (const file of node.files) out.push(`- \`${file}\``); - out.push(""); - } - } - out.push("## Offered checks — weigh which apply"); if (packet.checks.length === 0) { out.push("_No checks were offered for this diff._", ""); diff --git a/packages/ghost/src/scan/check-scaffold.ts b/packages/ghost/src/scan/check-scaffold.ts index 4e23f9d9..86335871 100644 --- a/packages/ghost/src/scan/check-scaffold.ts +++ b/packages/ghost/src/scan/check-scaffold.ts @@ -39,7 +39,7 @@ export async function addChecksDir( const skipped: string[] = []; await mkdir(checksDir, { recursive: true }); - if (await exists(join(packageDir, "anti-goal.median.md"))) { + if (await exists(join(packageDir, "cliche.median.md"))) { await writeFile( join(checksDir, MEDIAN_TELLS_FILENAME), await loadPayloadFile("median", MEDIAN_TELLS_FILENAME), @@ -47,7 +47,7 @@ export async function addChecksDir( ); written.push(MEDIAN_TELLS_FILENAME); } else { - skipped.push(`${MEDIAN_TELLS_FILENAME} (no anti-goal.median node)`); + skipped.push(`${MEDIAN_TELLS_FILENAME} (no cliche.median node)`); } await writeFile( diff --git a/packages/ghost/src/scan/fingerprint-package-lint.ts b/packages/ghost/src/scan/fingerprint-package-lint.ts new file mode 100644 index 00000000..df4ecefb --- /dev/null +++ b/packages/ghost/src/scan/fingerprint-package-lint.ts @@ -0,0 +1,375 @@ +import { readFile } from "node:fs/promises"; +import { relative, resolve } from "node:path"; +import { + classifyMaterialLocator, + closestIds, + expandLocalMaterialLocator, + extractSkeletonSections, + type GhostCatalog, + listBundledMaterialFiles, + materialLocatorClaimsPath, + parseGlossary, + parseSourceRef, + sliceNodeSection, +} from "#ghost-core"; +import { isMissingPathError } from "../internal/fs.js"; +import type { LoadedCheck } from "./check-files.js"; +import { GHOST_GLOSSARY_FILENAME, GHOST_MATERIALS_DIR } from "./constants.js"; +import { + type FingerprintPackagePaths, + resolveFingerprintPackage, +} from "./fingerprint-package.js"; +import { + lintFingerprintPackageManifest, + loadFingerprintPackage, +} from "./fingerprint-package-loader.js"; +import type { LintIssue, LintReport } from "./lint.js"; +import { resolveGitRoot } from "./package-paths.js"; + +/** + * `validate` for a package: artifact shape, per-node validity, and the + * deterministic kind-prefix lint enabled by glossary.md. The catalog is flat; + * loading collects malformed nodes so they can be surfaced as structured issues. + */ +export async function lintFingerprintPackage( + dirArg: string | undefined, + cwd = process.cwd(), +): Promise { + const paths = resolveFingerprintPackage(dirArg, cwd); + const issues: LintIssue[] = []; + + const manifestRaw = await readRequired( + paths.manifest, + "manifest.yml", + issues, + ); + + if (manifestRaw !== undefined) { + // shape pass: manifest well-formed. + const beforeManifestErrors = issues.filter( + (issue) => issue.severity === "error", + ).length; + lintFingerprintPackageManifest(manifestRaw, issues); + const manifestHasErrors = + issues.filter((issue) => issue.severity === "error").length > + beforeManifestErrors; + if (manifestHasErrors) return finalize(issues); + // catalog pass: load + validate the node catalog. + try { + const { manifest, catalog, checks, invalid, invalidChecks } = + await loadFingerprintPackage(paths); + // node pass: a node that failed its own schema was skipped while loading + // the catalog; surface it here so a malformed node is loud, not silent. + issues.push( + ...invalid.map((entry) => ({ + severity: "error" as const, + rule: "node-invalid", + message: entry.message, + path: entry.file, + })), + ); + issues.push( + ...invalidChecks.map((entry) => ({ + severity: "error" as const, + rule: "check-invalid", + message: entry.message, + path: entry.file, + })), + ); + await lintGlossary(paths.glossary, issues); + lintCover(manifest.cover, catalog, issues); + await lintKindPrefixes(paths, catalog, issues); + lintNodeDescriptions(catalog, issues); + lintSkeletonSections(catalog, issues); + await lintMaterialLocators(paths, catalog, issues, cwd); + lintCheckReferences(catalog, checks, issues); + } catch (err) { + issues.push({ + severity: "error", + rule: "package-catalog-invalid", + message: err instanceof Error ? err.message : String(err), + path: ".ghost", + }); + } + } + + return finalize(issues); +} + +async function lintGlossary( + glossaryPath: string, + issues: LintIssue[], +): Promise { + let raw: string; + try { + raw = await readFile(glossaryPath, "utf-8"); + } catch (err) { + if (isMissingPathError(err)) return; + throw err; + } + + const result = parseGlossary(raw); + if (result.glossary !== null) return; + issues.push( + ...result.errors.map((message) => ({ + severity: "error" as const, + rule: "glossary-invalid", + message, + path: GHOST_GLOSSARY_FILENAME, + })), + ); +} + +function lintCover( + coverId: string | undefined, + catalog: GhostCatalog, + issues: LintIssue[], +): void { + if (coverId === undefined) { + issues.push({ + severity: "warning", + rule: "cover-undeclared", + message: + 'no cover declared in manifest.yml — fingerprints steer better with one node guaranteed in context; add "cover: "', + path: "manifest.yml.cover", + }); + return; + } + + const cover = catalog.nodes.get(coverId); + if (cover === undefined) { + issues.push({ + severity: "error", + rule: "cover-missing", + message: `manifest cover "${coverId}" does not match any node`, + path: "manifest.yml.cover", + }); + return; + } + + const bytes = Buffer.byteLength(cover.body, "utf-8"); + if (bytes > 1500) { + issues.push({ + severity: "warning", + rule: "cover-oversized", + message: `cover node "${coverId}" body is ${bytes} bytes — the cover is a one-screen budget; element-scoped rules belong in foundation chapters`, + path: `${coverId}.md`, + }); + } +} + +async function lintKindPrefixes( + paths: FingerprintPackagePaths, + catalog: GhostCatalog, + issues: LintIssue[], +): Promise { + const declaredKinds = await readDeclaredGlossaryKinds(paths.glossary); + if (declaredKinds === undefined) return; + + const declared = new Set(declaredKinds); + for (const node of catalog.nodes.values()) { + if (node.kind === undefined || declared.has(node.kind)) continue; + + const suggestions = closestIds(node.kind, declaredKinds, 1); + const suggestion = suggestions[0]; + issues.push({ + severity: "warning", + rule: "kind-undeclared", + message: + `Kind prefix \`${node.kind}\` is not declared in ${GHOST_GLOSSARY_FILENAME}.` + + (suggestion === undefined + ? " Drop the prefix if this node has no kind." + : ` Did you mean \`${suggestion}\`? Drop the prefix if this node has no kind.`), + path: `${node.id}.md`, + }); + } +} + +/** + * The `description` is a node's entire retrieval payload: `gather` lists it as + * the text the agent selects against. A node without one renders as a bare id + * and is effectively invisible to selection, so `validate` makes that loud. + * Warning, not error: an undescribed node is legal, just undiscoverable. + */ +function lintNodeDescriptions( + catalog: GhostCatalog, + issues: LintIssue[], +): void { + for (const node of catalog.nodes.values()) { + if (node.description !== undefined && node.description.trim().length > 0) { + continue; + } + issues.push({ + severity: "warning", + rule: "node-description-missing", + message: + "node has no `description`, so `gather` lists it as a bare id the agent cannot select against; add a one-line description of what this truth is and when to pull it", + path: `${node.id}.md`, + }); + } +} + +function lintSkeletonSections( + catalog: GhostCatalog, + issues: LintIssue[], +): void { + for (const node of catalog.nodes.values()) { + const sections = extractSkeletonSections(node.body); + for (const section of sections) { + if (section.fences.length === 1) continue; + issues.push({ + severity: "warning", + rule: "skeleton-fence-count", + message: + "## Skeleton section should contain exactly one fenced block; pull will emit all found skeleton fences", + path: `${node.id}.md`, + }); + } + } +} + +async function lintMaterialLocators( + paths: FingerprintPackagePaths, + catalog: GhostCatalog, + issues: LintIssue[], + cwd: string, +): Promise { + const repoRoot = await resolveGitRoot(cwd); + const options = { + repoRoot, + packageDir: paths.packageDir, + materialsDir: GHOST_MATERIALS_DIR, + }; + + const claimedLocators: string[] = []; + for (const node of catalog.nodes.values()) { + for (const locator of node.materials ?? []) { + if (classifyMaterialLocator(locator).kind === "url") continue; + claimedLocators.push(locator); + const expanded = await expandLocalMaterialLocator(locator, options, { + cap: Number.POSITIVE_INFINITY, + }); + if (expanded.matches.length > 0) continue; + issues.push({ + severity: "warning", + rule: "material-locator-dead", + message: `material locator '${locator}' matches no local files`, + path: `${node.id}.md.materials`, + }); + } + } + + const bundledFiles = await listBundledMaterialFiles(options); + for (const file of bundledFiles) { + const claimed = claimedLocators.some((locator) => + materialLocatorClaimsPath(locator, file, options), + ); + if (claimed) continue; + issues.push({ + severity: "warning", + rule: "material-orphaned", + message: + "bundled material is not claimed by any node `materials` locator", + path: toPackageRelative(file, repoRoot, paths.packageDir), + }); + } +} + +function toPackageRelative( + repoRelativePath: string, + repoRoot: string, + packageDir: string, +): string { + const packageRelative = relative( + packageDir, + resolve(repoRoot, repoRelativePath), + ) + .replace(/\\/g, "/") + .replace(/^\.\//, ""); + return packageRelative.startsWith("../") ? repoRelativePath : packageRelative; +} + +function lintCheckReferences( + catalog: GhostCatalog, + checks: Map, + issues: LintIssue[], +): void { + for (const check of checks.values()) { + for (const raw of check.references) { + const parsed = parseSourceRef(raw); + if (parsed === null) { + issues.push({ + severity: "error", + rule: "check-reference-malformed", + message: `check reference '${raw}' is not a node id with optional '> Heading' anchor`, + path: `checks/${check.id}.md.references`, + }); + continue; + } + const node = catalog.nodes.get(parsed.nodeId); + if (node === undefined) { + issues.push({ + severity: "warning", + rule: "check-reference-unresolved", + message: `check reference '${raw}' does not resolve to a fingerprint node — if you pruned this rule from the node, delete its paired flag in the check too`, + path: `checks/${check.id}.md.references`, + }); + continue; + } + if ( + parsed.heading !== undefined && + sliceNodeSection(node.body, parsed.heading) === null + ) { + issues.push({ + severity: "warning", + rule: "check-reference-heading-missing", + message: `check reference '${raw}' names a heading that was not found — if you pruned this rule from the node, delete its paired flag in the check too`, + path: `checks/${check.id}.md.references`, + }); + } + } + } +} + +async function readDeclaredGlossaryKinds( + glossaryPath: string, +): Promise { + let raw: string; + try { + raw = await readFile(glossaryPath, "utf-8"); + } catch (err) { + if (isMissingPathError(err)) return undefined; + throw err; + } + + const result = parseGlossary(raw); + if (result.glossary === null) return []; + return result.glossary.frontmatter.kinds.map((kind) => kind.name); +} + +async function readRequired( + path: string, + label: string, + issues: LintIssue[], +): Promise { + try { + return await readFile(path, "utf-8"); + } catch { + issues.push({ + severity: "error", + rule: "package-artifact-missing", + message: `Fingerprint package is missing ${label}.`, + path: label, + }); + return undefined; + } +} + +function finalize(issues: LintIssue[]): LintReport { + 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/scan/fingerprint-package-loader.ts b/packages/ghost/src/scan/fingerprint-package-loader.ts index 03bf434e..ddb1e6be 100644 --- a/packages/ghost/src/scan/fingerprint-package-loader.ts +++ b/packages/ghost/src/scan/fingerprint-package-loader.ts @@ -4,7 +4,6 @@ import { assembleCatalog, type GhostFingerprintPackageManifest, GhostFingerprintPackageManifestSchema, - parseGlossary, UsageError, } from "#ghost-core"; import { isMissingPathError } from "../internal/fs.js"; @@ -37,12 +36,7 @@ export async function loadFingerprintPackage( // Per-node schema failures are collected as `invalid`. const { nodes: placedNodes, invalid } = await loadNodeFiles(paths.packageDir); const checkFiles = await loadCheckFiles(paths.packageDir); - const postureKinds = await readPostureGlossaryKinds(paths.glossary); - const catalog = assembleCatalog({ - placedNodes, - wildKinds: postureKinds.wild, - guardKinds: postureKinds.guard, - }); + const catalog = assembleCatalog({ placedNodes }); return { manifest, @@ -78,29 +72,6 @@ export function lintFingerprintPackageManifest( } } -async function readPostureGlossaryKinds( - glossaryPath: string, -): Promise<{ wild: string[]; guard: string[] }> { - let raw: string; - try { - raw = await readFile(glossaryPath, "utf-8"); - } catch (err) { - if (isMissingPathError(err)) return { wild: [], guard: [] }; - throw err; - } - - const result = parseGlossary(raw); - if (result.glossary === null) return { wild: [], guard: [] }; - return { - wild: result.glossary.kinds - .filter((kind) => kind.posture === "wild") - .map((kind) => kind.name), - guard: result.glossary.kinds - .filter((kind) => kind.posture === "guard") - .map((kind) => kind.name), - }; -} - function parseManifest( raw: string, label: string, diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index 2255e023..a983850a 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -1,17 +1,8 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join, relative, resolve } from "node:path"; +import { access, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { - classifyMaterialLocator, - closestIds, - expandLocalMaterialLocator, - extractSkeletonSections, type GhostCatalog, type GhostFingerprintPackageManifest, - listBundledMaterialFiles, - materialLocatorClaimsPath, - parseGlossary, - parseSourceRef, - sliceNodeSection, UsageError, } from "#ghost-core"; import { isExistingPathError, isMissingPathError } from "../internal/fs.js"; @@ -23,14 +14,8 @@ import { FINGERPRINT_MANIFEST_FILENAME, FINGERPRINT_PACKAGE_DIR, GHOST_GLOSSARY_FILENAME, - GHOST_MATERIALS_DIR, } from "./constants.js"; -import { - lintFingerprintPackageManifest, - loadFingerprintPackage, -} from "./fingerprint-package-loader.js"; -import type { LintIssue, LintReport } from "./lint.js"; -import { resolveGitRoot } from "./package-paths.js"; +import { loadFingerprintPackage } from "./fingerprint-package-loader.js"; import { DEFAULT_TEMPLATE_NAME, type GhostInitTemplate, @@ -40,6 +25,9 @@ import { listInitTemplates, } from "./templates.js"; +// The lint pass lives beside this module; re-export it so `validate` callers +// keep a single import site for package loading and linting. +export { lintFingerprintPackage } from "./fingerprint-package-lint.js"; export { loadFingerprintPackage }; export interface FingerprintPackagePaths { @@ -203,287 +191,3 @@ async function assertInitDoesNotOverwrite(paths: string[]): Promise { ); } } - -/** - * `validate` for a package: artifact shape, per-node validity, and the - * deterministic kind-prefix lint enabled by glossary.md. The catalog is flat; - * loading collects malformed nodes so they can be surfaced as structured issues. - */ -export async function lintFingerprintPackage( - dirArg: string | undefined, - cwd = process.cwd(), -): Promise { - const paths = resolveFingerprintPackage(dirArg, cwd); - const issues: LintIssue[] = []; - - const manifestRaw = await readRequired( - paths.manifest, - "manifest.yml", - issues, - ); - - if (manifestRaw !== undefined) { - // shape pass: manifest well-formed. - const beforeManifestErrors = issues.filter( - (issue) => issue.severity === "error", - ).length; - lintFingerprintPackageManifest(manifestRaw, issues); - const manifestHasErrors = - issues.filter((issue) => issue.severity === "error").length > - beforeManifestErrors; - if (manifestHasErrors) return finalize(issues); - // catalog pass: load + validate the node catalog. - try { - const { catalog, checks, invalid, invalidChecks } = - await loadFingerprintPackage(paths); - // node pass: a node that failed its own schema was skipped while loading - // the catalog; surface it here so a malformed node is loud, not silent. - issues.push( - ...invalid.map((entry) => ({ - severity: "error" as const, - rule: "node-invalid", - message: entry.message, - path: entry.file, - })), - ); - issues.push( - ...invalidChecks.map((entry) => ({ - severity: "error" as const, - rule: "check-invalid", - message: entry.message, - path: entry.file, - })), - ); - await lintGlossary(paths.glossary, issues); - await lintKindPrefixes(paths, catalog, issues); - lintSkeletonSections(catalog, issues); - await lintMaterialLocators(paths, catalog, issues, cwd); - lintCheckReferences(catalog, checks, issues); - } catch (err) { - issues.push({ - severity: "error", - rule: "package-catalog-invalid", - message: err instanceof Error ? err.message : String(err), - path: ".ghost", - }); - } - } - - return finalize(issues); -} - -async function lintGlossary( - glossaryPath: string, - issues: LintIssue[], -): Promise { - let raw: string; - try { - raw = await readFile(glossaryPath, "utf-8"); - } catch (err) { - if (isMissingPathError(err)) return; - throw err; - } - - const result = parseGlossary(raw); - if (result.glossary !== null) return; - issues.push( - ...result.errors.map((message) => ({ - severity: "error" as const, - rule: "glossary-invalid", - message, - path: GHOST_GLOSSARY_FILENAME, - })), - ); -} - -async function lintKindPrefixes( - paths: FingerprintPackagePaths, - catalog: GhostCatalog, - issues: LintIssue[], -): Promise { - const declaredKinds = await readDeclaredGlossaryKinds(paths.glossary); - if (declaredKinds === undefined) return; - - const declared = new Set(declaredKinds); - for (const node of catalog.nodes.values()) { - if (node.kind === undefined || declared.has(node.kind)) continue; - - const suggestions = closestIds(node.kind, declaredKinds, 1); - const suggestion = suggestions[0]; - issues.push({ - severity: "warning", - rule: "kind-undeclared", - message: - `Kind prefix \`${node.kind}\` is not declared in ${GHOST_GLOSSARY_FILENAME}.` + - (suggestion === undefined - ? " Drop the prefix if this node has no kind." - : ` Did you mean \`${suggestion}\`? Drop the prefix if this node has no kind.`), - path: `${node.id}.md`, - }); - } -} - -function lintSkeletonSections( - catalog: GhostCatalog, - issues: LintIssue[], -): void { - for (const node of catalog.nodes.values()) { - const sections = extractSkeletonSections(node.body); - for (const section of sections) { - if (section.fences.length === 1) continue; - issues.push({ - severity: "warning", - rule: "skeleton-fence-count", - message: - "## Skeleton section should contain exactly one fenced block; pull will emit all found skeleton fences", - path: `${node.id}.md`, - }); - } - } -} - -async function lintMaterialLocators( - paths: FingerprintPackagePaths, - catalog: GhostCatalog, - issues: LintIssue[], - cwd: string, -): Promise { - const repoRoot = await resolveGitRoot(cwd); - const options = { - repoRoot, - packageDir: paths.packageDir, - materialsDir: GHOST_MATERIALS_DIR, - }; - - const claimedLocators: string[] = []; - for (const node of catalog.nodes.values()) { - for (const locator of node.materials ?? []) { - if (classifyMaterialLocator(locator).kind === "url") continue; - claimedLocators.push(locator); - const expanded = await expandLocalMaterialLocator(locator, options, { - cap: Number.POSITIVE_INFINITY, - }); - if (expanded.matches.length > 0) continue; - issues.push({ - severity: "warning", - rule: "material-locator-dead", - message: `material locator '${locator}' matches no local files`, - path: `${node.id}.md.materials`, - }); - } - } - - const bundledFiles = await listBundledMaterialFiles(options); - for (const file of bundledFiles) { - const claimed = claimedLocators.some((locator) => - materialLocatorClaimsPath(locator, file, options), - ); - if (claimed) continue; - issues.push({ - severity: "warning", - rule: "material-orphaned", - message: - "bundled material is not claimed by any node `materials` locator", - path: toPackageRelative(file, repoRoot, paths.packageDir), - }); - } -} - -function toPackageRelative( - repoRelativePath: string, - repoRoot: string, - packageDir: string, -): string { - const packageRelative = relative( - packageDir, - resolve(repoRoot, repoRelativePath), - ) - .replace(/\\/g, "/") - .replace(/^\.\//, ""); - return packageRelative.startsWith("../") ? repoRelativePath : packageRelative; -} - -function lintCheckReferences( - catalog: GhostCatalog, - checks: Map, - issues: LintIssue[], -): void { - for (const check of checks.values()) { - for (const raw of check.references) { - const parsed = parseSourceRef(raw); - if (parsed === null) { - issues.push({ - severity: "error", - rule: "check-reference-malformed", - message: `check reference '${raw}' is not a node id with optional '> Heading' anchor`, - path: `checks/${check.id}.md.references`, - }); - continue; - } - const node = catalog.nodes.get(parsed.nodeId); - if (node === undefined) { - issues.push({ - severity: "warning", - rule: "check-reference-unresolved", - message: `check reference '${raw}' does not resolve to a fingerprint node — if you pruned this rule from the node, delete its paired flag in the check too`, - path: `checks/${check.id}.md.references`, - }); - continue; - } - if ( - parsed.heading !== undefined && - sliceNodeSection(node.body, parsed.heading) === null - ) { - issues.push({ - severity: "warning", - rule: "check-reference-heading-missing", - message: `check reference '${raw}' names a heading that was not found — if you pruned this rule from the node, delete its paired flag in the check too`, - path: `checks/${check.id}.md.references`, - }); - } - } - } -} - -async function readDeclaredGlossaryKinds( - glossaryPath: string, -): Promise { - let raw: string; - try { - raw = await readFile(glossaryPath, "utf-8"); - } catch (err) { - if (isMissingPathError(err)) return undefined; - throw err; - } - - const result = parseGlossary(raw); - if (result.glossary === null) return []; - return result.glossary.frontmatter.kinds.map((kind) => kind.name); -} - -async function readRequired( - path: string, - label: string, - issues: LintIssue[], -): Promise { - try { - return await readFile(path, "utf-8"); - } catch { - issues.push({ - severity: "error", - rule: "package-artifact-missing", - message: `Fingerprint package is missing ${label}.`, - path: label, - }); - return undefined; - } -} - -function finalize(issues: LintIssue[]): LintReport { - 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/scan/templates.ts b/packages/ghost/src/scan/templates.ts index 8de78dac..0dcbf0f4 100644 --- a/packages/ghost/src/scan/templates.ts +++ b/packages/ghost/src/scan/templates.ts @@ -23,10 +23,10 @@ export interface GhostInitTemplate { description: string; files(): TemplateFile[] | Promise; } -function manifestFile(): TemplateFile { +function manifestFile(cover?: string): TemplateFile { return { relativePath: "manifest.yml", - content: `schema: ${GHOST_FINGERPRINT_PACKAGE_SCHEMA}\nid: local\n`, + content: `schema: ${GHOST_FINGERPRINT_PACKAGE_SCHEMA}\nid: local\n${cover ? `cover: ${cover}\n` : ""}`, }; } @@ -55,7 +55,7 @@ const MINIMAL_TEMPLATE: GhostInitTemplate = { async files() { const medianFile = await medianTemplateFile(); return [ - manifestFile(), + manifestFile("index"), gitignoreFile(), { relativePath: "glossary.md", @@ -65,9 +65,9 @@ kinds: - name: condition - name: exemplar - name: anti-goal + - name: cliche - name: asset - name: pattern -# - { name: provocation, posture: wild, purpose: a deliberate provocation past the fingerprint — surfaced only on request } --- # principle @@ -88,8 +88,13 @@ evidence unless the node says the sample itself is normative. What this brand must never look, sound, or feel like — named generic patterns and rejected neighbors. Always-on, like a principle, but stated as the thing to steer away from. -\`anti-goal.median\` is the model's floor, not the brand's taste. Gather -anti-goals before styling anything greenfield. + +# cliche + +The defaults a generative model falls back on when no brand is steering it. +These are nobody's brand. Each entry pairs the tired move with the honest +replacement. Gather before styling anything greenfield; enforced by paired +checks at review. # asset @@ -153,7 +158,7 @@ const COMPOSITION_TEMPLATE: GhostInitTemplate = { async files() { const medianFile = await medianTemplateFile(); return [ - manifestFile(), + manifestFile("index"), gitignoreFile(), { relativePath: "glossary.md", @@ -163,9 +168,9 @@ kinds: - name: condition - name: exemplar - name: anti-goal + - name: cliche - name: asset - name: pattern -# - { name: provocation, posture: wild, purpose: a deliberate provocation past the fingerprint — surfaced only on request } --- # principle @@ -190,8 +195,13 @@ render travels with the prose. What this brand must never look, sound, or feel like — named generic patterns and rejected neighbors. Always-on, like a principle, but stated as the thing to steer away from. -\`anti-goal.median\` is the model's floor, not the brand's taste. Gather -anti-goals before styling anything greenfield. + +# cliche + +The defaults a generative model falls back on when no brand is steering it. +These are nobody's brand. Each entry pairs the tired move with the honest +replacement. Gather before styling anything greenfield; enforced by paired +checks at review. # asset @@ -312,25 +322,23 @@ When a blessed render of this pattern exists, add an \`exemplar.*\` node with const SKELETON_FILE_ORDER = new Map( [ "glossary.md", - "index.md", - "anti-goal.median.md", - "grammar.hierarchy.md", - "grammar.rhythm.md", - "grammar.surfaces.md", - "grammar.motion.md", - "grammar.color-roles.md", - "grammar.conversation.md", - "signature.shape.md", - "signature.palette.md", - "signature.type.md", - "signature.temperature.md", + "brand.md", + "cliche.median.md", + "foundation.composition.md", + "foundation.color.md", + "foundation.type.md", + "foundation.controls.md", + "foundation.layout.md", + "foundation.motion.md", + "foundation.voice.md", + "context.conversation.md", ].map((path, index) => [path, index]), ); const SKELETON_TEMPLATE: GhostInitTemplate = { name: "skeleton", description: - "Naked skeleton: the median floor + grammar law, with the signature dials left unanswered.", + "Naked skeleton: a brand cover, foundation chapters with open questions, and the cliche floor.", async files() { const skeletonFiles = [ ...(await loadPackedPayload("skeleton")), @@ -343,14 +351,14 @@ const SKELETON_TEMPLATE: GhostInitTemplate = { Number.MAX_SAFE_INTEGER) || a.relativePath.localeCompare(b.relativePath), ); - return [manifestFile(), gitignoreFile(), ...skeletonFiles]; + return [manifestFile("brand"), gitignoreFile(), ...skeletonFiles]; }, }; async function medianTemplateFile(): Promise { return { - relativePath: "anti-goal.median.md", - content: await loadPayloadFile("median", "anti-goal.median.md"), + relativePath: "cliche.median.md", + content: await loadPayloadFile("median", "cliche.median.md"), }; } diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index a54843df..b659cee1 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -34,10 +34,12 @@ true, and an agent reads the relevant truths before building. Guidance stays in prose. - A node's **kind** comes from its filename prefix (`principle.density.md` → kind `principle`). A bare name (`voice.md`) has no kind. -- The **glossary** declares the kind vocabulary, what each kind means, and optional consumption posture: `steady` by default, `posture: wild` for truths that require explicit request, or `posture: guard` for review-critical anti-goals with positive replacement. -- The starter `index.md` node (id `index`) is the human-curated front door: - by convention it carries the non-negotiables and reading posture, and agents - pull it first. +- The **glossary** declares the kind vocabulary and what each kind means. +- The manifest's `cover` names the node `gather` inlines above the menu every + time. It carries essence, temperature, and brand-only refusals. Admission + test: a violation visible in one element belongs in that element's chapter; + visible in one view, in the composition chapter; visible only across the + whole body of work, on the cover. - There is **no hierarchy, no inheritance, no edges**. Directories are for browsing only; the model reads a flat menu. - **Checks** are optional review assertions in a flat `.ghost/checks/*.md` @@ -62,27 +64,27 @@ ghost pulse # summarize local gather/pull events while tuning `gather` does no selection. It emits the menu and you read the ask against it, then pull the truths you judge relevant. Its header includes a coverage line — -total nodes, nodes carrying concrete material, and guards — so an all-prose or -unrouted fingerprint is visible before generation. +total nodes and nodes carrying concrete material — so an all-prose +fingerprint is visible before generation. Prefer `ghost pull` over reading files directly: it emits the same prose, inlines small local materials by default, turns binary materials into -inspect-pointers, orders the packet for steering (`index`, concrete nodes, -prose rules, guards), extracts Skeletons dead last, and appends structured +inspect-pointers, orders the packet for steering (cover when selected, concrete nodes, +prose rules), extracts Skeletons dead last, and appends structured events to `.ghost/.events` for local tuning. `review` does no grading. It assembles an advisory packet: touched files, -matched material-backed nodes, matched guard nodes, offered checks, probe +matched material-backed nodes, offered checks, probe evidence, coverage gaps, and the diff. The host agent renders findings. ## CLI verbs | Verb | Purpose | |---|---| -| `ghost init` | Scaffold `.ghost/` with the steering starter: manifest, glossary, `index.md`, and demo nodes for stance, composition, anti-goals, patterns, exemplars, materials, and decisions. `--template minimal` writes only the small manifest/glossary/index starter. `--with checks` also adds the checks directory. | +| `ghost init` | Scaffold `.ghost/` with the skeleton starter: manifest, glossary, `brand.md`, foundation chapters, context nodes, and the cliche floor. `--template minimal` writes only the small manifest/glossary/index starter. `--with checks` also adds the checks directory. | | `ghost checks init` | Scaffold `.ghost/checks/` with an example review assertion. | | `ghost validate [file-or-dir]` | Validate manifest, nodes, material locators, check references, and glossary kind prefixes. | -| `ghost gather [ask…] [--wild] [--format json]` | Emit the node menu for selection plus coverage line; log exposed ids. Default gather excludes wild kinds unless `--wild` is explicit. | +| `ghost gather [ask…] [--format json]` | Emit the node menu for selection plus coverage line; log exposed ids. | | `ghost pull […]` | Emit selected nodes' full bodies and materials in steering order; log selected/missed ids. | | `ghost review [--diff ] [--base ] [--format json] [--no-probes]` | Emit an advisory review packet for a diff (requires `.ghost/checks/`). | | `ghost export [--out ] [--no-checks] [--strict] [--format json]` | Package `.ghost/` as a portable brand artifact and report which material locators will not travel. | @@ -109,11 +111,11 @@ against the unpacked package with `--package `. - Author or update the fingerprint: follow [references/capture.md](references/capture.md). - Author material-backed nodes: follow [references/blocks.md](references/blocks.md). +- Choose which concrete code tiers the package carries: follow [references/concrete-tiers.md](references/concrete-tiers.md). - Choose the right human-agent authoring workflow: follow [references/authoring-scenarios.md](references/authoring-scenarios.md). - Adapt a starter (a body or the skeleton) to your brand: follow [references/adapting-a-starter.md](references/adapting-a-starter.md). - Gather applicable truths for a task: follow [references/recall.md](references/recall.md). - Shape a pre-generation brief: follow [references/brief.md](references/brief.md). -- Explore deliberate provocations with wild nodes: follow [references/wild.md](references/wild.md). - Probe readiness before generating: follow [references/self-check.md](references/self-check.md). - Audit steering coverage: follow [references/steering-audit.md](references/steering-audit.md). - Understand the package shape: see [references/schema.md](references/schema.md). @@ -128,7 +130,7 @@ conventions, but durable brand truth should be curated by the human. A silent fingerprint does not require stopping. Proceed from nearby product surfaces, local conventions, and ordinary reasoning when safe, and label that reasoning as provisional and non-Ghost-backed — unless the fingerprint itself -declares a stricter silence posture (check the `index` node), which overrides +declares a stricter silence posture (check the cover), which overrides this default. Ask a human before high-risk, irreversible, privacy, security, legal, or brand-defining choices. diff --git a/packages/ghost/src/skill-bundle/references/adapting-a-starter.md b/packages/ghost/src/skill-bundle/references/adapting-a-starter.md index 31792f16..dc6a0052 100644 --- a/packages/ghost/src/skill-bundle/references/adapting-a-starter.md +++ b/packages/ghost/src/skill-bundle/references/adapting-a-starter.md @@ -13,22 +13,21 @@ handoffs: vessel-light`) or the naked skeleton (`ghost init`) — into *your* brand's fingerprint without shipping a self-contradicting package. -A starter is factored by rate of change under adaptation. Knowing which stratum a -file belongs to tells you what to do with it: +The naked skeleton is factored by what must happen during adaptation: | Stratum | Files | On adaptation | | --- | --- | --- | -| Grammar | `grammar.*` | Keep unchanged — value-free decision logic that survives any adaptation. | -| Median floor | `anti-goal.median` | Prune, never rewrite — Ghost stamps this measured model truth into every initialized package; you own it after init. | -| Signature | `signature.*` | Answer — each is a dial; restate it with your brand's answer. | -| Values | `materials/tokens.css` | Edit — the single injection point for every literal value. | -| Registers | `register.*` | Re-tune — conditions referencing signature ids; revisit after the dials change. | -| Derived artifacts | `materials/ref/*.html`, `anti-goal.tells` | Regenerate — they demonstrate the values and near-misses of a *specific* signature. | +| Cover | `brand.md` | Write it — essence in one paragraph, temperature, and brand-only refusals. Apply the admission test to every line: a violation visible in one element belongs in that element's chapter; visible in one view, in the composition chapter; visible only across the whole body of work, here. Keep a one-screen budget and delete every scaffolding sentence when real. | +| Foundation | `foundation.*` | Answer each chapter's open section (`Palette`, `Typeface`, `Radius`, `Character`, `Warmth`), restate it as the brand's current answer, and edit misuse lists to the brand's real failure modes. | +| Context | `context.*` | Add or re-tune after the open questions are answered; context nodes state only what inverts in a matching situation. | +| Cliche floor | `cliche.*` | Prune — delete entries a foundation misuse list absorbs, delete the paired check flag with it, and replace generic entries with refusals only this brand makes. If a refusal passes the cover admission test, graduate it to `brand.md`. | +| Materials and refs | `materials/*`, referenced implementations, refs | Add or regenerate so the prose has concrete material to inspect. | +| Checks | `checks/*` | Keep paired with the nodes they enforce; delete or rewrite flags when their source rule changes. | Do the steps **in order and in one sitting** where possible. A half-adapted package is worse than an unadapted one: stale refs steer harder than any prose -you rewrote, so stopping after step 4 ships a fingerprint that contradicts -itself. +you rewrote, so stopping before refs and checks are current ships a fingerprint +that contradicts itself. ## The procedure @@ -36,66 +35,69 @@ itself. name. This is deliberately first: it is the explicit act that marks the adaptation as begun. Until it changes, the package honestly claims to be the starter, and every consuming agent cites it as a starter default. -2. **Prune `anti-goal.median`.** Each rule is a `###` heading section; delete - the whole section for every rule your brand legitimately violates (a brand - built on gradients deletes the Gradients section — that is the node working, - not failing). Do not rewrite surviving rules; they are the model's measured - floor, not your taste. Then run `ghost validate`: every check reference - orphaned by a pruned heading surfaces as its own warning — delete the paired - flag and its reference from the check. -3. **Answer the signature dials.** Walk each `signature.*` node as a - questionnaire item. Keep the fixed relationship (the part the node marks - as worth keeping); replace the starter's answer — or the open question — - with your brand's: restate the node as "this brand's current answer is …". - Ask the human for any dial they have not decided; never freehand a value - and present it as brand-backed. -4. **Edit `materials/tokens.css`.** Every literal value lives here — radii, - palette, type sizes, durations, eases. Change the values; keep the role - names. The role names are the grammar's vocabulary and the reason the - grammar nodes survive untouched. -5. **Regenerate the refs.** This is the step that decides whether the adaptation - succeeded. Exemplars dominate prose: a prose rule contradicted by a stale - ref loses. Rebuild each `materials/ref/*.html` against the new tokens and - answered dials, keep the annotation headers (`normative-for` / - `incidental`) current, and make each ref demonstrate its closed sets - completely. If the starter shipped no refs (the skeleton), generate them - now — a fingerprint with no exemplars steers at half strength. -6. **Rewrite `anti-goal.tells`.** The tells are near-misses of the *starter's* - signature; yours are different. For each answered dial, name the failure - mode one step away from your answer and its replacement. If the starter - shipped no tells node (the skeleton), author one. -7. **Re-run the checks — including against the refs.** `ghost validate` for - package shape, then review the regenerated refs against the median and - value checks (stage the ref changes and run `ghost review`). The floor - only holds if the refs hold it too: a ref that trips a median flag will - teach every future generation the violation. Rewrite the body's - `checks/values.md` alongside the dials it references. +2. **Write the cover.** Replace `brand.md` with the brand's essence in one + paragraph, the shared temperature of words and motion, and the refusals only + this brand makes. Test every line: a violation visible in one element moves + to that element's chapter; visible in one view, to the composition chapter; + only what shows solely across the whole body of work stays. Keep the cover + to one screen and delete every scaffolding sentence when it is real. +3. **Answer the foundation chapters.** Walk each `foundation.*` node. Keep the + usage rules that describe the closed vocabulary; answer the open section as + the brand's current answer. Ask the human for undecided values; never + freehand a value and present it as brand-backed. Edit each misuse list to the + failures this brand actually needs to avoid. +4. **Add or edit materials.** Record literal values where the implementation or + asset materials live, then add `materials` locators to the nodes that explain + them. Role names can stay stable while values change. The point is that a + realizing agent can inspect the same concrete source the prose governs. +5. **Re-tune contexts.** Revisit each `context.*` node after the foundation + answers exist. Keep only inversions that apply in that situation; delete + generic foundation rules repeated there, and add new contexts only when a + task situation truly bends the defaults. +6. **Prune `cliche.median`.** Each rule is a `###` heading section; delete the + whole section for every rule a foundation misuse list now absorbs or your + brand legitimately violates. Do not rewrite surviving measured defaults into + taste. Then run `ghost validate`: every check reference orphaned by a pruned + heading surfaces as its own warning — delete the paired flag and its + reference from the check. +7. **Regenerate refs.** Exemplars dominate prose: a prose rule contradicted by a + stale ref loses. Rebuild each ref against the new values and chapter answers, + keep any annotation headers current, and make each ref demonstrate its closed + sets completely. If the starter shipped no refs, generate them now — a + fingerprint with no exemplars steers at half strength. +8. **Rewrite checks and near-miss nodes.** For each answered foundation chapter, + name the failure mode one step away from the answer and its replacement. Put + pre-generation guidance in nodes and review assertions in `checks/`; never + leave a check that asserts an obligation no node states. +9. **Re-run the checks, including against refs.** `ghost validate` for package + shape, then review the regenerated refs against the median and value checks + (stage the ref changes and run `ghost review`). The floor only holds if the + refs hold it too: a ref that trips a median flag will teach every future + generation the violation. ## Consuming an unadapted starter -Work does not block on adaptation. Before the procedure runs (or midway -through it), cite starter content honestly: +Work does not block on adaptation. Before the procedure runs (or midway through +it), cite starter content honestly: -- Grammar: **Ghost-backed** — value-free decision logic that holds for any - brand. +- Cover scaffolding and unanswered foundation sections: **provisional** — never + plain brand truth. +- Foundation usage rules: **Ghost-backed starter structure** until the human + answers the open sections and edits the misuse lists. - Surviving median rules: **owner-backed after init** — Ghost stamps this measured model truth into every initialized package; you own the pruning and any adaptation thereafter. -- The starter's signature values (a body) or your provisional choices (the - skeleton): **Ghost-backed (starter default, unadapted)** or **provisional** - — never plain brand truth. The manifest id tells you which state you are - in: a starter id means unadapted. -- In a brief, `anti-goal.median` sits in the anti-goals slot — after intent, - inventory, and composition, never before the brand truths. If a median rule - conflicts with an answered signature node, the signature wins and the - median line is a prune candidate to report. +- Context nodes: **conditional** — read only when their situation matches, and + revisit them after the foundation answers change. +- The manifest id tells you which state you are in: a starter id means + unadapted. ## Never -- Never rewrite grammar nodes to taste — if a grammar rule is wrong for your - brand, it was never grammar; move it to a signature node and answer it. -- Never leave the starter's refs alongside your new tokens — regenerate or - delete; a stale exemplar outweighs your rewritten prose. +- Never leave cover scaffolding in a real fingerprint. +- Never present your provisional value as the brand's answer. +- Never leave stale refs alongside new values — regenerate or delete; a stale + exemplar outweighs rewritten prose. - Never prune a median rule without deleting its paired check flag, or keep a check that asserts an obligation no node states. - Never pre-write the manifest id change into automation — it is the human's diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index 3bef7fb1..da7416c7 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -79,7 +79,7 @@ says which. Counter-exemplars are as valuable as exemplars when they name the replacement. "We would never ship this; we would ship that instead" with artifacts attached -usually yields a sharper guard than an hour of affirmative description. Avoid +usually yields a sharper anti-goal than an hour of affirmative description. Avoid blacklist-only anti-goals: the rejected pattern should be purged from exemplars and enforced in review, not repeated as the model's main example. @@ -114,9 +114,7 @@ Two authoring decisions replace any notion of hierarchy: - **Kind** — declare the kind vocabulary in `glossary.md` and name each node `..md` so its normative weight is clear. The glossary defines what - each kind means and how strongly it binds. Use `posture: guard` for - review-critical replacement nodes, `posture: wild` for opt-in provocations, - and the default steady posture for ordinary truths. Kinds are your choice; + each kind means and how strongly it binds. Kinds are your choice; Ghost ships no fixed vocabulary. - **Altitude** — state a truth at the level it is actually true. Universal → state it plainly. Narrower → name the situation that activates it, in the prose. Never diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index 0d92bd35..0ff913e0 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -42,8 +42,8 @@ Rules: - Do not add sections for every kind. Sections dilute instruction weight. - Treat `ghost pull` ordering as signal: stance first, concrete material next, - prose rules, guards late, Skeletons dead last. + prose rules, Skeletons dead last. - If a pulled Skeleton matches the surface, begin the artifact from it verbatim before filling. -- Guards (`posture: guard`) are review-critical anti-goals: state the positive - replacement, not just the rejected pattern. +- Anti-goals are review-critical: state the positive replacement, not just the + rejected pattern. diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index 76655ebb..23bc3ed2 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -19,14 +19,13 @@ Ghost treats the fingerprint package as canonical. glossary.md # the kind vocabulary + what each kind means principle.trust.md # a brand truth of kind `principle` pattern.invoice.md # a pattern with an optional ## Skeleton - anti-goal.generic.md # a guard when its kind declares posture: guard + anti-goal.generic.md # a review-critical replacement rule voice.md # a brand truth without a kind ``` A **node** is a markdown file: a `description`, optional `materials`, and a prose body. The package is **flat** — no hierarchy, no inheritance, no edges. A -node's kind comes from its filename prefix; the glossary declares the kinds and -may declare consumption posture such as `posture: guard`. +node's kind comes from its filename prefix; the glossary declares the kinds. ## Lead with an annotated exemplar over a complete artifact @@ -119,18 +118,12 @@ Keep them short, specific, and attached to concrete objects whenever possible: Do not turn every observation into a rule. Stale or generic rules average against the exemplars and pull the packet back toward the median. -## Write guards as replacement, not as blacklist +## Write anti-goals as replacement, not as blacklist -A guard is ordinary node prose whose kind declares `posture: guard` in -`glossary.md`: +An anti-goal is ordinary node prose whose kind the glossary defines as a +review-critical replacement rule. -```yaml -kinds: - - name: anti-goal - posture: guard -``` - -A good guard states **not X; instead Y; recognize the switch by Z**. The +A good anti-goal states **not X; instead Y; recognize the switch by Z**. The replacement matters because negation alone raises the salience of the rejected thing. @@ -148,12 +141,12 @@ Instead: flat paper surfaces, one restrained accent, square alignment, and a next action backed by the number or source that justifies it. Recognize the switch: if removing the logo would make the surface look like any -SaaS template, the guard failed even when every token is technically valid. +SaaS template, the anti-goal failed even when every token is technically valid. ``` The strongest anti-goal is silent: purged from exemplars, absent from starter -structures, and enforced by a probe-backed check in review. Use guard prose to -name the replacement; use checks and `probe:` commands to catch regressions. +structures, and enforced by a probe-backed check in review. Use anti-goal prose +to name the replacement; use checks and `probe:` commands to catch regressions. ## The node shape @@ -210,9 +203,9 @@ human sees them. Two carve-outs come first, because they invert ordinary prose advice: -- **Guards keep their negation.** "Not X; instead Y; recognize the switch by Z" - is the required guard form. Naming the rejected thing is the guard's job; - never "improve" a guard by stating only the replacement. +- **Anti-goals keep their negation.** "Not X; instead Y; recognize the switch + by Z" is the required anti-goal form. Naming the rejected thing is the + anti-goal's job; never "improve" one by stating only the replacement. - **Invariants keep their absolutes.** "Never" and "always" are correct in an invariant when the hard line is real and human-ratified. Absolutes are lazy only when they stand in for an uncurated stance. @@ -261,7 +254,7 @@ strongest form that fixes the observed failure. | --- | --- | | missing the truth | sharper `description` / `index` mention | | inventing values | `asset.*` node with materials and exact names | -| producing generic output | `anti-goal.*` guard plus annotated `exemplar.*` | +| producing generic output | `anti-goal.*` replacement plus annotated `exemplar.*` | | choosing the wrong structure | `pattern.*` with bound/open and a `## Skeleton` | | crossing hard lines | invariant prose plus a check, optionally with `probe:` | | applying guidance too broadly | condition in prose | @@ -324,9 +317,7 @@ feed-back only; they are never gathered. Declare the kinds you will use in `glossary.md` — the frontmatter `kinds` list plus a `#` section per kind explaining its meaning and normative weight. Kinds are your choice; Ghost ships no fixed vocabulary. A node's filename prefix must -match a declared kind (or the node has no kind). Use `posture: guard` for kinds -whose nodes are review-critical replacements; use `posture: wild` only for -truths that should stay opt-in. +match a declared kind (or the node has no kind). The glossary is a dictionary of every term with defined meaning in the corpus. A root `voice.md` with a `voice` glossary entry declares the scope for future diff --git a/packages/ghost/src/skill-bundle/references/concrete-tiers.md b/packages/ghost/src/skill-bundle/references/concrete-tiers.md new file mode 100644 index 00000000..140ad6ba --- /dev/null +++ b/packages/ghost/src/skill-bundle/references/concrete-tiers.md @@ -0,0 +1,95 @@ +--- +name: concrete-tiers +description: Decide which concrete code tiers a fingerprint carries — tokens, skeletons, components, exemplars — and justify every absence. +handoffs: + - label: Audit what the package carries today + command: ghost gather + prompt: Which nodes carry concrete material, and which of the four tiers does the package cover? +--- + +# Recipe: Choose The Concrete Code Tiers + +**Goal:** decide, deliberately, which tiers of concrete code material a +fingerprint carries — and be able to say why any tier is absent. Absence +should be a decision, not a default. + +Prose states a truth; concrete code makes it the cheapest continuation. A +package can carry code at four tiers, and each does a different job during +generation: + +| Tier | Form | What it buys | +| --- | --- | --- | +| Tokens | `materials/tokens.css`, linked from nodes | Named values — the agent stops inventing colors, sizes, and durations. | +| Skeletons | `## Skeleton` HTML blocks inside pattern nodes | The opening structure — layout is committed before the generic default can win the first tokens. | +| Components | primitive classes or a class vocabulary in `materials/` | Reusable styling decisions — padding, variants, and states compress into names. | +| Exemplars | a complete surface with load-bearing moves annotated | Cross-node interactions — the constraints no single node states, demonstrated working together. | + +Two properties decide whether a tier steers, and they are not the same thing: + +- **Scope** — what the tier constrains: a token constrains one value, a + skeleton one block, an exemplar one whole surface. +- **Delivery** — whether the material is guaranteed to reach the agent. + Skeleton blocks live in node bodies, so `ghost pull` delivers them with the + truth. Files behind `materials:` locators reach only agents that spend a + turn reading them. A tier the agent never sees contributes nothing; + presence in the package is not presence in context. + +## The floor and the earned tiers + +**Tokens are the floor.** Every package that steers visual output carries a +token vocabulary, and every other tier repeats those token names. That +repetition is the one redundancy worth keeping: the same name appearing in +prose, skeleton, and exemplar is what stops the agent from inventing +plausible near-miss values. + +The other three tiers are earned by the package's shape: + +- **Skeletons** pay when the package specifies few, composition-critical + surfaces — where the first structural commitment decides everything + downstream — and when consuming agents may read only pulled bodies. +- **Components** pay when the package covers many diverse surfaces generated + often, and the consuming agent reads material files. A class vocabulary + amortizes across surfaces; for a single surface shape it is dead weight. +- **Exemplars** pay almost everywhere: they are the cheapest way to show + tiers interacting. One per distinct surface shape; a package with one + exemplar and many surface shapes pulls every task toward the demonstrated + shape. + +## Every tier is a copy that can drift + +Each tier repeats the token truth, and a stale copy steers harder than the +prose that corrects it. A hardcoded value in an exemplar where a token +exists is a lie waiting for a rename. Before adding a tier, accept its +maintenance bill; after any token change, sweep every tier for stranded +literals. + +A tier can also fight the package's stance. A package whose truth is +"compose each surface fresh from the tokens" contradicts itself by shipping +a component kit; a package whose truth is "compose from the grammar, the +refs are examples not a framework" hardens toward template convergence if +every pattern carries a mandatory skeleton. When a tier and the stance +conflict, the stance wins — that absence is the package expressing itself. + +## Annotate whatever code ships + +Un-annotated code teaches content along with structure. Whatever tier the +package carries, split the annotation two ways: name the load-bearing moves +(what makes it this brand — copy these) and mark the incidental content +(domain, figures, labels — swap these). An exemplar without this split +teaches the agent that the example's subject matter is the brand. + +## The self-check + +For any package, answer four questions: + +1. Where does the value vocabulary live? (If not a token file: why?) +2. What forces the opening structure of the package's most + composition-critical surface? (If nothing: is that surface's structure + genuinely open?) +3. What demonstrates the cross-node constraints working together? (If + nothing: which interactions is the agent left to infer?) +4. For every tier present: will the consuming agent actually have it in + context when it generates? + +Any answer may legitimately be "absent, because" — the recipe's only +requirement is that the "because" exists. diff --git a/packages/ghost/src/skill-bundle/references/recall.md b/packages/ghost/src/skill-bundle/references/recall.md index 9006a212..596d03cb 100644 --- a/packages/ghost/src/skill-bundle/references/recall.md +++ b/packages/ghost/src/skill-bundle/references/recall.md @@ -5,14 +5,9 @@ description: Gather and pull the applicable Ghost brand truths for a task. # Recipe: Recall Ghost Fingerprint -Wild posture is opt-in. Default `ghost gather` excludes kinds whose glossary -entry declares `posture: wild`; use `--wild` only when the brief names explicit -open territory where conformance is suspended. Guard posture stays visible by -default because guards are brand-defining. - -1. Run `ghost gather ` for the actual task. Read the coverage line: all - prose-only fingerprints are weak steering, and guard count tells you whether - review-critical negative space exists. +1. Run `ghost gather ` for the actual task. The cover is inlined by gather; + do not pull it separately. Read the coverage line: all-prose fingerprints are + weak steering. 2. Select against `description`; Ghost never selects for you. 3. Run `ghost pull […]`. Prefer `pull` over reading files directly: it orders the packet for steering, inlines small local materials, emits @@ -23,14 +18,13 @@ default because guards are brand-defining. `ghost pull` emits selected nodes in steering order: -1. `index` / stance first: non-negotiables and silence posture. +1. Cover first, when explicitly selected; normally it is already inlined by + gather and should not be pulled separately. 2. Concrete-material nodes: `materials`, substantial fenced examples, or `## Skeleton` sections, with materials inlined or pointed to inspect. 3. Prose-only rules: principles, conditions, decisions, and other rules without concrete payload. -4. Guard nodes: `posture: guard`, flagged review-critical; read the replacement - carefully. -5. Skeleton fences dead last: if one matches the surface, start from that +4. Skeleton fences dead last: if one matches the surface, start from that structure verbatim. Return the selected truths with node ids, how each applies, and where the diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index a131384f..1f7f55a1 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -1,6 +1,6 @@ --- name: schema -description: The Ghost fingerprint package shape: flat nodes, derived concreteness, Skeletons, guards, probes, and checks. +description: The Ghost fingerprint package shape: flat nodes, derived concreteness, Skeletons, probes, and checks. --- # Ghost Fingerprint Package Reference @@ -9,8 +9,8 @@ Canonical package: ```text .ghost/ - manifest.yml ghost.fingerprint-package/v1: schema + id - glossary.md kind vocabulary + meanings + optional posture + manifest.yml ghost.fingerprint-package/v1: schema + id + optional cover + glossary.md kind vocabulary + meanings materials/ bundled materials; never a node source ..md a brand truth of a declared kind .md a brand truth without a kind @@ -20,25 +20,16 @@ Canonical package: Reserved at the root: `manifest.yml`, `glossary.md`, `materials/`, and `checks/`. Every other `*.md` is a node. -## Glossary posture +## Manifest -A kind may declare posture. Omitted posture defaults to `steady`. +`manifest.yml` declares `schema`, `id`, and optionally `cover`. `cover` is a +node id. When present and resolved, `ghost gather` inlines that node above the +menu on every invocation. Use it for what selection cannot reliably retrieve: +essence, temperature, and brand-only refusals. -```yaml -kinds: - - name: principle - - name: anti-goal - posture: guard - - name: provocation - posture: wild -``` - -- `steady`: default, gathered normally. -- `guard`: review-critical negative space. Stays in default gather, appears at - the tail of `ghost pull`, and is auto-offered by `ghost review` when its - materials match touched files. -- `wild`: deliberate push beyond the fingerprint. Default gather excludes wild - kinds unless `--wild` is explicit. +`ghost validate` enforces the cover contract: a missing referenced cover is an +error, an undeclared cover is a warning, and a cover body past the one-screen +budget (1500 bytes) is a warning. ## Nodes @@ -110,10 +101,10 @@ probes are the same class as npm scripts; Git review is the boundary. ## Gather / Pull / Review -- `ghost gather` emits the node menu, including coverage counts. Checks are - invisible. +- `ghost gather` emits the cover above the node menu, then coverage counts. + Checks are invisible. - `ghost pull` emits selected nodes in steering order and inlines small local materials. Binary local materials become inspect-pointers. - `ghost review` matches diff files to local node materials, offers relevant - checks and matched guard nodes, embeds probe evidence, and emits a packet for - the host agent to judge. + checks, embeds probe evidence, and emits a packet for the host agent to + judge. diff --git a/packages/ghost/src/skill-bundle/references/steering-audit.md b/packages/ghost/src/skill-bundle/references/steering-audit.md index d74b1851..9a7c1070 100644 --- a/packages/ghost/src/skill-bundle/references/steering-audit.md +++ b/packages/ghost/src/skill-bundle/references/steering-audit.md @@ -1,6 +1,6 @@ --- name: steering-audit -description: Audit a Ghost fingerprint for concrete steering coverage and guard routing. +description: Audit a Ghost fingerprint for concrete steering coverage. --- # Recipe: Audit Steering Coverage @@ -19,16 +19,13 @@ ghost pulse --format json If checks are installed and a diff exists, run `ghost review` too. -## Headline the audit with concreteness + guards +## Headline the audit with concreteness Report first: - **Concreteness coverage:** total nodes, concrete-material nodes, prose-only nodes. Concrete means non-empty `materials`, a fenced code block of at least 3 lines, or a `## Skeleton` section. -- **Guard routing:** how many guard nodes exist, whether they stay in default - gather, and whether `ghost review` can auto-offer matched guards via - materials. - **Pulse by concreteness:** concrete exposure/pull rate vs prose-only exposure/pull rate. This is the tuning instrument: if concrete nodes are not pulled, descriptions or task selection are failing. @@ -39,8 +36,8 @@ Report first: | --- | --- | --- | --- | | Retrieval | strong / weak | descriptions, ids, `index` | sharpen descriptions or mention cold nodes in `index` | | Concreteness | strong / thin | materials, fenced examples, Skeletons | add concrete locators, exemplars, or opening structures | -| Guards | routed / missing / vague | `posture: guard`, review packet | write not-X-instead-Y guards and material locators | -| Consistency | clean / conflicting | concrete bodies vs rules/guards | update stale examples; examples average with rules | +| Anti-goals | present / missing / vague | `anti-goal.*`, review packet | write not-X-instead-Y replacements and material locators | +| Consistency | clean / conflicting | concrete bodies vs rules | update stale examples; examples average with rules | | Stance | present / missing | `index`, `principle.*` | write forced-choice principles | | Materials | present / missing | `materials`, inspect-pointers | point at real assets/components/tokens | | Exemplars | annotated / unannotated / missing | fenced samples, screenshots | say what to copy and what is incidental | diff --git a/packages/ghost/src/skill-bundle/references/wild.md b/packages/ghost/src/skill-bundle/references/wild.md deleted file mode 100644 index 7947cc71..00000000 --- a/packages/ghost/src/skill-bundle/references/wild.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: wild -description: Use wild nodes deliberately to explore past the fingerprint without claiming conformance. ---- - -# Recipe: Use Wild Nodes - -Use this ritual only when the human asks to explore beyond the current -fingerprint or when the brief declares explicit **open territory**: the part of -the work where conformance is intentionally suspended. - -1. Run `ghost gather --wild` to expose wild nodes alongside the - steady menu. Wild nodes push past the fingerprint; Ghost still does no - selection. -2. Select deliberately: choose at most a couple of wild nodes whose descriptions - match the open territory. Do not bulk-load wild nodes. -3. Run `ghost pull ... ...` to read the selected nodes. -4. In the working brief, name the open territory plainly: where conformance is - suspended, which wild node ids are being used, and which steady - non-negotiables still bind. -5. Generate two options: - - a conforming baseline grounded only in steady fingerprint context; - - a divergent take that uses the selected wild nodes inside the open - territory. -6. Ask a human to pick, reject, or carry the surprise back into the fingerprint - through ordinary reviewed edits. - -Wild nodes are never routine context. Never claim a divergent take as -Ghost-backed conformance, and never present surprise as something Ghost chose; -surprise is chosen by a human. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index ea313adb..ce0d6641 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -214,18 +214,16 @@ describe("ghost CLI", () => { "manifest.yml", ".gitignore", "glossary.md", - "index.md", - "anti-goal.median.md", - "grammar.hierarchy.md", - "grammar.rhythm.md", - "grammar.surfaces.md", - "grammar.motion.md", - "grammar.color-roles.md", - "grammar.conversation.md", - "signature.shape.md", - "signature.palette.md", - "signature.type.md", - "signature.temperature.md", + "brand.md", + "cliche.median.md", + "foundation.composition.md", + "foundation.color.md", + "foundation.type.md", + "foundation.controls.md", + "foundation.layout.md", + "foundation.motion.md", + "foundation.voice.md", + "context.conversation.md", ]; async function expectSkeletonPackage(written: string[]) { @@ -240,6 +238,9 @@ describe("ghost CLI", () => { await expect( readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), ).resolves.toContain("schema: ghost.fingerprint-package/v1"); + await expect( + readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), + ).resolves.toContain("cover: brand"); // The scaffolded package validates with zero errors AND zero warnings. const validate = await runCli(["validate", "--format", "json"], dir); @@ -258,36 +259,35 @@ describe("ghost CLI", () => { node.kind, ]), ); - expect(byId.get("index")).toBeUndefined; - expect(byId.has("index")).toBe(true); - expect(byId.get("anti-goal.median")).toBe("anti-goal"); + expect(byId.get("brand")).toBeUndefined; + expect(byId.has("brand")).toBe(false); + expect(byId.get("cliche.median")).toBe("cliche"); for (const slug of [ - "hierarchy", - "rhythm", - "surfaces", + "composition", + "color", + "type", + "controls", + "layout", "motion", - "color-roles", - "conversation", + "voice", ]) { - expect(byId.get(`grammar.${slug}`)).toBe("grammar"); - } - for (const slug of ["shape", "palette", "type", "temperature"]) { - expect(byId.get(`signature.${slug}`)).toBe("signature"); + expect(byId.get(`foundation.${slug}`)).toBe("foundation"); } + expect(byId.get("context.conversation")).toBe("context"); // The median floor survives intact: prune header + rule anchors. - const median = await runCli(["pull", "anti-goal.median"], dir); + const median = await runCli(["pull", "cliche.median"], dir); expect(median.code).toBe(0); expect(median.stdout).toContain( "This is the model's median, not your brand.", ); expect(median.stdout).toContain("### Side-stripe"); - // The dials ship unanswered and forbid freehanding. - const shape = await runCli(["pull", "signature.shape"], dir); - expect(shape.code).toBe(0); - expect(shape.stdout).toContain("This dial is unanswered"); - expect(shape.stdout).toContain("freehand"); + // The open questions ship unanswered and forbid freehanding. + const layout = await runCli(["pull", "foundation.layout"], dir); + expect(layout.code).toBe(0); + expect(layout.stdout).toContain("Open — ask the human"); + expect(layout.stdout).toContain("freehand"); // No Vessel strings anywhere in the scaffolded package. const forbidden = [ @@ -331,7 +331,7 @@ describe("ghost CLI", () => { expect(initOutput.written).toContain("manifest.yml"); expect(initOutput.written).toContain("glossary.md"); expect(initOutput.written).toContain("index.md"); - expect(initOutput.written).toContain("anti-goal.median.md"); + expect(initOutput.written).toContain("cliche.median.md"); expect(initOutput.written).not.toContain("principle.stance.md"); expect(initOutput.written).not.toContain("decision.tradeoff.md"); @@ -374,7 +374,7 @@ describe("ghost CLI", () => { expect(initOutput.written).toContain("index.md"); expect(initOutput.written).toContain("principle.composition.md"); expect(initOutput.written).toContain("pattern.status-with-next-step.md"); - expect(initOutput.written).toContain("anti-goal.median.md"); + expect(initOutput.written).toContain("cliche.median.md"); // The scaffolded package is valid as written. const validate = await runCli(["validate", "--format", "json"], dir); @@ -623,8 +623,8 @@ describe("ghost CLI", () => { expect(forced.code).toBe(0); await expect( - readFile(join(dir, ".ghost", "index.md"), "utf-8"), - ).resolves.toContain("skeleton fingerprint"); + readFile(join(dir, ".ghost", "brand.md"), "utf-8"), + ).resolves.toContain("This cover is unwritten"); }); it("does not guess arbitrary YAML files are validate.yml", async () => { @@ -663,7 +663,7 @@ describe("ghost CLI", () => { expect(init.code).toBe(0); expect(init.stdout).toContain("manifest.yml"); expect(init.stdout).toContain("glossary.md"); - expect(init.stdout).toContain("index.md"); + expect(init.stdout).toContain("brand.md"); expect(init.stdout).not.toContain("cache/:"); expect(init.stdout).not.toContain("memory/intent.md:"); expect( @@ -693,183 +693,155 @@ describe("ghost CLI", () => { const lint = await runCli(["validate"], dir); expect(lint.code).toBe(0); - // The seed node is the package-root index.md — id `index`, listed like any other. + // The seed cover is package-root brand.md, inlined and excluded from the menu. const gather = await runCli(["gather", "--format", "json"], dir); expect(gather.code).toBe(0); const slice = JSON.parse(gather.stdout); - expect(slice.nodes.some((n: { id: string }) => n.id === "index")).toBe( - true, + expect(slice.cover.id).toBe("brand"); + expect(slice.nodes.some((n: { id: string }) => n.id === "brand")).toBe( + false, ); }); - it("gather surfaces glossary kind purposes as a menu legend", async () => { + it("gather inlines the declared cover and excludes it from the menu", async () => { await runCli(["init"], dir); - // JSON carries the glossary's declared kinds with their prose purposes. - const gather = await runCli(["gather", "--format", "json"], dir); - expect(gather.code).toBe(0); - const menu = JSON.parse(gather.stdout); - const grammar = menu.kinds.find( - (k: { name: string }) => k.name === "grammar", - ); - expect(grammar.purpose).toContain("decision logic"); - - // Markdown renders the same legend above the node list. const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain("Kinds:"); - expect(markdown.stdout).toContain( - "- **grammar** — The brand's decision logic", - ); - - // A missing glossary degrades to no legend, not an error. - await rm(join(dir, ".ghost", "glossary.md")); - const bare = await runCli(["gather", "--format", "json"], dir); - expect(bare.code).toBe(0); - expect(JSON.parse(bare.stdout).kinds).toBeUndefined(); - }); - - it("runs validate from the unified cli", async () => { - await writeCheckPackage(dir); - const validate = await runCli(["validate"], dir); + expect(markdown.code).toBe(0); + expect(markdown.stdout).toContain("## Cover — `brand`"); + expect(markdown.stdout).toContain("This cover is unwritten."); + expect(markdown.stdout).toContain("9 nodes · 0 carry concrete material"); + expect(markdown.stdout).not.toContain("- `brand`"); - expect(validate.code).toBe(0); - expect(validate.stdout).toContain("0 error"); + const json = await runCli(["gather", "--format", "json"], dir); + expect(json.code).toBe(0); + const payload = JSON.parse(json.stdout); + expect(payload.cover).toMatchObject({ + id: "brand", + body: expect.stringContaining("This cover is unwritten."), + }); + expect(payload.nodes.map((node: { id: string }) => node.id)).not.toContain( + "brand", + ); + expect(payload.coverage).toEqual({ + nodes: 9, + concrete: 0, + undescribed: 0, + }); }); - it("gather excludes wild nodes by default and includes marked wild nodes with --wild", async () => { - await runCli(["init", "--template", "minimal"], dir); + it("gather degrades to the plain menu when the declared cover is missing", async () => { + await runCli(["init"], dir); await writeFile( - join(dir, ".ghost", "glossary.md"), - `--- -kinds: - - name: principle - - name: provocation - posture: wild ---- + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\ncover: missing\n", + ); -# principle + const markdown = await runCli(["gather"], dir); + expect(markdown.code).toBe(0); + expect(markdown.stdout).not.toContain("## Cover"); + // With no resolvable cover, brand stays a selectable menu node. + expect(markdown.stdout).toContain("10 nodes · 0 carry concrete material"); + expect(markdown.stdout).toContain("- `brand`"); -Durable stance. + const json = await runCli(["gather", "--format", "json"], dir); + expect(json.code).toBe(0); + const payload = JSON.parse(json.stdout); + expect(payload.cover).toBeUndefined(); + expect(payload.nodes.map((node: { id: string }) => node.id)).toContain( + "brand", + ); + }); -# provocation + it("validate reports cover declaration issues", async () => { + await runCli(["init"], dir); -a deliberate provocation past the fingerprint — surfaced only on request -`, - ); await writeFile( - join(dir, ".ghost", "provocation.noise.md"), - "---\ndescription: Break the usual rhythm.\n---\n\nInterrupt the grid.\n", + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\ncover: missing\n", ); - await writeFile( - join(dir, ".ghost", "principle.trust.md"), - "---\ndescription: Trust.\n---\n\nReduce felt risk.\n", + const missing = await runCli(["validate", "--format", "json"], dir); + expect(missing.code).toBe(1); + let report = JSON.parse(missing.stdout); + expect(report.issues).toContainEqual( + expect.objectContaining({ severity: "error", rule: "cover-missing" }), ); - const defaultJson = await runCli( - ["gather", "hero", "--format", "json"], - dir, + await writeFile( + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\n", ); - expect(defaultJson.code).toBe(0); - const defaultPayload = JSON.parse(defaultJson.stdout); - expect( - defaultPayload.nodes.map((node: { id: string }) => node.id), - ).not.toContain("provocation.noise"); - expect(defaultPayload.wildAvailable).toBe(1); - - const defaultMarkdown = await runCli(["gather", "hero"], dir); - expect(defaultMarkdown.stdout).not.toContain("`provocation.noise`"); - expect(defaultMarkdown.stdout).toContain( - "1 wild node available via `--wild`", + const undeclared = await runCli(["validate", "--format", "json"], dir); + expect(undeclared.code).toBe(0); + report = JSON.parse(undeclared.stdout); + expect(report.issues).toContainEqual( + expect.objectContaining({ + severity: "warning", + rule: "cover-undeclared", + }), ); - const wildJson = await runCli( - ["gather", "hero", "--wild", "--format", "json"], - dir, - ); - expect(wildJson.code).toBe(0); - const wildPayload = JSON.parse(wildJson.stdout); - const wildNode = wildPayload.nodes.find( - (node: { id: string }) => node.id === "provocation.noise", + await writeFile( + join(dir, ".ghost", "manifest.yml"), + "schema: ghost.fingerprint-package/v1\nid: local\ncover: brand\n", ); - expect(wildNode).toMatchObject({ - kind: "provocation", - wild: true, - }); - expect(wildPayload.wildAvailable).toBeUndefined(); - - const wildMarkdown = await runCli(["gather", "hero", "--wild"], dir); - expect(wildMarkdown.stdout).toContain( - "`provocation.noise` _(provocation)_ _(wild)", + const brand = await readFile(join(dir, ".ghost", "brand.md"), "utf-8"); + await writeFile( + join(dir, ".ghost", "brand.md"), + brand.replace("This cover is unwritten.", "x".repeat(1501)), ); - expect(wildMarkdown.stdout).toContain( - "Wild nodes are marked `(wild)`: they push past the fingerprint", + const oversized = await runCli(["validate", "--format", "json"], dir); + expect(oversized.code).toBe(0); + report = JSON.parse(oversized.stdout); + expect(report.issues).toContainEqual( + expect.objectContaining({ + severity: "warning", + rule: "cover-oversized", + }), ); - - const events = (await readFile(join(dir, ".ghost", ".events"), "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(events[0]).toMatchObject({ - event: "gather", - wild: false, - wildIds: [], - }); - expect(events[2]).toMatchObject({ - event: "gather", - wild: true, - wildIds: ["provocation.noise"], - }); }); - it("pull works for wild nodes and pulse reports wild usage", async () => { - await runCli(["init", "--template", "minimal"], dir); - await writeFile( - join(dir, ".ghost", "glossary.md"), - `--- -kinds: - - name: principle - - name: provocation - posture: wild ---- + it("pull sorts the cover before other requested nodes", async () => { + await runCli(["init"], dir); -# principle + const pull = await runCli(["pull", "foundation.layout", "brand"], dir); -Durable stance. + expect(pull.code).toBe(0); + expect(pull.stdout.indexOf("# `brand`")).toBeLessThan( + pull.stdout.indexOf("# `foundation.layout`"), + ); + }); -# provocation + it("gather surfaces glossary kind purposes as a menu legend", async () => { + await runCli(["init"], dir); -a deliberate provocation past the fingerprint — surfaced only on request -`, - ); - await writeFile( - join(dir, ".ghost", "provocation.noise.md"), - "---\ndescription: Break the usual rhythm.\n---\n\nInterrupt the grid.\n", + // JSON carries the glossary's declared kinds with their prose purposes. + const gather = await runCli(["gather", "--format", "json"], dir); + expect(gather.code).toBe(0); + const menu = JSON.parse(gather.stdout); + const foundation = menu.kinds.find( + (k: { name: string }) => k.name === "foundation", ); + expect(foundation.purpose).toContain("core elements"); - await runCli(["gather", "hero", "--wild"], dir); - const pull = await runCli(["pull", "provocation.noise"], dir); - expect(pull.code).toBe(0); - expect(pull.stdout).toContain("Interrupt the grid."); - expect(pull.stdout).toContain("_(wild)_"); + // Markdown renders the same legend above the node list. + const markdown = await runCli(["gather"], dir); + expect(markdown.stdout).toContain("Kinds:"); + expect(markdown.stdout).toContain("- **foundation** — The core elements"); - const events = (await readFile(join(dir, ".ghost", ".events"), "utf-8")) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - expect(events.at(-1)).toMatchObject({ - event: "pull", - ids: ["provocation.noise"], - wildIds: ["provocation.noise"], - }); + // A missing glossary degrades to no legend, not an error. + await rm(join(dir, ".ghost", "glossary.md")); + const bare = await runCli(["gather", "--format", "json"], dir); + expect(bare.code).toBe(0); + expect(JSON.parse(bare.stdout).kinds).toBeUndefined(); + }); - const pulse = await runCli(["pulse", "--format", "json"], dir); - expect(pulse.code).toBe(0); - expect(JSON.parse(pulse.stdout).wild).toEqual({ exposures: 1, pulls: 1 }); + it("runs validate from the unified cli", async () => { + await writeCheckPackage(dir); + const validate = await runCli(["validate"], dir); - const md = await runCli(["pulse"], dir); - expect(md.stdout).toContain("## Wild usage"); - expect(md.stdout).toContain("- Wild exposures: 1"); - expect(md.stdout).toContain("- Wild pulls: 1"); + expect(validate.code).toBe(0); + expect(validate.stdout).toContain("0 error"); }); it("announces the events tape once, on first write, on stderr only", async () => { @@ -889,7 +861,7 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(second.code).toBe(0); expect(second.stderr).not.toContain(".ghost/.events"); - const pull = await runCli(["pull", "index"], dir); + const pull = await runCli(["pull", "brand"], dir); expect(pull.code).toBe(0); expect(pull.stderr).not.toContain(".ghost/.events"); }); @@ -898,7 +870,7 @@ a deliberate provocation past the fingerprint — surfaced only on request await runCli(["init", "--template", "minimal"], dir); await writeFile( join(dir, ".ghost", "glossary.md"), - "---\nkinds:\n - name: anti-goal\n posture: guard\n - name: asset\n - name: principle\n---\n\n# anti-goal\n\nReview-critical replacement.\n\n# asset\n\nConcrete material.\n\n# principle\n\nRule.\n", + "---\nkinds:\n - name: anti-goal\n - name: asset\n - name: principle\n---\n\n# anti-goal\n\nReview-critical replacement.\n\n# asset\n\nConcrete material.\n\n# principle\n\nRule.\n", ); await writeFile( join(dir, ".ghost", "asset.tokens.md"), @@ -913,43 +885,47 @@ a deliberate provocation past the fingerprint — surfaced only on request "---\ndescription: Generic replacement.\n---\n\nNot vague; instead exact.\n", ); + // The seeded cover (`index`) is inlined above the menu, not counted in it. const gather = await runCli(["gather", "--format", "json"], dir); expect(gather.code).toBe(0); expect(JSON.parse(gather.stdout).coverage).toEqual({ - nodes: 5, + nodes: 4, concrete: 1, - guards: 2, + undescribed: 0, }); const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain( - "5 nodes · 1 carry concrete material · 2 guards", + expect(markdown.stdout).toContain("4 nodes · 1 carry concrete material"); + // No undescribed nodes: the coverage line stays quiet about them. + expect(markdown.stdout).not.toContain("lack descriptions"); + + // A node without a description is invisible to selection — the coverage + // line says so, and validate warns on it. + await writeFile( + join(dir, ".ghost", "principle.mute.md"), + "---\n{}\n---\n\nUndescribed truth.\n", ); + const gatherMute = await runCli(["gather"], dir); + expect(gatherMute.stdout).toContain( + "5 nodes · 1 carry concrete material · 1 lack descriptions", + ); + const gatherMuteJson = await runCli(["gather", "--format", "json"], dir); + expect(JSON.parse(gatherMuteJson.stdout).coverage.undescribed).toBe(1); const steering = await runCli( - ["pull", "anti-goal.generic", "principle.rule", "asset.tokens"], + ["pull", "principle.rule", "asset.tokens"], dir, ); expect(steering.code).toBe(0); expect(steering.stdout.indexOf("`asset.tokens`")).toBeLessThan( steering.stdout.indexOf("`principle.rule`"), ); - expect(steering.stdout.indexOf("`principle.rule`")).toBeLessThan( - steering.stdout.indexOf("`anti-goal.generic`"), - ); const given = await runCli( - [ - "pull", - "anti-goal.generic", - "principle.rule", - "asset.tokens", - "--order", - "given", - ], + ["pull", "principle.rule", "asset.tokens", "--order", "given"], dir, ); - expect(given.stdout.indexOf("`anti-goal.generic`")).toBeLessThan( - given.stdout.indexOf("`principle.rule`"), + expect(given.stdout.indexOf("`principle.rule`")).toBeLessThan( + given.stdout.indexOf("`asset.tokens`"), ); }); @@ -1300,7 +1276,6 @@ a deliberate provocation past the fingerprint — surfaced only on request pulls: 1, abandonedGathers: 1, pullsPerGather: 0.5, - wild: { exposures: 0, pulls: 0 }, }); const trust = report.nodes.find( (node: { id: string }) => node.id === "principle.trust", @@ -1325,20 +1300,21 @@ a deliberate provocation past the fingerprint — surfaced only on request hitRate: 0.5, coldNodes: [], }); + // The cover (`index`) is inlined by gather, never exposed on the menu, + // so it accrues no exposures and never counts as cold. const noKind = report.kinds.find( (kind: { kind: string }) => kind.kind === "(no kind)", ); expect(noKind).toMatchObject({ - exposures: 4, pulls: 0, hitRate: 0, - coldNodes: ["index", "voice"], + coldNodes: ["voice"], }); + expect(report.coldNodes).not.toContain("index"); const md = await runCli(["pulse"], dir); expect(md.stdout).toContain("# Ghost Pulse"); expect(md.stdout).toContain("## Kind hit rates"); - expect(md.stdout).toContain("## Wild usage"); expect(md.stdout).toContain("- Abandoned gathers: 1"); }); @@ -1457,7 +1433,6 @@ a deliberate provocation past the fingerprint — surfaced only on request "references/blocks.md", "references/brief.md", "references/recall.md", - "references/wild.md", "references/self-check.md", "references/schema.md", "references/authoring-scenarios.md", @@ -1699,11 +1674,11 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(JSON.parse(skipped.stdout).checks[0].probe).toBeUndefined(); }); - it("review auto-offers matched guard nodes without check references", async () => { + it("review matches anti-goal nodes through materials like any node", async () => { await runCli(["init", "--with", "checks"], dir); await writeFile( join(dir, ".ghost", "glossary.md"), - "---\nkinds:\n - name: anti-goal\n posture: guard\n---\n\n# anti-goal\n\nReview-critical replacements.\n", + "---\nkinds:\n - name: anti-goal\n---\n\n# anti-goal\n\nReview-critical replacements.\n", ); await writeFile( join(dir, ".ghost", "anti-goal.generic-logo.md"), @@ -1711,7 +1686,7 @@ a deliberate provocation past the fingerprint — surfaced only on request ); await writeFile( join(dir, ".ghost", "checks", "unrelated.md"), - "---\nname: unrelated\ndescription: Always review unrelated posture.\nseverity: low\nreferences:\n - missing.future\n---\n\nReview unrelated things.\n", + "---\nname: unrelated\ndescription: Always review unrelated things.\nseverity: low\nreferences:\n - missing.future\n---\n\nReview unrelated things.\n", ); const diff = [ "diff --git a/brand/logo.svg b/brand/logo.svg", @@ -1725,7 +1700,7 @@ a deliberate provocation past the fingerprint — surfaced only on request const result = await runCli(["review", "--diff=-"], dir, { stdin: diff }); expect(result.code).toBe(0); - expect(result.stdout).toContain("## Matched guard nodes — review-critical"); + expect(result.stdout).toContain("## Matched material-backed nodes"); expect(result.stdout).toContain( "### `anti-goal.generic-logo` _(anti-goal)_", ); @@ -1889,13 +1864,13 @@ a deliberate provocation past the fingerprint — surfaced only on request readFile(join(dir, ".ghost", "checks", "example.md.example"), "utf-8"), ).resolves.toContain("references:"); - // The live median check pairs with the skeleton's anti-goal.median node. + // The live median check pairs with the skeleton's cliche.median node. const median = await readFile( join(dir, ".ghost", "checks", "median-tells.md"), "utf-8", ); - expect(median).toContain("anti-goal.median"); - expect(median).toContain("anti-goal.median > Hover-lift"); + expect(median).toContain("cliche.median"); + expect(median).toContain("cliche.median > Hover-lift"); expect(median).toContain("prefers-reduced-motion"); expect(median).toContain( "`ghost validate` warns; delete the flag and its reference together.", @@ -1908,7 +1883,7 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(again.stderr).toContain("already exists"); // The scaffold validates cleanly on the default skeleton: median-tells - // references resolve against anti-goal.median and the grammar nodes. + // references resolve against cliche.median. const validate = await runCli(["validate", "--format", "json"], dir); expect(validate.code).toBe(0); const report = JSON.parse(validate.stdout); @@ -1920,12 +1895,12 @@ a deliberate provocation past the fingerprint — surfaced only on request it("checks init skips median tells when the median node is absent", async () => { await runCli(["init", "--template", "minimal"], dir); - await rm(join(dir, ".ghost", "anti-goal.median.md")); + await rm(join(dir, ".ghost", "cliche.median.md")); const add = await runCli(["checks", "init"], dir); expect(add.code).toBe(0); expect(add.stdout).toContain( - "skipped median-tells.md (no anti-goal.median node)", + "skipped median-tells.md (no cliche.median node)", ); await expect( @@ -1942,7 +1917,7 @@ a deliberate provocation past the fingerprint — surfaced only on request it("validate warns when a pruned median heading orphans its paired check", async () => { await runCli(["init"], dir); await runCli(["checks", "init"], dir); - const path = join(dir, ".ghost", "anti-goal.median.md"); + const path = join(dir, ".ghost", "cliche.median.md"); const median = await readFile(path, "utf-8"); await writeFile( path, @@ -1957,7 +1932,7 @@ a deliberate provocation past the fingerprint — surfaced only on request expect.objectContaining({ severity: "warning", rule: "check-reference-heading-missing", - message: expect.stringContaining("anti-goal.median > Side-stripe"), + message: expect.stringContaining("cliche.median > Side-stripe"), }), ]); expect(report.issues[0].message).toContain( diff --git a/packages/ghost/test/fingerprint-package.test.ts b/packages/ghost/test/fingerprint-package.test.ts index ce384696..2e060f6b 100644 --- a/packages/ghost/test/fingerprint-package.test.ts +++ b/packages/ghost/test/fingerprint-package.test.ts @@ -86,129 +86,103 @@ describe("split fingerprint package", () => { }); }); - it("parses glossary kind posture and defaults to steady", () => { + it("parses glossary kinds and their prose purposes", () => { const parsed = parseGlossary(`--- kinds: - name: principle - - name: provocation - posture: wild - name: anti-goal - posture: guard --- # principle Floor. -# provocation - -Provocation. - # anti-goal -Replacement guard. +Replacement rule. `); expect(parsed.glossary?.kinds).toEqual([ - { name: "principle", posture: "steady", purpose: "Floor." }, - { name: "provocation", posture: "wild", purpose: "Provocation." }, - { name: "anti-goal", posture: "guard", purpose: "Replacement guard." }, + { name: "principle", purpose: "Floor." }, + { name: "anti-goal", purpose: "Replacement rule." }, ]); expect(parsed.glossary?.frontmatter.kinds).toEqual([ - { name: "principle", posture: "steady" }, - { name: "provocation", posture: "wild" }, - { name: "anti-goal", posture: "guard" }, + { name: "principle" }, + { name: "anti-goal" }, ]); }); - it("rejects invalid glossary posture values during validation", async () => { + it("does not warn when a node kind is declared in the glossary", async () => { await writeManifest(dir); - const raw = `--- -kinds: - - name: provocation - posture: loud ---- - -# provocation - -Provocation. -`; - await writeFile(join(dir, "glossary.md"), raw); - - const parsed = parseGlossary(raw); - expect(parsed.glossary).toBeNull(); - expect(parsed.errors[0]).toContain("Invalid option"); + await writeGlossary(dir, ["principle"]); + await writeFile( + join(dir, "principle.density.md"), + "---\ndescription: Density stance.\n---\n\nUse density deliberately.\n", + ); const report = await lintFingerprintPackage(dir); - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - severity: "error", - rule: "glossary-invalid", - path: "glossary.md", - }); + + expect(report.errors).toBe(0); + // The only warning is the undeclared cover, never a kind complaint. + expect(report.warnings).toBe(1); + expect(report.issues).not.toContainEqual( + expect.objectContaining({ rule: "kind-undeclared" }), + ); }); - it("marks nodes wild or guard when their glossary kind declares posture", async () => { + it("does not warn for an bare node name without a kind", async () => { await writeManifest(dir); - await writeGlossary(dir, [ - "principle", - { name: "provocation", posture: "wild" }, - { name: "anti-goal", posture: "guard" }, - ]); - await writeFile( - join(dir, "provocation.noise.md"), - "---\ndescription: Noise.\n---\n\nBreak the pattern.\n", - ); - await writeFile( - join(dir, "anti-goal.generic.md"), - "---\ndescription: Generic.\n---\n\nNot vague; use concrete next steps.\n", - ); + await writeGlossary(dir, ["principle"]); await writeFile( - join(dir, "principle.density.md"), - "---\ndescription: Density.\n---\n\nKeep density intentional.\n", + join(dir, "voice.md"), + "---\ndescription: Voice.\n---\n\nSpeak plainly.\n", ); - const loaded = await loadFingerprintPackage(resolveFingerprintPackage(dir)); + const report = await lintFingerprintPackage(dir); - expect(loaded.catalog.nodes.get("provocation.noise")?.wild).toBe(true); - expect(loaded.catalog.nodes.get("anti-goal.generic")?.guard).toBe(true); - expect(loaded.catalog.nodes.get("anti-goal.generic")?.posture).toBe( - "guard", + expect(report.errors).toBe(0); + // The only warning is the undeclared cover, never a kind complaint. + expect(report.warnings).toBe(1); + expect(report.issues).not.toContainEqual( + expect.objectContaining({ rule: "kind-undeclared" }), ); - expect(loaded.catalog.nodes.get("principle.density")?.wild).toBeUndefined(); }); - it("does not warn when a node kind is declared in the glossary", async () => { + it("warns when a node has no description", async () => { await writeManifest(dir); await writeGlossary(dir, ["principle"]); await writeFile( join(dir, "principle.density.md"), - "---\ndescription: Density stance.\n---\n\nUse density deliberately.\n", + "---\n{}\n---\n\nUse density deliberately.\n", ); const report = await lintFingerprintPackage(dir); expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); - expect(report.issues).not.toContainEqual( - expect.objectContaining({ rule: "kind-undeclared" }), + // Undeclared cover + missing description. + expect(report.warnings).toBe(2); + expect(report.issues).toContainEqual( + expect.objectContaining({ + severity: "warning", + rule: "node-description-missing", + path: "principle.density.md", + }), ); }); - it("does not warn for an bare node name without a kind", async () => { + it("does not warn about descriptions when every node has one", async () => { await writeManifest(dir); await writeGlossary(dir, ["principle"]); await writeFile( - join(dir, "voice.md"), - "---\ndescription: Voice.\n---\n\nSpeak plainly.\n", + join(dir, "principle.density.md"), + "---\ndescription: Density stance.\n---\n\nUse density deliberately.\n", ); const report = await lintFingerprintPackage(dir); expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); expect(report.issues).not.toContainEqual( - expect.objectContaining({ rule: "kind-undeclared" }), + expect.objectContaining({ rule: "node-description-missing" }), ); }); @@ -223,14 +197,18 @@ Provocation. const report = await lintFingerprintPackage(dir); expect(report.errors).toBe(0); - expect(report.warnings).toBe(1); - expect(report.issues[0]).toMatchObject({ + // Undeclared cover + undeclared kind. + expect(report.warnings).toBe(2); + const kindIssue = report.issues.find( + (issue) => issue.rule === "kind-undeclared", + ); + expect(kindIssue).toMatchObject({ severity: "warning", rule: "kind-undeclared", path: "principles.density.md", message: expect.stringContaining("`principles`"), }); - expect(report.issues[0]?.message).toContain("Did you mean `principle`?"); + expect(kindIssue?.message).toContain("Did you mean `principle`?"); }); it("does not warn about node kinds when no glossary is present", async () => { @@ -243,7 +221,8 @@ Provocation. const report = await lintFingerprintPackage(dir); expect(report.errors).toBe(0); - expect(report.warnings).toBe(0); + // The only warning is the undeclared cover, never a kind complaint. + expect(report.warnings).toBe(1); expect(report.issues).not.toContainEqual( expect.objectContaining({ rule: "kind-undeclared" }), ); @@ -300,7 +279,8 @@ Provocation. const report = await lintFingerprintPackage(dir, dir); expect(report.errors).toBe(0); - expect(report.warnings).toBe(2); + // Undeclared cover + dead locator + orphaned material. + expect(report.warnings).toBe(3); expect(report.issues).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -463,26 +443,13 @@ async function writeChecks( } } -type TestGlossaryKind = - | string - | { name: string; posture?: "steady" | "wild" | "guard" }; - -async function writeGlossary( - dir: string, - kinds: TestGlossaryKind[], -): Promise { - const normalized = kinds.map((kind) => - typeof kind === "string" ? { name: kind } : kind, - ); +async function writeGlossary(dir: string, kinds: string[]): Promise { await writeFile( join(dir, "glossary.md"), - `---\nkinds:\n${normalized - .map( - (kind) => - ` - name: ${kind.name}${kind.posture ? `\n posture: ${kind.posture}` : ""}`, - ) + `---\nkinds:\n${kinds + .map((kind) => ` - name: ${kind}`) .join( "\n", - )}\n---\n\n${normalized.map((kind) => `# ${kind.name}\n\n${kind.name} purpose.`).join("\n\n")}\n`, + )}\n---\n\n${kinds.map((kind) => `# ${kind}\n\n${kind} purpose.`).join("\n\n")}\n`, ); } diff --git a/packages/ghost/test/ghost-core/catalog-assemble.test.ts b/packages/ghost/test/ghost-core/catalog-assemble.test.ts index 25451e2c..b5c6b3b8 100644 --- a/packages/ghost/test/ghost-core/catalog-assemble.test.ts +++ b/packages/ghost/test/ghost-core/catalog-assemble.test.ts @@ -69,17 +69,4 @@ describe("assembleCatalog (flat catalog assembly)", () => { expect(catalog.nodes.get("pattern.shell")?.hasSkeleton).toBe(true); expect(catalog.nodes.get("principle.short")?.concrete).toBe(false); }); - - it("marks nodes wild from glossary posture input", () => { - const catalog = assembleCatalog({ - placedNodes: [ - placed("principle.trust", { kind: "principle" }), - placed("provocation.noise", { kind: "provocation" }), - ], - wildKinds: ["provocation"], - }); - - expect(catalog.nodes.get("principle.trust")?.wild).toBeUndefined(); - expect(catalog.nodes.get("provocation.noise")?.wild).toBe(true); - }); }); diff --git a/packages/vessel-light/.ghost/glossary.md b/packages/vessel-light/.ghost/glossary.md index 8017688a..189a754c 100644 --- a/packages/vessel-light/.ghost/glossary.md +++ b/packages/vessel-light/.ghost/glossary.md @@ -4,7 +4,6 @@ kinds: - name: signature - name: register - name: anti-goal - posture: guard --- # grammar diff --git a/packages/vessel-light/.ghost/manifest.yml b/packages/vessel-light/.ghost/manifest.yml index 3cd8d93f..a24901fd 100644 --- a/packages/vessel-light/.ghost/manifest.yml +++ b/packages/vessel-light/.ghost/manifest.yml @@ -1,2 +1,3 @@ schema: ghost.fingerprint-package/v1 id: vessel-light +cover: index diff --git a/packages/vessel-react/fingerprint/glossary.md b/packages/vessel-react/fingerprint/glossary.md index 7205f5ad..a3a47957 100644 --- a/packages/vessel-react/fingerprint/glossary.md +++ b/packages/vessel-react/fingerprint/glossary.md @@ -4,7 +4,6 @@ kinds: - name: primitive - name: pattern - name: anti-goal - posture: guard --- # contract diff --git a/scripts/check-release-tarball.mjs b/scripts/check-release-tarball.mjs index ed8aa742..5921cd75 100644 --- a/scripts/check-release-tarball.mjs +++ b/scripts/check-release-tarball.mjs @@ -66,7 +66,7 @@ try { "package.json", "dist/bin.js", "dist/cli.js", - "dist/init-payloads/median/anti-goal.median.md", + "dist/init-payloads/median/cliche.median.md", "dist/init-payloads/median/median-tells.md", "dist/init-payloads/vessel-light/manifest.yml", "dist/init-payloads/vessel-light/materials/fonts/HKGrotesk-Regular.woff2", diff --git a/scripts/check-terminology.mjs b/scripts/check-terminology.mjs index 8eecb3d5..7efc69ea 100644 --- a/scripts/check-terminology.mjs +++ b/scripts/check-terminology.mjs @@ -65,6 +65,19 @@ const FORBIDDEN_PHRASES = [ "judgement", "judging", "judged", + "consumption posture", + "posture: guard", + "posture: wild", + "posture: steady", + "wild posture", + "guard posture", + "steady posture", + "wild-posture", + "guard-posture", + "wild node", + "wild nodes", + "guard node", + "guard nodes", ]; /** From 0a0040a90ca2ff37969051914c2b5d6b6fbb03e7 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Sat, 11 Jul 2026 04:08:04 -0400 Subject: [PATCH 2/2] Close the Vessel Light material contract (#222) * Close the Vessel Light material contract * Rename the evaluation harness to steering-control (#223) * Rename the evaluation harness to steering-control * Add a context selection control bench (#224) * Add a context selection control bench * Tighten context control semantics (#225) * Tighten context control semantics * Fix docs/code drift: GHOST_PACKAGE_DIR, goose install, legacy checks dir (#226) * Fix docs/code drift: GHOST_PACKAGE_DIR, goose install, legacy checks dir - Make every command (not just init/validate) respect GHOST_PACKAGE_DIR by centralizing the fallback in resolveFingerprintPackage(). Explicit --package still bypasses the stricter env-var path validation. - Add goose as a supported --agent in install/install.sh (curl installer), matching what ghost skill install --agent already supported. Fix the installer's stale file-list comment to match install/manifest.json. - Remove legacy pre-flat checks directory detection entirely from check-files.ts and node-files.ts; delete the now-obsolete tests for it. - Reconcile the 'reserved at the package root' docs (getting-started.mdx, fingerprint-authoring.mdx) to include materials/, matching the code and the other docs that already listed it. * Retire stale docs, dedupe READMEs, add troubleshooting guide (#227) - Retire docs/vessel-agent-safe-reference-system.md (Phase 0 migration plan, not durable architecture); fold its permanent invariants (North star, token contract, escape hatch policy) into packages/vessel-react/README.md. - Retire docs/agent-readable-brand-steering.md and its research-references.md appendix; most of it had already graduated into the shipped skill bundle in sharper form. Harvest the two gaps that hadn't graduated (bound/open pattern authoring, concept.* nodes) into packages/ghost/src/skill-bundle/references/capture.md. - Add docs/README.md stating the bar for what belongs in maintainer- facing docs/, so stale design docs don't reaccumulate. - Deduplicate packages/ghost/README.md against the root README: the root stays canonical (thesis, full model, repo layout), the package README becomes a lean npm-facing stub that links out. - Add apps/docs/src/content/docs/troubleshooting.mdx covering the failure modes with no doc today: empty gather, undeclared-kind warnings, review's exit 2, pull's unknown-id handling, stranded materials, stale CLI examples, and unmerged fingerprint edits. Cross-link it from Getting Started and CLI Reference. --- .changeset/cover-context-guidance.md | 5 + .changeset/docs-cleanup-pass.md | 5 + .changeset/docs-code-drift-fixes.md | 5 + .changeset/vessel-light-closure.md | 5 + CLAUDE.md | 4 +- README.md | 14 +- apps/docs/.ghost/voice.docs-language.md | 6 +- apps/docs/src/content/docs/cli-reference.mdx | 5 +- .../content/docs/fingerprint-authoring.mdx | 6 +- .../docs/src/content/docs/getting-started.mdx | 30 +- .../docs/src/content/docs/troubleshooting.mdx | 174 +++ docs/README.md | 28 + docs/agent-readable-brand-steering.md | 1023 ----------------- docs/purposes.md | 5 +- docs/research-references.md | 423 ------- docs/vessel-agent-safe-reference-system.md | 182 --- install/install.sh | 17 +- package.json | 3 +- packages/context-control/README.md | 109 ++ packages/context-control/cli.mjs | 63 + packages/context-control/demo/asks.md | 41 + packages/context-control/lib/bench.mjs | 106 ++ packages/context-control/lib/ghost.mjs | 66 ++ packages/context-control/lib/model.mjs | 212 ++++ packages/context-control/lib/score.mjs | 93 ++ packages/context-control/lib/server.mjs | 111 ++ packages/context-control/lib/tape.mjs | 54 + packages/context-control/package.json | 12 + .../test/context-control.test.ts | 181 +++ packages/context-control/ui/index.html | 301 +++++ packages/context-control/vitest.config.ts | 7 + packages/ghost/README.md | 29 +- .../src/commands/fingerprint-commands.ts | 8 +- packages/ghost/src/commands/init-command.ts | 9 +- packages/ghost/src/scan/check-files.ts | 21 - .../ghost/src/scan/fingerprint-package.ts | 12 +- packages/ghost/src/scan/node-files.ts | 4 +- packages/ghost/src/scan/templates.ts | 18 +- packages/ghost/src/skill-bundle/SKILL.md | 2 +- .../references/authoring-scenarios.md | 8 +- .../src/skill-bundle/references/brief.md | 5 +- .../src/skill-bundle/references/capture.md | 48 +- .../src/skill-bundle/references/self-check.md | 4 +- .../skill-bundle/references/steering-audit.md | 6 +- packages/ghost/test/cli.test.ts | 10 +- .../ghost/test/fingerprint-package.test.ts | 13 - .../README.md | 14 +- .../cli.mjs | 2 +- .../default-tells.json | 0 .../fixtures/metrics.sample.json | 0 .../lib/arms.mjs | 0 .../lib/asks.mjs | 0 .../lib/config.mjs | 2 +- .../lib/report.mjs | 10 +- packages/steering-control/lib/sameness.mjs | 192 ++++ .../lib/score.mjs | 16 +- .../lib/shoot.mjs | 2 +- .../lib/tape.mjs | 2 +- .../package.json | 4 +- .../test/report.test.ts | 2 +- .../steering-control/test/sameness.test.ts | 85 ++ .../test/steering-control.test.ts} | 6 +- .../vessel-light/.ghost/anti-goal.tells.md | 9 +- .../.ghost/checks/shape-matches-job.md | 43 + .../.ghost/grammar.color-roles.md | 4 +- .../vessel-light/.ghost/grammar.deletion.md | 35 + .../vessel-light/.ghost/grammar.hierarchy.md | 16 +- packages/vessel-light/.ghost/grammar.job.md | 54 + packages/vessel-light/.ghost/index.md | 44 +- .../.ghost/materials/primitives.css | 67 +- .../materials/ref/composition.editorial.html | 19 +- .../materials/ref/composition.overlay.html | 10 +- .../materials/ref/composition.table.html | 6 +- .../vessel-light/.ghost/materials/tokens.css | 105 +- .../.ghost/signature.temperature.md | 7 + .../vessel-light/.ghost/signature.type.md | 11 +- packages/vessel-light/demo/.gitignore | 2 +- packages/vessel-light/demo/README.md | 12 +- packages/vessel-react/README.md | 39 +- pnpm-lock.yaml | 4 +- scripts/check-vessel-light-closure.mjs | 157 +++ 81 files changed, 2465 insertions(+), 2009 deletions(-) create mode 100644 .changeset/cover-context-guidance.md create mode 100644 .changeset/docs-cleanup-pass.md create mode 100644 .changeset/docs-code-drift-fixes.md create mode 100644 .changeset/vessel-light-closure.md create mode 100644 apps/docs/src/content/docs/troubleshooting.mdx create mode 100644 docs/README.md delete mode 100644 docs/agent-readable-brand-steering.md delete mode 100644 docs/research-references.md delete mode 100644 docs/vessel-agent-safe-reference-system.md create mode 100644 packages/context-control/README.md create mode 100644 packages/context-control/cli.mjs create mode 100644 packages/context-control/demo/asks.md create mode 100644 packages/context-control/lib/bench.mjs create mode 100644 packages/context-control/lib/ghost.mjs create mode 100644 packages/context-control/lib/model.mjs create mode 100644 packages/context-control/lib/score.mjs create mode 100644 packages/context-control/lib/server.mjs create mode 100644 packages/context-control/lib/tape.mjs create mode 100644 packages/context-control/package.json create mode 100644 packages/context-control/test/context-control.test.ts create mode 100644 packages/context-control/ui/index.html create mode 100644 packages/context-control/vitest.config.ts rename packages/{steering-eval => steering-control}/README.md (89%) rename packages/{steering-eval => steering-control}/cli.mjs (98%) rename packages/{steering-eval => steering-control}/default-tells.json (100%) rename packages/{steering-eval => steering-control}/fixtures/metrics.sample.json (100%) rename packages/{steering-eval => steering-control}/lib/arms.mjs (100%) rename packages/{steering-eval => steering-control}/lib/asks.mjs (100%) rename packages/{steering-eval => steering-control}/lib/config.mjs (98%) rename packages/{steering-eval => steering-control}/lib/report.mjs (95%) create mode 100644 packages/steering-control/lib/sameness.mjs rename packages/{steering-eval => steering-control}/lib/score.mjs (96%) rename packages/{steering-eval => steering-control}/lib/shoot.mjs (93%) rename packages/{steering-eval => steering-control}/lib/tape.mjs (94%) rename packages/{steering-eval => steering-control}/package.json (58%) rename packages/{steering-eval => steering-control}/test/report.test.ts (97%) create mode 100644 packages/steering-control/test/sameness.test.ts rename packages/{steering-eval/test/steering-eval.test.ts => steering-control/test/steering-control.test.ts} (96%) create mode 100644 packages/vessel-light/.ghost/checks/shape-matches-job.md create mode 100644 packages/vessel-light/.ghost/grammar.deletion.md create mode 100644 packages/vessel-light/.ghost/grammar.job.md create mode 100644 scripts/check-vessel-light-closure.mjs diff --git a/.changeset/cover-context-guidance.md b/.changeset/cover-context-guidance.md new file mode 100644 index 00000000..61c0dbd3 --- /dev/null +++ b/.changeset/cover-context-guidance.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Treat the manifest cover as inlined context instead of asking agents to select an `index` node. diff --git a/.changeset/docs-cleanup-pass.md b/.changeset/docs-cleanup-pass.md new file mode 100644 index 00000000..f3b5ea38 --- /dev/null +++ b/.changeset/docs-cleanup-pass.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Document bound/open pattern authoring and `concept.*` nodes in the capture skill recipe; deduplicate the package README against the root README. diff --git a/.changeset/docs-code-drift-fixes.md b/.changeset/docs-code-drift-fixes.md new file mode 100644 index 00000000..c87aa501 --- /dev/null +++ b/.changeset/docs-code-drift-fixes.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Make `GHOST_PACKAGE_DIR` honored by every command, not just `init` and `validate`; support `goose` in the `install.sh` curl installer; and drop detection of the pre-flat legacy checks directory entirely. diff --git a/.changeset/vessel-light-closure.md b/.changeset/vessel-light-closure.md new file mode 100644 index 00000000..78db3f9f --- /dev/null +++ b/.changeset/vessel-light-closure.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Reconciles the vessel-light starter body: every token and primitive class is now demonstrated in a reference or removed, display type has one answer, and a closure check keeps the materials contract enforced. diff --git a/CLAUDE.md b/CLAUDE.md index e8e93704..c0d8b498 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ edits and checks. | `packages/ghost` | yes: `@design-intelligence/ghost` | The public package. Ships the `ghost` CLI, node authoring, corpus validation, gather/pull/pulse, review packet assembly, and the unified skill bundle. Shared runtime lives in `packages/ghost/src/ghost-core`. | | `packages/vessel-react` | no | A standalone shadcn component registry plus `vessel-mcp` MCP server: the opinionated default reference body. Design-system-agnostic; nothing in Ghost requires it. | | `packages/vessel-light` | no | Vessel's design language as a portable `.ghost/` fingerprint package for agents writing raw HTML/CSS. No build, no dependencies. | -| `packages/steering-eval` | no | Before/after evaluation harness: measures what handing an agent a `.ghost` fingerprint buys, as a deterministic `report.html`. | +| `packages/steering-control` | no | Before/after evaluation harness: measures what handing an agent a `.ghost` fingerprint buys, as a deterministic `report.html`. | | `apps/docs` | no | Docs site. | ## CLI Commands @@ -100,7 +100,7 @@ Core workflow: | Command | Description | | --- | --- | -| `ghost init` | Scaffold `.ghost/` with the skeleton starter: manifest, glossary, `index.md`, grammar nodes, and unanswered signature dials. `--template minimal` writes only the manifest/glossary/index starter; `--body vessel-light` installs a full inhabited fingerprint instead. `--with checks` also adds the checks directory. | +| `ghost init` | Scaffold `.ghost/` with the skeleton starter: manifest, glossary, a `brand.md` cover, foundation chapters, context nodes, and the cliche floor. `--template minimal` writes a smaller cover-led starter; `--body vessel-light` installs a full inhabited fingerprint instead. `--with checks` also adds the checks directory. | | `ghost checks init` | Scaffold `.ghost/checks/` with an example review assertion. | | `ghost validate` | Validate the package: manifest shape, node validity, material locators, check references, and glossary kind prefixes. | | `ghost gather [ask…]` | Emit the fingerprint menu for the agent to select from. | diff --git a/README.md b/README.md index 165d5cf3..3b502de0 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,19 @@ writes, and decides. ```text .ghost/ - manifest.yml # schema + package id (the anchor) + manifest.yml # schema + package id + optional cover id glossary.md # your kind vocabulary + what each kind means - index.md # the curated front door + brand.md # example cover inlined by gather principle.trust.md # a brand truth of kind `principle` asset.logo.md # a truth that may point at concrete materials checks/ # optional review assertions; never nodes ``` -The fingerprint is a **flat set of nodes**. A node is one markdown file: a -`description` in frontmatter, optional `materials`, and a brand truth in the -prose body. +The fingerprint is a **flat set of nodes**. The optional `cover:` in +`manifest.yml` may name any node; `ghost gather` inlines it before the menu. +The default skeleton calls that node `brand`, but the filename is not reserved. +A node is one markdown file: a `description` in frontmatter, optional +`materials`, and a brand truth in the prose body. ```markdown --- @@ -175,7 +177,7 @@ point at things that moved. | [`packages/ghost`](./packages/ghost) | The public `ghost` CLI, node authoring, fingerprint validation, gather/pull, review packet assembly, and the skill bundle. | yes: `@design-intelligence/ghost` | | [`packages/vessel-react`](./packages/vessel-react) | A standalone shadcn component registry plus `vessel-mcp` MCP server. | no | | [`packages/vessel-light`](./packages/vessel-light) | Vessel's design language as a portable `.ghost/` fingerprint for agents writing raw HTML/CSS. | no | -| [`packages/steering-eval`](./packages/steering-eval) | Before/after evaluation harness: measures what a `.ghost` fingerprint buys as a self-contained `report.html`. | no | +| [`packages/steering-control`](./packages/steering-control) | Before/after evaluation harness: measures what a `.ghost` fingerprint buys as a self-contained `report.html`. | no | | [`apps/docs`](./apps/docs) | Docs site. | no | ## Development diff --git a/apps/docs/.ghost/voice.docs-language.md b/apps/docs/.ghost/voice.docs-language.md index acf947b9..fd590929 100644 --- a/apps/docs/.ghost/voice.docs-language.md +++ b/apps/docs/.ghost/voice.docs-language.md @@ -17,9 +17,9 @@ Voice rules: - Prefer short declarative sentences around the model: "The CLI does the deterministic work; your agent does the interpretation." Do not bury the boundary in hedging. -- Use concrete artifact names: `.ghost/`, `manifest.yml`, `glossary.md`, - `index.md`, `checks/`, `ghost gather`, `ghost pull`. The docs earn trust by - naming the files and commands agents actually touch. +- Use concrete artifact names: `.ghost/`, `manifest.yml`, `glossary.md`, the + manifest-declared cover, `checks/`, `ghost gather`, `ghost pull`. The docs earn + trust by naming the files and commands agents actually touch. - Say **brand truth**, **fingerprint**, **node**, **kind**, **materials**, **feed-forward**, **feed-back**, and **advisory review packet** consistently. Do not introduce synonyms such as brand DNA, style cache, diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index d563a26f..357653bc 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -43,7 +43,8 @@ Every command follows one contract, so an agent can branch on the exit code: | `3` | A command-specific refusal, currently only `skill install` when a skill is already present (pass `--force`). | A missing package is reported, not treated as a crash: `validate` records it as -a finding (exit `1`). +a finding (exit `1`). Getting a code you don't understand, or a command that +refuses to run at all? See [Troubleshooting](/docs/troubleshooting). @@ -51,7 +52,7 @@ a finding (exit `1`). ### Initialize: `init` -Scaffold a `.ghost/` package. Every template and body includes `anti-goal.median`; the skeleton starter also includes a manifest, a `glossary.md` declaring the grammar/signature/register/anti-goal kinds, a core `index.md`, six grammar nodes of value-free decision logic, and four signature nodes as unanswered dials awaiting your brand's values. The median node is Ghost's measured knowledge of unsteered model behavior — model truth, not brand truth — stamped at init and owned (pruned, adapted) by you thereafter. Nothing in the skeleton is fiction: grammar and the median floor are safe to consume verbatim; the dials tell the agent to ask or flag, never freehand. Use `--template minimal` when you only want the small manifest/glossary/index starter. Add truths by adding files (`principle.density.md`). Use +Scaffold a `.ghost/` package. The skeleton starter includes a manifest, a glossary, a `brand.md` cover, foundation chapters with open questions, a conversation context, and the model cliche floor. The cover and foundations tell the agent to ask rather than invent brand values. Use `--template minimal` for a smaller cover-led starter. Add truths by adding files (`principle.density.md`). Use `--package ` for an exact directory, or set `GHOST_PACKAGE_DIR` when a host wrapper stores Ghost files outside the default `.ghost`. Core `init` scaffolds the fingerprint only; pass `--with checks` to also add the checks directory in diff --git a/apps/docs/src/content/docs/fingerprint-authoring.mdx b/apps/docs/src/content/docs/fingerprint-authoring.mdx index 27d639c5..6dacc278 100644 --- a/apps/docs/src/content/docs/fingerprint-authoring.mdx +++ b/apps/docs/src/content/docs/fingerprint-authoring.mdx @@ -84,9 +84,9 @@ filename's first dotted segment (`principle.trust.md` → kind `principle`), and must be a kind declared in `glossary.md`; a bare name (`voice.md`) has no kind. Folders are a human-browsing convenience only. -Reserved at the package root are `manifest.yml`, `glossary.md`, and the -`checks/` directory; every other `*.md` is a node. Moving or renaming a node -changes its id. +Reserved at the package root are `manifest.yml`, `glossary.md`, `materials/`, +and the `checks/` directory; every other `*.md` is a node. Moving or renaming +a node changes its id. Node frontmatter carries only descriptive properties: diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index c26c001b..71f8d741 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -80,12 +80,11 @@ by decision. One high-confidence truth beats an empty catalog. The CLI scaffolds; your agent authors: interviewing you, working with the material you show it, drafting node prose, and asking you to curate the claims. -`ghost init` writes a starter you adapt: a glossary, an index, truths that hold -for any brand, and a few files left as unanswered **dials** that -tell the agent to ask you rather than invent a value. Answering the dials is how -the starter becomes your brand. Prefer a bare start? `ghost init --template -minimal` writes only the manifest, glossary, and index. The full starter -anatomy is in the [CLI reference](/docs/cli). +`ghost init` writes a starter you adapt: a glossary, a `brand.md` cover, +foundation chapters with open questions, and the model cliche floor. Answering +the questions is how the starter becomes your brand. Prefer a bare start? +`ghost init --template minimal` writes a smaller cover-led starter. The full +starter anatomy is in the [CLI reference](/docs/cli). ```bash ghost init @@ -170,6 +169,10 @@ An undeclared kind prefix is a warning with a "did you mean" suggestion, not a failure. Treat fingerprint edits as ordinary Git-reviewed changes: uncommitted edits are drafts; checked-in nodes are canonical. +Hit something the loop above doesn't explain — an empty `gather`, a `review` +that refuses to run, a stale example? See +[Troubleshooting](/docs/troubleshooting). + @@ -181,9 +184,9 @@ The fingerprint is a **flat set of prose nodes**: ```text .ghost/ - manifest.yml # schema + package id + manifest.yml # schema + package id + optional cover id glossary.md # the kind vocabulary + what each kind means - index.md # the curated front door node + brand.md # example cover inlined by gather principle.trust.md # a brand truth of kind `principle` condition.density.md # a brand truth of kind `condition` asset.logo.md # a truth that may point at concrete materials @@ -191,9 +194,12 @@ The fingerprint is a **flat set of prose nodes**: checks/ # optional review assertions; never a node ``` -There is no hierarchy, no inheritance, and no edges between nodes. A node's -identity is its file path with `.md` dropped (`marketing.email.md` is the node -`marketing.email`). Its **kind** is the filename's first dotted segment, and must +There is no hierarchy, no inheritance, and no edges between nodes. The +optional `cover:` in `manifest.yml` may name any node; `ghost gather` inlines it +before the menu. The default skeleton calls that node `brand`, but the filename +is not reserved. A node's identity is its file path with `.md` dropped +(`marketing.email.md` is the node `marketing.email`). Its **kind** is the +filename's first dotted segment, and must be a kind declared in `glossary.md`; a bare name (`voice.md`) has no kind. Folders are a human-browsing convenience only; the model reads a flat menu. @@ -201,7 +207,7 @@ flat menu. Altitude lives in the prose: a universal truth is stated plainly; a narrower truth names the *situation* it applies in. The agent reads the condition and decides when the truth applies. Reserved at the package root: `manifest.yml`, -`glossary.md`, and `checks/`; every other `*.md` is a node. +`glossary.md`, `materials/`, and `checks/`; every other `*.md` is a node. A node may carry `materials`: repo-relative paths/globs or HTTPS URLs for the concrete materials the prose governs. `ghost gather` emits the menu; diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx new file mode 100644 index 00000000..ca4084fa --- /dev/null +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -0,0 +1,174 @@ +--- +title: Troubleshooting +description: What to do when gather comes back empty, validate warns about a kind, review refuses to run, or a pull misses an id. +kicker: Docs +section: guide +order: 35 +slug: troubleshooting +--- + + + +Every Ghost command follows one exit-code contract, so start by reading the +code before reading the message: + +| Code | Meaning | +| --- | --- | +| `0` | Success. | +| `1` | The command ran but the result is unhappy: `validate` found issues, or an unexpected error was thrown. | +| `2` | The command was called wrong: a bad flag or argument, a missing package for a command that needs one, a `pull` with no known ids, or `review` without a checks directory. | +| `3` | A command-specific refusal, currently only `skill install` when a skill is already present (pass `--force`). | + +The sections below are the specific failures behind those codes, and what to +do about each. + + + + + +`gather` does no filtering or ranking; it always emits the whole menu. If your +agent reads the menu and pulls nothing useful, the problem is retrieval, not +missing coverage. + +- **Check the description, not the body.** An agent selects against a node's + `description` alone. A vague description (`Our trust principles.`) is + invisible at selection time no matter how good the prose underneath is. + Rewrite it as a task-shaped phrase: what moment, surface, or question should + make an agent realize this node applies. +- **Check `ghost pulse`.** It shows whether a node appeared on the menu, got + pulled, was missed by an id the agent tried and failed, or stayed cold + every time. A cold node with real content is a description problem, not a + content problem. +- **Don't add more nodes to fix a retrieval miss.** A weak description with a + strong body is worse than a strong description with a thin body; the agent + never reaches the body. + +```bash +ghost gather "checkout settings" --format json +ghost pulse +``` + + + + + +A node's kind is the filename prefix before the first dot +(`principle.trust.md` → kind `principle`), and it must be declared in +`glossary.md`. An undeclared kind is a warning with a "did you mean" +suggestion, not a failure — `validate` still exits `0` unless something else +is wrong. + +- If the suggestion is right, it's usually a typo in the filename + (`pinciple.trust.md`) or a stale glossary that never picked up a kind you + started using. +- If you meant to introduce a new kind, add it to `glossary.md`'s `kinds` + list along with a `#` section explaining its meaning and normative weight. + Declaring a kind with one user is fine; Ghost ships no fixed vocabulary. +- If the file was never meant to carry a kind, drop the dotted prefix; a bare + name (`voice.md`) has no kind and needs no glossary entry. + +```bash +ghost validate --format json +``` + + + + + +`review` requires a checks directory. Without one, it refuses rather than +silently emitting an empty packet: + +```bash +ghost checks init +``` + +If checks already exist and `review` still exits oddly, check that you're +running it against a real diff — with no base and no staged/unstaged changes, +there's nothing to match against materials. Point it explicitly: + +```bash +ghost review --base main +git diff main | ghost review --diff=- +``` + + + + + +`pull` partially succeeds: known ids are emitted, unknown ids warn with +closest-id hints, and the miss is recorded for `ghost pulse`. A pull with +**no** known ids exits `2`. + +- Run `ghost gather` first and copy ids from the menu rather than guessing + them; a node's id is its file path with `.md` dropped, so a nested or + renamed file changes its id. +- A repeated miss on the same guessed id is retrieval signal: the id or the + glossary vocabulary isn't what agents expect. Consider renaming the node or + sharpening its description so the menu surfaces it before an id is needed. + + + + + +`materials` locators are repo-relative paths/globs or absolute HTTPS URLs. +When a path moves or a component gets deleted, the node still validates (path +resolution is checked, but drift after a refactor can slip through between +validate runs) and `ghost review` will simply stop matching that node to the +files that touch it. + +- Run `ghost export --strict` periodically; it audits every `materials` entry + and fails on any repo-relative path that resolves outside the package or + doesn't exist, which is exactly the "stranded" case a refactor produces. +- If `ghost review`'s coverage gaps list a touched file no node claims, that's + the same signal at review time: either the file needs a `materials` owner, + or it's genuinely out of scope. + +```bash +ghost export --strict +``` + + + + + +The CLI reference and its `--help` output are generated from the CLI source, +not hand-maintained. If you changed a command or flag and the docs didn't +follow: + +```bash +pnpm dump:cli-help +``` + +Commit the regenerated `apps/docs/src/generated/cli-manifest.json`. CI's +drift check declines a PR that changes a command surface without this step. + + + + + +Uncommitted or unmerged fingerprint edits are drafts; Ghost's tooling reads +whatever is on disk in your working tree, so a draft node does show up in +`gather` immediately. What it means for edits to "take effect" is usually one +of: + +- **The agent already had a stale menu in context.** Re-run `ghost gather` + after any edit; nothing caches the menu between calls. +- **The team disagrees on what's canonical.** Ordinary Git review is the + approval boundary for fingerprint edits, same as any other code change. + Treat an unmerged branch's nodes as provisional until they land. + + + + + +If none of the above matches what you're seeing, the fastest path is usually +to ask your agent directly, since it already has the skill bundle installed: + +```text +Run ghost validate and ghost pulse, and tell me what's wrong with this fingerprint. +``` + +If that doesn't resolve it, open an issue with the exact command, its exit +code, and `--format json` output attached. + + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..f1066fce --- /dev/null +++ b/docs/README.md @@ -0,0 +1,28 @@ +# docs/ + +Durable, current, maintainer-audience architecture only — the internal model +docs that defend a boundary or explain a rule a contributor needs before +touching the fingerprint schema or CLI. Not onboarding (that's +[apps/docs](../apps/docs)'s Five-Minute Ghost and Getting Started), not +design exploration, not migration plans, not evidence appendices. + +Before adding a doc here, ask: + +- **Is it durable?** A doc describing a migration in progress, a phased + plan, or a driving ethos that hasn't landed in the CLI/schema/skill bundle + yet belongs in your own working notes, not here. Land the idea in shipped + code or the skill bundle first; document the boundary it establishes + after. +- **Is it current?** A doc that duplicates or contradicts what + `packages/ghost/src/skill-bundle` or the CLI already say has failed its + purpose. When shipped material supersedes a doc here, retire the doc and + fold anything still-true into the shipped surface. +- **Does it earn standalone status?** Prefer folding a short rule into an + existing doc, a package README, or the skill bundle over adding a new + file. A doc belongs here only when it defends a boundary or model no + single existing surface owns. + +Every doc here should open with an audience/scope callout (see +[`purposes.md`](./purposes.md) for the pattern) so a reader without the full +vocabulary knows immediately whether to keep reading or go to the onboarding +docs instead. diff --git a/docs/agent-readable-brand-steering.md b/docs/agent-readable-brand-steering.md deleted file mode 100644 index 4730ec4a..00000000 --- a/docs/agent-readable-brand-steering.md +++ /dev/null @@ -1,1023 +0,0 @@ -# Model Steering for Agent-Readable Brand Systems - -> **Prose, exemplar materials, and invariants are the right starting triad, but they are not the whole steering system.** -> The fuller model is: **retrieval → conditioning → commitment → constraint → review → renewal.** - -> Research grounding for the claims in this doc — with honest evidence tiers — -> lives in [research-references.md](./research-references.md). - -Brand work is moving from static outputs to **agent-readable input systems**. Ghost is the operational version: making those inputs **selectable, grounded, ratified, and reviewable**. - -The core theory: - -> A model does not “know your brand” as a stable object. It generates the next token from learned priors plus current context. Authoring works by changing what context gets selected, what patterns are easiest to continue, what paths are disallowed, and what gets checked after generation. - -So the question is not only “what should we tell the model?” - -It is: - -1. **Will the right truth be retrieved?** -2. **Will the model understand what is normative?** -3. **Will it have concrete material to continue from?** -4. **Will it avoid generic gravity wells?** -5. **Will it commit to the right structure early?** -6. **Will hard lines be preserved?** -7. **Will drift be caught after generation?** -8. **Where does human originality enter instead of median recombination?** - ---- - -## 1. Retrieval handles - -### What they are - -The parts that help the agent decide what to read before generation: - -- node id -- filename/kind -- `description` -- glossary category -- `index.md` -- task-shaped language - -In Ghost, this is the `ghost gather` / `ghost pull` layer. - -### How they influence generation - -They do not directly affect the final output unless they get pulled into context. - -This is the first steering gate: - -> If the relevant truth is not retrieved, it has zero effect. - -A lot of “the model ignored the brand” is actually “the model never saw the right node.” - -### How to think of them - -Think of descriptions like tool descriptions or search snippets. - -They should answer: - -> “When should an agent realize this applies?” - -Not: - -> “What is this poetically about?” - -### Weak example - -```yaml -description: Our trust principles. -``` - -### Strong example - -```yaml -description: Checkout trust — gather for payment, pricing, fees, irreversible actions, disclosure, risk, or user hesitation before commitment. -``` - -### How to author - -Use the words future asks will contain: - -- checkout -- pricing -- empty state -- failure -- onboarding -- dashboard -- destructive action -- motion -- logo -- campaign -- launch -- recovery -- disclosure - -Good format: - -```yaml -description: — gather when . -``` - ---- - -## 2. Stance prose / principles - -### What they are - -The broad interpretive truths: - -- what the brand is for -- who it serves -- what it values -- what tradeoff wins -- how it behaves under pressure -- what it refuses to become - -This is the classic prose layer. - -### How they influence generation - -Prose changes the model’s **semantic prior**. - -It nudges the model toward a region of its learned distribution. But prose is coarse. Words like “premium,” “clean,” “bold,” “playful,” or “modern” activate generic learned associations. - -So prose is weakest when it uses adjectives. It is strongest when it encodes **decision logic**. - -### How to think of it - -Prose answers: - -> “When the model has to invent, what should it optimize for?” - -It gives decision logic, not exact form. - -### Weak example - -```markdown -We are modern, trustworthy, simple, and human. -``` - -This excludes almost nothing. - -### Strong example - -```markdown -At moments of financial commitment, reduce felt risk before increasing momentum. The user should understand what will happen, what it costs, and how to recover before the primary action asks for trust. Clarity beats speed when the action is irreversible. -``` - -That gives the model a tradeoff. - -### How to author - -Write as forced choices: - -```markdown -When X conflicts with Y, X wins. -``` - -Examples: - -```markdown -When density and spaciousness conflict on operational surfaces, density wins — but hierarchy must come from alignment and weight before color. - -When trust and conversion pressure conflict, trust wins. Never hide risk to make the primary action feel easier. - -When delight and comprehension conflict, comprehension wins. Motion may clarify state change; it must not become decoration. -``` - -Avoid brand-book filler. A principle should make the model reject a plausible wrong answer. - ---- - -## 3. Concrete inventory / agent-readable materials - -### What they are - -The concrete things the model should use instead of guessing: - -- CSS variables -- tokens -- fonts -- SVGs -- components -- HTML examples -- motion JSON -- copy snippets -- implementation paths -- Figma links -- screenshots -- asset folders - -HTML/CSS/SVG/JSON are not just documentation. They are **buildable context**. - -### How they influence generation - -Inventory solves knowledge gaps. - -If the model does not know your component names, token values, logo files, motion curves, or type ramp, it will invent plausible ones. - -No amount of “stay on brand” fixes missing facts. - -### How to think of it - -Inventory answers: - -> “What should the model not have to invent?” - -This is the factual layer. - -### Example - -```markdown ---- -description: Product color, type, and motion materials — gather before building UI, motion demos, or branded surfaces. -materials: - - src/styles/tokens.css - - src/components/brand/** - - src/motion/type-curves.json - - public/brand/logo*.svg ---- - -Use the product tokens rather than ad hoc values. Primary actions use `--color-action-primary`; never introduce a new blue because a generic button pattern expects one. Motion uses the curves in `src/motion/type-curves.json`; do not approximate easing with default `ease-in-out`. -``` - -### How to author - -Use inventory when the model keeps: - -- inventing colors -- inventing components -- using generic Tailwind defaults -- approximating motion -- missing asset rules -- creating new styles instead of using existing ones - -But do not turn the fingerprint into duplicated API docs. - -Bad: - -```markdown -The Button component takes size, variant, iconLeft, iconRight... -``` - -Better: - -```markdown -Primary actions use the existing Button primitive. The brand move is not the API; it is restraint: one primary action per surface, no competing accent treatment, and no new button color outside the token ramp. -``` - -`materials` locate. Prose explains meaning. - ---- - -## 4. Exemplars - -### What they are - -Concrete examples of good output: - -- a real screen -- a launch page -- a component implementation -- an HTML artifact -- an error message -- a motion demo -- a product flow -- a campaign module - -These can live directly in prose or be pointed to through `materials`. - -### How they influence generation - -Exemplars are often the strongest generation-time signal. - -Mechanically, they work like in-context demonstrations. The model sees structure, rhythm, naming, spacing, hierarchy, tone, and code shape, then continues in that pattern. - -This is why HTML/CSS examples are so powerful: they expose structure, not just appearance. - -A screenshot says: - -> “Look at this.” - -HTML/CSS says: - -> “Continue from this.” - -### How to think of them - -Exemplars answer: - -> “What does good look like?” - -But they need annotation. Otherwise the model copies accidents as if they were brand. - -### Example - -```markdown ---- -description: On-brand failure copy — gather for errors, failed saves, retries, and recovery states. ---- - -Normative for rhythm and stance at failure moments. Match the structure, not the exact words. - -> We couldn't save your changes. Your work is still here — try again, and if it keeps failing, we'll hold onto everything while you sort it out. - -What makes it ours: -- Leads with what happened, not apology. -- States what is safe before what to do. -- Gives one calm next step. -- No "Oops." -- No exclamation points. -- No blame on the user or the network. -``` - -### How to author - -For every exemplar, say: - -1. what it is normative for; -2. what is incidental; -3. what features make it on-brand; -4. when to reuse the pattern; -5. when not to. - -Good phrase: - -```markdown -This sample is normative for , not for . -``` - ---- - -## 5. Counter-exemplars / anti-goals - -### What they are - -Specific rejected patterns: - -- generic SaaS cards -- bootstrap-blue buttons -- gradient hero text -- emoji headings -- “Oops!” error copy -- fake dashboard previews -- over-rounded cards -- decorative motion -- three equal CTAs - -### How they influence generation - -Anti-goals fight model gravity wells. - -Models regress toward common training patterns. If you ask for “modern landing page,” you will get something like: - -- gradient headline -- rounded cards -- soft shadows -- three-column feature grid -- blue/purple accent -- generic dashboard mockup - -Positive prose often does not escape this. Specific negation does. - -### How to think of it - -Anti-goals answer: - -> “Which likely continuation should the model refuse?” - -They prune the distribution. - -### Example - -```markdown ---- -description: Generic generated UI we reject — gather before building product UI or marketing surfaces. ---- - -We are not the generic generated interface: no indigo-600 primary buttons, no rounded-xl white cards floating on gray-50, no emoji in headings, no gradient hero text, no fake dashboard preview unless the product moment is actually dashboard-shaped. - -When a layout feels like a template any product could ship, it is off-brand even if every token is technically valid. - -Default away from that gravity well: flat surfaces, hard alignment, one accent used sparingly, and hierarchy from spacing before decoration. -``` - -### How to author - -Do not say: - -```markdown -Avoid generic design. -``` - -Say: - -```markdown -No rounded-xl card grids on gray backgrounds. No gradient hero type. No decorative blob backgrounds. No fake analytics dashboard unless analytics is the actual product object. -``` - -Name the enemy. - ---- - -## 6. Invariants - -### What they are - -Hard always/never rules: - -- one primary CTA -- fees before commitment -- no new colors outside tokens -- logo never redrawn -- error states always include recovery -- motion respects reduced-motion -- type hierarchy never inverts -- children never outrank parent context - -### How they influence generation - -Invariants constrain the search space. - -They are strongest when they are: - -- short; -- concrete; -- checkable; -- numeric where possible; -- written as always/never; -- placed early in the brief; -- mirrored by review checks. - -### How to think of them - -Invariants answer: - -> “What must survive every interpretation?” - -They are the floor. - -### Example - -```markdown ---- -description: Checkout trust invariants — hard rules for payment, pricing, and irreversible commitment. ---- - -These hold for every checkout or payment-adjacent surface: - -- Fees are disclosed before the committing action. Never reveal a new fee after the primary CTA. -- At most one primary action appears in a checkout step. -- Reassurance sits near the action it supports; never isolate trust copy in a generic footer. -- Recovery is visible wherever failure is possible: retry, edit, cancel, or contact support. -- The primary price and the committing action appear in the same viewport before commitment. -``` - -### How to author - -Use invariant form when the failure is a repeated review comment. - -Good syntax: - -```markdown -- Always when . -- Never ; instead . -- At most/exactly/no more than . -``` - -If it cannot be reviewed, it may not be an invariant yet. - ---- - -## 7. Composition patterns - -### What they are - -Reusable structures that bind part of the output and leave part open. - -This is the “pattern” layer: - -- empty state -- status with next step -- pricing comparison -- checkout disclosure -- confirmation -- onboarding step -- launch hero -- feature proof -- destructive action modal - -### How they influence generation - -Patterns steer early commitment. - -Autoregressive generation commits quickly. Once the model starts a three-card grid, modal, dashboard, or hero section, the rest of the output gets pulled into that structure. - -A composition pattern gives the model a better starting scaffold. - -### How to think of it - -Patterns answer: - -> “What shape should this kind of thing take before details are chosen?” - -They sit between principle and exemplar. - -### Example - -```markdown ---- -description: Status plus next step — gather when the user asks where something stands and what to do now. ---- - -**Applies when:** the surface explains the state of something in flight: an order, transfer, review, delivery, setup, or request. - -**Bound:** - -- Current status renders first and largest. -- One sentence explains why the status matters. -- Exactly one primary next step appears last. -- Supporting evidence may not compete with the status. - -**Open:** - -- Evidence may be a timeline, timestamp, or compact stat list. -- Tone may flex warmer for consumer surfaces and more direct for operational ones. -- Secondary actions may appear only after the primary next step. - -**Refines:** `principle.composition`. If this conflicts with the composition floor, the floor wins. -``` - -### How to author - -Use the bound/open distinction. - -- **Bound** = do not redecide. -- **Open** = the agent may choose, within limits. - -If everything is bound, you wrote a template. -If nothing is bound, you wrote vibes. - ---- - -## 8. Conditions and altitude - -### What they are - -Scope rules for when a truth applies. - -A universal truth is stated plainly. A narrower truth names the situation that activates it. - -Ghost is right that this belongs in prose, not hierarchy. - -### How they influence generation - -Conditions prevent truth smearing. - -Without conditions, the model applies good guidance everywhere: - -- checkout trust leaks into marketing; -- operational density leaks into onboarding; -- failure voice leaks into success copy; -- launch-page drama leaks into settings UI. - -### How to think of it - -Conditions answer: - -> “When does this truth fire?” - -### Weak example - -```markdown -For dashboards, use compact density. -``` - -### Strong example - -```markdown -When the user's first question is comparative — “which record needs attention?” — density may increase by one step. Even then, hierarchy comes from alignment and weight before color. -``` - -The second can apply outside literal dashboards and avoid applying inside dashboards when the situation does not match. - -### How to author - -Use condition language: - -```markdown -When the user is about to commit money... -When recognition matters more than space... -When the surface must compare many records... -When the action is irreversible... -When the brand should recede behind the task... -``` - -Avoid pure destination buckets: - -```markdown -for dashboards -for mobile -for marketing -for emails -``` - -Those are hints, not the real condition. - ---- - -## 9. Decision traces / procedures - -This bucket is underweighted in many brand systems. - -### What they are - -Worked reasoning examples showing how a brand decision was made. - -Not just: - -> “Use dense layouts.” - -But: - -> “We considered a drawer, inline chips, and a command palette. We chose command palette because density beats discoverability in this operational moment.” - -### How they influence generation - -They teach the model the **tradeoff function**. - -Models imitate reasoning traces as well as outputs. A decision trace shows how the brand resolves conflict. - -This matters because two brands can share: - -- the same tokens; -- similar components; -- similar layouts; -- similar voice adjectives; - -but differ completely in how they decide under pressure. - -### How to think of it - -Decision traces answer: - -> “How do we choose when two good options conflict?” - -This is brand as taste under pressure. - -### Example - -```markdown ---- -description: How we choose dense operational controls — gather when filtering, searching, or narrowing large record sets. ---- - -Decision trace: - -We needed filtering for a high-volume review queue. - -Options considered: -- Drawer: clearer, but too heavy for repeated use. -- Inline chips: visible, but noisy after five filters. -- Command palette: less discoverable, but fastest for expert operators. - -We chose command palette because this surface is used repeatedly by trained operators. In this condition, speed and density beat first-use discoverability. On consumer onboarding, this decision would reverse. -``` - -### How to author - -Ask during fingerprint interviews: - -- What decision did you reverse? -- What almost shipped but felt wrong? -- What did you choose between? -- Which value won? -- When would that decision reverse? -- What did a senior designer catch that others missed? - -This captures the “taste move,” not just the artifact. - ---- - -## 10. Checks and review assertions - -### What they are - -Post-generation tests or review prompts. - -In Ghost, these live under the checks directory: - -```text -.ghost/checks/*.md -``` - -They are feed-back, not feed-forward. They do not appear in `ghost gather` or `ghost pull`. - -### How they influence generation - -Strictly speaking, checks do not steer the first generation unless the harness uses them in a loop. - -They steer the system by creating: - -```text -generate → review → repair → review again -``` - -That is often more reliable than trying to make the first sample perfect. - -### How to think of it - -Checks answer: - -> “How do we know the truth survived implementation?” - -They turn invariants into reviewable assertions. - -### Example - -```markdown ---- -name: checkout-fees-before-commitment -description: Checkout discloses fees before the committing action. -severity: high -references: - - principle.checkout-trust ---- - -Grade whether the change preserves the rule that fees are disclosed before the committing action. Flag any flow where a new fee appears after the primary CTA, where fee text is visually detached from the action, or where the user must expand optional UI to discover required cost. -``` - -### How to author - -Every hard “never” or “always” should be considered for a check. - -Good checks say: - -- what to inspect; -- what to flag; -- what counts as preserved; -- which node grounds the assertion. - -Do not invent new obligations in checks. They should reference the brand truth they enforce. - ---- - -## 11. Context assembly / brief shape - -### What it is - -The way selected truths are assembled before generation. - -This is not cosmetic. Order matters. - -The first few commitments in a generated output strongly shape everything after. If the model starts from a generic scaffold, later brand instructions have to fight that scaffold. - -### How it influences generation - -A good brief controls what the model commits to first. - -Useful order: - -1. grounded nodes; -2. non-negotiables; -3. applicable conditions; -4. intent; -5. inventory/materials; -6. composition pattern; -7. exemplars; -8. anti-goals; -9. silent/provisional areas. - -### How to think of it - -Context assembly answers: - -> “What should the model anchor on before it starts producing?” - -### Example - -```markdown -## Grounded in - -Pulled: `index`, `principle.checkout-trust`, `pattern.status-with-next-step`, `anti-goal.generic-ui`. - -## Non-negotiables - -- Fees before commitment. [`principle.checkout-trust`] -- At most one primary action per step. [`principle.checkout-trust`] -- No generic rounded-card SaaS layout. [`anti-goal.generic-ui`] - -## Conditions that apply - -This is a payment-adjacent commitment moment, so checkout trust rules apply. - -## Intent - -Reduce felt risk before increasing momentum. Clarity beats speed. - -## Inventory - -Use existing checkout components and token ramp. Do not introduce new button styles. - -## Composition - -Current state first, one line of meaning, one next step last. - -## Silent / provisional - -The fingerprint does not specify loading skeletons. Follow local code convention provisionally. -``` - -### How to author - -The fingerprint should make this easy: - -- keep nodes purpose-coherent; -- make descriptions selectable; -- put true global non-negotiables in `index`; -- distinguish hard rules from soft guidance; -- label silence. - ---- - -## 12. Deliberate novelty - -### What it is - -The non-median creative move: - -- the weird launch concept; -- the memorable above-the-fold idea; -- the campaign hook; -- the specific idea the system would not have produced from recombination alone. - -### How they influence generation - -They disrupt median convergence. - -A well-authored brand system makes outputs coherent. But coherence is not originality. Models recombine what they are given and drift toward the center of allowed possibilities. - -Deliberate novelty is human injection of a surprising idea. - -### How to think of it - -Deliberate novelty answers: - -> “What makes this specific output memorable, not merely correct?” - -It is usually task-specific, not permanent brand law. - -### Example - -```markdown ---- -description: Launch-specific creative hook — use only for the Type launch page. ---- - -For this launch, the hero should feel like entering a shared writing room after midnight: cursors moving, fragments collecting, multiple minds arriving at one sentence. This is a one-time campaign idea, not a reusable product UI pattern. - -Preserve: -- multiplayer atmosphere; -- language as material; -- visible collaboration; -- slightly nocturnal energy. - -Do not generalize this into the core product UI. -``` - -### How to author - -Keep deliberate novelty distinct from invariants. - -A one-time creative move can become an exemplar later, but do not accidentally turn a campaign-specific idea into permanent brand law. - -Ask: - -- Is this reusable truth? -- Or is this a specific creative leap for one moment? - -Both are valuable. They should not be stored with the same normative weight. - ---- - -## The updated steering stack - -| Bucket | What it is | Model mechanism | Best for | -|---|---|---|---| -| Retrieval handles | Descriptions, ids, glossary, index | Gets right context selected | Making truths findable | -| Stance prose | Principles and tradeoffs | Shifts semantic prior | Decision-making in novel cases | -| Inventory/materials | Tokens, components, HTML, CSS, SVG, JSON | Fixes knowledge gaps | Preventing invention | -| Exemplars | Good outputs in context | Pattern continuation / few-shot shaping | Form, rhythm, code shape | -| Anti-goals | Rejected defaults | Prunes generic priors | Escaping AI sameness | -| Invariants | Always/never rules | Constrains generation | Hard brand/product lines | -| Patterns | Bound/open structures | Controls early commitment | Reusable UI/content shapes | -| Conditions | Applicability rules | Gates truths | Preventing over-application | -| Decision traces | Worked tradeoff reasoning | Teaches choice function | Taste under conflict | -| Checks | Review assertions | Feedback loop | Catching drift | -| Brief assembly | Ordered context packet | Controls first commitments | Reliable generation setup | -| Deliberate novelty | Task-specific creative leap | Breaks median recombination | Memorable creative output | - ---- - -## How to update the original triad - -The original triad: - -> prose, exemplar materials, invariants - -Expanded: - -> **Prose for decision logic, materials for facts, exemplars for form, anti-goals for escaping priors, patterns for structure, invariants for hard boundaries, decision traces for taste, checks for drift, and deliberate novelty for non-median originality.** - -Or shorter: - -> **Say the stance. Show the material. Name the enemy. Bind the structure. State the hard lines. Show how decisions are made. Check the result. Leave room for the non-obvious idea.** - ---- - -## A practical authoring template - -A strong Ghost-style node could look like this: - -```markdown ---- -description: -materials: - - ---- - - - -**Applies when:** - -**Preserve:** -- -- - -**Never:** -- -- - -**Bound:** -- - -**Open:** -- - -**Use / inspect:** -- - -**Example:** -> - -What makes this example intentional: -- -- -- - -**Decision trace:** -We chose over because . This would reverse when . -``` - -Not every node needs every section. But this template forces the author to ask the right questions. - ---- - -## The diagnostic - -When the model fails, do not immediately add more prose. Classify the failure. - -### It invented a component, token, color, or asset - -Add **inventory/materials/exact values**. - -### It looked generic - -Add **exemplars and anti-goals**. - -### It chose the wrong structure - -Add a **composition pattern**. - -### It crossed a hard line - -Add an **invariant** and probably a **check**. - -### It applied good guidance in the wrong place - -Add **conditions / altitude**. - -### It made a technically correct but forgettable output - -Add a **specific creative concept** or stronger non-obvious idea. - -### It did not use an existing truth - -Fix the **description / retrieval handle**. - -### It made a bad tradeoff - -Add a **decision trace**. - ---- - -## Final mental model - -Brand steering for agents is not “documentation the model reads.” - -It is closer to a layered control system: - -1. **Retrieval** decides what enters context. -2. **Prose** sets decision logic. -3. **Materials** provide facts. -4. **Exemplars** teach form. -5. **Anti-goals** suppress generic priors. -6. **Patterns** control structural commitment. -7. **Invariants** define hard boundaries. -8. **Decision traces** teach taste. -9. **Checks** catch drift. -10. **Deliberate novelty** injects originality beyond recombination. - -That is the theory behind why prose, exemplar materials, and invariants matter — and why they are necessary but not sufficient. diff --git a/docs/purposes.md b/docs/purposes.md index 468f03d6..89f96510 100644 --- a/docs/purposes.md +++ b/docs/purposes.md @@ -49,8 +49,9 @@ One resolution mechanism, read-only: material count. The agent reads the ask against descriptions and pulls the truths it judges relevant. Ghost does no NLP and no selection. -The entrypoint node is `index.md` (id `index`): the human-curated front door. It -is an ordinary node, listed in the menu like any other. +The optional `cover` in `manifest.yml` names the human-curated front door. +`ghost gather` inlines that node before the menu and excludes it from the +selection list. Checks are **not** nodes and are **never gathered**. They live in `checks/`, bind to the prose they enforce via `references`, and are consumed only by diff --git a/docs/research-references.md b/docs/research-references.md deleted file mode 100644 index c6e40b45..00000000 --- a/docs/research-references.md +++ /dev/null @@ -1,423 +0,0 @@ -# Research References for Ghost's Steering Claims - -> **Audience: Ghost maintainers and contributors.** This doc maps the claims in -> [Model Steering for Agent-Readable Brand Systems](./agent-readable-brand-steering.md) -> to published research. It exists so we know which of our stances rest on -> validated results, which are extrapolations from adjacent domains, and which -> are our own untested priors. It is deliberately honest: a citation appears -> here only if the finding actually supports the claim as we make it. - -## How to read this doc - -Every claim carries an evidence tier: - -| Tier | Meaning | -| --- | --- | -| **Established** | Direct, replicated, peer-visible result. The claim as written is supported. | -| **Adjacent** | The finding is real but from a neighboring task or older model class. We are extrapolating; the extrapolation is stated. | -| **Prior** | Mechanistically sound reasoning or practitioner consensus, but no benchmarked result for the claim as we make it. Testable; not yet tested. | - -Rules for maintaining this doc: - -- **Cite for the finding, not the aura.** A paper goes next to a claim only if - its actual result supports that claim. Never cite a famous paper because it - is nearby-sounding. -- **Carry the caveats.** If a result is on 2021-era models, on classification - rather than generation, or contested, say so inline. -- **Downgrades are contributions.** If a newer result weakens a citation here, - moving a claim from Established to Adjacent (or deleting the citation) is as - valuable as adding one. -- **Priors want experiments.** Each Prior-tier claim should state the cheap - experiment that would settle it. - ---- - -## 1. The retrieval gate - -**Ghost claim:** a truth that is not retrieved has zero effect; selection into -context is the first steering gate (`ghost gather` / `ghost pull`, task-shaped -descriptions). - -**Tier: Established.** - -- Lewis et al., *Retrieval-Augmented Generation for Knowledge-Intensive NLP - Tasks*, [arXiv:2005.11401](https://arxiv.org/abs/2005.11401). Conditioning - generation on retrieved context outperforms relying on parametric knowledge - for knowledge-intensive tasks. The foundational result behind - retrieve-then-generate as an architecture. -- Park et al., *Generative Agents: Interactive Simulacra of Human Behavior*, - [arXiv:2304.03442](https://arxiv.org/abs/2304.03442). The closest published - precedent for Ghost's shape: a flat memory stream of natural-language records - with just-in-time relevance-scored retrieval into context. Their ablations - show retrieval quality dominates output believability. -- Liu et al., *What Makes Good In-Context Examples for GPT-3?*, - [arXiv:2101.06804](https://arxiv.org/abs/2101.06804). Semantically - task-relevant in-context material outperforms randomly selected material — - the justification for selecting nodes against the actual ask rather than - shipping a static prompt. - -**Honesty note:** these papers validate retrieve-then-generate and -relevance-based selection. None of them test *agent-side* selection against -human-authored descriptions specifically (Ghost's BYOA stance). That design -choice is architectural, argued in [purposes.md](./purposes.md), not -literature-backed. - ---- - -## 2. Relevance beats volume; small briefs - -**Ghost claims:** irrelevant or excess context degrades output ("3–5 nodes is -normal; 10 is a bad selection"); more context is not more control. - -**Tier: Established** for degradation from irrelevant and from lengthy input. -**Prior** for the specific 3–5 node budget. - -- Shi et al., *Large Language Models Can Be Easily Distracted by Irrelevant - Context*, [arXiv:2302.00093](https://arxiv.org/abs/2302.00093). Adding - irrelevant context measurably reduces accuracy — extra nodes are not merely - wasted, they actively hurt. -- Levy, Jacoby & Goldberg, *Same Task, More Tokens: the Impact of Input Length - on the Reasoning Performance of Large Language Models*, - [arXiv:2402.14848](https://arxiv.org/abs/2402.14848). Reasoning degrades as - input grows **even when the added content is relevant**, well before the - context limit. -- Hsieh et al., *RULER: What's the Real Context Size of Your Long-Context - Language Models?*, [arXiv:2404.06654](https://arxiv.org/abs/2404.06654). - Effective context is much shorter than advertised context; performance on - anything richer than needle retrieval decays far before the nominal window. - -**Honesty note:** "3–5 nodes" is our operating number, not a measured optimum. -The literature says *smaller and more relevant is better*; it does not say -where the knee is for brand-steering packets. Cheap experiment: same task, same -fingerprint, pull sets of 3 / 6 / 10 nodes, blind-grade outputs for adherence. - ---- - -## 3. Position and ordering in context - -**Ghost claims:** beginning and end of context are privileged; `ghost pull` -emission order (stance first, guards late, skeletons dead last) and the brief's -section order are load-bearing, not cosmetic. - -**Tier: Established** for position effects and order sensitivity. -**Prior** for Ghost's *specific* ordering being optimal. - -- Liu et al., *Lost in the Middle: How Language Models Use Long Contexts*, - [arXiv:2307.03172](https://arxiv.org/abs/2307.03172). The canonical U-shaped - result: information at the beginning and end of context is used far better - than information in the middle. Directly supports placing non-negotiables - early and the skeleton — the thing to continue from — last. -- Xiao et al., *Efficient Streaming Language Models with Attention Sinks*, - [arXiv:2309.17453](https://arxiv.org/abs/2309.17453). Mechanistic evidence - that initial tokens receive disproportionate attention regardless of semantic - content — the attention-level grounding for "beginning of context is - privileged." -- Lu et al., *Fantastically Ordered Prompts and Where to Find Them*, - [arXiv:2104.08786](https://arxiv.org/abs/2104.08786). Identical few-shot - examples in different orders swing performance from near state-of-the-art to - near random. Ordering is a real lever. (Measured on GPT-2/GPT-3-era models in - classification settings; the magnitude on current instruct models is likely - smaller, the direction holds.) -- Sclar et al., *Quantifying Language Models' Sensitivity to Spurious Features - in Prompt Design*, [arXiv:2310.11324](https://arxiv.org/abs/2310.11324). - Formatting choices alone cause double-digit accuracy swings. Supports the CLI - emitting a deterministic, consistent packet shape rather than leaving - assembly ad hoc per session. - -**Honesty note:** the literature establishes that position and order matter a -lot. It does not establish that *Ghost's* order (stance → materials → rules → -guards → skeletons) is the best one. That order is derived from the position -results plus the continuation argument in §8, and it is testable: permute -packet order on a fixed task set and grade adherence. - ---- - -## 4. Materials for facts: models invent what they were never taught - -**Ghost claims:** models have thin, unreliable knowledge of niche APIs, -internal design systems, and private tokens; no amount of "stay on brand" fixes -missing facts; `materials` exists to close knowledge gaps, not to exhort. - -**Tier: Established.** - -- Kandpal et al., *Large Language Models Struggle to Learn Long-Tail - Knowledge*, [arXiv:2211.08411](https://arxiv.org/abs/2211.08411). Ability to - answer factual questions correlates strongly with how often the fact appears - in pretraining data. An internal token name that appeared zero times will be - replaced with a plausible invention. The single best citation for the - inventory/materials layer. -- Meng et al., *Locating and Editing Factual Associations in GPT* (ROME), - [arXiv:2202.05262](https://arxiv.org/abs/2202.05262). Factual knowledge is - stored as localized associations in weights — grounding for "the model does - not know your brand as a stable object" and for why context injection, not - instruction, is the fix for a knowledge gap. -- Xu et al., *Hallucination is Inevitable: An Innate Limitation of Large - Language Models*, [arXiv:2401.11817](https://arxiv.org/abs/2401.11817). A - formal, computability-theoretic argument that hallucination cannot be fully - trained away. Supports the structural stance that grounding plus post-hoc - review is necessary — you cannot prompt your way to a model that never - invents. - ---- - -## 5. Exemplars: pattern continuation over instruction - -**Ghost claims:** exemplars are a strong generation-time signal; the model -absorbs structure, rhythm, and code shape from examples and continues in that -pattern; exemplars need annotation or the model copies accidents as brand. - -**Tier: Established** for demonstrations conditioning output strongly and for -structure/format being what gets absorbed. **Adjacent** for "often the -strongest generation-time signal" in open-ended design work. - -- Brown et al., *Language Models are Few-Shot Learners*, - [arXiv:2005.14165](https://arxiv.org/abs/2005.14165). The origin of - in-context demonstrations as a steering mechanism. -- Min et al., *Rethinking the Role of Demonstrations: What Makes In-Context - Learning Work?*, [arXiv:2202.12837](https://arxiv.org/abs/2202.12837). In - classification settings, what the model uses from demonstrations is largely - the **format, input distribution, and label space** — the structure — more - than the literal input-label mapping. This is why "normative for rhythm, not - for the exact words" works: structure transfers regardless. It is also why - unannotated exemplars are dangerous — the model absorbs *everything* - structural, including the accidents. -- Xie et al., *An Explanation of In-context Learning as Implicit Bayesian - Inference*, [arXiv:2111.02080](https://arxiv.org/abs/2111.02080), and Dai et - al., *Why Can GPT Learn In-Context?*, - [arXiv:2212.10559](https://arxiv.org/abs/2212.10559). Two mechanism accounts - of why demonstrations condition generation so strongly: they locate a latent - concept learned in pretraining / behave like implicit fine-tuning. These are - theoretical accounts, not settled fact, but both predict the same practical - behavior we rely on. - -**Honesty note:** the ranking claim — exemplars beating prose instructions for -*open-ended design generation* specifically — is an extrapolation from -classification and structured-task results. We hold it as a strong prior and -state it as a design stance ("highest-leverage"), not a measured ordering. -Cheap experiment: same brand truth expressed as (a) prose principle only, -(b) prose + annotated exemplar, on a fixed generation task; blind-grade. - ---- - -## 6. Anti-goals, gravity wells, and AI sameness - -**Ghost claims:** models regress toward the mean of training data (generic -cards, bootstrap-blue buttons); positive adjectives do not escape the well; -specific negation prunes it; coherence is not originality, so deliberate -novelty must be injected by humans. - -**Tier: Established** for mode-seeking genericness and diversity loss. -**Adjacent** for the negation caveat. **Prior** for "specific negation prunes -better than positive adjectives" as stated. - -- Holtzman et al., *The Curious Case of Neural Text Degeneration*, - [arXiv:1904.09751](https://arxiv.org/abs/1904.09751). - Likelihood-maximization produces generic, repetitive text — the formal - version of the gravity well. -- Kirk et al., *Understanding the Effects of RLHF on LLM Generalisation and - Diversity*, [arXiv:2310.06452](https://arxiv.org/abs/2310.06452). RLHF - improves generalization but measurably **reduces output diversity**. The - strongest citation for "AI sameness" being a property of aligned models, and - the direct motivation for the deliberate-novelty bucket: the system will not - produce non-median moves on its own. -- Padmakumar & He, *Does Writing with Language Models Reduce Content - Diversity?*, [arXiv:2309.05196](https://arxiv.org/abs/2309.05196). Model - assistance homogenizes what *humans* produce — the downstream brand-erosion - argument for why steering artifacts matter at all. -- Kassner & Schütze, *Negated and Misprimed Probes for Pretrained Language - Models*, [arXiv:1911.03343](https://arxiv.org/abs/1911.03343). Older LMs - handle negation poorly, and naming a concept — even to reject it — primes - it. **This is the citable basis for the guard rule in the skill bundle: - never state only the rejected pattern; state the positive replacement.** - Evidence is on BERT-era models; modern instruct models are meaningfully - better at negation, but "mentioning primes" remains directionally true and - the design rule costs nothing. -- Si et al., *Design2Code*, [arXiv:2403.03163](https://arxiv.org/abs/2403.03163). - Benchmark evidence that frontend generation has systematic, measurable - fidelity failures against a given design — the closest published grounding - for the frontend gravity well specifically. - -**Honesty note:** we have no benchmark showing that concrete anti-goals -("no rounded-xl card grids on gray-50") outperform positive adjectives -("modern, clean") at escaping generic output. It follows from the diversity -and priming results and matches consistent practitioner experience, but it is -a Prior. Cheap experiment: same task with adjective-only vs. anti-goal-only -steering; count generic-pattern occurrences. - ---- - -## 7. Invariants: short, concrete, checkable - -**Ghost claim:** invariants work best when short, concrete, numeric where -possible, and mirrored by review checks; "if it cannot be reviewed, it may not -be an invariant yet." - -**Tier: Established** for verifiability as the load-bearing property. - -- Zhou et al., *Instruction-Following Evaluation for Large Language Models* - (IFEval), [arXiv:2311.07911](https://arxiv.org/abs/2311.07911). The standard - instruction-following benchmark is built **entirely on verifiable - instructions** (exact counts, always/never, checkable formats) precisely - because vague instructions cannot be evaluated. The same reasoning as our - invariant form: an unfalsifiable rule is not yet a rule. - ---- - -## 8. Early commitment: skeletons and structural steering - -**Ghost claims:** autoregressive generation commits early; the first tokens of -an output constrain everything after; starting from a pulled Skeleton verbatim -controls structure better than describing structure. - -**Tier: Prior**, with mechanistic grounding. This is the weakest-cited claim -in the steering doc and we should say so. - -- Vaswani et al., *Attention Is All You Need*, - [arXiv:1706.03762](https://arxiv.org/abs/1706.03762). The autoregressive - factorization itself: every token is conditioned on all previous tokens. - Early commitments constraining later ones is definitional, not empirical. -- Ranzato et al., *Sequence Level Training with Recurrent Neural Networks*, - [arXiv:1511.06732](https://arxiv.org/abs/1511.06732). Exposure bias: errors - and choices early in a generated sequence compound, because the model - conditions on its own prior output. -- Press et al., *Measuring and Narrowing the Compositionality Gap in Language - Models*, [arXiv:2210.03350](https://arxiv.org/abs/2210.03350). Errors - compound across dependent steps — indirect support for controlling the first - structural commitment rather than correcting downstream. - -**Honesty note:** "hand the model a skeleton and it stays in that structure" -is a strong practitioner prior with mechanistic plausibility, not a -benchmarked result for UI/design generation. Cheap experiment: fixed task, -(a) prose structural description vs. (b) verbatim skeleton start; measure -structural adherence. If we ever want to state this as fact in public docs, -run that first. - ---- - -## 9. Decision traces: teaching the tradeoff function - -**Ghost claim:** worked reasoning examples ("we chose command palette over -drawer because…") teach the model *how the brand decides*, not just what it -produces; models imitate reasoning traces as well as outputs. - -**Tier: Established** that demonstrated reasoning steers model reasoning. -**Adjacent** for brand-tradeoff transfer specifically. - -- Wei et al., *Chain-of-Thought Prompting Elicits Reasoning in Large Language - Models*, [arXiv:2201.11903](https://arxiv.org/abs/2201.11903). Demonstrated - reasoning in context changes how the model reasons, not just what it - answers. Decision traces are brand-domain chain-of-thought exemplars. -- Zelikman et al., *STaR: Bootstrapping Reasoning With Reasoning*, - [arXiv:2203.14465](https://arxiv.org/abs/2203.14465). Reasoning traces are - themselves effective training/steering signal — showing *how* a conclusion - was reached transfers better than stating the conclusion. - -**Honesty note:** CoT results are on math/logic/symbolic tasks. That a worked -design tradeoff transfers the brand's *choice function* to novel conflicts is -an extrapolation we believe but have not measured. - ---- - -## 10. Checks: external review loops work; self-correction alone does not - -**Ghost claims:** a generate → review → repair loop is more reliable than -perfecting the first sample; checks are advisory packets graded by an agent; -checks must be grounded in specific fingerprint nodes. - -**Tier: Established**, including the caveat that makes Ghost's specific design -necessary. - -- Madaan et al., *Self-Refine: Iterative Refinement with Self-Feedback*, - [arXiv:2303.17651](https://arxiv.org/abs/2303.17651), and Shinn et al., - *Reflexion*, [arXiv:2303.11366](https://arxiv.org/abs/2303.11366). - Iterative feedback loops outperform single-shot generation across many - tasks. -- Bai et al., *Constitutional AI: Harmlessness from AI Feedback*, - [arXiv:2212.08073](https://arxiv.org/abs/2212.08073). The closest - architectural precedent for Ghost checks: explicit written principles - applied at **critique time** to revise outputs, rather than stuffed into the - generation prompt. -- Zheng et al., *LLM-as-a-Judge with MT-Bench and Chatbot Arena*, - [arXiv:2306.05685](https://arxiv.org/abs/2306.05685). Model-as-grader - agrees with human raters at usable rates — and has known biases (position, - verbosity, self-enhancement). Grounds "advisory packet for the host agent to - judge" and warns why check results are advisory, not authoritative. -- Huang et al., *Large Language Models Cannot Self-Correct Reasoning Yet*, - [arXiv:2310.01798](https://arxiv.org/abs/2310.01798). **The caveat that - validates the design:** intrinsic self-correction — "review your own work" - with no external signal — often makes outputs *worse*. Review loops work - when the critique is grounded in an external criterion. This is exactly why - checks carry `references` to specific fingerprint nodes and concrete - flag-conditions, instead of asking the agent to vibe-check itself. - ---- - -## 11. Checks never leak into generation - -**Ghost claim:** if checks appear in `gather`, the model writes to the test -and the review signal collapses (purposes.md, leak #4). - -**Tier: Established** as a general principle of measurement. - -- Manheim & Garrabrant, *Categorizing Variants of Goodhart's Law*, - [arXiv:1803.04585](https://arxiv.org/abs/1803.04585). When a measure becomes - a target, it ceases to be a good measure — formalized into variants. The - feed-forward/feed-back separation in Ghost is a Goodhart firewall: checks - retain diagnostic value only while the generator does not optimize against - them. - -**Honesty note:** Goodhart is a principle about optimization pressure, not an -LLM benchmark. A single generation pass that has seen a check is not -"optimizing" in the RL sense; the leak risk compounds over repair loops and -over authoring (checks drifting into de facto guidance). The architectural -rule is cheap insurance justified by the principle, and we should describe it -that way. - ---- - -## Claims we deliberately do not cite - -Listed so nobody back-fills a weak citation later. - -1. **The full steering hierarchy** (examples > constraints > scaffolding > - persona framing > quality adjectives). Each pairwise edge has partial - support above, but no paper establishes the total order. It is a synthesis. - Public docs should say "consistent with the literature," never "shown by." -2. **BYOA over built-in selection.** The choice to let the host agent select - nodes rather than ship an embedded retriever is architectural (simplicity, - agent context-awareness, no NLP in the CLI), defended in - [purposes.md](./purposes.md). The retrieval literature validates - retrieve-then-generate; it does not adjudicate who should do the - retrieving. -3. **Flat corpus over hierarchy/graph.** No paper compares flat prose corpora - against hierarchical knowledge bases as LLM steering input. Our argument is - about authoring cost, leak prevention, and projection freedom — an - engineering argument, not a model-science one. -4. **Descriptions in task language retrieve better than poetic descriptions.** - Almost certainly true by analogy to tool-description and retrieval-query - findings, but we have not seen it isolated in a study. Our own - `.ghost/.events` pulse data is the honest evidence source here: misses and - unused pulls are the experiment already running. -5. **Vendor guidance** (Anthropic's prompt-engineering and agent-building - posts, OpenAI's prompting guides). Aligned with our stances on ordering, - examples-over-adjectives, and determinism-in-the-harness, and worth reading - — but they are industry practice documents, not validation, and we cite - them nowhere as evidence. - ---- - -## Open experiments - -The Prior-tier claims above, collected. Each is cheap to run against a fixture -fingerprint and a fixed task set, blind-graded: - -| # | Claim | Experiment | -| --- | --- | --- | -| 1 | 3–5 node pull budget | Pull sets of 3 / 6 / 10 nodes, grade adherence and genericness. | -| 2 | Ghost's packet order | Permute pull emission order, grade adherence. | -| 3 | Skeleton-verbatim start controls structure | Prose structure description vs. verbatim skeleton start, measure structural adherence. | -| 4 | Anti-goals beat adjectives | Adjective-only vs. anti-goal-only steering, count generic-pattern occurrences. | -| 5 | Exemplar > prose principle for design output | Same truth as prose vs. prose + annotated exemplar, blind-grade. | - -Results, when we have them, should be recorded here and the corresponding -claims promoted or revised — in the steering doc as well as this one. diff --git a/docs/vessel-agent-safe-reference-system.md b/docs/vessel-agent-safe-reference-system.md deleted file mode 100644 index c9e6c28f..00000000 --- a/docs/vessel-agent-safe-reference-system.md +++ /dev/null @@ -1,182 +0,0 @@ -# Vessel: Agent-Safe Reference System - -> **Phase 0 invariant doc.** This document sets the rules that future Vessel -> work must preserve while reconciling latest shadcn sources, learning from -> downstream product forks, and baking in Orbit-style LLM-safe design-system -> discipline. It intentionally changes no runtime behavior. - -Vessel is Ghost's reference body: a portable, shadcn-compatible component -registry that agents can use to compose AI/product interfaces. It should have -taste, constraints, and safe defaults. It must not become the brand truth for -every Ghost consumer. - -## North star - -> Vessel is an agnostic, agent-safe reference implementation. It provides a -> coherent body a product's Ghost fingerprint can inhabit; it does not replace -> that product's fingerprint. - -A consuming repo owns its product stance, flows, copy, trust obligations, and -visual-language decisions through its local `.ghost/` fingerprint and whatever -implementation checks it chooses to run. Vessel supplies reusable materials and -safe authoring paths, not universal brand law. - -## What Vessel is - -- **A shadcn-compatible source-owned registry.** Components are copied into the - consuming repo through the shadcn registry model and may be owned there. -- **A reference implementation.** Vessel demonstrates one coherent way to build - AI/product surfaces with tokens, primitives, AI elements, and interaction - patterns. -- **An agent-facing system.** Registry metadata, generated skills, examples, and - component APIs should help agents choose named decisions instead of inventing - local styling. -- **A safe default.** The baseline language should be restrained, legible, - workbench-like, and useful for dense AI/product interfaces. - -## What Vessel is not - -- **Not Ghost's brand model.** Brand truths live in fingerprint prose, not in - Vessel's registry or tokens. -- **Not an extracted product UI kit.** Downstream product forks are useful - evidence and stress tests, not the visual source of truth for Vessel. -- **Not latest shadcn with a logo.** Upstream shadcn is raw material for - accessibility, anatomy, and compatibility; Vessel keeps its own token contract - and agent-safe authoring rules. -- **Not a sealed design system.** Consumers can own the code they install, but - Vessel should make the safe path obvious and the escape path visible. - -## Relationship to Ghost - -| Layer | Owns | Vessel's obligation | -| --- | --- | --- | -| Ghost fingerprint | Product truth: intent, stance, composition, conditions, material locators, and optional review checks | Do not encode this into Vessel as universal law. Provide materials a fingerprint can select, interpret, and review against. | -| Vessel | Reusable implementation vocabulary: tokens, primitives, AI elements, and registry metadata | Provide coherent defaults and safe decisions without becoming product-specific. | - -The seam matters: Vessel can make some low-level decisions harder to get wrong; -Ghost covers the high-altitude product truths that cannot be compiled into -component props or lint rules. - -## Relationship to shadcn - -Vessel follows the shadcn **copy-and-own** model. Syncing with upstream shadcn is -upstream hygiene, not visual direction. - -When reconciling latest shadcn components: - -1. **Adopt** mechanical improvements: accessibility fixes, Radix wiring, ARIA, - keyboard behavior, `data-slot` conventions, React compatibility, and Tailwind - 4-compatible structure. -2. **Adapt** useful anatomy through Vessel's token contract and component API. -3. **Reject** generic visual decisions that widen the authoring surface: raw - palette classes, arbitrary values, broad aliases, component-local theme hacks, - or styling that bypasses Vessel's semantic roles. - -Latest shadcn is a baseline to compare against. It is not authority over -Vessel's visual language. - -## Relationship to downstream product forks - -Real product forks show what happens when a copied registry lives inside a -shipping product and accumulates pressure. Vessel should mine that history for -reusable discipline, not copy any product wholesale. - -**Take spiritually:** - -- one semantic authoring contract; -- narrow product-extension tokens instead of broad alias sprawl; -- calm, dense, legible product surfaces; -- token checks that reject deleted families and raw palette drift; -- generated design-system manifests and agent-facing docs; -- real-world AI elements such as messages, tools, reasoning, code, files, - terminals, and prompt input. - -**Do not automatically copy:** - -- app chrome; -- desktop-shell assumptions; -- project-specific canvas tinting unless generalized; -- profile/editor specifics; -- every glass/composer surface token; -- any brand/licensed font requirement without an explicit distribution decision; -- product surfaces that the downstream product itself excludes as precedent. - -Downstream products are evidence. Vessel's job is synthesis. - -## Orbit lessons Vessel adopts - -Orbit's transferable lesson is: **make off-system output hard to express, not -merely discouraged in prose.** For Vessel, that means: - -1. **Decision names beat values.** Prefer props, variants, tokens, and registry - metadata that name intent (`surface=card`, `tone=muted`, `density=compact`) - over open-ended class strings and raw values. -2. **Docs are probability; checks are contracts.** If a rule can be deterministic - — no raw palette utilities, no deleted token aliases, no unapproved theme - bridge names — encode it as a script/lint/check. -3. **Escape hatches must be visible.** `className`, inline `style`, arbitrary - values, and local forks are sometimes necessary, but they should be easy to - grep, count, and review. -4. **Theme behavior belongs in tokens.** Components should consume semantic - roles; light/dark differences should live in the token/theme layer wherever - possible. -5. **Registry metadata is part of the API.** Agents need to know when to use a - component, when not to, which variants are safe, and what mistakes to avoid — - not just its source code. - -## Token contract invariants - -Future token work should preserve this shape: - -```text -primitive values - -> semantic roles - -> narrow Vessel extensions - -> Tailwind utility bridge -``` - -- Primitive values are the only broad place for literal color material. -- Shared UI authors against semantic roles first: `background`, `foreground`, - `card`, `popover`, `muted`, `accent`, `primary`, `secondary`, `destructive`, - `border`, `input`, `ring`, and sidebar roles. -- Vessel extensions must be narrow and job-named: composer surfaces, message - surfaces, tool/reasoning/status, chips, canvas, code/terminal affordances. -- Do not reintroduce broad duplicate aliases such as `background-alt`, - `text-alt`, `border-strong`, or `surface-card`. -- Bridge a token into Tailwind only when component code should author it as a - utility class. Raw-CSS-only hooks should remain raw CSS variables. - -## Registry invariants - -Every high-impact registry item should eventually expose decision metadata: - -- intent; -- when to use; -- when not to use; -- safe variants and what they mean; -- common misuses; -- related components; -- token roles; -- optional Ghost node or check reference ids when a consuming repo provides them. - -## Escape hatch policy - -Vessel remains source-owned and shadcn-compatible, so escape hatches are not -banned categorically. They are governed: - -- Prefer variants, slots, tokens, and safe primitives before `className`. -- Prefer adding a named decision to Vessel when the same override recurs. -- Keep arbitrary values, inline styles, and raw palette utilities out of normal - component source unless there is a documented technical reason. -- Add checks that can count or reject unsafe paths before relying on review prose. - -## Migration order this document protects - -1. Reconcile with latest shadcn for upstream hygiene. -2. Normalize the agnostic token contract. -3. Re-apply Vessel's own restrained reference stance. -4. Add Orbit safety: checks, metadata, and safer component APIs. -5. Mine downstream product forks selectively for reusable AI/product patterns. - -Do not invert this into "copy a product fork" or "accept latest shadcn -wholesale." Both would erase the reason Vessel exists. diff --git a/install/install.sh b/install/install.sh index d6381809..91a8a4cb 100755 --- a/install/install.sh +++ b/install/install.sh @@ -5,7 +5,7 @@ # curl -fsSL https://raw.githubusercontent.com/block/ghost/main/install/install.sh | sh # # Flags (set via env or args): -# GHOST_AGENT=claude|cursor|codex|opencode Target agent (default: auto-detect) +# GHOST_AGENT=claude|cursor|codex|opencode|goose Target agent (default: auto-detect) # GHOST_DEST= Override install path # GHOST_REF= Source ref (default: main) # GHOST_SOURCE= Override fetch base URL (for testing @@ -14,8 +14,9 @@ # What gets installed: # /ghost/ # SKILL.md -# references/authoring-scenarios.md, brief.md, capture.md -# references/inventory.md, recall.md, schema.md, self-check.md +# references/adapting-a-starter.md, authoring-scenarios.md, blocks.md +# references/brief.md, capture.md, concrete-tiers.md, recall.md +# references/schema.md, self-check.md, steering-audit.md # # Exit codes: # 0 installed @@ -75,6 +76,9 @@ detect_agent() { if [ -d "$HOME/.opencode/skills" ] || [ -d "$HOME/.opencode" ]; then echo "opencode"; return fi + if [ -d "$HOME/.agents/skills" ] || [ -d "$HOME/.goose" ] || [ -d "$HOME/.config/goose" ]; then + echo "goose"; return + fi echo "claude" } @@ -84,6 +88,9 @@ agent_dest_dir() { cursor) echo "$HOME/.cursor/skills" ;; codex) echo "$HOME/.codex/skills" ;; opencode) echo "$HOME/.opencode/skills" ;; + # Goose's canonical writable global skills location is ~/.agents/skills — + # the agent-neutral path it scans first among global roots. + goose) echo "$HOME/.agents/skills" ;; *) return 1 ;; esac } @@ -95,9 +102,9 @@ fi # Validate agent up-front. `exit` inside $() doesn't propagate, so we # can't rely on agent_dest_dir to fail the script. case "$GHOST_AGENT" in - claude|cursor|codex|opencode) ;; + claude|cursor|codex|opencode|goose) ;; *) - echo "ghost install: unsupported agent '$GHOST_AGENT' (claude|cursor|codex|opencode)" >&2 + echo "ghost install: unsupported agent '$GHOST_AGENT' (claude|cursor|codex|opencode|goose)" >&2 exit 1 ;; esac diff --git a/package.json b/package.json index d480332b..7477ad29 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test:watch": "vitest", "typecheck": "tsc --build", "build:ui": "pnpm --filter @design-intelligence/vessel-react build", - "check": "biome check . && pnpm typecheck && pnpm check:package-bin && pnpm check:packed-package && pnpm check:file-sizes && pnpm check:terminology && pnpm check:docs && pnpm check:install-bundle && pnpm check:release-tarball && pnpm check:release-workflows && pnpm check:cli-manifest", + "check": "biome check . && pnpm typecheck && pnpm check:package-bin && pnpm check:packed-package && pnpm check:file-sizes && pnpm check:terminology && pnpm check:docs && pnpm check:install-bundle && pnpm check:release-tarball && pnpm check:release-workflows && pnpm check:cli-manifest && pnpm check:vessel-light", + "check:vessel-light": "node scripts/check-vessel-light-closure.mjs", "check:file-sizes": "node scripts/check-file-sizes.mjs", "check:packed-package": "pnpm --filter @design-intelligence/ghost build && node scripts/check-packed-package.mjs", "check:package-bin": "chmod +x packages/ghost/dist/bin.js && node scripts/link-package-bin.mjs && node scripts/check-package-bin.mjs", diff --git a/packages/context-control/README.md b/packages/context-control/README.md new file mode 100644 index 00000000..90ad988b --- /dev/null +++ b/packages/context-control/README.md @@ -0,0 +1,109 @@ +# context-control + +A local UI for testing the selection half of Ghost's loop. steering-control +measures what a fingerprint buys downstream of generation; context-control +measures whether the right truths even make it into context. Selection is +the unit. No generation, ever. + +`ghost gather` does no filtering: the menu is always the whole catalog, and +selection happens in the model's head against each node's `description`. +So what this bench actually tests is whether descriptions are good enough +retrieval payloads — a truth with a vague description is invisible at +selection time no matter how good its body is. + +## Run + +```bash +pnpm --filter @design-intelligence/ghost build # bench shells the real CLI +tmp=$(mktemp -d) +node packages/ghost/dist/bin.js init --package "$tmp/.ghost" +node packages/context-control/cli.mjs \ + --package "$tmp/.ghost" \ + --asks packages/context-control/demo/asks.md +# → http://127.0.0.1:4114 +``` + +Options: `--package ` (default `./.ghost`), `--asks `, +`--port ` (default 4114), `--ghost ` (default: repo dist build, +else `ghost` on PATH). + +## Screens + +**fingerprint** — the catalog rendered as the selection surface the model +sees: id, kind, description, material count, coverage line. Click a node to +see its real `ghost pull` output in a drawer. Review descriptions as +retrieval payloads, not file contents; a node with no description is flagged +as invisible. + +**bench** — type an ask (or run the whole asks suite), fire N single-shot +selection trials, and read the heatmap: nodes × asks, each cell the +fraction of trials that selected the node. Solid column = confident +description. Speckled = coin-flip. Empty row = dead node. Blue outline = +the ask's expected set. Scores above the map: consistency (mean pairwise +Jaccard), mean per-trial precision and recall, poison-selection rate, unknown +ids, and nodes ever selected. + +Selection runs as a real agent would: the system prompt includes the cover +already in context and asks for a small pull from the menu. There is no +skill-less mode. One caveat remains: a live agent also +carries task context (open files, prior turns) that single-shot selection +lacks. + +**replay** — the real `.ghost/.events` tape grouped into sessions: each +gather with its ask, the pulls that followed, re-gathers, and pull misses. +Ground truth from the field keeps the bench honest. Events stay local; +nothing leaves the machine. + +## Asks format + +Use the same ask blocks as steering-control. `expect:` lists nodes a good +selector should pull. `poison:` lists nodes whose condition does not apply. +`discount:` remains available to steering-control and is ignored here. + +```markdown +## Ask 1 — notification settings + +Build a dense settings screen for notification preferences. + +expect: foundation.composition, foundation.controls, foundation.layout +poison: context.conversation +``` + +Asks without `expect:` still score consistency. Expected and poison sets add +precision, recall, and poison rate. List only selectable menu ids. Do not list +the manifest cover: gather has already placed it in context and removed it from +the menu. + +## Model adapters + +The contract is one function: `select({ ask, menu, cover, trial }) -> ids`. Two +adapters ship, selectable in the bench UI: + +- `fake-lexical` — deterministic lexical-overlap stub with per-trial + jitter near the decision boundary. No network, instant. Use it to + exercise the UI loop and as a dumb-retrieval baseline. +- `databricks` — a real LLM behind a Databricks serving endpoint with an + OpenAI-compatible chat API. This is the default whenever + `DATABRICKS_HOST` is set; put it in an untracked `.env` or `.env.local` + at the working directory and the CLI loads it on startup. Auth comes + from the `databricks` CLI's cached OAuth token; the endpoint from + `CONTEXT_CONTROL_ENDPOINT` (default `goose`), or name it inline as + `databricks:`. Trials run concurrently and sample at the + endpoint's default temperature — trial-to-trial variance is the signal + being measured, so it is not pinned to 0. + +Add providers to `MODEL_ADAPTERS` in `lib/model.mjs`. + +## Layout + +```text +cli.mjs # context-control → serves the UI +lib/ghost.mjs # shells ghost gather/pull --format json (never re-implements semantics) +lib/model.mjs # model adapters (fake-lexical stub) +lib/bench.mjs # trial runner + asks.md parser +lib/score.mjs # jaccard, consistency, precision/recall, rates, coverage +lib/tape.mjs # .ghost/.events parser + session grouping +lib/server.mjs # node:http JSON endpoints + static UI +ui/index.html # single-file UI, no build step +demo/asks.md # demo suite against the default ghost init skeleton +``` diff --git a/packages/context-control/cli.mjs b/packages/context-control/cli.mjs new file mode 100644 index 00000000..352f6454 --- /dev/null +++ b/packages/context-control/cli.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// context-control: view a fingerprint as the selection surface it is, and +// bench single-shot selection against it. No generation, ever. +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { defaultGhostBin } from "./lib/ghost.mjs"; +import { startServer } from "./lib/server.mjs"; + +// Load .env from the working directory if present (DATABRICKS_HOST lives +// in an untracked .env / .env.local, same convention as the ghost CLI). +for (const envFile of [".env", ".env.local"]) { + const envPath = resolve(process.cwd(), envFile); + if (existsSync(envPath)) { + process.loadEnvFile(envPath); + } +} + +const args = process.argv.slice(2); + +if (args.includes("--help") || args.includes("-h")) { + console.log(`usage: context-control [options] + +Serves the context-control UI for a .ghost fingerprint package. + +Options: + --package fingerprint package directory (default: ./.ghost) + --asks asks.md shared with steering-control + --port port (default: 4114) + --ghost ghost binary (default: repo dist build, else PATH)`); + process.exit(0); +} + +const opts = { package: "./.ghost", asks: null, port: 4114, ghost: null }; +for (let i = 0; i < args.length; i += 2) { + const key = args[i]?.replace(/^--/, ""); + if (!(key in opts) || args[i + 1] === undefined) { + console.error(`unknown or valueless option: ${args[i]}`); + process.exit(2); + } + opts[key] = args[i + 1]; +} + +const packageDir = resolve(process.cwd(), opts.package); +if (!existsSync(packageDir)) { + console.error(`no fingerprint package at ${packageDir}`); + process.exit(2); +} + +const ghostBin = opts.ghost ? [opts.ghost] : defaultGhostBin(); +const port = Number(opts.port); +if (!Number.isInteger(port) || port < 0 || port > 65535) { + console.error(`invalid port: ${opts.port}`); + process.exit(2); +} +await startServer({ + ghostBin, + packageDir, + asksPath: opts.asks ? resolve(process.cwd(), opts.asks) : null, + port, +}); +console.log( + `context-control: http://127.0.0.1:${port} (package: ${packageDir})`, +); diff --git a/packages/context-control/demo/asks.md b/packages/context-control/demo/asks.md new file mode 100644 index 00000000..83f40270 --- /dev/null +++ b/packages/context-control/demo/asks.md @@ -0,0 +1,41 @@ +# context-control demo asks + +These asks target the default skeleton written by `ghost init`. The format is +shared with steering-control. `expect:` names nodes a good selector should pull. +`poison:` names nodes whose condition does not apply. + +## Ask 1 — notification settings + +Build a dense settings screen for notification preferences. + +expect: foundation.composition, foundation.controls, foundation.layout +poison: context.conversation + +## Ask 2 — pricing landing page + +Build a pricing landing page with a hero, three plan tiers, a customer quote, +and a closing call to action. + +expect: foundation.composition, foundation.color, foundation.type, cliche.median +poison: context.conversation + +## Ask 3 — assistant conversation + +Build an AI assistant thread with user turns, tool calls, and a prompt composer. + +expect: context.conversation, foundation.composition, foundation.controls + +## Ask 4 — payment receipt email + +Build a transactional payment receipt email with the amount, payment method, +date, and invoice link. + +expect: foundation.layout, foundation.type, foundation.voice +poison: context.conversation + +## Ask 5 — modal motion + +Define the motion for a modal opening and closing. + +expect: foundation.composition, foundation.motion +poison: context.conversation diff --git a/packages/context-control/lib/bench.mjs b/packages/context-control/lib/bench.mjs new file mode 100644 index 00000000..484c1123 --- /dev/null +++ b/packages/context-control/lib/bench.mjs @@ -0,0 +1,106 @@ +// The bench loop: one ask, N single-shot selection trials, scored. +// Selection is the unit. No generation, ever. +import { consistency, precisionRecall, selectionRates } from "./score.mjs"; + +/** Run one ask against the gather output for independent selection trials. */ +export async function runAsk({ + model, + ask, + menu, + cover, + trials = 5, + expected, + poison, +}) { + const known = new Set(menu.map((entry) => entry.id)); + const selections = await Promise.all( + Array.from({ length: trials }, async (_, trial) => { + const ids = await model.select({ ask, menu, cover, trial }); + return { + ids: ids.filter((id) => known.has(id)), + unknownIds: ids.filter((id) => !known.has(id)), + }; + }), + ); + const trialIds = selections.map((selection) => selection.ids); + return { + ask, + model: model.name, + trials: trialIds, + unknownIds: selections.flatMap((selection) => selection.unknownIds), + expected: expected ?? null, + poison: poison ?? [], + scores: { + consistency: consistency(trialIds), + ...(expected ? precisionRecall(trialIds, expected, poison ?? []) : {}), + }, + rates: selectionRates(trialIds, menu), + }; +} + +/** + * Parse the asks format shared with steering-control: + * + * ## Ask 1 — billing settings page + * + * Build a billing settings page. Single HTML file. + * + * expect: foundation.composition, foundation.controls + * poison: context.conversation + */ +export function parseAsks(markdown, { validateIds } = {}) { + const asks = []; + let current = null; + + const flush = () => { + if (!current) return; + current.ask = current.bodyLines.join("\n").trim(); + delete current.bodyLines; + asks.push(current); + }; + + for (const line of markdown.split(/\r?\n/)) { + const heading = line.match(/^##\s+Ask\s+(\d+)\s+[—-]\s+(.+)\s*$/u); + if (heading) { + flush(); + current = { + n: Number(heading[1]), + title: heading[2].trim(), + ask: "", + bodyLines: [], + expected: [], + poison: [], + discount: [], + }; + continue; + } + if (!current) continue; + + const meta = line.match(/^\s*(expect|poison|discount):\s*(.*?)\s*$/u); + if (meta) { + const key = meta[1] === "expect" ? "expected" : meta[1]; + current[key] = splitIds(meta[2]); + continue; + } + current.bodyLines.push(line); + } + + flush(); + if (validateIds) { + for (const ask of asks) { + for (const id of [...ask.expected, ...ask.poison]) { + if (!validateIds.has(id)) { + throw new Error(`ask ${ask.n} references unknown node id: ${id}`); + } + } + } + } + return asks; +} + +function splitIds(value) { + return value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); +} diff --git a/packages/context-control/lib/ghost.mjs b/packages/context-control/lib/ghost.mjs new file mode 100644 index 00000000..23be51a5 --- /dev/null +++ b/packages/context-control/lib/ghost.mjs @@ -0,0 +1,66 @@ +// Thin shell adapter over the real ghost CLI. context-control never +// re-implements Ghost semantics; everything deterministic comes from +// `ghost gather --format json` and `ghost pull --format json`. +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** Resolve the ghost bin: repo dist build if present, else `ghost` on PATH. */ +export function defaultGhostBin() { + const repoBin = fileURLToPath( + new URL("../../ghost/dist/bin.js", import.meta.url), + ); + if (existsSync(repoBin)) return ["node", repoBin]; + return ["ghost"]; +} + +// The ghost CLI calls process.exit() right after writing stdout. When +// stdout is a pipe, output past the 64KB pipe buffer is dropped before it +// flushes, truncating large pull packets. Route stdout through a temp file +// (a file write flushes synchronously on exit) until the CLI flushes +// before exiting. +async function runGhost(ghostBin, args) { + const [cmd, ...prefix] = ghostBin; + const dir = await mkdtemp(join(tmpdir(), "context-control-")); + const outFile = join(dir, "stdout.json"); + try { + await execFileAsync( + "/bin/sh", + ["-c", `exec "$@" > "${outFile}"`, "sh", cmd, ...prefix, ...args], + { maxBuffer: 32 * 1024 * 1024 }, + ); + return await readFile(outFile, "utf-8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export async function gatherMenu({ ghostBin, packageDir }) { + const stdout = await runGhost(ghostBin, [ + "gather", + "--format", + "json", + "--package", + packageDir, + ]); + return JSON.parse(stdout); +} + +export async function pullNode({ ghostBin, packageDir, id }) { + const stdout = await runGhost(ghostBin, [ + "pull", + id, + "--format", + "json", + "--no-events", + "--package", + packageDir, + ]); + return JSON.parse(stdout); +} diff --git a/packages/context-control/lib/model.mjs b/packages/context-control/lib/model.mjs new file mode 100644 index 00000000..193e3e0a --- /dev/null +++ b/packages/context-control/lib/model.mjs @@ -0,0 +1,212 @@ +// Model adapters. The contract is one function: +// select({ ask, menu, trial }) -> Promise (node ids) +// +// Two adapters ship: +// +// - fake-lexical: a deterministic lexical-overlap stub with per-trial +// jitter near the decision boundary. No network, instant, and +// deliberately imperfect — it produces the speckle pattern the heatmap +// exists to expose. Use it to exercise the UI loop. +// +// - databricks: a real LLM behind a Databricks serving endpoint with an +// OpenAI-compatible chat API. This is the model actually under test: +// it sees the ask plus the menu (id, kind, description — exactly the +// selection surface `ghost gather` emits) and returns node ids. +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const STOPWORDS = new Set([ + "a", + "an", + "and", + "the", + "for", + "with", + "of", + "to", + "in", + "on", + "is", + "it", + "that", + "this", + "we", + "our", + "as", + "by", + "or", + "at", +]); + +function tokens(text) { + return (text ?? "") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length > 2 && !STOPWORDS.has(t)); +} + +/** Deterministic hash -> [0, 1). Stable across runs for the same inputs. */ +function jitter(...parts) { + let h = 2166136261; + for (const ch of parts.join("\u0000")) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 10000) / 10000; +} + +export function fakeModel() { + return { + name: "fake-lexical", + async select({ ask, menu, trial }) { + const askTokens = new Set(tokens(ask)); + const selected = []; + for (const entry of menu) { + const nodeTokens = tokens( + [entry.id.replaceAll(/[.-]/g, " "), entry.kind, entry.description] + .filter(Boolean) + .join(" "), + ); + if (nodeTokens.length === 0) continue; + let hits = 0; + for (const t of nodeTokens) if (askTokens.has(t)) hits += 1; + const score = hits / Math.sqrt(nodeTokens.length); + const noise = (jitter(ask, entry.id, String(trial)) - 0.5) * 0.24; + if (score + noise > 0.18) selected.push(entry.id); + } + return selected; + }, + }; +} + +// The selection prompt is a replica of what a real skill-equipped agent +// follows: the skill bundle's recall and brief recipes +// (packages/ghost/src/skill-bundle/references/). The bench measures actual +// agent behavior, so the protocol those recipes install is always on — +// there is no skill-less arm. If the recipes change, change this with them. +const SELECT_SYSTEM = `You are an agent selecting brand-fingerprint nodes for a task, +following the ghost skill's recall recipe. + +You will get an ask, the cover already in context, and the ghost gather menu. +Select only menu node ids against their descriptions. Do not select the cover. + +- Pull a small set: 3-5 nodes is normal; 10 is a bad selection unless the + task is unusually broad. +- Prefer nodes with concrete material for the surface being made. +- Anti-goal nodes are review-critical negative space; include one when the + ask enters the territory its description names. + +Respond with ONLY a JSON array of node id strings, nothing else.`; + +function selectUser(ask, menu, cover) { + const lines = menu.map((entry) => { + const flags = [entry.materials ? `${entry.materials} materials` : null] + .filter(Boolean) + .join(", "); + return `- ${entry.id}${entry.kind ? ` [${entry.kind}]` : ""}${flags ? ` (${flags})` : ""}: ${entry.description ?? "(no description)"}`; + }); + const coverLine = cover + ? `Cover already in context: ${cover.id}\n\n${cover.body}\n\n` + : ""; + return `${coverLine}Ask: ${ask}\n\nMenu:\n${lines.join("\n")}`; +} + +/** Parse a JSON id array out of a model reply, tolerating code fences. */ +export function parseIdReply(text) { + const match = String(text).match(/\[[\s\S]*?\]/); + if (!match) return []; + try { + const parsed = JSON.parse(match[0]); + if (!Array.isArray(parsed)) return []; + return parsed.filter((id) => typeof id === "string"); + } catch { + return []; + } +} + +/** + * Databricks serving-endpoint adapter (OpenAI-compatible chat API). + * Auth comes from the `databricks` CLI's cached OAuth token; the host + * comes from DATABRICKS_HOST. Nothing is stored. + */ +export function databricksModel({ + host = process.env.DATABRICKS_HOST, + endpoint = process.env.CONTEXT_CONTROL_ENDPOINT ?? "goose", +} = {}) { + if (!host) throw new Error("databricks model needs DATABRICKS_HOST"); + let tokenPromise = null; + const getToken = () => { + tokenPromise ??= execFileAsync("databricks", [ + "auth", + "token", + "--host", + host, + ]).then(({ stdout }) => JSON.parse(stdout).access_token); + return tokenPromise; + }; + return { + name: `databricks:${endpoint}`, + async select({ ask, menu, cover }) { + const token = await getToken(); + const res = await fetch( + `${host}/serving-endpoints/${endpoint}/invocations`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + messages: [ + { role: "system", content: SELECT_SYSTEM }, + { role: "user", content: selectUser(ask, menu, cover) }, + ], + // Trial-to-trial variance is the signal being measured, so + // sample at the endpoint's default temperature; do not pin 0. + max_tokens: 1024, + }), + }, + ); + if (!res.ok) { + throw new Error( + `databricks ${endpoint}: ${res.status} ${await res.text()}`, + ); + } + const data = await res.json(); + return parseIdReply(data.choices?.[0]?.message?.content ?? ""); + }, + }; +} + +const MODEL_ADAPTERS = { + databricks: { + available: () => Boolean(process.env.DATABRICKS_HOST), + create: (name) => { + const endpoint = name.includes(":") + ? name.slice(name.indexOf(":") + 1) + : null; + return databricksModel(endpoint ? { endpoint } : {}); + }, + }, + "fake-lexical": { + available: () => true, + create: () => fakeModel(), + }, +}; + +/** Resolve a model name exposed by availableModels. */ +export function resolveModel(name = availableModels()[0]) { + const key = name?.startsWith("databricks:") ? "databricks" : name; + const adapter = MODEL_ADAPTERS[key]; + if (!adapter) throw new Error(`unknown model: ${name}`); + return adapter.create(name); +} + +/** Available adapter names in default order. */ +export function availableModels() { + return Object.entries(MODEL_ADAPTERS) + .filter(([, adapter]) => adapter.available()) + .map(([name]) => name); +} diff --git a/packages/context-control/lib/score.mjs b/packages/context-control/lib/score.mjs new file mode 100644 index 00000000..6fea0be4 --- /dev/null +++ b/packages/context-control/lib/score.mjs @@ -0,0 +1,93 @@ +// Deterministic scoring over collected selections. No LLM judges here. + +/** Jaccard similarity of two id arrays. Empty ∩ empty = 1. */ +export function jaccard(a, b) { + const setA = new Set(a); + const setB = new Set(b); + if (setA.size === 0 && setB.size === 0) return 1; + let intersection = 0; + for (const id of setA) if (setB.has(id)) intersection += 1; + const union = setA.size + setB.size - intersection; + return union === 0 ? 1 : intersection / union; +} + +/** Mean pairwise Jaccard across all trials of one ask. */ +export function consistency(trials) { + if (trials.length < 2) return 1; + let sum = 0; + let pairs = 0; + for (let i = 0; i < trials.length; i += 1) { + for (let j = i + 1; j < trials.length; j += 1) { + sum += jaccard(trials[i], trials[j]); + pairs += 1; + } + } + return sum / pairs; +} + +/** Mean per-trial precision, recall, and poison-selection rate. */ +export function precisionRecall(trials, expected, poison = []) { + if (!expected || expected.length === 0) return {}; + const want = new Set(expected); + const avoid = new Set(poison); + if (trials.length === 0) return {}; + const perTrial = trials.map((trial) => { + const selected = new Set(trial); + let hits = 0; + for (const id of selected) if (want.has(id)) hits += 1; + return { + precision: selected.size === 0 ? 0 : hits / selected.size, + recall: hits / want.size, + poisonSelected: [...avoid].some((id) => selected.has(id)), + }; + }); + return { + precision: mean(perTrial.map((trial) => trial.precision)), + recall: mean(perTrial.map((trial) => trial.recall)), + ...(avoid.size > 0 + ? { + poisonRate: + perTrial.filter((trial) => trial.poisonSelected).length / + perTrial.length, + } + : {}), + }; +} + +/** Per-node selection rate for one ask. */ +export function selectionRates(trials, menu) { + const counts = new Map(menu.map((entry) => [entry.id, 0])); + for (const trial of trials) { + for (const id of new Set(trial)) { + if (counts.has(id)) counts.set(id, counts.get(id) + 1); + } + } + const count = Math.max(trials.length, 1); + return Object.fromEntries( + [...counts].map(([id, selected]) => [id, selected / count]), + ); +} + +/** Suite-level rollup: nodes never selected across all asks. */ +export function suiteCoverage(results, menu) { + const everSelected = new Set(); + for (const result of results) { + for (const trial of result.trials) { + for (const id of trial) everSelected.add(id); + } + } + const dead = menu + .map((entry) => entry.id) + .filter((id) => !everSelected.has(id)); + return { + nodes: menu.length, + selectedEver: everSelected.size, + dead, + }; +} + +function mean(values) { + return values.length + ? values.reduce((sum, value) => sum + value, 0) / values.length + : 0; +} diff --git a/packages/context-control/lib/server.mjs b/packages/context-control/lib/server.mjs new file mode 100644 index 00000000..950061cf --- /dev/null +++ b/packages/context-control/lib/server.mjs @@ -0,0 +1,111 @@ +// Local bench server: a handful of JSON endpoints plus the static UI. +// Plain node:http, no dependencies — this never leaves the machine. +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { fileURLToPath } from "node:url"; +import { parseAsks, runAsk } from "./bench.mjs"; +import { gatherMenu, pullNode } from "./ghost.mjs"; +import { availableModels, resolveModel } from "./model.mjs"; +import { suiteCoverage } from "./score.mjs"; +import { readTape, toSessions } from "./tape.mjs"; + +const UI_DIR = fileURLToPath(new URL("../ui/", import.meta.url)); + +export function startServer({ ghostBin, packageDir, asksPath, port = 4114 }) { + const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + try { + if (url.pathname === "/api/menu") { + return json(res, await gatherMenu({ ghostBin, packageDir })); + } + if (url.pathname === "/api/pull") { + const id = url.searchParams.get("id"); + if (!id) return json(res, { error: "missing id" }, 400); + return json(res, await pullNode({ ghostBin, packageDir, id })); + } + if (url.pathname === "/api/models") { + return json(res, { models: availableModels() }); + } + if (url.pathname === "/api/asks") { + const menu = (await gatherMenu({ ghostBin, packageDir })).nodes; + return json(res, { + asks: await loadAsks(asksPath, new Set(menu.map((node) => node.id))), + }); + } + if (url.pathname === "/api/bench" && req.method === "POST") { + const body = JSON.parse(await readBody(req)); + const trials = Number(body.trials ?? 5); + if (!Number.isInteger(trials) || trials < 1 || trials > 50) { + return json( + res, + { error: "trials must be an integer from 1 to 50" }, + 400, + ); + } + const gathered = await gatherMenu({ ghostBin, packageDir }); + const menu = gathered.nodes; + const model = resolveModel(body.model); + const asks = Array.isArray(body.asks) ? body.asks : [body]; + const results = []; + for (const item of asks) { + results.push( + await runAsk({ + model, + ask: item.ask, + menu, + cover: gathered.cover, + trials, + expected: item.expected ?? null, + poison: item.poison ?? [], + }), + ); + } + return json(res, { results, coverage: suiteCoverage(results, menu) }); + } + if (url.pathname === "/api/tape") { + const events = await readTape(packageDir); + return json(res, { events, sessions: toSessions(events) }); + } + if (url.pathname === "/" || url.pathname === "/index.html") { + const html = await readFile(`${UI_DIR}index.html`, "utf-8"); + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + return res.end(html); + } + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + } catch (err) { + json(res, { error: err.message }, 500); + } + }); + return new Promise((resolve) => { + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function loadAsks(asksPath, validIds) { + if (!asksPath) return []; + try { + return parseAsks(await readFile(asksPath, "utf-8"), { + validateIds: validIds, + }); + } catch (err) { + if (err && err.code === "ENOENT") return []; + throw err; + } +} + +function json(res, payload, status = 200) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (chunk) => { + data += chunk; + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} diff --git a/packages/context-control/lib/tape.mjs b/packages/context-control/lib/tape.mjs new file mode 100644 index 00000000..f44ee500 --- /dev/null +++ b/packages/context-control/lib/tape.mjs @@ -0,0 +1,54 @@ +// Replay: parse a .ghost/.events NDJSON tape into a session timeline. +// The tape is ground truth — real agents' asks, pulls, and re-gathers. +// Malformed lines are skipped, same posture as ghost pulse. +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function readTape(packageDir) { + let raw; + try { + raw = await readFile(join(packageDir, ".events"), "utf-8"); + } catch (err) { + if (err && err.code === "ENOENT") return []; + throw err; + } + const events = []; + for (const line of raw.split("\n")) { + if (line.trim().length === 0) continue; + try { + const parsed = JSON.parse(line); + if (typeof parsed?.ts !== "string") continue; + if (parsed.event === "gather" && Array.isArray(parsed.menu)) { + events.push(parsed); + } else if (parsed.event === "pull" && Array.isArray(parsed.ids)) { + events.push(parsed); + } + } catch { + // Skip malformed tape lines; replay should still be useful. + } + } + return events; +} + +/** + * Group the flat tape into sessions: a gather opens a session, the pulls + * that follow (until the next gather) belong to it. Leading orphan pulls + * form a headless session so nothing on the tape is hidden. + */ +export function toSessions(events) { + const sessions = []; + let current = null; + for (const event of events) { + if (event.event === "gather") { + current = { gather: event, pulls: [] }; + sessions.push(current); + } else { + if (!current) { + current = { gather: null, pulls: [] }; + sessions.push(current); + } + current.pulls.push(event); + } + } + return sessions; +} diff --git a/packages/context-control/package.json b/packages/context-control/package.json new file mode 100644 index 00000000..04f36a72 --- /dev/null +++ b/packages/context-control/package.json @@ -0,0 +1,12 @@ +{ + "name": "@design-intelligence/context-control", + "private": true, + "type": "module", + "version": "0.0.1", + "bin": { + "context-control": "./cli.mjs" + }, + "scripts": { + "test": "vitest run" + } +} diff --git a/packages/context-control/test/context-control.test.ts b/packages/context-control/test/context-control.test.ts new file mode 100644 index 00000000..7a13bbcd --- /dev/null +++ b/packages/context-control/test/context-control.test.ts @@ -0,0 +1,181 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { initFingerprintPackage } from "../../ghost/src/scan/fingerprint-package.js"; +import { parseAsks } from "../lib/bench.mjs"; +import { parseIdReply } from "../lib/model.mjs"; +import { + consistency, + jaccard, + precisionRecall, + selectionRates, + suiteCoverage, +} from "../lib/score.mjs"; +import { toSessions } from "../lib/tape.mjs"; + +describe("jaccard", () => { + it("is 1 for identical sets and for empty vs empty", () => { + expect(jaccard(["a", "b"], ["b", "a"])).toBe(1); + expect(jaccard([], [])).toBe(1); + }); + it("is 0 for disjoint sets", () => { + expect(jaccard(["a"], ["b"])).toBe(0); + }); + it("counts overlap over union", () => { + expect(jaccard(["a", "b", "c"], ["b", "c", "d"])).toBe(0.5); + }); +}); + +describe("consistency", () => { + it("is 1 for a single trial", () => { + expect(consistency([["a"]])).toBe(1); + }); + it("averages pairwise jaccard", () => { + const value = consistency([["a", "b"], ["a", "b"], ["a"]]); + expect(value).toBeCloseTo((1 + 0.5 + 0.5) / 3); + }); +}); + +describe("precisionRecall", () => { + it("returns no scores without an expected set", () => { + expect(precisionRecall([["a"]], null)).toEqual({}); + expect(precisionRecall([["a"]], [])).toEqual({}); + }); + it("averages retrieval scores across trials", () => { + const scores = precisionRecall( + [ + ["a", "x"], + ["a", "b"], + ], + ["a", "b", "c"], + ["x"], + ); + expect(scores.precision).toBeCloseTo(0.75); + expect(scores.recall).toBeCloseTo(0.5); + expect(scores.poisonRate).toBe(0.5); + }); +}); + +describe("selectionRates", () => { + it("returns per-node fraction of trials", () => { + const menu = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const rates = selectionRates([["a", "b"], ["a"]], menu); + expect(rates).toEqual({ a: 1, b: 0.5, c: 0 }); + }); + it("ignores duplicate ids within one trial", () => { + const rates = selectionRates([["a", "a"]], [{ id: "a" }]); + expect(rates.a).toBe(1); + }); +}); + +describe("suiteCoverage", () => { + it("finds dead nodes", () => { + const menu = [{ id: "a" }, { id: "b" }, { id: "dead" }]; + const results = [{ trials: [["a", "b"]] }, { trials: [["a"]] }]; + const cov = suiteCoverage(results, menu); + expect(cov.dead).toEqual(["dead"]); + expect(cov.selectedEver).toBe(2); + }); +}); + +describe("parseAsks", () => { + it("parses the shared ask blocks and metadata", () => { + const asks = parseAsks( + [ + "## Ask 1 — dense settings", + "", + "Build a dense settings screen.", + "", + "expect: foundation.composition, foundation.controls", + "poison: context.conversation", + "", + "## Ask 2 — marketing email", + "", + "Build a marketing email header.", + "", + "discount: unprompted-dark-theme", + ].join("\n"), + ); + expect(asks).toHaveLength(2); + expect(asks[0]).toMatchObject({ + n: 1, + title: "dense settings", + ask: "Build a dense settings screen.", + expected: ["foundation.composition", "foundation.controls"], + poison: ["context.conversation"], + }); + expect(asks[1].discount).toEqual(["unprompted-dark-theme"]); + }); + it("rejects expected or poison ids outside the menu", () => { + expect(() => + parseAsks("## Ask 1 — bad id\n\nBuild it.\n\nexpect: missing", { + validateIds: new Set(["known"]), + }), + ).toThrow("ask 1 references unknown node id: missing"); + }); +}); + +describe("demo asks", () => { + it("only expects selectable default-skeleton nodes", async () => { + const root = resolve(import.meta.dirname, "../../.."); + const dir = await mkdtemp(join(tmpdir(), "context-control-test-")); + try { + const initialized = await initFingerprintPackage( + join(dir, ".ghost"), + root, + ); + const nodeIds = new Set( + initialized.written + .filter((file) => file.endsWith(".md") && file !== "glossary.md") + .map((file) => file.slice(0, -3)), + ); + nodeIds.delete("brand"); + const parsed = parseAsks( + await readFile( + resolve(root, "packages/context-control/demo/asks.md"), + "utf-8", + ), + { validateIds: nodeIds }, + ); + expect(parsed.every((ask) => ask.ask.length > 0)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("parseIdReply", () => { + it("parses a bare JSON array", () => { + expect(parseIdReply('["a", "b.c"]')).toEqual(["a", "b.c"]); + }); + it("tolerates code fences and prose around the array", () => { + expect( + parseIdReply('Here you go:\n```json\n["foundation.motion"]\n```'), + ).toEqual(["foundation.motion"]); + }); + it("drops non-string entries and returns [] for garbage", () => { + expect(parseIdReply('["a", 3, null]')).toEqual(["a"]); + expect(parseIdReply("no array here")).toEqual([]); + expect(parseIdReply('{"ids": true}')).toEqual([]); + }); +}); + +describe("toSessions", () => { + it("groups pulls under the preceding gather", () => { + const sessions = toSessions([ + { event: "gather", ts: "t1", menu: ["a"] }, + { event: "pull", ts: "t2", ids: ["a"] }, + { event: "gather", ts: "t3", menu: ["a"], ask: "x" }, + { event: "pull", ts: "t4", ids: ["a"] }, + { event: "pull", ts: "t5", ids: ["a"] }, + ]); + expect(sessions).toHaveLength(2); + expect(sessions[0].pulls).toHaveLength(1); + expect(sessions[1].pulls).toHaveLength(2); + }); + it("collects orphan pulls into a headless session", () => { + const sessions = toSessions([{ event: "pull", ts: "t1", ids: ["a"] }]); + expect(sessions[0].gather).toBeNull(); + }); +}); diff --git a/packages/context-control/ui/index.html b/packages/context-control/ui/index.html new file mode 100644 index 00000000..5f6da428 --- /dev/null +++ b/packages/context-control/ui/index.html @@ -0,0 +1,301 @@ + + + + + +context-control + + + +
+

context-control · selection is the unit

+ + +
+
+
+ + + +
idkinddescription — the retrieval payload
+
+ + + + +
+ + + + + diff --git a/packages/context-control/vitest.config.ts b/packages/context-control/vitest.config.ts new file mode 100644 index 00000000..8b5840ac --- /dev/null +++ b/packages/context-control/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 4c9ed295..69722a1b 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -9,7 +9,8 @@ Today those decisions live in reviewers' heads: "that's not our voice," again, on every surface. The agent that built the thing never saw them. Ghost writes them down once, where the agent looks first. -[Documentation](https://block.github.io/ghost/) +[Documentation](https://block.github.io/ghost/) · +[Repo](https://github.com/block/ghost) ## Install @@ -48,28 +49,10 @@ ghost pulse # while tuning: see what agents reached for ``` Run `ghost --help` for the core workflow and `ghost --help` for -flags. - -## Shape - -```text -.ghost/ - manifest.yml # schema + package id - glossary.md # your kind vocabulary + what each kind means - index.md # the curated front door - principle.trust.md # a brand truth of kind `principle` - asset.logo.md # a truth that may point at concrete materials - checks/ # optional review assertions; separate from your truths -``` - -A node is markdown with a `description`, optional `materials`, and a prose -body. `materials` is a list of paths or URLs pointing at the concrete stuff -the truth is about. - -Checks live under `.ghost/checks/` (scaffold with `ghost checks init`) and -declare `references` to the truths they review. `ghost review` reads a diff, -matches touched files to node `materials`, offers relevant checks, and emits -an advisory packet for the host agent to weigh. +flags. The [CLI reference](https://block.github.io/ghost/docs/cli) covers +every command; [Getting Started](https://block.github.io/ghost/docs/getting-started) +covers the full model. Stuck? See +[Troubleshooting](https://block.github.io/ghost/docs/troubleshooting). ## Library diff --git a/packages/ghost/src/commands/fingerprint-commands.ts b/packages/ghost/src/commands/fingerprint-commands.ts index 47337781..20e12e34 100644 --- a/packages/ghost/src/commands/fingerprint-commands.ts +++ b/packages/ghost/src/commands/fingerprint-commands.ts @@ -7,7 +7,6 @@ import { resolveFingerprintPackage, } from "../fingerprint.js"; import { detectFileKind, lintDetectedFileKind } from "../scan/file-kind.js"; -import { resolveGhostDirDefault } from "../scan/index.js"; import { failFromError } from "./errors.js"; import { registerInitCommand } from "./init-command.js"; @@ -32,10 +31,9 @@ export function registerFingerprintCommands(cli: CAC): void { .option("--format ", "Output format: cli or json", { default: "cli" }) .action(async (path: string | undefined, opts) => { try { - const ghostDir = ghostDirFromEnv(); const exactPackage = typeof opts.package === "string" ? opts.package : undefined; - const packagePath = exactPackage ?? path ?? ghostDir; + const packagePath = exactPackage ?? path; const target = resolveFingerprintPackage( packagePath, process.cwd(), @@ -64,10 +62,6 @@ export function registerFingerprintCommands(cli: CAC): void { registerInitCommand(cli); } -function ghostDirFromEnv(): string { - return resolveGhostDirDefault(); -} - function writeLintReport(report: LintReport, format: unknown): void { if (format === "json") { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/packages/ghost/src/commands/init-command.ts b/packages/ghost/src/commands/init-command.ts index 02946d3c..85a70448 100644 --- a/packages/ghost/src/commands/init-command.ts +++ b/packages/ghost/src/commands/init-command.ts @@ -2,7 +2,6 @@ import type { CAC } from "cac"; import { UsageError } from "#ghost-core"; import { initFingerprintPackage } from "../fingerprint.js"; import { addChecksDir } from "../scan/check-scaffold.js"; -import { resolveGhostDirDefault } from "../scan/index.js"; import { getInitBody } from "../scan/templates.js"; import { failFromError } from "./errors.js"; @@ -38,8 +37,6 @@ export function registerInitCommand(cli: CAC): void { } const exactPackage = typeof opts.package === "string" ? opts.package : undefined; - const ghostDir = - exactPackage === undefined ? ghostDirFromEnv() : undefined; const withIds = parseWithCapabilities(opts.with); for (const id of withIds) { if (id !== "checks") { @@ -57,7 +54,7 @@ export function registerInitCommand(cli: CAC): void { } const result = await initFingerprintPackage( - exactPackage ?? ghostDir, + exactPackage, process.cwd(), { ...(typeof opts.template === "string" @@ -114,10 +111,6 @@ export function registerInitCommand(cli: CAC): void { }); } -function ghostDirFromEnv(): string { - return resolveGhostDirDefault(); -} - function parseWithCapabilities(withOpt: unknown): string[] { if (typeof withOpt !== "string" || withOpt.trim().length === 0) return []; return [ diff --git a/packages/ghost/src/scan/check-files.ts b/packages/ghost/src/scan/check-files.ts index dacbae9e..75393fb1 100644 --- a/packages/ghost/src/scan/check-files.ts +++ b/packages/ghost/src/scan/check-files.ts @@ -10,9 +10,6 @@ import { /** Reserved package-root directory holding review checks. */ export const GHOST_CHECKS_DIR = "checks"; -/** Pre-flat location for checks; detected only to warn on stale packages. */ -const LEGACY_HAUNTS_DIR = "haunts"; - const CHECK_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; export interface LoadedCheck { @@ -38,8 +35,6 @@ export async function loadCheckFiles( const checks = new Map(); const invalid: LoadedCheckFiles["invalid"] = []; - await detectLegacyHauntsDir(packageDir, invalid); - const checksDir = join(packageDir, GHOST_CHECKS_DIR); let entries: Array<{ name: string; isDirectory(): boolean }>; try { @@ -96,22 +91,6 @@ export async function loadCheckFiles( return { hasChecksDir: true, checks, invalid }; } -async function detectLegacyHauntsDir( - packageDir: string, - invalid: LoadedCheckFiles["invalid"], -): Promise { - try { - await readdir(join(packageDir, LEGACY_HAUNTS_DIR)); - } catch { - return; - } - invalid.push({ - file: LEGACY_HAUNTS_DIR, - message: - "the haunts/ directory is no longer supported; move haunts/checks/*.md to checks/ and delete haunts/", - }); -} - function referencesFromFrontmatter( frontmatter: Record | null, ): string[] { diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index a983850a..0031e670 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -12,10 +12,10 @@ import { FINGERPRINT_INTENT_FILENAME, FINGERPRINT_INVENTORY_FILENAME, FINGERPRINT_MANIFEST_FILENAME, - FINGERPRINT_PACKAGE_DIR, GHOST_GLOSSARY_FILENAME, } from "./constants.js"; import { loadFingerprintPackage } from "./fingerprint-package-loader.js"; +import { resolveGhostDirDefault } from "./package-paths.js"; import { DEFAULT_TEMPLATE_NAME, type GhostInitTemplate, @@ -78,11 +78,19 @@ export interface InitFingerprintPackageResult { written: string[]; } +/** + * Resolve the fingerprint package directory. `dirArg` (an explicit + * `--package
`) always wins and is used exactly as given — it may be + * absolute or relative, unlike `GHOST_PACKAGE_DIR`. When `dirArg` is + * omitted, `GHOST_PACKAGE_DIR` is honored so every command — not just `init` + * and `validate` — respects a host-configured package location. Falls back + * to the default `.ghost` when neither is set. + */ export function resolveFingerprintPackage( dirArg: string | undefined, cwd = process.cwd(), ): FingerprintPackagePaths { - const dir = resolve(cwd, dirArg ?? FINGERPRINT_PACKAGE_DIR); + const dir = resolve(cwd, dirArg ?? resolveGhostDirDefault()); const packageDir = dir; return { dir, diff --git a/packages/ghost/src/scan/node-files.ts b/packages/ghost/src/scan/node-files.ts index 85918b6b..8f03e409 100644 --- a/packages/ghost/src/scan/node-files.ts +++ b/packages/ghost/src/scan/node-files.ts @@ -11,8 +11,7 @@ import { * Reserved package-root entries that are never nodes: the manifest (the * package anchor), the glossary, `materials/` (bundled materials), and * `checks/` (review checks — feed-back only, never generation context). The - * list is closed. `haunts/` is the legacy pre-flat checks location, kept - * reserved so stale packages do not misload its contents as nodes. + * list is closed. */ const RESERVED_ROOT_ENTRIES = new Set([ FINGERPRINT_MANIFEST_FILENAME, @@ -20,7 +19,6 @@ const RESERVED_ROOT_ENTRIES = new Set([ GHOST_GLOSSARY_FILENAME, GHOST_MATERIALS_DIR, "checks", - "haunts", ]); export interface LoadedNodeFiles { diff --git a/packages/ghost/src/scan/templates.ts b/packages/ghost/src/scan/templates.ts index 0dcbf0f4..ee4517ef 100644 --- a/packages/ghost/src/scan/templates.ts +++ b/packages/ghost/src/scan/templates.ts @@ -44,14 +44,14 @@ function gitignoreFile(): TemplateFile { /** * The minimal starter: a manifest, a package-level glossary declaring the - * starter kind vocabulary, and a package-root `index.md` node. Additional + * starter kind vocabulary, and a package-root `index.md` cover. Additional * truths are plain markdown nodes; optional material locators live on the node * that explains them. Checks are opt-in via `ghost checks init`. */ const MINIMAL_TEMPLATE: GhostInitTemplate = { name: "minimal", description: - "Minimal node package: manifest + glossary + a starter index node.", + "Minimal node package: manifest + glossary + a starter cover node.", async files() { const medianFile = await medianTemplateFile(); return [ @@ -115,18 +115,18 @@ Replace this placeholder prose with two things: **The non-negotiables.** The handful of truths that apply to every task, every medium, no matter what else is gathered — hard invariants, the anti-goals, the -one-sentence stance. This node is the only one an agent is told to always pull, -so anything that must never be missed belongs here (stated briefly; link out to -the full node by id for depth). +one-sentence stance. Anything that must never be missed belongs here, stated +briefly; link out to the full node by id for depth. **How to read the rest.** What this fingerprint covers, how its kinds organize the corpus, and where the fingerprint deliberately stays silent — including, if you want one, a stricter silence posture ("when this fingerprint is silent on X, ask a human") that overrides the default proceed-provisionally behavior. -\`index\` is an ordinary node in every mechanical sense — everything below the -\`---\` is its body; the frontmatter above is the retrieval description. Its -privilege is pure convention: the recipes tell agents to pull it first. +\`index\` is this package's manifest-declared cover: \`ghost gather\` inlines its +body before the menu and excludes it from selection. Its privilege comes from +\`cover: index\` in \`manifest.yml\`, not from the filename. You may rename it; +update the manifest's \`cover\` id at the same time. The glossary declares the kind vocabulary. A node's kind comes from its filename prefix: \`principle.density.md\` has kind \`principle\` and slug @@ -143,7 +143,7 @@ it applies — never a filing destination. /** * The composition starter: everything in `minimal`, plus a worked composition * ladder — an invariants floor (`principle.composition`), one bound/open - * pattern, and an index that teaches the convention. For teams whose + * pattern, and a cover that teaches the convention. For teams whose * fingerprint must steer *what agents build*, not only what they say. * * The ladder is an authoring convention, not a schema: patterns state what is diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index b659cee1..e8a5104c 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -81,7 +81,7 @@ evidence, coverage gaps, and the diff. The host agent renders findings. | Verb | Purpose | |---|---| -| `ghost init` | Scaffold `.ghost/` with the skeleton starter: manifest, glossary, `brand.md`, foundation chapters, context nodes, and the cliche floor. `--template minimal` writes only the small manifest/glossary/index starter. `--with checks` also adds the checks directory. | +| `ghost init` | Scaffold `.ghost/` with the skeleton starter: manifest, glossary, a `brand.md` cover, foundation chapters, context nodes, and the cliche floor. `--template minimal` writes a smaller cover-led starter. `--with checks` also adds the checks directory. | | `ghost checks init` | Scaffold `.ghost/checks/` with an example review assertion. | | `ghost validate [file-or-dir]` | Validate manifest, nodes, material locators, check references, and glossary kind prefixes. | | `ghost gather [ask…] [--format json]` | Emit the node menu for selection plus coverage line; log exposed ids. | diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index da7416c7..43129dc8 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -166,10 +166,10 @@ pattern to imitate with the human's real truths. expose sensitive third-party details — then show the count only." Models imitate reasoning as well as outputs. -State a silence posture somewhere the agent always reads (usually `index.md`): -when the fingerprint is silent, proceed provisionally from local conventions -only when safe, and ask before brand-defining, irreversible, legal, -privacy-sensitive, or high-risk choices. +State the silence posture on the manifest-declared cover, which `gather` +inlines for every task: when the fingerprint is silent, proceed provisionally +from local conventions only when safe, and ask before brand-defining, +irreversible, legal, privacy-sensitive, or high-risk choices. ## Never diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index 0ff913e0..f0e83145 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -9,9 +9,8 @@ A brief is an ephemeral steering packet for the generating pass. It is not a new schema and is never written back into `.ghost/`. 1. Run `ghost gather --format json` and select against descriptions. -2. Pull a small set: **3–5 nodes is normal; 10 is a bad selection** unless the - task is unusually broad. Always include `index` unless already read this - session. +2. The cover is already in context. Pull a small set from the menu: **3–5 nodes + is normal; 10 is a bad selection** unless the task is unusually broad. 3. Prefer concrete nodes: `materials`, substantial fenced examples, or a `## Skeleton`. If there is **no concrete material for this surface**, the readiness ceiling is **Yellow**. diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index 23bc3ed2..d39c066e 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -88,6 +88,21 @@ safe before what to do. One calm next step. No "Oops," no exclamation points, no blame on the user or the network. ``` +## Write patterns as bound and open + +A pattern node binds part of a reusable structure and leaves the rest open. +State both explicitly: + +- **Bound** — what the agent should not redecide: the structural moves fixed + regardless of task (what renders first, what appears exactly once, what may + never compete for attention). +- **Open** — where the agent may choose, within limits (which evidence shape + fits, how tone flexes, where secondary content may go). + +If everything is bound, the node is a template, not a pattern. If nothing is +bound, it is vibes, not steering. A pattern may also `Refines:` a broader +principle by id; when the two conflict, the principle wins. + ## Add Skeletons when the opening structure matters A `## Skeleton` section contains the literal opening structure the agent should @@ -252,7 +267,7 @@ strongest form that fixes the observed failure. | If the agent keeps... | Author... | | --- | --- | -| missing the truth | sharper `description` / `index` mention | +| missing the truth | sharper `description`; move universal truth to the cover | | inventing values | `asset.*` node with materials and exact names | | producing generic output | `anti-goal.*` replacement plus annotated `exemplar.*` | | choosing the wrong structure | `pattern.*` with bound/open and a `## Skeleton` | @@ -261,6 +276,14 @@ strongest form that fixes the observed failure. | making bad tradeoffs | `decision.*` trace | | producing correct but forgettable work | scoped `concept.*` | +A `concept.*` node holds a one-time creative move — a launch hook, a +campaign idea, a specific non-median leap the corpus would not have produced +by recombining its other truths. Scope it explicitly to the task or surface +it was written for, keep it distinct from invariants, and do not generalize +it into permanent brand law. If it proves reusable later, promote it into an +exemplar or pattern deliberately; do not let a one-off default into durable +truth by accumulation. + Ask while authoring: - What complete artifact shows the brand at its best? @@ -291,19 +314,16 @@ ghost init # scaffolds the steering starter ghost validate ``` -`ghost init` seeds the steering starter: the manifest, a starter `glossary.md` -(with suggested kinds you keep, rename, or replace), the package-root -`index.md`, and worked demo nodes for stance, composition, anti-goals, patterns, -exemplars, materials, and decisions. Replace demo claims, paths, examples, and -decisions with real product truth before using it to steer generation. Use -`ghost init --template minimal` when you only want the small -manifest/glossary/index starter. - -Write `index.md` as the human-curated front door: non-negotiables that apply to -every task, what this fingerprint covers, how its kinds organize the corpus, and -any stricter silence posture. It is an ordinary node mechanically, but by -convention agents pull it first — anything that must never be missed belongs -here. +`ghost init` seeds the skeleton starter: the manifest, a starter +`glossary.md`, a `brand.md` cover, foundation chapters with open questions, and +the model cliche floor. Replace open questions with real product truth before +using it to steer generation. Use `ghost init --template minimal` for a +manifest, glossary, cover, and cliche floor. + +Write the manifest-declared cover as the human-curated front door: +non-negotiables that apply to every task, what the fingerprint covers, and any +stricter silence posture. `ghost gather` inlines it before the menu, so anything +that must never be missed belongs there. Nodes may carry a `materials` list in frontmatter: repo-relative paths/globs or HTTPS URLs for the concrete materials the prose governs. Put brand-owned diff --git a/packages/ghost/src/skill-bundle/references/self-check.md b/packages/ghost/src/skill-bundle/references/self-check.md index 5787b26a..83bbb2c3 100644 --- a/packages/ghost/src/skill-bundle/references/self-check.md +++ b/packages/ghost/src/skill-bundle/references/self-check.md @@ -60,6 +60,6 @@ When you cannot answer the grounding questions: A genuinely silent fingerprint is an expected state, not a blocker. When it does not cover the task, say so plainly and proceed with provisional local reasoning -when safe; label it non-Ghost-backed. If the fingerprint's `index` node declares -a stricter silence posture, honor it over this default. Ask a human before +when safe; label it non-Ghost-backed. If the fingerprint's cover declares a stricter +silence posture, honor it over this default. Ask a human before high-risk or brand-defining choices. diff --git a/packages/ghost/src/skill-bundle/references/steering-audit.md b/packages/ghost/src/skill-bundle/references/steering-audit.md index 9a7c1070..449ce996 100644 --- a/packages/ghost/src/skill-bundle/references/steering-audit.md +++ b/packages/ghost/src/skill-bundle/references/steering-audit.md @@ -34,16 +34,16 @@ Report first: | Row | Status | Evidence | Next move | | --- | --- | --- | --- | -| Retrieval | strong / weak | descriptions, ids, `index` | sharpen descriptions or mention cold nodes in `index` | +| Retrieval | strong / weak | descriptions, ids, cover | sharpen descriptions or move universal truth to the cover | | Concreteness | strong / thin | materials, fenced examples, Skeletons | add concrete locators, exemplars, or opening structures | | Anti-goals | present / missing / vague | `anti-goal.*`, review packet | write not-X-instead-Y replacements and material locators | | Consistency | clean / conflicting | concrete bodies vs rules | update stale examples; examples average with rules | -| Stance | present / missing | `index`, `principle.*` | write forced-choice principles | +| Stance | present / missing | cover, `principle.*` | write forced-choice principles | | Materials | present / missing | `materials`, inspect-pointers | point at real assets/components/tokens | | Exemplars | annotated / unannotated / missing | fenced samples, screenshots | say what to copy and what is incidental | | Patterns | bound-open / loose / missing | `pattern.*`, Skeletons | state applies / bound / open and add a Skeleton when opening structure matters | | Checks | covered / partial / missing | checks/, probes, review packet | add checks/probes for high-risk invariants | -| Silence posture | defined / missing | `index` | say when to proceed provisionally or ask | +| Silence posture | defined / missing | cover | say when to proceed provisionally or ask | ## Task-level readiness diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index ce0d6641..296fda98 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -657,7 +657,7 @@ describe("ghost CLI", () => { expect(JSON.parse(lint.stdout).errors).toBe(0); }); - it("initializes a bundle with manifest and starter index node", async () => { + it("initializes a bundle with manifest and starter brand cover", async () => { const init = await runCli(["init"], dir); expect(init.code).toBe(0); @@ -1959,14 +1959,6 @@ describe("ghost CLI", () => { expect(result.code).toBe(2); expect(result.stderr).toContain("ghost checks init"); }); - - it("validate flags the legacy haunts/ directory", async () => { - await runCli(["init"], dir); - await mkdir(join(dir, ".ghost", "haunts", "checks"), { recursive: true }); - const result = await runCli(["validate"], dir); - expect(result.code).toBe(1); - expect(result.stdout).toContain("no longer supported"); - }); }); function parseTarEntries(buffer: Buffer): Map { diff --git a/packages/ghost/test/fingerprint-package.test.ts b/packages/ghost/test/fingerprint-package.test.ts index 2e060f6b..8a55e055 100644 --- a/packages/ghost/test/fingerprint-package.test.ts +++ b/packages/ghost/test/fingerprint-package.test.ts @@ -362,19 +362,6 @@ Replacement rule. }); }); - it("flags the legacy haunts/ directory", async () => { - await writeManifest(dir); - await mkdir(join(dir, "haunts", "checks"), { recursive: true }); - - const report = await lintFingerprintPackage(dir); - - expect(report.errors).toBe(1); - expect(report.issues[0]).toMatchObject({ - rule: "check-invalid", - path: "haunts", - }); - }); - it("gives index.md the uniform id `index` — no core mapping", async () => { await writeManifest(dir); await writeFile( diff --git a/packages/steering-eval/README.md b/packages/steering-control/README.md similarity index 89% rename from packages/steering-eval/README.md rename to packages/steering-control/README.md index 7593b829..3a2b559d 100644 --- a/packages/steering-eval/README.md +++ b/packages/steering-control/README.md @@ -1,4 +1,4 @@ -# steering-eval +# steering-control A before/after evaluation harness for Ghost fingerprints. It measures what handing an agent a `.ghost` fingerprint buys — output quality, run-to-run @@ -28,7 +28,7 @@ a `.ghost/` fingerprint package, and an agent that drives the loop (Claude Code, goose, Cursor — anything that can read a prompt file and write HTML). ```bash -npx steering-eval init # writes eval.config.json + asks.md templates +npx steering-control init # writes eval.config.json + asks.md templates ``` Fill in `eval.config.json`: @@ -85,10 +85,10 @@ The harness assembles prompts and keeps books. **Your agent generates.** Per cell (arm × ask), for k = 1..runsPerCell: ```bash -steering-eval prompt --run # writes out//ask-/run-.prompt.md +steering-control prompt --run # writes out//ask-/run-.prompt.md # → hand the prompt file to a FRESH agent context; it writes run-.html # (gather arm: the agent runs `ghost pull` itself mid-task — that IS the test) -steering-eval finish # slices the selection tape, records context sizes +steering-control finish # slices the selection tape, records context sizes ``` Gather-arm runs are strictly serialized — the harness hard-fails if a run @@ -98,9 +98,9 @@ runs corrupt attribution. Then, zero LLM calls: ```bash -steering-eval shoot # screenshots every out/**/*.html via agent-browser (idempotent) -steering-eval score # out/metrics.json — distributions, consistency, retrieval -steering-eval report # out/report.html — self-contained, rebuildable from out/ alone +steering-control shoot # screenshots every out/**/*.html via agent-browser (idempotent) +steering-control score # out/metrics.json — distributions, consistency, retrieval +steering-control report # out/report.html — self-contained, rebuildable from out/ alone ``` ## What the report shows diff --git a/packages/steering-eval/cli.mjs b/packages/steering-control/cli.mjs similarity index 98% rename from packages/steering-eval/cli.mjs rename to packages/steering-control/cli.mjs index 7f9fa2ab..c180e26b 100755 --- a/packages/steering-eval/cli.mjs +++ b/packages/steering-control/cli.mjs @@ -28,7 +28,7 @@ try { } function usage(exitCode = 0) { - console.log(`usage: steering-eval [args] + console.log(`usage: steering-control [args] Commands: init diff --git a/packages/steering-eval/default-tells.json b/packages/steering-control/default-tells.json similarity index 100% rename from packages/steering-eval/default-tells.json rename to packages/steering-control/default-tells.json diff --git a/packages/steering-eval/fixtures/metrics.sample.json b/packages/steering-control/fixtures/metrics.sample.json similarity index 100% rename from packages/steering-eval/fixtures/metrics.sample.json rename to packages/steering-control/fixtures/metrics.sample.json diff --git a/packages/steering-eval/lib/arms.mjs b/packages/steering-control/lib/arms.mjs similarity index 100% rename from packages/steering-eval/lib/arms.mjs rename to packages/steering-control/lib/arms.mjs diff --git a/packages/steering-eval/lib/asks.mjs b/packages/steering-control/lib/asks.mjs similarity index 100% rename from packages/steering-eval/lib/asks.mjs rename to packages/steering-control/lib/asks.mjs diff --git a/packages/steering-eval/lib/config.mjs b/packages/steering-control/lib/config.mjs similarity index 98% rename from packages/steering-eval/lib/config.mjs rename to packages/steering-control/lib/config.mjs index d4af02e5..11622683 100644 --- a/packages/steering-eval/lib/config.mjs +++ b/packages/steering-control/lib/config.mjs @@ -122,7 +122,7 @@ function invalid(field) { fail(`missing/invalid field: ${field}\nExample: ${EXAMPLES[field]}`); } function fail(message) { - throw new Error(`steering-eval config error: ${message}`); + throw new Error(`steering-control config error: ${message}`); } function positiveInteger(value) { return Number.isInteger(value) && value > 0; diff --git a/packages/steering-eval/lib/report.mjs b/packages/steering-control/lib/report.mjs similarity index 95% rename from packages/steering-eval/lib/report.mjs rename to packages/steering-control/lib/report.mjs index 9b4c0c16..ee3f6425 100644 --- a/packages/steering-eval/lib/report.mjs +++ b/packages/steering-control/lib/report.mjs @@ -147,7 +147,7 @@ async function askSection(outDir, ask, askTitle, cells) { .map((c) => { const s = c.scores; const con = c.consistency ?? {}; - return `${esc(c.arm)}${s.min} / ${s.median} / ${s.max}${fmt(con.accentHue)}${fmt(con.radius)}${fmt(con.fontStack)}${(c.context?.tokensEstimate ?? 0).toLocaleString("en-US")}`; + return `${esc(c.arm)}${s.min} / ${s.median} / ${s.max}${fmt(con.accentHue)}${fmt(con.radius)}${fmt(con.fontStack)}${fmt(c.sameness)}${(c.context?.tokensEstimate ?? 0).toLocaleString("en-US")}`; }) .join("\n"); @@ -155,7 +155,7 @@ async function askSection(outDir, ask, askTitle, cells) {

Ask ${ask} \u2014 ${esc(askTitle)}

${rows.join("\n")}
- +${tableRows}
armtell score min/med/maxaccent-hue consistencyradiusfontcontext tokens
armtell score min/med/maxaccent-hue consistencyradiusfontsamenesscontext tokens
`; @@ -211,7 +211,7 @@ export async function renderReport(metricsJsonPath, outDir) { -steering-eval report +steering-control report + + +

Billing

+
+
+

Plan

Pro plan, billed monthly.

+

Usage

All quiet this cycle.

+

Invoices

Nothing due.

+
+
+

Fine print.

+ +`; + +const CARD_PAGE_OTHER_TEXT = CARD_PAGE.replace("Billing", "Receipts") + .replace("Pro plan, billed monthly.", "Starter plan, billed yearly.") + .replace("All quiet this cycle.", "Heavy usage this cycle.") + .replace("Nothing due.", "Two invoices due.") + .replace("Fine print.", "Different fine print entirely."); + +const LIST_PAGE = ` + + + + + +
+
+

A totally different shape

+
  • one
  • two
  • three
  • four
+
cell
+
+
+ +`; + +describe("structuralFingerprint", () => { + it("captures structure, layout, and custom properties, not text", () => { + const print = structuralFingerprint(CARD_PAGE); + expect(print.sequence).toContain( + "section|display:grid;gap:1rem;grid-template-columns:repeat(3, 1fr)", + ); + expect(print.headings).toEqual([1, 2, 2, 2]); + expect(print.customProps).toEqual(new Set(["--accent", "--radius"])); + expect(print.sequence.join(" ")).not.toContain("Billing"); + }); +}); + +describe("cellSameness", () => { + it("returns mean 1 for identical HTML strings", () => { + const { mean, pairs } = cellSameness([CARD_PAGE, CARD_PAGE, CARD_PAGE]); + expect(mean).toBe(1); + expect(pairs).toHaveLength(3); + }); + + it("returns a low mean for completely different structures", () => { + const { mean } = cellSameness([CARD_PAGE, LIST_PAGE]); + expect(mean).toBeLessThan(0.4); + }); + + it("returns a high mean when only text content differs", () => { + const { mean } = cellSameness([CARD_PAGE, CARD_PAGE_OTHER_TEXT]); + expect(mean).toBeGreaterThan(0.95); + }); + + it("returns null mean when there are fewer than two runs", () => { + expect(cellSameness([CARD_PAGE]).mean).toBeNull(); + expect(cellSameness([]).mean).toBeNull(); + }); +}); diff --git a/packages/steering-eval/test/steering-eval.test.ts b/packages/steering-control/test/steering-control.test.ts similarity index 96% rename from packages/steering-eval/test/steering-eval.test.ts rename to packages/steering-control/test/steering-control.test.ts index 642aa337..ba5d7145 100644 --- a/packages/steering-eval/test/steering-eval.test.ts +++ b/packages/steering-control/test/steering-control.test.ts @@ -13,7 +13,7 @@ let dir: string; beforeEach(() => { dir = join( tmpdir(), - `steering-eval-${Date.now()}-${Math.random().toString(36).slice(2)}`, + `steering-control-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); mkdirSync(dir, { recursive: true }); }); @@ -49,7 +49,7 @@ function writeProject() { return loadConfig(dir); } -describe("steering-eval", () => { +describe("steering-control", () => { it("teaches config validation errors", () => { writeFileSync( join(dir, "eval.config.json"), @@ -109,7 +109,7 @@ describe("steering-eval", () => { }); it("hard-fails when a lock exists", () => { - const cli = resolve("packages/steering-eval/cli.mjs"); + const cli = resolve("packages/steering-control/cli.mjs"); writeProject(); mkdirSync(join(dir, "out"), { recursive: true }); writeFileSync(join(dir, "out", ".run.lock"), "{}\n"); diff --git a/packages/vessel-light/.ghost/anti-goal.tells.md b/packages/vessel-light/.ghost/anti-goal.tells.md index 73ed11c8..5884a6b1 100644 --- a/packages/vessel-light/.ghost/anti-goal.tells.md +++ b/packages/vessel-light/.ghost/anti-goal.tells.md @@ -4,7 +4,9 @@ description: "Failure modes specific to this brand's own signature — gather al These are the near-misses of Vessel's own signature: outputs that got close enough to cite the brand and still missed it. Each reject names its -replacement. +replacement. When a tell fires, the fix direction is always toward the +token, the role, or deletion — never toward adding a compensating style on +top of the miss. Reject rectangular buttons. A rectangular button is not a variant; it is a different design system → controls take `--radius-control`, the pill. @@ -21,9 +23,8 @@ Vessel; it is a different brand. Reject custom shadows that sit near a tier → the exact tier — card, popover, or modal — chosen by interaction importance. -Reject borrowing `--shadow-btn` or `--shadow-kbd` for layout → component -shadows belong to the primitives that own them; layout elevation comes from -the three tiers. +Reject borrowing `--shadow-btn` for layout → component shadows belong to the +primitives that own them; layout elevation comes from the three tiers. Reject editorial display type in product UI, and product type timidity in editorial → each register keeps its own vocabulary; a modal title is not a diff --git a/packages/vessel-light/.ghost/checks/shape-matches-job.md b/packages/vessel-light/.ghost/checks/shape-matches-job.md new file mode 100644 index 00000000..f5d78bb0 --- /dev/null +++ b/packages/vessel-light/.ghost/checks/shape-matches-job.md @@ -0,0 +1,43 @@ +--- +name: Shape matches the job +description: Flags views composed for the wrong reader job — a form styled as a table, an announcement headline over a reviewable artifact, a live state presented as settled fact, a second primary, or a skeleton loader standing in for content. +severity: high +references: + - grammar.job + - grammar.deletion + - grammar.hierarchy +--- + +Apply this check to diffs that add or restructure a view. Classify the +reader's job first — what they do next with the view — then judge the +composition against the ref that job routes to. These flags catch the +template-convergence failure: a plausible layout applied to a job it does +not fit. + +Flag a view whose job routes to one ref but whose composition imitates +another — a submit-and-done task laid out as a dashboard of cards, a +records-scanning task rendered as prose sections, a stop-and-decide moment +inlined into the page instead of interrupting it. (`grammar.job`) + +Flag an announcement headline in display or headline type above an artifact +the reader will review — "Here's your draft", "Your plan is ready", "All +set". The artifact opens the view; status lives in quiet metadata or +nowhere. (`grammar.job`) + +Flag a live or ongoing value presented without its freshness — no timestamp +or "updated" line in muted metadata near the figure. A moving value styled +as a settled fact misleads. (`grammar.job`) + +Flag recommendation or verdict framing where the decision belongs to the +reader and the system cannot honestly rank the options. Present the +material; withhold the verdict. (`grammar.job`) + +Flag a second primary-variant action in a view, and flag a promoted primary +on a view that honestly earns none — a steady status view, an open +comparison. Zero is a valid spend of `--primary-budget`. +(`grammar.hierarchy`) + +Flag skeleton loaders, placeholder shimmer, or staged construction where +content will land — and any element that cannot name what breaks if it is +removed. The fix is demotion or deletion, never more emphasis. +(`grammar.deletion`) diff --git a/packages/vessel-light/.ghost/grammar.color-roles.md b/packages/vessel-light/.ghost/grammar.color-roles.md index 01f24754..d06607cb 100644 --- a/packages/vessel-light/.ghost/grammar.color-roles.md +++ b/packages/vessel-light/.ghost/grammar.color-roles.md @@ -11,8 +11,8 @@ may not author around them. Raw color values are implementation detail, never product language. Author with semantic roles: `background`, `foreground`, `card`, `popover`, -`primary`, `secondary`, `muted`, `accent`, `border`, `input`, `ring`, and the -status roles. If a container needs a color, it first needs a role. +`primary`, `secondary`, `muted`, `border`, `input`, `ring`, and the status +roles. If a container needs a color, it first needs a role. The status roles — destructive, success, warning, info — exist only when meaning demands them. Destructive means destructive or error. Success means diff --git a/packages/vessel-light/.ghost/grammar.deletion.md b/packages/vessel-light/.ghost/grammar.deletion.md new file mode 100644 index 00000000..a60c125c --- /dev/null +++ b/packages/vessel-light/.ghost/grammar.deletion.md @@ -0,0 +1,35 @@ +--- +description: "The deletion pass — every element must name what breaks if it goes; the fix for a crowded view is always demotion or deletion, never more emphasis; surfaces arrive settled, with no skeleton theater. Gather as the final pass over any composition, and whenever a view feels crowded, busy, or dressed up." +--- + +Restraint is not a mood; it is a test every element has to pass. Before a +view ships, run the pass: each label, divider, icon, caption, and helper +line must name what breaks if it is removed. If nothing breaks, delete it. +Convention is not a reason — "forms usually have this" keeps nothing. + +The test has a direction. When a view is crowded, or two elements compete +for attention, the fix is always demotion or deletion — never adding +emphasis to the loser. Raising the volume of one element to beat another +starts an arms race the composition always loses; removing the weaker claim +ends it. + +Some deletions we have already made, permanently: no decorative dividers +where a gap step states the relationship; no icons that restate their +adjacent label; no tooltips explaining obvious controls; no onboarding +chrome on routine views; no credibility costume — a small factual source or +timestamp line beats a dashboard of gauges. + +A view also arrives settled. No skeleton loaders, no placeholder shimmer +promising content that is not there — a fake page is hedging rendered as UI. +While work is genuinely pending, show the smallest true statement (a quiet +loading state, per the motion doctrine's loop exception) and nothing else. +When content lands, it lands in its final position: no reflow, no staggered +construction, no element arriving late to shift its neighbors. + +Do: a settings page whose every row survives the what-breaks question, with +one primary action and gaps doing the dividing. Never: the same page with +icon-per-row decoration, a divider under every group, a progress shimmer on +load, and a bolded second CTA competing for the eye. + +After everything deletable is gone, whatever remains is unmistakably the +point. diff --git a/packages/vessel-light/.ghost/grammar.hierarchy.md b/packages/vessel-light/.ghost/grammar.hierarchy.md index db66170f..1ba9e8dc 100644 --- a/packages/vessel-light/.ghost/grammar.hierarchy.md +++ b/packages/vessel-light/.ghost/grammar.hierarchy.md @@ -28,9 +28,13 @@ wants muted body or label, not a smaller custom font. If the prose needs emphasis, improve the sentence before adding a style. The control emphasis ladder is exactly five rungs: primary, secondary, -outline, ghost, link. One primary action per view is the rule; everything -else steps down the ladder. Primary is for the action the screen exists to -complete — if two buttons both look primary, the hierarchy failed. +outline, ghost, link. At most one primary action per view is the rule +(`--primary-budget: 1`); everything else steps down the ladder. Primary is +for the action the screen exists to complete — if two buttons both look +primary, the hierarchy failed. And some views honestly earn none: a steady +status view or an open comparison has no action the screen exists to +complete, and promoting one anyway puts the system's thumb on the scale. +Zero is a valid spend. Destructive is a meaning, not a rung: it is rare, it names the destructive act directly, and it is never borrowed for urgency, emphasis, or brand heat. @@ -40,6 +44,12 @@ guidance, not decoration. Errors state facts next to the field that caused them — do not hide field errors in modals, toasts, or generic banners when the user needs to fix one input. +Figures that will be compared or scanned — amounts, counts, dates in +columns, any metric — set in tabular numerals (`--numeric-tabular`), with +the unit tight to the number. Proportional figures in a data column wobble, +and a wobbling column reads as sloppy arithmetic. Mono already carries this +for machine detail; the rule extends to any numeric data in product UI. + Balanced text is for headings and compact statements. Do not balance long body copy into jagged reading. diff --git a/packages/vessel-light/.ghost/grammar.job.md b/packages/vessel-light/.ghost/grammar.job.md new file mode 100644 index 00000000..8b8de2a4 --- /dev/null +++ b/packages/vessel-light/.ghost/grammar.job.md @@ -0,0 +1,54 @@ +--- +description: "The routing rule — name the reader's job before composing anything; the job picks the ref to imitate and the register that applies, never the topic. Gather first for any new view, before structure exists." +materials: + - materials/ref/composition.form.html + - materials/ref/composition.table.html + - materials/ref/composition.editorial.html + - materials/ref/composition.overlay.html + - materials/ref/composition.conversation.html + - materials/ref/email.html +--- + +Before any structure exists, name what the reader does next with the view. +The topic never picks the shape; the reader's job does. A billing question +is not "a billing page" — it is a form to fill, a table to scan, a decision +to confirm, or a thread to continue, and each of those is a different +composition. A view built for the wrong job does not merely look off; it +misleads. + +The jobs and where they route: + +- **Fill something in** — settings, signup, any submit-and-done task → the + form ref. Stacked labels and fields, one submit, secondary escape. +- **Scan and compare records** — lists, audits, anything with rows and + figures → the table ref, in the data-density register. +- **Read a narrative** — a landing page, an announcement, a story → the + editorial ref, in the editorial register. +- **Stop and decide** — a confirmation, a destructive act, a choice that + interrupts the task → the overlay ref. The decision is the view. +- **Converse with the system** — an AI thread, an agent console, a prompt + composer → the conversation ref. +- **Be notified elsewhere** — receipts, alerts, anything that lands in an + inbox → the email ref, in the email register. + +Read the request's verbs, not its nouns: "edit / set up / submit" routes to +form; "compare / list / review the records" to table; "announce / tell the +story" to editorial; "confirm / are you sure" to overlay; "ask / draft with +me" to conversation. When two jobs fold into one request, build for the job +the reader performs on this view, now — the other job is the next view. When +a job fits none of these, compose from the grammar and say so; do not force +the nearest ref. + +Three shapes lie about the job, and we refuse them everywhere: + +- A view whose content is an artifact the reader will review — a draft, a + filled form, a generated plan — leads with the artifact, never with an + announcement headline. "Here's your draft" in display type demotes the + actual deliverable; the artifact sitting there with its actions is the + statement that it is ready. +- A view reporting a live or ongoing state carries its freshness — "updated + 2 min ago" — in quiet metadata. Presenting a moving value as a settled + fact is a small lie the reader eventually catches. +- A view where the decision belongs to the reader presents the material and + withholds the verdict. Recommendation framing on a choice the system + cannot honestly make is manufactured confidence, and it reads that way. diff --git a/packages/vessel-light/.ghost/index.md b/packages/vessel-light/.ghost/index.md index 7b01cbc2..56ada809 100644 --- a/packages/vessel-light/.ghost/index.md +++ b/packages/vessel-light/.ghost/index.md @@ -5,33 +5,29 @@ materials: --- vessel-light is Vessel without the React package: the design language as a -repo-local steering packet for agents writing raw HTML and CSS. +steering packet for agents writing raw HTML and CSS. The contract is narrow. Style only with the tokens. Compose only with the -closed sets the grammar enumerates. Imitate the refs when the task matches -them — they are worked examples to copy from, not a framework to import. +closed sets the grammar enumerates. Imitate the refs when the task matches — +worked examples to copy from, not a framework to import. -The corpus carries three trust tiers. Grammar and the median floor are law: -safe to consume verbatim, unchanged by any adaptation. Signature nodes are -dials awaiting your values: each states Vessel's current answer and stands -until you replace it. Registers are conditions: editorial, email, and -data-density each name the situation where parts of the default contract -invert. Gather against the actual task and pull only the nodes whose -conditions apply, because truths from the wrong register are poison, not -context. +Grammar and the median floor are safe verbatim, unchanged by any adaptation. +Signature nodes state Vessel's current answer and stand until you replace +them. Registers — editorial, email, and data-density — name the situations +where parts of the default contract invert; pull only the nodes whose +conditions match the task, because truths from the wrong register are +poison, not context. -The reset raises the floor; only the fingerprint escapes the median. +Monochrome comes first. Status color is functional language — danger, +success, warning, information — never atmosphere. Atmosphere is catered: a +five-hue expression palette whose volume rises with the register, from +charts-only in product to two hues in editorial, and never on controls. -Monochrome comes first. Status color is functional language for danger, -success, warning, and information — never atmosphere. Atmosphere exists, but -it is catered: a five-hue expression palette whose volume rises with the -register, from charts-only in product to two hues in editorial, and never on -controls. +It protects the taste that disappears when an agent freehands: quiet +surfaces, deliberate spacing, one obvious action, restrained motion. -The fingerprint protects the taste that disappears when an agent freehands: -quiet surfaces, deliberate spacing, one obvious action, and restrained -motion. - -When a requested UI is not covered by a ref, compose from the grammar. Do -not invent a new styling system. If the result needs a new pattern, the new -pattern should come from observed drift, not anticipation. +Name the reader's job before composing anything — the job picks the ref and +the register, never the topic. When a requested UI is not covered by a ref, +compose from the grammar; do not invent a new styling system. Before +anything ships, run the deletion pass: every element names what breaks if +it goes, and the view arrives settled. diff --git a/packages/vessel-light/.ghost/materials/primitives.css b/packages/vessel-light/.ghost/materials/primitives.css index 999159b4..4e034a13 100644 --- a/packages/vessel-light/.ghost/materials/primitives.css +++ b/packages/vessel-light/.ghost/materials/primitives.css @@ -30,42 +30,24 @@ body { background: var(--muted); color: var(--foreground); } -.surface--accent { - background: var(--accent); - color: var(--accent-foreground); -} -.surface--dark { - background: var(--surface-dark); - color: var(--surface-dark-text); - border-color: var(--surface-dark-border); -} .surface--pad-xs { - padding: 8px; + padding: var(--gap-sm); } .surface--pad-sm { - padding: 12px; + padding: 12px; /* off-scale surface geometry: between --gap-sm and --gap-md */ } .surface--pad-md { - padding: 16px; + padding: var(--gap-md); } .surface--pad-lg { - padding: 24px; + padding: var(--gap-lg); } .surface--radius-sm { border-radius: var(--radius-sm); } -.surface--radius-md { - border-radius: var(--radius-md); -} -.surface--radius-lg { - border-radius: var(--radius-lg); -} .surface--radius-surface { border-radius: var(--radius-surface); } -.surface--radius-control { - border-radius: var(--radius-control); -} .surface--border { border: 1px solid var(--border); } @@ -121,9 +103,6 @@ body { .stack--align-end { align-items: flex-end; } -.stack--align-baseline { - align-items: baseline; -} .stack--justify-center { justify-content: center; } @@ -143,25 +122,25 @@ body { color: var(--foreground); font-family: var(--font-sans); font-size: var(--text-body-size); - line-height: 1.625; + line-height: var(--text-body-line-height); } .text--display { font-size: var(--heading-display-font-size); - font-weight: 400; - line-height: 1; + font-weight: var(--heading-display-font-weight); + line-height: var(--heading-display-line-height); letter-spacing: var(--heading-display-letter-spacing); } .text--headline { font-size: var(--text-headline-size); font-weight: 400; line-height: 1.25; - letter-spacing: -0.025em; + letter-spacing: var(--text-heading-letter-spacing); } .text--title { font-size: var(--text-title-size); font-weight: 600; line-height: 1; - letter-spacing: -0.025em; + letter-spacing: var(--text-heading-letter-spacing); } .text--label { font-size: var(--label-font-size); @@ -201,7 +180,7 @@ body { gap: var(--gap-sm); min-width: 0; height: var(--control-height); - padding: 8px 24px; + padding: var(--gap-sm) var(--gap-lg); border: 1px solid transparent; border-radius: var(--radius-control); background: var(--primary); @@ -237,8 +216,8 @@ body { color: var(--primary-foreground); } .button--secondary { - background: var(--muted); - color: var(--foreground); + background: var(--secondary); + color: var(--secondary-foreground); } .button--ghost { background: transparent; @@ -271,43 +250,27 @@ body { color: var(--muted-foreground); box-shadow: none; } -.button--size-xs { - height: 24px; - padding: 4px 8px; - gap: var(--gap-xs); - font-size: 12px; -} .button--size-sm { height: 32px; - padding: 6px 16px; + padding: 6px var(--gap-md); gap: 6px; } .button--size-lg { height: 40px; - padding: 8px 32px; + padding: var(--gap-sm) var(--gap-xl); } .button--icon, -.button--icon-sm, -.button--icon-xs, -.button--icon-lg { +.button--icon-sm { padding: 0; } .button--icon { width: var(--control-height); height: var(--control-height); } -.button--icon-xs { - width: 24px; - height: 24px; -} .button--icon-sm { width: 32px; height: 32px; } -.button--icon-lg { - width: 40px; - height: 40px; -} /* field — defaults: label plus pill input with input border, muted placeholder, ring focus, compact error. */ .field { diff --git a/packages/vessel-light/.ghost/materials/ref/composition.editorial.html b/packages/vessel-light/.ghost/materials/ref/composition.editorial.html index ba68d064..13e1c0f6 100644 --- a/packages/vessel-light/.ghost/materials/ref/composition.editorial.html +++ b/packages/vessel-light/.ghost/materials/ref/composition.editorial.html @@ -1,5 +1,5 @@ @@ -14,11 +14,15 @@ body { margin: 0; } .page { width: min(calc(100% - (var(--page-container-side-gutter) * 2)), var(--page-container-max-width)); margin-inline: auto; } .section { padding-block: var(--section-padding-vertical); } - .hero { min-height: calc(var(--section-padding-vertical) * 5); justify-content: center; } + .hero { min-height: calc(var(--section-padding-vertical) * 5); } + /* editorial entrance: one staged reveal, opacity + small transform, --duration-slow with the one ease */ + .hero > * { animation: hero-enter var(--duration-slow) var(--ease-spring) both; } + @keyframes hero-enter { from { opacity: 0; transform: translateY(8px); } } .label-kicker { text-transform: uppercase; } - .display-heading { max-width: calc(var(--page-container-side-gutter) * 48); font-size: var(--heading-display-font-size); font-weight: var(--heading-display-font-weight); line-height: var(--heading-display-line-height); letter-spacing: var(--heading-display-letter-spacing); } + .display-heading { max-width: calc(var(--page-container-side-gutter) * 48); } .reading-line { max-width: calc(var(--page-container-side-gutter) * 32); font-size: var(--body-reading-size); line-height: var(--body-reading-line-height); letter-spacing: var(--body-reading-letter-spacing); } .section-heading { max-width: calc(var(--page-container-side-gutter) * 36); margin-bottom: var(--section-heading-margin-bottom); font-size: var(--heading-section-font-size); font-weight: var(--heading-section-font-weight); line-height: var(--heading-section-line-height); letter-spacing: var(--heading-section-letter-spacing); } + .note-heading { max-width: calc(var(--page-container-side-gutter) * 36); margin-bottom: var(--page-container-side-gutter); font-size: var(--heading-sub-font-size); font-weight: var(--heading-sub-font-weight); line-height: var(--heading-sub-line-height); letter-spacing: var(--heading-sub-letter-spacing); } /* expression budget: two hues per page — periwinkle tints the dark band, amber accents the pull quote */ .dark-band { width: 100vw; margin-left: calc(50% - 50vw); background: color-mix(in srgb, var(--expression-2) 12%, var(--surface-dark)); color: var(--surface-dark-text); } .dark-inner { width: min(calc(100% - (var(--page-container-side-gutter) * 2)), var(--page-container-max-width)); margin-inline: auto; padding-block: var(--section-padding-vertical); } @@ -31,11 +35,11 @@
-
+

Independent review layer

-

Make every generated screen answer to the same brand memory.

+

Make every generated screen answer to the same brand memory.

Vessel keeps interface materials plain, inspectable, and close to the repo so agents can preserve the feel behind the product, not just the surface.

- Review materials + Review materials
@@ -61,7 +65,8 @@

Every action is explicit

-
+
+

Why authorship survives generation

The interface should feel authored even when an agent produced the first pass.
diff --git a/packages/vessel-light/.ghost/materials/ref/composition.overlay.html b/packages/vessel-light/.ghost/materials/ref/composition.overlay.html index f5ed74e7..beda887a 100644 --- a/packages/vessel-light/.ghost/materials/ref/composition.overlay.html +++ b/packages/vessel-light/.ghost/materials/ref/composition.overlay.html @@ -1,5 +1,5 @@ @@ -17,6 +17,9 @@ .menu-item { justify-content: flex-start; width: 100%; height: 32px; padding: 6px 12px; } .scrim { position: fixed; inset: 0; display: grid; place-items: center; padding: 16px; } section[role="dialog"] { width: min(100%, 512px); } + /* dialog reveal: --duration-normal with the one ease, opacity + small transform */ + section[role="dialog"] { animation: dialog-reveal var(--duration-normal) var(--ease-spring) both; } + @keyframes dialog-reveal { from { opacity: 0; transform: translateY(4px) scale(0.98); } } @@ -39,7 +42,10 @@

Fingerprint