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/preserve-packet-meaning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@design-intelligence/ghost": patch
---

Include referenced baseline prose in Markdown review and preserve full parsed kind descriptions in guidance menus.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ ghost manifest # emit a machine-readable index of commands and flags
```

For a task-specific gather, your agent reads the complete, unfiltered menu and
pulls every node whose stated situation applies. Bare `ghost gather` inspects
pulls every node whose stated situation applies. `ghost gather --format json` inspects
the catalog without grounding a task. Because only selected nodes enter the
working context, the agent can see the shape of the brand without loading the
whole package.
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ghost manifest # emit a machine-readable index of commands and flags
```

For a task-specific gather, your agent reads the complete, unfiltered menu and
pulls every node whose stated situation applies. Bare `ghost gather` inspects
pulls every node whose stated situation applies. `ghost gather --format json` inspects
the catalog without grounding a task.

Run `ghost --help` for the core workflow and `ghost <command> --help` for
Expand Down
9 changes: 7 additions & 2 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,18 @@ function formatMenuMarkdown(menu: GhostGatherResult): string {
"",
"## Available guidance",
"",
"Pull the applicable IDs together with `ghost pull <id> [<id>…]`. If no listed guidance applies, run bare `ghost pull` to receive the cover and uncovered-guidance policy. Skip clear non-matches; topic overlap alone is not enough. Do not limit the number.",
"Pull the applicable IDs together with `ghost pull <id> [<id>…]`.",
menu.contract.selection.instruction,
menu.contract.ifNoneApply,
"",
];

const groups = groupMenuByKind(menu.nodes, menu.kinds ?? []);
const kinds = menu.kinds ?? [];
const groups = groupMenuByKind(menu.nodes, kinds);
for (const group of groups) {
lines.push(group.kind ? `### ${group.kind}` : "### Other guidance", "");
const purpose = kinds.find((kind) => kind.name === group.kind)?.purpose;
if (purpose) lines.push(purpose, "");
for (const entry of group.entries) {
lines.push(`- \`${entry.id}\``);
if (entry.for?.trim()) {
Expand Down
9 changes: 3 additions & 6 deletions packages/ghost/src/embed/gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,8 @@ export function menuCoverage(
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(),
// Later paragraphs may carry selection rules. Preserve the parsed purpose,
// including Markdown structure, rather than silently shortening its meaning.
purpose: kind.purpose.trim(),
}));
}
22 changes: 22 additions & 0 deletions packages/ghost/src/review/review-packet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export function formatReviewPacket(packet: ReviewPacket): string {
}
}

