diff --git a/.changeset/gather-menu-model-legibility.md b/.changeset/gather-menu-model-legibility.md new file mode 100644 index 00000000..5b4c0516 --- /dev/null +++ b/.changeset/gather-menu-model-legibility.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +`ghost gather` now leads with one selection contract, groups nodes by declared kind order with uncategorized nodes last, labels applicability, and reports factual coverage without readiness colors. diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 7091334d..14925774 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -91,15 +91,12 @@ function formatGatherJson(menu: GhostGatherResult): Record { function menuCoverageLine(menu: GhostGatherResult): string { const coverage = menu.coverage; - const payloadParts = [ - `${coverage.payloads.materials} with materials`, - `${coverage.payloads.fencedExamples} with substantial fenced examples`, - `${coverage.payloads.skeletons} with Skeletons`, - ]; - const parts = [ - `${coverage.nodes} nodes`, - `${coverage.concrete} carry payloads (${payloadParts.join(", ")})`, - ]; + const parts = [`${coverage.nodes} nodes`]; + if (coverage.concrete > 0) { + parts.push(`${coverage.concrete} with concrete support`); + } else { + parts.push("all prose, no concrete support"); + } if (coverage.withoutFor > 0) { parts.push(`${coverage.withoutFor} lack \`for\` payloads`); } @@ -109,59 +106,132 @@ function menuCoverageLine(menu: GhostGatherResult): string { function formatMenuMarkdown(menu: GhostGatherResult): string { const lines: string[] = ["# ghost package", ""]; if (menu.ask) lines.push(`Ask: ${menu.ask}`, ""); + + // Selection contract first: ghost's own instructions occupy the most + // privileged position, ahead of any package-authored prose. + lines.push( + "## Selection contract", + "", + "Complete and unfiltered: every selectable node appears below; nothing was pre-selected.", + menu.contract.selection.instruction, + "", + ); + if (!menu.ask) { + lines.push(menu.contract.noAsk, ""); + } + lines.push(menu.silence.ifNoneApply, "", "---", ""); + if (menu.cover.state === "resolved") { lines.push( - `## Cover in context: \`${menu.cover.id}\``, + `## Cover: \`${menu.cover.id}\``, "", menu.cover.node.body, "", - "Cover status: already in context; outside selection; do not pull again.", + "This cover is already in context and is not selectable.", "", "---", "", ); } + lines.push("## Available guidance", "", menuCoverageLine(menu), ""); - if (menu.ask) { - lines.push( - "Complete, unfiltered, unranked list from the ghost package. ghost has not selected nodes for this ask.", - "Pull every node whose `for` payload indicates its stated situation applies and whose guidance, material, structure, or refusal governs the work. Skip inapplicable nodes. Topic overlap alone is not applicability. Do not add nodes for completeness or omit applicable nodes to meet a count.", - "Next: `ghost pull […]`.", - "If nothing applies, name the package's silence, follow the cover silence posture, and do not invent ghost-backed guidance.", - "", - ); - } else { - lines.push( - "Complete, unfiltered, unranked list from the ghost package. Bare gather is catalog inspection; ghost has not grounded a task or selected nodes.", - "When grounding an ask, pull every applicable node with `ghost pull […]`. Skip inapplicable nodes and do not invent ghost-backed guidance when the ghost package is silent.", - "", - ); - } - if (menu.kinds !== undefined && menu.kinds.length > 0) { - lines.push("Kinds:", ""); - for (const kind of menu.kinds) { - lines.push(`- **${kind.name}** — ${kind.purpose}`); + lines.push( + `Evaluate all ${menu.nodes.length} selectable nodes. Numbering is for counting only. Pull by id.`, + "", + ); + + const groups = groupMenuByKind(menu.nodes, menu.kinds ?? []); + const kindPurpose = new Map( + (menu.kinds ?? []).map((kind) => [kind.name, kind.purpose]), + ); + let index = 0; + for (const group of groups) { + if (group.kind) { + lines.push(`### ${group.kind}`, ""); + const purpose = kindPurpose.get(group.kind); + if (purpose) lines.push(purpose, ""); + } else { + lines.push( + "### Uncategorized", + "", + "These nodes have no kind. Use the selection contract as written.", + "", + ); + } + for (const entry of group.entries) { + index += 1; + lines.push(`${index}. \`${entry.id}\``); + lines.push( + entry.for?.trim() + ? ` - Applies when: ${entry.for.trim()}` + : " - Applicability unstated: no `for` payload.", + ); + const metadata = formatMetadata(entry); + if (metadata.length > 0) { + lines.push(` - ${metadata.join("; ")}`); + } } lines.push(""); } - for (const entry of menu.nodes) { - const kind = entry.kind ? ` _(${entry.kind})_` : ""; - lines.push(`- \`${entry.id}\`${kind}`); - if (entry.for) lines.push(` - ${entry.for}`); - if (entry.materials !== undefined) { - lines.push(` - materials: ${entry.materials}`); - } - const payloadTypes = formatPayloadTypes(entry); - if (payloadTypes.length > 0) { - lines.push(` - payloads: ${payloadTypes.join(", ")}`); + + lines.push("Next: `ghost pull […]`."); + return `${lines.join("\n")}\n`; +} + +interface MenuGroup { + kind: string | undefined; + entries: CatalogMenuEntry[]; +} + +function groupMenuByKind( + menu: readonly CatalogMenuEntry[], + kinds: NonNullable, +): MenuGroup[] { + const declaredOrder = kinds.map((kind) => kind.name); + const declaredSet = new Set(declaredOrder); + const groups = new Map(); + + for (const entry of menu) { + const key = entry.kind; + const group = groups.get(key); + if (group) { + group.push(entry); + } else { + groups.set(key, [entry]); } } - return `${lines.join("\n")}\n`; + + for (const group of groups.values()) { + group.sort((a, b) => a.id.localeCompare(b.id)); + } + + const undeclaredKinds = [...groups.keys()] + .filter((key): key is string => key !== undefined && !declaredSet.has(key)) + .sort((a, b) => a.localeCompare(b)); + + const orderedKeys: (string | undefined)[] = [ + ...declaredOrder.filter((kind) => groups.has(kind)), + ...undeclaredKinds, + ...(groups.has(undefined) ? [undefined] : []), + ]; + + return orderedKeys.map((kind) => ({ kind, entries: groups.get(kind) ?? [] })); +} + +function formatMetadata(entry: CatalogMenuEntry): string[] { + const metadata: string[] = []; + if (entry.materials !== undefined) { + metadata.push(`materials: ${entry.materials}`); + } + const payloadTypes = formatPayloadTypes(entry); + if (payloadTypes.length > 0) { + metadata.push(`payloads: ${payloadTypes.join(", ")}`); + } + return metadata; } function formatPayloadTypes(entry: CatalogMenuEntry): string[] { const types: string[] = []; - if (entry.materials !== undefined) types.push("materials"); if (entry.hasFencedExample) types.push("substantial fenced example"); if (entry.hasSkeleton) types.push("Skeleton"); return types; diff --git a/packages/ghost/src/embed/gather.ts b/packages/ghost/src/embed/gather.ts index bf4c347f..95931a66 100644 --- a/packages/ghost/src/embed/gather.ts +++ b/packages/ghost/src/embed/gather.ts @@ -34,12 +34,9 @@ export function gatherGhostPackage( artifact: "ghost package", list: "Available guidance", }, - contract: gatherContract(ask), + contract: gatherContract(), cover: snapshot.cover, - silence: { - ifNoneApply: - "Name the package's silence, follow the cover silence posture when present, and do not invent ghost-backed guidance.", - }, + silence: silenceContract(snapshot.cover), coverage: menuCoverage(menu), ...(kinds.length > 0 ? { kinds } : {}), nodes: menu, @@ -51,7 +48,18 @@ export function normalizeAsk(ask: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } -export function gatherContract(ask: string | undefined): GhostGatherContract { +/** + * The gather selection contract, worded once and shared by both the markdown + * and JSON emitters so the two surfaces cannot drift apart. Leads with an + * instruction, not a description, since this is a contract, not a label. + */ +export const GATHER_SELECTION_INSTRUCTION = + "Pull every node whose `for` payload matches the task. Skip clear non-matches; topic overlap alone is not a match. Do not rank matches or cap their count. When uncertain, pull unless the node's kind legend states a stricter rule."; + +export const GATHER_NO_ASK_INSTRUCTION = + "When no ask is supplied, this menu is not grounded to a task. Re-run `ghost gather ` before pulling for a task."; + +export function gatherContract(): GhostGatherContract { return { completeness: { complete: true, @@ -61,15 +69,12 @@ export function gatherContract(ask: string | undefined): GhostGatherContract { }, selection: { basis: "applicability", - instruction: ask - ? "Pull every node whose `for` payload indicates its stated situation applies and whose guidance, material, structure, or refusal governs the work; skip inapplicable nodes." - : "Bare gather is catalog inspection. Do not treat the menu as task grounding until an ask is supplied; when grounding a task, pull every applicable node and skip inapplicable nodes.", + instruction: GATHER_SELECTION_INSTRUCTION, topicOverlapAloneIsApplicability: false, addForCompleteness: false, omitApplicableForCount: false, }, - noAsk: - "Bare gather is catalog inspection and does not imply task grounding.", + noAsk: GATHER_NO_ASK_INSTRUCTION, }; } @@ -92,14 +97,28 @@ export function menuCoverage( } function menuKinds(snapshot: GhostEmbedSnapshot): GhostMenuKind[] { - return (snapshot.glossary?.kinds ?? []) - .filter((kind) => kind.purpose.length > 0) - .map((kind) => ({ - name: kind.name, - // Legend entries are one line each: keep the section's first paragraph - // and collapse internal wrapping. - purpose: (kind.purpose.split(/\n\s*\n/, 1)[0] ?? "") - .replace(/\s+/g, " ") - .trim(), - })); + return (snapshot.glossary?.kinds ?? []).map((kind) => ({ + name: kind.name, + // Legend entries are one line each: keep the section's first paragraph + // and collapse internal wrapping. Empty purpose stays explicit so + // declared kind order survives even when the glossary has no prose yet. + purpose: (kind.purpose.split(/\n\s*\n/, 1)[0] ?? "") + .replace(/\s+/g, " ") + .trim(), + })); +} + +function silenceContract( + cover: GhostEmbedSnapshot["cover"], +): GhostGatherResult["silence"] { + if (cover.state === "resolved") { + return { + ifNoneApply: `If no node applies, say the package is silent on the task. Check the resolved cover \`${cover.id}\` for any silence rule; otherwise reason provisionally and label it as such. Never invent ghost-backed guidance.`, + }; + } + + return { + ifNoneApply: + "If no node applies, say the package is silent on the task. Reason provisionally and label it as such. Never invent ghost-backed guidance.", + }; } diff --git a/packages/ghost/src/init-payloads/skeleton/glossary.md b/packages/ghost/src/init-payloads/skeleton/glossary.md index 11e180ee..8d01fcaa 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -7,21 +7,28 @@ kinds: # standard -Shared guidance that is not specific to this brand. Every rule carries one of -two authority labels. An **Obligation** is a requirement brand preference -cannot waive — accessibility, safety, honesty, functional integrity; the -brand controls how it is expressed, not whether it holds. A **Default** is a -recommended starting position that protects unsteered work from generic model -behavior; explicit brand guidance in the cover, a foundation, or a matching -context may deliberately replace it. When a default with a paired check is -overridden, adapt or remove the check flag in the same change. +Shared guidance that is not specific to this brand. Pull every standard node +whose `for` payload matches the task. Within matching nodes, Obligations cannot +be waived; Defaults yield to explicit brand guidance. + +Every rule carries one of two authority labels. An **Obligation** is a +requirement brand preference cannot waive: accessibility, safety, honesty, +functional integrity. The brand controls how it is expressed, not whether it +holds. A **Default** is a recommended starting position that protects unsteered +work from generic model behavior; explicit brand guidance in the cover, a +foundation, or a matching context may deliberately replace it. When a default +with a paired check is overridden, adapt or remove the check flag in the same +change. # foundation -The brand's load-bearing decisions — color, type, controls, layout, motion, -voice, and composition, the rules for assembling them into a view. Each -foundation node is a chapter: usage law that holds no matter what the brand -values turn out to be, the brand's open questions (unanswered in this +The brand's load-bearing decisions for color, type, controls, layout, motion, +voice, and composition. Pull every foundation chapter whose subject the task +touches; its rules hold in every context unless matching context guidance +explicitly inverts them. + +Each foundation node is a chapter: usage law that holds no matter what the +brand values turn out to be, the brand's open questions (unanswered in this starter, marked as decisions only a human can make), and the chapter's rejected moves. Follow the rules as written. Never fill in an open value and present it as the brand's. A brand carries only the foundations its evidence @@ -29,11 +36,13 @@ supports; these chapters are subjects, not mandatory slots. # context -Where the defaults bend: a context names a situation — an AI conversation -thread, a data-dense console, a transactional email — and states only what -inverts there. A situation may combine surface, channel, modality, audience, -or moment. Read a context only when its situation matches the task. Rules -from the wrong context are contamination, not guidance. +Situation-specific guidance; pull only when the named situation matches the +task. Rules from the wrong context are contamination, not guidance. + +Where the defaults bend: a context names a situation, such as an AI +conversation thread, a data-dense console, or a transactional email, and states +only what inverts there. A situation may combine surface, channel, modality, +audience, or moment. --- diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index d5e804d9..eb9290c4 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -71,12 +71,14 @@ ghost review # assemble diff + matched material-backed nodes + checks ghost stats # summarize local gather/pull events while tuning ``` -`gather` does no selection. It emits the complete, unfiltered, unranked menu -from the ghost package. The selection rule lives in -[references/ground.md](references/ground.md). Its header includes a coverage -line: total nodes and nodes carrying concrete material. `gather` labels -materials, substantial fenced examples, and Skeletons separately, so an -all-prose package is visible before generation. +`gather` does no selection. It emits the selection contract, the cover when +resolved, then the complete, unfiltered, unranked menu of every selectable node. +The emitted contract owns the pull rule; kind legends can narrow it. Declared +kinds render in glossary order, undeclared kinds alphabetically, and +uncategorized nodes last. Its coverage line reports total selectable nodes, +concrete support, and missing `for` payloads. Each node labels applicability, +then any material count, substantial fenced example, or Skeleton metadata, so +an all-prose package is visible before generation. Prefer `ghost pull` over reading files directly: it emits the same prose, inlines small local materials by default, turns binary materials into diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index d42fbf36..4584ba80 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -13,15 +13,12 @@ should be shaped by a ghost package. Run `ghost gather ` with the real task, not a generic label. The cover is inlined by gather, so do not pull it separately. -`gather` presents every available node; it does not filter or rank. Judge each -node's `for` payload against the actual task and pull what applies. When you are -uncertain whether a node applies, pull it. Under-pull is silent and unrecoverable; -over-pull is mild dilution. Skip only clear non-matches. Topic overlap alone is -not applicability. +`gather` presents every selectable node; it does not filter or rank, and its +selection contract states the pull rule. Kind legends may narrow that global +rule, including stricter handling when uncertain. -Read the coverage line before you choose: an all-prose package is weak -steering. `gather` labels materials, substantial fenced examples, and Skeletons -separately, so payload shape is visible before generation. +Read the coverage line before you choose. It tells you whether the package has +concrete material and whether any node lacks a `for` payload. ## Pull and inspect @@ -39,20 +36,17 @@ ids to restore steering. The anchor is an ephemeral pre-generation block, never written into `.ghost/`. Do not call it a pull packet or review packet. -Keep it to three parts: +Keep it to two parts: 1. Up to five non-negotiables, each cited to a pulled node id. Guidance from a `Never` section states the positive replacement, never just the rejection. - Include conditional - guidance only when its stated situation actually holds, including guidance - whose kind has scoped meaning in the glossary. -2. One readiness color: Green when the surface is covered by inspected concrete - material; no concrete material for the surface caps readiness at Yellow; Red - means a brand-defining, high-risk, or irreversible gap, so ask a human or - author a node first. -3. Named silence, one line: what ghost does not cover and what provisional - reasoning carries it. Keep this separate from cited claims. Follow - [SKILL.md](../SKILL.md)'s canonical "When the package is silent" section. + Include conditional guidance only when its stated situation actually holds, + including guidance whose kind has scoped meaning in the glossary. +2. Named silence, one line: what ghost does not cover and what provisional + reasoning carries it. Ask a human or author guidance before proceeding when + the gap is consequential, irreversible, or brand-defining. Keep this + separate from cited claims. Follow [SKILL.md](../SKILL.md)'s canonical "When + the package is silent" section. Never restate or paraphrase the Skeleton into the anchor. Start the artifact from it verbatim, per the [SKILL.md](../SKILL.md) Skeleton convention. diff --git a/packages/ghost/src/skill-bundle/references/making.md b/packages/ghost/src/skill-bundle/references/making.md index 61b5bdc2..9a87ffdb 100644 --- a/packages/ghost/src/skill-bundle/references/making.md +++ b/packages/ghost/src/skill-bundle/references/making.md @@ -17,7 +17,7 @@ judges, repairs, and reviews in the same session. ## Ground Follow [ground.md](ground.md), which ends with the anchor: gather with the real -ask, select against each node's `for` payload, pull with an over-pull bias, and inspect decisive +ask, select and pull by the menu's selection contract, and inspect decisive materials before generating. Use this triage for material inspection: diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 92b5b0bc..38c9c4c1 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -39,6 +39,14 @@ Obligation or a replaceable Default), `foundation` (the brand's load-bearing decisions), and `context` (what bends in a named situation). A package may declare any vocabulary; the glossary is the only kind authority. +`ghost gather` renders each kind's **first paragraph only** as its menu +legend; later paragraphs are dropped. Write that first paragraph as +selection semantics: when to pull this kind, and any routing rule. Do not make +it anatomy, history, or rationale for what the kind does not yet cover. Put +anatomy and history in the paragraphs after it. Declared kinds render in +frontmatter order even when their purpose paragraph is empty; undeclared kinds +render alphabetically after declared kinds, and uncategorized nodes render last. + ## Nodes ```markdown @@ -108,8 +116,10 @@ it does not grade them. ## Command behavior -- `ghost gather` emits the cover, coverage counts, then a complete, unfiltered, - unranked node menu. Checks are absent. +- `ghost gather` emits the selection contract, the resolved cover when present, + coverage counts, then a complete, unfiltered, unranked menu of every + selectable node. It groups declared kinds in glossary order, undeclared kinds + alphabetically, and uncategorized nodes last. Checks are absent. - `ghost pull` emits selected nodes in steering order, inlines eligible local text materials once, leaves later duplicate pointers, turns binary materials into inspect-pointers, and leaves external materials as locators. diff --git a/packages/ghost/src/skill-bundle/references/steering-audit.md b/packages/ghost/src/skill-bundle/references/steering-audit.md index 9fec17f3..ddcf5454 100644 --- a/packages/ghost/src/skill-bundle/references/steering-audit.md +++ b/packages/ghost/src/skill-bundle/references/steering-audit.md @@ -25,8 +25,8 @@ Report first: - **Concreteness coverage:** total nodes, concrete-material nodes, prose-only nodes. Concrete means non-empty `materials`, a fenced code block of at least 3 - lines, or a `## Skeleton` section. `ghost gather` also breaks out materials, - substantial fenced examples, and Skeletons as payload labels. + lines, or a `## Skeleton` section. `ghost gather` reports material counts and + labels substantial fenced examples and Skeletons as payload metadata. - **Pull rate by concreteness:** concrete-material exposure/pull rate vs prose-only exposure/pull rate. In markdown this is the `Concrete material` row. This is the tuning instrument: if concrete nodes are not pulled when applicable, @@ -46,8 +46,7 @@ Report first: | Checks | covered / partial / missing | checks/, review packet | add checks for high-risk invariants | | Silence posture | defined / missing | cover | say when to proceed provisionally or ask | -## Task-level readiness - -For a task, gather, pull, and report the readiness color from the anchor -contract in [ground.md](ground.md). Never present steering coverage as -deterministic pass/fail. +For task-level use, gather, pull, inspect material when available, name gaps, +proceed provisionally for reversible gaps, and ask or author guidance for +consequential, irreversible, or brand-defining gaps. Never present steering +coverage as deterministic pass/fail. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index f9c8c8a0..c6e4a81c 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -566,12 +566,15 @@ describe("ghost CLI", () => { const markdown = await runCli(["gather"], dir); expect(markdown.code).toBe(0); - expect(markdown.stdout).toContain("## Cover in context: `brand`"); + expect(markdown.stdout).toContain("## Cover: `brand`"); + expect(markdown.stdout).toContain( + "This cover is already in context and is not selectable.", + ); expect(markdown.stdout).toContain("This cover is unwritten."); expect(markdown.stdout).toContain( - "9 nodes · 0 carry payloads (0 with materials, 0 with substantial fenced examples, 0 with Skeletons)", + "9 nodes · all prose, no concrete support", ); - expect(markdown.stdout).not.toContain("- `brand`"); + expect(markdown.stdout).not.toMatch(/\d+\.\s+`brand`/); const json = await runCli(["gather", "--format", "json"], dir); expect(json.code).toBe(0); @@ -602,12 +605,13 @@ describe("ghost CLI", () => { const markdown = await runCli(["gather"], dir); expect(markdown.code).toBe(0); - expect(markdown.stdout).not.toContain("## Cover"); + expect(markdown.stdout).not.toContain("## Cover:"); + expect(markdown.stdout).not.toContain("Check the resolved cover"); // With no resolvable cover, brand stays a selectable menu node. expect(markdown.stdout).toContain( - "10 nodes · 0 carry payloads (0 with materials, 0 with substantial fenced examples, 0 with Skeletons)", + "10 nodes · all prose, no concrete support", ); - expect(markdown.stdout).toContain("- `brand`"); + expect(markdown.stdout).toMatch(/\d+\.\s+`brand`/); const json = await runCli(["gather", "--format", "json"], dir); expect(json.code).toBe(0); @@ -688,13 +692,16 @@ describe("ghost CLI", () => { (k: { name: string }) => k.name === "foundation", ); expect(foundation.purpose).toContain("load-bearing decisions"); + expect(foundation.purpose).toContain("Pull every foundation chapter"); - // Markdown renders the same legend above the node list. + // Markdown renders the same legend beside that kind's group. const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain("Kinds:"); + expect(markdown.stdout).not.toContain("Kinds:"); + expect(markdown.stdout).toContain("### foundation"); expect(markdown.stdout).toContain( - "- **foundation** — The brand's load-bearing decisions", + "The brand's load-bearing decisions for color", ); + expect(markdown.stdout).toContain(menu.contract.noAsk); // A missing glossary degrades to no legend, not an error. await rm(join(dir, ".ghost", "glossary.md")); @@ -703,6 +710,55 @@ describe("ghost CLI", () => { expect(JSON.parse(bare.stdout).kinds).toBeUndefined(); }); + it("groups custom, undeclared, and uncategorized nodes deterministically", async () => { + await writeBareTestPackage(dir); + await writeFile( + join(dir, ".ghost", "glossary.md"), + "---\nkinds:\n - name: zeta\n - name: alpha\n---\n\n# alpha\n\nAlpha rules.\n", + ); + await Promise.all([ + writeFile( + join(dir, ".ghost", "zeta.rule.md"), + "---\nfor: Zeta work.\n---\n\nZeta.\n", + ), + writeFile( + join(dir, ".ghost", "alpha.rule.md"), + "---\nfor: Alpha work.\n---\n\nAlpha.\n", + ), + writeFile( + join(dir, ".ghost", "beta.rule.md"), + "---\nfor: Beta work.\n---\n\nBeta.\n", + ), + writeFile( + join(dir, ".ghost", "voice.md"), + "---\nfor: Writing copy.\n---\n\nPlain words.\n", + ), + ]); + + const json = await runCli(["gather", "test", "--format", "json"], dir); + expect(JSON.parse(json.stdout).kinds).toEqual([ + { name: "zeta", purpose: "" }, + { name: "alpha", purpose: "Alpha rules." }, + ]); + + const markdown = await runCli(["gather", "test"], dir); + const headings = [ + "### zeta", + "### alpha", + "### beta", + "### standard", + "### Uncategorized", + ]; + for (let index = 1; index < headings.length; index += 1) { + expect(markdown.stdout.indexOf(headings[index - 1] ?? "")).toBeLessThan( + markdown.stdout.indexOf(headings[index] ?? ""), + ); + } + expect(markdown.stdout.indexOf("### Uncategorized")).toBeLessThan( + markdown.stdout.indexOf("`voice`"), + ); + }); + it("runs validate from the unified cli", async () => { await writeCheckPackage(dir); const validate = await runCli(["validate"], dir); @@ -762,9 +818,7 @@ describe("ghost CLI", () => { withoutFor: 0, }); const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain( - "4 nodes · 1 carry payloads (1 with materials, 0 with substantial fenced examples, 0 with Skeletons)", - ); + expect(markdown.stdout).toContain("4 nodes · 1 with concrete support"); // No nodes lacking `for`: the coverage line stays quiet about them. expect(markdown.stdout).not.toContain("lack `for` payloads"); @@ -776,8 +830,12 @@ describe("ghost CLI", () => { ); const gatherMute = await runCli(["gather"], dir); expect(gatherMute.stdout).toContain( - "5 nodes · 1 carry payloads (1 with materials, 0 with substantial fenced examples, 0 with Skeletons) · 1 lack `for` payloads", + "5 nodes · 1 with concrete support · 1 lack `for` payloads", + ); + expect(gatherMute.stdout).toContain( + "Applicability unstated: no `for` payload.", ); + expect(gatherMute.stdout).toContain("Applies when: Tokens."); const gatherMuteJson = await runCli(["gather", "--format", "json"], dir); expect(JSON.parse(gatherMuteJson.stdout).coverage.withoutFor).toBe(1); @@ -1055,9 +1113,15 @@ describe("ghost CLI", () => { addForCompleteness: false, omitApplicableForCount: false, }, + noAsk: expect.any(String), }); + expect(menuPayload.contract.selection.instruction).not.toContain( + "context.*", + ); expect(menuPayload.next.command).toBe("ghost pull […]"); - expect(menuPayload.silence.ifNoneApply).toContain("do not invent"); + expect(menuPayload.silence.ifNoneApply).toContain( + "Never invent ghost-backed guidance", + ); expect( menuPayload.nodes.some((n: { id: string }) => n.id === "voice"), ).toBe(true); @@ -1066,6 +1130,11 @@ describe("ghost CLI", () => { expect(gatherMarkdown.stdout).toContain("# ghost package"); expect(gatherMarkdown.stdout).toContain("Ask: checkout hero"); expect(gatherMarkdown.stdout).toContain("## Available guidance"); + expect(gatherMarkdown.stdout).not.toContain(menuPayload.contract.noAsk); + expect(gatherMarkdown.stdout).toContain("### Uncategorized"); + expect(gatherMarkdown.stdout.indexOf("### Uncategorized")).toBeLessThan( + gatherMarkdown.stdout.indexOf("`voice`"), + ); const pull = await runCli(["pull", "principle.trust", "voice"], dir); expect(pull.code).toBe(0); diff --git a/packages/ghost/test/embed.test.ts b/packages/ghost/test/embed.test.ts index cc06086b..a154147e 100644 --- a/packages/ghost/test/embed.test.ts +++ b/packages/ghost/test/embed.test.ts @@ -41,7 +41,7 @@ async function writePackage(dir: string): Promise { ); await writeFile( join(dir, ".ghost", "glossary.md"), - "---\nkinds:\n - name: asset\n - name: principle\n---\n\n# asset\n\nConcrete materials.\n\n# principle\n\nRules.\n", + "---\nkinds:\n - name: asset\n - name: uncaptioned\n - name: principle\n---\n\n# asset\n\nConcrete materials.\n\n# principle\n\nRules.\n", ); await writeFile( join(dir, ".ghost", "cover.md"), @@ -152,10 +152,13 @@ describe("embed contract", () => { payloads: { materials: 1, fencedExamples: 0, skeletons: 1 }, withoutFor: 0, }); - expect(result.kinds).toContainEqual({ - name: "asset", - purpose: "Concrete materials.", - }); + expect(result.kinds).toEqual([ + { name: "asset", purpose: "Concrete materials." }, + { name: "uncaptioned", purpose: "" }, + { name: "principle", purpose: "Rules." }, + ]); + expect(result.contract.noAsk).toEqual(expect.any(String)); + expect(result.contract.selection.instruction).not.toContain("context.*"); expect(JSON.stringify(result)).not.toContain("Check tokens"); expect(snapshot.checks.size).toBe(1); });