Skip to content
Merged
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 one selection contract, groups nodes by declared kind order with uncategorized nodes last, labels applicability, and reports factual coverage without readiness colors.
154 changes: 112 additions & 42 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,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");
}
if (coverage.withoutFor > 0) {
parts.push(`${coverage.withoutFor} lack \`for\` payloads`);
}
Expand All @@ -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 <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(
`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 <id> [<id>…]`.");
return `${lines.join("\n")}\n`;
}

interface MenuGroup {
kind: string | undefined;
entries: CatalogMenuEntry[];
}

function groupMenuByKind(
menu: readonly CatalogMenuEntry[],
kinds: NonNullable<GhostGatherResult["kinds"]>,
): MenuGroup[] {
const declaredOrder = kinds.map((kind) => kind.name);
const declaredSet = new Set(declaredOrder);
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]);
}
}
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;
Expand Down
61 changes: 40 additions & 21 deletions packages/ghost/src/embed/gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <ask>` before pulling for a task.";

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

Expand All @@ -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.",
};
}
43 changes: 26 additions & 17 deletions packages/ghost/src/init-payloads/skeleton/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,42 @@ 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
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.

---

Expand Down
14 changes: 8 additions & 6 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading