From 90cf3441a58acf635a6f62122118c5748c95babc Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 2 Jun 2026 21:31:51 +0200 Subject: [PATCH 1/3] feat(orchestration): ground PRD on the codebase graph via AP (refactor handlers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing host-driven reducer to use automatised-pipeline more fully — no new module/tool, just two action emissions through the existing call_pipeline_tool protocol: - input-analysis: after index_codebase succeeds, emit prepare_prd_input in FEATURE mode { feature_description, output_dir, graph_path } (no finding_id), and store the returned grounding on state.codebase_grounding for later steps. Idempotent via prd_input_prepared. (AP now returns prd_context inline.) - self-check: after PRD export, emit validate_prd_against_graph { prd_path, graph_path } and merge the symbol-hallucination/community/process report into the existing done.verification as prd_graph_validation. Idempotent via prd_validated. Both calls are advisory — AP failure records the error and advances; pipelines without a codebase_path are unchanged (zero AP calls). State: +codebase_grounding, +prd_input_prepared, +prd_validation, +prd_validated (additive, mirror the existing codebase_graph_path/codebase_indexed pair). Used state.codebase_grounding (not prd_context — that's the PRD-kind enum). Verified: orchestration 100/100; build + typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/runner.test.ts | 100 +++++++++- .../self-check-prd-validation.test.ts | 141 ++++++++++++++ .../src/handlers/input-analysis.ts | 173 ++++++++++++++++-- .../orchestration/src/handlers/self-check.ts | 102 ++++++++++- packages/orchestration/src/types/actions.ts | 13 ++ packages/orchestration/src/types/state.ts | 51 ++++++ 6 files changed, 554 insertions(+), 26 deletions(-) create mode 100644 packages/orchestration/src/__tests__/self-check-prd-validation.test.ts 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__/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/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 From 640ebe8cba41752367fb79076feb888536ba9421 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 2 Jun 2026 21:42:10 +0200 Subject: [PATCH 2/3] feat(meta-prompting): inject codebase grounding into section prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the grounding loop: section drafts now USE the AP grounding instead of ignoring it. buildSectionPrompt gains an optional codebase_grounding input and renders a concise block (graph stats + up to 15 matched symbols with file/community + impacted communities/processes, capped) after . section-generation normalizes state.codebase_grounding (prd_context wrapper OR flat) and threads it into the draft call. Backward compatible: no grounding → byte-identical prompt (asserted via toBe). No new module — extended the existing interface + builder + call site. Verified: 124/124 across meta-prompting + orchestration; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/prompt-builders.test.ts | 115 ++++++++++++++ packages/meta-prompting/src/index.ts | 2 + .../meta-prompting/src/section-prompts.ts | 148 ++++++++++++++++++ .../src/__tests__/section-generation.test.ts | 73 +++++++++ .../src/handlers/section-generation.ts | 39 ++++- 5 files changed, 376 insertions(+), 1 deletion(-) 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__/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/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 { From 5577cb8106bcd72b65461630850ac14b8b6dc755 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 2 Jun 2026 22:27:21 +0200 Subject: [PATCH 3/3] chore(bundle): rebuild mcp-server/index.js for grounding changes The bundle-freshness CI check requires the committed bundle to match source. Regenerate via pnpm build && pnpm bundle after the input-analysis/self-check/ section-generation grounding refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- mcp-server/index.js | 268 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 246 insertions(+), 22 deletions(-) diff --git a/mcp-server/index.js b/mcp-server/index.js index 7079624..b5259e5 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -18341,6 +18341,33 @@ var init_state = __esm({ /** 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) @@ -18416,6 +18443,30 @@ var init_state = __esm({ * 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 @@ -18544,7 +18595,20 @@ var init_actions = __esm({ 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() }); DoneActionSchema = external_exports.object({ kind: external_exports.literal("done"), @@ -18990,12 +19054,45 @@ import { join as join3 } from "node:path"; function deriveOutputDir(codebasePath, runId) { return join3(codebasePath, ".prd-gen", "graphs", runId); } -var CORRELATION_ID, handleInputAnalysis; +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 CORRELATION_ID, PREPARE_CORRELATION_ID, handleInputAnalysis; var init_input_analysis = __esm({ async "packages/orchestration/dist/handlers/input-analysis.js"() { "use strict"; await init_state(); CORRELATION_ID = "input_analysis_index"; + PREPARE_CORRELATION_ID = "input_analysis_prepare_prd_input"; handleInputAnalysis = ({ state, result }) => { if (!state.codebase_path) { return { @@ -19006,12 +19103,42 @@ var init_input_analysis = __esm({ } }; } - 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." } }; } @@ -19053,18 +19180,14 @@ var init_input_analysis = __esm({ } }; } - 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 { @@ -19176,6 +19299,44 @@ function renderStrategiesBlock(assignment) { lines.push(``); return lines.join("\n"); } +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]; @@ -19190,6 +19351,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.`, "", @@ -19205,6 +19367,8 @@ A: ${c.answer}`).join("\n\n"); ${input.recall_summary} ` : "", + groundingBlock, + groundingBlock ? "" : "", clarificationLines ? ` ${clarificationLines} @@ -19224,7 +19388,7 @@ ${clarificationLines} `Produce the "${display}" section now. Markdown only.` ].filter((line) => line !== "").join("\n"); } -var COMMON_RULES, PER_SECTION_GUIDANCE; +var COMMON_RULES, PER_SECTION_GUIDANCE, GROUNDING_MAX_SYMBOLS, GROUNDING_MAX_COMMUNITY_NAMES, GROUNDING_MAX_PROCESS_NAMES; var init_section_prompts = __esm({ async "packages/meta-prompting/dist/section-prompts.js"() { "use strict"; @@ -19255,6 +19419,9 @@ var init_section_prompts = __esm({ risks: "Risk register: | Risk | Likelihood | Impact | Mitigation | Owner |. One row per risk.", timeline: "Phases with sprint counts. Each phase total = sum of stories in that phase. Grand total = sum of phases." }; + GROUNDING_MAX_SYMBOLS = 15; + GROUNDING_MAX_COMMUNITY_NAMES = 10; + GROUNDING_MAX_PROCESS_NAMES = 10; } }); @@ -19766,6 +19933,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) { @@ -19782,7 +19956,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", @@ -20965,6 +21144,41 @@ var init_dist5 = __esm({ }); // packages/orchestration/dist/handlers/self-check.js +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 + } + }; +} function invocationIdFor(idx) { return `${SELF_CHECK_JUDGE_INV_PREFIX}${idx.toString().padStart(4, "0")}`; } @@ -21071,7 +21285,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 } : {} } } }; @@ -21148,7 +21366,7 @@ function parseVerdictsFromSnapshot(snapshot, state, batchResult) { } return out; } -var VERIFY_BATCH_ID, RawVerdictSchema, handleSelfCheck; +var VERIFY_BATCH_ID, VALIDATE_PRD_CORRELATION_ID, RawVerdictSchema, handleSelfCheck; var init_self_check = __esm({ async "packages/orchestration/dist/handlers/self-check.js"() { "use strict"; @@ -21159,6 +21377,7 @@ var init_self_check = __esm({ init_zod(); init_protocol_ids(); VERIFY_BATCH_ID = "self_check_verify"; + VALIDATE_PRD_CORRELATION_ID = "self_check_validate_prd_against_graph"; RawVerdictSchema = external_exports.object({ verdict: VerdictSchema, rationale: external_exports.string(), @@ -21166,10 +21385,15 @@ var init_self_check = __esm({ confidence: external_exports.number().min(0).max(1) }); 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); }; } });