diff --git a/mcp-server/index.js b/mcp-server/index.js
index 5b99750..c372696 100755
--- a/mcp-server/index.js
+++ b/mcp-server/index.js
@@ -30159,6 +30159,33 @@ var PipelineStateSchema = external_exports.object({
/** Output directory passed to `index_codebase` so retries are idempotent. */
codebase_output_dir: external_exports.string().nullable(),
codebase_indexed: external_exports.boolean(),
+ /**
+ * Code-graph grounding for the feature, returned by automatised-pipeline
+ * `prepare_prd_input` in FEATURE MODE (response field `prd_context`):
+ * { matched_symbols, impacted_communities, impacted_processes,
+ * graph_stats, mode }
+ * Distinct from `prd_context` above (which is the PRD *kind* enum:
+ * feature/bug/incident/…). This is the code-graph evidence later steps
+ * (budget / section generation) inject as grounding so generated sections
+ * reference real symbols/communities/processes. Stored as an opaque object
+ * because the orchestration layer is a pure passthrough — it does not parse
+ * the AP payload (mirrors how `index_codebase` data is consumed inline).
+ *
+ * Null until `prepare_prd_input` succeeds, or permanently null when no
+ * codebase/feature_description is available (skip path, backward-compatible).
+ *
+ * source: AP feature-mode prepare_prd_input contract (shipped 2026-06).
+ */
+ codebase_grounding: external_exports.record(external_exports.string(), external_exports.unknown()).nullable().default(null),
+ /**
+ * Idempotency flag for the `prepare_prd_input` emission in input_analysis.
+ * Mirrors `codebase_indexed`: set true once the grounding call has been
+ * processed (success OR a skip-after-index decision) so the step advances
+ * exactly once and replayed state does not re-issue the call.
+ *
+ * source: AP feature-mode prepare_prd_input contract (shipped 2026-06).
+ */
+ prd_input_prepared: external_exports.boolean().default(false),
/**
* Preflight gate state — `null` while preflight has not been attempted
* yet, `"ok"` once Cortex (and ai-architect, when a codebase is given)
@@ -30234,6 +30261,30 @@ var PipelineStateSchema = external_exports.object({
* read in Phase B to attribute verdicts. Null until Phase A runs.
*/
verification_plan: VerificationPlanSnapshotSchema.nullable().default(null),
+ /**
+ * PRD-vs-graph validation report from automatised-pipeline
+ * `validate_prd_against_graph` (args { prd_path, graph_path }), fetched in
+ * self-check after the PRD file is exported. Symbol-hallucination /
+ * community-consistency / process-impact findings. Merged into the
+ * self-check `done.verification` surface (not a new top-level shape).
+ *
+ * Stored as an opaque object — the orchestration layer is a pure passthrough
+ * and does not parse the AP payload. Null until the call succeeds, or
+ * permanently null when no `codebase_graph_path` exists (skip path,
+ * backward-compatible).
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06).
+ */
+ prd_validation: external_exports.record(external_exports.string(), external_exports.unknown()).nullable().default(null),
+ /**
+ * Idempotency flag for the `validate_prd_against_graph` emission in
+ * self-check. Mirrors `prd_input_prepared`: set true once the validation
+ * call has been processed (success OR skip) so self-check advances to its
+ * existing verify phase exactly once.
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06).
+ */
+ prd_validated: external_exports.boolean().default(false),
/**
* Append-only queue of strategy execution results, populated when a
* section transitions to a terminal status (passed/failed). The
@@ -30387,7 +30438,20 @@ var EmitMessageActionSchema = external_exports.object({
var VerificationSummarySchema = external_exports.object({
claims_evaluated: external_exports.number().int().nonnegative(),
distribution: external_exports.record(VerdictSchema, external_exports.number().int().nonnegative()),
- distribution_suspicious: external_exports.boolean()
+ distribution_suspicious: external_exports.boolean(),
+ /**
+ * PRD-vs-graph validation report from automatised-pipeline
+ * `validate_prd_against_graph`, attached when the run had a code graph.
+ * Symbol-hallucination / community-consistency / process-impact findings.
+ * Opaque object — the orchestration layer is a passthrough and does not parse
+ * the AP payload. Absent when no codebase was provided (preserves the prior
+ * verification shape for non-codebase runs).
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06). Attached
+ * here (not as a new top-level done field) so KPI/test consumers read one
+ * typed verification surface.
+ */
+ prd_graph_validation: external_exports.record(external_exports.string(), external_exports.unknown()).optional()
});
var DoneActionSchema = external_exports.object({
kind: external_exports.literal("done"),
@@ -30802,9 +30866,42 @@ var handleContextDetection = ({ state, result }) => {
// packages/orchestration/dist/handlers/input-analysis.js
import { join as join3 } from "node:path";
var CORRELATION_ID = "input_analysis_index";
+var PREPARE_CORRELATION_ID = "input_analysis_prepare_prd_input";
function deriveOutputDir(codebasePath, runId) {
return join3(codebasePath, ".prd-gen", "graphs", runId);
}
+function emitPrepare(state) {
+ const featureDescription = state.feature_description.trim();
+ const graphPath = state.codebase_graph_path;
+ if (!featureDescription || !graphPath) {
+ return {
+ state: {
+ ...state,
+ prd_input_prepared: true,
+ current_step: "feasibility_gate"
+ },
+ action: {
+ kind: "emit_message",
+ message: "No feature description to ground; skipping code-graph grounding."
+ }
+ };
+ }
+ const outputDir = state.codebase_output_dir ?? deriveOutputDir(state.codebase_path, state.run_id);
+ return {
+ state: { ...state, codebase_output_dir: outputDir },
+ action: {
+ kind: "call_pipeline_tool",
+ tool_name: "prepare_prd_input",
+ // Feature mode: no finding_id. AP grounds the free text on the graph.
+ arguments: {
+ feature_description: featureDescription,
+ output_dir: outputDir,
+ graph_path: graphPath
+ },
+ correlation_id: PREPARE_CORRELATION_ID
+ }
+ };
+}
var handleInputAnalysis = ({ state, result }) => {
if (!state.codebase_path) {
return {
@@ -30815,12 +30912,42 @@ var handleInputAnalysis = ({ state, result }) => {
}
};
}
- if (state.codebase_indexed && state.codebase_graph_path) {
+ if (state.codebase_indexed && state.codebase_graph_path && state.prd_input_prepared) {
return {
state: { ...state, current_step: "feasibility_gate" },
action: {
kind: "emit_message",
- message: `Codebase indexed (graph: ${state.codebase_graph_path}).`
+ message: `Codebase analysis ready (graph: ${state.codebase_graph_path}).`
+ }
+ };
+ }
+ if (result?.kind === "tool_result" && result.correlation_id === PREPARE_CORRELATION_ID) {
+ if (!result.success) {
+ return {
+ state: {
+ ...appendError(state, `prepare_prd_input failed: ${result.error ?? "unknown"}; continuing without code-graph grounding`, "upstream_failure"),
+ prd_input_prepared: true,
+ current_step: "feasibility_gate"
+ },
+ action: {
+ kind: "emit_message",
+ message: "Code-graph grounding unavailable; proceeding without it.",
+ level: "warn"
+ }
+ };
+ }
+ const data = result.data ?? {};
+ const grounding = data.prd_context ?? result.data ?? {};
+ return {
+ state: {
+ ...state,
+ codebase_grounding: grounding,
+ prd_input_prepared: true,
+ current_step: "feasibility_gate"
+ },
+ action: {
+ kind: "emit_message",
+ message: "Feature grounded on code graph."
}
};
}
@@ -30862,18 +30989,14 @@ var handleInputAnalysis = ({ state, result }) => {
}
};
}
- return {
- state: {
- ...state,
- codebase_indexed: true,
- codebase_graph_path: graphPath,
- current_step: "feasibility_gate"
- },
- action: {
- kind: "emit_message",
- message: `Codebase indexed (graph: ${graphPath}).`
- }
- };
+ return emitPrepare({
+ ...state,
+ codebase_indexed: true,
+ codebase_graph_path: graphPath
+ });
+ }
+ if (state.codebase_indexed && state.codebase_graph_path && !state.prd_input_prepared) {
+ return emitPrepare(state);
}
const outputDir = state.codebase_output_dir ?? deriveOutputDir(state.codebase_path, state.run_id);
return {
@@ -31003,6 +31126,47 @@ function renderStrategiesBlock(assignment) {
lines.push(``);
return lines.join("\n");
}
+var GROUNDING_MAX_SYMBOLS = 15;
+var GROUNDING_MAX_COMMUNITY_NAMES = 10;
+var GROUNDING_MAX_PROCESS_NAMES = 10;
+function renderGroundingBlock(grounding) {
+ if (!grounding)
+ return "";
+ const symbols = grounding.matched_symbols ?? [];
+ const communities = grounding.impacted_communities ?? [];
+ const processes = grounding.impacted_processes ?? [];
+ const stats = grounding.graph_stats;
+ const summary = grounding.finding_summary?.trim() ?? "";
+ const hasStats = !!stats && [stats.nodes, stats.edges, stats.communities, stats.processes].some((n) => typeof n === "number");
+ const hasContent = symbols.length > 0 || communities.length > 0 || processes.length > 0 || summary.length > 0 || hasStats;
+ if (!hasContent)
+ return "";
+ const lines = [``];
+ if (summary)
+ lines.push(summary);
+ if (hasStats && stats) {
+ lines.push(`Graph: ${stats.nodes ?? "?"} nodes, ${stats.edges ?? "?"} edges, ${stats.communities ?? "?"} communities, ${stats.processes ?? "?"} processes.`);
+ }
+ if (symbols.length > 0) {
+ lines.push(`Matched symbols (showing ${Math.min(symbols.length, GROUNDING_MAX_SYMBOLS)} of ${symbols.length}):`);
+ for (const s of symbols.slice(0, GROUNDING_MAX_SYMBOLS)) {
+ const label = s.name ?? s.qualified_name ?? "(unnamed)";
+ const file = s.file_path ? ` \u2014 ${s.file_path}` : "";
+ const community = s.community_id !== void 0 ? ` (community ${s.community_id})` : "";
+ lines.push(` - ${label}${file}${community}`);
+ }
+ }
+ if (communities.length > 0) {
+ const shown = communities.slice(0, GROUNDING_MAX_COMMUNITY_NAMES);
+ lines.push(`Impacted communities (${communities.length}): ${shown.join(", ")}`);
+ }
+ if (processes.length > 0) {
+ const shown = processes.slice(0, GROUNDING_MAX_PROCESS_NAMES);
+ lines.push(`Impacted processes (${processes.length}): ${shown.join(", ")}`);
+ }
+ lines.push(``);
+ return lines.join("\n");
+}
function buildSectionPrompt(input) {
const display = SECTION_DISPLAY_NAMES[input.section_type];
const contextConfig = PRD_CONTEXT_CONFIGS[input.prd_context];
@@ -31017,6 +31181,7 @@ A: ${c.answer}`).join("\n\n");
``
].join("\n") : "";
const strategiesBlock = renderStrategiesBlock(input.strategy_assignment);
+ const groundingBlock = renderGroundingBlock(input.codebase_grounding);
return [
`You draft section "${display}" of a ${contextConfig.displayName} PRD.`,
"",
@@ -31032,6 +31197,8 @@ A: ${c.answer}`).join("\n\n");
${input.recall_summary}
` : "",
+ groundingBlock,
+ groundingBlock ? "" : "",
clarificationLines ? `
${clarificationLines}
@@ -31503,6 +31670,13 @@ function recallAction(feature, sectionType) {
correlation_id: correlationFor(RETRIEVE_PREFIX, sectionType)
};
}
+function normalizeGrounding(raw) {
+ if (!raw)
+ return void 0;
+ const nested = raw.prd_context;
+ const grounding = nested && typeof nested === "object" ? nested : raw;
+ return grounding;
+}
function draftAction(state, section, recall_summary, prior_violations) {
const display = SECTION_DISPLAY_NAMES[section.section_type];
if (!state.prd_context) {
@@ -31519,7 +31693,12 @@ function draftAction(state, section, recall_summary, prior_violations) {
// Phase 4 strategy-wiring (2026-04): pass the persisted assignment
// so every retry uses the SAME strategies the selector chose at the
// pending → retrieving transition.
- strategy_assignment: section.strategy_assignment
+ strategy_assignment: section.strategy_assignment,
+ // Thread AP's code-graph grounding (state.codebase_grounding, possibly
+ // wrapped in a prepare_prd_input response) into the prompt so drafts
+ // reference real symbols/files/communities/processes. `undefined` when no
+ // codebase grounding exists → prompt is byte-identical to before.
+ codebase_grounding: normalizeGrounding(state.codebase_grounding)
});
return {
kind: "spawn_subagents",
@@ -32617,6 +32796,42 @@ ${context.memory_excerpts.join("\n---\n")}
// packages/orchestration/dist/handlers/self-check.js
var VERIFY_BATCH_ID = "self_check_verify";
+var VALIDATE_PRD_CORRELATION_ID = "self_check_validate_prd_against_graph";
+function exportedPrdPath(state) {
+ return state.written_files.find((p) => /(^|\/)01-prd\.md$/.test(p)) ?? null;
+}
+function handlePrdValidation(state, result) {
+ if (result?.kind === "tool_result" && result.correlation_id === VALIDATE_PRD_CORRELATION_ID) {
+ if (!result.success) {
+ return {
+ state: appendError({ ...state, prd_validated: true }, `validate_prd_against_graph failed: ${result.error ?? "unknown"}; continuing without graph validation`, "upstream_failure"),
+ fallthrough: true
+ };
+ }
+ const report = result.data ?? {};
+ return {
+ state: { ...state, prd_validation: report, prd_validated: true },
+ fallthrough: true
+ };
+ }
+ if (state.prd_validated) {
+ return { state, fallthrough: true };
+ }
+ const graphPath = state.codebase_graph_path;
+ const prdPath = exportedPrdPath(state);
+ if (!graphPath || !prdPath) {
+ return { state: { ...state, prd_validated: true }, fallthrough: true };
+ }
+ return {
+ state,
+ action: {
+ kind: "call_pipeline_tool",
+ tool_name: "validate_prd_against_graph",
+ arguments: { prd_path: prdPath, graph_path: graphPath },
+ correlation_id: VALIDATE_PRD_CORRELATION_ID
+ }
+ };
+}
var RawVerdictSchema = external_exports.object({
verdict: VerdictSchema,
rationale: external_exports.string(),
@@ -32729,7 +32944,11 @@ function finalize(state, verdicts = []) {
verification: {
claims_evaluated: verificationReport.claims_evaluated,
distribution: verificationReport.distribution,
- distribution_suspicious: verificationReport.distribution_suspicious
+ distribution_suspicious: verificationReport.distribution_suspicious,
+ // Attach the PRD-vs-graph validation report when one was produced. Left
+ // undefined for non-codebase runs so the prior verification shape is
+ // unchanged (backward-compatible). See VerificationSummarySchema.
+ ...state.prd_validation ? { prd_graph_validation: state.prd_validation } : {}
}
}
};
@@ -32774,10 +32993,15 @@ function handleSelfCheckPhaseB(state, result) {
return finalize(stateAfter, verdicts);
}
var handleSelfCheck = ({ state, result }) => {
+ const validation = handlePrdValidation(state, result);
+ if ("action" in validation) {
+ return { state: validation.state, action: validation.action };
+ }
+ const stateAfterValidation = validation.state;
if (result?.kind === "subagent_batch_result" && result.batch_id === VERIFY_BATCH_ID) {
- return handleSelfCheckPhaseB(state, result);
+ return handleSelfCheckPhaseB(stateAfterValidation, result);
}
- return handleSelfCheckPhaseA(state);
+ return handleSelfCheckPhaseA(stateAfterValidation);
};
function parseVerdictsFromSnapshot(snapshot, state, batchResult) {
const sections = gatherSections(state);
diff --git a/packages/meta-prompting/src/__tests__/prompt-builders.test.ts b/packages/meta-prompting/src/__tests__/prompt-builders.test.ts
index 68069ff..dbf8016 100644
--- a/packages/meta-prompting/src/__tests__/prompt-builders.test.ts
+++ b/packages/meta-prompting/src/__tests__/prompt-builders.test.ts
@@ -241,6 +241,121 @@ describe("buildSectionPrompt", () => {
expect(out).not.toContain("FORBIDDEN (do NOT apply");
});
+ it("renders a block with real symbols/communities/processes when grounding is provided", () => {
+ const out = buildSectionPrompt({
+ section_type: "requirements",
+ feature_description: "OAuth login",
+ prd_context: "feature",
+ recall_summary: "",
+ clarification_qa: [],
+ prior_violations: [],
+ attempt: 1,
+ codebase_grounding: {
+ finding_summary: "OAuth touches 3 auth-layer symbols.",
+ matched_symbols: [
+ {
+ qualified_name: "src/auth.ts::loginHandler",
+ name: "loginHandler",
+ label: "function",
+ file_path: "src/auth.ts",
+ community_id: 7,
+ },
+ {
+ qualified_name: "src/session.ts::SessionStore",
+ name: "SessionStore",
+ label: "class",
+ file_path: "src/session.ts",
+ community_id: 7,
+ },
+ ],
+ impacted_communities: ["auth", "session"],
+ impacted_processes: ["login_flow", "token_refresh"],
+ graph_stats: {
+ nodes: 1200,
+ edges: 4300,
+ communities: 18,
+ processes: 12,
+ },
+ },
+ });
+ expect(out).toContain("");
+ // matched-symbol text: name + file_path + community
+ expect(out).toContain("loginHandler");
+ expect(out).toContain("src/auth.ts");
+ expect(out).toContain("community 7");
+ expect(out).toContain("SessionStore");
+ // impacted communities + processes
+ expect(out).toContain("auth");
+ expect(out).toContain("login_flow");
+ // graph stats header
+ expect(out).toContain("1200 nodes");
+ // finding summary
+ expect(out).toContain("OAuth touches 3 auth-layer symbols.");
+ });
+
+ it("caps matched symbols at 15 and reports the true total", () => {
+ const matched_symbols = Array.from({ length: 40 }, (_, i) => ({
+ qualified_name: `src/f${i}.ts::sym${i}`,
+ name: `sym${i}`,
+ file_path: `src/f${i}.ts`,
+ community_id: i,
+ }));
+ const out = buildSectionPrompt({
+ section_type: "requirements",
+ feature_description: "x",
+ prd_context: "feature",
+ recall_summary: "",
+ clarification_qa: [],
+ prior_violations: [],
+ attempt: 1,
+ codebase_grounding: { matched_symbols },
+ });
+ expect(out).toContain("showing 15 of 40");
+ expect(out).toContain("sym0");
+ expect(out).toContain("sym14");
+ // 16th symbol (index 15) must be dropped by the cap
+ expect(out).not.toContain("sym15 —");
+ });
+
+ it("omits the block when grounding is absent (back-compat, byte-identical)", () => {
+ const base = {
+ section_type: "overview" as const,
+ feature_description: "x",
+ prd_context: "feature" as const,
+ recall_summary: "",
+ clarification_qa: [],
+ prior_violations: [],
+ attempt: 1,
+ };
+ const withoutField = buildSectionPrompt(base);
+ const withUndefined = buildSectionPrompt({
+ ...base,
+ codebase_grounding: undefined,
+ });
+ expect(withoutField).not.toContain("");
+ expect(withUndefined).not.toContain("");
+ // No empty tag, and the two renderings are identical → backward compatible.
+ expect(withUndefined).toBe(withoutField);
+ });
+
+ it("omits the block when grounding is present but carries no usable evidence", () => {
+ const out = buildSectionPrompt({
+ section_type: "overview",
+ feature_description: "x",
+ prd_context: "feature",
+ recall_summary: "",
+ clarification_qa: [],
+ prior_violations: [],
+ attempt: 1,
+ codebase_grounding: {
+ matched_symbols: [],
+ impacted_communities: [],
+ impacted_processes: [],
+ },
+ });
+ expect(out).not.toContain("");
+ });
+
it("includes the COMMON_RULES that gate downstream validators", () => {
const out = buildSectionPrompt({
section_type: "requirements",
diff --git a/packages/meta-prompting/src/index.ts b/packages/meta-prompting/src/index.ts
index b03c2c6..fc707c1 100644
--- a/packages/meta-prompting/src/index.ts
+++ b/packages/meta-prompting/src/index.ts
@@ -1,6 +1,8 @@
export {
buildSectionPrompt,
type SectionPromptInput,
+ type CodebaseGrounding,
+ type GroundedSymbol,
} from "./section-prompts.js";
export {
diff --git a/packages/meta-prompting/src/section-prompts.ts b/packages/meta-prompting/src/section-prompts.ts
index f13b6ba..74988cb 100644
--- a/packages/meta-prompting/src/section-prompts.ts
+++ b/packages/meta-prompting/src/section-prompts.ts
@@ -23,6 +23,46 @@ import {
} from "@prd-gen/core";
import type { StrategyAssignment } from "@prd-gen/strategy";
+/**
+ * One symbol matched against the codebase graph by automatised-pipeline's
+ * feature-mode `prepare_prd_input`. Only the fields the rendered grounding
+ * block consumes are typed; the AP payload may carry more (relationships,
+ * processes), which this builder deliberately does not render to keep the
+ * prompt token-bounded.
+ *
+ * source: AP feature-mode prepare_prd_input contract (prd_context shape),
+ * shipped 2026-06.
+ */
+export interface GroundedSymbol {
+ readonly qualified_name?: string;
+ readonly name?: string;
+ readonly label?: string;
+ readonly file_path?: string;
+ readonly community_id?: string | number;
+}
+
+/**
+ * Code-graph grounding for the feature (AP `prepare_prd_input.prd_context`).
+ * Every field is optional so a partial / older payload still type-checks; the
+ * renderer guards each field independently and emits NOTHING when the grounding
+ * carries no usable evidence (backward-compatible with pre-grounding callers).
+ *
+ * source: AP feature-mode prepare_prd_input contract (prd_context shape),
+ * shipped 2026-06.
+ */
+export interface CodebaseGrounding {
+ readonly finding_summary?: string;
+ readonly matched_symbols?: ReadonlyArray;
+ readonly impacted_communities?: readonly string[];
+ readonly impacted_processes?: readonly string[];
+ readonly graph_stats?: {
+ readonly nodes?: number;
+ readonly edges?: number;
+ readonly communities?: number;
+ readonly processes?: number;
+ };
+}
+
export interface SectionPromptInput {
readonly section_type: SectionType;
readonly feature_description: string;
@@ -40,6 +80,20 @@ export interface SectionPromptInput {
* source: Phase 4 strategy-wiring (2026-04).
*/
readonly strategy_assignment?: StrategyAssignment;
+ /**
+ * Code-graph grounding for the feature, produced by automatised-pipeline's
+ * feature-mode `prepare_prd_input` (its `prd_context` payload) and threaded
+ * through from `PipelineState.codebase_grounding`. When present and non-empty,
+ * the rendered prompt gains a `` block listing real
+ * matched symbols / files / communities / processes so the drafted section
+ * references the actual codebase rather than inventing structure.
+ *
+ * Optional: pipelines without a codebase (or predating grounding) omit it,
+ * and the rendered prompt is then byte-identical to before.
+ *
+ * source: AP feature-mode prepare_prd_input contract, shipped 2026-06.
+ */
+ readonly codebase_grounding?: CodebaseGrounding;
}
const COMMON_RULES = [
@@ -136,6 +190,97 @@ function renderStrategiesBlock(
return lines.join("\n");
}
+/**
+ * Size caps for the grounding block. These bound prompt tokens — a large graph
+ * (hundreds of matched symbols / communities) must not blow the per-section
+ * prompt budget. Caps mirror the intent of summarizeRecall's truncation in
+ * orchestration: include enough real evidence to ground the draft, drop the
+ * long tail.
+ *
+ * source: provisional heuristic, parallel to RECALL_MAX_RESULTS_INCLUDED=8 in
+ * orchestration/section-generation.ts (Phase 3+4 retrieval budget). Symbols get
+ * a higher cap (15) because each is one short line and they are the primary
+ * grounding signal; community/process name lists get 10 each.
+ */
+const GROUNDING_MAX_SYMBOLS = 15;
+const GROUNDING_MAX_COMMUNITY_NAMES = 10;
+const GROUNDING_MAX_PROCESS_NAMES = 10;
+
+/**
+ * Render the `` block. Returns "" when grounding is absent
+ * or carries no usable evidence (no symbols, no communities, no processes, no
+ * stats) — so a pipeline without grounding produces a byte-identical prompt to
+ * before (no empty tags). Concise on purpose: a stats header, then capped lists
+ * of real symbols/files/communities/processes.
+ *
+ * source: AP feature-mode prepare_prd_input contract (prd_context), 2026-06.
+ */
+function renderGroundingBlock(
+ grounding: CodebaseGrounding | undefined,
+): string {
+ if (!grounding) return "";
+
+ const symbols = grounding.matched_symbols ?? [];
+ const communities = grounding.impacted_communities ?? [];
+ const processes = grounding.impacted_processes ?? [];
+ const stats = grounding.graph_stats;
+ const summary = grounding.finding_summary?.trim() ?? "";
+
+ const hasStats =
+ !!stats &&
+ [stats.nodes, stats.edges, stats.communities, stats.processes].some(
+ (n) => typeof n === "number",
+ );
+ const hasContent =
+ symbols.length > 0 ||
+ communities.length > 0 ||
+ processes.length > 0 ||
+ summary.length > 0 ||
+ hasStats;
+ if (!hasContent) return "";
+
+ const lines: string[] = [``];
+
+ if (summary) lines.push(summary);
+
+ if (hasStats && stats) {
+ lines.push(
+ `Graph: ${stats.nodes ?? "?"} nodes, ${stats.edges ?? "?"} edges, ` +
+ `${stats.communities ?? "?"} communities, ${stats.processes ?? "?"} processes.`,
+ );
+ }
+
+ if (symbols.length > 0) {
+ lines.push(
+ `Matched symbols (showing ${Math.min(symbols.length, GROUNDING_MAX_SYMBOLS)} of ${symbols.length}):`,
+ );
+ for (const s of symbols.slice(0, GROUNDING_MAX_SYMBOLS)) {
+ const label = s.name ?? s.qualified_name ?? "(unnamed)";
+ const file = s.file_path ? ` — ${s.file_path}` : "";
+ const community =
+ s.community_id !== undefined ? ` (community ${s.community_id})` : "";
+ lines.push(` - ${label}${file}${community}`);
+ }
+ }
+
+ if (communities.length > 0) {
+ const shown = communities.slice(0, GROUNDING_MAX_COMMUNITY_NAMES);
+ lines.push(
+ `Impacted communities (${communities.length}): ${shown.join(", ")}`,
+ );
+ }
+
+ if (processes.length > 0) {
+ const shown = processes.slice(0, GROUNDING_MAX_PROCESS_NAMES);
+ lines.push(
+ `Impacted processes (${processes.length}): ${shown.join(", ")}`,
+ );
+ }
+
+ lines.push(``);
+ return lines.join("\n");
+}
+
export function buildSectionPrompt(input: SectionPromptInput): string {
const display = SECTION_DISPLAY_NAMES[input.section_type];
const contextConfig = PRD_CONTEXT_CONFIGS[input.prd_context];
@@ -159,6 +304,7 @@ export function buildSectionPrompt(input: SectionPromptInput): string {
: "";
const strategiesBlock = renderStrategiesBlock(input.strategy_assignment);
+ const groundingBlock = renderGroundingBlock(input.codebase_grounding);
return [
`You draft section "${display}" of a ${contextConfig.displayName} PRD.`,
@@ -174,6 +320,8 @@ export function buildSectionPrompt(input: SectionPromptInput): string {
input.recall_summary
? `\n${input.recall_summary}\n\n`
: "",
+ groundingBlock,
+ groundingBlock ? "" : "",
clarificationLines
? `\n${clarificationLines}\n\n`
: "",
diff --git a/packages/orchestration/src/__tests__/runner.test.ts b/packages/orchestration/src/__tests__/runner.test.ts
index fc7f739..e6a7b7b 100644
--- a/packages/orchestration/src/__tests__/runner.test.ts
+++ b/packages/orchestration/src/__tests__/runner.test.ts
@@ -43,14 +43,14 @@ describe("pipeline runner — emit_message coalescing", () => {
}
});
- it("input_analysis stores graph_path and output_dir on success then advances to clarification compose", () => {
+ it("input_analysis: index_codebase success emits prepare_prd_input (feature mode) before advancing", () => {
const issued = step({ state: seed("/x") });
expect(issued.state.codebase_output_dir).not.toBeNull();
const correlation_id =
issued.action.kind === "call_pipeline_tool"
? issued.action.correlation_id
: "";
- const after = step({
+ const afterIndex = step({
state: issued.state,
result: {
kind: "tool_result",
@@ -59,10 +59,102 @@ describe("pipeline runner — emit_message coalescing", () => {
data: { graph_path: "/x/.prd-gen/graphs/test_run_001/graph" },
},
});
- expect(after.state.codebase_indexed).toBe(true);
- expect(after.state.codebase_graph_path).toBe(
+ // Graph recorded but step has NOT advanced — it now emits the grounding call.
+ expect(afterIndex.state.codebase_indexed).toBe(true);
+ expect(afterIndex.state.codebase_graph_path).toBe(
"/x/.prd-gen/graphs/test_run_001/graph",
);
+ expect(afterIndex.state.prd_input_prepared).toBe(false);
+ expect(afterIndex.state.current_step).toBe("input_analysis");
+ expect(afterIndex.action.kind).toBe("call_pipeline_tool");
+ if (afterIndex.action.kind === "call_pipeline_tool") {
+ expect(afterIndex.action.tool_name).toBe("prepare_prd_input");
+ // Feature mode: free-text feature + graph, no finding_id.
+ expect(afterIndex.action.arguments).toHaveProperty("feature_description");
+ expect(afterIndex.action.arguments).not.toHaveProperty("finding_id");
+ expect(afterIndex.action.arguments).toHaveProperty(
+ "graph_path",
+ "/x/.prd-gen/graphs/test_run_001/graph",
+ );
+ expect(afterIndex.action.arguments).toHaveProperty("output_dir");
+ }
+ });
+
+ it("input_analysis: prepare_prd_input result stores grounding then advances to clarification compose", () => {
+ const issued = step({ state: seed("/x") });
+ const indexCid =
+ issued.action.kind === "call_pipeline_tool"
+ ? issued.action.correlation_id
+ : "";
+ const afterIndex = step({
+ state: issued.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: indexCid,
+ success: true,
+ data: { graph_path: "/x/.prd-gen/graphs/test_run_001/graph" },
+ },
+ });
+ const prepareCid =
+ afterIndex.action.kind === "call_pipeline_tool"
+ ? afterIndex.action.correlation_id
+ : "";
+ const grounding = {
+ matched_symbols: [{ fqn: "auth::login" }],
+ impacted_communities: [1],
+ impacted_processes: [],
+ graph_stats: { nodes: 10 },
+ mode: "feature",
+ };
+ const after = step({
+ state: afterIndex.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: prepareCid,
+ success: true,
+ // AP feature mode wraps the grounding in `prd_context`.
+ data: { prd_context: grounding },
+ },
+ });
+ expect(after.state.prd_input_prepared).toBe(true);
+ expect(after.state.codebase_grounding).toEqual(grounding);
+ // prd_context (the PRD-kind enum) must NOT be clobbered by grounding.
+ expect(after.state.prd_context).toBe("feature");
+ expect(after.state.current_step).toBe("clarification");
+ expect(after.action.kind).toBe("spawn_subagents");
+ });
+
+ it("input_analysis: prepare_prd_input failure is advisory — advances without grounding", () => {
+ const issued = step({ state: seed("/x") });
+ const indexCid =
+ issued.action.kind === "call_pipeline_tool"
+ ? issued.action.correlation_id
+ : "";
+ const afterIndex = step({
+ state: issued.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: indexCid,
+ success: true,
+ data: { graph_path: "/x/g" },
+ },
+ });
+ const prepareCid =
+ afterIndex.action.kind === "call_pipeline_tool"
+ ? afterIndex.action.correlation_id
+ : "";
+ const after = step({
+ state: afterIndex.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: prepareCid,
+ success: false,
+ data: null,
+ error: "graph unreadable",
+ },
+ });
+ expect(after.state.prd_input_prepared).toBe(true);
+ expect(after.state.codebase_grounding).toBeNull();
expect(after.state.current_step).toBe("clarification");
expect(after.action.kind).toBe("spawn_subagents");
});
diff --git a/packages/orchestration/src/__tests__/section-generation.test.ts b/packages/orchestration/src/__tests__/section-generation.test.ts
index 3a021ee..a3ec9bd 100644
--- a/packages/orchestration/src/__tests__/section-generation.test.ts
+++ b/packages/orchestration/src/__tests__/section-generation.test.ts
@@ -119,6 +119,79 @@ describe("section-generation — cortex_recall_empty_count", () => {
});
});
+// ─── Codebase grounding threading into the draft prompt ────────────────────
+
+describe("section-generation — codebase_grounding flows into the draft prompt", () => {
+ /**
+ * Drive a fresh pipeline to the section-recall action, inject the grounding
+ * onto state, feed a non-empty recall result so the retrieving→generating
+ * transition fires draftAction, and return the emitted spawn_subagents draft
+ * prompt. This verifies the handler threads state.codebase_grounding through
+ * buildSectionPrompt (normalized via .prd_context where present).
+ */
+ function draftPromptWithGrounding(
+ runId: string,
+ grounding: Record,
+ ): string {
+ const { state: atRecall, correlationId } = driveToSectionRecall(runId);
+ const grounded: PipelineState = { ...atRecall, codebase_grounding: grounding };
+ const out = step({
+ state: grounded,
+ result: {
+ kind: "tool_result",
+ correlation_id: correlationId,
+ success: true,
+ data: { results: [{ content: "prior memory" }] },
+ },
+ });
+ if (out.action.kind !== "spawn_subagents") {
+ throw new Error(
+ `Expected spawn_subagents draft action, got '${out.action.kind}'.`,
+ );
+ }
+ const prompt = out.action.invocations[0]?.prompt;
+ if (!prompt) throw new Error("draft action had no prompt");
+ return prompt;
+ }
+
+ const PRD_CONTEXT_GROUNDING = {
+ finding_summary: "Feature touches the auth community.",
+ matched_symbols: [
+ {
+ qualified_name: "src/auth.ts::loginHandler",
+ name: "loginHandler",
+ file_path: "src/auth.ts",
+ community_id: 7,
+ },
+ ],
+ impacted_communities: ["auth"],
+ impacted_processes: ["login_flow"],
+ graph_stats: { nodes: 1200, edges: 4300, communities: 18, processes: 12 },
+ };
+
+ it("threads grounding wrapped in a prepare_prd_input response (.prd_context)", () => {
+ // AP stores the WHOLE response on state; grounding lives at .prd_context.
+ const prompt = draftPromptWithGrounding("grounding-nested", {
+ prd_context: PRD_CONTEXT_GROUNDING,
+ });
+ expect(prompt).toContain("");
+ expect(prompt).toContain("loginHandler");
+ expect(prompt).toContain("src/auth.ts");
+ expect(prompt).toContain("community 7");
+ expect(prompt).toContain("login_flow");
+ expect(prompt).toContain("1200 nodes");
+ });
+
+ it("threads an already-flat grounding object (no .prd_context wrapper)", () => {
+ const prompt = draftPromptWithGrounding(
+ "grounding-flat",
+ PRD_CONTEXT_GROUNDING,
+ );
+ expect(prompt).toContain("");
+ expect(prompt).toContain("loginHandler");
+ });
+});
+
// ─── D1.B: SectionStatus.attempt_log Zod round-trip ────────────────────────
describe("SectionStatusSchema — attempt_log Zod round-trip (Wave D1.B)", () => {
diff --git a/packages/orchestration/src/__tests__/self-check-prd-validation.test.ts b/packages/orchestration/src/__tests__/self-check-prd-validation.test.ts
new file mode 100644
index 0000000..93cdea0
--- /dev/null
+++ b/packages/orchestration/src/__tests__/self-check-prd-validation.test.ts
@@ -0,0 +1,141 @@
+/**
+ * Self-check Phase 0 — PRD-vs-graph validation emission + merge.
+ *
+ * Proves the refactor of self-check.ts:
+ * 1. When a code graph exists and the PRD was exported, self_check emits a
+ * call_pipeline_tool[validate_prd_against_graph] BEFORE the judge phase.
+ * 2. The tool_result is merged into the existing `done.verification` surface
+ * under `prd_graph_validation` (no new top-level shape).
+ * 3. With no codebase_graph_path, NO AP call is emitted (backward-compatible)
+ * and `done.verification` has no prd_graph_validation field.
+ *
+ * Drives the handler directly through the public step() API from a state
+ * positioned at current_step === "self_check" with files already written —
+ * the same position the runner reaches after file_export.
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06).
+ */
+
+import { describe, it, expect } from "vitest";
+import { newPipelineState, step, type PipelineState } from "../index.js";
+
+/** Must match self-check.ts:VALIDATE_PRD_CORRELATION_ID. */
+const VALIDATE_PRD_CORRELATION_ID = "self_check_validate_prd_against_graph";
+
+function stateAtSelfCheck(opts: {
+ graphPath: string | null;
+ prdPath?: string;
+}): PipelineState {
+ const s = newPipelineState({
+ run_id: "selfcheck_validate_001",
+ feature_description: "OAuth login",
+ });
+ const prdPath = opts.prdPath ?? "prd-output/selfchec/01-prd.md";
+ return {
+ ...s,
+ current_step: "self_check",
+ prd_context: "feature",
+ codebase_graph_path: opts.graphPath,
+ // Empty sections → judge phase short-circuits to finalize (done) on the
+ // fast path, so the test isolates the validation phase.
+ sections: [],
+ written_files: [prdPath, "prd-output/selfchec/02-data-model.md"],
+ };
+}
+
+describe("self-check PRD-vs-graph validation (Phase 0)", () => {
+ it("emits validate_prd_against_graph with the exported PRD path + graph_path", () => {
+ const s = stateAtSelfCheck({ graphPath: "/g/graph" });
+ const out = step({ state: s });
+
+ expect(out.action.kind).toBe("call_pipeline_tool");
+ if (out.action.kind === "call_pipeline_tool") {
+ expect(out.action.tool_name).toBe("validate_prd_against_graph");
+ expect(out.action.correlation_id).toBe(VALIDATE_PRD_CORRELATION_ID);
+ expect(out.action.arguments).toHaveProperty(
+ "prd_path",
+ "prd-output/selfchec/01-prd.md",
+ );
+ expect(out.action.arguments).toHaveProperty("graph_path", "/g/graph");
+ // finding is optional in the new contract — must NOT be sent.
+ expect(out.action.arguments).not.toHaveProperty("finding");
+ expect(out.action.arguments).not.toHaveProperty("finding_id");
+ }
+ // Not yet validated — the flag flips only on the result.
+ expect(out.state.prd_validated).toBe(false);
+ });
+
+ it("merges the validation report into done.verification.prd_graph_validation", () => {
+ const issued = step({ state: stateAtSelfCheck({ graphPath: "/g/graph" }) });
+ const cid =
+ issued.action.kind === "call_pipeline_tool"
+ ? issued.action.correlation_id
+ : "";
+ const report = {
+ hallucinated_symbols: ["Foo.bar"],
+ community_inconsistencies: [],
+ unverified_impact_claims: [],
+ verdict: "warn",
+ };
+ const after = step({
+ state: issued.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: cid,
+ success: true,
+ data: report,
+ },
+ });
+
+ expect(after.state.prd_validated).toBe(true);
+ expect(after.state.prd_validation).toEqual(report);
+ // Empty sections → judge phase short-circuits to done.
+ expect(after.action.kind).toBe("done");
+ if (after.action.kind === "done") {
+ expect(after.action.verification?.prd_graph_validation).toEqual(report);
+ }
+ });
+
+ it("validation failure is advisory — self-check still completes, no report attached", () => {
+ const issued = step({ state: stateAtSelfCheck({ graphPath: "/g/graph" }) });
+ const cid =
+ issued.action.kind === "call_pipeline_tool"
+ ? issued.action.correlation_id
+ : "";
+ const after = step({
+ state: issued.state,
+ result: {
+ kind: "tool_result",
+ correlation_id: cid,
+ success: false,
+ data: null,
+ error: "graph not found",
+ },
+ });
+
+ expect(after.state.prd_validated).toBe(true);
+ expect(after.state.prd_validation).toBeNull();
+ expect(after.action.kind).toBe("done");
+ if (after.action.kind === "done") {
+ expect(after.action.verification?.prd_graph_validation).toBeUndefined();
+ }
+ expect(
+ after.state.errors.some((e) =>
+ e.includes("validate_prd_against_graph failed"),
+ ),
+ ).toBe(true);
+ });
+
+ it("no graph_path → no AP call, no prd_graph_validation (backward-compatible)", () => {
+ const s = stateAtSelfCheck({ graphPath: null });
+ const out = step({ state: s });
+
+ // Skips straight past validation into the judge phase → done.
+ expect(out.action.kind).toBe("done");
+ if (out.action.kind === "done") {
+ expect(out.action.verification?.prd_graph_validation).toBeUndefined();
+ }
+ expect(out.state.prd_validated).toBe(true);
+ expect(out.state.prd_validation).toBeNull();
+ });
+});
diff --git a/packages/orchestration/src/handlers/input-analysis.ts b/packages/orchestration/src/handlers/input-analysis.ts
index 897772e..fa3e233 100644
--- a/packages/orchestration/src/handlers/input-analysis.ts
+++ b/packages/orchestration/src/handlers/input-analysis.ts
@@ -1,9 +1,22 @@
/**
- * Input analysis — call automatised-pipeline `index_codebase`.
+ * Input analysis — call automatised-pipeline `index_codebase`, then
+ * `prepare_prd_input` (FEATURE MODE) to ground the feature on the code graph.
*
- * Tool contract (source of truth: ai-automatised-pipeline/src/tool_schemas.rs):
- * inputs: { path: string, output_dir: string, language?: string }
- * output: { graph_path: string, ... } ← subsequent graph tools take graph_path
+ * Tool contracts (source of truth: ai-automatised-pipeline/src/tool_schemas.rs):
+ * index_codebase:
+ * inputs: { path, output_dir, language? }
+ * output: { graph_path } ← graph tools take this
+ * prepare_prd_input (feature mode — no finding_id):
+ * inputs: { feature_description, output_dir, graph_path }
+ * output: prd_context { matched_symbols, impacted_communities,
+ * impacted_processes, graph_stats, mode:"feature" }
+ *
+ * Two sequential host-driven calls, each following the established reducer
+ * protocol (emit call_pipeline_tool → host runs it → result fed back via
+ * tool_result → handler processes result.data):
+ * 1. index_codebase → sets codebase_graph_path / codebase_indexed
+ * 2. prepare_prd_input → sets codebase_grounding / prd_input_prepared
+ * Only after BOTH complete does the step advance to feasibility_gate.
*
* The output_dir is derived from run_id so retries are idempotent and runs
* do not collide. We write under /.prd-gen/graphs//
@@ -12,14 +25,73 @@
import { join } from "node:path";
import type { StepHandler } from "../runner.js";
-import { appendError } from "../types/state.js";
+import { appendError, type PipelineState } from "../types/state.js";
+import type { HandlerAction } from "../types/actions.js";
const CORRELATION_ID = "input_analysis_index";
+const PREPARE_CORRELATION_ID = "input_analysis_prepare_prd_input";
function deriveOutputDir(codebasePath: string, runId: string): string {
return join(codebasePath, ".prd-gen", "graphs", runId);
}
+/**
+ * Emit the `prepare_prd_input` (feature-mode) grounding call, OR skip it
+ * gracefully and advance when grounding is impossible.
+ *
+ * precondition: state.codebase_indexed && state.codebase_graph_path set;
+ * state.prd_input_prepared === false.
+ * postcondition: either (a) a call_pipeline_tool[prepare_prd_input] action
+ * with { feature_description, output_dir, graph_path }, leaving
+ * prd_input_prepared false (result will set it); or (b) when
+ * there is no usable feature_description, prd_input_prepared is
+ * set true and current_step advances to feasibility_gate with
+ * grounding left null — preserving the no-grounding behavior.
+ */
+function emitPrepare(state: PipelineState): {
+ state: PipelineState;
+ action: HandlerAction;
+} {
+ const featureDescription = state.feature_description.trim();
+ const graphPath = state.codebase_graph_path;
+
+ // No feature text to ground, or graph missing → skip grounding, advance.
+ // (graphPath is non-null by precondition; the guard keeps the type narrow.)
+ if (!featureDescription || !graphPath) {
+ return {
+ state: {
+ ...state,
+ prd_input_prepared: true,
+ current_step: "feasibility_gate",
+ },
+ action: {
+ kind: "emit_message",
+ message:
+ "No feature description to ground; skipping code-graph grounding.",
+ },
+ };
+ }
+
+ const outputDir =
+ state.codebase_output_dir ??
+ deriveOutputDir(state.codebase_path!, state.run_id);
+
+ return {
+ state: { ...state, codebase_output_dir: outputDir },
+ action: {
+ kind: "call_pipeline_tool",
+ tool_name: "prepare_prd_input",
+ // Feature mode: no finding_id. AP grounds the free text on the graph.
+ arguments: {
+ feature_description: featureDescription,
+ output_dir: outputDir,
+ graph_path: graphPath,
+ },
+ correlation_id: PREPARE_CORRELATION_ID,
+ },
+ };
+}
+
export const handleInputAnalysis: StepHandler = ({ state, result }) => {
// No codebase → skip indexing entirely.
if (!state.codebase_path) {
@@ -32,13 +104,68 @@ export const handleInputAnalysis: StepHandler = ({ state, result }) => {
};
}
- // Already indexed → move on.
- if (state.codebase_indexed && state.codebase_graph_path) {
+ // Both phases done (index + grounding) → move on. Guard placed BEFORE the
+ // result-routing blocks so a replayed terminal state advances idempotently
+ // without re-issuing either call.
+ if (
+ state.codebase_indexed &&
+ state.codebase_graph_path &&
+ state.prd_input_prepared
+ ) {
return {
state: { ...state, current_step: "feasibility_gate" },
action: {
kind: "emit_message",
- message: `Codebase indexed (graph: ${state.codebase_graph_path}).`,
+ message: `Codebase analysis ready (graph: ${state.codebase_graph_path}).`,
+ },
+ };
+ }
+
+ // Result of a prepare_prd_input (feature-mode grounding) call. Process its
+ // result.data, store the grounding, set the idempotency flag, and advance.
+ if (
+ result?.kind === "tool_result" &&
+ result.correlation_id === PREPARE_CORRELATION_ID
+ ) {
+ if (!result.success) {
+ // Grounding is best-effort: the PRD can still be generated without it.
+ // Treat AP failure as an upstream issue, flag prepared so we do not loop,
+ // and advance rather than failing the whole pipeline.
+ return {
+ state: {
+ ...appendError(
+ state,
+ `prepare_prd_input failed: ${result.error ?? "unknown"}; continuing without code-graph grounding`,
+ "upstream_failure",
+ ),
+ prd_input_prepared: true,
+ current_step: "feasibility_gate",
+ },
+ action: {
+ kind: "emit_message",
+ message:
+ "Code-graph grounding unavailable; proceeding without it.",
+ level: "warn",
+ },
+ };
+ }
+ // AP feature mode wraps the grounding in `prd_context`; tolerate a flat
+ // payload too (the orchestration layer does not parse the shape further).
+ const data = (result.data ?? {}) as {
+ prd_context?: Record;
+ };
+ const grounding: Record =
+ data.prd_context ?? (result.data as Record) ?? {};
+ return {
+ state: {
+ ...state,
+ codebase_grounding: grounding,
+ prd_input_prepared: true,
+ current_step: "feasibility_gate",
+ },
+ action: {
+ kind: "emit_message",
+ message: "Feature grounded on code graph.",
},
};
}
@@ -82,18 +209,24 @@ export const handleInputAnalysis: StepHandler = ({ state, result }) => {
},
};
}
- return {
- state: {
- ...state,
- codebase_indexed: true,
- codebase_graph_path: graphPath,
- current_step: "feasibility_gate",
- },
- action: {
- kind: "emit_message",
- message: `Codebase indexed (graph: ${graphPath}).`,
- },
- };
+ // Indexed — do NOT advance yet. Record the graph, then fall through to the
+ // prepare_prd_input emission below (state.codebase_graph_path is now set,
+ // state.prd_input_prepared is still false).
+ return emitPrepare({
+ ...state,
+ codebase_indexed: true,
+ codebase_graph_path: graphPath,
+ });
+ }
+
+ // Index already done but grounding not yet prepared (e.g. fresh entry with a
+ // pre-indexed codebase, or replay after the index step). Emit prepare.
+ if (
+ state.codebase_indexed &&
+ state.codebase_graph_path &&
+ !state.prd_input_prepared
+ ) {
+ return emitPrepare(state);
}
// Trigger the indexing call. Compute output_dir if not yet set so it
diff --git a/packages/orchestration/src/handlers/section-generation.ts b/packages/orchestration/src/handlers/section-generation.ts
index 3a0e209..dfdf51d 100644
--- a/packages/orchestration/src/handlers/section-generation.ts
+++ b/packages/orchestration/src/handlers/section-generation.ts
@@ -36,7 +36,10 @@ import {
CAPABILITIES,
type SectionType,
} from "@prd-gen/core";
-import { buildSectionPrompt } from "@prd-gen/meta-prompting";
+import {
+ buildSectionPrompt,
+ type CodebaseGrounding,
+} from "@prd-gen/meta-prompting";
import { selectStrategy, type StrategyAssignment } from "@prd-gen/strategy";
import { SECTIONS_BY_CONTEXT, SECTION_RECALL_TEMPLATES } from "../section-plan.js";
import {
@@ -116,6 +119,35 @@ function recallAction(
};
}
+/**
+ * Normalize the opaque `state.codebase_grounding` into the grounding shape the
+ * section prompt renders.
+ *
+ * AP's feature-mode `prepare_prd_input` returns a RESPONSE object that CONTAINS
+ * the grounding under `.prd_context`. `state.codebase_grounding` is stored as
+ * that whole response (the orchestration layer is a pure passthrough — see
+ * state.ts), so the grounding is `state.codebase_grounding.prd_context`. Fall
+ * back to the object itself if `.prd_context` is absent (already-flat payloads),
+ * and to `undefined` when no grounding exists at all.
+ *
+ * precondition: `raw` is `state.codebase_grounding` (Record | null).
+ * postcondition: returns a CodebaseGrounding (possibly empty-but-typed) when a
+ * grounding object is present, else `undefined`. An `undefined` result makes
+ * `renderGroundingBlock` emit nothing → byte-identical pre-grounding prompt.
+ *
+ * source: AP feature-mode prepare_prd_input contract — response carries
+ * `prd_context` (the grounding); shipped 2026-06.
+ */
+function normalizeGrounding(
+ raw: Record | null | undefined,
+): CodebaseGrounding | undefined {
+ if (!raw) return undefined;
+ const nested = (raw as { prd_context?: unknown }).prd_context;
+ const grounding =
+ nested && typeof nested === "object" ? nested : raw;
+ return grounding as CodebaseGrounding;
+}
+
function draftAction(
state: PipelineState,
section: SectionStatus,
@@ -141,6 +173,11 @@ function draftAction(
// so every retry uses the SAME strategies the selector chose at the
// pending → retrieving transition.
strategy_assignment: section.strategy_assignment,
+ // Thread AP's code-graph grounding (state.codebase_grounding, possibly
+ // wrapped in a prepare_prd_input response) into the prompt so drafts
+ // reference real symbols/files/communities/processes. `undefined` when no
+ // codebase grounding exists → prompt is byte-identical to before.
+ codebase_grounding: normalizeGrounding(state.codebase_grounding),
});
return {
diff --git a/packages/orchestration/src/handlers/self-check.ts b/packages/orchestration/src/handlers/self-check.ts
index ab7f8f5..e1f5857 100644
--- a/packages/orchestration/src/handlers/self-check.ts
+++ b/packages/orchestration/src/handlers/self-check.ts
@@ -32,6 +32,89 @@ import { z } from "zod";
import { SELF_CHECK_JUDGE_INV_PREFIX } from "./protocol-ids.js";
const VERIFY_BATCH_ID = "self_check_verify";
+const VALIDATE_PRD_CORRELATION_ID = "self_check_validate_prd_against_graph";
+
+/**
+ * Locate the exported PRD path in state.written_files. file-export writes the
+ * primary PRD as `/01-prd.md`; we match on that suffix so the lookup is
+ * resilient to the run-id-derived base prefix.
+ *
+ * source: file-export.ts buildFileSet — `${base}/01-prd.md` is the canonical
+ * combined PRD document.
+ */
+function exportedPrdPath(state: PipelineState): string | null {
+ return state.written_files.find((p) => /(^|\/)01-prd\.md$/.test(p)) ?? null;
+}
+
+/**
+ * Phase 0 — PRD-vs-graph validation. Runs once, before the judge phase, when
+ * a code graph exists. Emits a call_pipeline_tool for
+ * `validate_prd_against_graph` with { prd_path, graph_path }; processes the
+ * result into state.prd_validation; sets prd_validated for idempotency.
+ *
+ * Skips gracefully (sets prd_validated, leaves prd_validation null) when there
+ * is no graph_path or no exported PRD to validate — preserving the no-codebase
+ * behavior exactly.
+ *
+ * precondition: current_step === "self_check".
+ * postcondition: either a call_pipeline_tool action (prd_validated unchanged,
+ * set by the result branch) OR prd_validated === true with the
+ * handler falling through to the existing judge phase.
+ */
+function handlePrdValidation(
+ state: PipelineState,
+ result: ActionResult | undefined,
+):
+ | { state: PipelineState; action: NextAction }
+ | { state: PipelineState; fallthrough: true } {
+ // Result of the validate_prd_against_graph call.
+ if (
+ result?.kind === "tool_result" &&
+ result.correlation_id === VALIDATE_PRD_CORRELATION_ID
+ ) {
+ if (!result.success) {
+ // Validation is advisory — failure must not block self-check. Flag done,
+ // record the upstream issue, fall through to the judge phase.
+ return {
+ state: appendError(
+ { ...state, prd_validated: true },
+ `validate_prd_against_graph failed: ${result.error ?? "unknown"}; continuing without graph validation`,
+ "upstream_failure",
+ ),
+ fallthrough: true,
+ };
+ }
+ const report = (result.data ?? {}) as Record;
+ return {
+ state: { ...state, prd_validation: report, prd_validated: true },
+ fallthrough: true,
+ };
+ }
+
+ // Already validated (or skipped) → fall through to the judge phase.
+ if (state.prd_validated) {
+ return { state, fallthrough: true };
+ }
+
+ const graphPath = state.codebase_graph_path;
+ const prdPath = exportedPrdPath(state);
+
+ // No graph or no exported PRD → skip gracefully and fall through.
+ if (!graphPath || !prdPath) {
+ return { state: { ...state, prd_validated: true }, fallthrough: true };
+ }
+
+ // Emit the validation call. prd_validated stays false until the result.
+ return {
+ state,
+ action: {
+ kind: "call_pipeline_tool",
+ tool_name: "validate_prd_against_graph",
+ arguments: { prd_path: prdPath, graph_path: graphPath },
+ correlation_id: VALIDATE_PRD_CORRELATION_ID,
+ },
+ };
+}
const RawVerdictSchema = z.object({
verdict: VerdictSchema,
@@ -180,6 +263,12 @@ function finalize(
claims_evaluated: verificationReport.claims_evaluated,
distribution: verificationReport.distribution,
distribution_suspicious: verificationReport.distribution_suspicious,
+ // Attach the PRD-vs-graph validation report when one was produced. Left
+ // undefined for non-codebase runs so the prior verification shape is
+ // unchanged (backward-compatible). See VerificationSummarySchema.
+ ...(state.prd_validation
+ ? { prd_graph_validation: state.prd_validation }
+ : {}),
},
},
};
@@ -259,13 +348,22 @@ function handleSelfCheckPhaseB(
}
export const handleSelfCheck: StepHandler = ({ state, result }) => {
+ // Phase 0 — PRD-vs-graph validation runs before the judge phase. It either
+ // emits the validation call (and we return immediately) or falls through
+ // with possibly-updated state (validation stored / skipped / failed-advisory).
+ const validation = handlePrdValidation(state, result);
+ if ("action" in validation) {
+ return { state: validation.state, action: validation.action };
+ }
+ const stateAfterValidation = validation.state;
+
if (
result?.kind === "subagent_batch_result" &&
result.batch_id === VERIFY_BATCH_ID
) {
- return handleSelfCheckPhaseB(state, result);
+ return handleSelfCheckPhaseB(stateAfterValidation, result);
}
- return handleSelfCheckPhaseA(state);
+ return handleSelfCheckPhaseA(stateAfterValidation);
};
/**
diff --git a/packages/orchestration/src/types/actions.ts b/packages/orchestration/src/types/actions.ts
index 205db09..8ff4114 100644
--- a/packages/orchestration/src/types/actions.ts
+++ b/packages/orchestration/src/types/actions.ts
@@ -138,6 +138,19 @@ export const VerificationSummarySchema = z.object({
claims_evaluated: z.number().int().nonnegative(),
distribution: z.record(VerdictSchema, z.number().int().nonnegative()),
distribution_suspicious: z.boolean(),
+ /**
+ * PRD-vs-graph validation report from automatised-pipeline
+ * `validate_prd_against_graph`, attached when the run had a code graph.
+ * Symbol-hallucination / community-consistency / process-impact findings.
+ * Opaque object — the orchestration layer is a passthrough and does not parse
+ * the AP payload. Absent when no codebase was provided (preserves the prior
+ * verification shape for non-codebase runs).
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06). Attached
+ * here (not as a new top-level done field) so KPI/test consumers read one
+ * typed verification surface.
+ */
+ prd_graph_validation: z.record(z.string(), z.unknown()).optional(),
});
export type VerificationSummary = z.infer;
diff --git a/packages/orchestration/src/types/state.ts b/packages/orchestration/src/types/state.ts
index 796977c..e9d3829 100644
--- a/packages/orchestration/src/types/state.ts
+++ b/packages/orchestration/src/types/state.ts
@@ -159,6 +159,33 @@ export const PipelineStateSchema = z.object({
/** Output directory passed to `index_codebase` so retries are idempotent. */
codebase_output_dir: z.string().nullable(),
codebase_indexed: z.boolean(),
+ /**
+ * Code-graph grounding for the feature, returned by automatised-pipeline
+ * `prepare_prd_input` in FEATURE MODE (response field `prd_context`):
+ * { matched_symbols, impacted_communities, impacted_processes,
+ * graph_stats, mode }
+ * Distinct from `prd_context` above (which is the PRD *kind* enum:
+ * feature/bug/incident/…). This is the code-graph evidence later steps
+ * (budget / section generation) inject as grounding so generated sections
+ * reference real symbols/communities/processes. Stored as an opaque object
+ * because the orchestration layer is a pure passthrough — it does not parse
+ * the AP payload (mirrors how `index_codebase` data is consumed inline).
+ *
+ * Null until `prepare_prd_input` succeeds, or permanently null when no
+ * codebase/feature_description is available (skip path, backward-compatible).
+ *
+ * source: AP feature-mode prepare_prd_input contract (shipped 2026-06).
+ */
+ codebase_grounding: z.record(z.string(), z.unknown()).nullable().default(null),
+ /**
+ * Idempotency flag for the `prepare_prd_input` emission in input_analysis.
+ * Mirrors `codebase_indexed`: set true once the grounding call has been
+ * processed (success OR a skip-after-index decision) so the step advances
+ * exactly once and replayed state does not re-issue the call.
+ *
+ * source: AP feature-mode prepare_prd_input contract (shipped 2026-06).
+ */
+ prd_input_prepared: z.boolean().default(false),
/**
* Preflight gate state — `null` while preflight has not been attempted
* yet, `"ok"` once Cortex (and ai-architect, when a codebase is given)
@@ -236,6 +263,30 @@ export const PipelineStateSchema = z.object({
* read in Phase B to attribute verdicts. Null until Phase A runs.
*/
verification_plan: VerificationPlanSnapshotSchema.nullable().default(null),
+ /**
+ * PRD-vs-graph validation report from automatised-pipeline
+ * `validate_prd_against_graph` (args { prd_path, graph_path }), fetched in
+ * self-check after the PRD file is exported. Symbol-hallucination /
+ * community-consistency / process-impact findings. Merged into the
+ * self-check `done.verification` surface (not a new top-level shape).
+ *
+ * Stored as an opaque object — the orchestration layer is a pure passthrough
+ * and does not parse the AP payload. Null until the call succeeds, or
+ * permanently null when no `codebase_graph_path` exists (skip path,
+ * backward-compatible).
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06).
+ */
+ prd_validation: z.record(z.string(), z.unknown()).nullable().default(null),
+ /**
+ * Idempotency flag for the `validate_prd_against_graph` emission in
+ * self-check. Mirrors `prd_input_prepared`: set true once the validation
+ * call has been processed (success OR skip) so self-check advances to its
+ * existing verify phase exactly once.
+ *
+ * source: AP validate_prd_against_graph contract (shipped 2026-06).
+ */
+ prd_validated: z.boolean().default(false),
/**
* Append-only queue of strategy execution results, populated when a
* section transitions to a terminal status (passed/failed). The