From d8e3ba185f29fcf2996b3250039694e3bb0f3b6e Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Wed, 9 Sep 2026 00:25:42 -0400 Subject: [PATCH] Preserve guidance meaning in CLI output --- .changeset/preserve-packet-meaning.md | 5 + README.md | 2 +- packages/ghost/README.md | 2 +- packages/ghost/src/commands/gather-command.ts | 9 +- packages/ghost/src/embed/gather.ts | 9 +- packages/ghost/src/review/review-packet.ts | 22 +++ packages/ghost/src/skill-bundle/SKILL.md | 9 +- .../src/skill-bundle/references/ground.md | 2 +- .../src/skill-bundle/references/schema.md | 11 +- packages/ghost/test/cli.test.ts | 12 +- packages/ghost/test/semantic-delivery.test.ts | 134 ++++++++++++++++++ 11 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 .changeset/preserve-packet-meaning.md create mode 100644 packages/ghost/test/semantic-delivery.test.ts diff --git a/.changeset/preserve-packet-meaning.md b/.changeset/preserve-packet-meaning.md new file mode 100644 index 00000000..fde5b489 --- /dev/null +++ b/.changeset/preserve-packet-meaning.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": patch +--- + +Include referenced baseline prose in Markdown review and preserve full parsed kind descriptions in guidance menus. diff --git a/README.md b/README.md index 41f84294..4a424c5c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 54df63f5..45ecf94d 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -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 --help` for diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index dee9331f..51f2bc1b 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -99,13 +99,18 @@ function formatMenuMarkdown(menu: GhostGatherResult): string { "", "## Available guidance", "", - "Pull the applicable IDs together with `ghost pull […]`. 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 […]`.", + 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()) { diff --git a/packages/ghost/src/embed/gather.ts b/packages/ghost/src/embed/gather.ts index e48df23d..138f1ffa 100644 --- a/packages/ghost/src/embed/gather.ts +++ b/packages/ghost/src/embed/gather.ts @@ -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(), })); } diff --git a/packages/ghost/src/review/review-packet.ts b/packages/ghost/src/review/review-packet.ts index 89c1c6a1..72888bea 100644 --- a/packages/ghost/src/review/review-packet.ts +++ b/packages/ghost/src/review/review-packet.ts @@ -157,6 +157,8 @@ export function formatReviewPacket(packet: ReviewPacket): string { } } + const shownNodes = new Set(packet.materialNodes.map((node) => node.id)); + const shownSections = new Set(); out.push("## Offered checks — weigh which apply"); if (packet.checks.length === 0) { out.push("_No checks were offered for this diff._", ""); @@ -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(""); } diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index fb4abefe..2b34a66e 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -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. @@ -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 diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index d847c0e2..05489e7a 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -13,7 +13,7 @@ should be shaped by a ghost package. Run `ghost gather ` 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- diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 36748b4f..b287c624 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -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 @@ -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. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 3561964a..a62d2d2b 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -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:"); @@ -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. @@ -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. diff --git a/packages/ghost/test/semantic-delivery.test.ts b/packages/ghost/test/semantic-delivery.test.ts new file mode 100644 index 00000000..f5ba62c8 --- /dev/null +++ b/packages/ghost/test/semantic-delivery.test.ts @@ -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 { + 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); + }); +});