Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gather-menu-model-legibility.md
Original file line number Diff line number Diff line change
@@ -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.
100 changes: 57 additions & 43 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -91,15 +92,12 @@ function formatGatherJson(menu: GhostGatherResult): Record<string, unknown> {

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`);
}
Expand All @@ -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 <id> [<id>…]`.",
"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 <id> [<id>…]`. 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 <id> [<id>…]`.");
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;
Expand Down
54 changes: 48 additions & 6 deletions packages/ghost/src/embed/gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand All @@ -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 <ask>` with the real task before pulling.";

export function gatherContract(ask: string | undefined): GhostGatherContract {
return {
completeness: {
Expand All @@ -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 }),
};
}

Expand All @@ -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<string | undefined, CatalogMenuEntry[]>();
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)
Expand Down
3 changes: 2 additions & 1 deletion packages/ghost/src/embed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 13 additions & 9 deletions packages/ghost/src/init-payloads/skeleton/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 6 additions & 7 deletions packages/ghost/src/skill-bundle/references/ground.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@ should be shaped by a ghost package.
Run `ghost gather <ask>` 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

Expand Down
6 changes: 6 additions & 0 deletions packages/ghost/src/skill-bundle/references/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 17 additions & 15 deletions packages/ghost/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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");

Expand All @@ -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);
Expand Down Expand Up @@ -1061,7 +1061,9 @@ describe("ghost CLI", () => {
},
});
expect(menuPayload.next.command).toBe("ghost pull <id> [<id>…]");
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);
Expand Down
Loading