const shownNodes = new Set(packet.materialNodes.map((node) => node.id));
const shownSections = new Set<string>();
out.push("## Offered checks — weigh which apply");
if (packet.checks.length === 0) {
out.push("_No checks were offered for this diff._", "");
Expand All @@ -177,6 +179,26 @@ export function formatReviewPacket(packet: ReviewPacket): string {
for (const baseline of check.baseline) {
out.push(`- ${baseline.ref}`);
if (baseline.warning) out.push(` - ⚠ ${baseline.warning}`);
// A missing heading falls back to the whole node, so later sections
// can point back to it just as they can to a matched material node.
const wholeNode =
baseline.heading === undefined || baseline.warning !== undefined;
const sectionKey = `${baseline.nodeId}\0${baseline.heading?.toLowerCase() ?? ""}`;
if (
shownNodes.has(baseline.nodeId) ||
(!wholeNode && shownSections.has(sectionKey))
) {
out.push(" - Baseline prose shown above.", "");
continue;
}
if (baseline.for) out.push(` - Applies when: ${baseline.for}`);
out.push(
"",
...baseline.body.split(/\r?\n/).map((line) => `> ${line}`),
"",
);
if (wholeNode) shownNodes.add(baseline.nodeId);
else shownSections.add(sectionKey);
}
out.push("");
}
Expand Down
9 changes: 5 additions & 4 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ ghost stats # summarize local gather/pull events while tuning
`gather` does no selection. Its Markdown is an agent-facing instruction
surface: the task, then every selectable id and its applicability. Check the
full list and pull every id that applies. If none apply, run bare `ghost pull`.
Declared kind headings render in glossary order, undeclared kinds
alphabetically, and uncategorized guidance last.
Declared kinds render in glossary order with their full parsed purposes,
undeclared kinds alphabetically, and uncategorized guidance last. Read the kind
selection rules as well as each item's condition.
Markdown omits package diagnostics that do not change the next action. JSON
retains the selection contract, coverage, materials, substantial fenced
examples, Skeletons, and missing `for` payloads for integrations and audits.
Expand All @@ -92,8 +93,8 @@ and diagnostic metadata for integrations. Pulls append structured events to
tuning.

`review` does no grading. It assembles the review packet: touched files,
matched material-backed nodes, offered checks, coverage gaps, and the diff. The
host agent renders findings.
matched material-backed nodes, offered checks with baseline prose, coverage
gaps, and the diff. The host agent renders findings.

For visual work, do not stop at generation: ground, make, then verify in two
tracks, repair within budget, and review. See
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/src/skill-bundle/references/ground.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ should be shaped by a ghost package.
Run `ghost gather <ask>` with the real task, not a generic label. Read the
supplied guidance, then check every item under `Available guidance`. Pull every
id whose `Applies when` condition fits the task. Skip clear non-matches; topic
overlap alone is not enough.
overlap alone is not enough. Honor the kind's selection rules too.

The cover is not in this menu because every pull includes it automatically. If
nothing in the list applies, run bare `ghost pull` for the cover and uncovered-
Expand Down
11 changes: 6 additions & 5 deletions packages/ghost/src/skill-bundle/references/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ 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 --format json` includes each kind's first paragraph as its
purpose for operator tooling. Agent-facing Markdown uses kind headings only;
selection comes from each item's `Applies when` condition. Declared kinds render
in frontmatter order even when their purpose is empty; undeclared kinds render
`ghost gather` preserves each kind's full parsed purpose in JSON and shows it
once above that kind's entries in Markdown. Read the kind's selection rules
alongside each item's `Applies when` condition. Declared kinds render in
frontmatter order even when their purpose is empty; undeclared kinds render
alphabetically after declared kinds, and uncategorized guidance renders last.

## Nodes
Expand Down Expand Up @@ -129,5 +129,6 @@ it does not grade them.
JSON retains node kinds and transport diagnostics omitted from agent-facing
Markdown.
- `ghost review` matches touched files to exact local material paths, offers
relevant checks, and emits a review packet for the host agent.
relevant checks, and emits their referenced baseline prose for the host
agent. Repeated baselines point to prose already included in the packet.
- `ghost stats` summarizes local gather and pull events.
12 changes: 5 additions & 7 deletions packages/ghost/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,8 @@ describe("ghost CLI", () => {
expect(markdown.stdout).not.toContain("already in context");
expect(markdown.stdout).not.toContain("concrete support");
expect(markdown.stdout).not.toContain("Selection contract");
expect(markdown.stdout).not.toContain("selectable node");
expect(markdown.stdout).not.toContain("`for` payload");
expect(markdown.stdout).toContain("If no selectable node applies");
expect(markdown.stdout).toContain("whose `for` payload matches the task");
expect(markdown.stdout).not.toContain("Numbering");
expect(markdown.stdout).not.toContain("materials:");
expect(markdown.stdout).not.toContain("payloads:");
Expand Down Expand Up @@ -828,7 +828,7 @@ describe("ghost CLI", () => {
).rejects.toThrow();
});

it("keeps glossary kind purposes in JSON and only headings in Markdown", async () => {
it("keeps glossary kind purposes in JSON and Markdown", async () => {
await runCli(["init"], dir);

// JSON carries the glossary's declared kinds with their prose purposes.
Expand All @@ -841,13 +841,11 @@ describe("ghost CLI", () => {
expect(foundation.purpose).toContain("load-bearing decisions");
expect(foundation.purpose).toContain("Pull every foundation chapter");

// Markdown uses the kind only as navigation; purpose prose stays in JSON.
// Both formats preserve the kind's selection meaning.
const markdown = await runCli(["gather", "build", "a", "page"], dir);
expect(markdown.stdout).not.toContain("Kinds:");
expect(markdown.stdout).toContain("### foundation");
expect(markdown.stdout).not.toContain(
"The brand's load-bearing decisions for color",
);
expect(markdown.stdout).toContain(foundation.purpose);
expect(markdown.stdout).not.toContain(menu.contract.noAsk);

// A missing glossary degrades to no legend, not an error.
Expand Down
134 changes: 134 additions & 0 deletions packages/ghost/test/semantic-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadGhostPackage, resolveGhostPackage } from "../src/package.js";
import {
buildReviewPacket,
formatReviewPacket,
} from "../src/review/review-packet.js";
import { runCli } from "./cli-test-utils.js";

const PURPOSE =
"Rules for writing copy.\n\nSelect only for legal copy.\n\n- Keep the qualification.\n- Do not apply to informal messages.";
const BODY =
"## Wording\n\nState the unique approved phrase.\n\n## Tone\n\nUse the unique restrained tone.";

describe("agent-facing semantic delivery", () => {
let dir: string;
let packageDir: string;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "ghost-semantic-"));
packageDir = join(dir, ".ghost");
await mkdir(join(packageDir, "checks"), { recursive: true });
await writeFile(
join(packageDir, "manifest.yml"),
"schema: ghost.package/v1\nid: semantic-test\n",
);
await writeFile(
join(packageDir, "glossary.md"),
`---\nkinds:\n - name: rule\n - name: unused\n---\n\n# rule\n\n${PURPOSE}\n\n# unused\n\nUnused meaning.\n`,
);
await writeFile(
join(packageDir, "rule.voice.md"),
`---\nfor: Writing legal copy.\n---\n\n${BODY}\n`,
);
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

