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
3 changes: 3 additions & 0 deletions skills/piv-plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ You are the planning agent.
- `COMPLEXITY_SCORE: 0..10` (integer)
- `< 5`: bot review can run
- `>= 5`: requires human review (email notification + pause automated review)
- `ISSUE_REFINEMENT_JSON: {"title":"...","description":"..."}`
- Both fields must be non-empty strings.
- Preserve user intent; do not invent scope or requirements.
- Optional decomposition contract when task is too complex for one pass:
- If `COMPLEX`, include `SPLIT_TASKS_JSON: [...]` with a non-empty JSON array.
- Each split task object:
Expand Down
136 changes: 136 additions & 0 deletions src/core/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
AgentChatLogEntry,
AgentChatLogRole,
CodexUsageRecord,
IssueRef,
PlannedSplitTask,
PollingConfig,
ResolvedNotificationConfig,
Expand Down Expand Up @@ -750,6 +751,7 @@ async function handlePlanningStage(
state.codexSessionId = result.sessionId ?? state.codexSessionId;
state.planSummary = result.finalMessage || result.stdout;
appendCodexUsage(state, "planning", result.usage);
await applyPlannerIssueRefinement(linear, state.issue, state.planSummary);

const parsedPlan = parsePlannerDecision(state.planSummary);
state.complexityScore = parsedPlan.complexityScore;
Expand Down Expand Up @@ -1184,6 +1186,11 @@ export interface PlannerDecision {
complexityScore: number;
}

export interface PlannerIssueRefinement {
title: string;
description: string;
}

export function parsePlannerDecision(planSummary: string): PlannerDecision {
const complexity = parsePlannerComplexity(planSummary);
const complexityScore = parsePlannerComplexityScore(planSummary);
Expand Down Expand Up @@ -1237,6 +1244,90 @@ export function parsePlannerComplexityScore(planSummary: string): number {
return score;
}

export function parsePlannerIssueRefinement(
planSummary: string,
): PlannerIssueRefinement | null {
const marker = /\bISSUE_REFINEMENT_JSON\s*:/i;
const markerMatch = marker.exec(planSummary);
if (!markerMatch) {
return null;
}

const markerStart = markerMatch.index + markerMatch[0].length;
const rawPayload = planSummary.slice(markerStart).trim();
if (!rawPayload) {
throw new Error(
"Planner included ISSUE_REFINEMENT_JSON marker but no JSON payload.",
);
}

const jsonSource = unwrapFencedCodeBlock(rawPayload);
const jsonObjectText = extractFirstJsonObject(jsonSource);
if (!jsonObjectText) {
throw new Error(
"ISSUE_REFINEMENT_JSON must contain a JSON object with title and description.",
);
}

let parsed: unknown;
try {
parsed = JSON.parse(jsonObjectText);
} catch (error) {
throw new Error(
`Failed to parse ISSUE_REFINEMENT_JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("ISSUE_REFINEMENT_JSON must be a JSON object.");
}

const record = parsed as Record<string, unknown>;
const title =
typeof record.title === "string" ? record.title.trim() : undefined;
const description =
typeof record.description === "string"
? record.description.trim()
: undefined;
if (!title) {
throw new Error("ISSUE_REFINEMENT_JSON.title must be a non-empty string.");
}
if (!description) {
throw new Error(
"ISSUE_REFINEMENT_JSON.description must be a non-empty string.",
);
}

return { title, description };
}

export async function applyPlannerIssueRefinement(
linear: Pick<LinearClient, "updateIssueDetails">,
issue: IssueRef,
planSummary: string,
): Promise<boolean> {
const refinement = parsePlannerIssueRefinement(planSummary);
if (!refinement) {
return false;
}

const currentDescription = issue.description?.trim() ?? "";
if (
issue.title.trim() === refinement.title &&
currentDescription === refinement.description
) {
return false;
}

await linear.updateIssueDetails(
issue.id,
refinement.title,
refinement.description,
);
issue.title = refinement.title;
issue.description = refinement.description;
return true;
}

export function resolveReviewModeForComplexityScore(
complexityScore: number,
): "bot" | "human" {
Expand Down Expand Up @@ -1419,6 +1510,51 @@ function extractFirstJsonArray(input: string): string | null {
return null;
}

function extractFirstJsonObject(input: string): string | null {
const start = input.indexOf("{");
if (start === -1) {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < input.length; i += 1) {
const char = input[i];
if (!char) {
continue;
}
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return input.slice(start, i + 1);
}
}
}
return null;
}

export function buildRunLeaseOwnerId(nowMs = Date.now()): string {
return `${process.pid}-${nowMs}-${Math.floor(Math.random() * 100000)}`;
}
Expand Down
14 changes: 14 additions & 0 deletions src/services/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ export class LinearClient {
await this.client.updateIssue(issueId, { stateId });
}

async updateIssueDetails(
issueId: string,
title: string,
description: string,
): Promise<void> {
if (this.config.dryRun) {
return;
}
await this.client.updateIssue(issueId, {
title,
description,
});
}

async createTodoIssueFromPlan(
parentIssue: ParentIssueRef,
task: PlannedSplitTask,
Expand Down
1 change: 1 addition & 0 deletions src/skills/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export async function buildPlanPrompt(
`URL: ${issue.url}`,
supplementalSection,
"",
"Include ISSUE_REFINEMENT_JSON with a refined title and description that preserve original user intent and do not invent scope.",
"When including SPLIT_TASKS_JSON, write action-oriented task titles and clear descriptions that include expected behavior, implementation scope, and tests.",
"Create a concrete implementation plan and include risks and tests.",
].join("\n");
Expand Down
2 changes: 2 additions & 0 deletions tests/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe("buildPlanPrompt", () => {
expect(prompt).toContain("COMPLEXITY: SIMPLE|COMPLEX");
expect(prompt).toContain("COMPLEXITY_SCORE: 0..10");
expect(prompt).toContain("SPLIT_TASKS_JSON: [...]");
expect(prompt).toContain("ISSUE_REFINEMENT_JSON");
expect(prompt).toContain(
"When including SPLIT_TASKS_JSON, write action-oriented task titles",
);
Expand Down Expand Up @@ -97,6 +98,7 @@ describe("buildPlanPrompt", () => {

expect(prompt).toContain("Description: Planning should auto-select");
expect(prompt).toContain("Auto-selected supplemental skills:");
expect(prompt).toContain("Include ISSUE_REFINEMENT_JSON");
expect(prompt).toContain("1. linear");
expect(prompt).toContain("source: folder");
expect(prompt).toContain("score: 9");
Expand Down
126 changes: 126 additions & 0 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import os from "node:os";
import path from "node:path";
import { agentChatLogPath } from "../src/core/state";
import type {
IssueRef,
PollingConfig,
ResolvedProjectConfig,
RunState,
} from "../src/core/types";
import {
appendCodexUsage,
applyPlannerIssueRefinement,
buildIssueJobLogFields,
buildPrioritizedIssueQueue,
buildRunLeaseOwnerId,
Expand All @@ -18,6 +20,7 @@ import {
normalizeFailedReviewBugs,
parsePlannerComplexityScore,
parsePlannerDecision,
parsePlannerIssueRefinement,
readyPullRequestAfterPassingReview,
resolvePollingSettings,
resolveReviewModeForComplexityScore,
Expand Down Expand Up @@ -736,6 +739,129 @@ describe("parsePlannerComplexityScore", () => {
});
});

describe("parsePlannerIssueRefinement", () => {
it("returns null when refinement marker is missing", () => {
expect(parsePlannerIssueRefinement("no refinement provided")).toBeNull();
});

it("parses valid refinement payload", () => {
const result = parsePlannerIssueRefinement(
[
"ISSUE_REFINEMENT_JSON:",
"```json",
JSON.stringify(
{
title: "Refined issue title",
description: "Refined issue description",
},
null,
2,
),
"```",
].join("\n"),
);
expect(result).toEqual({
title: "Refined issue title",
description: "Refined issue description",
});
});

it("throws when refinement marker is present but object payload is missing", () => {
expect(() =>
parsePlannerIssueRefinement(
["ISSUE_REFINEMENT_JSON:", "```json", "{", "```"].join("\n"),
),
).toThrow("must contain a JSON object");
});

it("throws when title or description is empty", () => {
expect(() =>
parsePlannerIssueRefinement(
[
"ISSUE_REFINEMENT_JSON:",
JSON.stringify({ title: "", description: "desc" }),
].join("\n"),
),
).toThrow("title must be a non-empty string");

expect(() =>
parsePlannerIssueRefinement(
[
"ISSUE_REFINEMENT_JSON:",
JSON.stringify({ title: "Title", description: " " }),
].join("\n"),
),
).toThrow("description must be a non-empty string");
});
});

