feat: Improving the plan revisions (#465)

* feat(plans): Suggested plan for plan revisions.

* fix: add planCommentId to reduce GitHub API calls.

* Revert "fix: add planCommentId to reduce GitHub API calls."

This reverts commit ef9c24811fa291b12ac3601cc4cd3edb7c9a0fca.

* Improving plan revision: the implementation draft.

* fix schema composition order.

* fix: reusing existing retry helper (action) for reportPlanCommentToRun.

* mv: updatePlanCommentId.

* fix: higher severity for logging error.

* fix: add error handling when calling findExistingPlanCommentIdForIssue.

* feat: improving the revisit plan request detection by adding PLAN_REVISION_VERBS.

* Updating the plan with alternative non-determenistic solution.

* add more verbs to PLAN_REVISION_VERBS.

* fix: supply the previous plan in the event context as previousPlanBody, updating Plan mode instructions.

* fix: adjusting the way PLAN_REVISION_VERBS are used in sentences.

* fix: using GraphQL approach with NodeId to find commentId in findExistingPlanCommentIdForIssue.

* fix: use double word boundaries (both sides).

* fix condition in findExistingPlanCommentIdForIssue.

* fix: rm unused args from findExistingPlanCommentIdForIssue.

* bump the action version.

* fix(plan): rm everything related to approach A.

* fix(plan): No limit for progress comments.

* feat(plan): the new plan.

* Revert: changed to webhook (no longer involved).

* feat: mv plan comment lookup into a new API endpoint.

* Revert: changes to select_mode tool.

* FEAT: The new implementation.

* fix arktype issues.

* fix plan diagram.

* fix(selectMode): e2e type constraints for fetchExistingPlanComment.

* Revert "fix(selectMode): e2e type constraints for fetchExistingPlanComment."

This reverts commit 53f3b6650a9928e3080700faa9eead0052e94333.

* fix(selectMode): type constraints (copy) for fetchExistingPlanComment.

* feat: improving isHttpError helper and reusing it consistently instead of casting.

* address review: remove unconditional retry, add plan comment warning, dedupe type, remove dead guard, fix GraphQL types

* Fix: tightening the PlanEdit guidance.

* fix(select_mode): Providing the agent with existingPlanCommentId as well.

* fix(instructions): Adjusting the primary guidance to prefer Plan mode for issue-related ambiguous requests.

* fix(wiki): updating the delegation docs according to the current instructions.

* fix(instructions): rm implication to call for issue details.

* fix(select_mode): tweak for PlanEdit.

* fix(select_mode): more tweaks to PlanEdit.

* fix(select_mode): tweaks for the order of instructions and context.

* revert: to the state of 5c400efce1f1fec0a0855eeacad2bc3b721fd1bf.

* fix(select_mode): Correcting the guideline.

* rm the plan from the branch (impletemented).

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
Anna Bocharova
2026-03-10 20:34:54 +00:00
committed by pullfrog[bot]
parent 6c9747585f
commit 9c99bcbbac
6 changed files with 319 additions and 31 deletions
+138 -13
View File
@@ -145074,6 +145074,33 @@ function stripExistingFooter(body) {
} }
// mcp/comment.ts // mcp/comment.ts
async function updatePlanCommentId(ctx, planCommentNodeId) {
if (ctx.runId === void 0 || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json"
},
body: JSON.stringify({ planCommentNodeId }),
signal: AbortSignal.timeout(1e4)
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2e3,
label: "updatePlanCommentId"
}
);
} catch (error49) {
log.warning(`updatePlanCommentId exhausted retries: ${error49}`);
}
}
async function buildCommentFooter({ async function buildCommentFooter({
agent: agent2, agent: agent2,
octokit, octokit,
@@ -145117,14 +145144,17 @@ async function addFooter(ctx, body) {
} }
var Comment = type({ var Comment = type({
issueNumber: type.number.describe("the issue number to comment on"), issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content") body: type.string.describe("the comment body content"),
type: type.enumerated("Plan", "Comment").describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
).optional()
}); });
function CreateCommentTool(ctx) { function CreateCommentTool(ctx) {
return tool({ return tool({
name: "create_issue_comment", name: "create_issue_comment",
description: "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", description: "Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner, owner: ctx.repo.owner,
@@ -145132,6 +145162,9 @@ function CreateCommentTool(ctx) {
issue_number: issueNumber, issue_number: issueNumber,
body: bodyWithFooter body: bodyWithFooter
}); });
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return { return {
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
@@ -145169,16 +145202,50 @@ function EditCommentTool(ctx) {
}); });
} }
var ReportProgress = type({ var ReportProgress = type({
body: type.string.describe("the progress update content to share") body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
)
}); });
async function reportProgress(ctx, { body }) { async function reportProgress(ctx, params) {
const { body, target_plan_comment } = params;
ctx.toolState.lastProgressBody = body; ctx.toolState.lastProgressBody = body;
if (ctx.payload.event.silent) { if (ctx.payload.event.silent) {
return { body, action: "skipped" }; return { body, action: "skipped" };
} }
const existingCommentId = ctx.toolState.progressCommentId;
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber; const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const isPlanMode = ctx.toolState.selectedMode === "Plan"; const isPlanMode = ctx.toolState.selectedMode === "Plan";
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === void 0) {
log.warning("target_plan_comment requested but no existingPlanCommentId in tool state");
}
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== void 0) {
const commentId = ctx.toolState.existingPlanCommentId;
const customParts = isPlanMode && issueNumber !== void 0 ? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, commentId)] : void 0;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result2 = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result2.data.node_id) {
await updatePlanCommentId(ctx, result2.data.node_id);
}
return {
commentId: result2.data.id,
url: result2.data.html_url,
body: result2.data.body || "",
action: "updated"
};
}
const existingCommentId = ctx.toolState.progressCommentId;
if (existingCommentId) { if (existingCommentId) {
const customParts = isPlanMode && issueNumber !== void 0 ? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)] : void 0; const customParts = isPlanMode && issueNumber !== void 0 ? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)] : void 0;
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
@@ -145195,6 +145262,9 @@ async function reportProgress(ctx, { body }) {
body: bodyWithFooter body: bodyWithFooter
}); });
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
if (isPlanMode && result2.data.node_id) {
await updatePlanCommentId(ctx, result2.data.node_id);
}
return { return {
commentId: result2.data.id, commentId: result2.data.id,
url: result2.data.html_url, url: result2.data.html_url,
@@ -145234,6 +145304,9 @@ async function reportProgress(ctx, { body }) {
comment_id: result.data.id, comment_id: result.data.id,
body: bodyWithPlanLink body: bodyWithPlanLink
}); });
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
}
return { return {
commentId: updateResult.data.id, commentId: updateResult.data.id,
url: updateResult.data.html_url, url: updateResult.data.html_url,
@@ -145253,8 +145326,12 @@ function ReportProgressTool(ctx) {
name: "report_progress", name: "report_progress",
description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress, parameters: ReportProgress,
execute: execute(async ({ body }) => { execute: execute(async (params) => {
const result = await reportProgress(ctx, { body }); const reportParams = { body: params.body };
if (params.target_plan_comment !== void 0) {
reportParams.target_plan_comment = params.target_plan_comment;
}
const result = await reportProgress(ctx, reportParams);
if (result.action === "skipped") { if (result.action === "skipped") {
return { return {
success: true, success: true,
@@ -147693,6 +147770,9 @@ function ResolveReviewThreadTool(ctx) {
var SelectModeParams = type({ var SelectModeParams = type({
mode: type.string.describe( mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')" "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
) )
}); });
function resolveMode(modes2, modeName) { function resolveMode(modes2, modeName) {
@@ -147845,6 +147925,21 @@ Use max effort for thorough reviews.`,
### Effort ### Effort
Use mini or auto effort.`,
PlanEdit: `### Checklist (editing existing plan)
An existing plan comment was found for this issue. Update that comment with the revised plan \u2014 do not create a new plan comment.
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
2. When delegating, the subagent prompt must contain:
- the current plan (\`previousPlanBody\`) and the user's revision request
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instructions to produce a structured plan with clear milestones and to return the full plan via \`${ghPullfrogMcpName}/set_output\` as markdown (do not create plan files or save to disk)
3. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text and \`{ target_plan_comment: true }\` so the revised plan updates the existing plan comment (not the progress comment).
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${ghPullfrogMcpName}/report_progress\` so it is not left as "Leaping...".
### Effort
Use mini or auto effort.`, Use mini or auto effort.`,
Fix: `### Checklist Fix: `### Checklist
@@ -147884,14 +147979,29 @@ Use auto effort.`,
- if the task involved labeling, commenting, or other GitHub operations, perform those directly - if the task involved labeling, commenting, or other GitHub operations, perform those directly
5. Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.` 5. Use mini effort for simple research tasks, auto for typical tasks, max for complex multi-file changes.`
}; };
function buildOrchestratorGuidance(mode) { function buildOrchestratorGuidance(mode, overrideGuidance) {
const guidance = modeGuidance[mode.name] ?? ""; const guidance = overrideGuidance ?? modeGuidance[mode.name] ?? "";
return { return {
modeName: mode.name, modeName: mode.name,
description: mode.description, description: mode.description,
orchestratorGuidance: guidance orchestratorGuidance: guidance
}; };
} }
async function fetchExistingPlanComment(ctx, issueNumber) {
if (!ctx.apiToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
signal: AbortSignal.timeout(1e4)
});
const data = await response.json();
return response.ok && "commentId" in data ? data : null;
} catch {
return null;
}
}
function SelectModeTool(ctx) { function SelectModeTool(ctx) {
return tool({ return tool({
name: "select_mode", name: "select_mode",
@@ -147903,11 +148013,12 @@ function SelectModeTool(ctx) {
error: `mode already selected: "${ctx.toolState.selectedMode}". mode selection is final and cannot be changed. complete your current workflow within this mode.` error: `mode already selected: "${ctx.toolState.selectedMode}". mode selection is final and cannot be changed. complete your current workflow within this mode.`
}; };
} }
const selectedMode = resolveMode(ctx.modes, params.mode); const modeName = params.mode;
const selectedMode = resolveMode(ctx.modes, modeName);
if (!selectedMode) { if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", "); const availableModes = ctx.modes.map((m) => m.name).join(", ");
return { return {
error: `mode "${params.mode}" not found. available modes: ${availableModes}`, error: `mode "${modeName}" not found. available modes: ${availableModes}`,
availableModes: ctx.modes.map((m) => ({ availableModes: ctx.modes.map((m) => ({
name: m.name, name: m.name,
description: m.description description: m.description
@@ -147915,6 +148026,20 @@ function SelectModeTool(ctx) {
}; };
} }
ctx.toolState.selectedMode = selectedMode.name; ctx.toolState.selectedMode = selectedMode.name;
if (selectedMode.name === "Plan") {
const issueNumber = params.issue_number ?? ctx.payload.event.issue_number;
if (issueNumber !== void 0) {
const existing = await fetchExistingPlanComment(ctx, issueNumber);
if (existing !== null) {
ctx.toolState.existingPlanCommentId = existing.commentId;
ctx.toolState.previousPlanBody = existing.body;
return {
...buildOrchestratorGuidance(selectedMode, modeGuidance.PlanEdit),
previousPlanBody: existing.body
};
}
}
}
return buildOrchestratorGuidance(selectedMode); return buildOrchestratorGuidance(selectedMode);
}) })
}); });
@@ -148723,7 +148848,7 @@ import { join as join12 } from "node:path";
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.177", version: "0.0.178",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
+104 -10
View File
@@ -1,11 +1,43 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Agent } from "../agents/index.ts"; import type { Agent } from "../agents/index.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts"; import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts"; import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
}
}
/** /**
* The prefix text for the initial "leaping into action" comment. * The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state * This is used to identify if a comment is still in its initial state
@@ -86,15 +118,21 @@ export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string
export const Comment = type({ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"), issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"), body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
)
.optional(),
}); });
export function CreateCommentTool(ctx: ToolContext) { export function CreateCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_issue_comment", name: "create_issue_comment",
description: description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", "Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body); const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
@@ -104,6 +142,10 @@ export function CreateCommentTool(ctx: ToolContext) {
body: bodyWithFooter, body: bodyWithFooter,
}); });
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return { return {
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
@@ -147,6 +189,9 @@ export function EditCommentTool(ctx: ToolContext) {
export const ReportProgress = type({ export const ReportProgress = type({
body: type.string.describe("the progress update content to share"), body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
),
}); });
/** /**
@@ -161,13 +206,14 @@ export const ReportProgress = type({
*/ */
export async function reportProgress( export async function reportProgress(
ctx: ToolContext, ctx: ToolContext,
{ body }: { body: string } params: { body: string; target_plan_comment?: boolean }
): Promise<{ ): Promise<{
commentId?: number; commentId?: number;
url?: string; url?: string;
body: string; body: string;
action: "created" | "updated" | "skipped"; action: "created" | "updated" | "skipped";
}> { }> {
const { body, target_plan_comment } = params;
// always track the body for job summary // always track the body for job summary
ctx.toolState.lastProgressBody = body; ctx.toolState.lastProgressBody = body;
@@ -177,14 +223,50 @@ export async function reportProgress(
return { body, action: "skipped" }; return { body, action: "skipped" };
} }
const existingCommentId = ctx.toolState.progressCommentId;
// Explicit issue_number from the payload wins here
// This is especially important for manually triggered workflows from trigger links.
// Without this, the created "Implement plan" would be created for the wrong issue
// if the run happens to research other issues (and thus overwrite the toolState.issueNumber).
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber; const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const isPlanMode = ctx.toolState.selectedMode === "Plan"; const isPlanMode = ctx.toolState.selectedMode === "Plan";
// when editing existing plan: update the plan comment from tool state (set by select_mode)
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
log.warning("target_plan_comment requested but no existingPlanCommentId in tool state");
}
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) {
const commentId = ctx.toolState.existingPlanCommentId;
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, commentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter,
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
const existingCommentId = ctx.toolState.progressCommentId;
// if we already have a progress comment, update it // if we already have a progress comment, update it
if (existingCommentId) { if (existingCommentId) {
const customParts = const customParts =
@@ -209,6 +291,10 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return { return {
commentId: result.data.id, commentId: result.data.id,
url: result.data.html_url, url: result.data.html_url,
@@ -264,6 +350,10 @@ export async function reportProgress(
body: bodyWithPlanLink, body: bodyWithPlanLink,
}); });
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
}
return { return {
commentId: updateResult.data.id, commentId: updateResult.data.id,
url: updateResult.data.html_url, url: updateResult.data.html_url,
@@ -286,8 +376,12 @@ export function ReportProgressTool(ctx: ToolContext) {
description: description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress, parameters: ReportProgress,
execute: execute(async ({ body }) => { execute: execute(async (params) => {
const result = await reportProgress(ctx, { body }); const reportParams: { body: string; target_plan_comment?: boolean } = { body: params.body };
if (params.target_plan_comment !== undefined) {
reportParams.target_plan_comment = params.target_plan_comment;
}
const result = await reportProgress(ctx, reportParams);
if (result.action === "skipped") { if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary // no-op: no comment target, but progress is still tracked for job summary
+64 -4
View File
@@ -1,6 +1,7 @@
import { type } from "arktype"; import { type } from "arktype";
import { ghPullfrogMcpName } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts"; import type { Mode } from "../modes.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -8,6 +9,9 @@ export const SelectModeParams = type({
mode: type.string.describe( mode: type.string.describe(
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')" "the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'IncrementalReview', 'Fix', 'AddressReviews', 'Task', 'ResolveConflicts')"
), ),
"issue_number?": type("number").describe(
"optional issue number; when provided with Plan mode, used to look up an existing plan comment for this issue (edit vs create)"
),
}); });
function resolveMode(modes: Mode[], modeName: string): Mode | null { function resolveMode(modes: Mode[], modeName: string): Mode | null {
@@ -166,6 +170,22 @@ Use max effort for thorough reviews.`,
### Effort ### Effort
Use mini or auto effort.`,
PlanEdit: `### Checklist (editing existing plan)
An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
2. When delegating, the subagent prompt must contain:
- the current plan (\`previousPlanBody\`) and the user's revision request
- relevant codebase context (file paths, architecture notes from AGENTS.md)
- instructions to produce a structured plan with clear milestones and to return the full plan via \`${ghPullfrogMcpName}/set_output\` as markdown (do not create plan files or save to disk)
3. After the subagent completes, call \`${ghPullfrogMcpName}/report_progress\` with the full plan text and \`{ target_plan_comment: true }\` so the revised plan updates the existing plan comment (not the progress comment).
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${ghPullfrogMcpName}/report_progress\` so it is not left as "Leaping...".
### Effort
Use mini or auto effort.`, Use mini or auto effort.`,
Fix: `### Checklist Fix: `### Checklist
@@ -214,8 +234,8 @@ type OrchestratorGuidance = {
orchestratorGuidance: string; orchestratorGuidance: string;
}; };
function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance { function buildOrchestratorGuidance(mode: Mode, overrideGuidance?: string): OrchestratorGuidance {
const guidance = modeGuidance[mode.name] ?? ""; const guidance = overrideGuidance ?? modeGuidance[mode.name] ?? "";
return { return {
modeName: mode.name, modeName: mode.name,
description: mode.description, description: mode.description,
@@ -223,6 +243,28 @@ function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
}; };
} }
// matches the API response for /repo/[owner]/[repo]/issue/[issueNumber]/plan-comment
export type PlanCommentResponsePayload = { error: string } | { commentId: number; body: string };
async function fetchExistingPlanComment(
ctx: ToolContext,
issueNumber: number
): Promise<Extract<PlanCommentResponsePayload, { commentId: number }> | null> {
if (!ctx.apiToken) return null;
try {
const response = await apiFetch({
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/issue/${issueNumber}/plan-comment`,
method: "GET",
headers: { authorization: `Bearer ${ctx.apiToken}` },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as PlanCommentResponsePayload;
return response.ok && "commentId" in data ? data : null;
} catch {
return null;
}
}
export function SelectModeTool(ctx: ToolContext) { export function SelectModeTool(ctx: ToolContext) {
return tool({ return tool({
name: "select_mode", name: "select_mode",
@@ -236,12 +278,14 @@ export function SelectModeTool(ctx: ToolContext) {
}; };
} }
const selectedMode = resolveMode(ctx.modes, params.mode); const modeName = params.mode;
const selectedMode = resolveMode(ctx.modes, modeName);
if (!selectedMode) { if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", "); const availableModes = ctx.modes.map((m) => m.name).join(", ");
return { return {
error: `mode "${params.mode}" not found. available modes: ${availableModes}`, error: `mode "${modeName}" not found. available modes: ${availableModes}`,
availableModes: ctx.modes.map((m) => ({ availableModes: ctx.modes.map((m) => ({
name: m.name, name: m.name,
description: m.description, description: m.description,
@@ -250,6 +294,22 @@ export function SelectModeTool(ctx: ToolContext) {
} }
ctx.toolState.selectedMode = selectedMode.name; ctx.toolState.selectedMode = selectedMode.name;
if (selectedMode.name === "Plan") {
const issueNumber = params.issue_number ?? ctx.payload.event.issue_number;
if (issueNumber !== undefined) {
const existing = await fetchExistingPlanComment(ctx, issueNumber);
if (existing !== null) {
ctx.toolState.existingPlanCommentId = existing.commentId;
ctx.toolState.previousPlanBody = existing.body;
return {
...buildOrchestratorGuidance(selectedMode, modeGuidance.PlanEdit),
previousPlanBody: existing.body,
};
}
}
}
return buildOrchestratorGuidance(selectedMode); return buildOrchestratorGuidance(selectedMode);
}), }),
}); });
+3
View File
@@ -64,6 +64,9 @@ export interface ToolState {
progressCommentId: number | null | undefined; progressCommentId: number | null | undefined;
lastProgressBody?: string; lastProgressBody?: string;
wasUpdated?: boolean; wasUpdated?: boolean;
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
existingPlanCommentId?: number;
previousPlanBody?: string;
output?: string; output?: string;
usageEntries: AgentUsage[]; usageEntries: AgentUsage[];
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/pullfrog", "name": "@pullfrog/pullfrog",
"version": "0.0.177", "version": "0.0.178",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+9 -3
View File
@@ -41258,14 +41258,20 @@ function createOctokit(token) {
var LEAPING_INTO_ACTION_PREFIX = "Leaping into action"; var LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
var Comment = type({ var Comment = type({
issueNumber: type.number.describe("the issue number to comment on"), issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content") body: type.string.describe("the comment body content"),
type: type.enumerated("Plan", "Comment").describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
).optional()
}); });
var EditComment = type({ var EditComment = type({
commentId: type.number.describe("the ID of the comment to edit"), commentId: type.number.describe("the ID of the comment to edit"),
body: type.string.describe("the new comment body content") body: type.string.describe("the new comment body content")
}); });
var ReportProgress = type({ var ReportProgress = type({
body: type.string.describe("the progress update content to share") body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
)
}); });
var ReplyToReviewComment = type({ var ReplyToReviewComment = type({
pull_number: type.number.describe("the pull request number"), pull_number: type.number.describe("the pull request number"),
@@ -41312,7 +41318,7 @@ var Effort = type.enumerated("mini", "auto", "max");
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/pullfrog", name: "@pullfrog/pullfrog",
version: "0.0.177", version: "0.0.178",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",