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
9 changes: 9 additions & 0 deletions .changeset/gather-ranking-and-surface-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@anarchitecture/ghost": minor
---

Rank the closest nodes when `ghost gather` is given an inexact query (matching
id, description, then body, single words or a phrase) instead of dumping the
whole menu, and emit the stable `ERR_UNKNOWN_SURFACE` code with closest-id
suggestions when `gather`, `checks`, or `review` is given a node or surface that
is not in the package.
17 changes: 15 additions & 2 deletions apps/docs/src/content/docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,15 @@ ghost validate --format json
### Compose a surface slice: `gather`

With no argument, list every node by id and description so an agent can match a
task to one. With a surface, compose its context slice. Folders are walls,
files fill the corridor:
task to one. With an exact node id, compose its context slice. With an inexact
query (`ghost gather payment`), `gather` ranks the closest nodes instead of
dumping the whole menu: a verbatim match wins (an exact id, then a name,
description, or body substring), then a whole-name typo fallback, then
multi-word phrases by how many of their words a node covers — so
`ghost gather payment confirmation` still finds the right surface. Each
candidate is returned for the agent to pick with a follow-up `ghost gather`.

Composing a slice, folders are walls and files fill the corridor:

- **spine** (full bodies): every file from the package root down to the
surface's own folder. A sibling folder is a wall; its nodes never appear.
Expand All @@ -120,13 +127,19 @@ nodes always pass.
```bash
ghost gather
ghost gather checkout
ghost gather payment # inexact: ranks the closest nodes
ghost gather checkout --as email
ghost gather checkout --format json
```

This is the pre-generation step: Ghost gives agents surface-composition context
before they build, not only after a review finds drift.

Naming a surface that is not in the package is an error, not a silent empty
result: `gather` returns ranked candidates, and `checks` and `review` emit the
stable code `ERR_UNKNOWN_SURFACE` with closest-id suggestions, so an agent can
retry without a human.

</DocSection>

