diff --git a/.changeset/gather-menu-model-legibility.md b/.changeset/gather-menu-model-legibility.md new file mode 100644 index 00000000..15dcb14b --- /dev/null +++ b/.changeset/gather-menu-model-legibility.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +`ghost gather` now leads with a single selection contract, groups nodes by kind with the kind's legend inline above each group, numbers nodes continuously, labels each node's retrieval payload as "Applies when", and reports a shorter coverage line. diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 7091334d..b3663871 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -1,5 +1,6 @@ import type { CAC } from "cac"; import type { CatalogMenuEntry } from "#ghost-core"; +import { groupMenuByKind } from "../embed/gather.js"; import type { GhostGatherResult } from "../embed/index.js"; import { gatherGhostPackage, loadGhostSnapshot } from "../embed/index.js"; import { appendGhostEvent, resolveRunId } from "../observability-events.js"; @@ -91,15 +92,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; readiness caps at Yellow"); + } if (coverage.withoutFor > 0) { parts.push(`${coverage.withoutFor} lack \`for\` payloads`); } @@ -109,59 +107,75 @@ 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 node in the package appears below; nothing was pre-selected.", + menu.contract.selection.instruction, + "", + ); + if (!menu.ask && menu.contract.noAsk) { + lines.push(menu.contract.noAsk, ""); + } + lines.push(menu.silence.ifNoneApply, "", "---", ""); + if (menu.cover.state === "resolved") { lines.push( - `## Cover in context: \`${menu.cover.id}\``, + `## ${menu.cover.id}`, "", menu.cover.node.body, "", - "Cover status: already in context; outside selection; do not pull again.", + "Not part of the menu below; nothing to pull here.", "", "---", "", ); } + 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(""); - } - 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}`); + lines.push( + `Evaluate all ${menu.nodes.length} nodes. Order does not indicate priority; pull by id, not number.`, + "Each bullet states when that node applies.", + "", + ); + + 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) { + const purpose = kindPurpose.get(group.kind); + lines.push( + purpose ? `### ${group.kind} — ${purpose}` : `### ${group.kind}`, + "", + ); } - const payloadTypes = formatPayloadTypes(entry); - if (payloadTypes.length > 0) { - lines.push(` - payloads: ${payloadTypes.join(", ")}`); + for (const entry of group.entries) { + index += 1; + lines.push(`${index}. \`${entry.id}\``); + 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(""); } + + lines.push("Next: `ghost pull […]`."); return `${lines.join("\n")}\n`; } 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..ce4bf8a3 100644 --- a/packages/ghost/src/embed/gather.ts +++ b/packages/ghost/src/embed/gather.ts @@ -38,7 +38,7 @@ export function gatherGhostPackage( cover: snapshot.cover, silence: { ifNoneApply: - "Name the package's silence, follow the cover silence posture when present, and do not invent ghost-backed guidance.", + "If no node applies, say the package is silent on the task. Check whether the cover above states its own rule for missing guidance and follow that; otherwise reason provisionally and label it as such. Never invent ghost-backed guidance.", }, coverage: menuCoverage(menu), ...(kinds.length > 0 ? { kinds } : {}), @@ -51,6 +51,21 @@ export function normalizeAsk(ask: string | undefined): string | undefined { return normalized.length > 0 ? normalized : undefined; } +/** + * The gather selection contract, worded once and shared by both the markdown + * and JSON emitters so the two surfaces cannot drift apart. `context.*` + * nodes get a stricter uncertainty rule than other kinds: a wrong-situation + * context node is contamination (see the context kind's own glossary + * convention), so "when uncertain, pull" is qualified rather than blanket. + * 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; do not filter or rank beyond that. Skip clear non-matches. Topic overlap alone is not a match. When uncertain, pull — except for `context.*` nodes: a wrong-situation rule is contamination, so when unsure there, skip or ask instead."; + +export const GATHER_NO_ASK_INSTRUCTION = + "No ask supplied. Re-run `ghost gather ` with the real task before pulling."; + export function gatherContract(ask: string | undefined): GhostGatherContract { return { completeness: { @@ -61,15 +76,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.", + ...(ask ? {} : { noAsk: GATHER_NO_ASK_INSTRUCTION }), }; } @@ -91,6 +103,36 @@ export function menuCoverage( }; } +/** + * Group menu entries by kind, in the glossary's declared order, falling back + * to id order within a kind and for any kind the glossary does not declare. + * Grouping puts each kind's legend adjacent to the nodes it governs instead + * of relying on a per-entry kind tag the model must cross-reference. + */ +export function groupMenuByKind( + menu: readonly CatalogMenuEntry[], + kinds: readonly GhostMenuKind[], +): { kind: string | undefined; entries: CatalogMenuEntry[] }[] { + const order = kinds.map((kind) => kind.name); + 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]); + } + } + const orderedKeys = [ + ...order.filter((name) => groups.has(name)), + ...[...groups.keys()] + .filter((key) => key === undefined || !order.includes(key)) + .sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ]; + return orderedKeys.map((kind) => ({ kind, entries: groups.get(kind) ?? [] })); +} + function menuKinds(snapshot: GhostEmbedSnapshot): GhostMenuKind[] { return (snapshot.glossary?.kinds ?? []) .filter((kind) => kind.purpose.length > 0) diff --git a/packages/ghost/src/embed/types.ts b/packages/ghost/src/embed/types.ts index 91797a02..4ac3a585 100644 --- a/packages/ghost/src/embed/types.ts +++ b/packages/ghost/src/embed/types.ts @@ -76,7 +76,8 @@ export interface GhostGatherContract { addForCompleteness: false; omitApplicableForCount: false; }; - noAsk: string; + /** Present only when no ask was supplied to `gather`. */ + noAsk?: string; } export interface GhostGatherResult { diff --git a/packages/ghost/src/init-payloads/skeleton/glossary.md b/packages/ghost/src/init-payloads/skeleton/glossary.md index ec74254b..52027147 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -7,20 +7,24 @@ kinds: # foundation -The core elements — color, type, controls, layout, motion, voice — plus -composition, the rules for assembling them into a view. Each foundation -node is a chapter: usage rules that hold no matter what the brand values -turn out to be, the brand's open questions (unanswered in this starter, -marked as decisions only a human can make), and the chapter's misuse list. -Follow the usage rules as written. Never fill in an open value and present -it as the brand's. +Always-applicable usage rules for a core element (color, type, controls, +layout, motion, voice, composition). Pull every foundation chapter whose +element the task touches; these hold no matter what the brand values turn +out to be. + +Each foundation node is a chapter: usage rules, the brand's open questions +(unanswered in this starter, marked as decisions only a human can make), and +the chapter's misuse list. Follow the usage rules as written. Never fill in +an open value and present it as the brand's. # context +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 — an AI conversation thread, a data-dense console, a transactional email — and states only what -inverts there. Read a context only when its situation matches the task. -Rules from the wrong context are contamination, not guidance. +inverts there. # cliche diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index e02a109f..c32377d9 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -13,15 +13,14 @@ 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 available node; it does not filter or rank, and its +selection contract states the pull rule, including the uncertainty bias +(pull when unsure, except for a `context.*` node, where a wrong-situation +pull is contamination). Under-pull is silent and unrecoverable; over-pull is +mild dilution. 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. +steering and caps readiness at Yellow. ## Pull and inspect diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index ceb501fd..b4af6c5a 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -34,6 +34,12 @@ cover as a warning, and a cover body over 1500 bytes as a warning. node's id is its filename minus `.md`; its kind is the first dotted filename segment. A bare filename has no kind. Undeclared kind prefixes warn. +`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 — never +as anatomy, history, or rationale for what the kind does not yet cover. Put +anatomy and history in the paragraphs after it. + ## Nodes ```markdown diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index c8e3152a..df6c46ba 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -572,12 +572,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("## brand"); + expect(markdown.stdout).toContain( + "Not part of the menu below; nothing to pull here.", + ); 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; readiness caps at Yellow", ); - 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); @@ -608,12 +611,12 @@ 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("## Always-on guidance"); // 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; readiness caps at Yellow", ); - 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); @@ -693,12 +696,11 @@ describe("ghost CLI", () => { const foundation = menu.kinds.find( (k: { name: string }) => k.name === "foundation", ); - expect(foundation.purpose).toContain("core elements"); + expect(foundation.purpose).toContain("core element"); - // Markdown renders the same legend above the node list. + // Markdown renders the same legend inline above that kind's group. const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain("Kinds:"); - expect(markdown.stdout).toContain("- **foundation** — The core elements"); + expect(markdown.stdout).toContain("### foundation — Always-applicable"); // A missing glossary degrades to no legend, not an error. await rm(join(dir, ".ghost", "glossary.md")); @@ -766,9 +768,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"); @@ -780,7 +780,7 @@ 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", ); const gatherMuteJson = await runCli(["gather", "--format", "json"], dir); expect(JSON.parse(gatherMuteJson.stdout).coverage.withoutFor).toBe(1); @@ -1061,7 +1061,9 @@ describe("ghost CLI", () => { }, }); 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);