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
7 changes: 7 additions & 0 deletions .changeset/visible-load-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@design-intelligence/ghost": minor
---

Add load diagnostics to gather, pull, and review JSON and embedded results, with actionable warnings in Markdown. Gather and pull report skipped invalid guidance; review also reports skipped invalid checks. All-miss CLI pulls keep stdout empty and report load diagnostics on stderr before exiting with code 2.

Breaking API change: gather's `contract.completeness.complete` changes from literal `true` to `boolean` and is `false` when invalid guidance was skipped. Consumers must check the value before treating the menu as complete. Healthy results include `diagnostics: []`; no on-disk schema migration is required. Unreadable directories and malformed present glossaries now fail loading instead of appearing absent.
5 changes: 4 additions & 1 deletion packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ Embedded hosts can use `@design-intelligence/ghost/embed` for the same semantic
contract as CLI `gather` and `pull` without CLI-only presentation fields or event
side effects. `loadGhostSnapshot` reads the package, resolved/absent/dangling
cover state, glossary kinds, and checks. `gatherGhostPackage` returns the
complete unfiltered selectable menu without cover content; checks stay separate.
unfiltered selectable menu without cover content; checks stay separate.
Gather and pull return skipped guidance files in `diagnostics`. Check gather's
`contract.completeness.complete` before treating its menu as complete; these
loading diagnostics do not replace `ghost validate`.
`pullGhostNodes` includes the resolved cover before validated, de-duplicated
selected ids, returns misses with suggestions, stable concrete/prose ordering,
stripped node bodies, extracted Skeletons, and material transport packets. Use
Expand Down
7 changes: 6 additions & 1 deletion packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CAC } from "cac";
import { type CatalogMenuEntry, UsageError } from "#ghost-core";
import type { GhostGatherResult } from "../embed/index.js";
import { gatherGhostPackage, loadGhostSnapshot } from "../embed/index.js";
import { formatLoadDiagnostics } from "../internal/load-diagnostics.js";
import { appendGhostEvent, resolveRunId } from "../observability-events.js";
import { resolveGhostPackage } from "../package.js";
import { exitCli, failFromError } from "./errors.js";
Expand Down Expand Up @@ -73,6 +74,7 @@ function normalizeAskParts(askParts: string[] | undefined): string | undefined {
function formatGatherJson(menu: GhostGatherResult): Record<string, unknown> {
return {
kind: menu.kind,
diagnostics: menu.diagnostics,
...(menu.ask ? { ask: menu.ask } : {}),
source: menu.source,
contract: menu.contract,
Expand All @@ -91,7 +93,10 @@ function formatMenuMarkdown(menu: GhostGatherResult): string {
const lines: string[] = [
"# Guidance menu",
"",
"This is the complete, unfiltered menu. For the task below, check every `Applies when` condition and pull every applicable ID. The entries have not been selected or ranked.",
...(menu.diagnostics.length > 0
? [formatLoadDiagnostics(menu.diagnostics), ""]
: []),
`${menu.contract.completeness.complete ? "This is the complete, unfiltered menu." : "This menu is incomplete because invalid guidance was skipped."} For the task below, check every \`Applies when\` condition and pull every applicable ID. The entries have not been selected or ranked.`,
"",
"## Task",
"",
Expand Down
8 changes: 8 additions & 0 deletions packages/ghost/src/commands/pull-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CAC } from "cac";
import { inferMaterialMime, type TransportedMaterial } from "#ghost-core";
import type { GhostPulledNode, GhostPullResult } from "../embed/index.js";
import { loadGhostSnapshot, pullGhostNodes } from "../embed/index.js";
import { formatLoadDiagnostics } from "../internal/load-diagnostics.js";
import { appendGhostEvent, resolveRunId } from "../observability-events.js";
import {
GHOST_EVENTS_FILENAME,
Expand Down Expand Up @@ -69,6 +70,9 @@ export function registerPullCommand(cli: CAC): void {
}

if (result.ids.length === 0 && result.missed.length > 0) {
if (result.diagnostics.length > 0) {
console.error(formatLoadDiagnostics(result.diagnostics));
}
await exitCli(2);
return;
}
Expand Down Expand Up @@ -108,6 +112,7 @@ function formatPullJson(
): Record<string, unknown> {
return {
kind: "pull",
diagnostics: result.diagnostics,
requested: result.requested,
ids: result.ids,
...(result.missed.length > 0 ? { missed: result.missed } : {}),
Expand Down Expand Up @@ -136,6 +141,9 @@ function formatPullJson(

function formatPullMarkdown(result: GhostPullResult): string {
const sections: string[] = [];
if (result.diagnostics.length > 0) {
sections.push(formatLoadDiagnostics(result.diagnostics));
}
if (result.cover.state === "resolved") {
sections.push(formatNodeMarkdown(result.cover.node));
}
Expand Down
7 changes: 4 additions & 3 deletions packages/ghost/src/embed/gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ export function gatherGhostPackage(

return {
kind: "menu",
diagnostics: snapshot.invalid,
...(ask ? { ask } : {}),
source: {
artifact: "ghost package",
list: "Available guidance",
},
contract: gatherContract(),
contract: gatherContract(snapshot.invalid.length === 0),
coverage: menuCoverage(menu),
...(kinds.length > 0 ? { kinds } : {}),
nodes: menu,
Expand All @@ -60,10 +61,10 @@ export const GATHER_IF_NONE_APPLY_INSTRUCTION =
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 {
export function gatherContract(complete = true): GhostGatherContract {
return {
completeness: {
complete: true,
complete,
filtered: false,
ranked: false,
selectedByGhost: false,
Expand Down
10 changes: 9 additions & 1 deletion packages/ghost/src/embed/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export async function pullGhostNodes(
.map((id) => ({ requested: id, suggested: closestIds(id, selectableIds) }));

if (known.length === 0 && missed.length > 0) {
return emptyMissResult(selectedRequested, missed, coverId);
return emptyMissResult(
selectedRequested,
missed,
coverId,
snapshot.invalid,
);
}

const givenNodes = known.map(
Expand Down Expand Up @@ -92,6 +97,7 @@ export async function pullGhostNodes(

return {
kind: "pull",
diagnostics: snapshot.invalid,
requested: selectedRequested,
ids: known,
missed,
Expand All @@ -114,9 +120,11 @@ function emptyMissResult(
requested: readonly string[],
missed: readonly PullMiss[],
coverId: string | undefined,
diagnostics: GhostPullResult["diagnostics"],
): GhostPullResult {
return {
kind: "pull",
diagnostics,
requested,
ids: [],
missed,
Expand Down
20 changes: 16 additions & 4 deletions packages/ghost/src/embed/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile } from "node:fs/promises";
import type { GhostCheckFrontmatter } from "#ghost-core";
import { type GhostCatalogNode, parseGlossary } from "#ghost-core";
import { type GhostCatalogNode, parseGlossary, UsageError } from "#ghost-core";
import { isMissingPathError } from "../internal/fs.js";
import type { LoadedCheck } from "../scan/check-files.js";
import type { GhostPackagePaths } from "../scan/ghost-package.js";
Expand Down Expand Up @@ -97,10 +97,22 @@ async function loadSnapshotGlossary(
raw = await readFile(glossaryPath, "utf-8");
} catch (err) {
if (isMissingPathError(err)) return undefined;
throw err;
throw new Error(
`Cannot read glossary "${glossaryPath}": ${err instanceof Error ? err.message : String(err)}. Check the path and read permissions, then retry.`,
{ cause: err },
);
}
let result: ReturnType<typeof parseGlossary>;
try {
result = parseGlossary(raw);
if (result.glossary === null) {
throw new Error(result.errors[0] ?? "invalid glossary");
}
} catch (err) {
throw new UsageError(
`Cannot load glossary "${glossaryPath}": ${err instanceof Error ? err.message : String(err)}. Fix glossary.md frontmatter (for example, kinds: []), then run \`ghost validate\`.`,
);
}
const result = parseGlossary(raw);
if (result.glossary === null) return undefined;
return {
path: glossaryPath,
kinds: result.glossary.kinds.map((kind) => ({ ...kind })),
Expand Down
6 changes: 5 additions & 1 deletion packages/ghost/src/embed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export interface GhostGatherCoverage {

export interface GhostGatherContract {
completeness: {
complete: true;
complete: boolean;
filtered: false;
ranked: false;
selectedByGhost: false;
Expand All @@ -83,6 +83,8 @@ export interface GhostGatherContract {

export interface GhostGatherResult {
kind: "menu";
/** Invalid guidance files skipped during loading; never includes checks. */
diagnostics: GhostEmbedSnapshot["invalid"];
ask?: string;
source: {
artifact: "ghost package";
Expand Down Expand Up @@ -134,6 +136,8 @@ export interface GhostPullFallback {

export interface GhostPullResult {
kind: "pull";
/** Invalid guidance files skipped during loading, including all-miss pulls. */
diagnostics: GhostEmbedSnapshot["invalid"];
/** Caller-selected ids after de-duplicating and removing a cover alias. */
requested: readonly string[];
ids: readonly string[];
Expand Down
16 changes: 16 additions & 0 deletions packages/ghost/src/internal/load-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Format the loader's existing skipped-file records without running lint again. */
export function formatLoadDiagnostics(
diagnostics: readonly Readonly<{ file: string; message: string }>[],
): string {
if (diagnostics.length === 0) return "";
return [
"> Warning: invalid package files were skipped during loading. Fix the files below, then run `ghost validate` (use `--package <dir>` for a custom package).",
...diagnostics.map(
({ file, message }) => `> - ${oneLine(file)}: ${oneLine(message)}`,
),
].join("\n");
}

function oneLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
7 changes: 7 additions & 0 deletions packages/ghost/src/review/review-packet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
materialLocator,
normalizeMaterial,
} from "#ghost-core";
import { formatLoadDiagnostics } from "../internal/load-diagnostics.js";
import { GHOST_MATERIALS_DIR } from "../scan/constants.js";
import type { LoadedGhostPackage } from "../scan/ghost-package.js";
import { resolveGitRoot } from "../scan/package-paths.js";
Expand Down Expand Up @@ -40,6 +41,8 @@ export interface PacketCheck {

export interface ReviewPacket {
packageId: string;
/** Invalid guidance and check files skipped during loading. */
diagnostics: ReadonlyArray<Readonly<{ file: string; message: string }>>;
touchedFiles: string[];
materialNodes: PacketMaterialNode[];
checks: PacketCheck[];
Expand Down Expand Up @@ -92,6 +95,7 @@ export async function buildReviewPacket(

return {
packageId: ghostPackage.manifest.id,
diagnostics: [...ghostPackage.invalid, ...ghostPackage.invalidChecks],
touchedFiles: resolution.touchedFiles.map((file) => file.path),
materialNodes,
checks,
Expand Down Expand Up @@ -120,6 +124,9 @@ function materialNodeFromMatch(
export function formatReviewPacket(packet: ReviewPacket): string {
const out: string[] = [];
out.push(`# ghost review — package \`${packet.packageId}\``, "");
if (packet.diagnostics.length > 0) {
out.push(formatLoadDiagnostics(packet.diagnostics), "");
}
out.push(
"You are reviewing a diff against ghost package guidance. The command has",
"assembled the touched files, matched material-backed nodes, and offered",
Expand Down
22 changes: 19 additions & 3 deletions packages/ghost/src/scan/check-files.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { readdir, readFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { YAMLParseError } from "yaml";
import {
type GhostCheckDocument,
lintGhostCheck,
loadGhostCheck,
parseCheckMarkdown,
} from "#ghost-core";

import { isMissingPathError } from "../internal/fs.js";

/** Reserved package-root directory holding review checks. */
export const GHOST_CHECKS_DIR = "checks";

Expand Down Expand Up @@ -40,8 +43,14 @@ export async function loadCheckFiles(
let entries: Array<{ name: string; isDirectory(): boolean }>;
try {
entries = await readdir(checksDir, { withFileTypes: true });
} catch {
return { hasChecksDir: false, checks, invalid };
} catch (err) {
if (isMissingPathError(err)) {
return { hasChecksDir: false, checks, invalid };
}
throw new Error(
`Cannot read checks directory "${checksDir}": ${err instanceof Error ? err.message : String(err)}. Check the path and directory permissions, then retry.`,
{ cause: err },
);
}

for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
Expand All @@ -66,7 +75,14 @@ export async function loadCheckFiles(
}

const raw = await readFile(join(checksDir, entry.name), "utf-8");
const lint = lintGhostCheck(raw);
let lint: ReturnType<typeof lintGhostCheck>;
try {
lint = lintGhostCheck(raw);
} catch (err) {
if (!(err instanceof YAMLParseError)) throw err;
invalid.push({ file: `checks/${entry.name}`, message: err.message });
continue;
}
if (lint.errors > 0) {
const first = lint.issues.find((issue) => issue.severity === "error");
invalid.push({
Expand Down
20 changes: 17 additions & 3 deletions packages/ghost/src/scan/node-files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { YAMLParseError } from "yaml";
import { type PlacedNode, parseNode } from "#ghost-core";
import { isMissingPathError } from "../internal/fs.js";
import {
GHOST_GLOSSARY_FILENAME,
GHOST_MANIFEST_FILENAME,
Expand Down Expand Up @@ -67,8 +69,12 @@ async function walk(
try {
const dirents = await readdir(absDir, { withFileTypes: true });
entries = dirents.map((d) => ({ name: d.name, isDir: d.isDirectory() }));
} catch {
return;
} catch (err) {
if (isMissingPathError(err)) return;
throw new Error(
`Cannot read node directory "${absDir}": ${err instanceof Error ? err.message : String(err)}. Check the path and directory permissions, then retry.`,
{ cause: err },
);
}

for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
Expand All @@ -84,7 +90,15 @@ async function walk(
if (!entry.name.endsWith(".md")) continue;

const raw = await readFile(join(packageDir, relPath), "utf-8");
const { node, report } = parseNode(raw);
let parsed: ReturnType<typeof parseNode>;
try {
parsed = parseNode(raw);
} catch (err) {
if (!(err instanceof YAMLParseError)) throw err;
invalid.push({ file: relPath, message: err.message });
continue;
}
const { node, report } = parsed;
if (node === null || report.errors > 0) {
const first = report.issues.find((issue) => issue.severity === "error");
invalid.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 @@ -77,8 +77,9 @@ 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 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
selection rules as well as each item's condition. Loading failures appear in
both formats; do not treat excluded guidance as an authored absence.
Markdown omits other 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 @@ -93,8 +94,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 with baseline prose, coverage
gaps, and the diff. The host agent renders findings.
matched material-backed nodes, offered checks with baseline prose, loading
failures, 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
4 changes: 4 additions & 0 deletions packages/ghost/src/skill-bundle/references/ground.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ 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. Honor the kind's selection rules too.

If the output reports excluded guidance, do not interpret it as silence. Run
`ghost validate` against the same package, resolve the reported failures, or
state the grounding gap before continuing provisionally.

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-
guidance policy.
Expand Down
Loading
Loading