Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .operator/data/tasks/T20260705-8B7B4079.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions engine/pipeline/composers/_shared/aop-planner-deps.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 22 additions & 0 deletions engine/pipeline/composers/_shared/aop-planner-scratch.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
/**
* 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<AopPlannerScratch>();
148 changes: 148 additions & 0 deletions engine/pipeline/composers/_shared/planner-pr-body.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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.");
});
});
107 changes: 107 additions & 0 deletions engine/pipeline/composers/_shared/planner-pr-body.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
) => Promise<string>;
}

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<string>,
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<PlannerPrContent> {
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.`;
}
}
Loading
Loading