From 53b31169acabf597f30edd679c9a9faf7b19bb72 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Jul 2026 02:07:32 -0400 Subject: [PATCH 1/5] template: naked skeleton replaces steering default --- apps/docs/src/generated/cli-manifest.json | 4 +- packages/ghost/src/commands/init-command.ts | 2 +- packages/ghost/src/scan/skeleton-grammar.ts | 285 +++++++++++++++++++ packages/ghost/src/scan/skeleton-template.ts | 230 +++++++++++++++ packages/ghost/src/scan/steering-template.ts | 279 ------------------ packages/ghost/src/scan/templates.ts | 13 +- packages/ghost/test/cli.test.ts | 212 ++++++++------ 7 files changed, 652 insertions(+), 373 deletions(-) create mode 100644 packages/ghost/src/scan/skeleton-grammar.ts create mode 100644 packages/ghost/src/scan/skeleton-template.ts delete mode 100644 packages/ghost/src/scan/steering-template.ts diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 02c31fa7..bb1772e3 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-07T16:48:56.039Z", + "generatedAt": "2026-07-09T06:07:32.461Z", "tools": [ { "tool": "ghost", @@ -53,7 +53,7 @@ { "rawName": "--template ", "name": "template", - "description": "Init template to scaffold (default: steering)", + "description": "Init template to scaffold (default: skeleton)", "default": null, "takesValue": true, "negated": false diff --git a/packages/ghost/src/commands/init-command.ts b/packages/ghost/src/commands/init-command.ts index a9d41633..b9cb7029 100644 --- a/packages/ghost/src/commands/init-command.ts +++ b/packages/ghost/src/commands/init-command.ts @@ -14,7 +14,7 @@ export function registerInitCommand(cli: CAC): void { ) .option( "--template ", - "Init template to scaffold (default: steering)", + "Init template to scaffold (default: skeleton)", ) .option( "--with ", diff --git a/packages/ghost/src/scan/skeleton-grammar.ts b/packages/ghost/src/scan/skeleton-grammar.ts new file mode 100644 index 00000000..f6b1fb77 --- /dev/null +++ b/packages/ghost/src/scan/skeleton-grammar.ts @@ -0,0 +1,285 @@ +import type { TemplateFile } from "./templates.js"; + +/** + * The skeleton template's law tier: the median floor plus the six grammar + * nodes. These are the parts of the default fingerprint that are safe to + * consume verbatim and survive any future rebrand — they speak in token + * roles and closed sets, never in literal brand values. The signature dials + * live in `skeleton-template.ts`. + */ +export const SKELETON_GRAMMAR_FILES: TemplateFile[] = [ + { + relativePath: "anti-goal.median.md", + content: `--- +description: "The model's median defaults this fingerprint refuses — gather for any greenfield visual surface or first-draft copy. Each rule is reject→replace; delete lines your brand legitimately violates." +--- + +This is the model's median, not your brand. Each rule is reject→replace. +Delete every line your brand legitimately violates — \`ghost validate\` will +surface any check the deletion orphans. + +These are not aesthetic opinions. Where a count is given, it is the measured +convergence of 300 unsteered generations across three frontier models (the +antimedian experiment): the defaults a model reaches for when nobody hands it +a brand. An output showing several of these tells reads as generated, +whatever else it does right. A deterministic floor rides alongside these +rules in checks — contrast, reduced motion, sane z-index, no overshoot +easing — verified at review, never steered in prose. + + +Reject hover-lift (\`translateY\` + growing shadow) as the default interaction +→ confirm with color and background change at the fingerprint's fast +duration. (measured: 341) + + +Reject the indigo/blue/purple default accent (\`#4f46e5\`, \`#2563eb\`, +\`#8b5cf6\` family; purple/violet hue 260–310) → the fingerprint's declared +palette; absent one, monochrome plus a single functional accent. + + +Reject unprompted dark theme → the fingerprint's declared surface. +Dark-to-look-cool and light-to-be-safe are the same retreat from a decision. +(measured: 271) + + +Reject gradient page and section backgrounds and gradient-filled CTAs → flat +surfaces in semantic roles. + + +Reject side-stripe borders (a thick colored border on one side of a rounded +card) — the single most recognizable tell of AI-generated UI → a full +hairline border, a 4–8% surface tint, or a leading glyph. + + +Reject the cream/sand/beige default surface (warm off-white; token names +like \`--cream\`, \`--sand\`, \`--parchment\` are the tell) → a true off-white, +the brand's own hue, or a committed color; warmth via accent and type, not +the ground. + + +Reject glassmorphism (decorative backdrop blur) → flat surfaces with real +borders and the fingerprint's elevation tiers. + + +Reject chat bubbles with initials-circle avatars for assistant turns → +assistant text plain on the page surface; user turns marked compactly. + + +Reject stock template copy ("Simple, transparent pricing") → headings that +state what this product specifically does. + + +Reject celebration copy and UI (exclamation success, confetti language) → +quiet factual confirmation. + + +Reject nested cards and everything-in-cards → one surface level per region; +interior hierarchy from spacing and type; spacing and alignment group +without card overhead. + + +Reject the hero-metric template (big number + small label + supporting stats +as default proof) → evidence specific to the product, or nothing. A +prominent metric showing real user data is fine. + + +Reject every button a primary button → one primary per view; the rest step +down the fingerprint's emphasis ladder. + + +Reject an eyebrow kicker on every section → at most one, where the register +sanctions it; one named kicker is voice, every-section is AI grammar. + + +Reject decorative looping animation (pulse/float/shimmer) and uniform +fade-and-rise on every scrolled section → motion as evidence of state +change; loops only for genuine loading; decoration never compensates for +weak structure — fix spacing, copy, and hierarchy first. +`, + }, + { + relativePath: "grammar.hierarchy.md", + content: `--- +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. +`, + }, + { + relativePath: "grammar.rhythm.md", + content: `--- +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. +`, + }, + { + relativePath: "grammar.surfaces.md", + content: `--- +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. +`, + }, + { + relativePath: "grammar.motion.md", + content: `--- +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. +`, + }, + { + relativePath: "grammar.color-roles.md", + content: `--- +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. +`, + }, + { + relativePath: "grammar.conversation.md", + content: `--- +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/scan/skeleton-template.ts b/packages/ghost/src/scan/skeleton-template.ts new file mode 100644 index 00000000..c588c30c --- /dev/null +++ b/packages/ghost/src/scan/skeleton-template.ts @@ -0,0 +1,230 @@ +import { SKELETON_GRAMMAR_FILES } from "./skeleton-grammar.js"; +import type { GhostInitTemplate, TemplateFile } from "./templates.js"; + +interface SkeletonTemplateDeps { + manifestFile(): TemplateFile; + gitignoreFile(): TemplateFile; +} + +/** + * The skeleton starter: the default `ghost init` fingerprint. It carries the + * rebrand-safe law tier (the median floor + six grammar nodes, in + * `skeleton-grammar.ts`) and leaves the four signature dials explicitly + * unanswered — each dial states its fixed relationship, asks its question, + * and forbids freehanding a value as brand-backed. No brand values ship in + * this template; the corpus validates clean with zero warnings as written. + */ +export function createSkeletonTemplate({ + manifestFile, + gitignoreFile, +}: SkeletonTemplateDeps): GhostInitTemplate { + return { + name: "skeleton", + description: + "Naked skeleton: the median floor + grammar law, with the signature dials left unanswered.", + files() { + return [ + manifestFile(), + gitignoreFile(), + glossaryFile(), + indexFile(), + ...SKELETON_GRAMMAR_FILES, + ...SIGNATURE_DIAL_FILES, + ]; + }, + }; +} + +function glossaryFile(): TemplateFile { + return { + relativePath: "glossary.md", + content: `--- +kinds: + - name: grammar + - name: signature + - name: register + - name: anti-goal + posture: guard +--- + +# grammar + +The brand's decision logic: closed sets, role vocabularies, and assembly +rules, stated in token roles and never in literal values. Grammar survives a +rebrand unchanged — swap every value and these nodes still hold. Gather +grammar before inventing structure. + +# signature + +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. + +# register + +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 ghost skill bundle teaches authoring: how to answer the dials, add +registers, and grow this skeleton into a real fingerprint. +`, + }; +} + +function indexFile(): TemplateFile { + return { + relativePath: "index.md", + content: `--- +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 rebrand-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 future rebrand — 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. +`, + }; +} + +/** + * The four signature dials, each in the three-part unanswered shape: + * (1) the unanswered declaration, (2) the fixed relationship carried from + * the source corpus, (3) the open question + answer instruction + + * provisional-generation clause. No brand values appear anywhere. + */ +const SIGNATURE_DIAL_FILES: TemplateFile[] = [ + { + relativePath: "signature.shape.md", + content: `--- +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. +`, + }, + { + relativePath: "signature.palette.md", + content: `--- +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. +`, + }, + { + relativePath: "signature.type.md", + content: `--- +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. +`, + }, + { + relativePath: "signature.temperature.md", + content: `--- +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/scan/steering-template.ts b/packages/ghost/src/scan/steering-template.ts deleted file mode 100644 index a9441f4e..00000000 --- a/packages/ghost/src/scan/steering-template.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { GhostInitTemplate, TemplateFile } from "./templates.js"; - -interface SteeringTemplateDeps { - manifestFile(): TemplateFile; - gitignoreFile(): TemplateFile; -} - -export function createSteeringTemplate({ - manifestFile, - gitignoreFile, -}: SteeringTemplateDeps): GhostInitTemplate { - return STEERING_TEMPLATE_FACTORY(manifestFile, gitignoreFile); -} - -/** - * Steering starter: named truths for pre-generation steering. The extra kinds - * are vocabulary only: no schema or selection behavior changes. - */ -function STEERING_TEMPLATE_FACTORY( - manifestFile: () => TemplateFile, - gitignoreFile: () => TemplateFile, -): GhostInitTemplate { - return { - name: "steering", - description: "Steering starter: stance, patterns, exemplars, materials.", - files() { - return [ - manifestFile(), - gitignoreFile(), - { - relativePath: "glossary.md", - content: `--- -kinds: - - name: principle - - name: condition - - name: pattern - - name: exemplar - - name: anti-goal - posture: guard - - name: asset - - name: decision - - name: concept -# - { name: provocation, posture: wild, purpose: a deliberate provocation past the fingerprint — surfaced only on request } ---- - -# principle - -Durable stance, tradeoff, or invariant. Patterns may narrow principles, never loosen them. - -# condition - -Situational truth. Use only when the stated situation holds. - -# pattern - -Reusable composition. State what is bound, what is open, and when it applies. - -# exemplar - -Concrete reference for form, rhythm, quality, code shape, voice, or behavior. Say what to copy and not copy. - -# anti-goal - -Review-critical guard posture. State not-X-instead-Y: the rejected default, the positive replacement, and how to recognize the switch. - -# asset - -Material truth about concrete assets, tokens, components, type, motion, imagery, code, or files. \`materials\` locates; prose explains. - -# decision - -Worked tradeoff trace: what was chosen, what was rejected, and why. - -# concept - -Scoped creative direction for a specific output; not permanent brand law. -`, - }, - { - relativePath: "index.md", - content: `--- -description: Always read first — the steering front door, non-negotiables, and silence posture. ---- - -This fingerprint is demo content for **Morrow Ledger**, a small bookkeeping product for independent studios. It steers agent-generated product and brand work; it is not a component API or complete archive. - -## Before you trust this fingerprint - -Everything in this starter package is demo content. It is intentionally opinionated so you can see the shape of useful steering: hard truths, refusal lines, material locators, patterns, exemplars, and tradeoff traces. Keep any structure that helps, but replace the claims, paths, examples, and decisions with real product truth before using this fingerprint to guide generation. - -## Non-negotiables - -The lines below are demo non-negotiables for inspiration and guidance. Replace them with your real hard truths: - -- Morrow Ledger sounds like a careful bookkeeper, not a growth coach. -- Use slate ink, ledger green, and quiet amber before inventing new color. -- Preserve the composition floor in \`principle.composition\`. -- Avoid the generic generated defaults named in \`anti-goal.*\`. -- Label provisional reasoning when this fingerprint is silent. - -## How to read the corpus - -\`principle.*\` gives stance and hard floor. \`pattern.*\` gives bound/open structure. \`exemplar.*\` shows form and quality. \`anti-goal.*\` names likely wrong outputs. \`asset.*\` points to materials. \`decision.*\` explains tradeoffs. \`concept.*\` is scoped direction, not law. The starter nodes are teaching examples, not defaults Ghost believes about your brand. - -## Silence posture - -When this fingerprint is silent, proceed provisionally from local conventions only when safe. Ask before brand-defining, irreversible, legal, privacy-sensitive, or high-risk choices. -`, - }, - { - relativePath: "principle.stance.md", - content: `--- -description: Core stance and tradeoffs — gather when a task needs brand decision-making beyond exact materials. ---- - -Morrow Ledger helps independent studios know what changed in their money without making finance feel like a performance dashboard. - -Forced choices: - -- **Clarity beats momentum.** When a line could sound exciting or precise, choose precise. Write "3 invoices are 14+ days late" instead of "Cash flow needs attention." -- **Evidence beats reassurance.** Pair every claim with the number, date, file, or account behind it. Never say "all set" unless the surface shows what was checked. -- **Calm beats clever.** Use plain verbs: sent, due, matched, missing, paid. Avoid pep-talk verbs: crush, unlock, supercharge. -- **Small business time is scarce.** Lead with the next accountable action and its cost: "Review 2 unmatched deposits — about 4 minutes." - -The brand earns trust by making the boring truth easy to act on. If the work feels motivational, it is drifting. - -`, - }, - { - relativePath: "principle.composition.md", - content: `--- -description: Composition floor — hard layout invariants that hold whether or not a pattern matches the ask. ---- - -These demo invariants show the kind of floor that steers generation. Replace them with real hard lines that apply to every generated surface unless a narrower condition explicitly says otherwise. Patterns narrow them; nothing loosens them. - -For Morrow Ledger: - -- **One ledger fact leads.** Every surface opens with one factual sentence or number. If two facts compete, choose the one that changes what the user does next. -- **Maximum three figures above the fold.** A figure is any amount, count, date, or percentage. If there are four, demote one into details instead of shrinking type. -- **Actions are accountable verbs.** Primary actions begin with Review, Match, Send, Export, or Mark. Never use generic "Continue" when the action can be named. -- **Whitespace before boxes.** Separate sections with 24px vertical space first, a 1px #D8DED8 rule second, and a card only when the content has its own state. - -Token sketch for demos: - -\`\`\`css -:root { - --morrow-ink: #17201b; - --morrow-ledger: #2f6f4f; - --morrow-amber: #b7791f; - --morrow-paper: #f7f5ef; -} -\`\`\` - -`, - }, - { - relativePath: "anti-goal.generic-generated-output.md", - content: `--- -description: The generic generated output this fingerprint rejects. ---- - -This is demo anti-goal content. Replace it with the defaults this brand actually refuses. - -Not: a rounded-card SaaS dashboard full of gradients, celebratory deltas, and "You're crushing it" copy. - -Instead: a ledger-first surface with one leading fact, muted paper background, accountable next action, and evidence close to the claim. - -Concrete replacements: - -- Not "Revenue is up 24% 🎉" — instead "July receipts are $4,820 higher than June; 2 invoices explain most of the change." -- Not a decorative blob or fake chart preview — instead a compact list of the source transactions or invoices. -- Not "Oops, something went wrong" — instead "We could not match deposit 1042. Choose the invoice or mark it as owner contribution." - -A technically correct output can still be off-brand if any product could ship it. This guard is review-critical because generic finance optimism is the most likely model default. -`, - }, - { - relativePath: "pattern.status-with-next-step.md", - content: `--- -description: Status of an ongoing thing plus the single next step — use when the task asks where something stands or what happens next. ---- - -This is a demo pattern for inspiration and guidance. Replace it with a real reusable pattern from your product. - -Applies when the surface explains the state of something in flight: invoice payment, bank sync, export, tax packet, or reconciliation. - -**Bound (do not redecide):** - -- Status line first, exactly one sentence, no more than 11 words. -- One evidence row with amount, date, and source. -- One primary next step last, beginning with an accountable verb. - -**Open (within limits):** - -- Evidence may be a table row or two-item list. -- Use amber only for a user action needed within 7 days. - -## Skeleton - -\`\`\`tsx -
-

{scope}

-

{statusSentence}

-

{meaningSentence}

-
{amountDateSource}
- -
-\`\`\` - -`, - }, - { - relativePath: "exemplar.annotated-reference.md", - content: `--- -description: Annotated reference — replace with a sample that shows form, rhythm, quality, voice, or code shape. -materials: - - src/components/status-card.tsx ---- - -This is demo exemplar content. Replace \`src/components/status-card.tsx\` with a real screenshot, file, URL, or implementation path. - -Normative for: density, evidence placement, accountable action language. - -Annotated reference: - -\`\`\`tsx - -\`\`\` - -Copy the square edge, measured evidence, and action cost. Do not copy the exact amounts or dates. -`, - }, - { - relativePath: "asset.materials.md", - content: `--- -description: Concrete materials the agent should inspect before inventing visual language, components, type, motion, or assets. -materials: - - src/styles/tokens.css - - src/components/** - - public/brand/** ---- - -This is demo material inventory. Replace the paths with real tokens, components, SVGs, motion specs, images, or implementations. - -Morrow Ledger's starter materials should resolve to real files in your repo before use: - -- \`src/styles/tokens.css\` should define the ink, ledger, amber, paper, and rule colors. -- \`src/components/**\` should include the production Button, InlineAlert, and DataRow components. -- \`public/brand/**\` should include the wordmark and any blessed product screenshots. - -Use this node to locate real materials. \`materials\` points to concrete files; this prose explains what they mean. Do not duplicate API docs unless the API itself is the brand truth. -`, - }, - { - relativePath: "decision.tradeoff.md", - content: `--- -description: Worked tradeoff — replace with a decision where the brand chose between plausible options. ---- - -This is demo decision content. Replace it with a real tradeoff that helps an agent decide between plausible good answers. - -Morrow Ledger tradeoff: we chose visible evidence over shorter surfaces. - -- Option A: a compact success banner, "Bank sync complete." It was calm and fast, but it hid what changed. -- Option B: a slightly taller status block, "Bank sync complete; 14 transactions imported since Jul 12." It costs one extra line but gives the user a fact to trust. -- Decision: choose Option B whenever the action touches money, taxes, invoices, exports, or bank data. -- Reverses when: the evidence would expose sensitive third-party details in a shared view. Then show the count only and link to details. -`, - }, - ]; - }, - }; -} diff --git a/packages/ghost/src/scan/templates.ts b/packages/ghost/src/scan/templates.ts index 92c53446..74605b80 100644 --- a/packages/ghost/src/scan/templates.ts +++ b/packages/ghost/src/scan/templates.ts @@ -3,7 +3,7 @@ import { GHOST_EVENTS_FILENAME, LEGACY_PULL_HISTORY_FILENAME, } from "./constants.js"; -import { createSteeringTemplate } from "./steering-template.js"; +import { createSkeletonTemplate } from "./skeleton-template.js"; /** * A single seed file an `init` template writes, relative to the package dir. */ @@ -301,7 +301,7 @@ When a blessed render of this pattern exists, add an \`exemplar.*\` node with }, }; -const STEERING_TEMPLATE = createSteeringTemplate({ +const SKELETON_TEMPLATE = createSkeletonTemplate({ manifestFile, gitignoreFile, }); @@ -309,14 +309,17 @@ const STEERING_TEMPLATE = createSteeringTemplate({ const TEMPLATES = new Map([ [MINIMAL_TEMPLATE.name, MINIMAL_TEMPLATE], [COMPOSITION_TEMPLATE.name, COMPOSITION_TEMPLATE], - [STEERING_TEMPLATE.name, STEERING_TEMPLATE], + [SKELETON_TEMPLATE.name, SKELETON_TEMPLATE], ]); -export const DEFAULT_TEMPLATE_NAME = STEERING_TEMPLATE.name; +export const DEFAULT_TEMPLATE_NAME = SKELETON_TEMPLATE.name; /** Look up a registered init template by name. */ export function getInitTemplate(name: string): GhostInitTemplate | undefined { - if (name === "default") return STEERING_TEMPLATE; + // `default` and `steering` are silent aliases for the skeleton starter: + // `default` for callers that never named a template, `steering` for + // callers pinned to the pre-skeleton default's name. + if (name === "default" || name === "steering") return SKELETON_TEMPLATE; return TEMPLATES.get(name); } diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 2806055c..c7d80a04 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -210,40 +210,114 @@ describe("ghost CLI", () => { expect(result.stderr).toContain("--format json"); }); - it("initializes the default steering fingerprint package", async () => { - const init = await runCli(["init", "--format", "json"], dir); + const SKELETON_FILES = [ + "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", + ]; - expect(init.code).toBe(0); - const initOutput = JSON.parse(init.stdout); - expect(Object.keys(initOutput).sort()).toEqual(["dir", "written"]); - expect(initOutput.written).toContain("manifest.yml"); - expect(initOutput.written).toContain("glossary.md"); - expect(initOutput.written).toContain("index.md"); - expect(initOutput.written).toContain("principle.stance.md"); - expect(initOutput.written).toContain("principle.composition.md"); - expect(initOutput.written).toContain( - "anti-goal.generic-generated-output.md", - ); - expect(initOutput.written).toContain("pattern.status-with-next-step.md"); - expect(initOutput.written).toContain("exemplar.annotated-reference.md"); - expect(initOutput.written).toContain("asset.materials.md"); - expect(initOutput.written).toContain("decision.tradeoff.md"); + async function expectSkeletonPackage(written: string[]) { + // Exact file inventory: no anti-goal.tells, no register.*, no materials/. + expect([...written].sort()).toEqual([...SKELETON_FILES].sort()); + expect(written).not.toContain("anti-goal.tells.md"); + expect(written.some((f: string) => f.startsWith("register."))).toBe(false); + expect(written.some((f: string) => f.startsWith("materials/"))).toBe(false); // Core init is fingerprint-only: checks are opt-in via --with / checks init. - expect(initOutput.written).not.toContain("checks/example.md.example"); + expect(written).not.toContain("checks/example.md.example"); + await expect( readFile(join(dir, ".ghost", "manifest.yml"), "utf-8"), ).resolves.toContain("schema: ghost.fingerprint-package/v1"); - const index = await readFile(join(dir, ".ghost", "index.md"), "utf-8"); - expect(index).toContain( - "Everything in this starter package is demo content", - ); - expect(index).toContain( - "replace the claims, paths, examples, and decisions", + // The scaffolded package validates with zero errors AND zero warnings. + const validate = await runCli(["validate", "--format", "json"], dir); + expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); + + // The gather menu carries every node with its kind. + const gather = await runCli(["gather", "--format", "json"], dir); + expect(gather.code).toBe(0); + const menu = JSON.parse(gather.stdout); + const byId = new Map( + menu.nodes.map((node: { id: string; kind?: string }) => [ + node.id, + node.kind, + ]), ); + expect(byId.get("index")).toBeUndefined; + expect(byId.has("index")).toBe(true); + expect(byId.get("anti-goal.median")).toBe("anti-goal"); + for (const slug of [ + "hierarchy", + "rhythm", + "surfaces", + "motion", + "color-roles", + "conversation", + ]) { + expect(byId.get(`grammar.${slug}`)).toBe("grammar"); + } + for (const slug of ["shape", "palette", "type", "temperature"]) { + expect(byId.get(`signature.${slug}`)).toBe("signature"); + } - const validate = await runCli(["validate"], dir); - expect(validate.code).toBe(0); + // The median floor survives intact: prune header + rule anchors. + const median = await runCli(["pull", "anti-goal.median"], dir); + expect(median.code).toBe(0); + expect(median.stdout).toContain( + "This is the model's median, not your brand.", + ); + expect(median.stdout).toContain(""); + + // 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"); + + // No Vessel strings anywhere in the scaffolded package. + const forbidden = [ + "Vessel", + "HK Grotesk", + "999px", + "Morrow", + "amber", + "periwinkle", + "clay", + "orchid", + "sage", + ]; + for (const file of written) { + const content = await readFile(join(dir, ".ghost", file), "utf-8"); + for (const needle of forbidden) { + expect(content, `${file} must not contain "${needle}"`).not.toMatch( + new RegExp(`\\b${needle}\\b`, "i"), + ); + } + } + } + + it("initializes the default skeleton fingerprint package", async () => { + const init = await runCli(["init", "--format", "json"], dir); + + expect(init.code).toBe(0); + const initOutput = JSON.parse(init.stdout); + expect(Object.keys(initOutput).sort()).toEqual(["dir", "written"]); + await expectSkeletonPackage(initOutput.written); }); it("initializes the minimal fingerprint package", async () => { @@ -264,19 +338,23 @@ describe("ghost CLI", () => { expect(validate.code).toBe(0); }); - it("keeps default as an alias for the steering template", async () => { - const init = await runCli( - ["init", "--template", "default", "--format", "json"], - dir, - ); + it("keeps default and steering as aliases for the skeleton template", async () => { + for (const name of ["default", "steering"]) { + await rm(join(dir, ".ghost"), { recursive: true, force: true }); + const init = await runCli( + ["init", "--template", name, "--format", "json"], + dir, + ); - expect(init.code).toBe(0); - const initOutput = JSON.parse(init.stdout); - expect(initOutput.written).toContain("principle.stance.md"); - expect(initOutput.written).toContain("decision.tradeoff.md"); + expect(init.code).toBe(0); + const initOutput = JSON.parse(init.stdout); + expect([...initOutput.written].sort()).toEqual( + [...SKELETON_FILES].sort(), + ); - const validate = await runCli(["validate"], dir); - expect(validate.code).toBe(0); + const validate = await runCli(["validate"], dir); + expect(validate.code).toBe(0); + } }); it("initializes the composition starter template", async () => { @@ -317,55 +395,15 @@ describe("ghost CLI", () => { expect(pull.stdout).toContain("principle.composition"); }); - it("initializes the steering starter template", async () => { + it("initializes the skeleton starter template by name", async () => { const init = await runCli( - ["init", "--template", "steering", "--format", "json"], + ["init", "--template", "skeleton", "--format", "json"], dir, ); expect(init.code).toBe(0); const initOutput = JSON.parse(init.stdout); - expect(initOutput.written).toContain("manifest.yml"); - expect(initOutput.written).toContain("glossary.md"); - expect(initOutput.written).toContain("index.md"); - expect(initOutput.written).toContain("principle.stance.md"); - expect(initOutput.written).toContain("principle.composition.md"); - expect(initOutput.written).toContain( - "anti-goal.generic-generated-output.md", - ); - expect(initOutput.written).toContain("pattern.status-with-next-step.md"); - expect(initOutput.written).toContain("exemplar.annotated-reference.md"); - expect(initOutput.written).toContain("asset.materials.md"); - expect(initOutput.written).toContain("decision.tradeoff.md"); - expect(initOutput.written).not.toContain("concept.scoped-direction.md"); - - const validate = await runCli(["validate"], dir); - expect(validate.code).toBe(0); - - const gather = await runCli(["gather", "--format", "json"], dir); - expect(gather.code).toBe(0); - const menu = JSON.parse(gather.stdout); - const ids = menu.nodes.map((node: { id: string }) => node.id); - expect(ids).toContain("principle.stance"); - expect(ids).toContain("principle.composition"); - expect(ids).toContain("anti-goal.generic-generated-output"); - expect(ids).toContain("pattern.status-with-next-step"); - expect(ids).toContain("exemplar.annotated-reference"); - expect(ids).toContain("asset.materials"); - expect(ids).toContain("decision.tradeoff"); - - const antiGoal = menu.nodes.find( - (node: { id: string }) => - node.id === "anti-goal.generic-generated-output", - ); - expect(antiGoal.kind).toBe("anti-goal"); - const decision = menu.nodes.find( - (node: { id: string }) => node.id === "decision.tradeoff", - ); - expect(decision.kind).toBe("decision"); - expect(menu.kinds.map((kind: { name: string }) => kind.name)).toContain( - "concept", - ); + await expectSkeletonPackage(initOutput.written); }); it("rejects an unknown init template with a usage error", async () => { @@ -379,7 +417,7 @@ describe("ghost CLI", () => { expect(result.stderr).toContain("Unknown init template 'nope'"); expect(result.stderr).toContain("minimal"); expect(result.stderr).toContain("composition"); - expect(result.stderr).toContain("steering"); + expect(result.stderr).toContain("skeleton"); }); it("uses GHOST_PACKAGE_DIR as the default fingerprint package directory for init", async () => { @@ -501,7 +539,7 @@ describe("ghost CLI", () => { expect(forced.code).toBe(0); await expect( readFile(join(dir, ".ghost", "index.md"), "utf-8"), - ).resolves.toContain("Everything in this starter package is demo content"); + ).resolves.toContain("skeleton fingerprint"); }); it("does not guess arbitrary YAML files are validate.yml", async () => { @@ -586,15 +624,17 @@ describe("ghost CLI", () => { const gather = await runCli(["gather", "--format", "json"], dir); expect(gather.code).toBe(0); const menu = JSON.parse(gather.stdout); - const principle = menu.kinds.find( - (k: { name: string }) => k.name === "principle", + const grammar = menu.kinds.find( + (k: { name: string }) => k.name === "grammar", ); - expect(principle.purpose).toContain("Durable stance"); + 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("- **principle** — Durable stance"); + 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")); From dc8b7693876bb0e06476559cdc55641a0d3cf48b Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Jul 2026 02:43:14 -0400 Subject: [PATCH 2/5] =?UTF-8?q?checks:=20median-tells=20scaffold=20?= =?UTF-8?q?=E2=80=94=20live=20check=20pairing=20with=20anti-goal.median,?= =?UTF-8?q?=20deterministic=20floor,=20model=20tells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ghost/src/scan/check-scaffold.ts | 110 +++++++++++++++++++++- packages/ghost/test/cli.test.ts | 25 ++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/packages/ghost/src/scan/check-scaffold.ts b/packages/ghost/src/scan/check-scaffold.ts index c1c5073a..d646edc5 100644 --- a/packages/ghost/src/scan/check-scaffold.ts +++ b/packages/ghost/src/scan/check-scaffold.ts @@ -4,6 +4,106 @@ import { UsageError } from "#ghost-core"; import { GHOST_CHECKS_DIR } from "./check-files.js"; const EXAMPLE_CHECK_FILENAME = "example.md.example"; +const MEDIAN_TELLS_FILENAME = "median-tells.md"; + +const MEDIAN_TELLS_CONTENT = `--- +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 + - grammar.motion + - grammar.conversation +--- + +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 flag here — +delete both 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 > rule: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. (\`rule: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. (\`rule:median-dark-theme\`) + +Flag \`linear-gradient\` or \`radial-gradient\` as page or section +backgrounds, and gradient-filled buttons. (\`rule:median-gradients\`) + +Flag \`backdrop-filter: blur\` used for glassmorphism cards. +(\`rule: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 +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. (\`rule: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. (\`rule:median-cream-surface\`) + +Flag assistant messages rendered as bubbles with initials-circle avatars. +(\`rule:median-chat-bubbles\`) + +Flag emoji used as icons or imagery in interface chrome. Text labels carry +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. +(\`rule:median-stock-copy\`) + +Deterministic floor — licensed by the median node, verified here, never +steered in prose: + +Flag text/background pairs below WCAG AA contrast: 4.5:1 for body text, +3:1 for large text (24px and up, or 18.7px bold and up). Check the worst +stop when the background is a gradient. + +Flag animation or transition without a \`prefers-reduced-motion: reduce\` +alternative — a crossfade or instant state change. + +Flag ad-hoc z-index values (\`999\`, \`9999\`, or any value outside a +declared scale). Layering wants a semantic scale, not an arms race. + +Flag \`cubic-bezier\` easings whose control points overshoot the 0–1 range, +and keyframe names matching bounce, elastic, wobble, or jiggle. + +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. (\`rule:median-eyebrow-kicker\`) + +Flag five or more em-dashes in body copy in one view. + +Flag three or more instances of the aphoristic rebuttal cadence ("Not X. Y." +/ "Sentence. No qualifiers.") in one page's copy. + +Model-signature tells — skip any block whose model did not produce the diff +(advisory): + +Codex: flag a 1px border paired with a box-shadow of 16px blur or more on +the same element — pick a solid border or a tight shadow, not both. Flag +border-radius of 32px or more on cards, sections, or inputs. Flag 1px +linear-gradient grid or repeating-stripe backgrounds used as decoration. + +Gemini: flag \`transform\` (scale, rotate, translate) on \`img\` inside a +\`:hover\` rule, including group-hover utilities targeting a child image. +Animate the card's background, border, or shadow instead. +`; const EXAMPLE_CHECK_CONTENT = `--- name: logo-clearspace-holds @@ -33,12 +133,20 @@ export async function addChecksDir( } await mkdir(checksDir, { recursive: true }); + await writeFile( + join(checksDir, MEDIAN_TELLS_FILENAME), + MEDIAN_TELLS_CONTENT, + "utf-8", + ); await writeFile( join(checksDir, EXAMPLE_CHECK_FILENAME), EXAMPLE_CHECK_CONTENT, "utf-8", ); - return { dir: checksDir, written: [EXAMPLE_CHECK_FILENAME] }; + return { + dir: checksDir, + written: [MEDIAN_TELLS_FILENAME, EXAMPLE_CHECK_FILENAME], + }; } async function exists(path: string): Promise { diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index c7d80a04..f604f791 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -1744,25 +1744,42 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(pull.stdout).toContain(":root{}"); }); - it("checks init scaffolds .ghost/checks/ with an example", async () => { + it("checks init scaffolds .ghost/checks/ with median tells and an example", async () => { await runCli(["init"], dir); const add = await runCli(["checks", "init", "--format", "json"], dir); expect(add.code).toBe(0); const added = JSON.parse(add.stdout); - expect(added.written).toEqual(["example.md.example"]); + expect(added.written).toEqual(["median-tells.md", "example.md.example"]); await expect( 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. + const median = await readFile( + join(dir, ".ghost", "checks", "median-tells.md"), + "utf-8", + ); + expect(median).toContain("anti-goal.median"); + expect(median).toContain("rule:median-hover-lift"); + expect(median).toContain("prefers-reduced-motion"); + expect(median).toContain("delete both together"); + expect(median).not.toContain("Vessel"); + // Running init twice is a usage error. const again = await runCli(["checks", "init"], dir); expect(again.code).toBe(2); expect(again.stderr).toContain("already exists"); - // The scaffold validates cleanly (example.md.example is inert). - const validate = await runCli(["validate"], dir); + // The scaffold validates cleanly on the default skeleton: median-tells + // references resolve against anti-goal.median and the grammar nodes. + const validate = await runCli(["validate", "--format", "json"], dir); expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + const unresolved = report.issues.filter( + (f: { rule: string }) => f.rule === "check-reference-unresolved", + ); + expect(unresolved).toEqual([]); }); it("checks rejects unknown actions", async () => { From 8a9ae0a5160fce45a97f16fef9176319131f1aa0 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Jul 2026 02:44:26 -0400 Subject: [PATCH 3/5] skill: migrate Morrow teaching shapes to authoring-scenarios --- .../references/authoring-scenarios.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index db308fb8..f244ae32 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -134,6 +134,45 @@ prefix is a declared glossary kind (undeclared → warning with a "did you mean" suggestion). Use ordinary Git review as the approval boundary: uncommitted edits are drafts; checked-in nodes are canonical. +## Shapes Of A Useful Node + +The shapes below teach what steering prose looks like when it works. The +examples use **Morrow Ledger**, a fictional bookkeeping product — teaching +fiction for this recipe only, never content for a real corpus. Each shape is a +pattern to imitate with the human's real truths. + +- **Stance as forced choices.** Not adjectives — decisions under pressure: + "Clarity beats momentum: write '3 invoices are 14+ days late', not 'Cash + flow needs attention.'" A stance earns its place when it rejects a plausible + wrong answer. +- **Composition floor.** Hard invariants with numbers, holding unless a + narrower condition says otherwise: "Maximum three figures above the fold — + if there are four, demote one into details instead of shrinking type." +- **Anti-goal as not-X-instead-Y.** The rejected default *and* its committed + replacement: "Not 'Revenue is up 24% 🎉' — instead 'July receipts are $4,820 + higher than June; 2 invoices explain most of the change.'" A bare ban prunes + one path; the replacement commits another. +- **Pattern as bound/open.** Say what is decided and what remains free: + "Bound: status line first, one sentence, ≤11 words; one primary next step + last. Open: evidence may be a table row or a two-item list." All bound is a + template; nothing bound is vibes. +- **Annotated exemplar.** A concrete reference plus what it is normative for + and what is incidental: "Copy the square edge, measured evidence, and action + cost. Do not copy the exact amounts or dates." An unannotated exemplar + teaches its accidents as brand. +- **Material inventory.** `materials` locates; prose explains meaning: + "`src/styles/tokens.css` should define the ink, ledger, amber, and paper + colors." Never duplicate API docs unless the API itself is the brand truth. +- **Decision trace.** A worked tradeoff with the reversal condition: "We chose + visible evidence over shorter surfaces… Reverses when the evidence would + 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. + ## Never - Never derive brand truth from repo code alone; what the codebase repeats may From ef855710fd5edff8252564ebd7c5a921f47f1a4e Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Jul 2026 02:45:49 -0400 Subject: [PATCH 4/5] docs: skeleton starter in getting-started + cli-reference; minor changeset --- .changeset/skeleton-default-template.md | 5 +++++ apps/docs/src/content/docs/cli-reference.mdx | 2 +- apps/docs/src/content/docs/getting-started.mdx | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/skeleton-default-template.md diff --git a/.changeset/skeleton-default-template.md b/.changeset/skeleton-default-template.md new file mode 100644 index 00000000..0323e3b2 --- /dev/null +++ b/.changeset/skeleton-default-template.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +`ghost init` now scaffolds the skeleton starter: value-free grammar nodes, unanswered signature dials, and the `anti-goal.median` floor — replacing the Morrow Ledger demo content. `ghost checks init` scaffolds a live median-tells check alongside the example. `steering` remains as an alias for the new `skeleton` template. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 8151abe1..02e6275a 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -51,7 +51,7 @@ a finding (exit `1`). ### Initialize: `init` -Scaffold a `.ghost/` package with the steering starter: a manifest, a starter `glossary.md`, a core `index.md` node, and demo nodes for stance, composition, anti-goals, patterns, exemplars, materials, and decisions. The demo content is there for inspiration and guidance. Replace it with real product truth before using it to steer generation. 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 with the skeleton starter: 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, four signature nodes as unanswered dials awaiting your brand's values, and `anti-goal.median` — the model's measured defaults as reject→replace rules, pruned per brand. 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 `--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/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 023f0217..10d15b7d 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -30,7 +30,7 @@ assembles advisory review packets after, but never grades output itself. You work through a short loop: ```bash -ghost init # scaffold .ghost/ with the steering starter +ghost init # scaffold .ghost/ with the skeleton starter ghost validate # artifact shape + node validity + kind-prefix check ghost gather # emit the menu for this task; your agent selects the truths it needs ghost pull # read the selected truths' full bodies @@ -80,7 +80,7 @@ by decision. One high-confidence truth beats an empty catalog. The CLI handles the deterministic package work. Your agent handles the authoring: interviewing you, working with the material you show it, drafting node prose, -and asking you to curate the claims. `ghost init` scaffolds the steering starter: `manifest.yml`, a starter `glossary.md`, a core `index.md` node, and demo nodes for stance, composition, anti-goals, patterns, exemplars, materials, and decisions. The demo content is there for inspiration and guidance. Replace its claims, paths, examples, and decisions with your real product truth before using it to steer generation. Use `ghost init --template minimal` when you only want the small manifest/glossary/index starter. +and asking you to curate the claims. `ghost init` scaffolds the skeleton starter: `manifest.yml`, a `glossary.md` declaring the grammar/signature/register/anti-goal kinds, a core `index.md`, six grammar nodes of value-free decision logic, four signature nodes as unanswered dials, and `anti-goal.median` — the model's measured defaults as reject→replace rules headed by a prune instruction. Nothing in the skeleton is fiction: grammar and the median floor hold for any brand, and each dial tells the agent to ask you or flag the gap rather than freehand a value. Answering the dials is how the skeleton becomes your brand. Use `ghost init --template minimal` when you only want the small manifest/glossary/index starter. ```bash ghost init From 8d83bbfbff78eed2dd0bc286f66ed2cf639fba7b Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 9 Jul 2026 10:15:10 -0400 Subject: [PATCH 5/5] ghost init --body: packed payloads + vessel-light body install (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init: packed-payload mechanism — dist payloads, byte-capable template seam * template: skeleton rides packed files * init: --body vessel-light — install the full inhabited package * checks: packed-artifact body probes — fonts and checks survive pack * docs: body install in getting-started + cli-reference; minor changeset * skill: adapting-a-starter — the seven-step starter transplant recipe (#211) * skill: adapting-a-starter recipe — the seven-step transplant procedure * review: resolve package-relative material locators when matching diff paths (#212) * review: resolve package-relative material locators when matching diff paths * demo: drop stale parked-branch reference from eval artifacts gitignore * median: one authored home — model truth stamped at init, prunable by contract (#213) * corpus: audit backports, check cross-wiring, terminology sweeps * median: one authored home — headings contract, payload check, all templates stamp the floor * vessel-light: re-derive median onto the headings contract; docs get the ownership reframe --- .changeset/adapting-a-starter.md | 5 + .changeset/body-install.md | 5 + .changeset/median-headings-vessel-docs.md | 5 + .changeset/median-one-home.md | 5 + .changeset/review-locator-matching.md | 5 + apps/docs/src/content/docs/cli-reference.mdx | 19 +- .../docs/src/content/docs/getting-started.mdx | 14 +- apps/docs/src/generated/cli-manifest.json | 10 +- install/manifest.json | 1 + packages/ghost/package.json | 2 +- packages/ghost/src/commands/checks-command.ts | 9 +- packages/ghost/src/commands/init-command.ts | 41 ++- packages/ghost/src/commands/review-command.ts | 1 + .../init-payloads/median/anti-goal.median.md | 86 ++++++ .../src/init-payloads/median/median-tells.md | 117 +++++++ .../src/init-payloads/skeleton/glossary.md | 44 +++ .../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/review/resolve.ts | 11 +- packages/ghost/src/review/review-packet.ts | 11 + packages/ghost/src/scan/check-scaffold.ts | 125 ++------ .../ghost/src/scan/fingerprint-package.ts | 56 +++- packages/ghost/src/scan/packed-payloads.ts | 72 +++++ packages/ghost/src/scan/skeleton-grammar.ts | 285 ------------------ packages/ghost/src/scan/skeleton-template.ts | 230 -------------- packages/ghost/src/scan/templates.ts | 121 +++++++- packages/ghost/src/skill-bundle/SKILL.md | 1 + .../references/adapting-a-starter.md | 102 +++++++ .../references/authoring-scenarios.md | 2 +- .../src/skill-bundle/references/blocks.md | 2 +- .../src/skill-bundle/references/capture.md | 10 +- packages/ghost/test/cli.test.ts | 199 +++++++++++- .../vessel-light/.ghost/anti-goal.median.md | 37 +-- .../.ghost/checks/median-tells.md | 54 ++-- .../.ghost/checks/motion-restraint.md | 3 +- .../.ghost/checks/relationships.md | 5 +- packages/vessel-light/.ghost/checks/values.md | 5 +- packages/vessel-light/.ghost/glossary.md | 10 +- .../.ghost/grammar.color-roles.md | 6 +- .../vessel-light/.ghost/grammar.motion.md | 2 +- packages/vessel-light/.ghost/index.md | 2 +- .../vessel-light/.ghost/signature.type.md | 3 +- packages/vessel-light/README.md | 2 +- packages/vessel-light/demo/.gitignore | 4 +- scripts/check-packed-package.mjs | 33 ++ scripts/check-release-tarball.mjs | 47 +++ scripts/sync-init-payloads.mjs | 39 +++ 56 files changed, 1393 insertions(+), 728 deletions(-) create mode 100644 .changeset/adapting-a-starter.md create mode 100644 .changeset/body-install.md create mode 100644 .changeset/median-headings-vessel-docs.md create mode 100644 .changeset/median-one-home.md create mode 100644 .changeset/review-locator-matching.md create mode 100644 packages/ghost/src/init-payloads/median/anti-goal.median.md create mode 100644 packages/ghost/src/init-payloads/median/median-tells.md create mode 100644 packages/ghost/src/init-payloads/skeleton/glossary.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.conversation.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.motion.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md create mode 100644 packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md create mode 100644 packages/ghost/src/init-payloads/skeleton/index.md create mode 100644 packages/ghost/src/init-payloads/skeleton/signature.palette.md create mode 100644 packages/ghost/src/init-payloads/skeleton/signature.shape.md create mode 100644 packages/ghost/src/init-payloads/skeleton/signature.temperature.md create mode 100644 packages/ghost/src/init-payloads/skeleton/signature.type.md create mode 100644 packages/ghost/src/scan/packed-payloads.ts delete mode 100644 packages/ghost/src/scan/skeleton-grammar.ts delete mode 100644 packages/ghost/src/scan/skeleton-template.ts create mode 100644 packages/ghost/src/skill-bundle/references/adapting-a-starter.md create mode 100644 scripts/sync-init-payloads.mjs diff --git a/.changeset/adapting-a-starter.md b/.changeset/adapting-a-starter.md new file mode 100644 index 00000000..362e661a --- /dev/null +++ b/.changeset/adapting-a-starter.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +The skill bundle gains the `adapting-a-starter` recipe: the seven-step transplant procedure for turning an installed starter — a body like vessel-light or the naked skeleton — into your own brand's fingerprint, plus guidance for consuming an unadapted starter honestly. diff --git a/.changeset/body-install.md b/.changeset/body-install.md new file mode 100644 index 00000000..667136cd --- /dev/null +++ b/.changeset/body-install.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +`ghost init` gains `--body vessel-light`: install a full inhabited fingerprint package — answered signature dials, a materials tree with tokens and embedded fonts, annotated reference compositions, and its own checks. Init template payloads now ship as packed files in dist, so bodies can carry binary materials. diff --git a/.changeset/median-headings-vessel-docs.md b/.changeset/median-headings-vessel-docs.md new file mode 100644 index 00000000..6067b73c --- /dev/null +++ b/.changeset/median-headings-vessel-docs.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Reframe the median as Ghost-stamped model truth and align Vessel's median check with heading anchors. diff --git a/.changeset/median-one-home.md b/.changeset/median-one-home.md new file mode 100644 index 00000000..388757b3 --- /dev/null +++ b/.changeset/median-one-home.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +The median floor gets one authored home: `anti-goal.median` rules become markdown headings with per-rule check references, so `ghost validate` warns on every flag orphaned by pruning. All init templates (`skeleton`, `minimal`, `composition`) now stamp the median node; `ghost checks init` copies the paired check from the packaged payload and skips it when the node is absent. diff --git a/.changeset/review-locator-matching.md b/.changeset/review-locator-matching.md new file mode 100644 index 00000000..c966d0fe --- /dev/null +++ b/.changeset/review-locator-matching.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +`ghost review` now resolves package-relative `materials/…` locators to their repo-relative form before matching diff paths, so packages living below the repo root offer their value checks correctly. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 02e6275a..26d17987 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -51,12 +51,29 @@ a finding (exit `1`). ### Initialize: `init` -Scaffold a `.ghost/` package with the skeleton starter: 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, four signature nodes as unanswered dials awaiting your brand's values, and `anti-goal.median` — the model's measured defaults as reject→replace rules, pruned per brand. 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. 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 `--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 one move. +Templates are shapes of emptiness — structures awaiting your truths. A +**body** is the same anatomy inhabited: answered signature dials, a +`materials/` tree (tokens, fonts, worked reference compositions), and its own +`checks/`. Install one with `--body`: + +```bash +ghost init --body vessel-light +``` + +`vessel-light` installs Vessel's design language at full strength — the +fork-axis corpus, `materials/tokens.css` owning every literal value, embedded +fonts, six annotated refs, and five checks, included automatically (no +`--with checks` needed; combining them is an error). The manifest id stays +`vessel-light` until you adapt the starter: renaming it is deliberately the +first step of making the brand yours. `--body` and `--template` are mutually +exclusive. + ```bash diff --git a/apps/docs/src/content/docs/getting-started.mdx b/apps/docs/src/content/docs/getting-started.mdx index 10d15b7d..3dd3cebe 100644 --- a/apps/docs/src/content/docs/getting-started.mdx +++ b/apps/docs/src/content/docs/getting-started.mdx @@ -80,13 +80,25 @@ by decision. One high-confidence truth beats an empty catalog. The CLI handles the deterministic package work. Your agent handles the authoring: interviewing you, working with the material you show it, drafting node prose, -and asking you to curate the claims. `ghost init` scaffolds the skeleton starter: `manifest.yml`, a `glossary.md` declaring the grammar/signature/register/anti-goal kinds, a core `index.md`, six grammar nodes of value-free decision logic, four signature nodes as unanswered dials, and `anti-goal.median` — the model's measured defaults as reject→replace rules headed by a prune instruction. Nothing in the skeleton is fiction: grammar and the median floor hold for any brand, and each dial tells the agent to ask you or flag the gap rather than freehand a value. Answering the dials is how the skeleton becomes your brand. Use `ghost init --template minimal` when you only want the small manifest/glossary/index starter. +and asking you to curate the claims. `ghost init` stamps `anti-goal.median` into every template and body; the median node is Ghost's measured knowledge of unsteered model behavior — model truth, not brand truth — owned (pruned, adapted) by you after init. The skeleton starter also includes `manifest.yml`, 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. Nothing in the skeleton is fiction: grammar and the median floor hold for any brand, and each dial tells the agent to ask you or flag the gap rather than freehand a value. Answering the dials is how the skeleton becomes your brand. Use `ghost init --template minimal` when you only want the small manifest/glossary/index starter. ```bash ghost init ghost validate ``` +Prefer starting from a working brand instead of an empty anatomy? Install a +**body** — the same skeleton with a real brand's values plugged in: answered +dials, a `materials/` tree with tokens and fonts, worked reference +compositions, and its own checks: + +```bash +ghost init --body vessel-light +``` + +The body's manifest keeps its own id (`vessel-light`) until you adapt it — +renaming the id is deliberately the first step of making the brand yours. + Each node's prose records a durable brand truth. While drafting, ask three questions of every truth: **why** (the stance — what this brand is, who it serves, what must remain true), **with what** (the materials — building blocks, diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index bb1772e3..99351c47 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-09T06:07:32.461Z", + "generatedAt": "2026-07-09T11:06:22.019Z", "tools": [ { "tool": "ghost", @@ -58,6 +58,14 @@ "takesValue": true, "negated": false }, + { + "rawName": "--body ", + "name": "body", + "description": "Init body to install: a full inhabited package (e.g. vessel-light)", + "default": null, + "takesValue": true, + "negated": false + }, { "rawName": "--with ", "name": "with", diff --git a/install/manifest.json b/install/manifest.json index 2595f730..951098a7 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -8,6 +8,7 @@ }, "files": [ "SKILL.md", + "references/adapting-a-starter.md", "references/authoring-scenarios.md", "references/blocks.md", "references/brief.md", diff --git a/packages/ghost/package.json b/packages/ghost/package.json index 18a4fe60..fb7a8ca5 100644 --- a/packages/ghost/package.json +++ b/packages/ghost/package.json @@ -69,7 +69,7 @@ "provenance": true }, "scripts": { - "build": "rm -rf dist tsconfig.tsbuildinfo && tsc --build --force && chmod +x dist/bin.js && node ../../scripts/link-package-bin.mjs && cp -r src/skill-bundle dist/skill-bundle", + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc --build --force && chmod +x dist/bin.js && node ../../scripts/link-package-bin.mjs && cp -r src/skill-bundle dist/skill-bundle && node ../../scripts/sync-init-payloads.mjs", "prepublishOnly": "pnpm build" }, "dependencies": { diff --git a/packages/ghost/src/commands/checks-command.ts b/packages/ghost/src/commands/checks-command.ts index e1dd4b32..2fa22d7a 100644 --- a/packages/ghost/src/commands/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -35,7 +35,11 @@ export function registerChecksCommand(cli: CAC): void { if (opts.format === "json") { process.stdout.write( `${JSON.stringify( - { dir: result.dir, written: result.written }, + { + dir: result.dir, + written: result.written, + skipped: result.skipped, + }, null, 2, )}\n`, @@ -45,6 +49,9 @@ export function registerChecksCommand(cli: CAC): void { for (const file of result.written) { process.stdout.write(` ${file}\n`); } + for (const file of result.skipped) { + process.stdout.write(` skipped ${file}\n`); + } } process.exit(0); } catch (err) { diff --git a/packages/ghost/src/commands/init-command.ts b/packages/ghost/src/commands/init-command.ts index b9cb7029..02946d3c 100644 --- a/packages/ghost/src/commands/init-command.ts +++ b/packages/ghost/src/commands/init-command.ts @@ -3,6 +3,7 @@ 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"; export function registerInitCommand(cli: CAC): void { @@ -16,6 +17,10 @@ export function registerInitCommand(cli: CAC): void { "--template ", "Init template to scaffold (default: skeleton)", ) + .option( + "--body ", + "Init body to install: a full inhabited package (e.g. vessel-light)", + ) .option( "--with ", "Comma-separated capabilities to add after scaffolding (e.g. checks)", @@ -35,6 +40,22 @@ export function registerInitCommand(cli: CAC): void { 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") { + throw new UsageError( + `Unknown --with capability '${id}'. Available: checks.`, + ); + } + } + const body = + typeof opts.body === "string" ? getInitBody(opts.body) : undefined; + if (body?.includesChecks && withIds.includes("checks")) { + throw new UsageError( + `--with checks is redundant with --body ${body.name} — this body already includes its own checks/.`, + ); + } + const result = await initFingerprintPackage( exactPackage ?? ghostDir, process.cwd(), @@ -42,18 +63,10 @@ export function registerInitCommand(cli: CAC): void { ...(typeof opts.template === "string" ? { template: opts.template } : {}), + ...(typeof opts.body === "string" ? { body: opts.body } : {}), force: Boolean(opts.force), }, ); - - const withIds = parseWithCapabilities(opts.with); - for (const id of withIds) { - if (id !== "checks") { - throw new UsageError( - `Unknown --with capability '${id}'. Available: checks.`, - ); - } - } const addedChecks = withIds.includes("checks") ? await addChecksDir(result.paths.packageDir) : undefined; @@ -65,7 +78,12 @@ export function registerInitCommand(cli: CAC): void { dir: result.paths.dir, written: result.written, ...(addedChecks !== undefined - ? { checks: { written: addedChecks.written } } + ? { + checks: { + written: addedChecks.written, + skipped: addedChecks.skipped, + }, + } : {}), }, null, @@ -84,6 +102,9 @@ export function registerInitCommand(cli: CAC): void { for (const file of addedChecks.written) { process.stdout.write(` checks/${file}\n`); } + for (const file of addedChecks.skipped) { + process.stdout.write(` skipped checks/${file}\n`); + } } } process.exit(0); diff --git a/packages/ghost/src/commands/review-command.ts b/packages/ghost/src/commands/review-command.ts index eb314d3b..730e027c 100644 --- a/packages/ghost/src/commands/review-command.ts +++ b/packages/ghost/src/commands/review-command.ts @@ -53,6 +53,7 @@ export function registerReviewCommand(cli: CAC): void { diff: opts.diff, }); const packet = await buildReviewPacket(fingerprint, diffText, { + packageDir: paths.packageDir, runProbes: opts.probes !== false, cwd: process.cwd(), }); diff --git a/packages/ghost/src/init-payloads/median/anti-goal.median.md b/packages/ghost/src/init-payloads/median/anti-goal.median.md new file mode 100644 index 00000000..a0a5cefb --- /dev/null +++ b/packages/ghost/src/init-payloads/median/anti-goal.median.md @@ -0,0 +1,86 @@ +--- +description: "The model's median defaults this fingerprint refuses — gather for any greenfield visual surface or first-draft copy. Each rule is reject→replace; delete lines your brand legitimately violates." +--- + +This is the model's median, not your brand. Each rule is reject→replace. +Delete every line your brand legitimately violates — `ghost validate` will +warn on any check reference the deletion orphans — delete the paired flag too. + +These are not aesthetic opinions. Where a count is given, it is the measured +convergence of 300 unsteered generations across three frontier models (the +antimedian experiment): the defaults a model reaches for when nobody hands it +a brand. An output showing several of these tells reads as generated, +whatever else it does right. A deterministic floor rides alongside these +rules in the paired check — mechanical tells like gradient-filled text, +emoji as interface icons, em-dash pileups, and aphoristic cadence — +verified at review, never steered in prose. + +### Hover-lift +Reject hover-lift (`translateY` + growing shadow) as the default interaction +→ confirm with color and background change at the fingerprint's fast +duration. (measured: 341) + +### Indigo accent +Reject the indigo/blue/purple default accent (`#4f46e5`, `#2563eb`, +`#8b5cf6` family; purple/violet hue 260–310) → the fingerprint's declared +palette; absent one, monochrome plus a single functional accent. + +### Dark theme +Reject unprompted dark theme → the fingerprint's declared surface. +Dark-to-look-cool and light-to-be-safe are the same retreat from a decision. +(measured: 271) + +### Gradients +Reject gradient page and section backgrounds and gradient-filled CTAs → flat +surfaces in semantic roles. + +### Side-stripe +Reject side-stripe borders (a thick colored border on one side of a rounded +card) — the single most recognizable tell of AI-generated UI → a full +hairline border, a 4–8% surface tint, or a leading glyph. + +### Cream surface +Reject the cream/sand/beige default surface (warm off-white; token names +like `--cream`, `--sand`, `--parchment` are the tell) → a true off-white, +the brand's own hue, or a committed color; warmth via accent and type, not +the ground. + +### Glassmorphism +Reject glassmorphism (decorative backdrop blur) → flat surfaces with real +borders and the fingerprint's elevation tiers. + +### Chat bubbles +Reject chat bubbles with initials-circle avatars for assistant turns → +assistant text plain on the page surface; user turns marked compactly. + +### Stock copy +Reject stock template copy ("Simple, transparent pricing") → headings that +state what this product specifically does. + +### Celebration +Reject celebration copy and UI (exclamation success, confetti language) → +quiet factual confirmation. + +### Nested cards +Reject nested cards and everything-in-cards → one surface level per region; +interior hierarchy from spacing and type; spacing and alignment group +without card overhead. + +### Hero metric +Reject the hero-metric template (big number + small label + supporting stats +as default proof) → evidence specific to the product, or nothing. A +prominent metric showing real user data is fine. + +### Every button primary +Reject every button a primary button → one primary per view; the rest step +down the fingerprint's emphasis ladder. + +### Eyebrow kicker +Reject an eyebrow kicker on every section → at most one, where the register +sanctions it; one named kicker is voice, every-section is AI grammar. + +### Decorative motion +Reject decorative looping animation (pulse/float/shimmer) and uniform +fade-and-rise on every scrolled section → motion as evidence of state +change; loops only for genuine loading; decoration never compensates for +weak structure — fix spacing, copy, and hierarchy first. diff --git a/packages/ghost/src/init-payloads/median/median-tells.md b/packages/ghost/src/init-payloads/median/median-tells.md new file mode 100644 index 00000000..fd8e8acd --- /dev/null +++ b/packages/ghost/src/init-payloads/median/median-tells.md @@ -0,0 +1,117 @@ +--- +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 +--- + +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 — +`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`) + +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`) + +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`) + +Flag `linear-gradient` or `radial-gradient` as page or section +backgrounds, and gradient-filled buttons. (`anti-goal.median > Gradients`) + +Flag `backdrop-filter: blur` used for glassmorphism cards. +(`anti-goal.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 +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`) + +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`) + +Flag assistant messages rendered as bubbles with initials-circle avatars. +(`anti-goal.median > Chat bubbles`) + +Flag emoji used as icons or imagery in interface chrome. Text labels carry +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`) + +Flag exclamation-marked success copy, confetti language, and celebratory UI +("You did it!", "Awesome!"). Confirmation is quiet and factual. +(`anti-goal.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`) + +Deterministic floor — licensed by the median node, verified here, never +steered in prose: + +Flag text/background pairs below WCAG AA contrast: 4.5:1 for body text, +3:1 for large text (24px and up, or 18.7px bold and up). Check the worst +stop when the background is a gradient. + +Flag animation or transition without a `prefers-reduced-motion: reduce` +alternative — a crossfade or instant state change. + +Flag ad-hoc z-index values (`999`, `9999`, or any value outside a +declared scale). Layering wants a semantic scale, not an arms race. + +Flag `cubic-bezier` easings whose control points overshoot the 0–1 range, +and keyframe names matching bounce, elastic, wobble, or jiggle. + +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`) + +Flag five or more em-dashes in body copy in one view. + +Flag three or more instances of the aphoristic rebuttal cadence ("Not X. Y." +/ "Sentence. No qualifiers.") in one page's copy. + +Model-signature tells — skip any block whose model did not produce the diff +(advisory): + +Codex: flag a 1px border paired with a box-shadow of 16px blur or more on +the same element — pick a solid border or a tight shadow, not both. Flag +border-radius of 32px or more on cards, sections, or inputs unless an +answered shape dial (`signature.shape`) sanctions large radii — then it is +fidelity, not a tell. Flag 1px linear-gradient grid or repeating-stripe +backgrounds used as decoration. + +Gemini: flag `transform` (scale, rotate, translate) on `img` inside a +`:hover` rule, including group-hover utilities targeting a child image. +Animate the card's background, border, or shadow instead. diff --git a/packages/ghost/src/init-payloads/skeleton/glossary.md b/packages/ghost/src/init-payloads/skeleton/glossary.md new file mode 100644 index 00000000..053ffb08 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -0,0 +1,44 @@ +--- +kinds: + - name: grammar + - name: signature + - name: register + - name: anti-goal + posture: guard +--- + +# grammar + +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. + +# signature + +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. + +# register + +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 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. diff --git a/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md b/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md new file mode 100644 index 00000000..eb4f679d --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.color-roles.md @@ -0,0 +1,26 @@ +--- +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 new file mode 100644 index 00000000..fc2e1b63 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.conversation.md @@ -0,0 +1,23 @@ +--- +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 new file mode 100644 index 00000000..97f2c62b --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.hierarchy.md @@ -0,0 +1,39 @@ +--- +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 new file mode 100644 index 00000000..a2d95094 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.motion.md @@ -0,0 +1,24 @@ +--- +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 new file mode 100644 index 00000000..172b72ef --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.rhythm.md @@ -0,0 +1,23 @@ +--- +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 new file mode 100644 index 00000000..b9b4ce24 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/grammar.surfaces.md @@ -0,0 +1,26 @@ +--- +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 new file mode 100644 index 00000000..303364f0 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/index.md @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000..530d8784 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/signature.palette.md @@ -0,0 +1,24 @@ +--- +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 new file mode 100644 index 00000000..3f8ceb2d --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/signature.shape.md @@ -0,0 +1,19 @@ +--- +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 new file mode 100644 index 00000000..72ffad88 --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/signature.temperature.md @@ -0,0 +1,20 @@ +--- +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 new file mode 100644 index 00000000..e838028b --- /dev/null +++ b/packages/ghost/src/init-payloads/skeleton/signature.type.md @@ -0,0 +1,22 @@ +--- +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/review/resolve.ts b/packages/ghost/src/review/resolve.ts index d7e79043..10c6c909 100644 --- a/packages/ghost/src/review/resolve.ts +++ b/packages/ghost/src/review/resolve.ts @@ -1,11 +1,12 @@ import { classifyMaterialLocator, type GhostCatalog, + type MaterialTransportOptions, + materialLocatorClaimsPath, parseSourceRef, } from "#ghost-core"; import type { LoadedCheck } from "../scan/check-files.js"; import { parseTouchedFiles, type TouchedFile } from "./diff.js"; -import { matchesGlob } from "./glob.js"; export interface MatchedMaterialNode { id: string; @@ -39,6 +40,7 @@ export function resolveReview( catalog: GhostCatalog, checks: Map, diffText: string, + transport: MaterialTransportOptions, ): ReviewResolution { const touchedFiles = parseTouchedFiles(diffText); const materialNodeIds = new Set(); @@ -55,8 +57,13 @@ export function resolveReview( if (localLocators.length === 0) continue; materialNodeIds.add(node.id); for (const file of touchedFiles) { + // Resolve each locator the same way validate does: package-relative + // `materials/…` locators expand to their repo-relative form before + // matching diff paths (which git emits repo-relative). Matching the + // raw locator text silently missed every exact-path locator whenever + // the package lives below the repo root. const locators = localLocators.filter((locator) => - matchesGlob(locator, file.path), + materialLocatorClaimsPath(locator, file.path, transport), ); if (locators.length === 0) continue; claimedFiles.add(file.path); diff --git a/packages/ghost/src/review/review-packet.ts b/packages/ghost/src/review/review-packet.ts index 9ed87017..34f72895 100644 --- a/packages/ghost/src/review/review-packet.ts +++ b/packages/ghost/src/review/review-packet.ts @@ -1,5 +1,8 @@ +import { join } from "node:path"; import type { GhostCatalogNode } from "#ghost-core"; +import { GHOST_MATERIALS_DIR } from "../scan/constants.js"; import type { LoadedFingerprintPackage } from "../scan/fingerprint-package.js"; +import { resolveGitRoot } from "../scan/package-paths.js"; import { type BaselineProse, resolveBaseline } from "./baseline.js"; import { type ProbeEvidence, runProbe } from "./probes.js"; import type { CoverageGap } from "./resolve.js"; @@ -38,6 +41,8 @@ export interface ReviewPacket { } export interface BuildReviewPacketOptions { + /** Absolute path of the fingerprint package directory (default: cwd/.ghost). */ + packageDir?: string; runProbes?: boolean; cwd?: string; probeTimeoutMs?: number; @@ -48,10 +53,16 @@ export async function buildReviewPacket( diffText: string, options: BuildReviewPacketOptions = {}, ): Promise { + const cwd = options.cwd ?? process.cwd(); const resolution = resolveReview( fingerprint.catalog, fingerprint.checks, diffText, + { + repoRoot: await resolveGitRoot(cwd), + packageDir: options.packageDir ?? join(cwd, ".ghost"), + materialsDir: GHOST_MATERIALS_DIR, + }, ); const materialNodes: PacketMaterialNode[] = resolution.materialNodes.map( diff --git a/packages/ghost/src/scan/check-scaffold.ts b/packages/ghost/src/scan/check-scaffold.ts index d646edc5..4e23f9d9 100644 --- a/packages/ghost/src/scan/check-scaffold.ts +++ b/packages/ghost/src/scan/check-scaffold.ts @@ -2,109 +2,11 @@ import { access, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { UsageError } from "#ghost-core"; import { GHOST_CHECKS_DIR } from "./check-files.js"; +import { loadPayloadFile } from "./packed-payloads.js"; const EXAMPLE_CHECK_FILENAME = "example.md.example"; const MEDIAN_TELLS_FILENAME = "median-tells.md"; -const MEDIAN_TELLS_CONTENT = `--- -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 - - grammar.motion - - grammar.conversation ---- - -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 flag here — -delete both 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 > rule: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. (\`rule: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. (\`rule:median-dark-theme\`) - -Flag \`linear-gradient\` or \`radial-gradient\` as page or section -backgrounds, and gradient-filled buttons. (\`rule:median-gradients\`) - -Flag \`backdrop-filter: blur\` used for glassmorphism cards. -(\`rule: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 -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. (\`rule: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. (\`rule:median-cream-surface\`) - -Flag assistant messages rendered as bubbles with initials-circle avatars. -(\`rule:median-chat-bubbles\`) - -Flag emoji used as icons or imagery in interface chrome. Text labels carry -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. -(\`rule:median-stock-copy\`) - -Deterministic floor — licensed by the median node, verified here, never -steered in prose: - -Flag text/background pairs below WCAG AA contrast: 4.5:1 for body text, -3:1 for large text (24px and up, or 18.7px bold and up). Check the worst -stop when the background is a gradient. - -Flag animation or transition without a \`prefers-reduced-motion: reduce\` -alternative — a crossfade or instant state change. - -Flag ad-hoc z-index values (\`999\`, \`9999\`, or any value outside a -declared scale). Layering wants a semantic scale, not an arms race. - -Flag \`cubic-bezier\` easings whose control points overshoot the 0–1 range, -and keyframe names matching bounce, elastic, wobble, or jiggle. - -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. (\`rule:median-eyebrow-kicker\`) - -Flag five or more em-dashes in body copy in one view. - -Flag three or more instances of the aphoristic rebuttal cadence ("Not X. Y." -/ "Sentence. No qualifiers.") in one page's copy. - -Model-signature tells — skip any block whose model did not produce the diff -(advisory): - -Codex: flag a 1px border paired with a box-shadow of 16px blur or more on -the same element — pick a solid border or a tight shadow, not both. Flag -border-radius of 32px or more on cards, sections, or inputs. Flag 1px -linear-gradient grid or repeating-stripe backgrounds used as decoration. - -Gemini: flag \`transform\` (scale, rotate, translate) on \`img\` inside a -\`:hover\` rule, including group-hover utilities targeting a child image. -Animate the card's background, border, or shadow instead. -`; - const EXAMPLE_CHECK_CONTENT = `--- name: logo-clearspace-holds description: Logo usage preserves clearspace, lockup integrity, and glyph rules. @@ -121,6 +23,7 @@ is used when the full lockup is required. export interface AddChecksResult { dir: string; written: string[]; + skipped: string[]; } /** Scaffold the flat `.ghost/checks/` directory with an example check. */ @@ -132,20 +35,32 @@ export async function addChecksDir( throw new UsageError(`checks/ already exists at ${checksDir}.`); } + const written: string[] = []; + const skipped: string[] = []; + await mkdir(checksDir, { recursive: true }); - await writeFile( - join(checksDir, MEDIAN_TELLS_FILENAME), - MEDIAN_TELLS_CONTENT, - "utf-8", - ); + if (await exists(join(packageDir, "anti-goal.median.md"))) { + await writeFile( + join(checksDir, MEDIAN_TELLS_FILENAME), + await loadPayloadFile("median", MEDIAN_TELLS_FILENAME), + "utf-8", + ); + written.push(MEDIAN_TELLS_FILENAME); + } else { + skipped.push(`${MEDIAN_TELLS_FILENAME} (no anti-goal.median node)`); + } + await writeFile( join(checksDir, EXAMPLE_CHECK_FILENAME), EXAMPLE_CHECK_CONTENT, "utf-8", ); + written.push(EXAMPLE_CHECK_FILENAME); + return { dir: checksDir, - written: [MEDIAN_TELLS_FILENAME, EXAMPLE_CHECK_FILENAME], + written, + skipped, }; } diff --git a/packages/ghost/src/scan/fingerprint-package.ts b/packages/ghost/src/scan/fingerprint-package.ts index 6d8de169..2255e023 100644 --- a/packages/ghost/src/scan/fingerprint-package.ts +++ b/packages/ghost/src/scan/fingerprint-package.ts @@ -33,7 +33,10 @@ import type { LintIssue, LintReport } from "./lint.js"; import { resolveGitRoot } from "./package-paths.js"; import { DEFAULT_TEMPLATE_NAME, + type GhostInitTemplate, + getInitBody, getInitTemplate, + listInitBodies, listInitTemplates, } from "./templates.js"; @@ -70,8 +73,14 @@ export interface LoadedFingerprintPackage { } export interface InitFingerprintPackageOptions { - /** Init template name (default: "steering"). */ + /** Init template name (default: "skeleton"). Mutually exclusive with `body`. */ template?: string; + /** + * Init body name (e.g. "vessel-light"): a full inhabited package with + * answered dials, materials, and its own checks. Mutually exclusive with + * `template`. + */ + body?: string; force?: boolean; } @@ -103,18 +112,36 @@ export async function initFingerprintPackage( cwd = process.cwd(), options: InitFingerprintPackageOptions = {}, ): Promise { - const templateName = options.template ?? DEFAULT_TEMPLATE_NAME; - const template = getInitTemplate(templateName); - if (!template) { + if (options.body !== undefined && options.template !== undefined) { throw new UsageError( - `Unknown init template '${templateName}'. Available: ${listInitTemplates().join(", ")}.`, + "--body and --template are mutually exclusive. A template is a shape of emptiness; a body is a full inhabited package — pick one.", ); } + let source: Pick; + if (options.body !== undefined) { + const body = getInitBody(options.body); + if (!body) { + throw new UsageError( + `Unknown init body '${options.body}'. Available: ${listInitBodies().join(", ")}.`, + ); + } + source = body; + } else { + const templateName = options.template ?? DEFAULT_TEMPLATE_NAME; + const template = getInitTemplate(templateName); + if (!template) { + throw new UsageError( + `Unknown init template '${templateName}'. Available: ${listInitTemplates().join(", ")}.`, + ); + } + source = template; + } + const paths = resolveFingerprintPackage(dirArg, cwd); await mkdir(paths.packageDir, { recursive: true }); - const files = template.files().map((file) => ({ + const files = (await source.files()).map((file) => ({ relativePath: file.relativePath, path: join(paths.packageDir, file.relativePath), content: file.content, @@ -137,14 +164,17 @@ export async function initFingerprintPackage( async function writeInitFile( path: string, - content: string, + content: string | Uint8Array, force = false, ): Promise { try { - await writeFile(path, content, { - encoding: "utf-8", - flag: force ? "w" : "wx", - }); + await writeFile( + path, + content, + typeof content === "string" + ? { encoding: "utf-8", flag: force ? "w" : "wx" } + : { flag: force ? "w" : "wx" }, + ); } catch (err) { if (!force && isExistingPathError(err)) { throw new UsageError( @@ -395,7 +425,7 @@ function lintCheckReferences( issues.push({ severity: "warning", rule: "check-reference-unresolved", - message: `check reference '${raw}' does not resolve to a fingerprint node`, + 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; @@ -407,7 +437,7 @@ function lintCheckReferences( issues.push({ severity: "warning", rule: "check-reference-heading-missing", - message: `check reference '${raw}' names a heading that was not found`, + 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`, }); } diff --git a/packages/ghost/src/scan/packed-payloads.ts b/packages/ghost/src/scan/packed-payloads.ts new file mode 100644 index 00000000..2c7142cb --- /dev/null +++ b/packages/ghost/src/scan/packed-payloads.ts @@ -0,0 +1,72 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { TemplateFile } from "./templates.js"; + +/** + * Payload roots, first match wins. At runtime this module lives in + * `dist/scan/`, so `../init-payloads` is the packed payload dir. Under + * vitest it runs from `src/scan/`, where only committed payloads (skeleton) + * exist — synced payloads (vessel-light) resolve through the built + * `dist/init-payloads` sibling. + */ +const INIT_PAYLOAD_ROOTS = [ + fileURLToPath(new URL("../init-payloads", import.meta.url)), + fileURLToPath(new URL("../../dist/init-payloads", import.meta.url)), +]; + +const BINARY_EXTENSIONS = new Set([".woff", ".woff2"]); + +export async function loadPackedPayload(name: string): Promise { + const payloadDir = resolvePayloadDir(name); + const files = await listPayloadFiles(payloadDir); + + return Promise.all( + files.map(async (path) => ({ + relativePath: relative(payloadDir, path), + content: await readPayloadFile(path), + })), + ); +} + +export async function loadPayloadFile( + payload: string, + relativePath: string, +): Promise { + return readFile(join(resolvePayloadDir(payload), relativePath), "utf-8"); +} + +function resolvePayloadDir(name: string): string { + return ( + INIT_PAYLOAD_ROOTS.map((root) => join(root, name)).find((dir) => + existsSync(dir), + ) ?? join(INIT_PAYLOAD_ROOTS[0], name) + ); +} + +async function listPayloadFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return listPayloadFiles(path); + if (entry.isFile()) return [path]; + return []; + }), + ); + + return files.flat().sort((a, b) => a.localeCompare(b)); +} + +async function readPayloadFile(path: string): Promise { + const bytes = await readFile(path); + if (isBinaryPayloadPath(path)) return bytes; + return bytes.toString("utf-8"); +} + +function isBinaryPayloadPath(path: string): boolean { + const dot = path.lastIndexOf("."); + if (dot === -1) return false; + return BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase()); +} diff --git a/packages/ghost/src/scan/skeleton-grammar.ts b/packages/ghost/src/scan/skeleton-grammar.ts deleted file mode 100644 index f6b1fb77..00000000 --- a/packages/ghost/src/scan/skeleton-grammar.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { TemplateFile } from "./templates.js"; - -/** - * The skeleton template's law tier: the median floor plus the six grammar - * nodes. These are the parts of the default fingerprint that are safe to - * consume verbatim and survive any future rebrand — they speak in token - * roles and closed sets, never in literal brand values. The signature dials - * live in `skeleton-template.ts`. - */ -export const SKELETON_GRAMMAR_FILES: TemplateFile[] = [ - { - relativePath: "anti-goal.median.md", - content: `--- -description: "The model's median defaults this fingerprint refuses — gather for any greenfield visual surface or first-draft copy. Each rule is reject→replace; delete lines your brand legitimately violates." ---- - -This is the model's median, not your brand. Each rule is reject→replace. -Delete every line your brand legitimately violates — \`ghost validate\` will -surface any check the deletion orphans. - -These are not aesthetic opinions. Where a count is given, it is the measured -convergence of 300 unsteered generations across three frontier models (the -antimedian experiment): the defaults a model reaches for when nobody hands it -a brand. An output showing several of these tells reads as generated, -whatever else it does right. A deterministic floor rides alongside these -rules in checks — contrast, reduced motion, sane z-index, no overshoot -easing — verified at review, never steered in prose. - - -Reject hover-lift (\`translateY\` + growing shadow) as the default interaction -→ confirm with color and background change at the fingerprint's fast -duration. (measured: 341) - - -Reject the indigo/blue/purple default accent (\`#4f46e5\`, \`#2563eb\`, -\`#8b5cf6\` family; purple/violet hue 260–310) → the fingerprint's declared -palette; absent one, monochrome plus a single functional accent. - - -Reject unprompted dark theme → the fingerprint's declared surface. -Dark-to-look-cool and light-to-be-safe are the same retreat from a decision. -(measured: 271) - - -Reject gradient page and section backgrounds and gradient-filled CTAs → flat -surfaces in semantic roles. - - -Reject side-stripe borders (a thick colored border on one side of a rounded -card) — the single most recognizable tell of AI-generated UI → a full -hairline border, a 4–8% surface tint, or a leading glyph. - - -Reject the cream/sand/beige default surface (warm off-white; token names -like \`--cream\`, \`--sand\`, \`--parchment\` are the tell) → a true off-white, -the brand's own hue, or a committed color; warmth via accent and type, not -the ground. - - -Reject glassmorphism (decorative backdrop blur) → flat surfaces with real -borders and the fingerprint's elevation tiers. - - -Reject chat bubbles with initials-circle avatars for assistant turns → -assistant text plain on the page surface; user turns marked compactly. - - -Reject stock template copy ("Simple, transparent pricing") → headings that -state what this product specifically does. - - -Reject celebration copy and UI (exclamation success, confetti language) → -quiet factual confirmation. - - -Reject nested cards and everything-in-cards → one surface level per region; -interior hierarchy from spacing and type; spacing and alignment group -without card overhead. - - -Reject the hero-metric template (big number + small label + supporting stats -as default proof) → evidence specific to the product, or nothing. A -prominent metric showing real user data is fine. - - -Reject every button a primary button → one primary per view; the rest step -down the fingerprint's emphasis ladder. - - -Reject an eyebrow kicker on every section → at most one, where the register -sanctions it; one named kicker is voice, every-section is AI grammar. - - -Reject decorative looping animation (pulse/float/shimmer) and uniform -fade-and-rise on every scrolled section → motion as evidence of state -change; loops only for genuine loading; decoration never compensates for -weak structure — fix spacing, copy, and hierarchy first. -`, - }, - { - relativePath: "grammar.hierarchy.md", - content: `--- -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. -`, - }, - { - relativePath: "grammar.rhythm.md", - content: `--- -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. -`, - }, - { - relativePath: "grammar.surfaces.md", - content: `--- -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. -`, - }, - { - relativePath: "grammar.motion.md", - content: `--- -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. -`, - }, - { - relativePath: "grammar.color-roles.md", - content: `--- -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. -`, - }, - { - relativePath: "grammar.conversation.md", - content: `--- -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/scan/skeleton-template.ts b/packages/ghost/src/scan/skeleton-template.ts deleted file mode 100644 index c588c30c..00000000 --- a/packages/ghost/src/scan/skeleton-template.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { SKELETON_GRAMMAR_FILES } from "./skeleton-grammar.js"; -import type { GhostInitTemplate, TemplateFile } from "./templates.js"; - -interface SkeletonTemplateDeps { - manifestFile(): TemplateFile; - gitignoreFile(): TemplateFile; -} - -/** - * The skeleton starter: the default `ghost init` fingerprint. It carries the - * rebrand-safe law tier (the median floor + six grammar nodes, in - * `skeleton-grammar.ts`) and leaves the four signature dials explicitly - * unanswered — each dial states its fixed relationship, asks its question, - * and forbids freehanding a value as brand-backed. No brand values ship in - * this template; the corpus validates clean with zero warnings as written. - */ -export function createSkeletonTemplate({ - manifestFile, - gitignoreFile, -}: SkeletonTemplateDeps): GhostInitTemplate { - return { - name: "skeleton", - description: - "Naked skeleton: the median floor + grammar law, with the signature dials left unanswered.", - files() { - return [ - manifestFile(), - gitignoreFile(), - glossaryFile(), - indexFile(), - ...SKELETON_GRAMMAR_FILES, - ...SIGNATURE_DIAL_FILES, - ]; - }, - }; -} - -function glossaryFile(): TemplateFile { - return { - relativePath: "glossary.md", - content: `--- -kinds: - - name: grammar - - name: signature - - name: register - - name: anti-goal - posture: guard ---- - -# grammar - -The brand's decision logic: closed sets, role vocabularies, and assembly -rules, stated in token roles and never in literal values. Grammar survives a -rebrand unchanged — swap every value and these nodes still hold. Gather -grammar before inventing structure. - -# signature - -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. - -# register - -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 ghost skill bundle teaches authoring: how to answer the dials, add -registers, and grow this skeleton into a real fingerprint. -`, - }; -} - -function indexFile(): TemplateFile { - return { - relativePath: "index.md", - content: `--- -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 rebrand-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 future rebrand — 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. -`, - }; -} - -/** - * The four signature dials, each in the three-part unanswered shape: - * (1) the unanswered declaration, (2) the fixed relationship carried from - * the source corpus, (3) the open question + answer instruction + - * provisional-generation clause. No brand values appear anywhere. - */ -const SIGNATURE_DIAL_FILES: TemplateFile[] = [ - { - relativePath: "signature.shape.md", - content: `--- -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. -`, - }, - { - relativePath: "signature.palette.md", - content: `--- -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. -`, - }, - { - relativePath: "signature.type.md", - content: `--- -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. -`, - }, - { - relativePath: "signature.temperature.md", - content: `--- -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/scan/templates.ts b/packages/ghost/src/scan/templates.ts index 74605b80..8de78dac 100644 --- a/packages/ghost/src/scan/templates.ts +++ b/packages/ghost/src/scan/templates.ts @@ -3,14 +3,14 @@ import { GHOST_EVENTS_FILENAME, LEGACY_PULL_HISTORY_FILENAME, } from "./constants.js"; -import { createSkeletonTemplate } from "./skeleton-template.js"; +import { loadPackedPayload, loadPayloadFile } from "./packed-payloads.js"; /** * A single seed file an `init` template writes, relative to the package dir. */ export interface TemplateFile { /** Path relative to the package directory (e.g. "principle.voice.md"). */ relativePath: string; - content: string; + content: string | Uint8Array; } /** * An `init` template: a pure description of the seed files a fresh node package @@ -21,7 +21,7 @@ export interface TemplateFile { export interface GhostInitTemplate { name: string; description: string; - files(): TemplateFile[]; + files(): TemplateFile[] | Promise; } function manifestFile(): TemplateFile { return { @@ -52,7 +52,8 @@ const MINIMAL_TEMPLATE: GhostInitTemplate = { name: "minimal", description: "Minimal node package: manifest + glossary + a starter index node.", - files() { + async files() { + const medianFile = await medianTemplateFile(); return [ manifestFile(), gitignoreFile(), @@ -87,6 +88,8 @@ 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. # asset @@ -127,6 +130,7 @@ When a truth is narrower, state the condition in the prose — the situation whe it applies — never a filing destination. `, }, + medianFile, ]; }, }; @@ -146,7 +150,8 @@ const COMPOSITION_TEMPLATE: GhostInitTemplate = { name: "composition", description: "Composition starter: minimal files + an invariants floor and a worked bound/open pattern.", - files() { + async files() { + const medianFile = await medianTemplateFile(); return [ manifestFile(), gitignoreFile(), @@ -185,6 +190,8 @@ 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. # asset @@ -297,14 +304,55 @@ When a blessed render of this pattern exists, add an \`exemplar.*\` node with \`materials\` pointing at the screenshot and the implementation path. `, }, + medianFile, ]; }, }; -const SKELETON_TEMPLATE = createSkeletonTemplate({ - manifestFile, - gitignoreFile, -}); +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", + ].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.", + async files() { + const skeletonFiles = [ + ...(await loadPackedPayload("skeleton")), + await medianTemplateFile(), + ]; + skeletonFiles.sort( + (a, b) => + (SKELETON_FILE_ORDER.get(a.relativePath) ?? Number.MAX_SAFE_INTEGER) - + (SKELETON_FILE_ORDER.get(b.relativePath) ?? + Number.MAX_SAFE_INTEGER) || + a.relativePath.localeCompare(b.relativePath), + ); + return [manifestFile(), gitignoreFile(), ...skeletonFiles]; + }, +}; + +async function medianTemplateFile(): Promise { + return { + relativePath: "anti-goal.median.md", + content: await loadPayloadFile("median", "anti-goal.median.md"), + }; +} const TEMPLATES = new Map([ [MINIMAL_TEMPLATE.name, MINIMAL_TEMPLATE], @@ -312,6 +360,61 @@ const TEMPLATES = new Map([ [SKELETON_TEMPLATE.name, SKELETON_TEMPLATE], ]); +/** + * An init body: a full inhabited fingerprint package — answered signature + * dials, materials, refs, and its own checks. Templates are shapes of + * emptiness awaiting the owner's truths; a body is the same anatomy with a + * real brand's values plugged in. Bodies keep their own manifest id (e.g. + * `vessel-light`) so an unadapted install stays honestly labeled — changing + * the id is step one of adapting the starter, an explicit human act. + */ +export interface GhostInitBody { + name: string; + description: string; + /** Whether the body payload ships its own `checks/` directory. */ + includesChecks: boolean; + files(): Promise; +} + +const VESSEL_LIGHT_BODY: GhostInitBody = { + name: "vessel-light", + description: + "Vessel's design language at full strength: corpus, tokens, fonts, refs, and checks.", + includesChecks: true, + async files() { + const payload = await loadPackedPayload("vessel-light"); + payload.sort((a, b) => { + const ao = BODY_FILE_ORDER.get(a.relativePath); + const bo = BODY_FILE_ORDER.get(b.relativePath); + return ( + (ao ?? Number.MAX_SAFE_INTEGER) - (bo ?? Number.MAX_SAFE_INTEGER) || + a.relativePath.localeCompare(b.relativePath) + ); + }); + // The payload carries its own manifest.yml (id stays `vessel-light`); + // only the gitignore is generated locally. + return [gitignoreFile(), ...payload]; + }, +}; + +/** Listing order for the body scaffold output: anchors first, then the rest. */ +const BODY_FILE_ORDER = new Map( + ["manifest.yml", "glossary.md", "index.md"].map((path, i) => [path, i]), +); + +const BODIES = new Map([ + [VESSEL_LIGHT_BODY.name, VESSEL_LIGHT_BODY], +]); + +/** Look up a registered init body by name. */ +export function getInitBody(name: string): GhostInitBody | undefined { + return BODIES.get(name); +} + +export function listInitBodies(): string[] { + return [...BODIES.keys()]; +} + export const DEFAULT_TEMPLATE_NAME = SKELETON_TEMPLATE.name; /** Look up a registered init template by name. */ diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 414bfcc0..a54843df 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -110,6 +110,7 @@ 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 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). diff --git a/packages/ghost/src/skill-bundle/references/adapting-a-starter.md b/packages/ghost/src/skill-bundle/references/adapting-a-starter.md new file mode 100644 index 00000000..31792f16 --- /dev/null +++ b/packages/ghost/src/skill-bundle/references/adapting-a-starter.md @@ -0,0 +1,102 @@ +--- +name: adapting-a-starter +description: Transplant a starter fingerprint — a body like vessel-light or the naked skeleton — into your own brand, one procedure from manifest id to regenerated refs. +handoffs: + - label: Validate the adapted package + command: ghost validate --format json + prompt: Does the adapted fingerprint validate, and did any pruned rule orphan a check reference? +--- + +# Recipe: Adapt A Starter Fingerprint + +**Goal:** turn an installed starter — a full body (`ghost init --body +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: + +| 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. | + +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. + +## The procedure + +1. **Change the manifest id.** Edit `id:` in `manifest.yml` to your brand's + 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. + +## Consuming an unadapted starter + +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. +- 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. + +## 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 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 + act of ownership, the one step an agent should not take alone. diff --git a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md index f244ae32..3bef7fb1 100644 --- a/packages/ghost/src/skill-bundle/references/authoring-scenarios.md +++ b/packages/ghost/src/skill-bundle/references/authoring-scenarios.md @@ -86,7 +86,7 @@ and enforced in review, not repeated as the model's main example. ## 4. Draft The Nodes Write the smallest useful set of nodes, each a purpose-coherent prose truth with -a one-line `description`, named `..md` (or a bare slug when no kind is present). Ask three questions of each body: why (the stance), with what +a one-line `description`, named `..md` (or a bare slug when no kind is present). Ask three questions of each node body: why (the stance), with what (the materials), and how it is assembled (the patterns). These are drafting prompts, not fields. diff --git a/packages/ghost/src/skill-bundle/references/blocks.md b/packages/ghost/src/skill-bundle/references/blocks.md index b6a2df40..6d6f31e0 100644 --- a/packages/ghost/src/skill-bundle/references/blocks.md +++ b/packages/ghost/src/skill-bundle/references/blocks.md @@ -57,7 +57,7 @@ Neither is correct. A concrete block node is a deliberate trade, not a leak. reasoning, sources…) earns **one short prose body**. This is what the method is for. - The **composer middle** (card, table, form, sidebar…) is a call to weigh. Give - it a body when its arrangement carries a stance worth matching. + it a prose body when its arrangement carries a stance worth matching. If a primitive ever seems to need stance guidance, that is a signal it is doing a composer's job. Promote the pattern into a node; do not write a body on the diff --git a/packages/ghost/src/skill-bundle/references/capture.md b/packages/ghost/src/skill-bundle/references/capture.md index 2e4324fd..76655ebb 100644 --- a/packages/ghost/src/skill-bundle/references/capture.md +++ b/packages/ghost/src/skill-bundle/references/capture.md @@ -181,7 +181,7 @@ action beats completeness... `materials`, a substantial fenced example, or a `## Skeleton`. You do not declare a separate type. -## What a body answers +## What a node body answers While drafting, ask three questions of every truth — *why* (the stance), *with what* (the materials), and *how it is assembled* (the patterns). These are @@ -204,7 +204,7 @@ genuinely a different truth. ## Node prose stances -Node prose is steering payload. A generic sentence in a body averages every +Node prose is steering payload. A generic sentence in a node body averages every future generation toward the median, so hold drafts to these stances before the human sees them. @@ -226,13 +226,13 @@ Everywhere else: fits a competitor's brand, it is retrieval-dead; rewrite it until it could belong to no one else. - **Cut unratified hedges.** "Generally," "where possible," and "consider" in a - body mean the human never picked a side. Get the ratification or cut the + node body mean the human never picked a side. Get the ratification or cut the sentence. - **Ban brand-deck filler.** "Elevate," "delight," "seamless," "best-in-class," "empower." When a brand doc supplies these words, they are testimony to distill, never prose to keep. - **Settle the altitude on purpose.** Every truth is either claimed universal - or given its condition in the prose. A body that does neither was never + or given its condition in the prose. A node body that does neither was never curated for altitude; ask the human which it is. ## Score drafts before curation @@ -244,7 +244,7 @@ dimension: | --- | --- | | Testimony | Can you quote the human words or artifact this node came from? | | Discrimination | Does the description fit only this brand? | -| Force | Does the body decide something, or merely describe something? | +| Force | Does the node body decide something, or merely describe something? | | Altitude | Is it universal on purpose, or given its condition? | | Residue | Is it free of starter-demo prose and brand-deck filler? | diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index f604f791..ea313adb 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -281,7 +281,7 @@ describe("ghost CLI", () => { expect(median.stdout).toContain( "This is the model's median, not your brand.", ); - expect(median.stdout).toContain(""); + expect(median.stdout).toContain("### Side-stripe"); // The dials ship unanswered and forbid freehanding. const shape = await runCli(["pull", "signature.shape"], dir); @@ -331,11 +331,15 @@ 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).not.toContain("principle.stance.md"); expect(initOutput.written).not.toContain("decision.tradeoff.md"); - const validate = await runCli(["validate"], dir); + const validate = await runCli(["validate", "--format", "json"], dir); expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); }); it("keeps default and steering as aliases for the skeleton template", async () => { @@ -370,10 +374,14 @@ 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"); // The scaffolded package is valid as written. - const validate = await runCli(["validate"], dir); + const validate = await runCli(["validate", "--format", "json"], dir); expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); // The ladder nodes surface in the gather menu with their kinds. const gather = await runCli(["gather", "--format", "json"], dir); @@ -420,6 +428,83 @@ describe("ghost CLI", () => { expect(result.stderr).toContain("skeleton"); }); + it("installs the vessel-light body: full corpus, materials, checks", async () => { + const init = await runCli( + ["init", "--body", "vessel-light", "--format", "json"], + dir, + ); + expect(init.code).toBe(0); + const { written } = JSON.parse(init.stdout) as { written: string[] }; + + // The body is the inhabited package: corpus + tells + registers + + // materials tree + its own checks. No .events tape. + expect(written).toContain("manifest.yml"); + expect(written).toContain("anti-goal.median.md"); + expect(written).toContain("anti-goal.tells.md"); + expect(written).toContain("register.email.md"); + expect(written).toContain("signature.shape.md"); + expect(written).toContain("materials/tokens.css"); + expect(written).toContain("materials/fonts/HKGrotesk-Regular.woff2"); + expect(written).toContain("materials/ref/composition.form.html"); + expect(written).toContain("checks/median-tells.md"); + expect(written).toContain("checks/values.md"); + expect(written.some((p) => p.includes(".events"))).toBe(false); + + // Manifest id stays vessel-light: renaming it is step one of adapting + // the starter — an explicit human act, never pre-executed by init. + const manifest = await readFile( + join(dir, ".ghost", "manifest.yml"), + "utf-8", + ); + expect(manifest).toContain("id: vessel-light"); + + // Fonts survive the packed payload byte-identically. + const [installed, source] = await Promise.all([ + readFile( + join(dir, ".ghost", "materials", "fonts", "HKGrotesk-Regular.woff2"), + ), + readFile( + new URL( + "../../vessel-light/.ghost/materials/fonts/HKGrotesk-Regular.woff2", + import.meta.url, + ), + ), + ]); + expect(installed.equals(source)).toBe(true); + + // The installed body validates clean, checks included. + const validate = await runCli(["validate", "--format", "json"], dir); + expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); + }); + + it("rejects unknown bodies and contradictory body flags", async () => { + const unknown = await runCli(["init", "--body", "nope"], dir, { + allowNoExit: true, + }); + expect(unknown.code).toBe(2); + expect(unknown.stderr).toContain("Unknown init body 'nope'"); + expect(unknown.stderr).toContain("vessel-light"); + + const both = await runCli( + ["init", "--body", "vessel-light", "--template", "minimal"], + dir, + { allowNoExit: true }, + ); + expect(both.code).toBe(2); + expect(both.stderr).toContain("mutually exclusive"); + + const withChecks = await runCli( + ["init", "--body", "vessel-light", "--with", "checks"], + dir, + { allowNoExit: true }, + ); + expect(withChecks.code).toBe(2); + expect(withChecks.stderr).toContain("already includes its own checks/"); + }); + it("uses GHOST_PACKAGE_DIR as the default fingerprint package directory for init", async () => { const init = await runCli(["init", "--format", "json"], dir, { env: { GHOST_PACKAGE_DIR: ".agents/ghost" }, @@ -831,13 +916,13 @@ a deliberate provocation past the fingerprint — surfaced only on request const gather = await runCli(["gather", "--format", "json"], dir); expect(gather.code).toBe(0); expect(JSON.parse(gather.stdout).coverage).toEqual({ - nodes: 4, + nodes: 5, concrete: 1, - guards: 1, + guards: 2, }); const markdown = await runCli(["gather"], dir); expect(markdown.stdout).toContain( - "4 nodes · 1 carry concrete material · 1 guards", + "5 nodes · 1 carry concrete material · 2 guards", ); const steering = await runCli( @@ -1526,6 +1611,54 @@ a deliberate provocation past the fingerprint — surfaced only on request }); }); + it("review resolves package-relative locators when the package sits below the repo root", async () => { + // Regression: exact-path `materials/…` locators were matched as raw text + // against repo-relative diff paths, so a package below the repo root + // (e.g. packages/vessel-light/.ghost) never matched them — its value + // checks were silently dropped from the packet. + const packageDir = join("nested", "app", ".ghost"); + await runCli(["init", "--package", packageDir], dir); + await mkdir(join(dir, packageDir, "materials"), { recursive: true }); + await writeFile( + join(dir, packageDir, "materials", "tokens.css"), + ":root{}\n", + ); + await writeFile( + join(dir, packageDir, "asset.tokens.md"), + "---\ndescription: Tokens.\nmaterials:\n - materials/tokens.css\n---\n\nTokens prose.\n", + ); + await mkdir(join(dir, packageDir, "checks"), { recursive: true }); + await writeFile( + join(dir, packageDir, "checks", "token-discipline.md"), + "---\nname: token-discipline\ndescription: Tokens hold.\nseverity: high\nreferences:\n - asset.tokens\n---\n\nGrade token discipline.\n", + ); + const touched = `${packageDir.replaceAll("\\", "/")}/materials/tokens.css`; + const diff = [ + `diff --git a/${touched} b/${touched}`, + `--- a/${touched}`, + `+++ b/${touched}`, + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); + + const result = await runCli( + ["review", "--package", packageDir, "--diff=-", "--format", "json"], + dir, + { stdin: diff }, + ); + + expect(result.code).toBe(0); + const packet = JSON.parse(result.stdout); + expect(packet.materialNodes.map((n: { id: string }) => n.id)).toContain( + "asset.tokens", + ); + const check = packet.checks.find( + (c: { id: string }) => c.id === "token-discipline", + ); + expect(check).toMatchObject({ offered: "matched" }); + }); + it("review runs check probes as evidence and supports --no-probes", async () => { await runCli(["init", "--with", "checks"], dir); await writeFile( @@ -1751,6 +1884,7 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(add.code).toBe(0); const added = JSON.parse(add.stdout); expect(added.written).toEqual(["median-tells.md", "example.md.example"]); + expect(added.skipped).toEqual([]); await expect( readFile(join(dir, ".ghost", "checks", "example.md.example"), "utf-8"), ).resolves.toContain("references:"); @@ -1761,9 +1895,11 @@ a deliberate provocation past the fingerprint — surfaced only on request "utf-8", ); expect(median).toContain("anti-goal.median"); - expect(median).toContain("rule:median-hover-lift"); + expect(median).toContain("anti-goal.median > Hover-lift"); expect(median).toContain("prefers-reduced-motion"); - expect(median).toContain("delete both together"); + expect(median).toContain( + "`ghost validate` warns; delete the flag and its reference together.", + ); expect(median).not.toContain("Vessel"); // Running init twice is a usage error. @@ -1782,6 +1918,53 @@ a deliberate provocation past the fingerprint — surfaced only on request expect(unresolved).toEqual([]); }); + 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")); + + 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)", + ); + + await expect( + readFile(join(dir, ".ghost", "checks", "median-tells.md"), "utf-8"), + ).rejects.toThrow(); + + const validate = await runCli(["validate", "--format", "json"], dir); + expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.errors).toBe(0); + expect(report.warnings).toBe(0); + }); + + 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 median = await readFile(path, "utf-8"); + await writeFile( + path, + median.replace(/### Side-stripe\n[\s\S]*?(?=\n### Cream surface)/, ""), + ); + + const validate = await runCli(["validate", "--format", "json"], dir); + expect(validate.code).toBe(0); + const report = JSON.parse(validate.stdout); + expect(report.warnings).toBe(1); + expect(report.issues).toEqual([ + expect.objectContaining({ + severity: "warning", + rule: "check-reference-heading-missing", + message: expect.stringContaining("anti-goal.median > Side-stripe"), + }), + ]); + expect(report.issues[0].message).toContain( + "if you pruned this rule from the node, delete its paired flag in the check too", + ); + }); + it("checks rejects unknown actions", async () => { await runCli(["init"], dir); const result = await runCli(["checks", "remove"], dir); diff --git a/packages/vessel-light/.ghost/anti-goal.median.md b/packages/vessel-light/.ghost/anti-goal.median.md index 68e2bfd0..a0a5cefb 100644 --- a/packages/vessel-light/.ghost/anti-goal.median.md +++ b/packages/vessel-light/.ghost/anti-goal.median.md @@ -4,79 +4,82 @@ description: "The model's median defaults this fingerprint refuses — gather fo This is the model's median, not your brand. Each rule is reject→replace. Delete every line your brand legitimately violates — `ghost validate` will -surface any check the deletion orphans. +warn on any check reference the deletion orphans — delete the paired flag too. These are not aesthetic opinions. Where a count is given, it is the measured convergence of 300 unsteered generations across three frontier models (the antimedian experiment): the defaults a model reaches for when nobody hands it a brand. An output showing several of these tells reads as generated, -whatever else it does right. +whatever else it does right. A deterministic floor rides alongside these +rules in the paired check — mechanical tells like gradient-filled text, +emoji as interface icons, em-dash pileups, and aphoristic cadence — +verified at review, never steered in prose. - +### Hover-lift Reject hover-lift (`translateY` + growing shadow) as the default interaction → confirm with color and background change at the fingerprint's fast duration. (measured: 341) - +### Indigo accent Reject the indigo/blue/purple default accent (`#4f46e5`, `#2563eb`, `#8b5cf6` family; purple/violet hue 260–310) → the fingerprint's declared palette; absent one, monochrome plus a single functional accent. - +### Dark theme Reject unprompted dark theme → the fingerprint's declared surface. Dark-to-look-cool and light-to-be-safe are the same retreat from a decision. (measured: 271) - +### Gradients Reject gradient page and section backgrounds and gradient-filled CTAs → flat surfaces in semantic roles. - +### Side-stripe Reject side-stripe borders (a thick colored border on one side of a rounded card) — the single most recognizable tell of AI-generated UI → a full hairline border, a 4–8% surface tint, or a leading glyph. - +### Cream surface Reject the cream/sand/beige default surface (warm off-white; token names like `--cream`, `--sand`, `--parchment` are the tell) → a true off-white, the brand's own hue, or a committed color; warmth via accent and type, not the ground. - +### Glassmorphism Reject glassmorphism (decorative backdrop blur) → flat surfaces with real borders and the fingerprint's elevation tiers. - +### Chat bubbles Reject chat bubbles with initials-circle avatars for assistant turns → assistant text plain on the page surface; user turns marked compactly. - +### Stock copy Reject stock template copy ("Simple, transparent pricing") → headings that state what this product specifically does. - +### Celebration Reject celebration copy and UI (exclamation success, confetti language) → quiet factual confirmation. - +### Nested cards Reject nested cards and everything-in-cards → one surface level per region; interior hierarchy from spacing and type; spacing and alignment group without card overhead. - +### Hero metric Reject the hero-metric template (big number + small label + supporting stats as default proof) → evidence specific to the product, or nothing. A prominent metric showing real user data is fine. - +### Every button primary Reject every button a primary button → one primary per view; the rest step down the fingerprint's emphasis ladder. - +### Eyebrow kicker Reject an eyebrow kicker on every section → at most one, where the register sanctions it; one named kicker is voice, every-section is AI grammar. - +### Decorative motion Reject decorative looping animation (pulse/float/shimmer) and uniform fade-and-rise on every scrolled section → motion as evidence of state change; loops only for genuine loading; decoration never compensates for diff --git a/packages/vessel-light/.ghost/checks/median-tells.md b/packages/vessel-light/.ghost/checks/median-tells.md index 1570e0ed..c15e44e5 100644 --- a/packages/vessel-light/.ghost/checks/median-tells.md +++ b/packages/vessel-light/.ghost/checks/median-tells.md @@ -3,49 +3,60 @@ name: Median tells description: Flags the measured defaults of unsteered generation and current model-signature tells — hover-lift, default accents, unprompted dark theme, gradient text, frequency tells, and per-model signatures. severity: high references: - - anti-goal.median - - grammar.motion - - grammar.conversation + - 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 --- -These flags target the measured convergence patterns of unsteered model -generation, plus tells specific to individual models. Each is mechanically -detectable in a diff. Pruning a rule from `anti-goal.median` orphans its -paired flag here — delete both together. +This is Vessel's adaptation of the shared median check; the fidelity carve-outs +below are the body's own. These flags target the measured convergence patterns +of unsteered model generation, plus tells specific to individual models. Each +is mechanically detectable in a diff. Pruning a rule from `anti-goal.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 Vessel is color and background change, not lift. -(`anti-goal.median > rule:median-hover-lift`) +(`anti-goal.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. (`rule:median-indigo-accent`) +members. (`anti-goal.median > Indigo accent`) Flag whole-page dark backgrounds when the ask did not request dark mode. Dark surfaces in Vessel are the editorial dark band or an explicit `.dark` -theme, never an unprompted default. (`rule:median-dark-theme`) +theme, never an unprompted default. (`anti-goal.median > Dark theme`) Flag `linear-gradient` or `radial-gradient` as page or section backgrounds, -and gradient-filled buttons. (`rule:median-gradients`) +and gradient-filled buttons. (`anti-goal.median > Gradients`) Flag `backdrop-filter: blur` used for glassmorphism cards. -(`rule:median-glassmorphism`) +(`anti-goal.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 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. (`rule:median-side-stripe`) +sides stay thin. (`anti-goal.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. (`rule:median-cream-surface`) +diff. (`anti-goal.median > Cream surface`) Flag assistant messages rendered as bubbles with initials-circle avatars. -(`rule:median-chat-bubbles`) +(`anti-goal.median > Chat bubbles`) Flag emoji used as icons or imagery in interface chrome. Text labels carry meaning. @@ -53,13 +64,22 @@ 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. -(`rule:median-stock-copy`) +(`anti-goal.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`) + +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`) 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. (`rule:median-eyebrow-kicker`) +grammar. (`anti-goal.median > Eyebrow kicker`) Flag five or more em-dashes in body copy in one view. diff --git a/packages/vessel-light/.ghost/checks/motion-restraint.md b/packages/vessel-light/.ghost/checks/motion-restraint.md index 0de32a48..52040bfb 100644 --- a/packages/vessel-light/.ghost/checks/motion-restraint.md +++ b/packages/vessel-light/.ghost/checks/motion-restraint.md @@ -4,13 +4,14 @@ description: Flags non-token motion, looping decoration, and keyframes that do n severity: medium references: - grammar.motion + - signature.temperature --- Review changed transitions and animations for vocabulary first. Flag durations that do not use `--duration-fast`, `--duration-normal`, or `--duration-slow`. A hard-coded millisecond value is a drift even when it matches the token today. -Flag custom easing unless it uses the approved spring ease or a browser default already required by the primitive. +Flag custom easing unless it uses the fingerprint's one ease or a browser default already required by the primitive. Flag looping animations outside explicit loading states. Loading must be tied to ongoing work, not ambient motion. diff --git a/packages/vessel-light/.ghost/checks/relationships.md b/packages/vessel-light/.ghost/checks/relationships.md index 29ecb88b..1dfe6391 100644 --- a/packages/vessel-light/.ghost/checks/relationships.md +++ b/packages/vessel-light/.ghost/checks/relationships.md @@ -1,15 +1,16 @@ --- name: Relationship discipline -description: Flags structural violations of the grammar — emphasis-ladder breaks, sibling margins, decorative borders, nested cards. These rules survive any rebrand. +description: Flags structural violations of the grammar — emphasis-ladder breaks, sibling margins, decorative borders, nested cards. These rules survive any adaptation. severity: high references: - grammar.hierarchy - grammar.rhythm - grammar.surfaces + - anti-goal.median > Nested cards --- These assertions test relationships between token roles, not the values behind -them. They hold for any brand built on this grammar; a fork keeps this check +them. They hold for any brand built on this grammar; an adapted package keeps this check unchanged. Flag more than one primary-variant button per view. Secondary actions step diff --git a/packages/vessel-light/.ghost/checks/values.md b/packages/vessel-light/.ghost/checks/values.md index 031e2d40..8587251c 100644 --- a/packages/vessel-light/.ghost/checks/values.md +++ b/packages/vessel-light/.ghost/checks/values.md @@ -1,17 +1,18 @@ --- name: Value discipline -description: Flags off-signature values — non-pill controls, off-palette hues, raw color literals, expression over budget. A fork rewrites this check alongside the signature nodes. +description: Flags off-signature values — non-pill controls, off-palette hues, raw color literals, expression over budget. Adapting the dials rewrites this check alongside the signature nodes. severity: high references: - signature.shape - signature.palette + - grammar.color-roles - register.data-density - register.editorial - register.email --- These assertions test Vessel's current answers to the signature dials. A -rebrand that changes the dials rewrites this check with them; the paired +adaptation that changes the dials rewrites this check with them; the paired relationship check stays. Review changed HTML and CSS by view, not just by file. Classify the register diff --git a/packages/vessel-light/.ghost/glossary.md b/packages/vessel-light/.ghost/glossary.md index 04718897..8017688a 100644 --- a/packages/vessel-light/.ghost/glossary.md +++ b/packages/vessel-light/.ghost/glossary.md @@ -10,15 +10,15 @@ kinds: # grammar The brand's decision logic: closed sets, role vocabularies, and assembly -rules, stated in token roles and never in literal values. Grammar survives a -rebrand unchanged — fork the package, swap every value, and these nodes still +rules, stated in token roles and never in literal values. Grammar survives an +adaptation unchanged — adapt the package, swap every value, and these nodes still hold. Gather grammar before inventing structure. # signature The dials: the choices that make this brand this brand, each stated as a current answer that stands until you replace it. Signature nodes name real -values because they are the values — on fork, edit the token roles in +values because they are the values — on adaptation, edit the token roles in `materials/tokens.css` and restate the node. Gather signature before setting any value a dial governs. @@ -39,7 +39,7 @@ anti-goals before styling anything greenfield. --- -To adapt this package to another brand, follow the transplant procedure — -it ships with the ghost skill bundle. In short: new manifest id, prune the +To adapt this package to another brand, follow the `adapting-a-starter` +recipe in the ghost skill bundle. In short: new manifest id, prune the median rules your brand legitimately violates, answer each signature node, edit `materials/tokens.css`, regenerate the refs, rewrite the tells. diff --git a/packages/vessel-light/.ghost/grammar.color-roles.md b/packages/vessel-light/.ghost/grammar.color-roles.md index 99e28261..01f24754 100644 --- a/packages/vessel-light/.ghost/grammar.color-roles.md +++ b/packages/vessel-light/.ghost/grammar.color-roles.md @@ -21,9 +21,9 @@ 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: the expression roles (`--expression-1` through -`--expression-5`) exist, but their volume ladder is a brand answer — see the -palette signature — and each register caps how loud they may be. +register-gated: a closed expression set (`--expression-*`) exists, but its +size, members, and volume ladder are a brand answer — 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 diff --git a/packages/vessel-light/.ghost/grammar.motion.md b/packages/vessel-light/.ghost/grammar.motion.md index 406ea1c4..d7cbfbfd 100644 --- a/packages/vessel-light/.ghost/grammar.motion.md +++ b/packages/vessel-light/.ghost/grammar.motion.md @@ -22,6 +22,6 @@ keyframes are off-language. Prefer opacity and small transform changes. If removing an animation does not reduce comprehension, the animation was decoration. -Condition: editorial surfaces may stage entrances — scroll reveals and +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/vessel-light/.ghost/index.md b/packages/vessel-light/.ghost/index.md index 37753ab5..7b01cbc2 100644 --- a/packages/vessel-light/.ghost/index.md +++ b/packages/vessel-light/.ghost/index.md @@ -12,7 +12,7 @@ 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. The corpus carries three trust tiers. Grammar and the median floor are law: -safe to consume verbatim, unchanged by any rebrand. Signature nodes are +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 diff --git a/packages/vessel-light/.ghost/signature.type.md b/packages/vessel-light/.ghost/signature.type.md index c0c7b5b2..f5d59003 100644 --- a/packages/vessel-light/.ghost/signature.type.md +++ b/packages/vessel-light/.ghost/signature.type.md @@ -14,8 +14,7 @@ outside the text variants — and product UI never mixes the two vocabularies in one view. Vessel's current answer: HK Grotesk. vessel-light ships it as its embedded -voice; the React package currently falls back to system-ui. This fingerprint -is the intended future — here, `tokens.css` is canonical. +voice — here, `tokens.css` is canonical. The heading scale is editorial: display, section, sub, and card each carry their own rhythm (`--heading-display-*`, `--heading-section-*`, diff --git a/packages/vessel-light/README.md b/packages/vessel-light/README.md index 8f03e0fd..7df8c5dc 100644 --- a/packages/vessel-light/README.md +++ b/packages/vessel-light/README.md @@ -25,7 +25,7 @@ ghost review ## Structure -The corpus is factored by rate of change under rebrand: `grammar.*` nodes are value-free decision logic that survives any fork; `signature.*` nodes are the identity dials (shape, palette, type, temperature), each stating Vessel's current answer; `register.*` nodes are named conditions that re-tune the contract; `anti-goal.median` is the model's measured defaults (prune lines your brand legitimately violates) and `anti-goal.tells` guards near-misses of Vessel's own signature. Every literal value lives in `materials/tokens.css`. +The corpus is factored by rate of change under adaptation: `grammar.*` nodes are value-free decision logic that survives any adaptation; `signature.*` nodes are the identity dials (shape, palette, type, temperature), each stating Vessel's current answer; `register.*` nodes are named conditions that re-tune the contract; `anti-goal.median` is the model's measured defaults (prune lines your brand legitimately violates) and `anti-goal.tells` guards near-misses of Vessel's own signature. Every literal value lives in `materials/tokens.css`. ## Curation diff --git a/packages/vessel-light/demo/.gitignore b/packages/vessel-light/demo/.gitignore index ba1fd25c..e362c4c5 100644 --- a/packages/vessel-light/demo/.gitignore +++ b/packages/vessel-light/demo/.gitignore @@ -1,3 +1,3 @@ -# Eval run artifacts are not committed on mainline; the 2026-07-08 matrix run -# is parked on the `vessel-eval-artifacts` branch. Regenerate via steering-eval. +# Eval run artifacts are never committed; regenerate via steering-eval +# (prompt → generate → shoot → score → report) from asks.md + ballast.md. out/ diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.mjs index 0b28b049..c6745704 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.mjs @@ -148,6 +148,39 @@ try { } run("pnpm", ["exec", "ghost", "lint", ".ghost"], { cwd: consumerDir }); + // The vessel-light body must survive packing: corpus, binary fonts, checks. + // Run from the consumer root (where the tarball is installed) and target a + // separate package dir — `pnpm exec` from a bare subdirectory would escape + // to any globally installed ghost. + const bodyPackageDir = join("body-consumer", ".ghost"); + const bodyInit = run( + "pnpm", + [ + "exec", + "ghost", + "init", + "--body", + "vessel-light", + "--package", + bodyPackageDir, + "--format", + "json", + ], + { cwd: consumerDir }, + ); + const bodyOutput = JSON.parse(bodyInit); + for (const required of [ + "manifest.yml", + "anti-goal.median.md", + "materials/fonts/HKGrotesk-Regular.woff2", + "checks/values.md", + ]) { + if (!bodyOutput.written?.includes(required)) { + fail(`packed ghost init --body vessel-light did not write ${required}`); + } + } + run("pnpm", ["exec", "ghost", "lint", bodyPackageDir], { cwd: consumerDir }); + for (const specifier of PUBLIC_IMPORTS) { runNode(`await import(${JSON.stringify(specifier)});`, consumerDir); } diff --git a/scripts/check-release-tarball.mjs b/scripts/check-release-tarball.mjs index 9af27706..ed8aa742 100644 --- a/scripts/check-release-tarball.mjs +++ b/scripts/check-release-tarball.mjs @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, rmSync, + statSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -65,6 +66,10 @@ try { "package.json", "dist/bin.js", "dist/cli.js", + "dist/init-payloads/median/anti-goal.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", "node_modules/cac", "node_modules/yaml", "node_modules/zod", @@ -76,6 +81,20 @@ try { } } + // Binary payloads must survive packing byte-for-byte. + const fontRelative = "materials/fonts/HKGrotesk-Regular.woff2"; + const packedFont = statSync( + join(packageDir, "dist", "init-payloads", "vessel-light", fontRelative), + ); + const sourceFont = statSync( + join(ROOT, "packages", "vessel-light", ".ghost", fontRelative), + ); + if (packedFont.size !== sourceFont.size) { + fail( + `packed font size ${packedFont.size} does not match source ${sourceFont.size}`, + ); + } + const packedPkg = JSON.parse( readFileSync(join(packageDir, "package.json"), "utf8"), ); @@ -111,6 +130,34 @@ try { ); } + // The vessel-light body installs from the standalone tarball too. + const bodyDir = join(tmpRoot, "body-consumer"); + mkdirSync(bodyDir, { recursive: true }); + const bodyInit = run( + "node", + [ + join(packageDir, "dist", "bin.js"), + "init", + "--body", + "vessel-light", + "--format", + "json", + ], + { cwd: bodyDir }, + ); + const bodyOutput = JSON.parse(bodyInit); + for (const required of [ + "anti-goal.median.md", + "materials/fonts/HKGrotesk-Regular.woff2", + "checks/values.md", + ]) { + if (!bodyOutput.written?.includes(required)) { + fail( + `release tarball ghost init --body vessel-light did not write ${required}`, + ); + } + } + const topLevelEntries = readdirSync(extractDir); if (topLevelEntries.length !== 1 || topLevelEntries[0] !== "package") { fail("release tarball must extract to a single package/ directory"); diff --git a/scripts/sync-init-payloads.mjs b/scripts/sync-init-payloads.mjs new file mode 100644 index 00000000..ef9b7871 --- /dev/null +++ b/scripts/sync-init-payloads.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { cp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const distInitPayloadsDir = join( + ROOT, + "packages", + "ghost", + "dist", + "init-payloads", +); + +await rm(distInitPayloadsDir, { recursive: true, force: true }); + +const sourceInitPayloadsDir = join( + ROOT, + "packages", + "ghost", + "src", + "init-payloads", +); +if (existsSync(sourceInitPayloadsDir)) { + await cp(sourceInitPayloadsDir, distInitPayloadsDir, { recursive: true }); +} + +await cp( + join(ROOT, "packages", "vessel-light", ".ghost"), + join(distInitPayloadsDir, "vessel-light"), + { + recursive: true, + filter(source) { + const name = source.split(/[\\/]/).at(-1); + return name !== ".events" && name !== ".gitignore"; + }, + }, +);