async function check(id: string, reference: string): Promise<void> {
await writeFile(
join(packageDir, "checks", `${id}.md`),
`---\nname: ${id}\ndescription: Review wording.\nseverity: high\nreferences:\n - ${reference}\n---\n\nCompare the change with its referenced guidance.\n`,
);
}

async function packet(diff = "") {
const loaded = await loadGhostPackage(resolveGhostPackage(packageDir, dir));
return buildReviewPacket(loaded, diff, { cwd: dir, packageDir });
}

it("delivers every paragraph of kind meaning in JSON and Markdown", async () => {
await writeFile(
join(packageDir, "rule.other.md"),
"---\nfor: Writing legal copy.\n---\n\nOther rule.\n",
);
const json = await runCli(
["gather", "legal copy", "--format", "json"],
dir,
);
expect(json.code).toBe(0);
const menu = JSON.parse(json.stdout);
expect(
menu.kinds.find((kind: { name: string }) => kind.name === "rule").purpose,
).toBe(PURPOSE);

const markdown = await runCli(["gather", "legal copy"], dir);
expect(markdown.code).toBe(0);
expect(markdown.stdout.split(PURPOSE)).toHaveLength(2);
expect(markdown.stdout).not.toContain("Unused meaning.");
expect(markdown.stdout.indexOf(PURPOSE)).toBeLessThan(
markdown.stdout.indexOf("`rule.other`"),
);
expect(markdown.stdout).toContain(menu.contract.selection.instruction);
});

it("renders the baseline and applicability for a prose-only review check", async () => {
await check("voice", "rule.voice");
const result = await packet();
expect(result.materialNodes).toEqual([]);
expect(result.checks[0]?.baseline[0]?.body).toBe(BODY);
const markdown = formatReviewPacket(result);
expect(markdown).toContain("Applies when: Writing legal copy.");
expect(markdown).toContain("> State the unique approved phrase.");
expect(markdown).toContain("> Use the unique restrained tone.");
});

it("renders a repeated baseline section once and points back to it", async () => {
await check("first", "rule.voice > Wording");
await check("second", "rule.voice > wording");
const markdown = formatReviewPacket(await packet());
expect(markdown.split("State the unique approved phrase.")).toHaveLength(2);
expect(markdown).not.toContain("Use the unique restrained tone.");
expect(markdown).toContain("prose shown above");
});

it("preserves distinct baseline sections", async () => {
await check("first", "rule.voice > Wording");
await check("second", "rule.voice > Tone");
const markdown = formatReviewPacket(await packet());
expect(markdown).toContain("State the unique approved phrase.");
expect(markdown).toContain("Use the unique restrained tone.");
});

it("keeps missing-heading warnings and the whole-body fallback", async () => {
await check("first", "rule.voice > Missing");
await check("second", "rule.voice > Tone");
const markdown = formatReviewPacket(await packet());
expect(markdown).toContain("heading 'Missing' not found");
expect(markdown.split("State the unique approved phrase.")).toHaveLength(2);
expect(markdown.split("Use the unique restrained tone.")).toHaveLength(2);
expect(markdown).toContain("prose shown above");
});

it("reuses a matched material node's prose for whole-node and section references", async () => {
await writeFile(
join(packageDir, "rule.voice.md"),
`---\nfor: Writing legal copy.\nmaterials:\n - copy.txt\n---\n\n${BODY}\n`,
);
await check("first", "rule.voice");
await check("second", "rule.voice > Wording");
const result = await packet(
"diff --git a/copy.txt b/copy.txt\n--- a/copy.txt\n+++ b/copy.txt\n@@ -1 +1 @@\n-old\n+new\n",
);
expect(result.materialNodes).toHaveLength(1);
const markdown = formatReviewPacket(result);
expect(markdown.split("State the unique approved phrase.")).toHaveLength(2);
expect(markdown.split("Use the unique restrained tone.")).toHaveLength(2);
expect(markdown.split("prose shown above")).toHaveLength(3);
});
});
Loading