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
5 changes: 5 additions & 0 deletions .changeset/composition-graph-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anarchitecture/ghost": major
---

Remove check surface routing: every check is now offered to the reviewer and the agent judges relevance, so the check `surface:` field, `selectChecksForSurfaces`, `RoutedCheck`, and `CheckRelevance` are gone. Checks bind to the fingerprint through an optional `source:` pointer (`node > Heading`) that `review` surfaces so a finding can cite the prose it enforces.
6 changes: 3 additions & 3 deletions apps/docs/src/generated/cli-manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-06-29T21:44:31.543Z",
"generatedAt": "2026-06-30T05:25:40.805Z",
"tools": [
{
"tool": "ghost",
Expand Down Expand Up @@ -139,7 +139,7 @@
"tool": "ghost",
"name": "checks",
"rawName": "checks",
"description": "Select the markdown checks (ghost.check/v1) relevant to the named surfaces.",
"description": "List the markdown checks (ghost.check/v1) and ground the named surfaces.",
"group": "core",
"defaultHelp": true,
"compactName": "checks",
Expand All @@ -148,7 +148,7 @@
{
"rawName": "--surface <ids>",
"name": "surface",
"description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them.",
"description": "Surface id(s) the change touches (comma-separated or repeated). The agent names them; used to ground, not to filter checks.",
"default": null,
"takesValue": true,
"negated": false
Expand Down
318 changes: 318 additions & 0 deletions docs/ideas/composition-graph-gaps.md

Large diffs are not rendered by default.

46 changes: 22 additions & 24 deletions packages/ghost/src/commands/checks-command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { CAC } from "cac";
import {
type GhostCheckDocument,
type GraphSlice,
type RoutedCheck,
resolveGraphSlice,
selectChecksForSurfaces,
} from "#ghost-core";
import { resolveFingerprintPackage } from "../fingerprint.js";
import { loadChecksDir } from "../scan/checks-dir.js";
Expand All @@ -24,11 +23,11 @@ export function registerChecksCommand(cli: CAC): void {
cli
.command(
"checks",
"Select the markdown checks (ghost.check/v1) relevant to the named surfaces.",
"List the markdown checks (ghost.check/v1) and ground the named surfaces.",
)
.option(
"--surface <ids>",
"Surface id(s) the change touches (comma-separated or repeated). The agent names them.",
"Surface id(s) the change touches (comma-separated or repeated). The agent names them; used to ground, not to filter checks.",
)
.option(
"--package <dir>",
Expand Down Expand Up @@ -59,15 +58,14 @@ export function registerChecksCommand(cli: CAC): void {
const { checks, invalid } = await loadChecksDir(paths.dir);

// The agent names the touched surfaces (it analyzed the diff). Ghost
// routes + grounds for those surfaces; it does not infer from paths.
// grounds those surfaces; it does not infer from paths. Every check is
// offered — the agent judges relevance.
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.
// empty slice — 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 =
typeof opts.as === "string" && opts.as.length > 0
? opts.as
Expand All @@ -89,11 +87,12 @@ export function registerChecksCommand(cli: CAC): void {
`${JSON.stringify(
{
touched_surfaces: touched,
checks: routed.map((r) => ({
name: r.check.frontmatter.name,
severity: r.check.frontmatter.severity,
surface: r.check.frontmatter.surface ?? "core",
relevance: r.relevance,
checks: checks.map((check) => ({
name: check.frontmatter.name,
severity: check.frontmatter.severity,
...(check.frontmatter.source
? { source: check.frontmatter.source }
: {}),
})),
...(withGrounding ? { grounding } : {}),
invalid,
Expand All @@ -104,7 +103,7 @@ export function registerChecksCommand(cli: CAC): void {
);
} else {
process.stdout.write(
formatChecksMarkdown(touched, routed, grounding, invalid),
formatChecksMarkdown(touched, checks, grounding, invalid),
);
}
process.exit(0);
Expand Down Expand Up @@ -133,25 +132,24 @@ function provenanceLabel(

function formatChecksMarkdown(
touched: string[],
routed: RoutedCheck[],
checks: GhostCheckDocument[],
grounding: GraphSlice[],
invalid: Array<{ file: string; message: string }>,
): string {
const lines = ["# Relevant Checks", ""];
const lines = ["# Checks", ""];
lines.push(
`Touched surfaces: ${touched.length ? touched.map((s) => `\`${s}\``).join(", ") : "none (core only)"}`,
"",
);
if (routed.length === 0) {
lines.push("No checks govern the touched surfaces.");
if (checks.length === 0) {
lines.push("No checks defined.");
} else {
for (const { check, relevance } of routed) {
const why =
relevance.kind === "own"
? `own \`${relevance.surface}\``
: `inherited from \`${relevance.surface}\` (via \`${relevance.via}\`)`;
for (const check of checks) {
const source = check.frontmatter.source
? ` — enforces \`${check.frontmatter.source}\``
: "";
lines.push(
`- **${check.frontmatter.name}** (${check.frontmatter.severity}) — ${why}`,
`- **${check.frontmatter.name}** (${check.frontmatter.severity})${source}`,
);
}
}
Expand Down
44 changes: 21 additions & 23 deletions packages/ghost/src/commands/review-packet.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {
type GhostCheckDocument,
type GraphSlice,
type RoutedCheck,
resolveGraphSlice,
selectChecksForSurfaces,
UsageError,
} from "#ghost-core";
import { loadChecksDir } from "../scan/checks-dir.js";
Expand All @@ -15,11 +14,12 @@ import { findUnknownSurfaces, UnknownSurfaceError } from "./surface-guard.js";
const DEFAULT_REVIEW_MAX_DIFF_BYTES = 200_000;

/**
* Build an advisory review packet on the surface rails: for the agent-stated
* surfaces the change touches, select the markdown checks governing those
* surfaces and their ancestors, and ground each in the surface's fingerprint
* slice. The diff is embedded verbatim for the reviewer; it is not used to
* resolve surfaces (the agent already analyzed it and names the surfaces).
* Build an advisory review packet on the surface rails: ground the agent-stated
* touched surfaces in their fingerprint slices, and offer every markdown check
* for the reviewer to apply (the agent judges relevance against the diff and the
* grounded prose; a check's `source:` names the prose it enforces). The diff is
* embedded verbatim; it is not used to resolve surfaces (the agent already
* analyzed it and names the surfaces).
*/
export async function buildReviewPacket(options: {
packageDir?: string;
Expand All @@ -40,7 +40,6 @@ export async function buildReviewPacket(options: {
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) =>
resolveGraphSlice(loaded.graph, surface),
Expand All @@ -51,7 +50,7 @@ export async function buildReviewPacket(options: {
maxDiffBytes: options.maxDiffBytes,
}),
touched_surfaces: touched,
routed_checks: routed,
checks,
grounding,
invalid_checks: invalid,
};
Expand Down Expand Up @@ -79,7 +78,7 @@ function baseReviewPacket(
required_finding_citations: [
"diff location",
"surface the change touches",
"routed check when blocking",
"the applicable check when blocking (cite its `source:` section when the check declares one)",
"grounding ref (why / what) or local-evidence rationale when the surface is silent",
"repair or intentional-divergence rationale",
],
Expand Down Expand Up @@ -160,7 +159,7 @@ interface ReviewPacketBase {

interface ReviewPacket extends ReviewPacketBase {
touched_surfaces: string[];
routed_checks: RoutedCheck[];
checks: GhostCheckDocument[];
grounding: GraphSlice[];
invalid_checks: Array<{ file: string; message: string }>;
}
Expand All @@ -170,7 +169,7 @@ export function formatReviewPacketMarkdown(packet: ReviewPacket): string {

Package: ${packet.package_dir}

Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a routed check. Keep findings grounded in the touched surfaces' grounded nodes and routed checks; do not expand the review into unrelated audit categories.
Review this diff as a non-blocking design-language critic. Advisory findings must be evidence-routed and must cite: ${packet.required_finding_citations.join(", ")}. Do not fail the build unless the issue is tied to a check. Every check below is offered; judge which apply to this diff and the grounded prose, and ignore the rest. Keep findings grounded in the touched surfaces' grounded nodes and the applicable checks; do not expand the review into unrelated audit categories.

Read the grounded nodes for each touched surface (own first, then inherited from ancestors, then related). When a surface's grounding is silent, label the reasoning provisional or report missing-fingerprint / experience-gap instead of pretending the fingerprint is more specific than it is.

Expand All @@ -184,7 +183,7 @@ If the diff exposes missing fingerprint grounding or surface coverage, report it

${formatTouchedSurfacesSection(packet)}

${formatRoutedChecksSection(packet)}
${formatChecksSection(packet)}

${formatGroundingSection(packet)}

Expand Down Expand Up @@ -220,18 +219,17 @@ function formatTouchedSurfacesSection(packet: ReviewPacket): string {
return `## Touched Surfaces\n\n${surfaces}`;
}

function formatRoutedChecksSection(packet: ReviewPacket): string {
const lines = ["## Routed Checks", ""];
if (packet.routed_checks.length === 0) {
lines.push("No checks govern the touched surfaces.");
function formatChecksSection(packet: ReviewPacket): string {
const lines = ["## Checks", ""];
if (packet.checks.length === 0) {
lines.push("No checks defined.");
} else {
for (const { check, relevance } of packet.routed_checks) {
const why =
relevance.kind === "own"
? `own \`${relevance.surface}\``
: `inherited from \`${relevance.surface}\` (via \`${relevance.via}\`)`;
for (const check of packet.checks) {
const source = check.frontmatter.source
? ` — enforces \`${check.frontmatter.source}\``
: "";
lines.push(
`- **${check.frontmatter.name}** (${check.frontmatter.severity}) — ${why}`,
`- **${check.frontmatter.name}** (${check.frontmatter.severity})${source}`,
);
}
}
Expand Down
10 changes: 3 additions & 7 deletions packages/ghost/src/ghost-core/check/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
/**
* Public surface for `ghost.check/v1` — markdown + frontmatter checks an agent
* evaluates (Ghost never runs them). Ghost routes them by surface and grounds
* their findings in the fingerprint. See docs/ideas/phase-7b-grounded-checks.md.
* evaluates (Ghost never runs them). Every check is offered to the reviewer;
* the agent judges relevance against the diff and the grounded prose. A check's
* optional `source:` names the fingerprint prose it enforces.
*/

export { lintGhostCheck } from "./lint.js";
export { loadGhostCheck } from "./load.js";
export { type ParsedCheckMarkdown, parseCheckMarkdown } from "./parse.js";
export {
type CheckRelevance,
type RoutedCheck,
selectChecksForSurfaces,
} from "./route.js";
export {
GHOST_CHECK_SCHEMA,
GHOST_CHECK_SEVERITIES,
Expand Down
40 changes: 20 additions & 20 deletions packages/ghost/src/ghost-core/check/lint.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { NodeIdSchema } from "../node/schema.js";
import { parseCheckMarkdown } from "./parse.js";
import {
GHOST_CHECK_SEVERITIES,
type GhostCheckLintIssue,
type GhostCheckLintReport,
} from "./types.js";

const SURFACE_ID = /^[a-z0-9][a-z0-9_-]*$/;

/**
* Lint a Ghost check markdown file (`ghost.check/v1`): required frontmatter
* (`name`, `description`, `severity`), a known severity, a flat-slug `surface`
* when present, and a non-empty body. Ghost never executes the check — this only
* validates that it is well-formed and routable.
* (`name`, `description`, `severity`), an optional `source:` provenance pointer,
* and a non-empty body. Ghost never executes the check — this only validates
* that it is well-formed.
*/
export function lintGhostCheck(raw: string): GhostCheckLintReport {
const issues: GhostCheckLintIssue[] = [];
Expand Down Expand Up @@ -51,25 +50,26 @@ export function lintGhostCheck(raw: string): GhostCheckLintReport {
});
}

const surface = frontmatter.surface;
if (surface !== undefined) {
if (typeof surface !== "string" || !SURFACE_ID.test(surface)) {
const source = frontmatter.source;
if (source !== undefined) {
// `source:` is a soft provenance pointer: `<node-id>` with an optional
// `> <heading>` anchor. The node-id part should resolve like a path id; a
// malformed shape is a *warning*, never an error, since it may name
// not-yet-written prose (OKF-style tolerance).
const nodePart =
typeof source === "string" ? source.split(">")[0].trim() : "";
if (
typeof source !== "string" ||
!NodeIdSchema.safeParse(nodePart).success
) {
issues.push({
severity: "error",
rule: "check-surface-invalid",
severity: "warning",
rule: "check-source-malformed",
message:
"surface must be a flat slug (lowercase alphanumeric plus _ -, no dots)",
path: "surface",
"source should be a node path id with an optional `> Heading` anchor (e.g. 'checkout/payment > Confirmation')",
path: "source",
});
}
} else {
issues.push({
severity: "warning",
rule: "check-surface-unplaced",
message:
"check has no surface; it will govern the implicit `core` (applies everywhere). Add `surface:` to scope it.",
path: "surface",
});
}

if (body.trim().length === 0) {
Expand Down
6 changes: 3 additions & 3 deletions packages/ghost/src/ghost-core/check/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export function loadGhostCheck(raw: string): GhostCheckDocument {
: typeof frontmatter.turn_limit === "number"
? (frontmatter.turn_limit as number)
: undefined;
const surface =
typeof frontmatter.surface === "string" ? frontmatter.surface : undefined;
const source =
typeof frontmatter.source === "string" ? frontmatter.source : undefined;

return {
frontmatter: {
Expand All @@ -42,7 +42,7 @@ export function loadGhostCheck(raw: string): GhostCheckDocument {
severity: severity as GhostCheckMarkdownSeverity,
...(tools ? { tools } : {}),
...(turnLimit !== undefined ? { turn_limit: turnLimit } : {}),
...(surface ? { surface } : {}),
...(source ? { source } : {}),
},
body,
};
Expand Down
Loading