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";
+ },
+ },
+);