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

Allow `ghost review --node <id>` to include host-selected guidance and offer referencing checks alongside existing material matches without filtering other checks.
12 changes: 12 additions & 0 deletions packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ the catalog without grounding a task.
Run `ghost --help` for the core workflow and `ghost <command> --help` for
current flags and command behavior.

When new work uses unchanged components or prose guidance, name the nodes that
govern the change:

```bash
ghost review --node component.button --node voice
```

Repeat `--node` for each applicable ID. This adds guidance and referencing
checks alongside existing diff matches; it never filters other checks.
Unknown IDs stop review rather than produce a partial packet. Review still
requires `.ghost/checks/`, and the agent decides which offered checks apply.

## Library

```ts
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/src/commands/command-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const COMMAND_DISCOVERY = [
defaultHelp: true,
compactName: "review",
summary:
"Emit an advisory review packet for a diff (needs .ghost/checks/).",
"Review a diff with matched and optional explicit guidance (needs .ghost/checks/).",
},
{
name: "checks",
Expand Down
26 changes: 25 additions & 1 deletion packages/ghost/src/commands/review-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { promisify } from "node:util";
import type { CAC } from "cac";
import { UsageError } from "#ghost-core";
import { loadGhostPackage, resolveGhostPackage } from "../package.js";
import { validateExplicitReviewNodes } from "../review/resolve.js";
import {
buildReviewPacket,
formatReviewPacket,
Expand All @@ -17,12 +19,16 @@ export function registerReviewCommand(cli: CAC): void {
cli
.command(
"review",
"Emit an advisory review packet for a diff using material-backed nodes and checks.",
"Emit an advisory review packet for a diff using matched and explicit guidance plus checks.",
)
.option(
"--package <dir>",
"Use this ghost package directory (default: ./.ghost)",
)
.option(
"--node <id>",
"Add explicit guidance and its checks; repeat for multiple IDs (does not filter matches or always-offered checks)",
)
.option("--base <ref>", "Git ref to diff against (default: HEAD)")
.option("--diff <path>", "Read diff from a file, or '-' for stdin")
.option("--format <fmt>", "Output format: markdown or json", {
Expand All @@ -44,13 +50,18 @@ export function registerReviewCommand(cli: CAC): void {
await exitCli(2);
return;
}
const nodeIds = validateExplicitReviewNodes(
ghostPackage.catalog,
normalizeNodeOption(opts.node),
);
const diffText = await resolveDiff({
base: opts.base,
diff: opts.diff,
});
const packet = await buildReviewPacket(ghostPackage, diffText, {
packageDir: paths.packageDir,
cwd: process.cwd(),
nodeIds,
});
process.stdout.write(
format === "json"
Expand All @@ -64,6 +75,19 @@ export function registerReviewCommand(cli: CAC): void {
});
}

function normalizeNodeOption(value: unknown): string[] {
if (value === undefined) return [];
const values = Array.isArray(value) ? value : [value];
return values.map((id: unknown) => {
if (typeof id !== "string" || id.trim().length === 0) {
throw new UsageError(
"--node requires a nonempty node ID for each occurrence.",
);
}
return id;
});
}

async function resolveDiff(options: {
base?: string;
diff?: string;
Expand Down
54 changes: 49 additions & 5 deletions packages/ghost/src/review/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
type MaterialTransportOptions,
materialLocator,
materialLocatorClaimsPath,
NodeIdSchema,
parseCheckReference,
UsageError,
} from "#ghost-core";
import type { LoadedCheck } from "../scan/check-files.js";
import { parseTouchedFiles, type TouchedFile } from "./diff.js";
Expand All @@ -18,8 +20,9 @@ export interface MatchedMaterialNode {
export interface OfferedCheck {
id: string;
severity: string | undefined;
offered: "matched" | "always";
offered: "matched" | "always" | "explicit";
via: string[];
explicitVia?: string[];
}

export interface CoverageGap {
Expand All @@ -30,18 +33,39 @@ export interface CoverageGap {
}

export interface ReviewResolution {
explicitNodeIds: string[];
touchedFiles: TouchedFile[];
materialNodes: MatchedMaterialNode[];
offeredChecks: OfferedCheck[];
gaps: CoverageGap[];
}

/** Validate the complete selection before any review work; identities are exact. */
export function validateExplicitReviewNodes(
catalog: GhostCatalog,
ids: readonly string[] = [],
): string[] {
const uniqueIds = [...new Set(ids)];
const invalid = uniqueIds.filter(
(id) => !NodeIdSchema.safeParse(id).success || !catalog.nodes.has(id),
);
if (invalid.length > 0) {
throw new UsageError(
`Invalid or unknown review node IDs: ${invalid.map((id) => JSON.stringify(id)).join(", ")}. Run ghost gather --format json with the same --package to find exact node IDs.`,
);
}
return uniqueIds;
}

export function resolveReview(
catalog: GhostCatalog,
checks: Map<string, LoadedCheck>,
diffText: string,
transport: MaterialTransportOptions,
nodeIds: readonly string[] = [],
): ReviewResolution {
const explicitNodeIds = validateExplicitReviewNodes(catalog, nodeIds);
const explicitNodes = new Set(explicitNodeIds);
const touchedFiles = parseTouchedFiles(diffText);
const materialNodeIds = new Set<string>();
const matched = new Map<
Expand Down Expand Up @@ -83,22 +107,39 @@ export function resolveReview(

for (const check of checks.values()) {
const matchedRefs: string[] = [];
const explicitRefs: string[] = [];
let referencesMaterial = false;
for (const raw of check.references) {
const ref = parseCheckReference(raw);
if (ref === null) continue;
if (explicitNodes.has(ref.nodeId)) explicitRefs.push(raw);
if (materialNodeIds.has(ref.nodeId)) {
referencesMaterial = true;
referencedMaterialNodes.add(ref.nodeId);
if (touchedMaterialNodes.has(ref.nodeId)) matchedRefs.push(raw);
}
}
if (matchedRefs.length > 0 || !referencesMaterial) {
if (
matchedRefs.length > 0 ||
!referencesMaterial ||
explicitRefs.length > 0
) {
offeredChecks.push({
id: check.id,
severity: check.doc.frontmatter.severity,
offered: matchedRefs.length > 0 ? "matched" : "always",
via: matchedRefs.length > 0 ? matchedRefs : check.references.slice(),
offered:
matchedRefs.length > 0
? "matched"
: !referencesMaterial
? "always"
: "explicit",
via:
matchedRefs.length > 0
? matchedRefs
: !referencesMaterial
? check.references.slice()
: explicitRefs,
...(explicitRefs.length > 0 ? { explicitVia: explicitRefs } : {}),
});
}
}
Expand All @@ -111,7 +152,9 @@ export function resolveReview(
gaps.push({
kind: "unmatched-file",
detail:
"changed files match no node `materials` locators — no ghost package guidance claims them",
explicitNodeIds.length > 0
? "changed files have no local material locator matches"
: "changed files match no node `materials` locators — no ghost package guidance claims them",
files: unmatched,
});
}
Expand All @@ -135,6 +178,7 @@ export function resolveReview(
}));

return {
explicitNodeIds,
touchedFiles,
materialNodes: matchedNodes,
offeredChecks,
Expand Down
88 changes: 85 additions & 3 deletions packages/ghost/src/review/review-packet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ export interface PacketMaterialNode {
files: string[];
}

/** Explicit guidance without invented touched-file or material-match claims. */
export type PacketExplicitNode = Omit<
PacketMaterialNode,
"matchedMaterials" | "files"
>;

export interface PacketCheck {
id: string;
severity: string | undefined;
offered: "matched" | "always";
offered: "matched" | "always" | "explicit";
via: string[];
explicitVia?: string[];
prose: string;
baseline: BaselineProse[];
}
Expand All @@ -45,6 +52,10 @@ export interface ReviewPacket {
diagnostics: ReadonlyArray<Readonly<{ file: string; message: string }>>;
touchedFiles: string[];
materialNodes: PacketMaterialNode[];
/** Present only when the host explicitly names guidance for this review. */
explicitNodeIds?: string[];
/** Selected nodes not already included in materialNodes. */
explicitNodes?: PacketExplicitNode[];
checks: PacketCheck[];
gaps: CoverageGap[];
diff: string;
Expand All @@ -55,6 +66,7 @@ export interface BuildReviewPacketOptions {
/** Absolute path of the ghost package directory (default: cwd/.ghost). */
packageDir?: string;
cwd?: string;
nodeIds?: readonly string[];
}

export async function buildReviewPacket(
Expand All @@ -72,19 +84,35 @@ export async function buildReviewPacket(
packageDir: options.packageDir ?? join(cwd, ".ghost"),
materialsDir: GHOST_MATERIALS_DIR,
},
options.nodeIds,
);

const materialNodes: PacketMaterialNode[] = resolution.materialNodes.map(
(matched) => materialNodeFromMatch(ghostPackage, matched),
);

const matchedIds = new Set(materialNodes.map((node) => node.id));
const explicitNodes = resolution.explicitNodeIds
.filter((id) => !matchedIds.has(id))
.map((id): PacketExplicitNode => {
const node = ghostPackage.catalog.nodes.get(id) as GhostCatalogNode;
return {
id: node.id,
...(node.kind !== undefined ? { kind: node.kind } : {}),
...(node.for !== undefined ? { for: node.for } : {}),
prose: node.body,
materials: node.materials ?? [],
};
});

const checks: PacketCheck[] = resolution.offeredChecks.map((offered) => {
const check = ghostPackage.checks.get(offered.id);
return {
id: offered.id,
severity: offered.severity,
offered: offered.offered,
via: offered.via,
...(offered.explicitVia ? { explicitVia: offered.explicitVia } : {}),
prose: check?.doc.body.trim() ?? "",
baseline:
check?.references
Expand All @@ -98,6 +126,9 @@ export async function buildReviewPacket(
diagnostics: [...ghostPackage.invalid, ...ghostPackage.invalidChecks],
touchedFiles: resolution.touchedFiles.map((file) => file.path),
materialNodes,
...(resolution.explicitNodeIds.length > 0
? { explicitNodeIds: resolution.explicitNodeIds, explicitNodes }
: {}),
checks,
gaps: resolution.gaps,
diff: diffText,
Expand Down Expand Up @@ -158,13 +189,56 @@ export function formatReviewPacket(packet: ReviewPacket): string {
: undefined;
out.push(`- \`${locator}\`${note ? ` — Note: ${note}` : ""}`);
}
if (packet.explicitNodeIds?.includes(node.id)) {
const additional = node.materials.filter(
(material) =>
!node.matchedMaterials.includes(materialLocator(material)),
);
if (additional.length > 0) {
out.push("Other declared materials (not diff matches):");
for (const material of additional) {
const { locator, note } = normalizeMaterial(material);
out.push(`- \`${locator}\`${note ? `: ${note}` : ""}`);
}
}
}
out.push("Files:");
for (const file of node.files) out.push(`- \`${file}\``);
out.push("");
}
}

