diff --git a/.operator/data/tasks/T20260705-8B7B4079.md b/.operator/data/tasks/T20260705-8B7B4079.md index e1f0903..92b560f 100644 --- a/.operator/data/tasks/T20260705-8B7B4079.md +++ b/.operator/data/tasks/T20260705-8B7B4079.md @@ -2,12 +2,12 @@ id: T20260705-8B7B4079 kind: task title: Split aop-planner-stage.ts under the 200 code-line cap by extracting PR-body rendering -status: pending +status: completed priority: 4 created_at: '2026-07-11T08:48:02Z' +completed_at: "2026-07-27T18:05:36Z" parent_id: F20260705-CC7FF1B9 --- - # Split aop-planner-stage.ts under the pipeline line cap ## Problem diff --git a/engine/pipeline/composers/_shared/aop-planner-deps.ts b/engine/pipeline/composers/_shared/aop-planner-deps.ts new file mode 100644 index 0000000..7f6a31b --- /dev/null +++ b/engine/pipeline/composers/_shared/aop-planner-deps.ts @@ -0,0 +1,56 @@ +import type { + StateManager, VCSPlatform, KindRegistry, + WorkItemSource, AgentEventStream, AgentRoleName, WorkItemKind, +} from "@operator/core"; +import type { AgentsFile } from "../../../config/schemas.js"; +import type { PromptSource } from "@operator/core"; +import type { PRManager } from "../../../delivery/pr-manager.js"; +import type { WorkspaceGit } from "../../../infra/git.js"; +import type { Logger } from "../../../logging/logger.js"; +import type { StateContextVars } from "../../../work-items/work-items.js"; + +export interface AopPlannerHookDeps { + readonly state: StateManager; + readonly vcs: VCSPlatform; + readonly prManager: PRManager; + readonly git: WorkspaceGit; + readonly kindRegistry: KindRegistry; + /** Storage directory for parent work-items (e.g. the findings dir). */ + readonly parentDataDir: string; + /** Storage directory for child work-items (e.g. the tasks dir). */ + readonly childDataDir: string; + readonly automationDir: string; + readonly workspacePath: string; + readonly templatesDir: string; + readonly agentsConfig: AgentsFile; + readonly promptSource: PromptSource; + readonly workItemSource: WorkItemSource; + readonly agentEventStream: AgentEventStream; + readonly stateVars?: StateContextVars; + readonly log?: Logger; + readonly debug?: boolean; + readonly debugRunUrl?: string; + readonly kv?: import("@operator/core").KVStore; + + // ── Stage-shape parameters ──────────────────────────────────────── + /** Parent work-item kind (e.g. `"finding"`). */ + readonly parentKind: WorkItemKind; + /** Planner agent role (e.g. `"planner"`). */ + readonly agentRole: AgentRoleName; + /** Verifier chain topic suffix, used as `verifier/{verifierTopic}`. */ + readonly verifierTopic: string; + /** Branch prefix for the parent's PRs (e.g. `"ai/findings"`). */ + readonly branchPrefix: string; + /** PR title prefix (e.g. `"[AI:Finding]"`). */ + readonly prPrefix: string; + /** In-progress PR body template filename. */ + readonly prTemplate: string; + /** Human-facing display name (e.g. `"Finding"`). */ + readonly displayName: string; + /** ID prefix for parent items (e.g. `"F"`) — used for parsing date / seq. */ + readonly idPrefix: string; + /** Prompt variable name for the parent id (e.g. `"FINDING_ID"`). */ + readonly idVarName: string; + /** Prompt variable name for the seq (e.g. `"FINDING_SEQ"`). */ + readonly seqVarName: string; +} diff --git a/engine/pipeline/composers/_shared/aop-planner-scratch.ts b/engine/pipeline/composers/_shared/aop-planner-scratch.ts new file mode 100644 index 0000000..7dad27b --- /dev/null +++ b/engine/pipeline/composers/_shared/aop-planner-scratch.ts @@ -0,0 +1,22 @@ +import type { WorkItemFileData } from "../../../work-items/work-items.js"; +import type { HeadSnapshot } from "../../primitives/head-snapshot-contract.js"; +import { createScratchStore } from "./scratch.js"; + +export interface AopPlannerScratch { + readonly itemId: string; + readonly filePath: string; + readonly item: WorkItemFileData; + readonly codeReviewId: number | null; + readonly headSnapshot: HeadSnapshot; + readonly alreadyPlannedChildren?: ReadonlyArray; + /** + * Set by `afterAgent` on the rejected verdict path. `buildPR` reads this + * to produce a rejection-specific PR title (REJECTED suffix) + body that + * carries the agent's reasoning, instead of the standard in-progress + * template. The human reviewer sees the rejection explanation directly + * in the PR description without opening the execution log. + */ + rejection?: { agentRole: string; reason: string }; +} + +export const aopPlannerScratch = createScratchStore(); diff --git a/engine/pipeline/composers/_shared/planner-pr-body.test.ts b/engine/pipeline/composers/_shared/planner-pr-body.test.ts new file mode 100644 index 0000000..483ae93 --- /dev/null +++ b/engine/pipeline/composers/_shared/planner-pr-body.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from "vitest"; +import type { WorkItemFileData } from "../../../work-items/work-items.js"; +import { + buildCatchUpPlannerPr, + buildPlanPlannerPr, + buildPlannerTerminalComment, + buildRejectionPlannerPr, +} from "./planner-pr-body.js"; + +const shape = { + prPrefix: "[AI:Finding]", + displayName: "Finding", + idVarName: "FINDING_ID", +} as const; + +function makeItem(overrides: Partial = {}): WorkItemFileData { + return { + id: "F-PR", + kind: "finding", + title: "F-PR title", + body: "F-PR body paragraph.", + status: "in-progress", + priority: 3, + source: "analyzer-x", + createdAt: "2026-04-16", + ...overrides, + }; +} + +describe("buildRejectionPlannerPr", () => { + it("renders byte-identical rejection title, body, and commit message", () => { + const item = makeItem(); + const rejection = { + agentRole: "planner", + reason: "Finding invalid — base class already maps name (case-insensitive)", + }; + const result = buildRejectionPlannerPr(item, "F-PR", rejection, shape); + + expect(result.title).toBe("[AI:Finding] F-PR: REJECTED — F-PR title"); + expect(result.body).toBe([ + "## Finding F-PR: rejected by planner", + "", + "**Reason**: Finding invalid — base class already maps name (case-insensitive)", + "", + "**What this PR does**: flips `status: pending → rejected` on the finding file so the orchestrator stops re-picking this item.", + "", + "**Original finding body**:", + "", + "F-PR body paragraph.", + "", + "---", + "", + "**Reviewer action**: merge this PR to propagate the rejection to the base branch, or close-without-merge if you disagree — the supervisor will handle override on the next cycle.", + ].join("\n")); + expect(result.commitMessage).toBe( + "Finding F-PR: rejected — Finding invalid — base class already maps name (case-insensitive)", + ); + }); + + it("truncates an over-long original body at 1500 characters", () => { + const longBody = "x".repeat(1600); + const item = makeItem({ body: longBody }); + const result = buildRejectionPlannerPr( + item, + "F-PR", + { agentRole: "planner", reason: "invalid" }, + shape, + ); + expect(result.body).toContain("x".repeat(1500)); + expect(result.body).toContain("[…truncated]"); + expect(result.body).not.toContain("x".repeat(1600)); + }); +}); + +describe("buildCatchUpPlannerPr", () => { + it("renders byte-identical catch-up title, body, and commit message", () => { + const item = makeItem(); + const childIds = ["T-EXISTING-1"]; + const result = buildCatchUpPlannerPr(item, "F-PR", childIds, shape); + + expect(result.title).toBe("[AI:Finding] F-PR (catch-up): F-PR title"); + expect(result.body).toBe([ + "## Finding F-PR: catch-up — planner skipped", + "", + "**What this PR does**: flips `status: pending → in-progress` on the finding file only. No new child items were emitted.", + "", + "**Why planner was skipped**: 1 child item(s) for this finding already exist on the base branch:", + "", + "- `T-EXISTING-1`", + "", + "**Reviewer action**: safe to merge as-is — this is the idempotency contract bringing the recorded finding status in line with prior planning.", + ].join("\n")); + expect(result.commitMessage).toBe( + "Finding F-PR: catch-up status flip (1 child item(s) already exist)", + ); + }); +}); + +describe("buildPlanPlannerPr", () => { + it("renders byte-identical plan title and commit message with template body", async () => { + const item = makeItem(); + const loadTemplate = vi.fn().mockResolvedValue("rendered template body"); + const result = await buildPlanPlannerPr(item, "F-PR", { + ...shape, + templatesDir: "/tmp/templates", + prTemplate: "finding-pr-inprogress-body.md", + loadTemplate, + }); + + expect(result.title).toBe("[AI:Finding] F-PR: F-PR title"); + expect(result.body).toBe("rendered template body"); + expect(result.commitMessage).toBe("Finding F-PR: planner emitted child item(s)"); + expect(loadTemplate).toHaveBeenCalledWith( + "/tmp/templates", + "finding-pr-inprogress-body.md", + { + FINDING_ID: "F-PR", + TYPE: "finding", + PRIORITY: "3", + SOURCE: "analyzer-x", + ASSUMPTION: "F-PR body paragraph.", + }, + ); + }); + + it("falls back to the in-progress stub when template loading fails", async () => { + const item = makeItem(); + const result = await buildPlanPlannerPr(item, "F-PR", { + ...shape, + templatesDir: "/tmp/templates", + prTemplate: "missing.md", + loadTemplate: vi.fn().mockRejectedValue(new Error("missing template")), + }); + + expect(result.body).toBe("## Finding F-PR\n\nIn progress."); + }); +}); + +describe("buildPlannerTerminalComment", () => { + it("renders byte-identical terminal comments for each disposition", () => { + expect(buildPlannerTerminalComment("Finding", "F-PR", "failed", "retries exhausted")) + .toBe("Finding **F-PR** failed: retries exhausted. Remove the failed label to retry."); + expect(buildPlannerTerminalComment("Finding", "F-PR", "cancelled", "stale")) + .toBe("Finding **F-PR** cancelled: stale. Closing PR — no retry will be attempted."); + expect(buildPlannerTerminalComment("Finding", "F-PR", "rejected", "scope")) + .toBe("Finding **F-PR** rejected: scope. Closing PR — the retrospective will regenerate a replacement item with updated scope."); + }); +}); diff --git a/engine/pipeline/composers/_shared/planner-pr-body.ts b/engine/pipeline/composers/_shared/planner-pr-body.ts new file mode 100644 index 0000000..26f7d12 --- /dev/null +++ b/engine/pipeline/composers/_shared/planner-pr-body.ts @@ -0,0 +1,107 @@ +import type { WorkItemFileData } from "../../../work-items/work-items.js"; + +export interface PlannerPrContent { + readonly title: string; + readonly body: string; + readonly commitMessage: string; +} + +export interface PlannerPrShape { + readonly prPrefix: string; + readonly displayName: string; + readonly idVarName: string; +} + +export interface PlanPrTemplateDeps extends PlannerPrShape { + readonly templatesDir: string; + readonly prTemplate: string; + readonly loadTemplate: ( + templatesDir: string, + template: string, + vars: Record, + ) => Promise; +} + +export function buildRejectionPlannerPr( + item: WorkItemFileData, + itemId: string, + rejection: { readonly agentRole: string; readonly reason: string }, + shape: PlannerPrShape, +): PlannerPrContent { + const title = `${shape.prPrefix} ${itemId}: REJECTED — ${item.title}`; + const body = [ + `## ${shape.displayName} ${itemId}: rejected by ${rejection.agentRole}`, + "", + `**Reason**: ${rejection.reason}`, + "", + `**What this PR does**: flips \`status: pending → rejected\` on the ${shape.displayName.toLowerCase()} file so the orchestrator stops re-picking this item.`, + "", + `**Original ${shape.displayName.toLowerCase()} body**:`, + "", + item.body.slice(0, 1500) + (item.body.length > 1500 ? "\n\n[…truncated]" : ""), + "", + "---", + "", + `**Reviewer action**: merge this PR to propagate the rejection to the base branch, or close-without-merge if you disagree — the supervisor will handle override on the next cycle.`, + ].join("\n"); + const commitMessage = `${shape.displayName} ${itemId}: rejected — ${rejection.reason.slice(0, 80)}`; + return { title, body, commitMessage }; +} + +export function buildCatchUpPlannerPr( + item: WorkItemFileData, + itemId: string, + childIds: ReadonlyArray, + shape: PlannerPrShape, +): PlannerPrContent { + const title = `${shape.prPrefix} ${itemId} (catch-up): ${item.title}`; + const body = [ + `## ${shape.displayName} ${itemId}: catch-up — planner skipped`, + "", + `**What this PR does**: flips \`status: pending → in-progress\` on the ${shape.displayName.toLowerCase()} file only. No new child items were emitted.`, + "", + `**Why planner was skipped**: ${childIds.length} child item(s) for this ${shape.displayName.toLowerCase()} already exist on the base branch:`, + "", + ...childIds.map((id) => `- \`${id}\``), + "", + `**Reviewer action**: safe to merge as-is — this is the idempotency contract bringing the recorded ${shape.displayName.toLowerCase()} status in line with prior planning.`, + ].join("\n"); + const commitMessage = `${shape.displayName} ${itemId}: catch-up status flip (${childIds.length} child item(s) already exist)`; + return { title, body, commitMessage }; +} + +export async function buildPlanPlannerPr( + item: WorkItemFileData, + itemId: string, + deps: PlanPrTemplateDeps, +): Promise { + const title = `${deps.prPrefix} ${itemId}: ${item.title}`; + const body = await deps + .loadTemplate(deps.templatesDir, deps.prTemplate, { + FINDING_ID: itemId, + [deps.idVarName]: itemId, + TYPE: item.kind, + PRIORITY: String(item.priority), + SOURCE: item.source ?? "unknown", + ASSUMPTION: item.body.slice(0, 500), + }) + .catch(() => `## ${deps.displayName} ${itemId}\n\nIn progress.`); + const commitMessage = `${deps.displayName} ${itemId}: planner emitted child item(s)`; + return { title, body, commitMessage }; +} + +export function buildPlannerTerminalComment( + displayName: string, + itemId: string, + disposition: "failed" | "cancelled" | "rejected", + reason: string, +): string { + switch (disposition) { + case "failed": + return `${displayName} **${itemId}** failed: ${reason}. Remove the failed label to retry.`; + case "cancelled": + return `${displayName} **${itemId}** cancelled: ${reason}. Closing PR — no retry will be attempted.`; + case "rejected": + return `${displayName} **${itemId}** rejected: ${reason}. Closing PR — the retrospective will regenerate a replacement item with updated scope.`; + } +} diff --git a/engine/pipeline/composers/aop-planner-after-agent.ts b/engine/pipeline/composers/aop-planner-after-agent.ts new file mode 100644 index 0000000..716586b --- /dev/null +++ b/engine/pipeline/composers/aop-planner-after-agent.ts @@ -0,0 +1,192 @@ +import type { OperationContext } from "@operator/core"; +import { errorMessage } from "@operator/core"; +import { formatDebugRunLinkSuffix } from "../../delivery/vcs-helpers.js"; +import { updateStatusAndSync } from "../../work-items/work-items.js"; +import type { StageDef, StageInput, AgentResult, Verdict } from "../types.js"; +import type { WorkspaceHandle } from "../primitives/workspace-scope.js"; +import { applyAgentEvents } from "../primitives/aop-applier.js"; +import { verifyHeadUnchanged } from "../primitives/head-snapshot-contract.js"; +import { aopPlannerScratch } from "./_shared/aop-planner-scratch.js"; +import { buildPlannerTerminalComment } from "./_shared/planner-pr-body.js"; +import type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; +import { StageLogicError } from "./errors.js"; + +export function buildAopPlannerAfterAgent(deps: AopPlannerHookDeps) { + return async ( + stage: StageDef, + input: StageInput, + agentResult: AgentResult, + _workspace: WorkspaceHandle, + ctx: OperationContext, + ): Promise<{ verdictOverride?: Verdict; summaryOverride?: string } | void> => { + const itemId = input.scopeKey; + const scratch = aopPlannerScratch.get(ctx, itemId); + if (!scratch) { + throw new StageLogicError( + "STAGE_SCRATCH_MISSING", + `${stage.name} afterAgent: missing scratch for ${itemId} — beforeAgent not run`, + ); + } + try { + // Idempotency path — beforeAgent's scan found existing child items. + if (scratch.alreadyPlannedChildren) { + if (scratch.item.status === "pending") { + await updateStatusAndSync(scratch.filePath, "in-progress", deps.state, ctx); + deps.log?.info( + `${stage.name}: ${itemId} idempotent refresh — status: pending → in-progress`, + { stage: stage.name, itemId, alreadyPlanned: true }, + ); + } + const count = scratch.alreadyPlannedChildren.length; + if (scratch.codeReviewId) { + await deps.prManager.postBotComment( + scratch.codeReviewId, + `${deps.displayName} **${itemId}** already has ${count} child item(s); planner skipped, status refreshed to \`in-progress\`.`, + ); + } + return { + summaryOverride: `${deps.displayName} ${itemId} already planned (${count} item(s)); status refreshed.`, + }; + } + + // HEAD-unchanged contract. + const headCheck = await verifyHeadUnchanged(deps.git, scratch.headSnapshot); + if (!headCheck.ok) { + const headChanged = new StageLogicError("HEAD_CHANGED", headCheck.message ?? "HEAD moved"); + deps.log?.error(`${stage.name}: ${deps.agentRole} violated read-only contract for ${itemId} (HEAD ${headCheck.preSha?.slice(0, 7)} → ${headCheck.postSha.slice(0, 7)})`, { + stage: stage.name, itemId, + preAgentHead: headCheck.preSha, postAgentHead: headCheck.postSha, + code: headChanged.code, + }); + await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); + return { + verdictOverride: "failed", + summaryOverride: headChanged.message, + }; + } + + // Pre-applier terminal verdicts. + if (agentResult.verdict !== "approved") { + const status = agentResult.verdict === "cancelled" ? "cancelled" + : agentResult.verdict === "rejected" ? "rejected" + : "failed"; + await updateStatusAndSync(scratch.filePath, status, deps.state, ctx); + if (scratch.codeReviewId) { + const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); + const body = buildPlannerTerminalComment(deps.displayName, itemId, status as "failed" | "cancelled" | "rejected", agentResult.summary) + suffix; + await deps.prManager.postBotComment(scratch.codeReviewId, body); + } + return; + } + + // AOP applier path. + const datePart = itemId.slice(deps.idPrefix.length, deps.idPrefix.length + 8); + const applied = await applyAgentEvents( + agentResult.output, + { + stream: deps.agentEventStream, + source: deps.workItemSource, + registry: deps.kindRegistry, + log: deps.log, + }, + { + workItem: { id: itemId, kind: deps.parentKind }, + date: datePart, + }, + ctx, + ); + deps.log?.info( + `${stage.name}: ${itemId} applier verdict=${applied.verdict}, child-items=${applied.applied.childItems.length}, parse-errors=${applied.diagnostics.filter((d) => d.severity === "error").length}, apply-errors=${applied.applyErrors.length}`, + { + stage: stage.name, itemId, plannerVerdict: applied.verdict, + childItems: applied.applied.childItems.length, + applyErrors: applied.applyErrors.length, + }, + ); + + if (applied.verdict === "rejected") { + // Agent rejected the item as invalid. Mark terminal, stash reason + // for buildPR, post bot comment. Human merges to propagate or + // closes-without-merge; supervisor handles override next cycle. + await updateStatusAndSync(scratch.filePath, "rejected", deps.state, ctx); + const reason = applied.summary || `${deps.displayName} ${itemId} marked invalid by ${deps.agentRole}`; + scratch.rejection = { agentRole: deps.agentRole, reason }; + if (scratch.codeReviewId) { + const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); + await deps.prManager.postBotComment( + scratch.codeReviewId, + `${deps.displayName} **${itemId}** determined invalid by ${deps.agentRole}: ${reason}${suffix}\n\nThe PR carries the \`status: rejected\` flip ready for review. Merge to propagate the rejection to the base branch, or close-without-merge if you disagree (the supervisor handles override on the next cycle).`, + ); + } + return { + verdictOverride: "rejected", + summaryOverride: reason, + }; + } + + if (applied.verdict === "failed") { + await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); + if (scratch.codeReviewId) { + const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); + await deps.prManager.postBotComment( + scratch.codeReviewId, + `${deps.displayName} **${itemId}** failed: ${applied.summary}.${suffix}`, + ); + } + return { + verdictOverride: "failed", + summaryOverride: applied.summary, + }; + } + + const createdIds = applied.applied.childItems.map((c) => c.id); + if (createdIds.length === 0) { + deps.log?.error(`${stage.name}: ${deps.agentRole} approved but no EMIT child-item records for ${itemId}`, { + stage: stage.name, itemId, + }); + await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); + if (scratch.codeReviewId) { + const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); + await deps.prManager.postBotComment( + scratch.codeReviewId, + `${deps.displayName} **${itemId}** failed: ${deps.agentRole} returned approved verdict without any EMIT child-item records.${suffix}`, + ); + } + return { + verdictOverride: "failed", + summaryOverride: `${deps.agentRole} approved without any EMIT child-item records`, + }; + } + + if (scratch.item.status === "pending") { + await updateStatusAndSync(scratch.filePath, "in-progress", deps.state, ctx); + deps.log?.info( + `${stage.name}: ${itemId} status: pending → in-progress (plan created)`, + { stage: stage.name, itemId, childrenCreated: createdIds.length }, + ); + } + + if (scratch.codeReviewId) { + const taskList = createdIds.map((id) => `- [ ] **${id}**`).join("\n"); + await deps.prManager.postBotComment( + scratch.codeReviewId, + `${deps.displayName} **${itemId}** verified. ${createdIds.length} item(s) created:\n\n${taskList}`, + ); + } + + deps.log?.info(`${stage.name}: ${itemId} valid, ${createdIds.length} children created`, { + stage: stage.name, itemId, childrenCreated: createdIds.length, childIds: createdIds, + }); + + return { + summaryOverride: `${deps.displayName} ${itemId} verified; ${createdIds.length} item(s) created: ${createdIds.join(", ")}`, + }; + } catch (err) { + deps.log?.error(`${stage.name}: afterAgent failed for ${itemId}`, { + stage: stage.name, itemId, error: errorMessage(err), + }); + throw err; + } + // Scratch cleared in buildPR.finally (last hook reading scratch.rejection). + }; +} diff --git a/engine/pipeline/composers/aop-planner-build-pr.ts b/engine/pipeline/composers/aop-planner-build-pr.ts new file mode 100644 index 0000000..e8fadd6 --- /dev/null +++ b/engine/pipeline/composers/aop-planner-build-pr.ts @@ -0,0 +1,70 @@ +import { join } from "node:path"; +import type { OperationContext } from "@operator/core"; +import { readWorkItemFile } from "../../work-items/work-items.js"; +import type { StageDef, StageInput } from "../types.js"; +import { aopPlannerScratch } from "./_shared/aop-planner-scratch.js"; +import type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; +import { + buildCatchUpPlannerPr, + buildPlanPlannerPr, + buildRejectionPlannerPr, +} from "./_shared/planner-pr-body.js"; + +export function buildAopPlannerBuildPR(deps: AopPlannerHookDeps) { + return async ( + _stage: StageDef, + input: StageInput, + ctx: OperationContext, + ): Promise<{ title: string; body: string; commitMessage: string; onSuccess?: "in-review" | "ready-to-merge" | "none" }> => { + const itemId = input.scopeKey; + const filePath = join(deps.parentDataDir, `${itemId}.md`); + const item = await readWorkItemFile(filePath); + + // afterAgent stashes rejection context in scratch when the agent + // determined the item is invalid. Render a rejection-specific PR so + // the human reviewer sees WHAT was rejected and WHY directly in the + // PR description — without needing to open the execution log. + const scratch = aopPlannerScratch.get(ctx, itemId); + const shape = { + prPrefix: deps.prPrefix, + displayName: deps.displayName, + idVarName: deps.idVarName, + }; + try { + if (scratch?.rejection) { + const { title, body, commitMessage } = buildRejectionPlannerPr( + item, itemId, scratch.rejection, shape, + ); + return { title, body, commitMessage, onSuccess: "in-review" }; + } + + // Catch-up path: idempotency scan found pre-existing child items on + // the base branch, planner was skipped (see synthesizeAgentResult). + // Diff carries only the parent's frontmatter flip — no new child + // files. Render a self-describing body so the human reviewer does + // NOT have to guess "is this a plan or a status-only bump?". + if (scratch?.alreadyPlannedChildren && scratch.alreadyPlannedChildren.length > 0) { + const { title, body, commitMessage } = buildCatchUpPlannerPr( + item, itemId, scratch.alreadyPlannedChildren, shape, + ); + return { title, body, commitMessage, onSuccess: "in-review" }; + } + + // Plan path: planner ran and emitted new child items. The standard + // in-progress template covers this case — assumption + metadata. + const { title, body, commitMessage } = await buildPlanPlannerPr(item, itemId, { + ...shape, + templatesDir: deps.templatesDir, + prTemplate: deps.prTemplate, + loadTemplate: (templatesDir, template, vars) => deps.prManager.loadTemplate(templatesDir, template, vars), + }); + return { title, body, commitMessage, onSuccess: "in-review" }; + } finally { + // buildPR is the last hook that reads scratch — clear here to bound + // store lifetime to a single cycle. Moved from afterAgent.finally + // 2026-05-13 when Fix 8 made buildPR depend on scratch.rejection + // (set by afterAgent on the rejected path). + aopPlannerScratch.clear(ctx, itemId); + } + }; +} diff --git a/engine/pipeline/composers/aop-planner-build-run-input.ts b/engine/pipeline/composers/aop-planner-build-run-input.ts new file mode 100644 index 0000000..3bc7dcd --- /dev/null +++ b/engine/pipeline/composers/aop-planner-build-run-input.ts @@ -0,0 +1,69 @@ +import { join } from "node:path"; +import type { OperationContext } from "@operator/core"; +import type { AgentRunInput } from "../../agents/runtime.js"; +import { resolveRole, buildRunInput } from "../../agents/roles.js"; +import { readWorkItemFile } from "../../work-items/work-items.js"; +import type { StageDef, StageInput } from "../types.js"; +import type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; + +export function buildAopPlannerBuildRunInput(deps: AopPlannerHookDeps) { + return async ( + _stage: StageDef, + input: StageInput, + _ctx: OperationContext, + ): Promise => { + const itemId = input.scopeKey; + const filePath = join(deps.parentDataDir, `${itemId}.md`); + const item = await readWorkItemFile(filePath); + const role = resolveRole(deps.agentsConfig, deps.agentRole); + const reviewCriteria = role.review + ? await deps.promptSource.loadChain(`verifier/${deps.verifierTopic}`) + : undefined; + + // itemId conforms to {idPrefix}{date}-{seq} — slice 1..9 = date, + // split on `-`[1] = seq. The idPrefix length is fixed at 1 by the + // current id-pattern convention. + const datePart = itemId.slice(deps.idPrefix.length, deps.idPrefix.length + 8); + const itemSeq = itemId.split("-")[1] ?? "0001"; + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + + const { buildExecutionHistoryBlock } = await import("../primitives/execution-context.js"); + const historyBlock = await buildExecutionHistoryBlock(deps.kv, itemId); + const taskContent = historyBlock + ? `${historyBlock}\n${item.body}` + : item.body; + + // Pass through the parent finding's path (either explicit + // frontmatter `path:` or the body-derived heuristic via + // `derivePathFromBody` — picks up the `**Domain**` field findings + // emit). Without this, the prompt-builder's layers 3 + 5 cannot + // filter `.operator/context/{backend,frontend}.md` and every + // finding-plan execution carries both contexts (~6k extra chars + // per call). Symmetrical to the wiring in + // `verifier-driven-creator-stage.beforeAgent`. + const itemPath = item.path ?? undefined; + + return buildRunInput( + role, + { + promptSource: deps.promptSource, + automationDir: deps.automationDir, + vars: { + [deps.idVarName]: itemId, + DATE: datePart, + [deps.seqVarName]: itemSeq, + TIMESTAMP: timestamp, + ...deps.stateVars, + }, + rulesFrom: itemPath ? deps.agentRole : undefined, + contextPath: itemPath, + }, + { + taskContent, + cwd: deps.workspacePath, + maxRetries: 2, + reviewCriteria, + }, + ); + }; +} diff --git a/engine/pipeline/composers/aop-planner-stage.ts b/engine/pipeline/composers/aop-planner-stage.ts index afe0e39..ecbd908 100644 --- a/engine/pipeline/composers/aop-planner-stage.ts +++ b/engine/pipeline/composers/aop-planner-stage.ts @@ -1,32 +1,22 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { - OperationContext, StateManager, VCSPlatform, KindRegistry, - WorkItemSource, AgentEventStream, AgentRoleName, WorkItemKind, -} from "@operator/core"; -import { errorMessage } from "@operator/core"; -import type { AgentRunInput } from "../../agents/runtime.js"; -import type { AgentsFile } from "../../config/schemas.js"; -import type { PromptSource } from "@operator/core"; -import type { PRManager } from "../../delivery/pr-manager.js"; -import type { WorkspaceGit } from "../../infra/git.js"; -import type { Logger } from "../../logging/logger.js"; -import { resolveRole, buildRunInput } from "../../agents/roles.js"; -import { findCodeReviewForBranch, formatDebugRunLinkSuffix } from "../../delivery/vcs-helpers.js"; +import type { OperationContext } from "@operator/core"; +import { findCodeReviewForBranch } from "../../delivery/vcs-helpers.js"; import { - readWorkItemFile, updateWorkItemFileStatus, updateStatusAndSync, - type StateContextVars, type WorkItemFileData, + readWorkItemFile, updateWorkItemFileStatus, } from "../../work-items/work-items.js"; -import type { StageDef, StageInput, AgentResult, Verdict } from "../types.js"; +import type { StageDef, StageInput } from "../types.js"; import type { WorkspaceHandle } from "../primitives/workspace-scope.js"; -import { applyAgentEvents } from "../primitives/aop-applier.js"; import { findChildrenByParentId } from "../primitives/idempotency-scan.js"; -import { - captureHeadSnapshot, verifyHeadUnchanged, - type HeadSnapshot, -} from "../primitives/head-snapshot-contract.js"; -import { createScratchStore } from "./_shared/scratch.js"; -import { StageLogicError } from "./errors.js"; +import { captureHeadSnapshot } from "../primitives/head-snapshot-contract.js"; +import { aopPlannerScratch } from "./_shared/aop-planner-scratch.js"; +import type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; + +export type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; +export { buildAopPlannerAfterAgent } from "./aop-planner-after-agent.js"; +export { buildAopPlannerSynthesizeAgentResult } from "./aop-planner-synthesize.js"; +export { buildAopPlannerBuildRunInput } from "./aop-planner-build-run-input.js"; +export { buildAopPlannerBuildPR } from "./aop-planner-build-pr.js"; /** * Generic stage composer for the "AOP planner" pattern. @@ -58,71 +48,6 @@ import { StageLogicError } from "./errors.js"; * {@link AopPlannerHookDeps}. */ -interface AopPlannerScratch { - readonly itemId: string; - readonly filePath: string; - readonly item: WorkItemFileData; - readonly codeReviewId: number | null; - readonly headSnapshot: HeadSnapshot; - readonly alreadyPlannedChildren?: ReadonlyArray; - /** - * Set by `afterAgent` on the rejected verdict path. `buildPR` reads this - * to produce a rejection-specific PR title (REJECTED suffix) + body that - * carries the agent's reasoning, instead of the standard in-progress - * template. The human reviewer sees the rejection explanation directly - * in the PR description without opening the execution log. - */ - rejection?: { agentRole: string; reason: string }; -} - -const aopPlannerScratch = createScratchStore(); - -export interface AopPlannerHookDeps { - readonly state: StateManager; - readonly vcs: VCSPlatform; - readonly prManager: PRManager; - readonly git: WorkspaceGit; - readonly kindRegistry: KindRegistry; - /** Storage directory for parent work-items (e.g. the findings dir). */ - readonly parentDataDir: string; - /** Storage directory for child work-items (e.g. the tasks dir). */ - readonly childDataDir: string; - readonly automationDir: string; - readonly workspacePath: string; - readonly templatesDir: string; - readonly agentsConfig: AgentsFile; - readonly promptSource: PromptSource; - readonly workItemSource: WorkItemSource; - readonly agentEventStream: AgentEventStream; - readonly stateVars?: StateContextVars; - readonly log?: Logger; - readonly debug?: boolean; - readonly debugRunUrl?: string; - readonly kv?: import("@operator/core").KVStore; - - // ── Stage-shape parameters ──────────────────────────────────────── - /** Parent work-item kind (e.g. `"finding"`). */ - readonly parentKind: WorkItemKind; - /** Planner agent role (e.g. `"planner"`). */ - readonly agentRole: AgentRoleName; - /** Verifier chain topic suffix, used as `verifier/{verifierTopic}`. */ - readonly verifierTopic: string; - /** Branch prefix for the parent's PRs (e.g. `"ai/findings"`). */ - readonly branchPrefix: string; - /** PR title prefix (e.g. `"[AI:Finding]"`). */ - readonly prPrefix: string; - /** In-progress PR body template filename. */ - readonly prTemplate: string; - /** Human-facing display name (e.g. `"Finding"`). */ - readonly displayName: string; - /** ID prefix for parent items (e.g. `"F"`) — used for parsing date / seq. */ - readonly idPrefix: string; - /** Prompt variable name for the parent id (e.g. `"FINDING_ID"`). */ - readonly idVarName: string; - /** Prompt variable name for the seq (e.g. `"FINDING_SEQ"`). */ - readonly seqVarName: string; -} - export function buildAopPlannerBeforeAgent(deps: AopPlannerHookDeps) { return async ( stage: StageDef, @@ -184,378 +109,3 @@ export function buildAopPlannerBeforeAgent(deps: AopPlannerHookDeps) { } }; } - -export function buildAopPlannerSynthesizeAgentResult(_deps: AopPlannerHookDeps) { - return async ( - _stage: StageDef, - input: StageInput, - _workspace: WorkspaceHandle, - ctx: OperationContext, - ): Promise => { - const itemId = input.scopeKey; - const scratch = aopPlannerScratch.get(ctx, itemId); - if (!scratch || !scratch.alreadyPlannedChildren) return null; - const count = scratch.alreadyPlannedChildren.length; - return { - verdict: "approved", - output: `=== EMIT verdict ===\nvalue: approved\nsummary: Item ${itemId} already has ${count} child item(s); planner skipped.\n=== END EMIT ===`, - attempts: 0, - summary: `[idempotency] Item ${itemId} already planned (${count} child item(s) exist); skipped planner re-run.`, - }; - }; -} - -export function buildAopPlannerBuildRunInput(deps: AopPlannerHookDeps) { - return async ( - _stage: StageDef, - input: StageInput, - _ctx: OperationContext, - ): Promise => { - const itemId = input.scopeKey; - const filePath = join(deps.parentDataDir, `${itemId}.md`); - const item = await readWorkItemFile(filePath); - const role = resolveRole(deps.agentsConfig, deps.agentRole); - const reviewCriteria = role.review - ? await deps.promptSource.loadChain(`verifier/${deps.verifierTopic}`) - : undefined; - - // itemId conforms to {idPrefix}{date}-{seq} — slice 1..9 = date, - // split on `-`[1] = seq. The idPrefix length is fixed at 1 by the - // current id-pattern convention. - const datePart = itemId.slice(deps.idPrefix.length, deps.idPrefix.length + 8); - const itemSeq = itemId.split("-")[1] ?? "0001"; - const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - - const { buildExecutionHistoryBlock } = await import("../primitives/execution-context.js"); - const historyBlock = await buildExecutionHistoryBlock(deps.kv, itemId); - const taskContent = historyBlock - ? `${historyBlock}\n${item.body}` - : item.body; - - // Pass through the parent finding's path (either explicit - // frontmatter `path:` or the body-derived heuristic via - // `derivePathFromBody` — picks up the `**Domain**` field findings - // emit). Without this, the prompt-builder's layers 3 + 5 cannot - // filter `.operator/context/{backend,frontend}.md` and every - // finding-plan execution carries both contexts (~6k extra chars - // per call). Symmetrical to the wiring in - // `verifier-driven-creator-stage.beforeAgent`. - const itemPath = item.path ?? undefined; - - return buildRunInput( - role, - { - promptSource: deps.promptSource, - automationDir: deps.automationDir, - vars: { - [deps.idVarName]: itemId, - DATE: datePart, - [deps.seqVarName]: itemSeq, - TIMESTAMP: timestamp, - ...deps.stateVars, - }, - rulesFrom: itemPath ? deps.agentRole : undefined, - contextPath: itemPath, - }, - { - taskContent, - cwd: deps.workspacePath, - maxRetries: 2, - reviewCriteria, - }, - ); - }; -} - -export function buildAopPlannerBuildPR(deps: AopPlannerHookDeps) { - return async ( - _stage: StageDef, - input: StageInput, - ctx: OperationContext, - ): Promise<{ title: string; body: string; commitMessage: string; onSuccess?: "in-review" | "ready-to-merge" | "none" }> => { - const itemId = input.scopeKey; - const filePath = join(deps.parentDataDir, `${itemId}.md`); - const item = await readWorkItemFile(filePath); - - // afterAgent stashes rejection context in scratch when the agent - // determined the item is invalid. Render a rejection-specific PR so - // the human reviewer sees WHAT was rejected and WHY directly in the - // PR description — without needing to open the execution log. - const scratch = aopPlannerScratch.get(ctx, itemId); - try { - if (scratch?.rejection) { - const title = `${deps.prPrefix} ${itemId}: REJECTED — ${item.title}`; - const body = [ - `## ${deps.displayName} ${itemId}: rejected by ${scratch.rejection.agentRole}`, - "", - `**Reason**: ${scratch.rejection.reason}`, - "", - `**What this PR does**: flips \`status: pending → rejected\` on the ${deps.displayName.toLowerCase()} file so the orchestrator stops re-picking this item.`, - "", - `**Original ${deps.displayName.toLowerCase()} body**:`, - "", - item.body.slice(0, 1500) + (item.body.length > 1500 ? "\n\n[…truncated]" : ""), - "", - "---", - "", - `**Reviewer action**: merge this PR to propagate the rejection to develop, or close-without-merge if you disagree — the supervisor will handle override on the next cycle.`, - ].join("\n"); - const commitMessage = `${deps.displayName} ${itemId}: rejected — ${scratch.rejection.reason.slice(0, 80)}`; - return { title, body, commitMessage, onSuccess: "in-review" }; - } - - // Catch-up path: idempotency scan found pre-existing child items on - // the base branch, planner was skipped (see synthesizeAgentResult). - // Diff carries only the parent's frontmatter flip — no new child - // files. Render a self-describing body so the human reviewer does - // NOT have to guess "is this a plan or a status-only bump?". - if (scratch?.alreadyPlannedChildren && scratch.alreadyPlannedChildren.length > 0) { - const childIds = scratch.alreadyPlannedChildren; - const title = `${deps.prPrefix} ${itemId} (catch-up): ${item.title}`; - const body = [ - `## ${deps.displayName} ${itemId}: catch-up — planner skipped`, - "", - `**What this PR does**: flips \`status: pending → in-progress\` on the ${deps.displayName.toLowerCase()} file only. No new child items were emitted.`, - "", - `**Why planner was skipped**: ${childIds.length} child item(s) for this ${deps.displayName.toLowerCase()} already exist on the base branch:`, - "", - ...childIds.map((id) => `- \`${id}\``), - "", - `**Reviewer action**: safe to merge as-is — this is the idempotency contract bringing the recorded ${deps.displayName.toLowerCase()} status in line with prior planning.`, - ].join("\n"); - const commitMessage = `${deps.displayName} ${itemId}: catch-up status flip (${childIds.length} child item(s) already exist)`; - return { title, body, commitMessage, onSuccess: "in-review" }; - } - - // Plan path: planner ran and emitted new child items. The standard - // in-progress template covers this case — assumption + metadata. - const title = `${deps.prPrefix} ${itemId}: ${item.title}`; - const body = await deps.prManager - .loadTemplate(deps.templatesDir, deps.prTemplate, { - FINDING_ID: itemId, - [deps.idVarName]: itemId, - TYPE: item.kind, - PRIORITY: String(item.priority), - SOURCE: item.source ?? "unknown", - ASSUMPTION: item.body.slice(0, 500), - }) - .catch(() => `## ${deps.displayName} ${itemId}\n\nIn progress.`); - - const commitMessage = `${deps.displayName} ${itemId}: planner emitted child item(s)`; - return { title, body, commitMessage, onSuccess: "in-review" }; - } finally { - // buildPR is the last hook that reads scratch — clear here to bound - // store lifetime to a single cycle. Moved from afterAgent.finally - // 2026-05-13 when Fix 8 made buildPR depend on scratch.rejection - // (set by afterAgent on the rejected path). - aopPlannerScratch.clear(ctx, itemId); - } - }; -} - -export function buildAopPlannerAfterAgent(deps: AopPlannerHookDeps) { - return async ( - stage: StageDef, - input: StageInput, - agentResult: AgentResult, - _workspace: WorkspaceHandle, - ctx: OperationContext, - ): Promise<{ verdictOverride?: Verdict; summaryOverride?: string } | void> => { - const itemId = input.scopeKey; - const scratch = aopPlannerScratch.get(ctx, itemId); - if (!scratch) { - throw new StageLogicError( - "STAGE_SCRATCH_MISSING", - `${stage.name} afterAgent: missing scratch for ${itemId} — beforeAgent not run`, - ); - } - try { - // Idempotency path — beforeAgent's scan found existing child items. - if (scratch.alreadyPlannedChildren) { - if (scratch.item.status === "pending") { - await updateStatusAndSync(scratch.filePath, "in-progress", deps.state, ctx); - deps.log?.info( - `${stage.name}: ${itemId} idempotent refresh — status: pending → in-progress`, - { stage: stage.name, itemId, alreadyPlanned: true }, - ); - } - const count = scratch.alreadyPlannedChildren.length; - if (scratch.codeReviewId) { - await deps.prManager.postBotComment( - scratch.codeReviewId, - `${deps.displayName} **${itemId}** already has ${count} child item(s); planner skipped, status refreshed to \`in-progress\`.`, - ); - } - return { - summaryOverride: `${deps.displayName} ${itemId} already planned (${count} item(s)); status refreshed.`, - }; - } - - // HEAD-unchanged contract. - const headCheck = await verifyHeadUnchanged(deps.git, scratch.headSnapshot); - if (!headCheck.ok) { - const headChanged = new StageLogicError("HEAD_CHANGED", headCheck.message ?? "HEAD moved"); - deps.log?.error(`${stage.name}: ${deps.agentRole} violated read-only contract for ${itemId} (HEAD ${headCheck.preSha?.slice(0, 7)} → ${headCheck.postSha.slice(0, 7)})`, { - stage: stage.name, itemId, - preAgentHead: headCheck.preSha, postAgentHead: headCheck.postSha, - code: headChanged.code, - }); - await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); - return { - verdictOverride: "failed", - summaryOverride: headChanged.message, - }; - } - - // Pre-applier terminal verdicts. - if (agentResult.verdict !== "approved") { - const status = agentResult.verdict === "cancelled" ? "cancelled" - : agentResult.verdict === "rejected" ? "rejected" - : "failed"; - await updateStatusAndSync(scratch.filePath, status, deps.state, ctx); - if (scratch.codeReviewId) { - const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); - const body = buildTerminalComment(deps.displayName, itemId, status as "failed" | "cancelled" | "rejected", agentResult.summary) + suffix; - await deps.prManager.postBotComment(scratch.codeReviewId, body); - } - return; - } - - // AOP applier path. - const datePart = itemId.slice(deps.idPrefix.length, deps.idPrefix.length + 8); - const applied = await applyAgentEvents( - agentResult.output, - { - stream: deps.agentEventStream, - source: deps.workItemSource, - registry: deps.kindRegistry, - log: deps.log, - }, - { - workItem: { id: itemId, kind: deps.parentKind }, - date: datePart, - }, - ctx, - ); - deps.log?.info( - `${stage.name}: ${itemId} applier verdict=${applied.verdict}, child-items=${applied.applied.childItems.length}, parse-errors=${applied.diagnostics.filter((d) => d.severity === "error").length}, apply-errors=${applied.applyErrors.length}`, - { - stage: stage.name, itemId, plannerVerdict: applied.verdict, - childItems: applied.applied.childItems.length, - applyErrors: applied.applyErrors.length, - }, - ); - - if (applied.verdict === "rejected") { - // Rejection is a SUCCESS for the agent — it correctly identified - // a false-positive / obsolete / invalid item. Mark the item - // terminal (rejected) in state; persist will then commit the - // status flip + create the PR which acts as a normal data-sync - // vehicle awaiting human review. PR body explains the rejection - // reasoning (rendered by `buildPR` reading `scratch.rejection`). - // NO auto-close per MVP rules (user explicit guidance: never - // auto-close PRs unless stage config declares it). The human - // reviewer either merges the PR (propagating rejection to - // develop) or closes-without-merge to override the rejection; - // supervisor handles human override decisions on the next cycle. - await updateStatusAndSync(scratch.filePath, "rejected", deps.state, ctx); - const reason = applied.summary || `${deps.displayName} ${itemId} marked invalid by ${deps.agentRole}`; - // Stash rejection context so buildPR can produce a rejection-specific - // PR title + body instead of the standard in-progress template. - scratch.rejection = { agentRole: deps.agentRole, reason }; - if (scratch.codeReviewId) { - const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); - await deps.prManager.postBotComment( - scratch.codeReviewId, - `${deps.displayName} **${itemId}** determined invalid by ${deps.agentRole}: ${reason}${suffix}\n\nThe PR carries the \`status: rejected\` flip ready for review. Merge to propagate the rejection to develop, or close-without-merge if you disagree (the supervisor handles override on the next cycle).`, - ); - } - return { - verdictOverride: "rejected", - summaryOverride: reason, - }; - } - - if (applied.verdict === "failed") { - await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); - if (scratch.codeReviewId) { - const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); - await deps.prManager.postBotComment( - scratch.codeReviewId, - `${deps.displayName} **${itemId}** failed: ${applied.summary}.${suffix}`, - ); - } - return { - verdictOverride: "failed", - summaryOverride: applied.summary, - }; - } - - const createdIds = applied.applied.childItems.map((c) => c.id); - if (createdIds.length === 0) { - deps.log?.error(`${stage.name}: ${deps.agentRole} approved but no EMIT child-item records for ${itemId}`, { - stage: stage.name, itemId, - }); - await updateStatusAndSync(scratch.filePath, "failed", deps.state, ctx); - if (scratch.codeReviewId) { - const suffix = formatDebugRunLinkSuffix(deps.debug, deps.debugRunUrl); - await deps.prManager.postBotComment( - scratch.codeReviewId, - `${deps.displayName} **${itemId}** failed: ${deps.agentRole} returned approved verdict without any EMIT child-item records.${suffix}`, - ); - } - return { - verdictOverride: "failed", - summaryOverride: `${deps.agentRole} approved without any EMIT child-item records`, - }; - } - - if (scratch.item.status === "pending") { - await updateStatusAndSync(scratch.filePath, "in-progress", deps.state, ctx); - deps.log?.info( - `${stage.name}: ${itemId} status: pending → in-progress (plan created)`, - { stage: stage.name, itemId, childrenCreated: createdIds.length }, - ); - } - - if (scratch.codeReviewId) { - const taskList = createdIds.map((id) => `- [ ] **${id}**`).join("\n"); - await deps.prManager.postBotComment( - scratch.codeReviewId, - `${deps.displayName} **${itemId}** verified. ${createdIds.length} item(s) created:\n\n${taskList}`, - ); - } - - deps.log?.info(`${stage.name}: ${itemId} valid, ${createdIds.length} children created`, { - stage: stage.name, itemId, childrenCreated: createdIds.length, childIds: createdIds, - }); - - return { - summaryOverride: `${deps.displayName} ${itemId} verified; ${createdIds.length} item(s) created: ${createdIds.join(", ")}`, - }; - } catch (err) { - deps.log?.error(`${stage.name}: afterAgent failed for ${itemId}`, { - stage: stage.name, itemId, error: errorMessage(err), - }); - throw err; - } - // Scratch cleared in buildPR's finally — buildPR is the last hook - // that reads scratch (it consumes scratch.rejection set above on the - // rejected path to render REJECTED title + reason in the PR body). - // Matches the pattern used by discovery-iteration-stage + weekly- - // metrics-stage. If buildPR is somehow skipped, the entry sticks - // around for the current cycle's traceId and is GC'd when ctx goes - // out of scope (next cycle uses a fresh traceId, no leak). - }; -} - -function buildTerminalComment(displayName: string, itemId: string, disposition: "failed" | "cancelled" | "rejected", reason: string): string { - switch (disposition) { - case "failed": - return `${displayName} **${itemId}** failed: ${reason}. Remove the failed label to retry.`; - case "cancelled": - return `${displayName} **${itemId}** cancelled: ${reason}. Closing PR — no retry will be attempted.`; - case "rejected": - return `${displayName} **${itemId}** rejected: ${reason}. Closing PR — the retrospective will regenerate a replacement item with updated scope.`; - } -} diff --git a/engine/pipeline/composers/aop-planner-synthesize.ts b/engine/pipeline/composers/aop-planner-synthesize.ts new file mode 100644 index 0000000..547b2b5 --- /dev/null +++ b/engine/pipeline/composers/aop-planner-synthesize.ts @@ -0,0 +1,25 @@ +import type { OperationContext } from "@operator/core"; +import type { StageDef, StageInput, AgentResult } from "../types.js"; +import type { WorkspaceHandle } from "../primitives/workspace-scope.js"; +import { aopPlannerScratch } from "./_shared/aop-planner-scratch.js"; +import type { AopPlannerHookDeps } from "./_shared/aop-planner-deps.js"; + +export function buildAopPlannerSynthesizeAgentResult(_deps: AopPlannerHookDeps) { + return async ( + _stage: StageDef, + input: StageInput, + _workspace: WorkspaceHandle, + ctx: OperationContext, + ): Promise => { + const itemId = input.scopeKey; + const scratch = aopPlannerScratch.get(ctx, itemId); + if (!scratch || !scratch.alreadyPlannedChildren) return null; + const count = scratch.alreadyPlannedChildren.length; + return { + verdict: "approved", + output: `=== EMIT verdict ===\nvalue: approved\nsummary: Item ${itemId} already has ${count} child item(s); planner skipped.\n=== END EMIT ===`, + attempts: 0, + summary: `[idempotency] Item ${itemId} already planned (${count} child item(s) exist); skipped planner re-run.`, + }; + }; +}