From 99e3e1fffbb0adb9c2076b027042348fcf15ccca Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 19 Aug 2026 22:21:35 -0400 Subject: [PATCH 1/2] Restructure gather menu for model legibility - Single-source the selection contract between markdown and JSON emitters, leading with an instruction and adding a context-aware uncertainty rule (context.* wrong-situation pulls are contamination, not mild dilution). - Group menu nodes by glossary kind order with the kind's legend inline above each group; number nodes continuously across groups. - Drop the cover's 'do not pull again' ceremony down to a plain node header plus one line; state 'each bullet applies when' once above the list instead of repeating the label per node. - Compress the coverage line (drop zero-count noise and the 'carry payloads' jargon); rewrite the silence line to point at the cover's own missing-guidance rule instead of an undefined 'silence posture'. - Update the starter glossary's foundation/context legends to lead with selection semantics, per a documented schema.md convention. Co-authored-by: Goose Ai-assisted: true --- .changeset/gather-menu-model-legibility.md | 5 + packages/ghost/src/commands/gather-command.ts | 100 ++++++++++-------- packages/ghost/src/embed/gather.ts | 54 ++++++++-- packages/ghost/src/embed/types.ts | 3 +- .../src/init-payloads/skeleton/glossary.md | 17 +-- .../src/skill-bundle/references/ground.md | 13 ++- .../src/skill-bundle/references/schema.md | 6 ++ packages/ghost/test/cli.test.ts | 32 +++--- 8 files changed, 153 insertions(+), 77 deletions(-) create mode 100644 .changeset/gather-menu-model-legibility.md 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 11e180ee..3c229023 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -18,10 +18,13 @@ 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 +32,13 @@ supports; these chapters are subjects, not mandatory slots. # 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. 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. +or moment. --- diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index d42fbf36..3e93c0dd 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 92b5b0bc..d45d9de3 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -39,6 +39,12 @@ 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 — 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 f9c8c8a0..b84439c2 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("## 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); @@ -602,12 +605,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); @@ -688,12 +691,13 @@ 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 inline above 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** — The brand's load-bearing decisions", + "### foundation — The brand's load-bearing decisions", ); // A missing glossary degrades to no legend, not an error. @@ -762,9 +766,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,7 +778,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); @@ -1057,7 +1059,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); From 223c9dddb810d4057b7bc74c294731e3f849db5c Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Fri, 4 Sep 2026 17:10:50 -0400 Subject: [PATCH 2/2] Harden gather menu contracts Preserve the public noAsk shape, keep selection vocabulary-neutral, render all supported kind shapes honestly, and remove the readiness color rubric. Align the shipped skill guidance and add coverage for custom ordering and uncategorized nodes.\n\nCo-authored-by: Goose \nAi-assisted: true --- .changeset/gather-menu-model-legibility.md | 2 +- packages/ghost/src/commands/gather-command.ts | 88 +++++++++++++++---- packages/ghost/src/embed/gather.ts | 85 +++++++----------- packages/ghost/src/embed/types.ts | 3 +- .../src/init-payloads/skeleton/glossary.md | 28 +++--- packages/ghost/src/skill-bundle/SKILL.md | 14 +-- .../src/skill-bundle/references/ground.md | 31 +++---- .../src/skill-bundle/references/making.md | 2 +- .../src/skill-bundle/references/schema.md | 14 +-- .../skill-bundle/references/steering-audit.md | 13 ++- packages/ghost/test/cli.test.ts | 79 +++++++++++++++-- packages/ghost/test/embed.test.ts | 13 +-- 12 files changed, 238 insertions(+), 134 deletions(-) diff --git a/.changeset/gather-menu-model-legibility.md b/.changeset/gather-menu-model-legibility.md index 15dcb14b..5b4c0516 100644 --- a/.changeset/gather-menu-model-legibility.md +++ b/.changeset/gather-menu-model-legibility.md @@ -2,4 +2,4 @@ "@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. +`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 b3663871..14925774 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -1,6 +1,5 @@ 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"; @@ -96,7 +95,7 @@ function menuCoverageLine(menu: GhostGatherResult): string { if (coverage.concrete > 0) { parts.push(`${coverage.concrete} with concrete support`); } else { - parts.push("all prose, no concrete support; readiness caps at Yellow"); + parts.push("all prose, no concrete support"); } if (coverage.withoutFor > 0) { parts.push(`${coverage.withoutFor} lack \`for\` payloads`); @@ -113,22 +112,22 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { lines.push( "## Selection contract", "", - "Complete and unfiltered: every node in the package appears below; nothing was pre-selected.", + "Complete and unfiltered: every selectable node appears below; nothing was pre-selected.", menu.contract.selection.instruction, "", ); - if (!menu.ask && menu.contract.noAsk) { + if (!menu.ask) { lines.push(menu.contract.noAsk, ""); } lines.push(menu.silence.ifNoneApply, "", "---", ""); if (menu.cover.state === "resolved") { lines.push( - `## ${menu.cover.id}`, + `## Cover: \`${menu.cover.id}\``, "", menu.cover.node.body, "", - "Not part of the menu below; nothing to pull here.", + "This cover is already in context and is not selectable.", "", "---", "", @@ -137,8 +136,7 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { lines.push("## Available guidance", "", menuCoverageLine(menu), ""); 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.", + `Evaluate all ${menu.nodes.length} selectable nodes. Numbering is for counting only. Pull by id.`, "", ); @@ -149,22 +147,28 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { 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( - purpose ? `### ${group.kind} — ${purpose}` : `### ${group.kind}`, + "### 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}\``); - 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( + 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(""); @@ -174,6 +178,58 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { 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]); + } + } + + 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.hasFencedExample) types.push("substantial fenced example"); diff --git a/packages/ghost/src/embed/gather.ts b/packages/ghost/src/embed/gather.ts index ce4bf8a3..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: - "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.", - }, + silence: silenceContract(snapshot.cover), coverage: menuCoverage(menu), ...(kinds.length > 0 ? { kinds } : {}), nodes: menu, @@ -53,20 +50,16 @@ export function normalizeAsk(ask: string | undefined): string | 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. + * 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; 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."; + "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 = - "No ask supplied. Re-run `ghost gather ` with the real task before pulling."; + "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(ask: string | undefined): GhostGatherContract { +export function gatherContract(): GhostGatherContract { return { completeness: { complete: true, @@ -81,7 +74,7 @@ export function gatherContract(ask: string | undefined): GhostGatherContract { addForCompleteness: false, omitApplicableForCount: false, }, - ...(ask ? {} : { noAsk: GATHER_NO_ASK_INSTRUCTION }), + noAsk: GATHER_NO_ASK_INSTRUCTION, }; } @@ -103,45 +96,29 @@ 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 ?? []).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 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(), - })); +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/embed/types.ts b/packages/ghost/src/embed/types.ts index 4ac3a585..91797a02 100644 --- a/packages/ghost/src/embed/types.ts +++ b/packages/ghost/src/embed/types.ts @@ -76,8 +76,7 @@ export interface GhostGatherContract { addForCompleteness: false; omitApplicableForCount: false; }; - /** Present only when no ask was supplied to `gather`. */ - noAsk?: string; + 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 3c229023..8d01fcaa 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -7,14 +7,18 @@ 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 @@ -35,10 +39,10 @@ supports; these chapters are subjects, not mandatory slots. 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. A situation may combine surface, channel, modality, audience, -or moment. +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 3e93c0dd..4584ba80 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -13,14 +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, 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. +`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 and caps readiness at Yellow. +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 @@ -38,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 d45d9de3..38c9c4c1 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -41,9 +41,11 @@ 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 — never -as anatomy, history, or rationale for what the kind does not yet cover. Put -anatomy and history in the paragraphs after it. +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 @@ -114,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 b84439c2..c6e4a81c 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -566,13 +566,13 @@ describe("ghost CLI", () => { const markdown = await runCli(["gather"], dir); expect(markdown.code).toBe(0); - expect(markdown.stdout).toContain("## brand"); + expect(markdown.stdout).toContain("## Cover: `brand`"); expect(markdown.stdout).toContain( - "Not part of the menu below; nothing to pull here.", + "This cover is already in context and is not selectable.", ); expect(markdown.stdout).toContain("This cover is unwritten."); expect(markdown.stdout).toContain( - "9 nodes · all prose, no concrete support; readiness caps at Yellow", + "9 nodes · all prose, no concrete support", ); expect(markdown.stdout).not.toMatch(/\d+\.\s+`brand`/); @@ -605,10 +605,11 @@ describe("ghost CLI", () => { const markdown = await runCli(["gather"], dir); expect(markdown.code).toBe(0); - expect(markdown.stdout).not.toContain("## Always-on guidance"); + 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 · all prose, no concrete support; readiness caps at Yellow", + "10 nodes · all prose, no concrete support", ); expect(markdown.stdout).toMatch(/\d+\.\s+`brand`/); @@ -693,12 +694,14 @@ describe("ghost CLI", () => { expect(foundation.purpose).toContain("load-bearing decisions"); expect(foundation.purpose).toContain("Pull every foundation chapter"); - // Markdown renders the same legend inline above that kind's group. + // Markdown renders the same legend beside that kind's group. const markdown = await runCli(["gather"], dir); 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")); @@ -707,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); @@ -780,6 +832,10 @@ describe("ghost CLI", () => { expect(gatherMute.stdout).toContain( "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); @@ -1057,7 +1113,11 @@ 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( "Never invent ghost-backed guidance", @@ -1070,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); });