describe("applyPlannerIssueRefinement", () => {
it("updates Linear issue details and mutates run-state issue when changed", async () => {
const updateIssueDetails = mock(async () => {});
const issue: IssueRef = {
id: "lin_1",
key: "ROY-1",
title: "Raw issue",
description: "Raw description",
url: "https://linear.app/roy/issue/ROY-1/raw-issue",
};

const changed = await applyPlannerIssueRefinement(
{ updateIssueDetails },
issue,
[
"ISSUE_REFINEMENT_JSON:",
JSON.stringify({
title: "Refined issue",
description: "Refined description",
}),
].join("\n"),
);

expect(changed).toBe(true);
expect(updateIssueDetails).toHaveBeenCalledTimes(1);
expect(updateIssueDetails).toHaveBeenCalledWith(
"lin_1",
"Refined issue",
"Refined description",
);
expect(issue.title).toBe("Refined issue");
expect(issue.description).toBe("Refined description");
});

it("skips update when refinement marker is absent or values are unchanged", async () => {
const updateIssueDetails = mock(async () => {});
const issue: IssueRef = {
id: "lin_2",
key: "ROY-2",
title: "Existing title",
description: "Existing description",
url: "https://linear.app/roy/issue/ROY-2/existing-title",
};

const noMarkerChanged = await applyPlannerIssueRefinement(
{ updateIssueDetails },
issue,
"Scope summary only.",
);
const unchangedChanged = await applyPlannerIssueRefinement(
{ updateIssueDetails },
issue,
[
"ISSUE_REFINEMENT_JSON:",
JSON.stringify({
title: "Existing title",
description: "Existing description",
}),
].join("\n"),
);

expect(noMarkerChanged).toBe(false);
expect(unchangedChanged).toBe(false);
expect(updateIssueDetails).toHaveBeenCalledTimes(0);
});
});

describe("planner routing with missing score", () => {
it("routes simple plans without score to bot review mode", () => {
const decision = parsePlannerDecision(
Expand Down
Loading