const shownNodes = new Set(packet.materialNodes.map((node) => node.id));
if (packet.explicitNodeIds?.length) {
out.push(
"## Explicit guidance",
"",
"The host supplied these nodes for this review. Weigh their applicability; selection does not establish a file match.",
"",
);
const matchedIds = new Set(packet.materialNodes.map((node) => node.id));
for (const id of packet.explicitNodeIds) {
if (matchedIds.has(id)) out.push(`- \`${id}\` (prose shown above)`);
}
for (const node of packet.explicitNodes ?? []) {
out.push(`### \`${node.id}\``, "");
if (node.for) out.push(`Applies when: ${node.for}`, "");
out.push(node.prose, "");
if (node.materials.length > 0) {
out.push("Declared materials (not diff matches):");
for (const material of node.materials) {
const { locator, note } = normalizeMaterial(material);
out.push(`- \`${locator}\`${note ? `: ${note}` : ""}`);
}
out.push("");
}
}
out.push("");
}

const shownNodes = new Set([
...packet.materialNodes.map((node) => node.id),
...(packet.explicitNodes ?? []).map((node) => node.id),
]);
const shownSections = new Set<string>();
out.push("## Offered checks — weigh which apply");
if (packet.checks.length === 0) {
Expand All @@ -178,9 +252,17 @@ export function formatReviewPacket(packet: ReviewPacket): string {
out.push(
check.offered === "matched"
? `Offered via material match: ${refs}`
: `Always offered — no referenced material-backed node gates it: ${refs}`,
: check.offered === "explicit"
? `Offered via explicit guidance: ${refs}`
: `Always offered — no referenced material-backed node gates it: ${refs}`,
"",
);
if (check.explicitVia?.length && check.offered !== "explicit") {
out.push(
`Also selected explicitly: ${check.explicitVia.map((ref) => `\`${ref}\``).join(", ")}`,
"",
);
}
if (check.baseline.length > 0) {
out.push("Baseline prose:");
for (const baseline of check.baseline) {
Expand Down
4 changes: 4 additions & 0 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ tuning.
`review` does no grading. It assembles the review packet: touched files,
matched material-backed nodes, offered checks with baseline prose, loading
failures, coverage gaps, and the diff. The host agent renders findings.
Use `ghost review --node <id>` for guidance that governs the change but may not
match touched material files. Repeat the flag for multiple IDs. Explicit
selection adds context and checks; it does not filter other checks or grade
applicability.

For visual work, do not stop at generation: ground, make, then verify in two
tracks, repair within budget, and review. See
Expand Down
Loading
Loading