diff --git a/.changeset/ghost-check-json-report.md b/.changeset/ghost-check-json-report.md
new file mode 100644
index 00000000..d52d22f6
--- /dev/null
+++ b/.changeset/ghost-check-json-report.md
@@ -0,0 +1,5 @@
+---
+"@anarchitecture/ghost": minor
+---
+
+Add `ghost check --format json` for detector-backed markdown checks, producing `ghost.check-report/v1` findings that review orchestrators can convert into PR comments.
diff --git a/apps/docs/src/content/docs/cli-reference.mdx b/apps/docs/src/content/docs/cli-reference.mdx
index ab1d0491..0b88c6cb 100644
--- a/apps/docs/src/content/docs/cli-reference.mdx
+++ b/apps/docs/src/content/docs/cli-reference.mdx
@@ -141,6 +141,46 @@ A mixed pull partially succeeds: known ids are emitted, unknown ids warn with cl
+
+
+### Run deterministic checks: `check`
+
+Run detector-backed `ghost.check/v1` checks against a unified diff and emit a
+stable report. Checks without `detector:` remain agent-evaluated and are
+surfaced by `ghost review`, not run by `ghost check`.
+
+
+
+```bash
+ghost check --diff change.patch --format json
+ghost check --base origin/main --format json
+```
+
+The JSON report is intended for review orchestrators that own PR posting
+identity:
+
+```json
+{
+ "schema": "ghost.check-report/v1",
+ "result": "fail",
+ "findings": [
+ {
+ "check_id": "no-palette-utilities",
+ "title": "No raw palette utilities",
+ "severity": "high",
+ "path": "src/features/chat/ui/GhostReviewDemo.tsx",
+ "line": 20,
+ "detector": "forbidden-regex",
+ "message": "Added UI code matched a forbidden pattern.",
+ "match": "bg-gray-100",
+ "repair": "Use semantic token utilities."
+ }
+ ]
+}
+```
+
+
+
### Install the skill: `skill`
diff --git a/apps/docs/src/content/docs/integrations-enterprise-review.mdx b/apps/docs/src/content/docs/integrations-enterprise-review.mdx
new file mode 100644
index 00000000..c85744e1
--- /dev/null
+++ b/apps/docs/src/content/docs/integrations-enterprise-review.mdx
@@ -0,0 +1,89 @@
+---
+title: Enterprise review integration
+slug: integrations/enterprise-review
+description: How Ghost deterministic findings flow into enterprise PR review systems.
+section: guide
+order: 50
+---
+
+
+
+Ghost is not a PR bot. Ghost emits deterministic design findings; your review
+orchestrator owns PR discovery, diff-line mapping, status updates, and the
+GitHub posting identity.
+
+A typical enterprise lane is:
+
+```text
+PR opened or updated
+ ↓
+Enterprise code-review routine
+ ↓
+ghost check --package --diff --format json
+ ↓
+Ghost emits deterministic findings
+ ↓
+Review orchestrator maps findings to inline PR comments
+ ↓
+The organization's approved review bot posts comments
+```
+
+Repo-local scripts should not call `gh pr comment` directly for Ghost review
+findings when a first-party review system owns comment identity, auditability,
+and branch-protection behavior.
+
+
+
+
+
+An enterprise review lane can invoke Ghost as a deterministic check engine:
+
+```bash
+ghost check --package --diff --format json
+```
+
+For environments that do not have `ghost` preinstalled, use a pinned npm
+invocation:
+
+```bash
+npx --yes @anarchitecture/ghost@0.18.1 check \
+ --package \
+ --diff \
+ --format json
+```
+
+The JSON shape is stable for consumers:
+
+```json
+{
+ "schema": "ghost.check-report/v1",
+ "result": "fail",
+ "findings": [
+ {
+ "check_id": "no-palette-utilities",
+ "title": "No raw palette utilities",
+ "severity": "high",
+ "path": "src/features/chat/ui/GhostReviewDemo.tsx",
+ "line": 20,
+ "detector": "forbidden-regex",
+ "message": "Added UI code matched a forbidden pattern.",
+ "match": "bg-gray-100",
+ "repair": "Use semantic token utilities."
+ }
+ ]
+}
+```
+
+
+
+
+
+Use the base branch Ghost package as trusted review policy and the PR diff as
+reviewed input. A PR should not be able to change the policy that evaluates
+itself by default.
+
+For demos where a PR introduces its first Ghost package, make any head-branch
+policy mode explicit in the host review system and clearly label findings as
+demo-grounded. Production review lanes should use the base-branch package.
+
+
diff --git a/apps/docs/src/generated/cli-manifest.json b/apps/docs/src/generated/cli-manifest.json
index cf285923..0999c0be 100644
--- a/apps/docs/src/generated/cli-manifest.json
+++ b/apps/docs/src/generated/cli-manifest.json
@@ -1,5 +1,5 @@
{
- "generatedAt": "2026-07-03T05:32:38.719Z",
+ "generatedAt": "2026-07-06T18:55:13.135Z",
"tools": [
{
"tool": "ghost",
@@ -76,6 +76,50 @@
}
]
},
+ {
+ "tool": "ghost",
+ "name": "check",
+ "rawName": "check",
+ "description": "Run deterministic ghost.check/v1 gates with detectors against a git diff.",
+ "group": "core",
+ "defaultHelp": true,
+ "compactName": "check",
+ "summary": "Run deterministic detector-backed checks against a diff.",
+ "options": [
+ {
+ "rawName": "--base [",
+ "name": "base",
+ "description": "Git ref to diff against (default: HEAD)",
+ "default": null,
+ "takesValue": true,
+ "negated": false
+ },
+ {
+ "rawName": "--diff ",
+ "name": "diff",
+ "description": "Unified diff file to check instead of running git diff. Use '-' for stdin.",
+ "default": null,
+ "takesValue": true,
+ "negated": false
+ },
+ {
+ "rawName": "--package ",
+ "name": "package",
+ "description": "Use this fingerprint package directory (default: ./.ghost)",
+ "default": null,
+ "takesValue": true,
+ "negated": false
+ },
+ {
+ "rawName": "--format ",
+ "name": "format",
+ "description": "Output format: markdown or json",
+ "default": "markdown",
+ "takesValue": true,
+ "negated": false
+ }
+ ]
+ },
{
"tool": "ghost",
"name": "gather",
diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts
index 29cb055e..26111a82 100644
--- a/packages/ghost/src/cli.ts
+++ b/packages/ghost/src/cli.ts
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { cac } from "cac";
+import { registerCheckCommand } from "./commands/check-command.js";
import { formatGhostHelp } from "./commands/command-discovery.js";
import { registerFingerprintCommands } from "./commands/fingerprint-commands.js";
import { registerGatherCommand } from "./commands/gather-command.js";
@@ -21,6 +22,7 @@ export function buildCli(): ReturnType {
const cli = cac("ghost");
registerFingerprintCommands(cli);
+ registerCheckCommand(cli);
registerGatherCommand(cli);
registerPullCommand(cli);
registerPulseCommand(cli);
diff --git a/packages/ghost/src/commands/check-command.ts b/packages/ghost/src/commands/check-command.ts
new file mode 100644
index 00000000..f1058daa
--- /dev/null
+++ b/packages/ghost/src/commands/check-command.ts
@@ -0,0 +1,363 @@
+import { execFile } from "node:child_process";
+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 { matchesGlob } from "../review/glob.js";
+import { resolveFingerprintPackage } from "../scan/fingerprint-package.js";
+import { loadHauntTree } from "../scan/haunt-tree.js";
+import { resolveGhostDirDefault } from "../scan/package-paths.js";
+import { failFromError } from "./errors.js";
+
+const execFileAsync = promisify(execFile);
+
+const CHECK_REPORT_SCHEMA = "ghost.check-report/v1" as const;
+
+type CheckResult = "pass" | "fail";
+
+type CheckDetectorType = "forbidden-regex" | "required-regex";
+
+type CheckDetector = {
+ type: CheckDetectorType;
+ pattern: string;
+ flags?: string;
+ paths?: string[];
+};
+
+type CheckFrontmatterExtras = {
+ id?: unknown;
+ name?: unknown;
+ title?: unknown;
+ description?: unknown;
+ severity?: unknown;
+ detector?: unknown;
+ message?: unknown;
+ repair?: unknown;
+};
+
+export interface GhostCheckReport {
+ schema: typeof CHECK_REPORT_SCHEMA;
+ result: CheckResult;
+ findings: GhostCheckFinding[];
+}
+
+export interface GhostCheckFinding {
+ check_id: string;
+ title: string;
+ severity: string;
+ path?: string;
+ line?: number;
+ detector: CheckDetectorType;
+ message: string;
+ match?: string;
+ repair?: string;
+}
+
+type AddedLine = {
+ path: string;
+ line: number;
+ text: string;
+};
+
+type AddedFile = {
+ path: string;
+ lines: AddedLine[];
+};
+
+/** Register `ghost check`, the deterministic diff gate used by external review lanes. */
+export function registerCheckCommand(cli: CAC): void {
+ cli
+ .command(
+ "check",
+ "Run deterministic ghost.check/v1 gates with detectors against a git diff.",
+ )
+ .option("--base ][", "Git ref to diff against (default: HEAD)")
+ .option(
+ "--diff ",
+ "Unified diff file to check instead of running git diff. Use '-' for stdin.",
+ )
+ .option(
+ "--package ",
+ "Use this fingerprint package directory (default: ./.ghost)",
+ )
+ .option("--format ", "Output format: markdown or json", {
+ default: "markdown",
+ })
+ .action(async (opts) => {
+ try {
+ if (opts.format !== "markdown" && opts.format !== "json") {
+ throw new UsageError("--format must be 'markdown' or 'json'");
+ }
+ const packageDir = resolveFingerprintPackage(
+ typeof opts.package === "string"
+ ? opts.package
+ : resolveGhostDirDefault(),
+ process.cwd(),
+ ).dir;
+ const diffText =
+ typeof opts.diff === "string"
+ ? await readDiffInput(opts.diff)
+ : await readGitDiff(process.cwd(), opts.base ?? "HEAD");
+ const report = await runDeterministicChecks({ packageDir, diffText });
+
+ if (opts.format === "json") {
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+ } else {
+ process.stdout.write(formatCheckReportMarkdown(report));
+ }
+ process.exit(report.result === "fail" ? 1 : 0);
+ } catch (err) {
+ failFromError(err);
+ }
+ });
+}
+
+export async function runDeterministicChecks({
+ packageDir,
+ diffText,
+}: {
+ packageDir: string;
+ diffText: string;
+}): Promise {
+ const hauntTree = await loadHauntTree(packageDir);
+ const checks = [...hauntTree.checks.values()];
+ const addedFiles = parseAddedFiles(diffText);
+ const findings: GhostCheckFinding[] = [];
+
+ for (const check of checks) {
+ const frontmatter = check.doc.frontmatter as CheckFrontmatterExtras;
+ const detector = parseDetector(frontmatter.detector);
+ if (!detector) continue;
+
+ const regex = compileDetectorRegex(detector);
+ const checkId = check.id || checkIdFor(frontmatter);
+ const title = titleFor(frontmatter);
+ const severity = stringOr(frontmatter.severity, "medium");
+ const message =
+ stringOr(frontmatter.message, undefined) ??
+ stringOr(frontmatter.description, undefined) ??
+ "Added diff content matched a Ghost deterministic check.";
+ const repair = stringOr(frontmatter.repair, undefined);
+
+ const targetFiles = addedFiles.filter((file) =>
+ detectorMatchesPath(detector, file.path),
+ );
+
+ if (detector.type === "forbidden-regex") {
+ for (const file of targetFiles) {
+ for (const line of file.lines) {
+ regex.lastIndex = 0;
+ for (const match of line.text.matchAll(regex)) {
+ findings.push({
+ check_id: checkId,
+ title,
+ severity,
+ path: line.path,
+ line: line.line,
+ detector: detector.type,
+ message,
+ ...(match[0] ? { match: match[0] } : {}),
+ ...(repair ? { repair } : {}),
+ });
+ if (!regex.global) break;
+ }
+ }
+ }
+ continue;
+ }
+
+ if (detector.type === "required-regex") {
+ for (const file of targetFiles) {
+ if (file.lines.length === 0) continue;
+ const hasRequired = file.lines.some((line) => {
+ regex.lastIndex = 0;
+ return regex.test(line.text);
+ });
+ if (!hasRequired) {
+ findings.push({
+ check_id: checkId,
+ title,
+ severity,
+ path: file.path,
+ line: file.lines[0]?.line,
+ detector: detector.type,
+ message,
+ ...(repair ? { repair } : {}),
+ });
+ }
+ }
+ }
+ }
+
+ return {
+ schema: CHECK_REPORT_SCHEMA,
+ result: findings.length > 0 ? "fail" : "pass",
+ findings,
+ };
+}
+
+async function readDiffInput(input: string): Promise {
+ if (input === "-") return readStdin();
+ return readFile(resolve(process.cwd(), input), "utf-8");
+}
+
+async function readStdin(): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of process.stdin) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ return Buffer.concat(chunks).toString("utf-8");
+}
+
+async function readGitDiff(cwd: string, base: unknown): Promise {
+ const { stdout } = await execFileAsync(
+ "git",
+ ["diff", "--unified=80", typeof base === "string" ? base : "HEAD"],
+ {
+ cwd,
+ maxBuffer: 1024 * 1024 * 20,
+ },
+ );
+ return stdout;
+}
+
+function parseAddedFiles(diffText: string): AddedFile[] {
+ const files = new Map();
+ let currentPath: string | undefined;
+ let newLine: number | undefined;
+
+ for (const line of diffText.split(/\r?\n/)) {
+ if (line.startsWith("+++ ")) {
+ const path = normalizeDiffPath(line.slice(4).trim());
+ currentPath = path === "/dev/null" ? undefined : path;
+ newLine = undefined;
+ if (currentPath && !files.has(currentPath)) {
+ files.set(currentPath, { path: currentPath, lines: [] });
+ }
+ continue;
+ }
+
+ if (line.startsWith("@@")) {
+ const match = /\+(\d+)(?:,(\d+))?/.exec(line);
+ newLine = match ? Number(match[1]) : undefined;
+ continue;
+ }
+
+ if (!currentPath || newLine === undefined) continue;
+ if (line.startsWith("+")) {
+ files.get(currentPath)?.lines.push({
+ path: currentPath,
+ line: newLine,
+ text: line.slice(1),
+ });
+ newLine += 1;
+ continue;
+ }
+ if (line.startsWith("-")) continue;
+ if (line.startsWith("\\")) continue;
+ newLine += 1;
+ }
+
+ return [...files.values()].filter((file) => file.lines.length > 0);
+}
+
+function normalizeDiffPath(raw: string): string {
+ const withoutQuotes = raw.replace(/^"|"$/g, "");
+ if (withoutQuotes === "/dev/null") return withoutQuotes;
+ return withoutQuotes.replace(/^[ab]\//, "");
+}
+
+function parseDetector(value: unknown): CheckDetector | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
+ const raw = value as Record;
+ if (raw.type !== "forbidden-regex" && raw.type !== "required-regex") return;
+ if (typeof raw.pattern !== "string" || raw.pattern.length === 0) return;
+ const flags = typeof raw.flags === "string" ? raw.flags : undefined;
+ const paths = Array.isArray(raw.paths)
+ ? raw.paths.filter((path): path is string => typeof path === "string")
+ : undefined;
+ return {
+ type: raw.type,
+ pattern: raw.pattern,
+ ...(flags ? { flags } : {}),
+ ...(paths && paths.length > 0 ? { paths } : {}),
+ };
+}
+
+function compileDetectorRegex(detector: CheckDetector): RegExp {
+ const flags = new Set((detector.flags ?? "").split(""));
+ flags.add("g");
+ try {
+ return new RegExp(detector.pattern, [...flags].join(""));
+ } catch (err) {
+ throw new UsageError(
+ `Invalid ${detector.type} detector regex '${detector.pattern}': ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+}
+
+function detectorMatchesPath(detector: CheckDetector, path: string): boolean {
+ if (!detector.paths || detector.paths.length === 0) return true;
+ return detector.paths.some((pattern) => matchesGlob(pattern, path));
+}
+
+function checkIdFor(frontmatter: CheckFrontmatterExtras): string {
+ return (
+ stringOr(frontmatter.id, undefined) ??
+ slugify(stringOr(frontmatter.name, undefined) ?? "ghost-check")
+ );
+}
+
+function titleFor(frontmatter: CheckFrontmatterExtras): string {
+ return (
+ stringOr(frontmatter.title, undefined) ??
+ stringOr(frontmatter.description, undefined) ??
+ stringOr(frontmatter.name, "Ghost check")
+ );
+}
+
+function stringOr(value: unknown, fallback: string | undefined): string;
+function stringOr(value: unknown, fallback: string): string;
+function stringOr(
+ value: unknown,
+ fallback: string | undefined,
+): string | undefined {
+ return typeof value === "string" && value.trim().length > 0
+ ? value.trim()
+ : fallback;
+}
+
+function slugify(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+function formatCheckReportMarkdown(report: GhostCheckReport): string {
+ const lines = ["# Ghost Check Report", "", `Result: ${report.result}`, ""];
+ if (report.findings.length === 0) {
+ lines.push("No deterministic check findings.", "");
+ return lines.join("\n");
+ }
+
+ lines.push("## Findings", "");
+ for (const finding of report.findings) {
+ const location = finding.path
+ ? `${finding.path}${finding.line ? `:${finding.line}` : ""}`
+ : "PR-level";
+ lines.push(`- [${finding.severity}] ${finding.title}`);
+ lines.push(` - Location: ${location}`);
+ lines.push(` - Check: ${finding.check_id}`);
+ lines.push(` - Detector: ${finding.detector}`);
+ if (finding.match) lines.push(` - Match: \`${finding.match}\``);
+ lines.push(` - Message: ${finding.message}`);
+ if (finding.repair) lines.push(` - Repair: ${finding.repair}`);
+ }
+ lines.push("");
+ return lines.join("\n");
+}
diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts
index ba6b56e0..65fd6023 100644
--- a/packages/ghost/src/commands/command-discovery.ts
+++ b/packages/ghost/src/commands/command-discovery.ts
@@ -140,6 +140,13 @@ const COMMAND_DISCOVERY = [
compactName: "pulse",
summary: "Summarize local gather/pull events from .ghost/.events.",
},
+ {
+ name: "check",
+ group: "core",
+ defaultHelp: true,
+ compactName: "check",
+ summary: "Run deterministic detector-backed checks against a diff.",
+ },
{
name: "review",
group: "core",
diff --git a/packages/ghost/src/ghost-core/check/index.ts b/packages/ghost/src/ghost-core/check/index.ts
index c14b29f5..392bcad3 100644
--- a/packages/ghost/src/ghost-core/check/index.ts
+++ b/packages/ghost/src/ghost-core/check/index.ts
@@ -1,8 +1,7 @@
/**
* Public surface for `ghost.check/v1` — markdown + frontmatter checks an agent
- * 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.
+ * evaluates. Detector-backed checks can also be run deterministically by
+ * `ghost check`; checks without detectors remain agent-evaluated in review.
*/
export { lintGhostCheck } from "./lint.js";
@@ -14,8 +13,11 @@ export {
sliceNodeSection,
} from "./source-ref.js";
export {
+ GHOST_CHECK_DETECTOR_TYPES,
GHOST_CHECK_SCHEMA,
GHOST_CHECK_SEVERITIES,
+ type GhostCheckDetector,
+ type GhostCheckDetectorType,
type GhostCheckDocument,
type GhostCheckFrontmatter,
type GhostCheckLintIssue,
diff --git a/packages/ghost/src/ghost-core/check/lint.ts b/packages/ghost/src/ghost-core/check/lint.ts
index e4a0b1a9..ec207a00 100644
--- a/packages/ghost/src/ghost-core/check/lint.ts
+++ b/packages/ghost/src/ghost-core/check/lint.ts
@@ -1,6 +1,7 @@
import { parseCheckMarkdown } from "./parse.js";
import { parseSourceRef } from "./source-ref.js";
import {
+ GHOST_CHECK_DETECTOR_TYPES,
GHOST_CHECK_SEVERITIES,
type GhostCheckLintIssue,
type GhostCheckLintReport,
@@ -93,6 +94,23 @@ export function lintGhostCheck(raw: string): GhostCheckLintReport {
}
}
+ const detector = frontmatter.detector;
+ if (detector !== undefined) {
+ lintDetector(detector, issues);
+ }
+
+ for (const key of ["id", "title", "message", "repair"]) {
+ const value = frontmatter[key];
+ if (value !== undefined && typeof value !== "string") {
+ issues.push({
+ severity: "error",
+ rule: `check-${key}-invalid`,
+ message: `${key} must be a string when provided`,
+ path: key,
+ });
+ }
+ }
+
if (body.trim().length === 0) {
issues.push({
severity: "error",
@@ -129,3 +147,72 @@ function finalize(issues: GhostCheckLintIssue[]): GhostCheckLintReport {
info: issues.filter((issue) => issue.severity === "info").length,
};
}
+
+function lintDetector(detector: unknown, issues: GhostCheckLintIssue[]): void {
+ if (!detector || typeof detector !== "object" || Array.isArray(detector)) {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-invalid",
+ message: "detector must be an object with type and pattern",
+ path: "detector",
+ });
+ return;
+ }
+
+ const raw = detector as Record;
+ if (
+ typeof raw.type !== "string" ||
+ !GHOST_CHECK_DETECTOR_TYPES.includes(raw.type as never)
+ ) {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-type-invalid",
+ message: `detector.type must be one of: ${GHOST_CHECK_DETECTOR_TYPES.join(", ")}`,
+ path: "detector.type",
+ });
+ }
+
+ if (typeof raw.pattern !== "string" || raw.pattern.length === 0) {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-pattern-missing",
+ message: "detector.pattern must be a non-empty regex string",
+ path: "detector.pattern",
+ });
+ } else {
+ try {
+ new RegExp(raw.pattern);
+ } catch (err) {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-pattern-invalid",
+ message: `detector.pattern is not a valid regex: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ path: "detector.pattern",
+ });
+ }
+ }
+
+ if (raw.flags !== undefined && typeof raw.flags !== "string") {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-flags-invalid",
+ message: "detector.flags must be a string when provided",
+ path: "detector.flags",
+ });
+ }
+
+ if (
+ raw.paths !== undefined &&
+ (!Array.isArray(raw.paths) ||
+ raw.paths.some((path) => typeof path !== "string"))
+ ) {
+ issues.push({
+ severity: "error",
+ rule: "check-detector-paths-invalid",
+ message: "detector.paths must be a string array when provided",
+ path: "detector.paths",
+ });
+ }
+}
diff --git a/packages/ghost/src/ghost-core/check/load.ts b/packages/ghost/src/ghost-core/check/load.ts
index 49abf44d..f06cc5a6 100644
--- a/packages/ghost/src/ghost-core/check/load.ts
+++ b/packages/ghost/src/ghost-core/check/load.ts
@@ -1,5 +1,6 @@
import { parseCheckMarkdown } from "./parse.js";
import type {
+ GhostCheckDetector,
GhostCheckDocument,
GhostCheckMarkdownSeverity,
} from "./types.js";
@@ -39,6 +40,14 @@ export function loadGhostCheck(raw: string): GhostCheckDocument {
: undefined;
const source =
typeof frontmatter.source === "string" ? frontmatter.source : undefined;
+ const id = typeof frontmatter.id === "string" ? frontmatter.id : undefined;
+ const title =
+ typeof frontmatter.title === "string" ? frontmatter.title : undefined;
+ const message =
+ typeof frontmatter.message === "string" ? frontmatter.message : undefined;
+ const repair =
+ typeof frontmatter.repair === "string" ? frontmatter.repair : undefined;
+ const detector = loadDetector(frontmatter.detector);
return {
frontmatter: {
@@ -49,7 +58,29 @@ export function loadGhostCheck(raw: string): GhostCheckDocument {
...(turnLimit !== undefined ? { turn_limit: turnLimit } : {}),
...(references ? { references } : {}),
...(source ? { source } : {}),
+ ...(id ? { id } : {}),
+ ...(title ? { title } : {}),
+ ...(message ? { message } : {}),
+ ...(repair ? { repair } : {}),
+ ...(detector ? { detector } : {}),
},
body,
};
}
+
+function loadDetector(value: unknown): GhostCheckDetector | undefined {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
+ const raw = value as Record;
+ if (raw.type !== "forbidden-regex" && raw.type !== "required-regex") return;
+ if (typeof raw.pattern !== "string" || raw.pattern.length === 0) return;
+ const flags = typeof raw.flags === "string" ? raw.flags : undefined;
+ const paths = Array.isArray(raw.paths)
+ ? raw.paths.filter((path): path is string => typeof path === "string")
+ : undefined;
+ return {
+ type: raw.type,
+ pattern: raw.pattern,
+ ...(flags ? { flags } : {}),
+ ...(paths && paths.length > 0 ? { paths } : {}),
+ };
+}
diff --git a/packages/ghost/src/ghost-core/check/types.ts b/packages/ghost/src/ghost-core/check/types.ts
index 7fca514b..62c02351 100644
--- a/packages/ghost/src/ghost-core/check/types.ts
+++ b/packages/ghost/src/ghost-core/check/types.ts
@@ -1,5 +1,20 @@
export const GHOST_CHECK_SCHEMA = "ghost.check/v1" as const;
+export const GHOST_CHECK_DETECTOR_TYPES = [
+ "forbidden-regex",
+ "required-regex",
+] as const;
+export type GhostCheckDetectorType =
+ (typeof GHOST_CHECK_DETECTOR_TYPES)[number];
+
+export interface GhostCheckDetector {
+ type: GhostCheckDetectorType;
+ pattern: string;
+ flags?: string;
+ /** Optional glob-like path filters within the reviewed diff. */
+ paths?: string[];
+}
+
/** Severity vocabulary, matching the established agent-check format. */
export const GHOST_CHECK_SEVERITIES = ["high", "medium", "low"] as const;
export type GhostCheckMarkdownSeverity =
@@ -27,6 +42,16 @@ export interface GhostCheckFrontmatter {
references?: string[];
/** Deprecated single-reference alias retained for artifact-level linting. */
source?: string;
+ /** Optional stable id for deterministic check reports. Defaults to name slug. */
+ id?: string;
+ /** Optional PR-comment title. Defaults to description, then name. */
+ title?: string;
+ /** Optional message for deterministic check reports. Defaults to description. */
+ message?: string;
+ /** Optional repair guidance surfaced with deterministic findings. */
+ repair?: string;
+ /** Optional deterministic detector. If omitted, Ghost routes the check but does not run it. */
+ detector?: GhostCheckDetector;
}
export interface GhostCheckDocument {
diff --git a/packages/ghost/src/ghost-core/index.ts b/packages/ghost/src/ghost-core/index.ts
index 11bba608..33ea20da 100644
--- a/packages/ghost/src/ghost-core/index.ts
+++ b/packages/ghost/src/ghost-core/index.ts
@@ -13,8 +13,11 @@ export {
} from "./catalog/index.js";
// --- Check (ghost.check/v1) — markdown checks, agent-evaluated ---
export {
+ GHOST_CHECK_DETECTOR_TYPES,
GHOST_CHECK_SCHEMA,
GHOST_CHECK_SEVERITIES,
+ type GhostCheckDetector,
+ type GhostCheckDetectorType,
type GhostCheckDocument,
type GhostCheckFrontmatter,
type GhostCheckLintIssue,
diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts
index 18941eb7..cacb8750 100644
--- a/packages/ghost/test/cli.test.ts
+++ b/packages/ghost/test/cli.test.ts
@@ -128,6 +128,7 @@ describe("ghost CLI", () => {
"init",
"validate",
"gather",
+ "check",
"pull",
"pulse",
"review",
@@ -153,6 +154,7 @@ describe("ghost CLI", () => {
"validate [file]",
"init",
"gather [...ask]",
+ "check",
"pull <...ids>",
"pulse",
"review",
@@ -179,6 +181,7 @@ describe("ghost CLI", () => {
(command: { name: string }) => command.name,
);
expect(names).toContain("gather");
+ expect(names).toContain("check");
expect(names).toContain("pulse");
expect(names).toContain("review");
expect(names).toContain("haunt");
@@ -828,6 +831,113 @@ describe("ghost CLI", () => {
expect(Object.keys(byId)).toContain("email/marketing/index");
});
+ it("check emits deterministic JSON findings for detector-backed checks", async () => {
+ const ghost = join(dir, ".ghost");
+ await mkdir(join(ghost, "haunts", "checks"), { recursive: true });
+ await writeFile(
+ join(ghost, "manifest.yml"),
+ "schema: ghost.fingerprint-package/v1\nid: c-check\n",
+ );
+ await writeFile(join(ghost, "glossary.md"), "---\n---\n\n# Glossary\n");
+ await writeFile(join(ghost, "index.md"), "---\n---\n\nCore.\n");
+ await writeFile(
+ join(ghost, "haunts", "checks", "haunt.yml"),
+ "schema: ghost.haunt/v1\nid: checks\n",
+ );
+ await writeFile(
+ join(ghost, "haunts", "checks", "palette.md"),
+ `---
+name: palette utilities
+description: No raw palette utilities.
+severity: high
+references: [index]
+title: No raw palette utilities
+message: Added UI code matched a forbidden pattern.
+repair: Use semantic token utilities.
+detector:
+ type: forbidden-regex
+ pattern: 'bg-gray-100'
+ paths: ['src/**/*.tsx']
+---
+Flag raw palette utilities.
+`,
+ );
+ await writeFile(
+ join(dir, "change.patch"),
+ webPatch(
+ "src/features/chat/ui/GhostReviewDemo.tsx",
+ '',
+ ),
+ );
+
+ const result = await runCli(
+ ["check", "--diff", "change.patch", "--format", "json"],
+ dir,
+ );
+
+ expect(result.code).toBe(1);
+ const report = JSON.parse(result.stdout);
+ expect(report.schema).toBe("ghost.check-report/v1");
+ expect(report.result).toBe("fail");
+ expect(report.findings[0]).toMatchObject({
+ check_id: "palette",
+ title: "No raw palette utilities",
+ severity: "high",
+ path: "src/features/chat/ui/GhostReviewDemo.tsx",
+ line: 1,
+ detector: "forbidden-regex",
+ message: "Added UI code matched a forbidden pattern.",
+ match: "bg-gray-100",
+ repair: "Use semantic token utilities.",
+ });
+ });
+
+ it("check passes when required regex is present in each changed file", async () => {
+ const ghost = join(dir, ".ghost");
+ await mkdir(join(ghost, "haunts", "checks"), { recursive: true });
+ await writeFile(
+ join(ghost, "manifest.yml"),
+ "schema: ghost.fingerprint-package/v1\nid: c-required\n",
+ );
+ await writeFile(join(ghost, "glossary.md"), "---\n---\n\n# Glossary\n");
+ await writeFile(join(ghost, "index.md"), "---\n---\n\nCore.\n");
+ await writeFile(
+ join(ghost, "haunts", "checks", "haunt.yml"),
+ "schema: ghost.haunt/v1\nid: checks\n",
+ );
+ await writeFile(
+ join(ghost, "haunts", "checks", "token.md"),
+ `---
+name: token use
+description: Require semantic tokens.
+severity: medium
+references: [index]
+detector:
+ type: required-regex
+ pattern: 'bg-accent'
+ paths: ['src/**/*.tsx']
+---
+Require semantic token utilities.
+`,
+ );
+ await writeFile(
+ join(dir, "change.patch"),
+ webPatch("src/app/page.tsx", ''),
+ );
+
+ const result = await runCli(
+ ["check", "--diff", "change.patch", "--format", "json"],
+ dir,
+ );
+
+ expect(result.code).toBe(0);
+ expect(JSON.parse(result.stdout)).toMatchObject({
+ schema: "ghost.check-report/v1",
+ result: "pass",
+ findings: [],
+ });
+ });
+
it("review matches diff files to node materials and offers checks", async () => {
await runCli(["init", "--with", "checks"], dir);
await writeFile(
@@ -1271,3 +1381,13 @@ Native Swift app.
Use feature scopes.
`;
}
+
+function webPatch(path: string, added: string): string {
+ return `diff --git a/${path} b/${path}
+index 1111111..2222222 100644
+--- a/${path}
++++ b/${path}
+@@ -0,0 +1 @@
++${added}
+`;
+}
diff --git a/packages/ghost/test/ghost-core/check-md.test.ts b/packages/ghost/test/ghost-core/check-md.test.ts
index 3fb2b35a..e8235efe 100644
--- a/packages/ghost/test/ghost-core/check-md.test.ts
+++ b/packages/ghost/test/ghost-core/check-md.test.ts
@@ -13,6 +13,13 @@ tools: [Read, Grep]
turn-limit: 20
references:
- principle.trust
+title: Use design tokens
+message: Added UI code matched a forbidden pattern.
+repair: Replace raw values with semantic tokens.
+detector:
+ type: forbidden-regex
+ pattern: '#[0-9a-fA-F]{3,8}'
+ paths: ['**/*.tsx']
---
## Purpose
@@ -112,15 +119,19 @@ describe("loadGhostCheck", () => {
severity: "high",
tools: ["Read", "Grep"],
turn_limit: 20,
+ references: ["principle.trust"],
+ title: "Use design tokens",
+ message: "Added UI code matched a forbidden pattern.",
+ repair: "Replace raw values with semantic tokens.",
+ detector: {
+ type: "forbidden-regex",
+ pattern: "#[0-9a-fA-F]{3,8}",
+ paths: ["**/*.tsx"],
+ },
});
expect(doc.body).toContain("Flag hex literals");
});
- it("carries references through", () => {
- const doc = loadGhostCheck(VALID);
- expect(doc.frontmatter.references).toEqual(["principle.trust"]);
- });
-
it("carries an optional source pointer through", () => {
const doc = loadGhostCheck(
VALID.replace(
]