<DocSection title="Govern Changes">
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/generated/cli-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-28T21:25:38.799Z",
"generatedAt": "2026-06-29T14:19:25.804Z",
"tools": [
{
"tool": "ghost",
Expand Down
1 change: 1 addition & 0 deletions install/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"references/remediate.md",
"references/review.md",
"references/schema.md",
"references/self-check.md",
"references/verify.md"
]
}
12 changes: 12 additions & 0 deletions packages/ghost/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
formatReviewPacketMarkdown,
} from "./commands/review-packet.js";
import { registerSkillCommand } from "./commands/skill-command.js";
import {
UnknownSurfaceError,
writeUnknownSurfaceError,
} from "./commands/surface-guard.js";

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -87,6 +91,14 @@ export function buildCli(): ReturnType<typeof cac> {
}
process.exit(0);
} catch (err) {
if (err instanceof UnknownSurfaceError) {
writeUnknownSurfaceError(
err.unknown,
opts.format === "json" ? "json" : "markdown",
);
process.exit(2);
return;
}
console.error(
`Error: ${err instanceof Error ? err.message : String(err)}`,
);
Expand Down
5 changes: 5 additions & 0 deletions packages/ghost/src/commands/checks-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { resolveFingerprintPackage } from "../fingerprint.js";
import { loadChecksDir } from "../scan/checks-dir.js";
import { loadFingerprintPackage } from "../scan/fingerprint-package.js";
import { guardSurfaces } from "./surface-guard.js";

function parseSurfaceIds(value: unknown): string[] {
const raw = Array.isArray(value) ? value : value === undefined ? [] : [value];
Expand Down Expand Up @@ -60,6 +61,10 @@ export function registerChecksCommand(cli: CAC): void {
// routes + grounds for those surfaces; it does not infer from paths.
const touched = parseSurfaceIds(opts.surface);

// A named surface absent from the graph is an error, not a silent
// empty route — emit ERR_UNKNOWN_SURFACE with suggestions and stop.
if (guardSurfaces(loaded.graph, touched, opts.format)) return;

const routed = selectChecksForSurfaces(checks, loaded.graph, touched);

const incarnation =
Expand Down
83 changes: 59 additions & 24 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type GraphSlice,
type GraphSliceProvenance,
resolveGraphSlice,
type SearchHit,
searchGraph,
} from "#ghost-core";
import { resolveFingerprintPackage } from "../fingerprint.js";
import { loadFingerprintPackage } from "../scan/fingerprint-package.js";
Expand Down Expand Up @@ -47,20 +49,43 @@ export function registerGatherCommand(cli: CAC): void {
// The agent names the node (it analyzed the prompt + diff). Ghost
// does not infer the anchor from repo paths.
const surface = surfaceArg;

// No node named, or an unknown one: return the menu, never the tree.
const known = new Set(menu.map((entry) => entry.id));
if (!surface || !known.has(surface)) {

// No node named: list the full menu so the agent can match against it.
if (!surface) {
if (opts.format === "json") {
process.stdout.write(
`${JSON.stringify({ kind: "menu", surfaces: menu }, null, 2)}\n`,
);
} else {
process.stdout.write(formatMenuMarkdown(menu, surface));
process.stdout.write(formatMenuMarkdown(menu));
}
process.exit(0);
return;
}

// An inexact query (not an exact node id): rank the closest nodes
// rather than dumping the whole menu. This is `gather`'s search front
// end — the same act as picking from the menu, done intelligently.
if (!known.has(surface)) {
const matches = searchGraph(surface, loaded.graph);
if (opts.format === "json") {
process.stdout.write(
`${JSON.stringify(
{
kind: "candidates",
code: "ERR_UNKNOWN_SURFACE",
query: surface,
candidates: matches,
},
null,
2,
)}\n`,
);
} else {
process.stdout.write(formatCandidatesMarkdown(surface, matches));
}
// Unknown surface is an error (2); no surface at all is a valid menu
// request (0).
process.exit(surface && !known.has(surface) ? 2 : 0);
process.exit(2);
return;
}

Expand All @@ -83,23 +108,13 @@ export function registerGatherCommand(cli: CAC): void {
});
}

function formatMenuMarkdown(
menu: GraphMenuEntry[],
unknown: string | undefined,
): string {
const lines: string[] = ["# Ghost Nodes"];
if (unknown) {
lines.push(
"",
`Node \`${unknown}\` is not in this package. Pick one of the nodes below.`,
);
} else {
lines.push(
"",
"No node selected. Match the ask to one of these nodes, then run `ghost gather <node>`.",
);
}
lines.push("");
function formatMenuMarkdown(menu: GraphMenuEntry[]): string {
const lines: string[] = [
"# Ghost Nodes",
"",
"No node selected. Match the ask to one of these nodes, then run `ghost gather <node>`.",
"",
];
for (const entry of menu) {
const parent =
entry.parent === entry.id ? "" : ` (under \`${entry.parent}\`)`;
Expand All @@ -109,6 +124,26 @@ function formatMenuMarkdown(
return `${lines.join("\n")}\n`;
}

function formatCandidatesMarkdown(query: string, matches: SearchHit[]): string {
const lines: string[] = ["# Ghost Nodes", ""];
if (matches.length === 0) {
lines.push(
`No node matches \`${query}\`. Run \`ghost gather\` to list every node.`,
);
return `${lines.join("\n")}\n`;
}
lines.push(
`\`${query}\` is not a node id. Closest matches — run \`ghost gather <node>\`:`,
"",
);
for (const hit of matches) {
const kind = hit.surface ? "surface" : "node";
lines.push(`- \`${hit.id}\` (${kind})`);
if (hit.description) lines.push(` - ${hit.description}`);
}
return `${lines.join("\n")}\n`;
}

function provenanceLabel(provenance: GraphSliceProvenance): string {
switch (provenance.kind) {
case "own":
Expand Down
6 changes: 6 additions & 0 deletions packages/ghost/src/commands/review-packet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
loadFingerprintPackage,
resolveFingerprintPackage,
} from "../scan/fingerprint-package.js";
import { findUnknownSurfaces, UnknownSurfaceError } from "./surface-guard.js";

const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000;

Expand All @@ -33,6 +34,11 @@ export async function buildReviewPacket(options: {
// The agent names the touched surfaces; dedupe and route.
const touched = [...new Set(options.surfaces.filter((s) => s.length > 0))];

// A named surface absent from the graph is an error, not a silent empty
// route. The command renders this with suggestions.
const unknown = findUnknownSurfaces(loaded.graph, touched);
if (unknown.length > 0) throw new UnknownSurfaceError(unknown);

const routed = selectChecksForSurfaces(checks, loaded.graph, touched);
// Grounding is the gather slice: the prose nodes a finding can cite.
const grounding = touched.map((surface) =>
Expand Down
92 changes: 92 additions & 0 deletions packages/ghost/src/commands/surface-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { buildGraphMenu, closestIds, type GhostGraph } from "#ghost-core";

/**
* The single stable error code the surface-naming commands branch on. When an
* agent names a surface that is not in the graph, `checks`/`review`/`gather`
* emit this code with closest-id suggestions, so the agent can self-correct
* instead of silently routing to nothing.
*/
export const ERR_UNKNOWN_SURFACE = "ERR_UNKNOWN_SURFACE" as const;

export interface UnknownSurface {
surface: string;
suggestions: string[];
}

/**
* Find any named surfaces absent from the graph, each with closest-id
* suggestions. Empty when every surface resolves. Pure: no I/O, no exit.
*/
export function findUnknownSurfaces(
graph: GhostGraph,
surfaces: string[],
): UnknownSurface[] {
const known = new Set(buildGraphMenu(graph).map((entry) => entry.id));
const unknown: UnknownSurface[] = [];
for (const surface of surfaces) {
if (!known.has(surface)) {
unknown.push({ surface, suggestions: closestIds(surface, known) });
}
}
return unknown;
}

/**
* Thrown by library code (e.g. the review packet builder) that cannot itself
* decide output format or exit. The command catches it and renders via
* {@link writeUnknownSurfaceError}.
*/
export class UnknownSurfaceError extends Error {
readonly code = ERR_UNKNOWN_SURFACE;
constructor(readonly unknown: UnknownSurface[]) {
super(`unknown surface(s): ${unknown.map((u) => u.surface).join(", ")}`);
this.name = "UnknownSurfaceError";
}
}

/** Render an unknown-surface failure in the requested format (no exit). */
export function writeUnknownSurfaceError(
unknown: UnknownSurface[],
format: "markdown" | "json",
): void {
if (format === "json") {
process.stdout.write(
`${JSON.stringify(
{
error: `unknown surface(s): ${unknown.map((u) => u.surface).join(", ")}`,
code: ERR_UNKNOWN_SURFACE,
unknown,
},
null,
2,
)}\n`,
);
} else {
for (const { surface, suggestions } of unknown) {
const didYouMean =
suggestions.length > 0
? ` Did you mean: ${suggestions.map((s) => `\`${s}\``).join(", ")}?`
: "";
process.stderr.write(
`Error [${ERR_UNKNOWN_SURFACE}]: unknown surface \`${surface}\`.${didYouMean}\n`,
);
}
}
}

/**
* Emit the unknown-surface error in the requested format and exit 2. Returns
* `true` when it handled (and the caller should stop); `false` when every
* surface is known.
*/
export function guardSurfaces(
graph: GhostGraph,
surfaces: string[],
format: "markdown" | "json",
): boolean {
const unknown = findUnknownSurfaces(graph, surfaces);
if (unknown.length === 0) return false;
writeUnknownSurfaceError(unknown, format);
process.exit(2);
return true;
}
6 changes: 6 additions & 0 deletions packages/ghost/src/ghost-core/graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export {
lintGraph,
} from "./lint.js";
export { buildGraphMenu, type GraphMenuEntry } from "./menu.js";
export {
closestIds,
type SearchHit,
type SearchReason,
searchGraph,
} from "./search.js";
export {
type GraphSlice,
type GraphSliceNode,
Expand Down
Loading