diff --git a/.changeset/gather-ranking-and-surface-guard.md b/.changeset/gather-ranking-and-surface-guard.md new file mode 100644 index 00000000..fcdff64c --- /dev/null +++ b/.changeset/gather-ranking-and-surface-guard.md @@ -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. diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx index 844f02d2..0ae8df0b 100644 --- a/apps/docs/src/content/docs/cli-reference.mdx +++ b/apps/docs/src/content/docs/cli-reference.mdx @@ -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. @@ -120,6 +127,7 @@ 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 ``` @@ -127,6 +135,11 @@ 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. + diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json index 5c74ff8e..1b607ccb 100644 --- a/apps/docs/src/generated/cli-manifest.json +++ b/apps/docs/src/generated/cli-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-28T21:25:38.799Z", + "generatedAt": "2026-06-29T14:19:25.804Z", "tools": [ { "tool": "ghost", diff --git a/install/manifest.json b/install/manifest.json index a5c27e6f..8a3df528 100644 --- a/install/manifest.json +++ b/install/manifest.json @@ -16,6 +16,7 @@ "references/remediate.md", "references/review.md", "references/schema.md", + "references/self-check.md", "references/verify.md" ] } diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index 957239e3..eeced85b 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -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); @@ -87,6 +91,14 @@ export function buildCli(): ReturnType { } 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)}`, ); diff --git a/packages/ghost/src/commands/checks-command.ts b/packages/ghost/src/commands/checks-command.ts index 487e38cc..d344c664 100644 --- a/packages/ghost/src/commands/checks-command.ts +++ b/packages/ghost/src/commands/checks-command.ts @@ -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]; @@ -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 = diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 5a2a9267..522daa52 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -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"; @@ -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; } @@ -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 `.", - ); - } - 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 `.", + "", + ]; for (const entry of menu) { const parent = entry.parent === entry.id ? "" : ` (under \`${entry.parent}\`)`; @@ -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 \`:`, + "", + ); + 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": diff --git a/packages/ghost/src/commands/review-packet.ts b/packages/ghost/src/commands/review-packet.ts index 80a9fab4..49449e8f 100644 --- a/packages/ghost/src/commands/review-packet.ts +++ b/packages/ghost/src/commands/review-packet.ts @@ -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; @@ -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) => diff --git a/packages/ghost/src/commands/surface-guard.ts b/packages/ghost/src/commands/surface-guard.ts new file mode 100644 index 00000000..3c4a6078 --- /dev/null +++ b/packages/ghost/src/commands/surface-guard.ts @@ -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; +} diff --git a/packages/ghost/src/ghost-core/graph/index.ts b/packages/ghost/src/ghost-core/graph/index.ts index 053b3498..7b479859 100644 --- a/packages/ghost/src/ghost-core/graph/index.ts +++ b/packages/ghost/src/ghost-core/graph/index.ts @@ -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, diff --git a/packages/ghost/src/ghost-core/graph/search.ts b/packages/ghost/src/ghost-core/graph/search.ts new file mode 100644 index 00000000..7d1ce2f5 --- /dev/null +++ b/packages/ghost/src/ghost-core/graph/search.ts @@ -0,0 +1,263 @@ +import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js"; + +/** + * Node ranking for `gather`. Where `gather` with no argument lists the full + * node menu sorted by id, and `gather ` composes a slice, an inexact + * query (`gather payment`) needs the *closest* nodes ranked, not the whole menu + * dumped. This is that ranking: deterministic and LLM-free — a name match + * outranks a description match outranks an incidental body mention, a + * whole-name typo is tolerated, and a multi-word phrase matches by how many of + * its words a node covers. Selection machinery, not interpretation. + */ + +/** Why a hit matched, strongest first. Doubles as the ranking tier. */ +export type SearchReason = "exact" | "name" | "description" | "body" | "fuzzy"; + +export interface SearchHit { + id: string; + description?: string; + /** True when the node is a directory/surface (vs. a leaf node). */ + surface: boolean; + /** Higher is more relevant; ties break on id ascending. */ + score: number; + reason: SearchReason; +} + +const SCORE: Record = { + exact: 100, + name: 80, + description: 50, + body: 20, + fuzzy: 10, +}; + +const DEFAULT_LIMIT = 20; + +/** + * Rank a package's local nodes against `query`, nearest first. A node with + * children (or whose index sits in its own folder) is flagged as a surface. + * Inherited (extended-package) nodes are excluded, mirroring `buildGraphMenu` — + * ranking lists what this package offers to anchor at. + */ +export function searchGraph( + query: string, + graph: GhostGraph, + opts: { limit?: number } = {}, +): SearchHit[] { + const needle = query.trim().toLowerCase(); + const limit = opts.limit ?? DEFAULT_LIMIT; + const hits: SearchHit[] = []; + + if (needle.length === 0) return []; + const tokens = tokenize(needle); + + for (const node of graph.nodes.values()) { + if (node.origin === "inherited") continue; + if (node.id === GHOST_GRAPH_ROOT_ID) continue; + // A surface is a directory: its index node sits in its own folder + // (`folder === id`), or it has children placed under it. A leaf's folder is + // its parent directory, so it never matches. + const surface = + node.folder === node.id || (graph.children.get(node.id)?.length ?? 0) > 0; + + const scored = scoreCandidate( + needle, + tokens, + node.id, + node.description, + node.body, + ); + if (!scored) continue; + hits.push({ + id: node.id, + ...(node.description ? { description: node.description } : {}), + surface, + score: scored.score, + reason: scored.reason, + }); + } + + hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); + return hits.slice(0, Math.max(0, limit)); +} + +/** + * Split a query into meaningful tokens: lowercase words of length >= 2 that are + * not common stopwords. An agent's natural query ("payment confirmation + * screen") is a phrase, not a node id, so search must match its words + * independently rather than as one verbatim string. + */ +function tokenize(needle: string): string[] { + return needle + .split(/[^a-z0-9]+/i) + .map((token) => token.toLowerCase()) + .filter((token) => token.length >= 2 && !STOPWORDS.has(token)); +} + +const STOPWORDS = new Set([ + "a", + "an", + "and", + "as", + "at", + "by", + "for", + "from", + "in", + "of", + "on", + "or", + "the", + "to", + "with", +]); + +interface ScoredMatch { + score: number; + reason: SearchReason; +} + +/** + * Score a candidate against the query, strongest signal first: + * + * 1. Whole-query matches (the query, verbatim, in name/description/body) win — + * an exact id, then a name/description/body substring. These are the precise + * hits and keep single-word ranking sharp. + * 2. A whole-name typo gets the fuzzy tier (e.g. `markting` → `marketing`). + * 3. Otherwise, multi-word coverage: how many query tokens appear in the + * candidate (the agent typed a phrase, not an id). The tier follows the + * strongest field any token hit, and the score scales with the fraction of + * tokens covered, so a closer phrase match outranks a looser one. A single + * token hitting only the body is the weakest signal that still counts. + * + * Returns undefined when nothing matches. + */ +function scoreCandidate( + needle: string, + tokens: string[], + name: string, + description: string | undefined, + body: string, +): ScoredMatch | undefined { + const lowerName = name.toLowerCase(); + const lowerDesc = description?.toLowerCase(); + const lowerBody = body.toLowerCase(); + + // 1. Whole-query matches: precise, highest-ranked. + if (lowerName === needle) return { score: SCORE.exact, reason: "exact" }; + if (lowerName.includes(needle)) return { score: SCORE.name, reason: "name" }; + if (lowerDesc?.includes(needle)) { + return { score: SCORE.description, reason: "description" }; + } + if (lowerBody.includes(needle)) return { score: SCORE.body, reason: "body" }; + + // 2. Whole-name typo fallback. + const segment = lowerName.split("/").pop() ?? lowerName; + if (isFuzzyMatch(needle, segment) || isFuzzyMatch(needle, lowerName)) { + return { score: SCORE.fuzzy, reason: "fuzzy" }; + } + + // 3. Multi-word token coverage. Only meaningful for multi-token queries; a + // single token already had its verbatim shot above (a single-token body hit + // is the verbatim body case, already handled). + if (tokens.length < 2) return undefined; + + let covered = 0; + let strongest: SearchReason | undefined; + for (const token of tokens) { + const field = matchField(token, lowerName, lowerDesc, lowerBody); + if (!field) continue; + covered += 1; + if (!strongest || SCORE[field] > SCORE[strongest]) strongest = field; + } + if (covered === 0 || !strongest) return undefined; + + // Scale the field tier by the fraction of tokens covered so a full-phrase + // match outranks a partial one, but keep it below the verbatim tiers. + const coverage = covered / tokens.length; + return { score: Math.round(SCORE[strongest] * coverage), reason: strongest }; +} + +/** The strongest field a single token appears in, or undefined. */ +function matchField( + token: string, + lowerName: string, + lowerDesc: string | undefined, + lowerBody: string, +): SearchReason | undefined { + if (lowerName.includes(token)) return "name"; + if (lowerDesc?.includes(token)) return "description"; + if (lowerBody.includes(token)) return "body"; + return undefined; +} + +/** + * Suggest the ids closest to `query`, nearest first. Used for "did you mean" + * surface suggestions on an unknown name. Substring matches always rank above + * pure edit-distance neighbours. + */ +export function closestIds( + query: string, + ids: Iterable, + max = 3, +): string[] { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return []; + + const scored: { id: string; rank: number; distance: number }[] = []; + for (const id of ids) { + const lower = id.toLowerCase(); + const segment = lower.split("/").pop() ?? lower; + const distance = Math.min( + levenshtein(needle, lower), + levenshtein(needle, segment), + ); + const substring = lower.includes(needle) || needle.includes(lower); + if (substring) { + scored.push({ id, rank: 0, distance }); + } else if (distance <= fuzzyThreshold(needle)) { + scored.push({ id, rank: 1, distance }); + } + } + + scored.sort( + (a, b) => + a.rank - b.rank || a.distance - b.distance || a.id.localeCompare(b.id), + ); + return scored.slice(0, Math.max(0, max)).map((entry) => entry.id); +} + +/** A length-proportional edit-distance threshold for typo tolerance. */ +function fuzzyThreshold(needle: string): number { + if (needle.length <= 4) return 1; + if (needle.length <= 8) return 2; + return 3; +} + +function isFuzzyMatch(needle: string, candidate: string): boolean { + return levenshtein(needle, candidate) <= fuzzyThreshold(needle); +} + +/** Classic iterative Levenshtein distance. Dependency-free, O(n*m). */ +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + let curr = new Array(b.length + 1); + + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min( + (prev[j] ?? 0) + 1, + (curr[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + cost, + ); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length] ?? 0; +} diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts index 237e6133..772955ef 100644 --- a/packages/ghost/src/ghost-core/index.ts +++ b/packages/ghost/src/ghost-core/index.ts @@ -25,6 +25,7 @@ export { ancestorChain, assembleGraph, buildGraphMenu, + closestIds, GHOST_GRAPH_ROOT_ID, type GhostGraph, type GhostGraphNode, @@ -40,6 +41,9 @@ export { type PlacedNode, type ResolveGraphSliceOptions, resolveGraphSlice, + type SearchHit, + type SearchReason, + searchGraph, } from "./graph/index.js"; // --- Node (ghost.node/v1) — the markdown node artifact --- export { diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 3ee5ae28..6111775a 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -64,6 +64,13 @@ node shape. edge hub's subtree. The agent reads the descriptions and pulls what it needs with a follow-up `gather`. +Naming a node that is not in the package is an error, not a silent empty +result. An inexact `gather ` ranks the closest nodes as `candidates` +(matching id, description, then body — single words or a phrase) under the +stable code `ERR_UNKNOWN_SURFACE`; `checks` and `review` emit the same code with +closest-id `suggestions` (in `--format json`) and a "Did you mean" line +otherwise. Branch on the code and retry with a ranked candidate or suggestion. + Checks and review validate output; they are not generation input. `manifest.yml` anchors the package with `schema: ghost.fingerprint-package/v1`. @@ -97,7 +104,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one | `ghost validate [file-or-dir]` | Validate the package: artifact shape and the node graph (links resolve, one root, acyclic). | | `ghost checks --surface ` | Select and ground the markdown checks governing the named surfaces. | | `ghost review --surface [--diff ]` | Emit an advisory review packet: touched surfaces, routed checks, and fingerprint grounding (diff embedded verbatim). | -| `ghost gather [surface] [--as ]` | Compose a surface's context slice (corridor spine + relates edges, plus spoke pointers), or list the surface menu. | +| `ghost gather [node] [--as ]` | Compose a node's context slice (corridor spine + relates edges, plus spoke pointers), list the node menu, or rank the closest nodes for an inexact query. | | `ghost skill install` | Install this unified skill bundle. | ## Advanced CLI Verbs @@ -110,6 +117,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one ## Workflows +- Self-check before generating: follow [references/self-check.md](references/self-check.md). - Collaborative authoring scenarios: follow [references/authoring-scenarios.md](references/authoring-scenarios.md). - Fingerprint capture: follow [references/capture.md](references/capture.md). - Recall surface-composition context: follow [references/recall.md](references/recall.md). diff --git a/packages/ghost/src/skill-bundle/references/brief.md b/packages/ghost/src/skill-bundle/references/brief.md index 042c2917..a420ec92 100644 --- a/packages/ghost/src/skill-bundle/references/brief.md +++ b/packages/ghost/src/skill-bundle/references/brief.md @@ -5,6 +5,10 @@ description: Build a concise pre-generation brief from a surface's gather slice. # Recipe: Brief Work From Ghost Fingerprint +0. Before building, run the [self-check](self-check.md): if you cannot name the + nodes you gathered, label each claim as Ghost-backed or provisional, and + point to where the fingerprint is silent, you are not grounded yet — gather + first. 1. Match the ask to a surface in the menu (`ghost gather --format json` with no surface lists the surfaces and their descriptions), then run `ghost gather --format json`. diff --git a/packages/ghost/src/skill-bundle/references/self-check.md b/packages/ghost/src/skill-bundle/references/self-check.md new file mode 100644 index 00000000..a2e0f113 --- /dev/null +++ b/packages/ghost/src/skill-bundle/references/self-check.md @@ -0,0 +1,42 @@ +--- +name: self-check +description: A pre-generation probe that reveals whether you have actually gathered and reasoned about a surface's fingerprint, before you build or review its UI. +--- + +# Recipe: Self-Check Before Generating + +Before writing or reviewing UI for a surface, check whether you are grounded in +its fingerprint or about to fall back on generic instincts. These questions test +your **grounding and provenance**, not the surface's contents — so they hold for +any fingerprint, however sparse, whatever the product, whoever authored it. + +Ask yourself: + +1. **What did you gather?** Which Ghost nodes did you pull for this surface + (`ghost gather `), and can you cite them by id? If you have not + gathered, you are not grounded — gather first. +2. **What is Ghost-backed vs. provisional?** For each claim you are about to + encode in the UI, is it backed by a gathered node (cite the id), or is it + your own provisional local reasoning? You must be able to label every claim + as one or the other. +3. **Where is the fingerprint silent?** What does this surface's slice not + cover for your task, and what will carry the reasoning in those gaps? Naming + the silence is part of being grounded; pretending coverage you do not have is + not. + +Optionally, where the prose speaks to it: if the gathered nodes say anything +about hierarchy, density, restraint, repetition, or trust, state what they say. +If they do not, that is a valid answer — record it as silence, not as a failure. + +When you cannot answer 1–3: + +1. Run `ghost gather` to list the node menu, or `ghost gather ` to rank + the closest nodes for a term the work is about. +2. Run `ghost gather --format json` to compose the slice and read the + gathered nodes' prose. +3. Re-ask the three questions, citing node ids. + +A genuinely silent fingerprint is an expected state, not a blocker. When the +slice does not cover the task, say so plainly and proceed with provisional local +reasoning when safe; label it non-Ghost-backed. Ask a human before +product-surface-defining choices. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 2a49ebb6..f29c38fc 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -920,17 +920,74 @@ composition: ); }); - it("returns the menu and exits non-zero for an unknown surface", async () => { + it("ranks closest candidates for an inexact gather query", async () => { await writeGatherPackage(dir); const result = await runCli( - ["gather", "nope", "--package", ".ghost", "--format", "json"], + ["gather", "marketng", "--package", ".ghost", "--format", "json"], dir, { allowNoExit: true }, ); expect(result.code).toBe(2); - expect(JSON.parse(result.stdout).kind).toBe("menu"); + const payload = JSON.parse(result.stdout); + expect(payload.kind).toBe("candidates"); + expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); + expect(payload.candidates.map((c: { id: string }) => c.id)).toContain( + "email/marketing", + ); + }); + + it("matches a gather query by phrase against descriptions", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + ["gather", "marketing email", "--package", ".ghost", "--format", "json"], + dir, + { allowNoExit: true }, + ); + + expect(result.code).toBe(2); + const payload = JSON.parse(result.stdout); + expect(payload.candidates[0].id).toBe("email/marketing"); + }); + + it("returns an empty candidate set for a gather query with no match", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + ["gather", "zzzzznope", "--package", ".ghost", "--format", "json"], + dir, + { allowNoExit: true }, + ); + + expect(result.code).toBe(2); + const payload = JSON.parse(result.stdout); + expect(payload.kind).toBe("candidates"); + expect(payload.candidates).toEqual([]); + }); + + it("errors with ERR_UNKNOWN_SURFACE when checks names an unknown surface", async () => { + await writeGatherPackage(dir); + + const result = await runCli( + [ + "checks", + "--surface", + "checkou", + "--package", + ".ghost", + "--format", + "json", + ], + dir, + { allowNoExit: true }, + ); + + expect(result.code).toBe(2); + const payload = JSON.parse(result.stdout); + expect(payload.code).toBe("ERR_UNKNOWN_SURFACE"); + expect(payload.unknown[0].suggestions).toContain("checkout"); }); it("migrates a legacy package to the surface model", async () => { diff --git a/packages/ghost/test/ghost-core/graph-search.test.ts b/packages/ghost/test/ghost-core/graph-search.test.ts new file mode 100644 index 00000000..057c0758 --- /dev/null +++ b/packages/ghost/test/ghost-core/graph-search.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + assembleGraph, + closestIds, + type PlacedNode, + searchGraph, +} from "../../src/ghost-core/index.js"; + +function root( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, +): PlacedNode { + return { id, parent: "core", folder: "", doc: { frontmatter: fm, body: id } }; +} +function dir( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, + body = id, +): PlacedNode { + const slash = id.lastIndexOf("/"); + const parent = slash === -1 ? "core" : id.slice(0, slash); + return { id, parent, folder: id, doc: { frontmatter: fm, body } }; +} +function leaf( + id: string, + fm: PlacedNode["doc"]["frontmatter"] = {}, + body = id, +): PlacedNode { + const slash = id.lastIndexOf("/"); + const folder = slash === -1 ? "" : id.slice(0, slash); + const parent = folder === "" ? "core" : folder; + return { id, parent, folder, doc: { frontmatter: fm, body } }; +} + +function fixture() { + return assembleGraph({ + placedNodes: [ + root("core"), + dir("marketing", { description: "Outbound brand surfaces." }), + leaf( + "marketing/email", + { description: "Lifecycle email." }, + "Restraint and a single call to action.", + ), + dir("checkout", { description: "The payment flow." }), + ], + }); +} + +describe("searchGraph", () => { + it("ranks exact name over description over body", () => { + const graph = fixture(); + const hits = searchGraph("marketing", graph); + expect(hits[0]?.id).toBe("marketing"); + expect(hits[0]?.reason).toBe("exact"); + }); + + it("flags a directory node as a surface and a leaf as a node", () => { + const graph = fixture(); + const email = searchGraph("email", graph).find( + (h) => h.id === "marketing/email", + ); + expect(email?.surface).toBe(false); + + const surface = searchGraph("checkout", graph)[0]; + expect(surface?.surface).toBe(true); + }); + + it("matches a body mention at the lowest tier", () => { + const graph = fixture(); + const hits = searchGraph("restraint", graph); + expect(hits[0]?.id).toBe("marketing/email"); + expect(hits[0]?.reason).toBe("body"); + }); + + it("finds a typo via the fuzzy fallback", () => { + const graph = fixture(); + const hits = searchGraph("markting", graph); + expect(hits.map((h) => h.id)).toContain("marketing"); + expect(hits.find((h) => h.id === "marketing")?.reason).toBe("fuzzy"); + }); + + it("caps the result count with limit", () => { + const graph = fixture(); + const capped = searchGraph("e", graph, { limit: 1 }); + expect(capped.length).toBeLessThanOrEqual(1); + }); + + it("matches a multi-word phrase by token coverage", () => { + const graph = fixture(); + // "Outbound brand surfaces." — neither word is the id, but both are in the + // description, so the phrase should still find the marketing surface. + const hits = searchGraph("outbound brand", graph); + expect(hits[0]?.id).toBe("marketing"); + }); + + it("ranks fuller phrase coverage above partial coverage", () => { + const graph = fixture(); + // 'payment flow' — both words are in checkout's description; only 'payment' + // weakly elsewhere. Checkout should lead. + const hits = searchGraph("payment flow", graph); + expect(hits[0]?.id).toBe("checkout"); + }); + + it("drops stopwords so they do not force false coverage", () => { + const graph = fixture(); + // "the payment flow" — 'the' is a stopword; coverage is over payment+flow. + const withStop = searchGraph("the payment flow", graph); + const withoutStop = searchGraph("payment flow", graph); + expect(withStop[0]?.id).toBe(withoutStop[0]?.id); + expect(withStop[0]?.score).toBe(withoutStop[0]?.score); + }); + + it("excludes the implicit core root and returns nothing for an empty query", () => { + const graph = fixture(); + expect(searchGraph("core", graph).every((h) => h.id !== "core")).toBe(true); + expect(searchGraph(" ", graph)).toEqual([]); + }); +}); + +describe("closestIds", () => { + const ids = ["marketing", "marketing/email", "checkout", "core"]; + + it("suggests the nearest id for a typo", () => { + expect(closestIds("markting", ids)[0]).toBe("marketing"); + }); + + it("ranks substring matches above pure edit-distance neighbours", () => { + expect(closestIds("check", ids)[0]).toBe("checkout"); + }); + + it("returns nothing for an empty query and respects max", () => { + expect(closestIds("", ids)).toEqual([]); + expect(closestIds("marketing", ids, 1).length).toBe(1); + }); +});