Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 560e27bda5 | |||
| 1e17a76863 | |||
| ada5584737 | |||
| b6e2c61d30 | |||
| e58299740d | |||
| 67fe18e504 | |||
| 588badd1b0 | |||
| 8c01ee3251 | |||
| 8cee07d388 | |||
| b835d53d83 | |||
| c6a757424c | |||
| 57f54e37c5 | |||
| 3bacf01e48 | |||
| 6607112d0b |
+65
-31
@@ -21,22 +21,21 @@ import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill } from "../utils/skills.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||||
import { deriveLabelFromTaskInput } from "./sessionLabeler.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentRunContext,
|
||||
type AgentUsage,
|
||||
agent,
|
||||
buildCommitPrompt,
|
||||
getGitStatus,
|
||||
logTokenTable,
|
||||
MAX_COMMIT_RETRIES,
|
||||
MAX_STDERR_LINES,
|
||||
mergeAgentUsage,
|
||||
} from "./shared.ts";
|
||||
|
||||
async function installClaudeCli(): Promise<string> {
|
||||
@@ -65,6 +64,24 @@ function writeMcpConfig(ctx: AgentRunContext): string {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `--agents` JSON definition for the `reviewfrog` subagent.
|
||||
* The non-mutative + non-recursive contract is enforced by the prose system
|
||||
* prompt baked into the agent — see action/agents/reviewer.ts for why we no
|
||||
* longer wire per-agent `disallowedTools` here.
|
||||
*/
|
||||
function buildAgentsJson(): string {
|
||||
const agents = {
|
||||
[REVIEWER_AGENT_NAME]: {
|
||||
description:
|
||||
"Read-only review subagent for self-review and lens-based code review. " +
|
||||
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
||||
prompt: REVIEWER_SYSTEM_PROMPT,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(agents);
|
||||
}
|
||||
|
||||
// ── model helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// claude CLI expects bare model names (e.g. "claude-sonnet-4-6"), not provider-prefixed specifiers
|
||||
@@ -239,6 +256,23 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({ toolName, input: block.input || {} });
|
||||
|
||||
// surface the subagent identity when the orchestrator dispatches a
|
||||
// Task — claude rolls subagent activity up into a single tool_result
|
||||
// (no per-event session_id in its stream), so this log line is the
|
||||
// only attribution available before the subagent's report-back.
|
||||
if (toolName === "Task" && block.input && typeof block.input === "object") {
|
||||
const taskInput = block.input as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const label = deriveLabelFromTaskInput(taskInput);
|
||||
log.info(
|
||||
`» dispatching subagent: ${label}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
}
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||||
log.debug("» report_progress detected, disabling todo tracking");
|
||||
@@ -604,6 +638,8 @@ export const claude = agent({
|
||||
agent: "claude",
|
||||
});
|
||||
|
||||
installBundledSkills({ home: homeEnv.HOME });
|
||||
|
||||
const mcpConfigPath = writeMcpConfig(ctx);
|
||||
const effort = resolveEffort(model);
|
||||
|
||||
@@ -622,6 +658,8 @@ export const claude = agent({
|
||||
effort,
|
||||
"--disallowedTools",
|
||||
"Bash,Agent(Bash)",
|
||||
"--agents",
|
||||
buildAgentsJson(),
|
||||
];
|
||||
|
||||
if (model) {
|
||||
@@ -650,35 +688,31 @@ export const claude = agent({
|
||||
onToolUse: ctx.onToolUse,
|
||||
};
|
||||
|
||||
let result = await runClaude({
|
||||
const result = await runClaude({
|
||||
...runParams,
|
||||
args: [...baseArgs, "-p", ctx.instructions.full],
|
||||
});
|
||||
// usage needs to aggregate across the initial run + every commit retry.
|
||||
// each runClaude() returns only its own iteration's usage, so without
|
||||
// merging the caller sees only the final retry's slice and undercounts.
|
||||
let aggregatedUsage = result.usage;
|
||||
|
||||
// post-run: if the working tree is dirty, resume the session and ask the agent to commit
|
||||
for (let attempt = 0; attempt < MAX_COMMIT_RETRIES; attempt++) {
|
||||
if (!result.success || !result.sessionId) break;
|
||||
const status = getGitStatus();
|
||||
if (!status) break;
|
||||
|
||||
log.info(`» dirty working tree (attempt ${attempt + 1}/${MAX_COMMIT_RETRIES}):\n${status}`);
|
||||
result = await runClaude({
|
||||
...runParams,
|
||||
args: [
|
||||
...baseArgs,
|
||||
"-p",
|
||||
buildCommitPrompt("claude", status),
|
||||
"--resume",
|
||||
result.sessionId,
|
||||
],
|
||||
});
|
||||
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
|
||||
}
|
||||
|
||||
return { ...result, usage: aggregatedUsage };
|
||||
// post-run retry loop aggregates usage across the initial run + every
|
||||
// resume, so the caller sees the whole session — not just the final
|
||||
// slice. claude needs a sessionId to `--resume`; if it's missing the
|
||||
// loop bails (checks still ran, so persistent hook failures still fail
|
||||
// the run). the reflection prompt fires once after gates go clean, as a
|
||||
// dedicated turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("claude"),
|
||||
canResume: (r) => Boolean(r.sessionId),
|
||||
resume: async (c) => {
|
||||
const sessionId = c.previousResult.sessionId;
|
||||
if (!sessionId) throw new Error("unreachable: canResume gated on sessionId");
|
||||
return runClaude({
|
||||
...runParams,
|
||||
args: [...baseArgs, "-p", c.prompt, "--resume", sessionId],
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+261
-45
@@ -18,25 +18,24 @@ import { performance } from "node:perf_hooks";
|
||||
import { pullfrogMcpName } from "../external.ts";
|
||||
import { modelAliases } from "../models.ts";
|
||||
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill } from "../utils/skills.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||||
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentRunContext,
|
||||
type AgentUsage,
|
||||
agent,
|
||||
buildCommitPrompt,
|
||||
getGitStatus,
|
||||
logTokenTable,
|
||||
MAX_COMMIT_RETRIES,
|
||||
MAX_STDERR_LINES,
|
||||
mergeAgentUsage,
|
||||
} from "./shared.ts";
|
||||
|
||||
async function installOpencodeCli(): Promise<string> {
|
||||
@@ -54,6 +53,7 @@ type OpenCodeConfig = {
|
||||
mcp?: Record<string, unknown>;
|
||||
permission?: Record<string, unknown>;
|
||||
provider?: Record<string, unknown>;
|
||||
agent?: Record<string, unknown>;
|
||||
model?: string;
|
||||
enabled_providers?: string[];
|
||||
[key: string]: unknown;
|
||||
@@ -72,6 +72,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
mcp: {
|
||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||
},
|
||||
agent: buildReviewerAgentConfig(),
|
||||
};
|
||||
|
||||
if (model) {
|
||||
@@ -86,6 +87,24 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
return JSON.stringify(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only subagent for self-review and /anneal lens dispatch. The
|
||||
* non-mutative + non-recursive contract is enforced by the prose system
|
||||
* prompt — see action/agents/reviewer.ts for why we no longer wire per-agent
|
||||
* tool/permission denies here.
|
||||
*/
|
||||
function buildReviewerAgentConfig(): Record<string, unknown> {
|
||||
return {
|
||||
[REVIEWER_AGENT_NAME]: {
|
||||
description:
|
||||
"Read-only review subagent for self-review and lens-based code review. " +
|
||||
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
|
||||
mode: "subagent",
|
||||
prompt: REVIEWER_SYSTEM_PROMPT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── model auto-select fallback ──────────────────────────────────────────────────
|
||||
//
|
||||
// steps 1–2 of model resolution (PULLFROG_MODEL env, slug resolution) are handled
|
||||
@@ -280,6 +299,72 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
let currentStepType: string | null = null;
|
||||
let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }> = [];
|
||||
|
||||
// per-session labeler so parallel subagent log lines can be differentiated.
|
||||
// the orchestrator's task tool_use events seed the labeler; the next
|
||||
// previously-unseen sessionID consumes the head of the pending-label queue.
|
||||
// NB: opencode's runtime currently encapsulates subagent execution inside
|
||||
// the `task` tool — subagent-internal tool_use/tool_result events do not
|
||||
// surface on the parent's NDJSON stream. The labeler is therefore mostly
|
||||
// dormant in practice for opencode (no per-event session differentiation
|
||||
// is needed because there are no per-subagent events). The orchestrator's
|
||||
// `task` dispatch log (with `description: <lens>`) and the per-task
|
||||
// duration log below are the actual attribution surface available today.
|
||||
// The labeler is kept in place defensively so that if/when opencode begins
|
||||
// streaming subagent sessions, attribution flips on with no further work.
|
||||
const labeler = new SessionLabeler();
|
||||
function eventLabel(event: Record<string, unknown>): string {
|
||||
const sid = event.sessionID ?? event.session_id;
|
||||
return labeler.labelFor(typeof sid === "string" ? sid : null);
|
||||
}
|
||||
function withLabel(label: string, message: string): string {
|
||||
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
|
||||
}
|
||||
|
||||
// tracks per-task dispatch metadata so the matching tool_result can log a
|
||||
// labeled "» subagent finished: lens=X duration=Ys" line. this is the most
|
||||
// useful per-lens observability available given that subagent-internal
|
||||
// events aren't streamed.
|
||||
//
|
||||
// matching strategy is hybrid because opencode does NOT reliably emit a
|
||||
// tool_result with a callID equal to the originating tool_use.callID for
|
||||
// the `task` tool (verified empirically in T3 — 5 task dispatches recorded
|
||||
// here, 0 finish lines fired, yet aggregation succeeded so results did
|
||||
// arrive on the stream). we keep an exact-match Map for the fast path, and
|
||||
// also a FIFO queue for the fallback path where the callID mismatches.
|
||||
// the queue + map share entries by reference so popping one removes both.
|
||||
interface TaskDispatch {
|
||||
label: string;
|
||||
startedAt: number;
|
||||
toolUseCallID: string;
|
||||
}
|
||||
const taskDispatchByCallID = new Map<string, TaskDispatch>();
|
||||
const pendingTaskDispatches: TaskDispatch[] = [];
|
||||
// every non-task tool_use callID we've observed. lets us tell, on a
|
||||
// tool_result, whether its callID belongs to a known non-task tool (in
|
||||
// which case we never fall back to FIFO) or is unrecognised (in which case
|
||||
// a long-output result is a strong "this is probably a task result with a
|
||||
// mismatched callID" signal).
|
||||
const knownNonTaskCallIDs = new Set<string>();
|
||||
|
||||
function emitSubagentFinished(
|
||||
dispatch: TaskDispatch,
|
||||
status: string,
|
||||
output: unknown,
|
||||
matchKind: "exact" | "fifo"
|
||||
) {
|
||||
const subagentDuration = performance.now() - dispatch.startedAt;
|
||||
const outputStr = typeof output === "string" ? output : "";
|
||||
const outputPreview = outputStr.length > 120 ? `${outputStr.slice(0, 120)}…` : outputStr;
|
||||
const matchSuffix = matchKind === "fifo" ? " [fifo-matched]" : "";
|
||||
log.info(
|
||||
`» subagent finished: ${dispatch.label} (${(subagentDuration / 1000).toFixed(1)}s, status=${status})${matchSuffix}` +
|
||||
(outputPreview ? ` — ${outputPreview.replace(/\n/g, " ")}` : "")
|
||||
);
|
||||
taskDispatchByCallID.delete(dispatch.toolUseCallID);
|
||||
const idx = pendingTaskDispatches.indexOf(dispatch);
|
||||
if (idx >= 0) pendingTaskDispatches.splice(idx, 1);
|
||||
}
|
||||
|
||||
function buildUsage(): AgentUsage | undefined {
|
||||
const totalInput =
|
||||
accumulatedTokens.input + accumulatedTokens.cacheRead + accumulatedTokens.cacheWrite;
|
||||
@@ -297,39 +382,76 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
const handlers = {
|
||||
init: (event: OpenCodeInitEvent) => {
|
||||
// bind this sessionID to a label so subsequent events (tool_use,
|
||||
// tool_result, text, message) route to the right prefix. for the
|
||||
// first session this is "orchestrator"; for subagents it pops from
|
||||
// the pending-dispatch queue.
|
||||
const label = labeler.labelFor(event.session_id ?? null);
|
||||
log.debug(
|
||||
`» ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
||||
withLabel(
|
||||
label,
|
||||
`» ${params.label} init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
|
||||
)
|
||||
);
|
||||
log.debug(`» ${params.label} init event (full): ${JSON.stringify(event)}`);
|
||||
finalOutput = "";
|
||||
accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
accumulatedCostUsd = 0;
|
||||
tokensLogged = false;
|
||||
log.debug(withLabel(label, `» ${params.label} init event (full): ${JSON.stringify(event)}`));
|
||||
// only reset run-wide state on the orchestrator's init — child sessions
|
||||
// emit their own init events and we don't want them to clobber the
|
||||
// parent's accumulated counters.
|
||||
if (label === ORCHESTRATOR_LABEL) {
|
||||
finalOutput = "";
|
||||
accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
accumulatedCostUsd = 0;
|
||||
tokensLogged = false;
|
||||
} else {
|
||||
log.info(`» ${params.label} subagent init: ${label} (session ${event.session_id || "?"})`);
|
||||
}
|
||||
},
|
||||
message: (event: OpenCodeMessageEvent) => {
|
||||
const label = eventLabel(event);
|
||||
if (event.role === "assistant" && event.content?.trim()) {
|
||||
const message = event.content.trim();
|
||||
if (event.delta) {
|
||||
log.debug(
|
||||
`» ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
||||
withLabel(
|
||||
label,
|
||||
`» ${params.label} thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
log.debug(
|
||||
`» ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
||||
withLabel(
|
||||
label,
|
||||
`» ${params.label} message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
|
||||
)
|
||||
);
|
||||
finalOutput = message;
|
||||
// same reasoning as `text` handler — only orchestrator's non-delta
|
||||
// assistant message is the run output; subagent reports stay scoped
|
||||
// to the box / debug log.
|
||||
if (label === ORCHESTRATOR_LABEL) {
|
||||
finalOutput = message;
|
||||
}
|
||||
}
|
||||
} else if (event.role === "user") {
|
||||
log.debug(
|
||||
`» ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
||||
withLabel(
|
||||
label,
|
||||
`» ${params.label} message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
text: (event: OpenCodeTextEvent) => {
|
||||
if (event.part?.text?.trim()) {
|
||||
const message = event.part.text.trim();
|
||||
log.box(message, { title: params.label });
|
||||
finalOutput = message;
|
||||
const label = eventLabel(event);
|
||||
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
|
||||
log.box(message, { title: boxTitle });
|
||||
// only the orchestrator's final text is the run's "output" — children
|
||||
// emit their own text on report-back, which would clobber the parent's
|
||||
// final answer if we accepted any text into finalOutput.
|
||||
if (label === ORCHESTRATOR_LABEL) {
|
||||
finalOutput = message;
|
||||
}
|
||||
}
|
||||
},
|
||||
step_start: (event: OpenCodeStepStartEvent) => {
|
||||
@@ -372,6 +494,40 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the orchestrator dispatches a subagent via the `task` tool, push
|
||||
// a label for the upcoming child session so its events are attributable.
|
||||
// record BEFORE label lookup: this event's session is the parent (whose
|
||||
// label is already bound); the dispatch label is for the next new
|
||||
// sessionID that appears.
|
||||
if (toolName === "task") {
|
||||
const taskInput = (event.part?.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
// dual-index by callID (fast path) AND in a FIFO queue (fallback path
|
||||
// for when opencode's task tool_result carries a different callID).
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: toolId,
|
||||
};
|
||||
taskDispatchByCallID.set(toolId, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
} else {
|
||||
// remember non-task callIDs so a later tool_result with that callID
|
||||
// is correctly identified as not-a-task (and we don't FIFO-pop a
|
||||
// pending task by mistake).
|
||||
knownNonTaskCallIDs.add(toolId);
|
||||
}
|
||||
|
||||
const label = eventLabel(event);
|
||||
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1]!.toolCalls.push(toolName);
|
||||
}
|
||||
@@ -384,10 +540,13 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
}
|
||||
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({ toolName, input: event.part?.state?.input || {} });
|
||||
const inputFormatted = formatJsonValue(event.part?.state?.input || {});
|
||||
const toolCallLine =
|
||||
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
||||
log.info(withLabel(label, toolCallLine));
|
||||
|
||||
if (event.part?.state?.status === "completed" && event.part.state.output) {
|
||||
log.debug(` output: ${event.part.state.output}`);
|
||||
log.debug(withLabel(label, ` output: ${event.part.state.output}`));
|
||||
}
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
@@ -405,9 +564,34 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
const toolId = event.part?.callID || event.tool_id;
|
||||
const status = event.part?.state?.status || event.status || "unknown";
|
||||
const output = event.part?.state?.output || event.output;
|
||||
const label = eventLabel(event);
|
||||
|
||||
thinkingTimer.markToolResult();
|
||||
|
||||
// surface subagent completion at info level — opencode otherwise hides
|
||||
// per-task timing in debug-only logs, so a parallel multi-lens fan-out
|
||||
// looks like N dispatches followed by a long quiet gap then a single
|
||||
// assistant turn. with this line you can see each lens finishing.
|
||||
//
|
||||
// matching is hybrid: exact callID first; FIFO fallback when the
|
||||
// tool_result's callID is unrecognised. opencode does not consistently
|
||||
// surface matching callIDs for the `task` tool, so the FIFO path is the
|
||||
// one that fires in practice. we only fall through to FIFO when the
|
||||
// callID is brand-new (not in `knownNonTaskCallIDs`) so genuinely
|
||||
// non-task tool_results never accidentally pop a pending task.
|
||||
if (taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0) {
|
||||
if (toolId && taskDispatchByCallID.has(toolId)) {
|
||||
const dispatch = taskDispatchByCallID.get(toolId);
|
||||
if (dispatch) emitSubagentFinished(dispatch, status, output, "exact");
|
||||
} else {
|
||||
const callIDIsKnownNonTask = toolId ? knownNonTaskCallIDs.has(toolId) : false;
|
||||
if (!callIDIsKnownNonTask && pendingTaskDispatches.length > 0) {
|
||||
const dispatch = pendingTaskDispatches[0]!;
|
||||
emitSubagentFinished(dispatch, status, output, "fifo");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolId) {
|
||||
const toolStartTime = toolCallTimings.get(toolId);
|
||||
if (toolStartTime) {
|
||||
@@ -415,24 +599,35 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
toolCallTimings.delete(toolId);
|
||||
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
|
||||
log.debug(
|
||||
`» ${params.label} tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms`
|
||||
withLabel(
|
||||
label,
|
||||
`» ${params.label} tool_result${stepContext}: id=${toolId}, status=${status}, duration=${Math.round(toolDuration)}ms`
|
||||
)
|
||||
);
|
||||
if (output) {
|
||||
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
||||
log.debug(
|
||||
withLabel(
|
||||
label,
|
||||
` output: ${typeof output === "string" ? output : JSON.stringify(output)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (toolDuration > 5000) {
|
||||
log.info(
|
||||
`» tool call took ${(toolDuration / 1000).toFixed(1)}s - may indicate network latency`
|
||||
withLabel(
|
||||
label,
|
||||
`» tool call took ${(toolDuration / 1000).toFixed(1)}s - may indicate network latency`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status === "error") {
|
||||
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
||||
log.info(`» tool call failed: ${errorMsg}`);
|
||||
log.info(withLabel(label, `» tool call failed: ${errorMsg}`));
|
||||
} else if (output) {
|
||||
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
||||
log.debug(`tool output: ${outputStr}`);
|
||||
log.debug(withLabel(label, `tool output: ${outputStr}`));
|
||||
}
|
||||
},
|
||||
result: async (event: OpenCodeResultEvent) => {
|
||||
@@ -557,6 +752,28 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
params.todoTracker?.cancel();
|
||||
}
|
||||
|
||||
// any pending task dispatches that never got a matching tool_result are
|
||||
// surfaced here so the gap is visible rather than silently swallowed.
|
||||
// this happens when opencode delivers the subagent's reply through a
|
||||
// path other than tool_result (e.g. inlined into the next assistant
|
||||
// message). flushing here is best-effort attribution — the durations
|
||||
// reported are upper bounds (the subagent could have finished any time
|
||||
// between dispatch and run-end), but the labels and ordering are exact.
|
||||
//
|
||||
// NB: the `result` event handler is dead in opencode (opencode never
|
||||
// emits a `result`-typed event), which is why this flush lives here in
|
||||
// the post-subprocess block instead.
|
||||
if (pendingTaskDispatches.length > 0) {
|
||||
for (const dispatch of [...pendingTaskDispatches]) {
|
||||
const elapsed = performance.now() - dispatch.startedAt;
|
||||
log.info(
|
||||
`» subagent finished (inferred at run-end): ${dispatch.label} (≤${(elapsed / 1000).toFixed(1)}s) — no matching tool_result observed; subagent reply likely arrived via assistant message`
|
||||
);
|
||||
}
|
||||
pendingTaskDispatches.length = 0;
|
||||
taskDispatchByCallID.clear();
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
log.info(
|
||||
`» ${params.label} completed in ${Math.round(duration)}ms with exit code ${result.exitCode}`
|
||||
@@ -665,6 +882,8 @@ export const opencode = agent({
|
||||
agent: "opencode",
|
||||
});
|
||||
|
||||
installBundledSkills({ home: homeEnv.HOME });
|
||||
|
||||
// base args shared between initial run and continue runs
|
||||
const baseArgs = ["run", "--format", "json", "--print-logs"];
|
||||
|
||||
@@ -699,29 +918,26 @@ export const opencode = agent({
|
||||
onToolUse: ctx.onToolUse,
|
||||
};
|
||||
|
||||
let result = await runOpenCode({
|
||||
const result = await runOpenCode({
|
||||
...runParams,
|
||||
args: [...baseArgs, ctx.instructions.full],
|
||||
});
|
||||
// usage needs to aggregate across the initial run + every commit retry.
|
||||
// each runOpenCode() returns only its own iteration's usage, so without
|
||||
// merging the caller sees only the final retry's slice and undercounts.
|
||||
let aggregatedUsage = result.usage;
|
||||
|
||||
// post-run: if the working tree is dirty, continue the session and ask the agent to commit
|
||||
for (let attempt = 0; attempt < MAX_COMMIT_RETRIES; attempt++) {
|
||||
if (!result.success) break;
|
||||
const status = getGitStatus();
|
||||
if (!status) break;
|
||||
|
||||
log.info(`» dirty working tree (attempt ${attempt + 1}/${MAX_COMMIT_RETRIES}):\n${status}`);
|
||||
result = await runOpenCode({
|
||||
...runParams,
|
||||
args: [...baseArgs, "--continue", buildCommitPrompt("opencode", status)],
|
||||
});
|
||||
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
|
||||
}
|
||||
|
||||
return { ...result, usage: aggregatedUsage };
|
||||
// post-run retry loop aggregates usage across the initial run + every
|
||||
// resume, so the caller sees the whole session — not just the final
|
||||
// slice. opencode always accepts `--continue`, so no canResume guard.
|
||||
// the reflection prompt fires once after gates go clean, as a dedicated
|
||||
// turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("opencode"),
|
||||
resume: async (c) =>
|
||||
runOpenCode({
|
||||
...runParams,
|
||||
args: [...baseArgs, "--continue", c.prompt],
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SPAWN_TIMEOUT_CODE, SpawnTimeoutError } from "../utils/subprocess.ts";
|
||||
import type { AgentResult } from "./shared.ts";
|
||||
|
||||
vi.mock("./shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./shared.ts")>();
|
||||
return {
|
||||
...actual,
|
||||
getGitStatus: vi.fn(() => ""),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/subprocess.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/subprocess.ts")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { runPostRunRetryLoop, executeStopHook } = await import("./postRun.ts");
|
||||
const { getGitStatus } = await import("./shared.ts");
|
||||
const { spawn } = await import("../utils/subprocess.ts");
|
||||
const mockedGetGitStatus = vi.mocked(getGitStatus);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
|
||||
const successResult = (overrides: Partial<AgentResult> = {}): AgentResult => ({
|
||||
success: true,
|
||||
output: "ok",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("runPostRunRetryLoop — reflection turn", () => {
|
||||
beforeEach(() => {
|
||||
mockedGetGitStatus.mockReset();
|
||||
mockedGetGitStatus.mockReturnValue("");
|
||||
mockedSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not flip a successful run to failed when reflection returns success:false", async () => {
|
||||
// the reflection turn is a best-effort nudge (update_learnings). if it
|
||||
// fails — e.g. the model API errors mid-turn — the underlying task has
|
||||
// already completed and been gated cleanly, so the run as a whole must
|
||||
// still be reported as successful.
|
||||
const initial = successResult({ output: "task done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({ success: false, error: "model API transient failure" });
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: call update_learnings if anything is worth saving",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("task done");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(resume).toHaveBeenCalledTimes(1);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toMatch(/REFLECTION/);
|
||||
});
|
||||
|
||||
it("still aggregates usage from a failed reflection turn", async () => {
|
||||
// the reflection consumed tokens even if it didn't produce useful output;
|
||||
// the run total must reflect that so billing/reporting stays accurate.
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: false,
|
||||
error: "model API transient failure",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.usage?.inputTokens).toBe(110);
|
||||
expect(result.usage?.outputTokens).toBe(55);
|
||||
});
|
||||
|
||||
it("falls back to the reflection's output when the pre-reflection output is empty", async () => {
|
||||
// the preservation fix must only kick in when the task actually produced
|
||||
// meaningful output. runs that communicate exclusively through MCP tools
|
||||
// (e.g. report_progress) leave result.output = "" — using `??` here kept
|
||||
// the empty string and dropped the reflection's reply, leaving the
|
||||
// fallback `handleAgentResult` path with nothing to show. prefer the
|
||||
// reflection's output (even a trivial "done") over no output at all.
|
||||
const initial = successResult({ output: "" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("done");
|
||||
});
|
||||
|
||||
it("preserves the pre-reflection task output when a trivial reflection ('done') succeeds", async () => {
|
||||
// the reflection turn is a meta-ask — its literal reply ("done" or a
|
||||
// short "updated learnings with N bullets") is not the task summary the
|
||||
// caller wants to see. before this fix, `result = reflectionResult`
|
||||
// clobbered the task's output on the returned AgentResult, so downstream
|
||||
// consumers (handleAgentResult's fallback path when toolState is empty,
|
||||
// programmatic callers of main()) saw "done" instead of the real
|
||||
// summary. assert the task's output survives a successful reflection.
|
||||
const initial = successResult({ output: "Implemented feature X; tests pass; pushed PR #42" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("Implemented feature X; tests pass; pushed PR #42");
|
||||
});
|
||||
|
||||
it("skips reflection entirely when canResume returns false", async () => {
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
canResume: () => false,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches a reflection turn that dirties the tree via the dirty-tree gate on the next iteration", async () => {
|
||||
// PR claims: "if the reflection turn dirties the tree, the loop picks
|
||||
// that up on the next iteration via the normal dirty-tree gate." lock
|
||||
// it in — without this invariant the reflection prompt could bypass the
|
||||
// commit-before-you-finish contract whenever the agent misbehaves.
|
||||
//
|
||||
// three getGitStatus calls in sequence:
|
||||
// 1. clean (triggers reflection)
|
||||
// 2. reflection left the tree dirty
|
||||
// 3. retry committed the changes — now clean, loop exits
|
||||
mockedGetGitStatus
|
||||
.mockReturnValueOnce("")
|
||||
.mockReturnValueOnce(" M scratch/notes.md")
|
||||
.mockReturnValueOnce("");
|
||||
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "resumed" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// call 0: reflection; call 1: dirty-tree retry
|
||||
expect(resume).toHaveBeenCalledTimes(2);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toContain("REFLECTION");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("scratch/notes.md");
|
||||
});
|
||||
|
||||
it("surfaces a persistent stop hook failure as AgentResult.error after MAX_POST_RUN_RETRIES", async () => {
|
||||
// PR test plan item #1: "confirm the agent is resumed with the hook
|
||||
// output and the run fails after 3 attempts if never resolved."
|
||||
//
|
||||
// stop the hook from passing on every invocation, have `resume` always
|
||||
// return success (the agent tried but couldn't fix the issue), and
|
||||
// verify: (a) the loop exhausts all retries, (b) the final result is
|
||||
// success=false, (c) the error mentions the retry count and the hook
|
||||
// output verbatim so the GitHub comment surfaces what actually failed.
|
||||
const hookFailure = {
|
||||
stdout: "lint: 3 issues in src/foo.ts",
|
||||
stderr: "",
|
||||
exitCode: 7,
|
||||
durationMs: 5,
|
||||
};
|
||||
mockedSpawn.mockResolvedValue(hookFailure);
|
||||
|
||||
const initial = successResult({ output: "agent done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "retry done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: undefined,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("exit code 7");
|
||||
expect(result.error).toContain("3 retry attempts");
|
||||
expect(result.error).toContain("lint: 3 issues in src/foo.ts");
|
||||
// each retry feeds the hook output back into the agent as the resume prompt
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("STOP HOOK FAILED");
|
||||
expect(call[0].prompt).toContain("lint: 3 issues in src/foo.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a persistently dirty tree (no stop hook failure) as a soft-fail", async () => {
|
||||
// the PR documents: "dirty-tree-only failures preserve prior behavior:
|
||||
// they're logged but don't fail the run." a regression that started
|
||||
// surfacing dirty-tree as AgentResult.error would make every run that
|
||||
// leaves untracked test fixtures around fail spuriously. guard it.
|
||||
mockedGetGitStatus.mockReturnValue(" M src/foo.ts");
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "tried but tree still dirty" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
// retries were attempted (the loop fed the dirty-tree prompt back to the agent)
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a stop hook failure even when canResume is false (no retry budget, still fails the run)", async () => {
|
||||
// the retry loop is best-effort. when canResume says no (e.g. claude
|
||||
// without a sessionId), we still need the failure gate to fire so the
|
||||
// user sees WHY the run failed instead of an opaque success. covers the
|
||||
// "checks still ran even if we can't resume" comment in postRun.ts.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "typecheck: 2 errors",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm typecheck",
|
||||
resume,
|
||||
canResume: () => false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("typecheck: 2 errors");
|
||||
// no retries were attempted because canResume said no — error lists no
|
||||
// retry count (that would be a lie).
|
||||
expect(result.error).not.toContain("retry attempt");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits the loop when the initial result is already failed", async () => {
|
||||
// if the agent already failed (timeout, model error) there's no point
|
||||
// running gates or a reflection — the run is toast. preserve the original
|
||||
// error verbatim so triage is straightforward.
|
||||
const initial: AgentResult = {
|
||||
success: false,
|
||||
error: "agent died mid-turn",
|
||||
output: "partial",
|
||||
};
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("agent died mid-turn");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedGetGitStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aggregates usage across every gate retry", async () => {
|
||||
// billing/reporting rely on the usage summary reflecting the full run,
|
||||
// not just the final retry's slice. regression gate.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "fail",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
output: "retry",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: "flaky",
|
||||
resume,
|
||||
});
|
||||
|
||||
// 100 initial + 10 * 3 retries = 130
|
||||
expect(result.usage?.inputTokens).toBe(130);
|
||||
expect(result.usage?.outputTokens).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeStopHook — output capture", () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("includes both stdout and stderr in the failure output when both are populated", async () => {
|
||||
// hooks that wrap other tools commonly emit a benign warning to stderr
|
||||
// (e.g. "config file not found, using defaults") and the actionable error
|
||||
// to stdout. a `(stderr || stdout)` heuristic drops stdout entirely
|
||||
// whenever stderr is non-empty, starving the agent of the information it
|
||||
// needs to fix the issue.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "ERROR: lint check failed at path/to/file.ts:42",
|
||||
stderr: "Warning: config file not found, using defaults",
|
||||
exitCode: 1,
|
||||
durationMs: 5,
|
||||
});
|
||||
const failure = await executeStopHook("run-lint");
|
||||
expect(failure).not.toBeNull();
|
||||
expect(failure?.output).toContain("ERROR: lint check failed at path/to/file.ts:42");
|
||||
expect(failure?.output).toContain("Warning: config file not found, using defaults");
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) when spawn throws a timeout", async () => {
|
||||
// infra-level failures can't be fixed by the agent. surfacing them as a
|
||||
// hook failure would put the loop into a retry cycle that never
|
||||
// terminates. soft-fail and let the run succeed.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
new SpawnTimeoutError("hook exceeded 10 minutes", SPAWN_TIMEOUT_CODE)
|
||||
);
|
||||
const failure = await executeStopHook("slow-hook");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) on spawn ENOENT (command not found)", async () => {
|
||||
// if the user misconfigures the hook (wrong binary, typo), the spawn
|
||||
// itself throws. same rationale as timeouts: soft-fail, don't retry.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error("spawn nosuchbin ENOENT"), { code: "ENOENT" })
|
||||
);
|
||||
const failure = await executeStopHook("nosuchbin");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates oversize output, keeping the tail", async () => {
|
||||
// the error is embedded in AgentResult.error and flows into GitHub
|
||||
// comments (65535-char cap). the 4096-char truncation is our guardrail;
|
||||
// lock it in so a well-meaning refactor can't blow the comment budget.
|
||||
const longTail = "LAST_LINE_IS_ACTIONABLE";
|
||||
const longOutput = "x".repeat(10_000) + longTail;
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: longOutput,
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const failure = await executeStopHook("noisy");
|
||||
expect(failure?.output).toContain(longTail);
|
||||
expect(failure?.output).toContain("truncated");
|
||||
expect(failure?.output.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { type AgentId, formatMcpToolRef } from "../external.ts";
|
||||
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import {
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
SPAWN_TIMEOUT_CODE,
|
||||
SpawnTimeoutError,
|
||||
spawn,
|
||||
} from "../utils/subprocess.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentUsage,
|
||||
buildCommitPrompt,
|
||||
getGitStatus,
|
||||
hasPostRunIssues,
|
||||
MAX_POST_RUN_RETRIES,
|
||||
mergeAgentUsage,
|
||||
type PostRunIssues,
|
||||
type StopHookFailure,
|
||||
} from "./shared.ts";
|
||||
|
||||
/**
|
||||
* hook output can flow into two size-sensitive places: the LLM resume prompt
|
||||
* (context window) and AgentResult.error (surfaced in GitHub comments capped
|
||||
* at 65535 chars). truncate the tail to keep both bounded; the tail is
|
||||
* usually the most actionable part of a failing script's output.
|
||||
*/
|
||||
const MAX_HOOK_OUTPUT_CHARS = 4096;
|
||||
|
||||
function truncateHookOutput(raw: string): string {
|
||||
if (raw.length <= MAX_HOOK_OUTPUT_CHARS) return raw;
|
||||
return `...(truncated, showing last ${MAX_HOOK_OUTPUT_CHARS} chars)\n${raw.slice(-MAX_HOOK_OUTPUT_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* run the user-configured stop hook.
|
||||
*
|
||||
* parallel to `executeLifecycleHook` (which soft-fails with a warning), but
|
||||
* returns structured output so agent harnesses can feed the failure back into
|
||||
* the session as a resume prompt.
|
||||
*
|
||||
* - non-zero exit → `StopHookFailure`, actionable: the output is fed to the
|
||||
* agent so it can fix the underlying issue.
|
||||
* - timeout / spawn error → null, treated as passed: we can't usefully ask the
|
||||
* agent to fix an infrastructure problem, and retrying would risk infinite
|
||||
* loops.
|
||||
*/
|
||||
export async function executeStopHook(script: string): Promise<StopHookFailure | null> {
|
||||
log.info("» executing stop hook...");
|
||||
try {
|
||||
const result = await spawn({
|
||||
cmd: "bash",
|
||||
args: ["-c", script],
|
||||
env: process.env,
|
||||
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
|
||||
activityTimeout: 0,
|
||||
onStdout: (chunk) => process.stdout.write(chunk),
|
||||
onStderr: (chunk) => process.stderr.write(chunk),
|
||||
});
|
||||
if (result.exitCode === 0) {
|
||||
log.info("» stop hook passed");
|
||||
return null;
|
||||
}
|
||||
// include both streams — scripts often emit a benign warning to stderr
|
||||
// and the actionable error to stdout (or vice versa), and picking one
|
||||
// starves the agent of the diagnostic it needs. stderr-first so stdout
|
||||
// (typically longer, where truncation is more likely to bite) keeps its
|
||||
// tail — summaries/totals usually live at the end.
|
||||
const combined = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
|
||||
const output = truncateHookOutput(combined);
|
||||
log.info(`» stop hook failed with exit code ${result.exitCode}`);
|
||||
return { exitCode: result.exitCode, output };
|
||||
} catch (err) {
|
||||
const isTimeout =
|
||||
err instanceof SpawnTimeoutError &&
|
||||
(err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.warning(
|
||||
`stop hook ${isTimeout ? "timed out" : "failed to spawn"}: ${msg} — skipping retry`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStopHookPrompt(failure: StopHookFailure): string {
|
||||
return [
|
||||
`STOP HOOK FAILED — the repo-configured stop hook exited with code ${failure.exitCode}. your work is not done until the hook exits cleanly. address the issue below and push any resulting changes to a pull request.`,
|
||||
"",
|
||||
"```",
|
||||
failure.output || "(no output)",
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* check the two post-run gates: did the stop hook pass and is the working
|
||||
* tree clean? returns everything that still needs fixing so the caller can
|
||||
* render a single combined resume prompt.
|
||||
*/
|
||||
export async function collectPostRunIssues(params: {
|
||||
stopScript: string | null | undefined;
|
||||
}): Promise<PostRunIssues> {
|
||||
const issues: PostRunIssues = {};
|
||||
if (params.stopScript) {
|
||||
const failure = await executeStopHook(params.stopScript);
|
||||
if (failure) issues.stopHook = failure;
|
||||
}
|
||||
const status = getGitStatus();
|
||||
if (status) issues.dirtyTree = status;
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function buildPostRunPrompt(issues: PostRunIssues): string {
|
||||
const parts: string[] = [];
|
||||
if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
|
||||
if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* prompt for a dedicated post-run reflection turn nudging the agent to call
|
||||
* `update_learnings` if it discovered anything worth persisting.
|
||||
*
|
||||
* this exists because the learnings step baked into mode checklists is
|
||||
* frequently ignored — the agent stays focused on the task and the meta-ask
|
||||
* falls through. delivering it as its own resume turn, with nothing competing
|
||||
* for attention, raises the fire rate substantially.
|
||||
*/
|
||||
export function buildLearningsReflectionPrompt(agentId: AgentId): string {
|
||||
const t = (name: string) => formatMcpToolRef(agentId, name);
|
||||
return [
|
||||
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs?`,
|
||||
"",
|
||||
`if so, call \`${t("update_learnings")}\` to persist it.`,
|
||||
"",
|
||||
`rules:`,
|
||||
`- only call \`${t("update_learnings")}\` when the finding is high-confidence and broadly useful. skip if unsure, speculative, or one-off.`,
|
||||
`- pass the FULL merged list: existing learnings from the original prompt + your new discoveries. one fact per bullet, lines starting with \`- \`.`,
|
||||
`- deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`,
|
||||
`- if you already called \`${t("update_learnings")}\` earlier in this run, or nothing new is worth capturing, just reply "done" and stop — do not edit the repo for this reflection.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* shared post-run retry loop used by every agent harness.
|
||||
*
|
||||
* checks the post-run gates (stop hook + dirty tree), and if either is
|
||||
* failing, invokes `resume` to let the agent fix and push in the same turn.
|
||||
* bails at `MAX_POST_RUN_RETRIES` attempts. the `canResume` predicate is
|
||||
* consulted before each retry — harnesses that can't re-enter the session
|
||||
* (e.g. claude without a sessionId) return false here.
|
||||
*
|
||||
* an optional `reflectionPrompt` fires exactly once, after the gates first
|
||||
* observe a clean state. it's a one-shot nudge (e.g. "update learnings if
|
||||
* relevant"), not a gate, so it does not consume the gate-retry budget. if
|
||||
* the reflection turn dirties the tree, the loop picks that up on the next
|
||||
* iteration via the normal dirty-tree gate.
|
||||
*
|
||||
* stop hook must pass for the run to succeed; persistent hook failures are
|
||||
* surfaced as `AgentResult.error`. dirty-tree-only failures preserve prior
|
||||
* behavior: they're logged but don't fail the run.
|
||||
*/
|
||||
export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
initialResult: R;
|
||||
initialUsage: AgentUsage | undefined;
|
||||
stopScript: string | null | undefined;
|
||||
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
|
||||
canResume?: ((result: R) => boolean) | undefined;
|
||||
reflectionPrompt?: string | undefined;
|
||||
}): Promise<AgentResult> {
|
||||
let result = params.initialResult;
|
||||
let aggregatedUsage = params.initialUsage;
|
||||
let finalIssues: PostRunIssues = {};
|
||||
let gateResumeCount = 0;
|
||||
let pendingReflection = params.reflectionPrompt;
|
||||
|
||||
while (gateResumeCount < MAX_POST_RUN_RETRIES) {
|
||||
if (!result.success) break;
|
||||
const issues = await collectPostRunIssues({ stopScript: params.stopScript });
|
||||
finalIssues = issues;
|
||||
|
||||
if (!hasPostRunIssues(issues)) {
|
||||
// gates are clean. if a reflection prompt is pending, deliver it once
|
||||
// and loop back to re-check — the reflection may have touched the tree.
|
||||
if (!pendingReflection) break;
|
||||
if (params.canResume && !params.canResume(result)) break;
|
||||
log.info("» post-run reflection: nudging agent to update learnings if relevant");
|
||||
const preReflection = result;
|
||||
const reflectionResult = await params.resume({
|
||||
prompt: pendingReflection,
|
||||
previousResult: result,
|
||||
});
|
||||
aggregatedUsage = mergeAgentUsage(aggregatedUsage, reflectionResult.usage);
|
||||
pendingReflection = undefined;
|
||||
if (!reflectionResult.success) {
|
||||
// reflection is a best-effort nudge. its failure must not flip a
|
||||
// successful run to failed — the gated work is already done. keep
|
||||
// the pre-reflection result and exit without re-running the gates
|
||||
// (which would risk a flaky false-positive hook failure right after
|
||||
// it just passed).
|
||||
log.warning(
|
||||
`» reflection turn failed (${reflectionResult.error ?? "unknown error"}), preserving prior successful result`
|
||||
);
|
||||
result = preReflection;
|
||||
break;
|
||||
}
|
||||
// reflection replies are meta-asks ("done", "updated learnings with N
|
||||
// bullets") — not a task summary. keep the pre-reflection output so
|
||||
// the returned AgentResult still reflects what the run accomplished,
|
||||
// while inheriting reflection-specific fields the harness needs for
|
||||
// any subsequent gate retry (e.g. the new sessionId claude emits per
|
||||
// --resume invocation).
|
||||
// use `||` (not `??`) so an empty pre-reflection output falls through
|
||||
// to the reflection's reply. runs that only emit MCP tool calls and no
|
||||
// plain text leave result.output = "" — keeping "" would starve the
|
||||
// fallback path in handleAgentResult of anything to show.
|
||||
result = {
|
||||
...reflectionResult,
|
||||
output: preReflection.output || reflectionResult.output,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// checks still ran even if we can't resume, so the failure gate below
|
||||
// can still catch a persistent stop-hook failure.
|
||||
if (params.canResume && !params.canResume(result)) {
|
||||
log.info("» post-run retry skipped: cannot resume agent session");
|
||||
break;
|
||||
}
|
||||
|
||||
log.info(`» post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`);
|
||||
const prompt = buildPostRunPrompt(issues);
|
||||
result = await params.resume({ prompt, previousResult: result });
|
||||
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
|
||||
gateResumeCount++;
|
||||
}
|
||||
|
||||
// we exhausted retries without observing a clean state — finalIssues
|
||||
// reflects pre-resume state, so re-check to see what the last resume
|
||||
// actually did. when the subprocess failed we skip: its own error is more
|
||||
// actionable than a stale "stop hook still failing" message. when the loop
|
||||
// already observed a clean state we skip: re-running the hook risks flaky
|
||||
// false-positive failures right after it just passed.
|
||||
if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
|
||||
finalIssues = await collectPostRunIssues({ stopScript: params.stopScript });
|
||||
}
|
||||
|
||||
if (result.success && finalIssues.stopHook) {
|
||||
const retryNote =
|
||||
gateResumeCount > 0
|
||||
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
|
||||
: "";
|
||||
return {
|
||||
...result,
|
||||
success: false,
|
||||
error: `stop hook failed${retryNote} (exit code ${finalIssues.stopHook.exitCode}): ${finalIssues.stopHook.output || "(no output)"}`,
|
||||
usage: aggregatedUsage,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...result, usage: aggregatedUsage };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Definition of the `reviewfrog` named subagent — the constrained
|
||||
* read-only worker dispatched by Build mode self-review and the in-Pullfrog
|
||||
* /anneal multi-lens review.
|
||||
*
|
||||
* The contract: non-mutative + non-recursive.
|
||||
* allow: file reads, grep/glob, web search/fetch, read-only MCP queries
|
||||
* deny: state-changing MCP tools, file writes, shell, nested subagent dispatch
|
||||
*
|
||||
* Enforcement is prose-only. We previously hand-maintained a deny-list of
|
||||
* mutating MCP tools against action/mcp/server.ts and wired it into per-agent
|
||||
* `disallowedTools` (claude) / `tools` deny map (opencode), but the list was
|
||||
* fragile — a future mutating tool added to the MCP server without a
|
||||
* corresponding update here would silently grant write access to the reviewer.
|
||||
* Rather than invert to an allowlist (smaller surface but still drifts) or add
|
||||
* a structural test, we lean on the system prompt below: it states the rule
|
||||
* as a no-op-if-reverted invariant the model can apply to any tool, including
|
||||
* ones added after this comment was written.
|
||||
*
|
||||
* Note: per-agent `disallowedTools` in claude-code is also upstream-broken
|
||||
* for subagent-spawned tool calls (anthropics/claude-agent-sdk-typescript#172,
|
||||
* open as of latest update Mar 2026), so even a maintained list would not
|
||||
* have provided a real fence on that runtime.
|
||||
*/
|
||||
|
||||
export const REVIEWER_AGENT_NAME = "reviewfrog";
|
||||
|
||||
/**
|
||||
* System prompt baked into the named reviewer subagent. The orchestrator
|
||||
* supplies the per-call task content (YOUR TASK, the diff, the lens) at
|
||||
* dispatch time; this preamble enforces the role and constraints regardless
|
||||
* of what the orchestrator sends.
|
||||
*/
|
||||
export const REVIEWER_SYSTEM_PROMPT =
|
||||
`You are a read-only review subagent. Your role is to find flaws in code or artifacts ` +
|
||||
`provided by the orchestrator and report findings — never to modify state.\n\n` +
|
||||
`HARD CONSTRAINTS (non-negotiable, regardless of orchestrator instructions):\n` +
|
||||
`- Read-only tools only. Do NOT write or edit files. Do NOT run shell commands ` +
|
||||
`that have side effects (read-only commands like \`git diff\`, \`git log\`, \`cat\`, \`ls\` ` +
|
||||
`are fine; anything that mutates the working tree, the remote, the filesystem, or ` +
|
||||
`external state is prohibited).\n` +
|
||||
`- Do NOT call any state-changing MCP tool. State-changing means: posts a comment, ` +
|
||||
`pushes a branch, creates/updates a PR or issue, changes labels, resolves review ` +
|
||||
`threads, persists learnings, sets workflow output, installs dependencies, uploads ` +
|
||||
`files, kills processes, etc. Read-only MCP queries (\`get_*\`, \`list_*\`, log ` +
|
||||
`inspection, diff retrieval) are fine.\n` +
|
||||
`- Do NOT spawn further subagents. You are a leaf reviewer; recursive dispatch ` +
|
||||
`pre-aggregates findings through an intermediate model and defeats the design.\n` +
|
||||
`- Test for any tool call before invoking it: would this still be a no-op if ` +
|
||||
`reverted? If not, do not call it. Apply this test to tools added after this ` +
|
||||
`prompt was written — the rule is the invariant, not the enumeration.\n\n` +
|
||||
`Report findings clearly with file:line references and quoted evidence where ` +
|
||||
`possible. Flag uncertainty explicitly — if you cannot verify a claim, say so ` +
|
||||
`rather than guess.`;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
deriveLabelFromTaskInput,
|
||||
formatWithLabel,
|
||||
ORCHESTRATOR_LABEL,
|
||||
SessionLabeler,
|
||||
} from "./sessionLabeler.ts";
|
||||
|
||||
describe("deriveLabelFromTaskInput", () => {
|
||||
test("prefers explicit lens marker in prompt over description", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
prompt: "lens: security\nReview the diff for...",
|
||||
description: "general review",
|
||||
})
|
||||
).toBe("lens:security");
|
||||
});
|
||||
|
||||
test("supports lens=<name> alternative syntax", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
prompt: "lens=user-journey\nWalk through the happy path...",
|
||||
})
|
||||
).toBe("lens:user-journey");
|
||||
});
|
||||
|
||||
test("falls back to description when no lens marker present", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
prompt: "Review this diff for any bugs",
|
||||
description: "Auth lens",
|
||||
})
|
||||
).toBe("lens:auth-lens");
|
||||
});
|
||||
|
||||
test("falls back to subagent_type when description and lens marker absent", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
prompt: "Some generic prompt",
|
||||
subagent_type: "reviewfrog",
|
||||
})
|
||||
).toBe("reviewfrog");
|
||||
});
|
||||
|
||||
test("returns generic subagent when nothing identifiable", () => {
|
||||
expect(deriveLabelFromTaskInput({})).toBe("subagent");
|
||||
});
|
||||
|
||||
test("slug normalizes whitespace and special chars", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
description: "Schema migration & operational readiness!",
|
||||
})
|
||||
).toBe("lens:schema-migration-operational-readiness");
|
||||
});
|
||||
|
||||
test("slug truncates labels longer than 40 chars to keep prefix readable", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
description: "this is a very long lens description that exceeds the slug limit",
|
||||
})
|
||||
).toBe("lens:this-is-a-very-long-lens-description-tha");
|
||||
});
|
||||
|
||||
test("ignores lens marker mid-line — must be at line start", () => {
|
||||
expect(
|
||||
deriveLabelFromTaskInput({
|
||||
prompt: "Please review the lens: security claim made above",
|
||||
description: "billing",
|
||||
})
|
||||
).toBe("lens:billing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionLabeler", () => {
|
||||
test("first session seen is the orchestrator", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL);
|
||||
// bound — same session returns same label on second call
|
||||
expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL);
|
||||
expect(labeler.size()).toBe(1);
|
||||
});
|
||||
|
||||
test("FIFO matches dispatched labels to new sessions in dispatch order", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
// orchestrator session
|
||||
labeler.labelFor("parent");
|
||||
|
||||
// orchestrator dispatches 3 tasks in one assistant turn
|
||||
labeler.recordTaskDispatch({ description: "security" });
|
||||
labeler.recordTaskDispatch({ description: "correctness" });
|
||||
labeler.recordTaskDispatch({ description: "user journey" });
|
||||
|
||||
expect(labeler.pendingDispatchCount()).toBe(3);
|
||||
|
||||
// children appear (potentially interleaved)
|
||||
expect(labeler.labelFor("child-1")).toBe("lens:security");
|
||||
expect(labeler.labelFor("child-2")).toBe("lens:correctness");
|
||||
expect(labeler.labelFor("child-3")).toBe("lens:user-journey");
|
||||
|
||||
expect(labeler.pendingDispatchCount()).toBe(0);
|
||||
expect(labeler.size()).toBe(4);
|
||||
});
|
||||
|
||||
test("interleaved events from parent and children resolve to stable labels", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
labeler.labelFor("parent");
|
||||
labeler.recordTaskDispatch({ description: "security" });
|
||||
labeler.recordTaskDispatch({ description: "correctness" });
|
||||
|
||||
// child-1 emits an event first (its label binds)
|
||||
expect(labeler.labelFor("child-1")).toBe("lens:security");
|
||||
// parent emits some events in between
|
||||
expect(labeler.labelFor("parent")).toBe(ORCHESTRATOR_LABEL);
|
||||
// child-2 finally appears
|
||||
expect(labeler.labelFor("child-2")).toBe("lens:correctness");
|
||||
// child-1 emits more events — still the same label
|
||||
expect(labeler.labelFor("child-1")).toBe("lens:security");
|
||||
});
|
||||
|
||||
test("falls back to subagent#N when child appears without a queued dispatch", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
labeler.labelFor("parent");
|
||||
// no recordTaskDispatch — but a child appears anyway (defensive path)
|
||||
expect(labeler.labelFor("ghost")).toBe("subagent#1");
|
||||
expect(labeler.labelFor("ghost-2")).toBe("subagent#2");
|
||||
});
|
||||
|
||||
test("undefined/null/empty sessionID resolves to orchestrator label without binding", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
expect(labeler.labelFor(undefined)).toBe(ORCHESTRATOR_LABEL);
|
||||
expect(labeler.labelFor(null)).toBe(ORCHESTRATOR_LABEL);
|
||||
expect(labeler.labelFor("")).toBe(ORCHESTRATOR_LABEL);
|
||||
// size stays zero — those calls didn't bind anything
|
||||
expect(labeler.size()).toBe(0);
|
||||
});
|
||||
|
||||
test("entries returns insertion-ordered (sessionID, label) pairs", () => {
|
||||
const labeler = new SessionLabeler();
|
||||
labeler.labelFor("parent");
|
||||
labeler.recordTaskDispatch({ description: "security" });
|
||||
labeler.labelFor("child-1");
|
||||
expect(labeler.entries()).toEqual([
|
||||
["parent", ORCHESTRATOR_LABEL],
|
||||
["child-1", "lens:security"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("realistic four-lens parallel fan-out — interleaved tool_use stream", () => {
|
||||
// simulates the event order we'd see when the orchestrator dispatches
|
||||
// 4 lens subagents in a single assistant turn and they all start emitting
|
||||
// tool_use events more or less concurrently.
|
||||
const labeler = new SessionLabeler();
|
||||
|
||||
// 1. orchestrator's `init` event
|
||||
expect(labeler.labelFor("p")).toBe(ORCHESTRATOR_LABEL);
|
||||
|
||||
// 2. orchestrator emits 4 task tool_use events back-to-back
|
||||
labeler.recordTaskDispatch({ description: "correctness & invariants" });
|
||||
labeler.recordTaskDispatch({ description: "security" });
|
||||
labeler.recordTaskDispatch({ description: "user journey" });
|
||||
labeler.recordTaskDispatch({ description: "schema migration" });
|
||||
|
||||
// 3. children emit in arbitrary interleaved order
|
||||
const observed: Array<[string, string]> = [];
|
||||
for (const session of ["c1", "c2", "p", "c3", "c1", "c4", "c2", "p"]) {
|
||||
observed.push([session, labeler.labelFor(session)]);
|
||||
}
|
||||
|
||||
expect(observed).toEqual([
|
||||
["c1", "lens:correctness-invariants"],
|
||||
["c2", "lens:security"],
|
||||
["p", ORCHESTRATOR_LABEL],
|
||||
["c3", "lens:user-journey"],
|
||||
["c1", "lens:correctness-invariants"],
|
||||
["c4", "lens:schema-migration"],
|
||||
["c2", "lens:security"],
|
||||
["p", ORCHESTRATOR_LABEL],
|
||||
]);
|
||||
|
||||
expect(labeler.size()).toBe(5);
|
||||
expect(labeler.pendingDispatchCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWithLabel", () => {
|
||||
test("prefixes a single-line message with magenta-wrapped label", () => {
|
||||
const out = formatWithLabel("orchestrator", "hello world");
|
||||
expect(out).toContain("[orchestrator]");
|
||||
expect(out).toContain("hello world");
|
||||
// ANSI magenta + reset markers around the bracketed label (escapes
|
||||
// built via fromCharCode to satisfy biome's no-control-character-in-regex)
|
||||
const ESC = String.fromCharCode(27);
|
||||
expect(out).toMatch(new RegExp(`${ESC}\\[35m\\[orchestrator\\]${ESC}\\[0m hello world$`));
|
||||
});
|
||||
|
||||
test("prefixes every line of a multi-line message", () => {
|
||||
const out = formatWithLabel("lens:security", "line one\nline two\nline three");
|
||||
const lines = out.split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
for (const line of lines) {
|
||||
expect(line).toContain("[lens:security]");
|
||||
}
|
||||
expect(lines[0]).toContain("line one");
|
||||
expect(lines[1]).toContain("line two");
|
||||
expect(lines[2]).toContain("line three");
|
||||
});
|
||||
|
||||
test("handles empty input without throwing", () => {
|
||||
const out = formatWithLabel("orchestrator", "");
|
||||
expect(out).toContain("[orchestrator]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Track per-session labels so log lines from parallel subagents can be
|
||||
* differentiated. The orchestrator dispatches lens subagents (e.g. reviewfrog)
|
||||
* via the Task tool; each subagent runs in its own opencode/claude Session
|
||||
* with its own `sessionID` (or `session_id`) tag on the NDJSON event stream.
|
||||
*
|
||||
* Without per-session prefixing, parallel subagent tool_use / tool_result /
|
||||
* text events appear as a single interleaved stream tagged with `[Pullfrog]`,
|
||||
* making it impossible for a human reading the logs to attribute work to a
|
||||
* specific lens.
|
||||
*
|
||||
* The labeler is deliberately runtime-agnostic — both opencode.ts and
|
||||
* claude.ts feed it the same shape. The contract is FIFO: when the orchestrator
|
||||
* dispatches N task tool_use blocks in a single assistant turn (the parallel
|
||||
* fan-out the multi-lens prompt requires), the i-th new sessionID is assumed
|
||||
* to belong to the i-th task dispatch. This is correct as long as parallel
|
||||
* dispatches are emitted in source-order and the runtimes respect that order
|
||||
* when assigning child sessions; we do not depend on it for correctness of
|
||||
* the read-only contract — only for log readability.
|
||||
*/
|
||||
|
||||
export interface TaskDispatchInput {
|
||||
description?: string | undefined;
|
||||
subagent_type?: string | undefined;
|
||||
prompt?: string | undefined;
|
||||
}
|
||||
|
||||
export const ORCHESTRATOR_LABEL = "orchestrator";
|
||||
|
||||
const LENS_PROMPT_PATTERN = /^\s*(?:lens|Lens|LENS)\s*[:=]\s*([A-Za-z][\w &/.-]{0,60})/m;
|
||||
|
||||
function slug(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable label from a Task tool's input. Tries (in order):
|
||||
* 1. explicit `lens: <name>` marker on a line in the prompt — preferred,
|
||||
* lets the orchestrator name the lens deterministically
|
||||
* 2. the Task tool's `description` field — short, written by orchestrator
|
||||
* per call, usually enough
|
||||
* 3. the `subagent_type` (e.g. `reviewfrog`) — falls back to the named
|
||||
* subagent identity when description is missing
|
||||
* 4. generic "subagent" — last resort
|
||||
*/
|
||||
export function deriveLabelFromTaskInput(input: TaskDispatchInput): string {
|
||||
if (typeof input.prompt === "string") {
|
||||
const match = input.prompt.match(LENS_PROMPT_PATTERN);
|
||||
if (match?.[1]) {
|
||||
const slugged = slug(match[1]);
|
||||
if (slugged) return `lens:${slugged}`;
|
||||
}
|
||||
}
|
||||
if (input.description) {
|
||||
const slugged = slug(input.description);
|
||||
if (slugged) return `lens:${slugged}`;
|
||||
}
|
||||
if (input.subagent_type) {
|
||||
return input.subagent_type;
|
||||
}
|
||||
return "subagent";
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful tracker mapping sessionIDs to human labels.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - First call to `labelFor()` returns ORCHESTRATOR_LABEL and binds that
|
||||
* sessionID to it. Every subsequent event from that session gets the
|
||||
* same label.
|
||||
* - When the orchestrator emits a Task tool_use, the harness calls
|
||||
* `recordTaskDispatch()` to push the dispatch's derived label onto a
|
||||
* pending FIFO queue.
|
||||
* - The next previously-unseen sessionID consumes the head of the queue.
|
||||
* - If `labelFor()` is called for a new session with an empty queue
|
||||
* (e.g. a subagent emitted events before the parent's tool_use was
|
||||
* parsed, or the runtime spawned a session we didn't expect), the
|
||||
* labeler falls back to `subagent#N` so log lines remain attributable.
|
||||
*/
|
||||
export class SessionLabeler {
|
||||
private readonly labels = new Map<string, string>();
|
||||
private readonly pendingLabels: string[] = [];
|
||||
private fallbackCounter = 0;
|
||||
|
||||
recordTaskDispatch(input: TaskDispatchInput): string {
|
||||
const label = deriveLabelFromTaskInput(input);
|
||||
this.pendingLabels.push(label);
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a label for the given sessionID. Binds on first call.
|
||||
* Pass undefined/empty for events that lack a session id — the caller
|
||||
* gets ORCHESTRATOR_LABEL so the line is still attributable.
|
||||
*/
|
||||
labelFor(sessionID: string | undefined | null): string {
|
||||
if (!sessionID) return ORCHESTRATOR_LABEL;
|
||||
const existing = this.labels.get(sessionID);
|
||||
if (existing) return existing;
|
||||
|
||||
let label: string;
|
||||
if (this.labels.size === 0) {
|
||||
label = ORCHESTRATOR_LABEL;
|
||||
} else if (this.pendingLabels.length > 0) {
|
||||
label = this.pendingLabels.shift() as string;
|
||||
} else {
|
||||
this.fallbackCounter += 1;
|
||||
label = `subagent#${this.fallbackCounter}`;
|
||||
}
|
||||
this.labels.set(sessionID, label);
|
||||
return label;
|
||||
}
|
||||
|
||||
/** number of distinct sessions seen so far (for diagnostics) */
|
||||
size(): number {
|
||||
return this.labels.size;
|
||||
}
|
||||
|
||||
/** all (sessionID, label) pairs, oldest first */
|
||||
entries(): Array<[string, string]> {
|
||||
return Array.from(this.labels.entries());
|
||||
}
|
||||
|
||||
/** how many pending labels are queued waiting to bind to a new session */
|
||||
pendingDispatchCount(): number {
|
||||
return this.pendingLabels.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log message with a session label prefix in magenta. Mirrors the
|
||||
* style of utils/log.ts:prefixLines() so per-session prefixes look the same
|
||||
* as the dormant withLogPrefix-based ones.
|
||||
*/
|
||||
export function formatWithLabel(label: string, message: string): string {
|
||||
const MAGENTA = "\x1b[35m";
|
||||
const RESET = "\x1b[0m";
|
||||
const colored = `${MAGENTA}[${label}]${RESET} `;
|
||||
return message
|
||||
.split("\n")
|
||||
.map((line) => `${colored}${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
+28
-4
@@ -8,9 +8,13 @@ import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
// maximum number of stderr lines to keep in the rolling buffer during agent execution
|
||||
export const MAX_STDERR_LINES = 20;
|
||||
|
||||
// ── post-run commit enforcement ─────────────────────────────────────────────────
|
||||
// ── post-run retry loop ────────────────────────────────────────────────────────
|
||||
|
||||
export const MAX_COMMIT_RETRIES = 3;
|
||||
/**
|
||||
* how many times the post-run loop may resume the agent to fix a dirty tree
|
||||
* or a failing stop hook before giving up.
|
||||
*/
|
||||
export const MAX_POST_RUN_RETRIES = 3;
|
||||
|
||||
export function getGitStatus(): string {
|
||||
try {
|
||||
@@ -23,7 +27,7 @@ export function getGitStatus(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCommitPrompt(_agentId: AgentId, status: string): string {
|
||||
export function buildCommitPrompt(status: string): string {
|
||||
return [
|
||||
`UNCOMMITTED CHANGES — the working tree is dirty. push all changes to a pull request (new or existing). \`git status\` must be clean before you finish.`,
|
||||
"",
|
||||
@@ -33,6 +37,20 @@ export function buildCommitPrompt(_agentId: AgentId, status: string): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface StopHookFailure {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export interface PostRunIssues {
|
||||
stopHook?: StopHookFailure;
|
||||
dirtyTree?: string;
|
||||
}
|
||||
|
||||
export function hasPostRunIssues(issues: PostRunIssues): boolean {
|
||||
return issues.stopHook !== undefined || issues.dirtyTree !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* token/cost usage data from a single agent run.
|
||||
*
|
||||
@@ -82,6 +100,12 @@ export interface AgentRunContext {
|
||||
tmpdir: string;
|
||||
instructions: ResolvedInstructions;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
/**
|
||||
* user-configured stop hook script. runs after the agent finishes each
|
||||
* attempt; non-zero exit resumes the agent with the hook output as
|
||||
* guidance. null when the repo has no stop hook configured.
|
||||
*/
|
||||
stopScript?: string | null | undefined;
|
||||
/**
|
||||
* called synchronously when the agent subprocess is killed for inner
|
||||
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
|
||||
@@ -116,7 +140,7 @@ export function formatCostUsd(costUsd: number): string {
|
||||
* merge two AgentUsage snapshots into one running total.
|
||||
*
|
||||
* both agent harnesses invoke their runner multiple times per `run()` when the
|
||||
* post-run dirty-tree loop kicks in (MAX_COMMIT_RETRIES). each invocation
|
||||
* post-run retry loop kicks in (MAX_POST_RUN_RETRIES). each invocation
|
||||
* produces its own AgentUsage; we sum them so downstream callers (usage
|
||||
* summary, WorkflowRun persistence) see the whole session — not just the
|
||||
* final retry's slice.
|
||||
|
||||
+7
-3
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||
import * as p from "@clack/prompts";
|
||||
import arg from "arg";
|
||||
import pc from "picocolors";
|
||||
import { modelAliases, type ProviderConfig, providers } from "../models.ts";
|
||||
import { modelAliases, type ProviderConfig, providers, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
const PULLFROG_API_URL = (process.env.PULLFROG_API_URL || "https://pullfrog.com").replace(
|
||||
/\/+$/,
|
||||
@@ -24,7 +24,7 @@ function buildProviders(): CliProvider[] {
|
||||
return Object.entries(providers)
|
||||
.filter(([key]) => key !== "opencode" && key !== "openrouter")
|
||||
.map(([key, config]: [string, ProviderConfig]) => {
|
||||
const aliases = modelAliases.filter((a) => a.provider === key);
|
||||
const aliases = modelAliases.filter((a) => a.provider === key && !a.fallback);
|
||||
const recommended = aliases.find((a) => a.preferred);
|
||||
const sorted = [...aliases].sort((a, b) => {
|
||||
if (a.preferred && !b.preferred) return -1;
|
||||
@@ -796,8 +796,12 @@ async function main() {
|
||||
const resolved = resolveModelProvider(secrets.model);
|
||||
if (!resolved) bail(`unknown model provider: ${secrets.model}`);
|
||||
provider = resolved;
|
||||
// walk the fallback chain so a deprecated stored slug shows the model
|
||||
// the run will actually execute against (e.g. "GPT", not "GPT Codex").
|
||||
const displayAlias = resolveDisplayAlias(secrets.model);
|
||||
const label = displayAlias ? displayAlias.displayName : secrets.model;
|
||||
spin.start("");
|
||||
spin.stop(`using model ${pc.cyan(secrets.model)}`);
|
||||
spin.stop(`using model ${pc.cyan(label)}`);
|
||||
} else {
|
||||
const providerId = await p.select({
|
||||
message: "select your preferred model provider",
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
||||
|
||||
@@ -96,4 +96,9 @@ const cliPath = "./dist/cli.mjs";
|
||||
const cliContent = readFileSync(cliPath, "utf8");
|
||||
writeFileSync(cliPath, `#!/usr/bin/env node\n${cliContent}`);
|
||||
|
||||
// copy bundled SKILL.md files into dist/ so the npm-published runtime can read
|
||||
// them via readFileSync. source-mode runs (PULLFROG_FORCE_LOCAL_CLI=1) read
|
||||
// directly from action/skills/ instead. see utils/skills.ts.
|
||||
cpSync("./skills", "./dist/skills", { recursive: true });
|
||||
|
||||
console.log("» build completed successfully");
|
||||
|
||||
+4
-2
@@ -36,7 +36,9 @@ export {
|
||||
parseModel,
|
||||
providers,
|
||||
resolveCliModel,
|
||||
resolveDisplayAlias,
|
||||
resolveModelSlug,
|
||||
resolveOpenRouterModel,
|
||||
} from "./models.ts";
|
||||
|
||||
// tool permission types shared with server dispatch
|
||||
@@ -277,8 +279,8 @@ export interface WriteablePayload {
|
||||
timeout?: string | undefined;
|
||||
/** working directory for the agent */
|
||||
cwd?: string | undefined;
|
||||
/** pre-created progress comment ID for updating status */
|
||||
progressCommentId?: string | undefined;
|
||||
/** pre-created progress comment (ID + type) for updating status */
|
||||
progressComment?: { id: string; type: "issue" | "review" } | undefined;
|
||||
}
|
||||
|
||||
// immutable payload type for agent execution
|
||||
|
||||
@@ -24,7 +24,9 @@ export {
|
||||
providers,
|
||||
pullfrogMcpName,
|
||||
resolveCliModel,
|
||||
resolveDisplayAlias,
|
||||
resolveModelSlug,
|
||||
resolveOpenRouterModel,
|
||||
} from "../external.ts";
|
||||
export type { Mode } from "../modes.ts";
|
||||
export { modes } from "../modes.ts";
|
||||
@@ -38,6 +40,17 @@ export {
|
||||
stripExistingFooter,
|
||||
} from "../utils/buildPullfrogFooter.ts";
|
||||
export type { ResourceUsage, UsageSummary } from "../utils/github.ts";
|
||||
export type {
|
||||
CreateProgressCommentTarget,
|
||||
ProgressComment,
|
||||
ProgressCommentType,
|
||||
} from "../utils/progressComment.ts";
|
||||
export {
|
||||
createLeapingProgressComment,
|
||||
deleteProgressCommentApi,
|
||||
getProgressComment,
|
||||
updateProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
export {
|
||||
isValidTimeString,
|
||||
parseTimeString,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRun
|
||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
||||
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
||||
import { handleAgentResult } from "./utils/run.ts";
|
||||
import { type AccountPlan, isInfraCovered } from "./utils/runContext.ts";
|
||||
import { resolveRunContextData } from "./utils/runContextData.ts";
|
||||
import { setEnvAllowlist } from "./utils/secrets.ts";
|
||||
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||
@@ -111,6 +112,106 @@ interface OidcCredentials {
|
||||
requestToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing-layer error surfaced from `/api/proxy-token` as a 402. User-actionable
|
||||
* — distinct from TransientError (503 / transient sync issue) so the job
|
||||
* summary + PR comment can use affirmative "you need to do X" copy rather than
|
||||
* the ambiguous "billing error" label that makes transient outages look like
|
||||
* the user's fault.
|
||||
*
|
||||
* `code` is a server-side discriminator: `router_requires_card` (no card + no
|
||||
* wallet balance on Router), or null for unclassified. `declineCode` is
|
||||
* Stripe's more specific sub-reason on `card_declined` (e.g.
|
||||
* `insufficient_funds`, `lost_card`). `needsReauthentication` is the 3DS case
|
||||
* broken out for convenience.
|
||||
*/
|
||||
class BillingError extends Error {
|
||||
code: string | null;
|
||||
declineCode: string | null;
|
||||
needsReauthentication: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
opts: {
|
||||
code?: string | null;
|
||||
declineCode?: string | null;
|
||||
needsReauthentication?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "BillingError";
|
||||
this.code = opts.code ?? null;
|
||||
this.declineCode = opts.declineCode ?? null;
|
||||
this.needsReauthentication = opts.needsReauthentication ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient service failures from `/api/proxy-token` (503: partial OpenRouter
|
||||
* usage sync, DB flake, in-flight payment intent). Not the user's fault — the
|
||||
* summary uses "temporarily unavailable" framing, and the non-zero exit lets
|
||||
* GH Actions apply whatever retry policy the workflow has configured.
|
||||
*/
|
||||
class TransientError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TransientError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a BillingError as user-facing markdown (shared between GH job summary
|
||||
* and the PR progress comment). Branches:
|
||||
*
|
||||
* - `router_requires_card`: the user is on Router mode with no card AND
|
||||
* no wallet balance. Points at the add-card flow in the console.
|
||||
* - `needsReauthentication` (Stripe `authentication_required` decline): the
|
||||
* issuer requires a 3D Secure challenge on each off-session charge —
|
||||
* re-adding the card won't help because the issuer's policy persists
|
||||
* across credentials. The escape valve is a manual top-up from the
|
||||
* dashboard, where 3DS runs interactively inside Stripe Checkout.
|
||||
* - default: generic "manage billing" with the declineCode appended if
|
||||
* classified (insufficient_funds, lost_card, generic_decline, etc).
|
||||
*/
|
||||
function formatBillingErrorSummary(error: BillingError): string {
|
||||
if (error.code === "router_requires_card") {
|
||||
return [
|
||||
"### ⛔ Pullfrog Router requires a card",
|
||||
"",
|
||||
"This run was going to use Pullfrog Router, which bills at raw OpenRouter cost and needs a card on file. Runs won't proceed until a card is added.",
|
||||
"",
|
||||
"[Add a card →](https://pullfrog.com/console#model-access) — your first $20 of Router usage is free.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.needsReauthentication) {
|
||||
return [
|
||||
"### ❌ Pullfrog billing error — card requires 3DS on every charge",
|
||||
"",
|
||||
`Your card issuer requires a 3D Secure challenge on each off-session charge (\`${error.declineCode ?? "authentication_required"}\`), which we can't run from the agent. Top up your Router credit balance manually — 3DS runs interactively in Stripe Checkout, and subsequent runs draw from the prepaid balance without triggering another off-session charge.`,
|
||||
"",
|
||||
"[Top up your Router credit balance →](https://pullfrog.com/console)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const codeSuffix = error.declineCode ? ` (\`${error.declineCode}\`)` : "";
|
||||
return `### ❌ Pullfrog billing error\n\n${error.message}${codeSuffix}\n\n[Manage billing →](https://pullfrog.com/console)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a TransientError as user-facing markdown. Distinct framing from
|
||||
* BillingError so the user doesn't read "❌" and assume their card failed.
|
||||
*/
|
||||
function formatTransientErrorSummary(error: TransientError): string {
|
||||
return [
|
||||
"### ⚠️ Pullfrog temporarily unavailable",
|
||||
"",
|
||||
error.message,
|
||||
"",
|
||||
"This is typically transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||
try {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
@@ -125,6 +226,31 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
headers: { Authorization: `Bearer ${oidcToken}` },
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
declineCode?: string;
|
||||
needsReauthentication?: boolean;
|
||||
} | null;
|
||||
throw new BillingError(body?.error ?? "insufficient balance", {
|
||||
code: body?.code ?? null,
|
||||
declineCode: body?.declineCode ?? null,
|
||||
needsReauthentication: body?.needsReauthentication ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 503 = transient sync issue (partial OpenRouter failure, DB flake,
|
||||
// in-flight top-up). Not the user's fault — TransientError renders a
|
||||
// "temporarily unavailable" summary instead of the "billing error"
|
||||
// label that BillingError uses.
|
||||
if (response.status === 503) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new TransientError(
|
||||
body?.error ?? "billing service temporarily unavailable — retry shortly"
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
log.warning(`proxy key mint failed (${response.status})`);
|
||||
return null;
|
||||
@@ -133,6 +259,8 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
const data = (await response.json()) as { key: string };
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) throw error;
|
||||
if (error instanceof TransientError) throw error;
|
||||
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
} finally {
|
||||
@@ -144,29 +272,29 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
async function resolveProxyModel(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
}): Promise<void> {
|
||||
// env override = BYOK escape hatch, don't proxy
|
||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||
|
||||
// OSS: server decided the model
|
||||
if (ctx.oss && ctx.proxyModel) {
|
||||
if (!ctx.oidcCredentials) {
|
||||
log.warning("» oss repo but no OIDC credentials available — skipping proxy");
|
||||
return;
|
||||
}
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
||||
if (!key) return;
|
||||
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||
if (!needsProxy) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
||||
if (!ctx.oidcCredentials) {
|
||||
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// managed billing will add its path here later
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
||||
if (!key) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
core.setSecret(key);
|
||||
ctx.payload.proxyModel = ctx.proxyModel;
|
||||
const label = ctx.oss ? "oss" : "router";
|
||||
log.info(`» proxy: ${label} → ${ctx.proxyModel}`);
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
@@ -191,12 +319,12 @@ export async function main(): Promise<MainResult> {
|
||||
let activityTimeout: ActivityTimeout | null = null;
|
||||
let safetyNetTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
// parse prompt early to extract progressCommentId for toolState
|
||||
// parse prompt early to extract progressComment for toolState
|
||||
const resolvedPromptInput = resolvePromptInput();
|
||||
|
||||
const toolState = initToolState({
|
||||
progressCommentId:
|
||||
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
|
||||
progressComment:
|
||||
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressComment : undefined,
|
||||
});
|
||||
|
||||
// resolve and fingerprint git binary before any agent code runs
|
||||
@@ -251,13 +379,39 @@ export async function main(): Promise<MainResult> {
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
}
|
||||
|
||||
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
||||
await resolveProxyModel({
|
||||
payload,
|
||||
oss: runContext.oss,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
});
|
||||
// Proxy decision: mint an OpenRouter key for OSS repos or managed billing
|
||||
// accounts. BillingError (402) and TransientError (503) both surface here.
|
||||
// Handle explicitly so the user sees an actionable message (job summary +
|
||||
// PR progress comment when one exists) — otherwise the error unwinds past
|
||||
// the main try/catch (which needs toolState) and lands in runMain with only
|
||||
// a generic core.setFailed.
|
||||
try {
|
||||
await resolveProxyModel({
|
||||
payload,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) {
|
||||
const summary = formatBillingErrorSummary(error);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
// Mirror to the PR progress comment if the trigger created one
|
||||
// (mention / PR event). Without this, auto-reload declines are only
|
||||
// visible in the job summary — users rarely open that, so the agent
|
||||
// just appears to silently stop mid-run.
|
||||
await reportErrorToComment({ toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof TransientError) {
|
||||
const summary = formatTransientErrorSummary(error);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
await reportErrorToComment({ toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// create octokit with MCP token for GitHub API calls
|
||||
const octokit = createOctokit(tokenRef.mcpToken);
|
||||
@@ -350,6 +504,8 @@ export async function main(): Promise<MainResult> {
|
||||
jobId: runInfo.jobId,
|
||||
mcpServerUrl: "",
|
||||
tmpdir,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
resolvedModel,
|
||||
};
|
||||
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
||||
@@ -421,6 +577,15 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
toolState.todoTracker = todoTracker;
|
||||
|
||||
// on cancellation, stop scheduling new tracker writes immediately. without this, a
|
||||
// debounced write queued just before SIGTERM could land at GitHub *after* the post-step
|
||||
// writes its "This run was cancelled" message, clobbering it back to the task list.
|
||||
// we can't await in-flight writes (the process is exiting), but cancelling the timer
|
||||
// shrinks the race window. post-cleanup has its own verify-retry loop for the rest.
|
||||
onExitSignal(() => {
|
||||
todoTracker?.cancel();
|
||||
});
|
||||
|
||||
// when the agent subprocess is killed for inner activity timeout, stop
|
||||
// the MCP HTTP server so mcp-proxy's SSE reconnect attempts don't keep
|
||||
// the outer activity timer alive. start a short safety-net timer — if
|
||||
@@ -458,6 +623,7 @@ export async function main(): Promise<MainResult> {
|
||||
tmpdir,
|
||||
instructions,
|
||||
todoTracker,
|
||||
stopScript: runContext.repoSettings.stopScript,
|
||||
onActivityTimeout: onInnerActivityTimeout,
|
||||
onToolUse: (event) => {
|
||||
const wasTracked = recordDiffReadFromToolUse({
|
||||
@@ -528,23 +694,17 @@ export async function main(): Promise<MainResult> {
|
||||
// post-agent review cleanup: reportReviewNodeId → follow-up re-review dispatch.
|
||||
// runs after the agent exits so ordering is architecturally guaranteed (no LLM involvement).
|
||||
// best-effort: cleanup failures must not turn a successful agent run into a failure.
|
||||
//
|
||||
// note: progress-comment deletion on review submission is owned by
|
||||
// create_pull_request_review (action/mcp/review.ts) and runs atomically
|
||||
// with the submission, so it survives any path out of main (success,
|
||||
// timeout, crash) without relying on cleanup ordering here.
|
||||
if (toolContext) {
|
||||
await postReviewCleanup(toolContext).catch((error) => {
|
||||
log.debug(`post-review cleanup failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// review submitted → always delete the progress comment.
|
||||
// the review is the durable artifact; the progress comment is noise.
|
||||
// defense-in-depth: covers the case where the agent calls report_progress
|
||||
// despite mode instructions, which sets finalSummaryWritten and prevents
|
||||
// the stranded-comment heuristic below from firing.
|
||||
if (toolContext && toolState.review && toolState.progressCommentId) {
|
||||
await deleteProgressComment(toolContext).catch((error) => {
|
||||
log.debug(`review progress comment cleanup failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// clean up stranded progress comments. two cases:
|
||||
// 1. wasUpdated=false: nothing wrote to the comment ("Leaping into action" orphan)
|
||||
// 2. tracker published a checklist but the agent never wrote a final summary
|
||||
@@ -556,7 +716,7 @@ export async function main(): Promise<MainResult> {
|
||||
const trackerWasLastWriter = todoTracker?.hasPublished && !toolState.finalSummaryWritten;
|
||||
if (
|
||||
toolContext &&
|
||||
toolState.progressCommentId &&
|
||||
toolState.progressComment &&
|
||||
(!toolState.wasUpdated || trackerWasLastWriter)
|
||||
) {
|
||||
await deleteProgressComment(toolContext).catch((error) => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "scratch",
|
||||
"pullNumber": 49,
|
||||
"reviewId": 3485940013,
|
||||
"review": {
|
||||
"body": "### This is the final PR Bugbot will review for you during this billing cycle\n\nYour free Bugbot reviews will reset on November 30\n\n<details>\n<summary>Details</summary>\n\nYour team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.\n\nTo receive Bugbot reviews on all of your PRs, visit the [Cursor dashboard](https://www.cursor.com/dashboard?tab=bugbot) to activate Pro and start your 14-day free trial.\n</details>\n\n",
|
||||
"user": {
|
||||
"login": "cursor[bot]"
|
||||
}
|
||||
},
|
||||
"threads": [
|
||||
{
|
||||
"id": "PRRT_kwDOPaxxp85iysVl",
|
||||
"path": ".github/workflows/test.yml",
|
||||
"line": null,
|
||||
"startLine": null,
|
||||
"diffSide": "RIGHT",
|
||||
"isResolved": true,
|
||||
"isOutdated": true,
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
"fullDatabaseId": "2544544046",
|
||||
"body": "### Bug: GitHub Actions workflow triggered for wrong branch\n\n<!-- **High Severity** -->\n\n<!-- DESCRIPTION START -->\nThe `pull_request` trigger specifies `branches: [mainc]`, but the `push` trigger specifies `branches: [main]`. This mismatch means pull requests will only trigger tests if targeting a non-existent `mainc` branch rather than the actual `main` development branch, preventing CI from running on most pull requests.\n<!-- DESCRIPTION END -->\n\n<!-- LOCATIONS START\n.github/workflows/test.yml#L6-L7\nLOCATIONS END -->\n<a href=\"https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIn0sImlhdCI6MTc2MzYyMDgxOSwiZXhwIjoxNzY0MjI1NjE5fQ.BjkWsTqiNriojI5v10JcveUY2M50f9eflTNDgWAdjdW9w7E0EEY4GJfyzBrA72neco3qAlc34WipASNuEQbTD1fZvwtJY-TeNTDzoKmwA6gtwICB8t7qT87GPvcbDrdGGWdC8kW1jf-LntTmD0k7gt0AeENRAdRSiD3dbqYFN0huXHaB8f2Y48mpmLcnnUpoaaZe7By-Y0DnILyHppwx3AH75nKE_ZeAee3rQNGX4cwcHgB5emTSM93pMDQhT1vbIRYHMaFkOaW2-kDOA8H2QqxD4mT8VzY3skvxIo5HNZCvqE84NtEygHqkBv88g2EEijOPAAeskfsdp087yIzV9g\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/fix-in-cursor-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/fix-in-cursor-light.svg\"><img alt=\"Fix in Cursor\" src=\"https://cursor.com/fix-in-cursor.svg\"></picture></a> <a href=\"https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIiwicmVwb093bmVyIjoicHVsbGZyb2dhaSIsInJlcG9OYW1lIjoic2NyYXRjaCIsInByTnVtYmVyIjo0OSwiY29tbWl0U2hhIjoiNThiOGJmNmQ1MWE1Mjg4OGFjNGFkNzA5YWVmYTk2MWFkZDMyNDBiMSJ9LCJpYXQiOjE3NjM2MjA4MTksImV4cCI6MTc2NDIyNTYxOX0.SFDZe8R9uwhPjS55J4i_mV2ybsSZoQYM6YzdUOava4IKy1IK2OrVkVsG3-8p4rRaMBXdDZZ4ObPbtk70KqdAiLEDKBqaFcWqELc49lr0XRKUmu4F6EhESFQOvt7MLSVDIOgee8YRlhS6xtoPDqsRiV2KGOwyLEdCeYdrYz9i1DanIswWSoMRVvkjxZ6GUBYVAUg_JsgAXoKVJ-L9Q5Ygho6acVAr5NlGeBp2f6g49GX4GfDOPeV3SORQS1CjxQVRbjI-g0rW55NIisBEl8279VwG6-dTISNbyasZOB6R3eEmC4vmyAAGJjUsMwqhMPw1oaMMmYNSbtZLDESxME9IUg\"><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"https://cursor.com/fix-in-web-dark.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"https://cursor.com/fix-in-web-light.svg\"><img alt=\"Fix in Web\" src=\"https://cursor.com/fix-in-web.svg\"></picture></a>\n\n",
|
||||
"createdAt": "2025-11-20T06:40:19Z",
|
||||
"diffHunk": "@@ -0,0 +1,36 @@\n+name: Test\n+\n+on:\n+ push:\n+ branches: [main]\n+ pull_request:\n+ branches: [mainc]",
|
||||
"line": null,
|
||||
"startLine": null,
|
||||
"originalLine": 7,
|
||||
"originalStartLine": null,
|
||||
"author": {
|
||||
"login": "cursor"
|
||||
},
|
||||
"pullRequestReview": {
|
||||
"databaseId": 3485940013,
|
||||
"author": {
|
||||
"login": "cursor"
|
||||
}
|
||||
},
|
||||
"reactionGroups": [
|
||||
{
|
||||
"content": "THUMBS_UP",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "THUMBS_DOWN",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "LAUGH",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "HOORAY",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "CONFUSED",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "HEART",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "ROCKET",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": "EYES",
|
||||
"reactors": {
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"prFiles": [
|
||||
{
|
||||
"filename": ".github/workflows/test.yml",
|
||||
"patch": "@@ -0,0 +1,36 @@\n+name: Test\n+\n+on:\n+ push:\n+ branches: [main]\n+ pull_request:\n+ branches: [main]\n+\n+jobs:\n+ test:\n+ runs-on: ubuntu-latest\n+\n+ strategy:\n+ matrix:\n+ node-version: [22.x]\n+\n+ steps:\n+ - name: Checkout code\n+ uses: actions/checkout@v4\n+\n+ - name: Setup pnpm\n+ uses: pnpm/action-setup@v2\n+ with:\n+ version: 8\n+\n+ - name: Setup Node.js ${{ matrix.node-version }}\n+ uses: actions/setup-node@v4\n+ with:\n+ node-version: ${{ matrix.node-version }}\n+ cache: 'pnpm'\n+\n+ - name: Install dependencies\n+ run: pnpm install\n+\n+ - name: Run tests\n+ run: pnpm test"
|
||||
},
|
||||
{
|
||||
"filename": "index.test.ts",
|
||||
"patch": "@@ -1,5 +1,5 @@\n import { describe, it, expect } from 'vitest'\n-import { add } from './index.js'\n+import { add, multiply, subtract, divide } from './index.js'\n \n describe('add function', () => {\n it('should add two positive numbers correctly', () => {\n@@ -25,3 +25,51 @@ describe('add function', () => {\n expect(add(0.1, 0.2)).toBeCloseTo(0.3)\n })\n })\n+\n+describe('multiply function', () => {\n+ it('should multiply two positive numbers correctly', () => {\n+ expect(multiply(3, 4)).toBe(12)\n+ })\n+\n+ it('should multiply negative numbers correctly', () => {\n+ expect(multiply(-2, 3)).toBe(-6)\n+ expect(multiply(-2, -3)).toBe(6)\n+ })\n+\n+ it('should handle zero correctly', () => {\n+ expect(multiply(5, 0)).toBe(0)\n+ expect(multiply(0, 5)).toBe(0)\n+ })\n+})\n+\n+describe('subtract function', () => {\n+ it('should subtract two positive numbers correctly', () => {\n+ expect(subtract(10, 3)).toBe(7)\n+ })\n+\n+ it('should handle negative numbers correctly', () => {\n+ expect(subtract(5, -3)).toBe(8)\n+ expect(subtract(-5, 3)).toBe(-8)\n+ })\n+\n+ it('should handle zero correctly', () => {\n+ expect(subtract(5, 0)).toBe(5)\n+ expect(subtract(0, 5)).toBe(-5)\n+ })\n+})\n+\n+describe('divide function', () => {\n+ it('should divide two positive numbers correctly', () => {\n+ expect(divide(10, 2)).toBe(5)\n+ })\n+\n+ it('should handle negative numbers correctly', () => {\n+ expect(divide(-10, 2)).toBe(-5)\n+ expect(divide(10, -2)).toBe(-5)\n+ })\n+\n+ it('should handle decimal results correctly', () => {\n+ expect(divide(10, 3)).toBeCloseTo(3.333, 2)\n+ expect(divide(7, 2)).toBe(3.5)\n+ })\n+})"
|
||||
},
|
||||
{
|
||||
"filename": "index.ts",
|
||||
"patch": "@@ -3,11 +3,13 @@ export function add(a: number, b: number) {\n }\n \n export function multiply(a: number, b: number) {\n- // Bug: accidentally adding 1 to the result\n- return a * b + 1;\n+ return a * b;\n }\n \n export function subtract(a: number, b: number) {\n- // Bug: accidentally adding instead of subtracting\n- return a + b;\n+ return a - b;\n+}\n+\n+export function divide(a: number, b: number) {\n+ return a / b;\n }"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "scratch",
|
||||
"pullNumber": 64,
|
||||
"reviewId": 3531000326,
|
||||
"review": {
|
||||
"body": "This PR looks great. The retry logic is well-implemented and the tests are comprehensive.",
|
||||
"user": {
|
||||
"login": "pullfrog[bot]"
|
||||
}
|
||||
},
|
||||
"threads": [],
|
||||
"prFiles": []
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"owner": "pullfrog",
|
||||
"name": "test-repo",
|
||||
"pullNumber": 1,
|
||||
"files": [
|
||||
{
|
||||
"sha": "a2d9c355792f1883c26d43d219db006b05781e4c",
|
||||
"filename": "src/format.ts",
|
||||
"status": "modified",
|
||||
"additions": 12,
|
||||
"deletions": 2,
|
||||
"changes": 14,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fformat.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fformat.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fformat.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -1,7 +1,17 @@\n-export function formatCurrency(amount: number) {\n- return `$${amount.toFixed(2)}`;\n+export function formatCurrency(amount: number, currency = \"USD\") {\n+ return new Intl.NumberFormat(\"en-US\", {\n+ style: \"currency\",\n+ currency,\n+ }).format(amount);\n }\n \n export function formatPercent(value: number) {\n return `${(value * 100).toFixed(1)}%`;\n }\n+\n+export function formatNumber(value: number, decimals = 2) {\n+ return new Intl.NumberFormat(\"en-US\", {\n+ minimumFractionDigits: decimals,\n+ maximumFractionDigits: decimals,\n+ }).format(value);\n+}"
|
||||
},
|
||||
{
|
||||
"sha": "0786b9ce6870e65c644673745266e87eef057ce4",
|
||||
"filename": "src/math.ts",
|
||||
"status": "modified",
|
||||
"additions": 5,
|
||||
"deletions": 2,
|
||||
"changes": 7,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fmath.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fmath.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fmath.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -3,13 +3,16 @@ export function add(a: number, b: number) {\n }\n \n export function subtract(a: number, b: number) {\n- return a + b; // bug: should be a - b\n+ return a - b;\n }\n \n export function multiply(a: number, b: number) {\n- return a * b + 1; // bug: off by one\n+ return a * b;\n }\n \n export function divide(a: number, b: number) {\n+ if (b === 0) {\n+ throw new Error(\"division by zero\");\n+ }\n return a / b;\n }"
|
||||
},
|
||||
{
|
||||
"sha": "cf92d8f6562c1be779506fec1049f38c9206c869",
|
||||
"filename": "src/old-module.ts",
|
||||
"status": "removed",
|
||||
"additions": 0,
|
||||
"deletions": 4,
|
||||
"changes": 4,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/91ef1048326ef786fbcf95f29b3e2555506d2d54/src%2Fold-module.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/91ef1048326ef786fbcf95f29b3e2555506d2d54/src%2Fold-module.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fold-module.ts?ref=91ef1048326ef786fbcf95f29b3e2555506d2d54",
|
||||
"patch": "@@ -1,4 +0,0 @@\n-// this module is deprecated and will be removed\n-export function legacyHelper() {\n- return \"old\";\n-}"
|
||||
},
|
||||
{
|
||||
"sha": "a5bfb8a1be72e4f0816a5c4c83ee784a06559629",
|
||||
"filename": "src/validate.ts",
|
||||
"status": "added",
|
||||
"additions": 11,
|
||||
"deletions": 0,
|
||||
"changes": 11,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fvalidate.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/src%2Fvalidate.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/src%2Fvalidate.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -0,0 +1,11 @@\n+export function isPositive(n: number) {\n+ return n > 0;\n+}\n+\n+export function isInRange(value: number, min: number, max: number) {\n+ return value >= min && value <= max;\n+}\n+\n+export function isInteger(n: number) {\n+ return Number.isInteger(n);\n+}"
|
||||
},
|
||||
{
|
||||
"sha": "5815895211d8e3355fdb77b9e216e73a248644d9",
|
||||
"filename": "test/math.test.ts",
|
||||
"status": "modified",
|
||||
"additions": 4,
|
||||
"deletions": 0,
|
||||
"changes": 4,
|
||||
"blob_url": "https://github.com/pullfrog/test-repo/blob/0311c0fb58fc7faa46e51c174394a4468f379681/test%2Fmath.test.ts",
|
||||
"raw_url": "https://github.com/pullfrog/test-repo/raw/0311c0fb58fc7faa46e51c174394a4468f379681/test%2Fmath.test.ts",
|
||||
"contents_url": "https://api.github.com/repos/pullfrog/test-repo/contents/test%2Fmath.test.ts?ref=0311c0fb58fc7faa46e51c174394a4468f379681",
|
||||
"patch": "@@ -17,4 +17,8 @@ describe(\"math\", () => {\n it(\"divides\", () => {\n expect(divide(10, 2)).toBe(5);\n });\n+\n+ it(\"throws on division by zero\", () => {\n+ expect(() => divide(1, 0)).toThrow(\"division by zero\");\n+ });\n });"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`fetchAndFormatPrDiff > generates accurate TOC line numbers for pullfrog/test-repo#1 > content 1`] = `
|
||||
exports[`formatFilesWithLineNumbers > generates accurate TOC line numbers for pullfrog/test-repo#1 > content 1`] = `
|
||||
"## Files (5)
|
||||
- src/format.ts → lines 9-32 · diff-41c7b3ac268a3a1ae5c7be92f1230f600013b7170e44a693570ccbdb183ea36b
|
||||
- src/math.ts → lines 33-55 · diff-9c6e445a719b33e276684bdf95c69e617f0303638d44cf90d61295f2720ecc63
|
||||
@@ -96,7 +96,7 @@ diff --git a/test/math.test.ts b/test/math.test.ts
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`fetchAndFormatPrDiff > generates accurate TOC line numbers for pullfrog/test-repo#1 > toc 1`] = `
|
||||
exports[`formatFilesWithLineNumbers > generates accurate TOC line numbers for pullfrog/test-repo#1 > toc 1`] = `
|
||||
"## Files (5)
|
||||
- src/format.ts → lines 9-32 · diff-41c7b3ac268a3a1ae5c7be92f1230f600013b7170e44a693570ccbdb183ea36b
|
||||
- src/math.ts → lines 33-55 · diff-9c6e445a719b33e276684bdf95c69e617f0303638d44cf90d61295f2720ecc63
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`getFormattedReviewThreads > formats body-only review > content 1`] = `
|
||||
exports[`formatReviewData > formats body-only review > content 1`] = `
|
||||
"# Review Threads (0) for PR #64 - Review 3531000326 by pullfrog[bot]
|
||||
|
||||
## Review Body
|
||||
@@ -11,9 +11,9 @@ This PR looks great. The retry logic is well-implemented and the tests are compr
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats body-only review > toc 1`] = `""`;
|
||||
exports[`formatReviewData > formats body-only review > toc 1`] = `""`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
exports[`formatReviewData > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor[bot]
|
||||
|
||||
## TOC
|
||||
@@ -68,4 +68,4 @@ LOCATIONS END -->
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`getFormattedReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 25-52"`;
|
||||
exports[`formatReviewData > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 25-52"`;
|
||||
|
||||
+42
-52
@@ -1,8 +1,7 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken, createOctokit } from "../utils/github.ts";
|
||||
import { fetchAndFormatPrDiff } from "./checkout.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { type FormatFilesResult, formatFilesWithLineNumbers } from "./checkout.ts";
|
||||
|
||||
/**
|
||||
* parses TOC entries like "- src/math.ts → lines 7-42 · diff-<hex>" into structured data.
|
||||
@@ -22,59 +21,50 @@ function parseTocEntries(toc: string) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
// prefer explicit GH_TOKEN, fall back to acquiring one via GitHub App credentials
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
// fixture captured by action/scripts/refresh-test-fixtures.ts. running
|
||||
// the formatter against checked-in JSON keeps this test offline and
|
||||
// deterministic — re-fetch the fixture (with creds) when GitHub's
|
||||
// pulls.listFiles response shape changes, then review the snapshot diff.
|
||||
type DiffFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
files: Parameters<typeof formatFilesWithLineNumbers>[0];
|
||||
};
|
||||
|
||||
function loadFixture<T>(file: string): T {
|
||||
return JSON.parse(readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")) as T;
|
||||
}
|
||||
|
||||
describe("fetchAndFormatPrDiff", () => {
|
||||
it(
|
||||
"generates accurate TOC line numbers for pullfrog/test-repo#1",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const token = await getToken();
|
||||
const octokit = createOctokit(token);
|
||||
const ctx = {
|
||||
octokit,
|
||||
repo: {
|
||||
owner: "pullfrog",
|
||||
name: "test-repo",
|
||||
data: {} as RestEndpointMethodTypes["repos"]["get"]["response"]["data"],
|
||||
},
|
||||
} as ToolContext;
|
||||
const result = await fetchAndFormatPrDiff(ctx, 1);
|
||||
describe("formatFilesWithLineNumbers", () => {
|
||||
it("generates accurate TOC line numbers for pullfrog/test-repo#1", () => {
|
||||
const fx = loadFixture<DiffFixture>("pullfrog-test-repo-pr-1.diff.json");
|
||||
const result: FormatFilesResult = formatFilesWithLineNumbers(fx.files);
|
||||
|
||||
// verify content includes TOC at the start
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
|
||||
// parse TOC and validate every entry's line numbers against actual content
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
const contentLines = result.content.split("\n");
|
||||
const tocEntries = parseTocEntries(result.toc);
|
||||
expect(tocEntries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
for (const entry of tocEntries) {
|
||||
// line numbers are 1-indexed, arrays are 0-indexed
|
||||
const firstLine = contentLines[entry.startLine - 1];
|
||||
expect(firstLine).toBeDefined();
|
||||
// first line of each file section should be the diff header
|
||||
expect(firstLine).toBe(`diff --git a/${entry.filename} b/${entry.filename}`);
|
||||
|
||||
// endLine should be within bounds
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
// current file starts right after previous file ends
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
// snapshot the full output for regression detection
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
expect(entry.endLine).toBeLessThanOrEqual(contentLines.length);
|
||||
}
|
||||
);
|
||||
|
||||
// verify adjacent files don't overlap and are contiguous
|
||||
for (let i = 1; i < tocEntries.length; i++) {
|
||||
const prev = tocEntries[i - 1];
|
||||
const curr = tocEntries[i];
|
||||
expect(curr.startLine).toBe(prev.endLine + 1);
|
||||
}
|
||||
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
+57
-61
@@ -4,6 +4,11 @@ import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrog
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import {
|
||||
createLeapingProgressComment,
|
||||
deleteProgressCommentApi,
|
||||
updateProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -184,12 +189,15 @@ export const ReportProgress = type({
|
||||
/**
|
||||
* Report progress to a GitHub comment.
|
||||
*
|
||||
* progressCommentId has three states:
|
||||
* progressComment has three states:
|
||||
* - undefined: no comment yet — will create one if an issue/PR target exists
|
||||
* - number: active comment — will update it in place
|
||||
* - object: active comment — will update it in place via the right REST endpoint for its type
|
||||
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
|
||||
*
|
||||
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
|
||||
*
|
||||
* The "existing plan comment" path always targets a top-level issue comment (plan comments are
|
||||
* created by create_issue_comment with type:"Plan", never as review-thread replies).
|
||||
*/
|
||||
export async function reportProgress(
|
||||
ctx: ToolContext,
|
||||
@@ -212,6 +220,7 @@ export async function reportProgress(
|
||||
|
||||
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
|
||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||
const apiCtx = { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name };
|
||||
|
||||
// when editing existing plan: update the plan comment from tool state (set by select_mode)
|
||||
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
|
||||
@@ -225,63 +234,57 @@ export async function reportProgress(
|
||||
const footer = buildCommentFooter(ctx, 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,
|
||||
});
|
||||
const result = await updateProgressComment(
|
||||
apiCtx,
|
||||
{ id: commentId, type: "issue" },
|
||||
bodyWithFooter
|
||||
);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
if (isPlanMode && result.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: result.id,
|
||||
url: result.html_url,
|
||||
body: result.body || "",
|
||||
action: "updated",
|
||||
};
|
||||
}
|
||||
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
const existingComment = ctx.toolState.progressComment;
|
||||
|
||||
// if we already have a progress comment, update it
|
||||
if (existingCommentId) {
|
||||
if (existingComment) {
|
||||
const customParts =
|
||||
isPlanMode && issueNumber !== undefined
|
||||
? [buildImplementPlanLink(ctx, issueNumber, existingCommentId)]
|
||||
? [buildImplementPlanLink(ctx, issueNumber, existingComment.id)]
|
||||
: undefined;
|
||||
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = buildCommentFooter(ctx, customParts);
|
||||
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
const result = await updateProgressComment(apiCtx, existingComment, bodyWithFooter);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
if (isPlanMode && result.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id });
|
||||
if (isPlanMode && result.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: result.id,
|
||||
url: result.html_url,
|
||||
body: result.body || "",
|
||||
action: "updated",
|
||||
};
|
||||
}
|
||||
|
||||
// null = progress comment was deleted by stranded-comment cleanup in main.ts
|
||||
if (existingCommentId === null) {
|
||||
if (existingComment === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
|
||||
@@ -294,49 +297,43 @@ export async function reportProgress(
|
||||
}
|
||||
|
||||
// for new comments, we need to create first, then update with Plan link if in Plan mode
|
||||
// self-created progress comments are always top-level issue comments — review-reply
|
||||
// progress comments only originate from the dispatch path and arrive pre-created.
|
||||
const initialBody = addFooter(ctx, body);
|
||||
const created = await createLeapingProgressComment(
|
||||
apiCtx,
|
||||
{ kind: "issue", issueNumber },
|
||||
initialBody
|
||||
);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
issue_number: issueNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
|
||||
// store the comment ID for future updates
|
||||
ctx.toolState.progressCommentId = result.data.id;
|
||||
ctx.toolState.progressComment = created.comment;
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
// if Plan mode, update the comment to add the "Implement plan" link
|
||||
if (isPlanMode) {
|
||||
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
|
||||
const customParts = [buildImplementPlanLink(ctx, issueNumber, created.comment.id)];
|
||||
const bodyWithoutFooter = stripExistingFooter(body);
|
||||
const footer = buildCommentFooter(ctx, customParts);
|
||||
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
|
||||
|
||||
const updateResult = await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
const updateResult = await updateProgressComment(apiCtx, created.comment, bodyWithPlanLink);
|
||||
|
||||
if (updateResult.data.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id });
|
||||
if (updateResult.node_id) {
|
||||
await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.node_id });
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: updateResult.data.id,
|
||||
url: updateResult.data.html_url,
|
||||
body: updateResult.data.body || "",
|
||||
commentId: updateResult.id,
|
||||
url: updateResult.html_url,
|
||||
body: updateResult.body || "",
|
||||
action: "created",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
body: result.data.body || "",
|
||||
commentId: created.comment.id,
|
||||
url: created.html_url,
|
||||
body: created.body || "",
|
||||
action: "created",
|
||||
};
|
||||
}
|
||||
@@ -393,20 +390,19 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
* Delete the progress comment if it exists.
|
||||
* Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or
|
||||
* checklist left by the todo tracker when the agent didn't call report_progress).
|
||||
* Sets progressCommentId to null so subsequent report_progress calls are no-ops.
|
||||
* Sets progressComment to null so subsequent report_progress calls are no-ops.
|
||||
*/
|
||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
if (!existingCommentId) {
|
||||
const existing = ctx.toolState.progressComment;
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
comment_id: existingCommentId,
|
||||
});
|
||||
await deleteProgressCommentApi(
|
||||
{ octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name },
|
||||
existing
|
||||
);
|
||||
} catch (error) {
|
||||
// ignore 404 - comment already deleted
|
||||
if (error instanceof Error && error.message.includes("Not Found")) {
|
||||
@@ -417,7 +413,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
}
|
||||
|
||||
// set to null (not undefined) so report_progress skips instead of creating a new comment
|
||||
ctx.toolState.progressCommentId = null;
|
||||
ctx.toolState.progressComment = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
+121
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyPushError } from "./git.ts";
|
||||
|
||||
// re-export the normalizeUrl function for testing
|
||||
// note: in a real scenario, we'd export this from git.ts or move to a shared utils file
|
||||
@@ -61,3 +62,123 @@ describe("push URL validation", () => {
|
||||
expect(pushUrlNormalized).toBe(actualUrlNormalized);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyPushError", () => {
|
||||
describe("concurrent-push", () => {
|
||||
it("matches client-side non-fast-forward (`fetch first`)", () => {
|
||||
const msg =
|
||||
"git push failed (exit 1): To https://github.com/o/r.git\n" +
|
||||
" ! [rejected] feature -> feature (fetch first)\n" +
|
||||
"error: failed to push some refs to 'https://github.com/o/r.git'\n" +
|
||||
"hint: Updates were rejected because the remote contains work";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
|
||||
it("matches client-side `non-fast-forward` wording", () => {
|
||||
const msg = "! [rejected] main -> main (non-fast-forward)";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
|
||||
it("matches server-side `cannot lock ref` (the case from #571)", () => {
|
||||
const msg =
|
||||
"remote: error: cannot lock ref 'refs/heads/feature': is at " +
|
||||
"abc123 but expected def456\n" +
|
||||
" ! [remote rejected] feature -> feature (cannot lock ref ...)";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transient", () => {
|
||||
it("matches RPC failed with HTTP 502", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 502"
|
||||
)
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches early EOF mid-pack", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: the remote end hung up unexpectedly\nfatal: early EOF")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches RPC failed", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: RPC failed; curl 56 OpenSSL SSL_read: Connection reset by peer")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches HTTP/2 stream not closed cleanly", () => {
|
||||
expect(
|
||||
classifyPushError("fatal: HTTP/2 stream 7 was not closed cleanly: PROTOCOL_ERROR (err 1)")
|
||||
).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches DNS resolution failure", () => {
|
||||
expect(classifyPushError("fatal: Could not resolve host: github.com")).toBe("transient");
|
||||
});
|
||||
|
||||
it("matches unexpected disconnect during sideband read", () => {
|
||||
expect(classifyPushError("fatal: unexpected disconnect while reading sideband packet")).toBe(
|
||||
"transient"
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies HTTP 429 (rate-limit / abuse detection) as transient", () => {
|
||||
// 429 is the documented exception to the otherwise-permanent 4xx class —
|
||||
// GitHub's abuse detection occasionally surfaces it on git push.
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 429"
|
||||
)
|
||||
).toBe("transient");
|
||||
expect(classifyPushError("remote: HTTP 429: too many requests")).toBe("transient");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown", () => {
|
||||
it("does NOT classify auth/403 as transient", () => {
|
||||
// permission denied is permanent within a run — retrying just wastes
|
||||
// time. must NOT match the HTTP-5xx regex.
|
||||
expect(
|
||||
classifyPushError(
|
||||
"remote: Permission to o/r.git denied to bot.\n" +
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 403"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does NOT classify protected-branch rejection as concurrent-push", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
" ! [remote rejected] main -> main (push declined due to repository rule violations)"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does NOT classify 404 as transient", () => {
|
||||
expect(
|
||||
classifyPushError(
|
||||
"fatal: unable to access 'https://github.com/o/r.git/': The requested URL returned error: 404"
|
||||
)
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for an empty message", () => {
|
||||
expect(classifyPushError("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordering", () => {
|
||||
it("prefers concurrent-push over transient when both signals appear", () => {
|
||||
// a server-side cannot-lock-ref response that also includes an HTTP
|
||||
// 5xx in the libcurl envelope should still route to the recovery
|
||||
// path, not a blind retry.
|
||||
const msg =
|
||||
"remote: error: cannot lock ref 'refs/heads/feature': is at A but expected B\n" +
|
||||
"fatal: unable to access ...: The requested URL returned error: 500";
|
||||
expect(classifyPushError(msg)).toBe("concurrent-push");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+114
-23
@@ -153,6 +153,62 @@ export const PushBranch = type({
|
||||
force: type.boolean.describe("Force push (use with caution)").default(false),
|
||||
});
|
||||
|
||||
// classify an error from `$git("push", ...)` to decide retry vs. recovery
|
||||
// vs. rethrow. exported for tests.
|
||||
//
|
||||
// - `concurrent-push`: server-side compare-and-swap failed because the ref
|
||||
// advanced between fetch and push. recovery is fetch + integrate + retry.
|
||||
// matches both the client-side detection (`fetch first` /
|
||||
// `non-fast-forward`) and the server-side detection (`cannot lock ref`
|
||||
// with `is at <SHA1> but expected <SHA2>`).
|
||||
// - `transient`: network or upstream server hiccup (RPC failed mid-stream,
|
||||
// HTTP 5xx, early EOF, reset, timeout, dns flake). push is idempotent so
|
||||
// verbatim retry with backoff is safe.
|
||||
// - `unknown`: anything else (including auth/permission/protected-branch
|
||||
// rejections). retrying these wastes time; surface to the caller.
|
||||
//
|
||||
// kept conservative: a misclassification of `unknown` -> `transient` would
|
||||
// cause two extra round-trips on a permanently-failing push, while the
|
||||
// reverse (true transient labeled `unknown`) just falls back to current
|
||||
// behavior. so we only mark as transient when the error string is
|
||||
// unambiguously a network/server-side fault, not a refusal.
|
||||
export type PushErrorKind = "concurrent-push" | "transient" | "unknown";
|
||||
|
||||
const CONCURRENT_PUSH_PATTERNS = ["fetch first", "non-fast-forward", "cannot lock ref"] as const;
|
||||
|
||||
const TRANSIENT_PATTERNS: RegExp[] = [
|
||||
/RPC failed/i,
|
||||
/early EOF/,
|
||||
/the remote end hung up unexpectedly/,
|
||||
/Connection reset/i,
|
||||
/Could not resolve host/i,
|
||||
/Operation timed out/i,
|
||||
/HTTP\/2 stream \d+ was not closed cleanly/i,
|
||||
/unexpected disconnect while reading sideband packet/i,
|
||||
// libcurl HTTP 5xx surfaced by git over https. matches both the
|
||||
// libcurl-style "The requested URL returned error: 502" and the more
|
||||
// recent "HTTP 502" wording. most 4xx is intentionally excluded —
|
||||
// 401/403/404 indicate auth/permission problems that are not
|
||||
// retry-safe — but 429 (rate-limited / abuse detection) IS retry-safe
|
||||
// and GitHub occasionally surfaces it on git push, so it's included
|
||||
// explicitly below.
|
||||
/HTTP 5\d\d/,
|
||||
/returned error: 5\d\d/i,
|
||||
/HTTP 429/,
|
||||
/returned error: 429/i,
|
||||
];
|
||||
|
||||
export function classifyPushError(msg: string): PushErrorKind {
|
||||
if (CONCURRENT_PUSH_PATTERNS.some((p) => msg.includes(p))) return "concurrent-push";
|
||||
if (TRANSIENT_PATTERNS.some((p) => p.test(msg))) return "transient";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// backoff delays before retry attempts 2 and 3. attempt 1 is the original
|
||||
// push. total worst-case added latency: ~7s. small enough that the agent
|
||||
// rarely notices, large enough to ride out most upstream hiccups.
|
||||
const TRANSIENT_RETRY_DELAYS_MS = [2000, 5000];
|
||||
|
||||
export function PushBranchTool(ctx: ToolContext) {
|
||||
const defaultBranch = ctx.repo.data.default_branch || "main";
|
||||
const pushPermission = ctx.payload.push;
|
||||
@@ -234,30 +290,65 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
log.warning(`force pushing - this will overwrite remote history`);
|
||||
}
|
||||
|
||||
try {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("fetch first") || msg.includes("non-fast-forward")) {
|
||||
// git rebase is blocked through the MCP tool when shell is disabled
|
||||
// (rebase --exec can execute arbitrary code). merge always works and
|
||||
// integrates remote changes cleanly, so suggest it as the default.
|
||||
const integrateStep =
|
||||
ctx.payload.shell === "disabled"
|
||||
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
|
||||
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
|
||||
throw new Error(
|
||||
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally.\n\n` +
|
||||
`to resolve this:\n` +
|
||||
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
||||
`${integrateStep}\n` +
|
||||
`3. resolve any merge conflicts if needed\n` +
|
||||
`4. retry push_branch`
|
||||
);
|
||||
// retry transient network/server errors (RPC failed, early EOF, 5xx,
|
||||
// connection reset, etc) with backoff. push is idempotent: if the remote
|
||||
// never received the pack, retry creates the ref; if it did, the retry
|
||||
// is a no-op fast-forward to the same SHA. concurrent-push rejections
|
||||
// and permission errors are NOT retried — they need user intervention.
|
||||
let lastErr: unknown;
|
||||
let pushed = false;
|
||||
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
if (attempt > 0) {
|
||||
log.info(`push succeeded on attempt ${attempt + 1}`);
|
||||
}
|
||||
pushed = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const kind = classifyPushError(msg);
|
||||
|
||||
if (kind === "concurrent-push") {
|
||||
// git rebase is blocked through the MCP tool when shell is disabled
|
||||
// (rebase --exec can execute arbitrary code). merge always works and
|
||||
// integrates remote changes cleanly, so suggest it as the default.
|
||||
const integrateStep =
|
||||
ctx.payload.shell === "disabled"
|
||||
? `2. use the git tool to merge the remote branch into yours: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] })`
|
||||
: `2. use the git tool to rebase or merge your changes on top: git({ command: "merge", args: ["origin/${pushDest.remoteBranch}"] }) (or 'rebase')`;
|
||||
throw new Error(
|
||||
`push rejected: the remote branch '${pushDest.remoteBranch}' has new commits you don't have locally (often a concurrent push to the same branch).\n\n` +
|
||||
`to resolve this:\n` +
|
||||
`1. use git_fetch to fetch the remote branch: git_fetch({ ref: "${pushDest.remoteBranch}" })\n` +
|
||||
`${integrateStep}\n` +
|
||||
`3. resolve any merge conflicts if needed\n` +
|
||||
`4. retry push_branch`
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "transient" && attempt < TRANSIENT_RETRY_DELAYS_MS.length) {
|
||||
// jitter avoids lockstep retries when several agents are hit by the
|
||||
// same upstream blip simultaneously — without it, all retries land
|
||||
// on the same recovering server at the same instant.
|
||||
const baseDelay = TRANSIENT_RETRY_DELAYS_MS[attempt] ?? 5000;
|
||||
const delay = Math.round(baseDelay * (0.75 + Math.random() * 0.5));
|
||||
log.info(
|
||||
`push attempt ${attempt + 1} failed (transient), retrying in ${delay}ms: ${msg.slice(0, 300)}`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!pushed) {
|
||||
// safety net — loop should always either break with success or throw.
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
commentableLinesForFile,
|
||||
createReviewWithStrandedRecovery,
|
||||
type DroppedComment,
|
||||
duplicateReviewDecision,
|
||||
formatDroppedCommentsNote,
|
||||
MAX_DROPPED_COMMENT_LINES,
|
||||
type ReviewCommentInput,
|
||||
@@ -643,3 +644,66 @@ describe("reviewSkipDecision", () => {
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateReviewDecision", () => {
|
||||
// regression: colinhacks/zod#5897 had two reviews submitted from the same
|
||||
// workflow run 8 seconds apart — a substantive review followed by an empty
|
||||
// "Reviewed — no issues found." follow-up. the agent re-classified the
|
||||
// first review's non-blocking observations as "no actionable issues" and
|
||||
// submitted the canonical body per modes.ts. this guard makes the second
|
||||
// call a no-op without burning a GitHub API call or polluting the PR.
|
||||
|
||||
it("allows the first submission when no prior review exists", () => {
|
||||
const decision = duplicateReviewDecision({
|
||||
existing: undefined,
|
||||
currentCheckoutSha: "sha1",
|
||||
});
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks a second submission when checkoutSha matches the prior reviewedSha", () => {
|
||||
// exact reproduction of the zod#5897 shape: same session, same checked-out
|
||||
// SHA, second create_pull_request_review call.
|
||||
const decision = duplicateReviewDecision({
|
||||
existing: { id: 100, reviewedSha: "sha1" },
|
||||
currentCheckoutSha: "sha1",
|
||||
});
|
||||
expect(decision?.kind).toBe("already-submitted");
|
||||
expect(decision?.reviewId).toBe(100);
|
||||
expect(decision?.reason).toContain("already submitted");
|
||||
expect(decision?.reason).toContain("checkout_pr");
|
||||
});
|
||||
|
||||
it("allows a follow-up when checkoutSha advanced past the prior reviewedSha", () => {
|
||||
// the new-commits-mid-review path advances toolState.checkoutSha to the
|
||||
// new HEAD before returning, and the agent is told to call checkout_pr
|
||||
// again — both paths leave checkoutSha != reviewedSha. those are real
|
||||
// follow-up reviews and must go through.
|
||||
const decision = duplicateReviewDecision({
|
||||
existing: { id: 100, reviewedSha: "sha-old" },
|
||||
currentCheckoutSha: "sha-new",
|
||||
});
|
||||
expect(decision).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks when checkoutSha is missing — cannot prove the SHA moved", () => {
|
||||
// if the agent never called checkout_pr, we have no anchor to compare
|
||||
// against. assume duplicate rather than letting a second review through
|
||||
// — the prior review still satisfies the agent's intent.
|
||||
const decision = duplicateReviewDecision({
|
||||
existing: { id: 100, reviewedSha: "sha1" },
|
||||
currentCheckoutSha: undefined,
|
||||
});
|
||||
expect(decision?.kind).toBe("already-submitted");
|
||||
});
|
||||
|
||||
it("blocks when prior reviewedSha is missing — cannot prove the SHA moved", () => {
|
||||
// belt-and-suspenders: if for any reason the prior review didn't capture
|
||||
// a reviewedSha, treat the second call as a duplicate to be safe.
|
||||
const decision = duplicateReviewDecision({
|
||||
existing: { id: 100, reviewedSha: undefined },
|
||||
currentCheckoutSha: "sha1",
|
||||
});
|
||||
expect(decision?.kind).toBe("already-submitted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../utils/diffCoverage.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import { deleteProgressComment } from "./comment.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -169,6 +170,58 @@ export type ReviewSkipDecision =
|
||||
| { kind: "no-issues"; reason: string }
|
||||
| { kind: "empty-downgraded-approve"; reason: string };
|
||||
|
||||
/**
|
||||
* decision returned by duplicateReviewDecision when a session has already
|
||||
* submitted a review and the current call would be a duplicate.
|
||||
*/
|
||||
export type DuplicateReviewDecision = {
|
||||
kind: "already-submitted";
|
||||
reviewId: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* decide whether a second create_pull_request_review call in the same session
|
||||
* is a duplicate of an earlier submission.
|
||||
*
|
||||
* the agent is instructed to call create_pull_request_review exactly once per
|
||||
* Review-mode session (see action/modes.ts), but in practice it sometimes
|
||||
* submits twice — once with substantive feedback, then again with the
|
||||
* canonical "Reviewed — no issues found." body when the prompt's branch
|
||||
* logic re-classifies non-blocking observations. the second submission is
|
||||
* always redundant: the first review is the record, and the duplicate just
|
||||
* adds noise to the PR.
|
||||
*
|
||||
* legitimate follow-up reviews after new commits ARE allowed: the
|
||||
* new-commits-mid-review path advances toolState.checkoutSha past the
|
||||
* previously reviewed sha, and a subsequent checkout_pr advances it again.
|
||||
* any call where checkoutSha has moved past the prior reviewedSha is a real
|
||||
* follow-up and goes through. anything else — same sha, or no checkoutSha
|
||||
* to compare against — is a duplicate.
|
||||
*/
|
||||
export function duplicateReviewDecision(params: {
|
||||
existing: { id: number; reviewedSha: string | undefined } | undefined;
|
||||
currentCheckoutSha: string | undefined;
|
||||
}): DuplicateReviewDecision | null {
|
||||
const existing = params.existing;
|
||||
if (!existing) return null;
|
||||
// checkoutSha advanced past the prior reviewed sha — legitimate follow-up
|
||||
// (e.g. after checkout_pr re-fetched new commits the agent was nudged to
|
||||
// pull). only treat as a duplicate when we cannot prove the SHA moved.
|
||||
if (
|
||||
params.currentCheckoutSha &&
|
||||
existing.reviewedSha &&
|
||||
params.currentCheckoutSha !== existing.reviewedSha
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "already-submitted",
|
||||
reviewId: existing.id,
|
||||
reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call (call \`checkout_pr\` again first if new commits were pushed)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* decide whether to skip a review submission before any network call.
|
||||
*
|
||||
@@ -299,6 +352,26 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
// set issue context (PRs are issues)
|
||||
ctx.toolState.issueNumber = pull_number;
|
||||
|
||||
// guard against duplicate review submissions in the same session.
|
||||
// see duplicateReviewDecision for the rationale — short version: the
|
||||
// agent occasionally submits twice (substantive review + canonical
|
||||
// "no issues found" follow-up) and the second is always redundant.
|
||||
// legit re-reviews after new commits are still allowed because
|
||||
// checkout_pr advances toolState.checkoutSha past the prior reviewedSha.
|
||||
const dup = duplicateReviewDecision({
|
||||
existing: ctx.toolState.review,
|
||||
currentCheckoutSha: ctx.toolState.checkoutSha,
|
||||
});
|
||||
if (dup) {
|
||||
log.info(`skipping duplicate review submission: ${dup.reason}`);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: dup.reason,
|
||||
reviewId: dup.reviewId,
|
||||
};
|
||||
}
|
||||
|
||||
// skip empty COMMENT reviews before any GitHub call. see reviewSkipDecision
|
||||
// for the cases (no-issues vs empty-downgraded-approve) and why GitHub 422s
|
||||
// the shape we'd otherwise POST.
|
||||
@@ -464,6 +537,17 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
reviewedSha: actuallyReviewedSha,
|
||||
};
|
||||
|
||||
// a submitted review obsoletes the progress comment — the review IS the
|
||||
// durable artifact. owned here (not in main.ts) so cleanup is atomic with
|
||||
// submission and survives any path out of the run (success, timeout,
|
||||
// crash). deleteProgressComment sets progressComment = null, so a later
|
||||
// report_progress call short-circuits to a no-op.
|
||||
// best-effort: a cleanup failure must not turn a successful review into
|
||||
// a tool-call failure visible to the agent.
|
||||
await deleteProgressComment(ctx).catch((err) => {
|
||||
log.debug(`progress comment cleanup after review failed: ${err}`);
|
||||
});
|
||||
|
||||
// detect commits pushed since checkout and guide the agent to review them
|
||||
// inline instead of dispatching a separate workflow run
|
||||
if (
|
||||
|
||||
+30
-33
@@ -1,43 +1,40 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
import { getReviewData } from "./reviewComments.ts";
|
||||
import { type FormatReviewDataInput, formatReviewData } from "./reviewComments.ts";
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
// fixtures captured by action/scripts/refresh-test-fixtures.ts; re-run
|
||||
// (with creds) when GitHub's review/threads/listFiles response shape
|
||||
// changes, then review the snapshot diff.
|
||||
type ReviewFixture = FormatReviewDataInput & {
|
||||
owner: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function loadFixture(file: string): ReviewFixture {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, "__fixtures__", file), "utf-8")
|
||||
) as ReviewFixture;
|
||||
}
|
||||
|
||||
describe("getFormattedReviewThreads", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
describe("formatReviewData", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", () => {
|
||||
const fx = loadFixture("pullfrog-scratch-pr-49-review-3485940013.json");
|
||||
const result = formatReviewData(fx);
|
||||
expect(result).toBeDefined();
|
||||
if (!result) return;
|
||||
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
pullNumber: 49,
|
||||
reviewId: 3485940013,
|
||||
}))!;
|
||||
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
expect(result.formatted.toc).toMatchSnapshot("toc");
|
||||
expect(result.formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
|
||||
it("formats body-only review", { timeout: 30000 }, async () => {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
it("formats body-only review", () => {
|
||||
const fx = loadFixture("pullfrog-scratch-pr-64-review-3531000326.json");
|
||||
const result = formatReviewData(fx);
|
||||
expect(result).toBeDefined();
|
||||
if (!result) return;
|
||||
|
||||
const { formatted } = (await getReviewData({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
pullNumber: 64,
|
||||
reviewId: 3531000326,
|
||||
}))!;
|
||||
|
||||
expect(formatted.toc).toMatchSnapshot("toc");
|
||||
expect(formatted.content).toMatchSnapshot("content");
|
||||
expect(result.formatted.toc).toMatchSnapshot("toc");
|
||||
expect(result.formatted.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
|
||||
+76
-28
@@ -497,6 +497,67 @@ interface GetReviewDataInput {
|
||||
approvedBy?: string | undefined;
|
||||
}
|
||||
|
||||
// pure formatter: takes already-fetched GitHub responses and produces the
|
||||
// review data the MCP tool returns. extracted from getReviewData so tests
|
||||
// can drive it from checked-in fixtures without live API access.
|
||||
//
|
||||
// `prFiles` may be empty when `threads` is empty — callers that hit the
|
||||
// network should skip the listFiles call in that case as a perf
|
||||
// optimization. when both are empty and `review.body` is also empty, the
|
||||
// formatter returns undefined just like getReviewData.
|
||||
export interface FormatReviewDataInput {
|
||||
review: ReviewResponse;
|
||||
threads: ReviewThread[];
|
||||
prFiles: ReviewPrFile[];
|
||||
pullNumber: number;
|
||||
reviewId: number;
|
||||
}
|
||||
|
||||
export type ReviewResponse = {
|
||||
body: string | null | undefined;
|
||||
user: { login: string } | null | undefined;
|
||||
};
|
||||
|
||||
export type ReviewPrFile = {
|
||||
filename: string;
|
||||
patch?: string | undefined;
|
||||
};
|
||||
|
||||
export function formatReviewData(input: FormatReviewDataInput):
|
||||
| {
|
||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
||||
reviewer: string;
|
||||
formatted: { toc: string; content: string };
|
||||
}
|
||||
| undefined {
|
||||
const rawReviewBody = input.review.body;
|
||||
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
||||
const reviewer = input.review.user?.login ?? "unknown";
|
||||
|
||||
if (input.threads.length === 0 && !reviewBody) return undefined;
|
||||
|
||||
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||
|
||||
if (input.threads.length > 0) {
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of input.prFiles) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
threadBlocks = buildThreadBlocks(input.threads, filePatchMap, input.reviewId);
|
||||
}
|
||||
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
pullNumber: input.pullNumber,
|
||||
reviewId: input.reviewId,
|
||||
reviewer,
|
||||
reviewBody,
|
||||
});
|
||||
|
||||
return { threadBlocks, reviewer, formatted };
|
||||
}
|
||||
|
||||
export async function getReviewData(input: GetReviewDataInput): Promise<
|
||||
| {
|
||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>;
|
||||
@@ -515,38 +576,25 @@ export async function getReviewData(input: GetReviewDataInput): Promise<
|
||||
getReviewThreads(input),
|
||||
]);
|
||||
|
||||
const rawReviewBody = review.data.body;
|
||||
const reviewBody = rawReviewBody ? stripExistingFooter(rawReviewBody) : "";
|
||||
const reviewer = review.data.user?.login ?? "unknown";
|
||||
// skip listFiles when there are no threads — prFiles is only used for
|
||||
// building thread blocks, and an empty array short-circuits below.
|
||||
const prFiles =
|
||||
threads.length > 0
|
||||
? await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
|
||||
owner: input.owner,
|
||||
repo: input.name,
|
||||
pull_number: input.pullNumber,
|
||||
per_page: 100,
|
||||
})
|
||||
: [];
|
||||
|
||||
if (threads.length === 0 && !reviewBody) return undefined;
|
||||
|
||||
let threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||
|
||||
if (threads.length > 0) {
|
||||
const prFiles = await input.octokit.paginate(input.octokit.rest.pulls.listFiles, {
|
||||
owner: input.owner,
|
||||
repo: input.name,
|
||||
pull_number: input.pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of prFiles) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
threadBlocks = buildThreadBlocks(threads, filePatchMap, input.reviewId);
|
||||
}
|
||||
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
return formatReviewData({
|
||||
review: review.data,
|
||||
threads,
|
||||
prFiles,
|
||||
pullNumber: input.pullNumber,
|
||||
reviewId: input.reviewId,
|
||||
reviewer,
|
||||
reviewBody,
|
||||
});
|
||||
|
||||
return { threadBlocks, reviewer, formatted };
|
||||
}
|
||||
|
||||
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
|
||||
+21
-9
@@ -12,6 +12,12 @@ import { log } from "../utils/cli.ts";
|
||||
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import {
|
||||
type ProgressComment,
|
||||
type ProgressCommentType,
|
||||
parseProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
import type { AccountPlan } from "../utils/runContext.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
@@ -109,8 +115,8 @@ export interface ToolState {
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
||||
progressCommentId: number | null | undefined;
|
||||
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
||||
progressComment: ProgressComment | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
@@ -132,20 +138,19 @@ export interface ToolState {
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressCommentId: string | undefined;
|
||||
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
||||
const resolved = parseProgressComment(params.progressComment);
|
||||
|
||||
if (resolvedId) {
|
||||
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
||||
if (resolved) {
|
||||
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressCommentId: resolvedId,
|
||||
hadProgressComment: !!resolvedId,
|
||||
progressComment: resolved,
|
||||
hadProgressComment: !!resolved,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
@@ -169,6 +174,13 @@ export interface ToolContext {
|
||||
jobId: string | undefined;
|
||||
mcpServerUrl: string;
|
||||
tmpdir: string;
|
||||
// repo-level OSS flag + account-level billing plan. together they decide
|
||||
// whether pullfrog is paying for marginal infra — see isInfraCovered in
|
||||
// utils/runContext.ts. plan gating for things like update_learnings is
|
||||
// enforced server-side via 402, so we pass plan along mostly for future
|
||||
// use / observability. see wiki/pricing.md.
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
// resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview").
|
||||
// undefined when payload.proxyModel is set or when the alias is unresolvable.
|
||||
// used by the schema sanitizer to detect Gemini-routed traffic.
|
||||
|
||||
+78
-4
@@ -6,7 +6,9 @@ import {
|
||||
parseModel,
|
||||
providers,
|
||||
resolveCliModel,
|
||||
resolveDisplayAlias,
|
||||
resolveModelSlug,
|
||||
resolveOpenRouterModel,
|
||||
} from "./models.ts";
|
||||
|
||||
describe("parseModel", () => {
|
||||
@@ -28,7 +30,7 @@ describe("parseModel", () => {
|
||||
describe("getModelProvider", () => {
|
||||
it("extracts provider from slug", () => {
|
||||
expect(getModelProvider("anthropic/claude-opus")).toBe("anthropic");
|
||||
expect(getModelProvider("openai/gpt-codex")).toBe("openai");
|
||||
expect(getModelProvider("openai/gpt")).toBe("openai");
|
||||
expect(getModelProvider("google/gemini-pro")).toBe("google");
|
||||
});
|
||||
});
|
||||
@@ -56,7 +58,6 @@ describe("getModelEnvVars", () => {
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/nemotron-3-super-free")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||
@@ -71,8 +72,12 @@ describe("resolveModelSlug", () => {
|
||||
});
|
||||
|
||||
it("resolves openai alias", () => {
|
||||
const resolved = resolveModelSlug("openai/gpt-codex");
|
||||
expect(resolved).toBe("openai/gpt-5.3-codex");
|
||||
const resolved = resolveModelSlug("openai/gpt");
|
||||
expect(resolved).toBe("openai/gpt-5.5");
|
||||
});
|
||||
|
||||
it("returns the raw resolve for deprecated aliases (does not walk fallback)", () => {
|
||||
expect(resolveModelSlug("openai/gpt-codex")).toBe("openai/gpt-5.3-codex");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown slug", () => {
|
||||
@@ -89,6 +94,75 @@ describe("resolveCliModel", () => {
|
||||
it("returns undefined for unknown slug", () => {
|
||||
expect(resolveCliModel("bogus/nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("walks fallback chain for deprecated deepseek aliases", () => {
|
||||
expect(resolveCliModel("deepseek/deepseek-reasoner")).toBe("deepseek/deepseek-v4-pro");
|
||||
expect(resolveCliModel("deepseek/deepseek-chat")).toBe("deepseek/deepseek-v4-flash");
|
||||
});
|
||||
|
||||
it("walks fallback chain for deprecated openai codex aliases", () => {
|
||||
expect(resolveCliModel("openai/gpt-codex")).toBe("openai/gpt-5.5");
|
||||
expect(resolveCliModel("openai/gpt-codex-mini")).toBe("openai/gpt-5.4-mini");
|
||||
expect(resolveCliModel("opencode/gpt-codex")).toBe("opencode/gpt-5.5");
|
||||
expect(resolveCliModel("openrouter/gpt-codex")).toBe("openrouter/openai/gpt-5.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDisplayAlias", () => {
|
||||
it("returns the alias itself for a non-deprecated slug", () => {
|
||||
const alias = resolveDisplayAlias("anthropic/claude-opus");
|
||||
expect(alias?.slug).toBe("anthropic/claude-opus");
|
||||
expect(alias?.displayName).toBe("Claude Opus");
|
||||
});
|
||||
|
||||
it("walks fallback chain to terminal alias for deprecated slug", () => {
|
||||
const alias = resolveDisplayAlias("openai/gpt-codex");
|
||||
expect(alias?.slug).toBe("openai/gpt");
|
||||
expect(alias?.displayName).toBe("GPT");
|
||||
});
|
||||
|
||||
it("walks fallback chain for deepseek-reasoner -> deepseek-pro", () => {
|
||||
const alias = resolveDisplayAlias("deepseek/deepseek-reasoner");
|
||||
expect(alias?.slug).toBe("deepseek/deepseek-pro");
|
||||
expect(alias?.displayName).toBe("DeepSeek Pro");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown slug", () => {
|
||||
expect(resolveDisplayAlias("bogus/nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOpenRouterModel", () => {
|
||||
it("returns the openrouter specifier for a non-deprecated alias", () => {
|
||||
expect(resolveOpenRouterModel("anthropic/claude-opus")).toBe(
|
||||
"openrouter/anthropic/claude-opus-4.7"
|
||||
);
|
||||
});
|
||||
|
||||
it("walks fallback chain for deprecated deepseek aliases", () => {
|
||||
expect(resolveOpenRouterModel("deepseek/deepseek-reasoner")).toBe(
|
||||
"openrouter/deepseek/deepseek-v4-pro"
|
||||
);
|
||||
expect(resolveOpenRouterModel("deepseek/deepseek-chat")).toBe(
|
||||
"openrouter/deepseek/deepseek-v4-flash"
|
||||
);
|
||||
expect(resolveOpenRouterModel("openrouter/deepseek-chat")).toBe(
|
||||
"openrouter/deepseek/deepseek-v4-flash"
|
||||
);
|
||||
});
|
||||
|
||||
it("walks fallback chain for deprecated openai codex aliases", () => {
|
||||
expect(resolveOpenRouterModel("openai/gpt-codex")).toBe("openrouter/openai/gpt-5.5");
|
||||
expect(resolveOpenRouterModel("openai/gpt-codex-mini")).toBe("openrouter/openai/gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("returns undefined for free opencode models with no openrouter equivalent", () => {
|
||||
expect(resolveOpenRouterModel("opencode/big-pickle")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown slug", () => {
|
||||
expect(resolveOpenRouterModel("bogus/nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelAliases registry", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const providers = {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "anthropic/claude-opus-4-7",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
|
||||
preferred: true,
|
||||
},
|
||||
"claude-sonnet": {
|
||||
@@ -78,16 +78,38 @@ export const providers = {
|
||||
displayName: "OpenAI",
|
||||
envVars: ["OPENAI_API_KEY"],
|
||||
models: {
|
||||
gpt: {
|
||||
displayName: "GPT",
|
||||
resolve: "openai/gpt-5.5",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5",
|
||||
preferred: true,
|
||||
},
|
||||
"gpt-pro": {
|
||||
displayName: "GPT Pro",
|
||||
resolve: "openai/gpt-5.5-pro",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
|
||||
},
|
||||
"gpt-mini": {
|
||||
displayName: "GPT Mini",
|
||||
resolve: "openai/gpt-5.4-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
|
||||
},
|
||||
// legacy aliases — openai unified the codex line into the main GPT family
|
||||
// and is shutting down every "-codex" snapshot on 2026-07-23. transparently
|
||||
// upgrade existing users via the fallback chain. UI display sites resolve
|
||||
// to the terminal alias's label (so dropdown trigger + PR footers show
|
||||
// "GPT" / "GPT Mini", not the historical name).
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
preferred: true,
|
||||
fallback: "openai/gpt",
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openai/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
fallback: "openai/gpt-mini",
|
||||
},
|
||||
o3: {
|
||||
displayName: "O3",
|
||||
@@ -118,14 +140,14 @@ export const providers = {
|
||||
models: {
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "xai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
resolve: "xai/grok-4.3",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
||||
preferred: true,
|
||||
},
|
||||
"grok-fast": {
|
||||
displayName: "Grok Fast",
|
||||
resolve: "xai/grok-4-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4-fast",
|
||||
resolve: "xai/grok-4-1-fast",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.1-fast",
|
||||
},
|
||||
"grok-code-fast": {
|
||||
displayName: "Grok Code Fast",
|
||||
@@ -138,16 +160,30 @@ export const providers = {
|
||||
displayName: "DeepSeek",
|
||||
envVars: ["DEEPSEEK_API_KEY"],
|
||||
models: {
|
||||
"deepseek-pro": {
|
||||
displayName: "DeepSeek Pro",
|
||||
resolve: "deepseek/deepseek-v4-pro",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v4-pro",
|
||||
preferred: true,
|
||||
},
|
||||
"deepseek-flash": {
|
||||
displayName: "DeepSeek Flash",
|
||||
resolve: "deepseek/deepseek-v4-flash",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v4-flash",
|
||||
},
|
||||
// legacy aliases — deepseek retires these on 2026-07-24; transparently
|
||||
// upgrade existing users to the v4 family via the fallback chain.
|
||||
"deepseek-reasoner": {
|
||||
displayName: "DeepSeek Reasoner",
|
||||
resolve: "deepseek/deepseek-reasoner",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
preferred: true,
|
||||
fallback: "deepseek/deepseek-pro",
|
||||
},
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "deepseek/deepseek-chat",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
fallback: "deepseek/deepseek-flash",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -157,8 +193,8 @@ export const providers = {
|
||||
models: {
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
resolve: "moonshotai/kimi-k2.6",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
|
||||
preferred: true,
|
||||
},
|
||||
},
|
||||
@@ -177,7 +213,7 @@ export const providers = {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "opencode/claude-opus-4-7",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
|
||||
},
|
||||
"claude-sonnet": {
|
||||
displayName: "Claude Sonnet",
|
||||
@@ -189,15 +225,33 @@ export const providers = {
|
||||
resolve: "opencode/claude-haiku-4-5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
},
|
||||
gpt: {
|
||||
displayName: "GPT",
|
||||
resolve: "opencode/gpt-5.5",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5",
|
||||
},
|
||||
"gpt-pro": {
|
||||
displayName: "GPT Pro",
|
||||
resolve: "opencode/gpt-5.5-pro",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
|
||||
},
|
||||
"gpt-mini": {
|
||||
displayName: "GPT Mini",
|
||||
resolve: "opencode/gpt-5.4-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
|
||||
},
|
||||
// legacy aliases — see openai provider above for context.
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "opencode/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
fallback: "opencode/gpt",
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "opencode/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
fallback: "opencode/gpt-mini",
|
||||
},
|
||||
"gemini-pro": {
|
||||
displayName: "Gemini Pro",
|
||||
@@ -211,8 +265,8 @@ export const providers = {
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "opencode/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
resolve: "opencode/kimi-k2.6",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
displayName: "GPT Nano",
|
||||
@@ -233,12 +287,6 @@ export const providers = {
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
"nemotron-3-super-free": {
|
||||
displayName: "Nemotron 3 Super",
|
||||
resolve: "opencode/nemotron-3-super-free",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
openrouter: provider({
|
||||
@@ -247,8 +295,8 @@ export const providers = {
|
||||
models: {
|
||||
"claude-opus": {
|
||||
displayName: "Claude Opus",
|
||||
resolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.6",
|
||||
resolve: "openrouter/anthropic/claude-opus-4.7",
|
||||
openRouterResolve: "openrouter/anthropic/claude-opus-4.7",
|
||||
preferred: true,
|
||||
},
|
||||
"claude-sonnet": {
|
||||
@@ -261,15 +309,33 @@ export const providers = {
|
||||
resolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
openRouterResolve: "openrouter/anthropic/claude-haiku-4.5",
|
||||
},
|
||||
gpt: {
|
||||
displayName: "GPT",
|
||||
resolve: "openrouter/openai/gpt-5.5",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5",
|
||||
},
|
||||
"gpt-pro": {
|
||||
displayName: "GPT Pro",
|
||||
resolve: "openrouter/openai/gpt-5.5-pro",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.5-pro",
|
||||
},
|
||||
"gpt-mini": {
|
||||
displayName: "GPT Mini",
|
||||
resolve: "openrouter/openai/gpt-5.4-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.4-mini",
|
||||
},
|
||||
// legacy aliases — see openai provider for context.
|
||||
"gpt-codex": {
|
||||
displayName: "GPT Codex",
|
||||
resolve: "openrouter/openai/gpt-5.3-codex",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.3-codex",
|
||||
fallback: "openrouter/gpt",
|
||||
},
|
||||
"gpt-codex-mini": {
|
||||
displayName: "GPT Codex Mini",
|
||||
resolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||
fallback: "openrouter/gpt-mini",
|
||||
},
|
||||
"o4-mini": {
|
||||
displayName: "O4 Mini",
|
||||
@@ -288,18 +354,31 @@ export const providers = {
|
||||
},
|
||||
grok: {
|
||||
displayName: "Grok",
|
||||
resolve: "openrouter/x-ai/grok-4",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4",
|
||||
resolve: "openrouter/x-ai/grok-4.3",
|
||||
openRouterResolve: "openrouter/x-ai/grok-4.3",
|
||||
},
|
||||
"deepseek-pro": {
|
||||
displayName: "DeepSeek Pro",
|
||||
resolve: "openrouter/deepseek/deepseek-v4-pro",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v4-pro",
|
||||
},
|
||||
"deepseek-flash": {
|
||||
displayName: "DeepSeek Flash",
|
||||
resolve: "openrouter/deepseek/deepseek-v4-flash",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v4-flash",
|
||||
},
|
||||
// legacy alias — deepseek retires this on 2026-07-24; transparently
|
||||
// upgrade existing users to the v4 family via the fallback chain.
|
||||
"deepseek-chat": {
|
||||
displayName: "DeepSeek Chat",
|
||||
resolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
openRouterResolve: "openrouter/deepseek/deepseek-v3.2",
|
||||
fallback: "openrouter/deepseek-flash",
|
||||
},
|
||||
"kimi-k2": {
|
||||
displayName: "Kimi K2",
|
||||
resolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.5",
|
||||
resolve: "openrouter/moonshotai/kimi-k2.6",
|
||||
openRouterResolve: "openrouter/moonshotai/kimi-k2.6",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -367,11 +446,15 @@ export function resolveModelSlug(slug: string): string | undefined {
|
||||
const MAX_FALLBACK_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* resolve a model slug to the CLI-ready model string, following the fallback
|
||||
* chain when a model is deprecated. returns the first non-deprecated resolve
|
||||
* target, or undefined if the chain is exhausted or broken.
|
||||
* walk the fallback chain to the terminal (non-deprecated) alias.
|
||||
* returns undefined if the chain is broken, exhausted, or cyclic.
|
||||
*
|
||||
* use this in UI display sites (dropdown trigger labels, PR-comment footers,
|
||||
* etc.) so a deprecated stored slug renders as the model the user actually
|
||||
* runs against — not the historical name. selectable lists should still hide
|
||||
* deprecated aliases by filtering on `!a.fallback`.
|
||||
*/
|
||||
export function resolveCliModel(slug: string): string | undefined {
|
||||
export function resolveDisplayAlias(slug: string): ModelAlias | undefined {
|
||||
let current = slug;
|
||||
const visited = new Set<string>();
|
||||
for (let i = 0; i < MAX_FALLBACK_DEPTH; i++) {
|
||||
@@ -379,8 +462,27 @@ export function resolveCliModel(slug: string): string | undefined {
|
||||
visited.add(current);
|
||||
const alias = modelAliases.find((a) => a.slug === current);
|
||||
if (!alias) return undefined;
|
||||
if (!alias.fallback) return alias.resolve;
|
||||
if (!alias.fallback) return alias;
|
||||
current = alias.fallback;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve a model slug to the CLI-ready model string, following the fallback
|
||||
* chain when a model is deprecated. returns the first non-deprecated resolve
|
||||
* target, or undefined if the chain is exhausted or broken.
|
||||
*/
|
||||
export function resolveCliModel(slug: string): string | undefined {
|
||||
return resolveDisplayAlias(slug)?.resolve;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve a model slug to the OpenRouter-ready model string, following the
|
||||
* fallback chain when a model is deprecated. returns undefined if the chain
|
||||
* is exhausted/broken or the terminal alias has no openrouter equivalent
|
||||
* (e.g. free opencode models).
|
||||
*/
|
||||
export function resolveOpenRouterModel(slug: string): string | undefined {
|
||||
return resolveDisplayAlias(slug)?.openRouterResolve;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// changes to mode definitions should be reflected in docs/modes.mdx
|
||||
import { REVIEWER_AGENT_NAME } from "./agents/reviewer.ts";
|
||||
import { type AgentId, formatMcpToolRef, pullfrogMcpName } from "./external.ts";
|
||||
|
||||
export interface Mode {
|
||||
@@ -82,9 +83,36 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
- plan your approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach.
|
||||
- run relevant tests/lints before committing
|
||||
|
||||
4. **self-review**: delegate a read-only subagent to review your diff. the subagent must ONLY read files, grep, and search — no MCP tools, no writes, no shell commands, no side effects. provide it with the output of \`git diff\` and instruct it to look for bugs, logic errors, missing edge cases, and unintended changes. review its findings, address any valid points, and discard nitpicks or false positives. then:
|
||||
- verify only intended changes are present, no debug artifacts or commented-out code remain, and no unrelated files were modified
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
4. **self-review**: judgment call — does YOUR diff warrant a fresh-eyes pass?
|
||||
|
||||
Skip self-review (commit directly) when the diff is **genuinely trivial**:
|
||||
- doc typos, comment-only edits, whitespace/format-only, import reordering
|
||||
- lockfile or generated-code regeneration, mechanical rename whose only effect is import-path updates (size of diff is irrelevant — read the *shape*, not the line count)
|
||||
- low-risk dep patch bump from a trusted source
|
||||
|
||||
Run self-review when the diff has **any behavioral surface, however small**:
|
||||
- 1-line changes to SQL operators / comparison logic / regexes / redirects / HTTP methods / response codes
|
||||
- any change to money / tax / currency / billing / fee / refund / payout calculations or constants
|
||||
- any change to auth / permissions / roles / sessions / tokens / signature verification
|
||||
- any change to feature-flag defaults, retry counts, timeouts, rate limits, batch sizes
|
||||
- new endpoints, new code paths, new error branches — even small ones
|
||||
- mixed diffs (whitespace + a single semantic line) — the semantic line still triggers self-review
|
||||
- anything you're uncertain about
|
||||
|
||||
Tie-breaker: when in doubt, run self-review. One false-positive subagent dispatch costs cents; one false-negative shipped bug costs much more. There's no value in dispatching for a typo, but there's also no excuse for skipping on a 1-line change to a billing path.
|
||||
|
||||
Otherwise delegate the \`${REVIEWER_AGENT_NAME}\` subagent to review your diff with fresh eyes against YOUR TASK. The subagent's baked-in system prompt enforces a non-mutative + non-recursive contract: read-only file/search/web tools and read-only MCP queries only; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch. Enforcement is prose-only — restate the constraint in your dispatch instructions and do not relax it.
|
||||
|
||||
Provide the subagent with YOUR TASK, the output of \`git diff\`, and a tight summary (not raw output) of any lint/typecheck/test failures you fixed during build — what broke, root cause, the fix — so it can check that fixes addressed root causes rather than suppressed symptoms; say "no build-phase failures" if the build path was clean. Instruct it to flag bugs, logic errors, missing edge cases, gaps between request and diff, and unintended changes.
|
||||
|
||||
Delegation + research discipline (distilled from \`/anneal\` canonical — these are codified learnings from many review rounds, not theoretical best practices):
|
||||
- Do NOT summarize what you implemented — that biases the subagent toward validating the shape of your solution rather than questioning it.
|
||||
- Do NOT curate a reading list of files. Let the subagent discover scope from the diff and codebase.
|
||||
- Do NOT pre-shape output with a severity / category schema. That leaks your hypotheses; severity is your call during evaluation.
|
||||
- Do NOT defect-hunt the diff yourself in parallel with the subagent. Your role is dispatch + evaluation; doing the review yourself reintroduces the implementation bias the subagent is meant to mitigate.
|
||||
- For diffs that rely on third-party API contracts, SDK semantics, framework directives, or DB engine specifics, instruct the subagent to verify load-bearing claims via web search and quote source URLs rather than trust training data — this is the single most common review-quality failure mode.
|
||||
|
||||
Review the findings, address valid points, and discard nitpicks or false positives. The reviewer is fallible — it biases toward *recommending additions* (defensive checks for impossible cases, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards). For each finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three is not enough — a fix that improves correctness while degrading elegance still degrades the codebase. Reject bloat-shaped findings without applying them, and after applying the rest re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. The goal is code that is sound and correct *while remaining elegant*; the smallest diff that fixes the real defect almost always wins. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
|
||||
|
||||
5. **finalize**:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails — prepush errors are usually the repo's tests/lint, not infra timeouts)
|
||||
@@ -109,11 +137,12 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
|
||||
3. For each comment:
|
||||
- understand the feedback
|
||||
- make the code change using your native tools
|
||||
- record what was done
|
||||
- evaluate whether applying it would leave the code more **sound, correct, AND elegant**. reviewers are fallible and bias toward *recommending additions* (defensive checks for impossible cases, extra abstractions, comments restating obvious code, tests asserting tautologies, "just-in-case" guards). if a request would add bloat — ceremony without commensurate correctness benefit — push back in your reply rather than mechanically applying it. two-out-of-three is not enough; improving correctness while degrading elegance still degrades the code.
|
||||
- if the request stands, make the code change using your native tools; otherwise reply explaining why
|
||||
- record what was done (or why nothing was done)
|
||||
|
||||
4. Quality check:
|
||||
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
|
||||
5. Finalize:
|
||||
@@ -124,29 +153,94 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
|
||||
${learningsStep(t, 6)}`,
|
||||
},
|
||||
// Review and IncrementalReview use the multi-lens orchestrator pattern
|
||||
// (canonical source: .claude/commands/anneal.md). The orchestrator does
|
||||
// triage → parallel read-only subagent fan-out → aggregate → draft comments
|
||||
// → submit. For someone else's PR, parallel lenses (correctness, security,
|
||||
// research-validated claims, user-journey, etc.) provide breadth across
|
||||
// angles that a single subagent can't carry coherently. Build mode keeps
|
||||
// a single fresh-eyes subagent (different problem shape — orchestrator
|
||||
// wrote the code and bias-mitigation comes from delegating to one
|
||||
// subagent that doesn't share the implementation context).
|
||||
// Deliberate omission vs canonical /anneal: severity categorization in the
|
||||
// final message (the review body has its own CAUTION/IMPORTANT framing
|
||||
// instead of a severity table).
|
||||
{
|
||||
name: "Review",
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC first and treat its file line ranges as your coverage checklist.
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
|
||||
|
||||
2. For each area of change:
|
||||
- read the diff and trace data flow, check boundaries, and verify assumptions
|
||||
- plan your investigation: identify the highest-risk areas (tricky state transitions, boundary crossings, assumption chains) and prioritize depth over breadth
|
||||
- use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context
|
||||
- if the PR removes features, deletes exports, renames identifiers, or changes architectural patterns, run a dedicated impact analysis: list what changed, then use grep across code, tests, docs (\`docs/\`, \`wiki/\`), comments, configs, and UI to find stale references
|
||||
- report impact-analysis findings in the summary body, ordered by severity (runtime breakage > incorrect docs > stale comments)
|
||||
- draft inline comments with NEW line numbers from the diff — every comment must be actionable (2-3 sentences max)
|
||||
- use GitHub permalink format for code references
|
||||
- for large or cross-cutting PRs that touch disparate subsystems, consider delegating read-only subagents to investigate areas in parallel. subagents must ONLY read files, grep, and search — no MCP tools, no writes, no shell commands, no side effects. collect their findings and use them to draft comments.
|
||||
2. **triage**: orient yourself on the PR — identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). orientation only — defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
|
||||
|
||||
3. Self-critique: review all drafted comments and drop any that are praise, style preferences, speculative/unverified claims, about pre-existing code unrelated to the PR, or not actionable.
|
||||
if the PR is **genuinely trivial**, skip steps 3–4 entirely and submit \`Reviewed — no issues found.\` per step 5. there's no value in dispatching even one lens for a typo.
|
||||
|
||||
"Genuinely trivial" (skip):
|
||||
- single-word doc typo, whitespace/format-only, comment-only across any number of files
|
||||
- lockfile or generated-code regeneration (size of diff is irrelevant — read the *shape*)
|
||||
- mechanical rename whose only effect is import-path updates
|
||||
- low-risk dep patch bump
|
||||
|
||||
"Looks trivial but isn't" (do **NOT** skip — small diff, big blast radius):
|
||||
- any 1-line change to SQL / regex / auth / billing / permission / signature-verification code
|
||||
- flipping a feature-flag default, default config value, or retry/timeout constant
|
||||
- changing a money/tax/currency/fee constant by any amount
|
||||
- changing an HTTP method, redirect URL, response code, or status enum
|
||||
- tightening or loosening a comparison operator (\`<\` ↔ \`<=\`, \`==\` ↔ \`!=\`)
|
||||
- renaming a public API surface (still trivial in shape, but needs an impact lens)
|
||||
- adding a new direct dependency (supply-chain surface)
|
||||
- any "typo fix" in user-facing copy that changes meaning ("approved" → "denied")
|
||||
- mixed diffs where a semantic 1-liner is buried in whitespace/formatting changes
|
||||
|
||||
When unsure, treat as non-trivial. The cost of one extra subagent is cents; the cost of a missed billing/auth/data bug is much more.
|
||||
|
||||
otherwise pick lenses by where the PR concentrates risk — **there's no fixed count**. lens count is judgment, not a formula. concrete shapes to anchor against:
|
||||
|
||||
- **1 lens** — pure refactor / mechanical rename across many files (impact); new test file with no source change (test-integrity); small isolated bug fix (correctness); doc-only PR with non-trivial technical content (research-validated or holistic)
|
||||
- **2–3 lenses (most PRs land here)** — new CRUD endpoint (correctness + security + test-integrity); new UI flow (user-journey + correctness); a single bug fix in a non-critical subsystem (correctness + test-integrity); design doc covering one domain (research-validated + correctness or holistic)
|
||||
- **4–5 lenses (high-stakes subsystem touches)** — any billing/payments change (billing-subsystem + correctness + security + operational-readiness); new auth flow (auth-subsystem + correctness + security + test-integrity); schema migration (schema-migration-subsystem + correctness + operational-readiness + impact); cross-subsystem PR that touches billing AND auth AND schema (one subsystem lens per domain + correctness)
|
||||
- **6+ lenses** — almost always a smell; you're either covering overlapping ground or this PR should have been split. push back via the review body rather than expanding lens count.
|
||||
|
||||
lenses come in two flavors, and you can mix them:
|
||||
- **themed lenses** — a perspective applied across the whole diff (correctness, security, user-journey, performance, etc.).
|
||||
- **subsystem lenses** — a domain-scoped frame for high-stakes subsystems the PR touches (e.g. "the auth lens", "the billing lens", "the schema-migration lens"). a subsystem lens is "review the PR specifically for what could go wrong in this subsystem" and naturally combines theme + scope. **for high-stakes domains, lead with the subsystem lens rather than the generic themed equivalent** — "billing-subsystem" outperforms "correctness on billing code" because the framing primes the subagent to remember domain-specific failure modes (double-charges, refund races, currency rounding, dispute flows) the generic lens misses.
|
||||
|
||||
starter menu (combine, omit, or invent your own):
|
||||
- **correctness & invariants** — bugs, races, error handling, edge cases, state-machine boundaries
|
||||
- **impact** — when the PR removes features, deletes exports, renames identifiers, or changes architectural patterns: stale references in code, tests, docs (\`docs/\`, \`wiki/\`), comments, configs, UI
|
||||
- **research-validated assumptions** — third-party API contracts, SDK semantics, framework directives, version-gated behavior. the subagent must verify load-bearing claims via web search and quote source URLs.
|
||||
- **security** — new endpoints, authZ, input validation, secrets handling, replay/CSRF/injection, cross-tenant isolation
|
||||
- **user-journey** — UX-touching flows: walk through happy path and failure modes as a user
|
||||
- **operational readiness** — observability, alerting, migrations (forward + rollback), feature flags, on-call burden
|
||||
- **integration & cross-cutting** — API contracts between modules, backward-compat of public surfaces, multi-service ordering
|
||||
- **test integrity** — meaningful coverage for the changed behavior; deterministic; no shared-state pollution
|
||||
- **performance** — N+1 queries, hot-path allocation, latency budgets, index coverage
|
||||
- **holistic** — does the PR make sense as a whole? symmetric flows (delete for every create, rollback for every migration)?
|
||||
- **subsystem lenses** (invent as the PR demands) — auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling, etc.
|
||||
|
||||
3. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 3 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff path / target — reading the diff and the codebase is its job
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
- the read-only contract restated in your dispatch instructions so the rule is present twice (the subagent's system prompt also enforces it). The test: would this call still be a no-op if reverted? If not (PR comments, branch pushes, issue updates, set_output, label changes, dependency installs, etc.), don't make it.
|
||||
- if the lens touches external contracts, instruct the subagent to verify load-bearing claims via web search rather than trust training data, and to quote source URLs in its reasoning. action runs are non-interactive — there's no human in the loop to catch "I'm pretty sure Stripe does X."
|
||||
- ask the subagent to report findings with file paths and NEW line numbers from the diff so you can anchor inline comments without re-reading the entire diff.
|
||||
|
||||
delegation discipline:
|
||||
- do NOT lens-review the diff yourself in parallel with the subagents (your job is dispatch + comment-drafting; doing the lens work yourself reintroduces the bias the fan-out avoids)
|
||||
- do NOT summarize the PR for them (biases toward a validation frame)
|
||||
- do NOT hand them a curated reading list (let them discover scope)
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point — overlapping findings are a strong signal)
|
||||
|
||||
4. **aggregate & draft**: merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
|
||||
|
||||
for surviving findings, draft inline comments with NEW line numbers from the diff. every comment must be actionable, 2-3 sentences max. use GitHub permalink format for code references. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
|
||||
|
||||
5. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` — the review is the final record and the progress comment will be cleaned up automatically.
|
||||
|
||||
4. Submit — ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`.
|
||||
Do NOT call \`report_progress\` — the review is the final record and the progress
|
||||
comment will be cleaned up automatically.
|
||||
note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss):
|
||||
@@ -160,30 +254,57 @@ ${learningsStep(t, 6)}`,
|
||||
- **no actionable issues**:
|
||||
\`approved: true\`, body: "Reviewed — no issues found."`,
|
||||
},
|
||||
// IncrementalReview shares Review's multi-lens orchestrator pattern but
|
||||
// scopes the target to the incremental diff and adds prior-review-feedback
|
||||
// tracking. The "issues must be NEW since the last Pullfrog review" filter
|
||||
// lives at aggregation time (step 5), NOT in the subagent prompt — pushing
|
||||
// the filter into subagents matches the canonical anneal anti-pattern of
|
||||
// "list known pre-existing failures — don't flag these" and suppresses
|
||||
// signal on regressions the new commits amplified. The body-format rules
|
||||
// (Reviewed changes / Prior review feedback) are unchanged from the prior
|
||||
// version. Same severity-table omission as Review.
|
||||
{
|
||||
name: "IncrementalReview",
|
||||
description:
|
||||
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR via \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
|
||||
2. If \`incrementalDiffPath\` is present, read it to see what changed since the last review. This is a range-diff that isolates the net changes, filtering out base branch noise. If not present, fall back to reviewing the full PR diff.
|
||||
2. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
|
||||
|
||||
3. Fetch previous reviews via \`${t("list_pull_request_reviews")}\`. For the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback.
|
||||
3. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll need this in step 6 to track which prior comments were addressed.
|
||||
|
||||
4. For each area of the new changes:
|
||||
- review the incremental diff while using the full diff for context
|
||||
- check whether prior review feedback was addressed by the new commits
|
||||
- trace data flow, check boundaries, verify assumptions, consider lifecycle, spot performance issues
|
||||
- if the new commits remove, rename, or deprecate anything, run impact analysis with grep across code/tests/docs/comments/configs to find stale references and include those findings in the summary body
|
||||
- never repeat prior feedback. only comment on genuinely new issues introduced by the new commits.
|
||||
- draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable (2-3 sentences max)
|
||||
- for large or cross-cutting PRs, consider delegating read-only subagents for parallel investigation. subagents must ONLY read files, grep, and search — no MCP tools, no writes, no shell commands, no side effects. collect their findings and use them to draft comments.
|
||||
4. **triage & fan out**: orient on the *incremental* changes — domain, seams, external contracts, user-facing surfaces.
|
||||
|
||||
5. Self-critique: drop any comments that are praise, style preferences, speculative, about pre-existing code, or not actionable.
|
||||
if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 7's non-substantive path (do NOT submit a review).
|
||||
|
||||
6. **Summarize**: build two distinct sections for the review body:
|
||||
"Genuinely trivial" (skip): formatting/comment tweaks, import reordering, lockfile regen, mechanical rename of import paths, whitespace-only.
|
||||
"Looks trivial but isn't" (do NOT skip — same anti-patterns as Review mode): 1-line changes to SQL/regex/auth/billing/permissions/signature-verification code; flipping feature-flag defaults or retry/timeout constants; money/tax/HTTP-method/redirect changes; tightening or loosening a comparison operator; mixed diffs with a semantic line buried in formatting.
|
||||
When unsure, treat as non-trivial.
|
||||
|
||||
otherwise pick lenses by where the new commits concentrate risk — **there's no fixed count**, same calibration as Review mode (1 lens for pure refactor / isolated fix; 2–3 for typical features; 4–5 for high-stakes subsystem touches; 6+ is a smell). lens framing follows Review mode: themed lenses (correctness & invariants, impact when new commits remove/rename/deprecate things, research-validated assumptions, security, user-journey, operational readiness, integration & cross-cutting, test integrity, performance, holistic) and subsystem lenses (auth, billing, schema migration, etc.) — for high-stakes domains lead with the subsystem lens rather than the generic themed equivalent.
|
||||
|
||||
dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 4 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff scope (incremental diff path if available, full diff otherwise). do NOT tell them to skip pre-existing issues — that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 5), not in the subagent prompt
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
- the read-only contract restated in your dispatch instructions so the rule is present twice (the subagent's system prompt also enforces it). The test: would this call still be a no-op if reverted? If not (PR comments, branch pushes, issue updates, set_output, label changes, dependency installs, etc.), don't make it.
|
||||
- if the lens touches external contracts, instruct the subagent to verify load-bearing claims via web search and quote source URLs. action runs are non-interactive — there's no human to catch "I'm pretty sure Stripe does X."
|
||||
- ask the subagent to report findings with file paths and NEW line numbers from the full PR diff so you can anchor inline comments.
|
||||
|
||||
delegation discipline:
|
||||
- do NOT lens-review the diff yourself in parallel with the subagents
|
||||
- do NOT summarize the changes for them (biases toward validation frame)
|
||||
- do NOT hand them a curated reading list (let them discover scope)
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point)
|
||||
|
||||
5. **aggregate, draft, self-critique**: merge findings; de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 1 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 3) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable, 2-3 sentences max.
|
||||
|
||||
then check: which prior review comments were addressed by the new commits? track the addressed ones for step 6b.
|
||||
|
||||
6. **build the review body** — two distinct sections:
|
||||
a. **Reviewed changes**: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed.
|
||||
b. **Prior review feedback** (only if any were addressed): list only the prior review comments that WERE addressed by the new commits (\`- [x] safeParse instead of parse — addressed\`). omit unaddressed comments. omit this entire section if nothing was addressed. a change can appear in both sections.
|
||||
- no headings, no tables, no prose paragraphs in either section — just bullets
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pullfrog",
|
||||
"version": "0.0.202",
|
||||
"version": "0.0.204",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pullfrog": "dist/cli.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { accessSync, constants, existsSync } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import actionPackageJson from "./package.json" with { type: "json" };
|
||||
|
||||
@@ -42,9 +42,45 @@ function canAccessExecutable(path: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// reject PATH entries that an attacker can plausibly write to before pullfrog
|
||||
// runs. specifically: relative entries (., bin, etc., which resolve against
|
||||
// cwd), and anything inside the customer's checkout. an attacker who can land
|
||||
// a malicious `npx` in the repo and prepend `$GITHUB_WORKSPACE/bin` to
|
||||
// `GITHUB_PATH` from a prior workflow step would otherwise get full code
|
||||
// execution under our action token.
|
||||
//
|
||||
// on Windows the filesystem is case-insensitive but `resolve()` preserves
|
||||
// input case, so we lowercase both sides before comparing — otherwise an
|
||||
// attacker can bypass the filter by varying the case of GITHUB_WORKSPACE in
|
||||
// their injected PATH entry (`d:\a\repo` vs `D:\a\repo`).
|
||||
function normalizePathForCompare(path: string): string {
|
||||
return process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
|
||||
}
|
||||
|
||||
function isUntrustedPathEntry(entry: string, untrustedRoots: string[]): boolean {
|
||||
if (!isAbsolute(entry)) return true;
|
||||
const normalized = normalizePathForCompare(entry);
|
||||
for (const root of untrustedRoots) {
|
||||
if (normalized === root) return true;
|
||||
if (normalized.startsWith(root + sep)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUntrustedPathRoots(env: NodeJS.ProcessEnv): string[] {
|
||||
const roots: string[] = [];
|
||||
const workspace = env.GITHUB_WORKSPACE;
|
||||
if (workspace && isAbsolute(workspace)) roots.push(normalizePathForCompare(workspace));
|
||||
return roots;
|
||||
}
|
||||
|
||||
function resolveExecutable(params: { command: string; env: NodeJS.ProcessEnv }): string | null {
|
||||
const pathValue = params.env.PATH ?? "";
|
||||
const pathEntries = pathValue.split(delimiter).filter(Boolean);
|
||||
const untrustedRoots = getUntrustedPathRoots(params.env);
|
||||
const pathEntries = pathValue
|
||||
.split(delimiter)
|
||||
.filter(Boolean)
|
||||
.filter((entry) => !isUntrustedPathEntry(entry, untrustedRoots));
|
||||
const extensions =
|
||||
process.platform === "win32"
|
||||
? (params.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* refresh checked-in test fixtures for mcp/checkout.test.ts and
|
||||
* mcp/reviewComments.test.ts.
|
||||
*
|
||||
* those tests used to hit live GitHub on every run, which made them
|
||||
* cred-gated (GH_TOKEN or GITHUB_APP_ID + GITHUB_PRIVATE_KEY) and
|
||||
* non-deterministic. they now read from action/mcp/__fixtures__/*.json,
|
||||
* which this script regenerates on demand.
|
||||
*
|
||||
* run with creds set (locally via .env, or in a CI cron with secrets):
|
||||
*
|
||||
* GH_TOKEN=… node action/scripts/refresh-test-fixtures.ts
|
||||
* # or
|
||||
* GITHUB_APP_ID=… GITHUB_PRIVATE_KEY=… node action/scripts/refresh-test-fixtures.ts
|
||||
*
|
||||
* commit the resulting fixture changes; review the diff before merging
|
||||
* (anything unexpected indicates real GitHub API drift).
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import {
|
||||
REVIEW_THREADS_QUERY,
|
||||
type ReviewThread,
|
||||
type ReviewThreadsQueryResponse,
|
||||
} from "../mcp/reviewComments.ts";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "../..");
|
||||
const fixturesDir = resolve(scriptDir, "../mcp/__fixtures__");
|
||||
|
||||
loadDotenv({ path: resolve(repoRoot, ".env") });
|
||||
|
||||
type DiffFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
files: unknown;
|
||||
};
|
||||
|
||||
type ReviewFixture = {
|
||||
owner: string;
|
||||
name: string;
|
||||
pullNumber: number;
|
||||
reviewId: number;
|
||||
review: { body: string | null | undefined; user: { login: string } | null | undefined };
|
||||
threads: ReviewThread[];
|
||||
prFiles: Array<{ filename: string; patch?: string | undefined }>;
|
||||
};
|
||||
|
||||
const DIFF_TARGETS: Array<Pick<DiffFixture, "owner" | "name" | "pullNumber">> = [
|
||||
{ owner: "pullfrog", name: "test-repo", pullNumber: 1 },
|
||||
];
|
||||
|
||||
const REVIEW_TARGETS: Array<Pick<ReviewFixture, "owner" | "name" | "pullNumber" | "reviewId">> = [
|
||||
{ owner: "pullfrog", name: "scratch", pullNumber: 49, reviewId: 3485940013 },
|
||||
{ owner: "pullfrog", name: "scratch", pullNumber: 64, reviewId: 3531000326 },
|
||||
];
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
||||
return await acquireNewToken();
|
||||
}
|
||||
|
||||
async function refreshDiffFixture(
|
||||
octokit: Octokit,
|
||||
target: (typeof DIFF_TARGETS)[number]
|
||||
): Promise<void> {
|
||||
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const fixture: DiffFixture = { ...target, files };
|
||||
const path = resolve(
|
||||
fixturesDir,
|
||||
`${target.owner}-${target.name}-pr-${target.pullNumber}.diff.json`
|
||||
);
|
||||
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
|
||||
async function refreshReviewFixture(
|
||||
octokit: Octokit,
|
||||
target: (typeof REVIEW_TARGETS)[number]
|
||||
): Promise<void> {
|
||||
const [review, threadsResp] = await Promise.all([
|
||||
octokit.rest.pulls.getReview({
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
review_id: target.reviewId,
|
||||
}),
|
||||
octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: target.owner,
|
||||
name: target.name,
|
||||
prNumber: target.pullNumber,
|
||||
}),
|
||||
]);
|
||||
|
||||
const allThreads = threadsResp.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
const threads = allThreads.filter((thread): thread is ReviewThread => {
|
||||
if (!thread?.comments?.nodes) return false;
|
||||
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === target.reviewId);
|
||||
});
|
||||
|
||||
// skip listFiles entirely when there are no threads — prFiles is only
|
||||
// used for thread blocks, so an empty array short-circuits in the
|
||||
// formatter. mirrors getReviewData's runtime perf optimization and
|
||||
// keeps body-only-review fixtures small.
|
||||
const prFiles =
|
||||
threads.length > 0
|
||||
? await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner: target.owner,
|
||||
repo: target.name,
|
||||
pull_number: target.pullNumber,
|
||||
per_page: 100,
|
||||
})
|
||||
: [];
|
||||
|
||||
// strip prFiles down to the fields the formatter actually reads. keeps
|
||||
// fixtures small and avoids capturing volatile fields (sha, blob_url,
|
||||
// contents_url, etc.) that would churn unrelated to formatter behavior.
|
||||
const trimmedFiles = prFiles.map((f) => ({
|
||||
filename: f.filename,
|
||||
...(f.patch ? { patch: f.patch } : {}),
|
||||
}));
|
||||
|
||||
const fixture: ReviewFixture = {
|
||||
...target,
|
||||
review: {
|
||||
body: review.data.body,
|
||||
user: review.data.user ? { login: review.data.user.login } : null,
|
||||
},
|
||||
threads,
|
||||
prFiles: trimmedFiles,
|
||||
};
|
||||
const path = resolve(
|
||||
fixturesDir,
|
||||
`${target.owner}-${target.name}-pr-${target.pullNumber}-review-${target.reviewId}.json`
|
||||
);
|
||||
writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const token = await getToken();
|
||||
const octokit = new Octokit({ auth: token });
|
||||
mkdirSync(fixturesDir, { recursive: true });
|
||||
|
||||
for (const t of DIFF_TARGETS) await refreshDiffFixture(octokit, t);
|
||||
for (const t of REVIEW_TARGETS) await refreshReviewFixture(octokit, t);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: git-archaeology
|
||||
description: Investigate how code reached its current state — when a line, function, import, or whole file was changed or deleted, who removed it, and what it looked like before. Use when `git blame` came up empty, when content has been refactored away, or when you need the full evolution of a function across commits.
|
||||
---
|
||||
|
||||
# Git history archaeology
|
||||
|
||||
`git blame` only sees what's still in the working tree. For anything that was
|
||||
deleted, moved, or refactored away, you need the commands below. Most agents
|
||||
under-use them and end up scrolling through `git log -p` instead.
|
||||
|
||||
## Output discipline (read first)
|
||||
|
||||
`git log -p` on a long-lived file can dump tens of thousands of lines and blow
|
||||
the context window. Always:
|
||||
|
||||
1. **Start narrow.** Use `--oneline` or `--stat` to get a list of candidate
|
||||
commits.
|
||||
2. **Drill in.** Use `git show <sha> -- <path>` for the diff of one specific
|
||||
commit.
|
||||
3. **Scope the search.** Add `--since="3 months ago"`, `-n 20`, or a path
|
||||
restriction (`-- <path>`) so output stays manageable.
|
||||
4. **Avoid `git log -p` without a path filter** on any non-trivial repo.
|
||||
|
||||
## Decision tree (by agent intent)
|
||||
|
||||
### "When did this exact line, string, or import disappear?"
|
||||
|
||||
```bash
|
||||
git log -S'<exact-string>' --oneline -- <file>
|
||||
```
|
||||
|
||||
The pickaxe. Returns commits that **changed the count** of that string in the
|
||||
file. The most recent hit is typically the removal commit. Add `-p` only after
|
||||
you've narrowed to a few candidates.
|
||||
|
||||
Notes:
|
||||
- `-S` is exact-string by default. Add `--pickaxe-regex` to make it a regex.
|
||||
- The argument is "cuddled" with `-S` (`-S'foo bar'`), no space.
|
||||
- `-S` will not detect pure in-file moves (count unchanged). Use `-G` for that.
|
||||
- `--pickaxe-all` shows the entire changeset of matching commits, useful when
|
||||
a commit changes both a definition and its call sites in other files.
|
||||
|
||||
### "When did the diff stop matching this regex?"
|
||||
|
||||
```bash
|
||||
git log -G'<regex>' --oneline -- <file>
|
||||
```
|
||||
|
||||
Like `-S` but matches any added or removed hunk line against the regex. Use
|
||||
`-G` when:
|
||||
- You don't know the exact string but know a pattern.
|
||||
- You want to catch in-file moves (`-S` won't).
|
||||
- You want to find any diff that touched a pattern, even if the count was
|
||||
preserved (e.g., a refactor that changed call sites without removing the
|
||||
function).
|
||||
|
||||
### "How did this function evolve over time?"
|
||||
|
||||
```bash
|
||||
git log -L :<function-name>:<file>
|
||||
```
|
||||
|
||||
Every commit that touched the function, with diffs scoped to just the function
|
||||
body. Works for languages git understands (most mainstream ones).
|
||||
|
||||
### "How did lines N–M evolve?"
|
||||
|
||||
```bash
|
||||
git log -L <N>,<M>:<file>
|
||||
```
|
||||
|
||||
### "What's the full history of this file, including across renames?"
|
||||
|
||||
```bash
|
||||
git log --follow --oneline -- <file> # overview
|
||||
git log --follow -p -- <file> # with diffs (use sparingly)
|
||||
```
|
||||
|
||||
`--follow` only works for a single file, not directories.
|
||||
|
||||
### "Where was a now-deleted line last present?"
|
||||
|
||||
Two-step pattern when you have an exact deleted string:
|
||||
|
||||
```bash
|
||||
# 1. find a historical commit that contained the string
|
||||
git log -S'<deleted-string>' --oneline --all -- <file>
|
||||
|
||||
# 2. reverse-blame from that commit to find the last commit it survived in
|
||||
git blame --reverse <old-sha>..HEAD -- <file>
|
||||
```
|
||||
|
||||
The reverse blame tells you, for each line, the last commit it survived in
|
||||
before being modified or deleted. Pinpoints the exact deletion commit.
|
||||
|
||||
### "This file no longer exists — when was it deleted, and what was in it?"
|
||||
|
||||
```bash
|
||||
# find all commits that touched the path, even on other branches
|
||||
git log --all --full-history --oneline -- <deleted-path>
|
||||
|
||||
# the most recent of those is usually the deletion. confirm:
|
||||
git show <sha> --stat
|
||||
|
||||
# view the file's contents at any commit where it existed
|
||||
git show <sha>^:<deleted-path>
|
||||
```
|
||||
|
||||
If you don't know the path, find it from filename alone:
|
||||
|
||||
```bash
|
||||
# list all delete events with paths
|
||||
git log --all --diff-filter=D --summary | grep -i '<filename>'
|
||||
|
||||
# or glob across all branches
|
||||
git log --all --oneline -- '**/<filename>.*'
|
||||
```
|
||||
|
||||
### "Who deleted it, in one shot?"
|
||||
|
||||
```bash
|
||||
git rev-list -n 1 HEAD -- <deleted-path> # the deletion commit
|
||||
git show $(git rev-list -n 1 HEAD -- <deleted-path>) -- <deleted-path>
|
||||
```
|
||||
|
||||
### "Restore a deleted file (locally, no commit)"
|
||||
|
||||
```bash
|
||||
git restore --source=<deletion-sha>^ -- <deleted-path>
|
||||
# or, on older git:
|
||||
git checkout <deletion-sha>^ -- <deleted-path>
|
||||
```
|
||||
|
||||
The `^` is critical — at the deletion commit the file is already gone, so we
|
||||
read from its parent.
|
||||
|
||||
### "Search commit messages, not content"
|
||||
|
||||
```bash
|
||||
git log --all --grep='<text>' --oneline
|
||||
git log --all --grep='<text>' -i --oneline # case-insensitive
|
||||
```
|
||||
|
||||
Orthogonal to `-S`/`-G`, which only see the diff.
|
||||
|
||||
## Standard workflow for "why does this code look like this"
|
||||
|
||||
1. `git log --follow --oneline -- <file>` — overview of commits touching it.
|
||||
2. If a recent commit looks suspicious: `git show <sha> -- <file>`.
|
||||
3. If you expected to find something and it's missing:
|
||||
`git log -S'<expected-string>' --oneline -- <file>`.
|
||||
4. For a specific function's full lifecycle:
|
||||
`git log -L :<fn>:<file>`.
|
||||
5. For the deletion point of a known string: pickaxe to find an old commit
|
||||
that contained it, then `git blame --reverse <old-sha>..HEAD -- <file>`.
|
||||
|
||||
## Useful flags reference
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--all` | Search all refs, not just the current branch. Use when investigating something that may have lived only on a feature branch. |
|
||||
| `--full-history` | Keeps commits that history-simplification would otherwise drop. Needed for accurate history across merges. |
|
||||
| `--follow` | Track a single file across renames. Single-file only. |
|
||||
| `-M` / `-C` | Detect renames (`-M`) and copies (`-C`) when reading diffs. |
|
||||
| `--diff-filter=D` | Restrict to commits that **deleted** something. `A`=added, `M`=modified, `R`=renamed. |
|
||||
| `--source` | When combined with `--all`, annotate each commit with the ref it was reached from. |
|
||||
| `--pickaxe-all` | With `-S`/`-G`, show all files in the matching commit, not just the matching file. |
|
||||
| `--pickaxe-regex` | Treat the `-S` argument as a regex. |
|
||||
| `--since` / `--until` | Time-bound the search. Cheap perf win on big repos. |
|
||||
| `-n <count>` | Cap result count. |
|
||||
| `--stat` | Per-commit file stats instead of full patches. Good first pass. |
|
||||
|
||||
## Notes and pitfalls
|
||||
|
||||
- Always include `--` before paths to disambiguate from refs (e.g.
|
||||
`git log -S'foo' -- src/auth.ts`).
|
||||
- `-S` triggers on **count change**. A pure refactor that moves a line within
|
||||
the same file will not match. Use `-G` for those.
|
||||
- `-G` runs diff twice and greps; it's slower than `-S`. Scope with paths and
|
||||
`--since` on big repos.
|
||||
- Without `--all`, `git log -- <path>` shows nothing if the path never existed
|
||||
on the current branch. When in doubt, add `--all`.
|
||||
- `git log --full-history -- <path>` alone has had bugs in some git versions
|
||||
for deleted files; pair with `--all` for reliability.
|
||||
- For files that were renamed, `git log -- <new-path>` only shows post-rename
|
||||
history. Use `--follow` (one file) or `git log --all -- <old-path>` when
|
||||
hunting across rename events.
|
||||
@@ -7,32 +7,32 @@ exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
||||
"releaseDate": "2026-04-16",
|
||||
},
|
||||
"deepseek": {
|
||||
"modelId": "deepseek-reasoner",
|
||||
"releaseDate": "2025-12-01",
|
||||
"modelId": "deepseek-v4-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"google": {
|
||||
"modelId": "gemma-4-31b-it",
|
||||
"releaseDate": "2026-04-02",
|
||||
},
|
||||
"moonshotai": {
|
||||
"modelId": "kimi-k2.5",
|
||||
"releaseDate": "2026-01",
|
||||
"modelId": "kimi-k2.6",
|
||||
"releaseDate": "2026-04-21",
|
||||
},
|
||||
"openai": {
|
||||
"modelId": "gpt-5.4-nano",
|
||||
"releaseDate": "2026-03-17",
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-23",
|
||||
},
|
||||
"opencode": {
|
||||
"modelId": "claude-opus-4-7",
|
||||
"releaseDate": "2026-04-16",
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"openrouter": {
|
||||
"modelId": "anthropic/claude-opus-4.7",
|
||||
"releaseDate": "2026-04-16",
|
||||
"modelId": "x-ai/grok-4.3",
|
||||
"releaseDate": "2026-05-01",
|
||||
},
|
||||
"xai": {
|
||||
"modelId": "grok-4.20-multi-agent-0309",
|
||||
"releaseDate": "2026-03-09",
|
||||
"modelId": "grok-4.3",
|
||||
"releaseDate": "2026-05-01",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# outputs a JSON array of agent names to stdout.
|
||||
#
|
||||
# only agents whose harness file changed AND are exported from index.ts are included.
|
||||
# shared.ts/index.ts and other non-harness action changes fall back to opencode as a canary.
|
||||
# shared.ts/index.ts/postRun.ts and other non-harness action changes fall back to opencode as a canary.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -39,7 +39,7 @@ has_non_agent_change=false
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
case "$file" in
|
||||
action/agents/shared.ts|action/agents/index.ts)
|
||||
action/agents/shared.ts|action/agents/index.ts|action/agents/postRun.ts)
|
||||
has_non_agent_change=true
|
||||
;;
|
||||
action/agents/*.ts)
|
||||
|
||||
@@ -6,9 +6,18 @@
|
||||
* set MATRIX_FILTER to a substring to restrict the matrix to matching aliases
|
||||
* — useful for iterating on a single provider without paying for every model.
|
||||
*
|
||||
* passthrough pruning: openrouter/* aliases and keyed opencode/* aliases are
|
||||
* just routing-layer wrappers around models we already smoke-test directly
|
||||
* (anthropic/*, openai/*, google/*, etc). running every passthrough burns CI
|
||||
* minutes without catching anything the direct smoke doesn't. we keep one
|
||||
* canary per routing layer to validate the routing layer itself is alive;
|
||||
* slug-drift is caught separately by the `models-catalog` job. set
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 to bypass this for full validation.
|
||||
*
|
||||
* usage:
|
||||
* node action/test/list-aliases.ts
|
||||
* MATRIX_FILTER=gemini node action/test/list-aliases.ts
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 node action/test/list-aliases.ts
|
||||
*/
|
||||
import { modelAliases } from "../models.ts";
|
||||
|
||||
@@ -16,10 +25,25 @@ function agentForSlug(slug: string): "claude" | "opencode" {
|
||||
return slug.startsWith("anthropic/") ? "claude" : "opencode";
|
||||
}
|
||||
|
||||
// one canary per routing layer — proves the routing surface (auth, tool-call
|
||||
// translation) is alive without re-testing every underlying model.
|
||||
const ROUTING_CANARIES = new Set(["openrouter/claude-sonnet", "opencode/claude-sonnet"]);
|
||||
|
||||
function isPrunablePassthrough(alias: (typeof modelAliases)[number]): boolean {
|
||||
if (ROUTING_CANARIES.has(alias.slug)) return false;
|
||||
if (alias.provider === "openrouter") return true;
|
||||
// opencode FREE models (big-pickle, mimo, minimax, gpt-5-nano) are unique
|
||||
// to opencode and used in prod — keep them. only prune the keyed mirrors.
|
||||
if (alias.provider === "opencode" && !alias.isFree) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const filter = process.env.MATRIX_FILTER?.trim() ?? "";
|
||||
const includeAllPassthroughs = process.env.INCLUDE_ALL_PASSTHROUGHS === "1";
|
||||
|
||||
const matrix = modelAliases
|
||||
.filter((alias) => (filter ? alias.slug.toLowerCase().includes(filter.toLowerCase()) : true))
|
||||
.filter((alias) => includeAllPassthroughs || !isPrunablePassthrough(alias))
|
||||
.map((alias) => ({
|
||||
slug: alias.slug,
|
||||
agent: agentForSlug(alias.slug),
|
||||
|
||||
+12
-9
@@ -218,18 +218,21 @@ type RetryDecision = { retry: false } | { retry: true; reason: string; backoffMs
|
||||
* - security checks failed (sandbox breach, token leak, etc.)
|
||||
* - agent successfully ran and called set_output but produced wrong results
|
||||
*/
|
||||
// detect rate limit / quota errors across all providers
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
"Rate limit reached", // anthropic
|
||||
"Resource has been exhausted", // google/gemini
|
||||
"quota exceeded", // google/gemini
|
||||
"429", // generic HTTP 429
|
||||
"Too Many Requests", // generic
|
||||
// detect rate limit / quota errors across all providers. `\b429\b` uses word
|
||||
// boundaries because a bare "429" substring false-matches UUIDs (e.g. MCP
|
||||
// session ids like `...-4429-...`) and microsecond timestamps in agent stdout,
|
||||
// which used to send transient failures down the 60s rate-limit retry path
|
||||
// and push retries past the per-step CI timeout.
|
||||
const RATE_LIMIT_PATTERNS: RegExp[] = [
|
||||
/rate limit reached/i, // anthropic
|
||||
/resource has been exhausted/i, // google/gemini
|
||||
/quota exceeded/i, // google/gemini
|
||||
/\b429\b/, // generic HTTP 429
|
||||
/too many requests/i, // generic
|
||||
];
|
||||
|
||||
function isRateLimited(output: string): boolean {
|
||||
const lower = output.toLowerCase();
|
||||
return RATE_LIMIT_PATTERNS.some((p) => lower.includes(p.toLowerCase()));
|
||||
return RATE_LIMIT_PATTERNS.some((p) => p.test(output));
|
||||
}
|
||||
|
||||
function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDecision {
|
||||
|
||||
@@ -31,7 +31,6 @@ describe("validateAgentApiKey", () => {
|
||||
"opencode/gpt-5-nano",
|
||||
"opencode/mimo-v2-pro-free",
|
||||
"opencode/minimax-m2.5-free",
|
||||
"opencode/nemotron-3-super-free",
|
||||
]) {
|
||||
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { modelAliases } from "../models.ts";
|
||||
import { resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
|
||||
@@ -26,7 +26,9 @@ export interface BuildPullfrogFooterParams {
|
||||
}
|
||||
|
||||
function formatModelLabel(slug: string): string {
|
||||
const alias = modelAliases.find((a) => a.slug === slug);
|
||||
// walk the fallback chain so a deprecated stored slug shows the model the
|
||||
// run actually executed against (e.g. "GPT", not "GPT Codex").
|
||||
const alias = resolveDisplayAlias(slug);
|
||||
if (!alias) return `\`${slug}\``;
|
||||
return alias.isFree ? `\`${alias.displayName}\` (free)` : `\`${alias.displayName}\``;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ToolState } from "../mcp/server.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { updateProgressComment } from "./progressComment.ts";
|
||||
import { getGitHubInstallationToken } from "./token.ts";
|
||||
|
||||
interface ReportErrorParams {
|
||||
@@ -13,8 +14,8 @@ interface ReportErrorParams {
|
||||
export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void> {
|
||||
const formattedError = ctx.title ? `${ctx.title}\n\n${ctx.error}` : ctx.error;
|
||||
|
||||
const commentId = ctx.toolState.progressCommentId;
|
||||
if (!commentId) {
|
||||
const comment = ctx.toolState.progressComment;
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +40,11 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise<void
|
||||
model: ctx.toolState.model,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
body: `${formattedError}${footer}`,
|
||||
});
|
||||
await updateProgressComment(
|
||||
{ octokit, owner: repoContext.owner, repo: repoContext.name },
|
||||
comment,
|
||||
`${formattedError}${footer}`
|
||||
);
|
||||
|
||||
// mark as updated so exit handler doesn't try to update again
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
+13
-2
@@ -154,8 +154,19 @@ export async function $git(
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
log.info(`git ${subcommand} failed: ${stderr}`);
|
||||
throw new Error(`git ${subcommand} failed: ${stderr}`);
|
||||
const stdout = result.stdout.trim();
|
||||
// stderr is the primary channel for git diagnostics, but in rare cases
|
||||
// (e.g. some HTTPS smart-protocol failures) the only useful detail is
|
||||
// on stdout — without it the agent / operator sees an empty error.
|
||||
// include exit code so we can distinguish e.g. signal-killed (1 with
|
||||
// empty output) from a genuine git-level rejection.
|
||||
const detail =
|
||||
stderr && stdout
|
||||
? `${stderr}\n--- stdout ---\n${stdout}`
|
||||
: stderr || stdout || "(no output)";
|
||||
const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
|
||||
log.info(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+5
-2
@@ -23,7 +23,10 @@ export const JsonPayload = type({
|
||||
"eventInstructions?": "string",
|
||||
"event?": "object",
|
||||
"timeout?": "string | undefined",
|
||||
"progressCommentId?": "string | undefined",
|
||||
"progressComment?": type({
|
||||
id: "string",
|
||||
type: "'issue' | 'review'",
|
||||
}).or("undefined"),
|
||||
});
|
||||
|
||||
// permission levels that indicate collaborator status (have push access)
|
||||
@@ -156,7 +159,7 @@ export function resolvePayload(
|
||||
event,
|
||||
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
||||
cwd: resolveCwd(inputs.cwd),
|
||||
progressCommentId: jsonPayload?.progressCommentId,
|
||||
progressComment: jsonPayload?.progressComment,
|
||||
|
||||
// permissions: inputs > repoSettings > fallbacks
|
||||
push: inputs.push ?? repoSettings.push ?? "restricted",
|
||||
|
||||
+89
-28
@@ -4,6 +4,12 @@ import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
|
||||
import {
|
||||
getProgressComment,
|
||||
type ProgressComment,
|
||||
parseProgressComment,
|
||||
updateProgressComment,
|
||||
} from "./progressComment.ts";
|
||||
import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
@@ -50,41 +56,47 @@ function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean)
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<number | null> {
|
||||
if (!ctx.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
async function validateStuckProgressComment(
|
||||
ctx: PostCleanupContext
|
||||
): Promise<ProgressComment | null> {
|
||||
const promptComment = ctx.promptInput?.progressComment;
|
||||
if (!promptComment) {
|
||||
log.info("[post] no progressComment in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(ctx.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
const comment = parseProgressComment(promptComment);
|
||||
if (!comment) {
|
||||
log.info(`[post] progressComment.id is not a positive integer: ${promptComment.id}`);
|
||||
return null;
|
||||
}
|
||||
log.info(`[post] validating progressComment from prompt input: ${comment.id} (${comment.type})`);
|
||||
|
||||
try {
|
||||
const commentResult = await ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
});
|
||||
const fetched = await getProgressComment(
|
||||
{ octokit: ctx.octokit, owner: ctx.repoContext.owner, repo: ctx.repoContext.name },
|
||||
comment
|
||||
);
|
||||
|
||||
const body = commentResult.data.body ?? "";
|
||||
const body = fetched.body ?? "";
|
||||
|
||||
if (isLeapingIntoActionCommentBody(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
log.info(`[post] comment ${comment.id} is stuck on "Leaping into action"`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
// detect stranded todo checklists left by the tracker when the process was killed
|
||||
// before the agent could call report_progress with a final summary
|
||||
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on a todo checklist`);
|
||||
return commentId;
|
||||
log.info(`[post] comment ${comment.id} is stuck on a todo checklist`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
log.info(`[post] comment ${comment.id} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
log.info(`[post] failed to get comment ${comment.id}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -158,11 +170,13 @@ export async function runPostCleanup(): Promise<void> {
|
||||
|
||||
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
|
||||
|
||||
const commentId = await validateStuckProgressComment(ctx);
|
||||
const stuck = await validateStuckProgressComment(ctx);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
if (!stuck) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
log.info(
|
||||
`» [post] validated stuck comment: ${stuck.id} (${stuck.type}), updating with error message`
|
||||
);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody(
|
||||
@@ -170,16 +184,63 @@ export async function runPostCleanup(): Promise<void> {
|
||||
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
|
||||
);
|
||||
|
||||
await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» [post] successfully updated progress comment");
|
||||
await writeAndVerify(ctx, stuck, body);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// post-cleanup runs in a separate process from the cancelled action, so any in-flight
|
||||
// HTTP write the action's todoTracker had on the wire when SIGTERM landed can still get
|
||||
// processed by GitHub *after* our update — clobbering the cancellation message back to
|
||||
// the stale task list. (action-side mitigation: SIGTERM handler cancels the tracker; here
|
||||
// we close the remaining race by reading back our write and re-issuing if it lost.)
|
||||
const VERIFY_DELAY_MS = 3000;
|
||||
const MAX_WRITE_ATTEMPTS = 3;
|
||||
|
||||
async function writeAndVerify(
|
||||
ctx: PostCleanupContext,
|
||||
comment: ProgressComment,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
const apiCtx = {
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
};
|
||||
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
|
||||
await updateProgressComment(apiCtx, comment, body);
|
||||
await new Promise((resolve) => setTimeout(resolve, VERIFY_DELAY_MS));
|
||||
|
||||
let fetched: Awaited<ReturnType<typeof getProgressComment>>;
|
||||
try {
|
||||
fetched = await getProgressComment(apiCtx, comment);
|
||||
} catch (error) {
|
||||
// verify GET failed (5xx, secondary rate limit, network blip). the PUT itself
|
||||
// returned 200, so we trust it landed; another write-and-verify pass would just
|
||||
// amplify writes against a flaky GitHub. log and exit — if a stale tracker write
|
||||
// does clobber us, the comment will be wrong but the agent's commit + replies
|
||||
// already conveyed the substance of the run.
|
||||
log.warning(
|
||||
`[post] verify GET failed after attempt ${attempt} — trusting our PUT landed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetched.body === body) {
|
||||
log.info(
|
||||
`» [post] successfully updated progress comment (attempt ${attempt}/${MAX_WRITE_ATTEMPTS})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
`[post] body was overwritten after our write (attempt ${attempt}/${MAX_WRITE_ATTEMPTS}), retrying`
|
||||
);
|
||||
}
|
||||
log.warning(
|
||||
`[post] gave up after ${MAX_WRITE_ATTEMPTS} attempts — comment may be stale (in-flight writes from the cancelled run kept clobbering us)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Single source of truth for reading, updating, deleting, and creating "progress comments" —
|
||||
* the GitHub comments Pullfrog uses to surface a run's status.
|
||||
*
|
||||
* A progress comment can be one of two distinct GitHub entities with non-overlapping IDs and
|
||||
* distinct REST endpoints:
|
||||
* - "issue": a top-level issue/PR timeline comment (octokit.rest.issues.*Comment)
|
||||
* - "review": an inline PR review-thread comment (octokit.rest.pulls.*ReviewComment)
|
||||
*
|
||||
* Callers carry a `ProgressComment` (id + type) value end-to-end so the right endpoint is always
|
||||
* picked. Adding a third comment type later means one new branch in this file, not six.
|
||||
*/
|
||||
|
||||
export type ProgressCommentType = "issue" | "review";
|
||||
|
||||
export type ProgressComment = {
|
||||
id: number;
|
||||
type: ProgressCommentType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the on-the-wire `{ id: string; type }` shape (the form carried in `JsonPayload`)
|
||||
* into the in-memory `ProgressComment` shape. Returns undefined when the id isn't a
|
||||
* positive integer so callers can short-circuit cleanly. Callers handle logging.
|
||||
*/
|
||||
export function parseProgressComment(
|
||||
raw: { id: string; type: ProgressCommentType } | null | undefined
|
||||
): ProgressComment | undefined {
|
||||
if (!raw?.id) return undefined;
|
||||
const id = parseInt(raw.id, 10);
|
||||
if (Number.isNaN(id) || id <= 0) return undefined;
|
||||
return { id, type: raw.type };
|
||||
}
|
||||
|
||||
// minimal Octokit shape needed by the progress-comment helpers. structural so the helper
|
||||
// can be called from both the action package (@octokit/rest v22) and the root project
|
||||
// (@octokit/rest v21) without a nominal type clash. only the methods used here are listed.
|
||||
interface CommentResponse {
|
||||
data: { id: number; body?: string | null | undefined; html_url: string; node_id?: string };
|
||||
}
|
||||
export interface ProgressCommentOctokit {
|
||||
rest: {
|
||||
issues: {
|
||||
createComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
issue_number: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
getComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<CommentResponse>;
|
||||
updateComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
deleteComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
pulls: {
|
||||
createReplyForReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
pull_number: number;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
getReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<CommentResponse>;
|
||||
updateReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
body: string;
|
||||
}) => Promise<CommentResponse>;
|
||||
deleteReviewComment: (params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
comment_id: number;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiCtx {
|
||||
octokit: ProgressCommentOctokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a progress comment via the appropriate REST endpoint for its type.
|
||||
* Returns the common subset of fields callers actually use.
|
||||
*/
|
||||
export async function getProgressComment(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment
|
||||
): Promise<{ id: number; body: string | undefined; html_url: string }> {
|
||||
const result = await (comment.type === "review"
|
||||
? ctx.octokit.rest.pulls.getReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
})
|
||||
: ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
}));
|
||||
return {
|
||||
id: result.data.id,
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a progress comment in place via the appropriate REST endpoint.
|
||||
* Returns the common subset of fields callers actually use.
|
||||
*/
|
||||
export async function updateProgressComment(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment,
|
||||
body: string
|
||||
): Promise<{
|
||||
id: number;
|
||||
body: string | undefined;
|
||||
html_url: string;
|
||||
node_id: string | undefined;
|
||||
}> {
|
||||
const result = await (comment.type === "review"
|
||||
? ctx.octokit.rest.pulls.updateReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
body,
|
||||
})
|
||||
: ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
body,
|
||||
}));
|
||||
return {
|
||||
id: result.data.id,
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
node_id: result.data.node_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a progress comment via the appropriate REST endpoint.
|
||||
* Lower-level than `deleteProgressComment` in mcp/comment.ts — that one also clears
|
||||
* tool state. Callers that don't have a ToolContext (post cleanup, error handlers)
|
||||
* should use this directly; the higher-level wrapper delegates here.
|
||||
*/
|
||||
export async function deleteProgressCommentApi(
|
||||
ctx: ApiCtx,
|
||||
comment: ProgressComment
|
||||
): Promise<void> {
|
||||
if (comment.type === "review") {
|
||||
await ctx.octokit.rest.pulls.deleteReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated target for `createLeapingProgressComment`. The two variants map to the two
|
||||
* distinct GitHub create endpoints; review-reply additionally needs the parent comment ID.
|
||||
*/
|
||||
export type CreateProgressCommentTarget =
|
||||
| { kind: "issue"; issueNumber: number }
|
||||
| { kind: "reviewReply"; pullNumber: number; replyToCommentId: number };
|
||||
|
||||
export interface CreatedProgressComment {
|
||||
comment: ProgressComment;
|
||||
body: string | undefined;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial "Leaping into action..." progress comment.
|
||||
*
|
||||
* Reliability: when `kind: "reviewReply"` fails (e.g. the parent comment was deleted or the
|
||||
* thread is otherwise unreachable), falls back to a top-level issue comment on the same PR
|
||||
* rather than leaving the run with no progress surface. The fallback is logged.
|
||||
*
|
||||
* (PR # === issue # in GitHub's number space, so `pullNumber` doubles as the fallback target.)
|
||||
*/
|
||||
export async function createLeapingProgressComment(
|
||||
ctx: ApiCtx,
|
||||
target: CreateProgressCommentTarget,
|
||||
body: string
|
||||
): Promise<CreatedProgressComment> {
|
||||
if (target.kind === "reviewReply") {
|
||||
try {
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
pull_number: target.pullNumber,
|
||||
comment_id: target.replyToCommentId,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: result.data.id, type: "review" },
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
} catch (error) {
|
||||
// console.warn (not the action-flavored log.warning) because this helper runs in
|
||||
// both the action runtime and the Next.js webhook context, and we don't want a
|
||||
// ::warning:: GitHub Actions annotation leaking into Vercel logs.
|
||||
console.warn(
|
||||
`[progressComment] review reply failed (parent ${target.replyToCommentId} on PR #${target.pullNumber}), falling back to issue comment:`,
|
||||
error
|
||||
);
|
||||
const fallback = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issue_number: target.pullNumber,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: fallback.data.id, type: "issue" },
|
||||
body: fallback.data.body ?? undefined,
|
||||
html_url: fallback.data.html_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
issue_number: target.issueNumber,
|
||||
body,
|
||||
});
|
||||
return {
|
||||
comment: { id: result.data.id, type: "issue" },
|
||||
body: result.data.body ?? undefined,
|
||||
html_url: result.data.html_url,
|
||||
};
|
||||
}
|
||||
+17
-1
@@ -19,7 +19,23 @@ export async function handleAgentResult(ctx: HandleAgentResultParams): Promise<M
|
||||
};
|
||||
}
|
||||
|
||||
if (!ctx.toolState.wasUpdated && ctx.toolState.hadProgressComment && !ctx.silent) {
|
||||
// Review and IncrementalReview modes intentionally never set wasUpdated:
|
||||
// the prompt forbids report_progress (the review IS the durable record),
|
||||
// and IncrementalReview's non-substantive path produces no review at
|
||||
// all by design. wasUpdated staying false is also load-bearing for the
|
||||
// stranded-comment cleanup in main.ts which deletes the "Leaping into
|
||||
// action" orphan via `(!wasUpdated || trackerWasLastWriter)`. Skip the
|
||||
// strict completion check for these modes — the agent's exit code is
|
||||
// the completion signal, not a progress-comment write.
|
||||
// See plans/review_progress_comment_cleanup_b0120f6c.plan.md.
|
||||
const mode = ctx.toolState.selectedMode;
|
||||
const isReviewMode = mode === "Review" || mode === "IncrementalReview";
|
||||
if (
|
||||
!isReviewMode &&
|
||||
!ctx.toolState.wasUpdated &&
|
||||
ctx.toolState.hadProgressComment &&
|
||||
!ctx.silent
|
||||
) {
|
||||
const error = ctx.result.error || "agent completed without reporting progress";
|
||||
try {
|
||||
await reportErrorToComment({
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface RepoSettings {
|
||||
setupScript: string | null;
|
||||
postCheckoutScript: string | null;
|
||||
prepushScript: string | null;
|
||||
stopScript: string | null;
|
||||
push: PushPermission;
|
||||
shell: ShellPermission;
|
||||
prApproveEnabled: boolean;
|
||||
@@ -23,10 +24,27 @@ export interface RepoSettings {
|
||||
envAllowlist: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-level billing plan. Orthogonal to repo-level OSS status. Mirrors
|
||||
* the server's `AccountPlan` in `utils/billing.ts`. `"none"` = free tier,
|
||||
* `"payg"` = card on file / pay-as-you-go.
|
||||
*/
|
||||
export type AccountPlan = "none" | "payg";
|
||||
|
||||
/**
|
||||
* "Is Pullfrog absorbing marginal infra cost for this repo?" — composite
|
||||
* predicate over the two orthogonal dimensions (repo-level OSS, account-level
|
||||
* plan). Mirrors `isInfraCovered` in the server's `utils/billing.ts`.
|
||||
*/
|
||||
export function isInfraCovered(params: { isOss: boolean; plan: AccountPlan }): boolean {
|
||||
return params.isOss || params.plan === "payg";
|
||||
}
|
||||
|
||||
export interface RunContext {
|
||||
settings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
@@ -37,6 +55,7 @@ const defaultSettings: RepoSettings = {
|
||||
setupScript: null,
|
||||
postCheckoutScript: null,
|
||||
prepushScript: null,
|
||||
stopScript: null,
|
||||
push: "restricted",
|
||||
shell: "restricted",
|
||||
prApproveEnabled: false,
|
||||
@@ -49,6 +68,7 @@ const defaultRunContext: RunContext = {
|
||||
settings: defaultSettings,
|
||||
apiToken: "",
|
||||
oss: false,
|
||||
plan: "none",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -90,6 +110,7 @@ export async function fetchRunContext(params: {
|
||||
settings: RepoSettings | null;
|
||||
apiToken: string;
|
||||
oss?: boolean;
|
||||
plan?: AccountPlan;
|
||||
proxyModel?: string;
|
||||
dbSecrets?: Record<string, string>;
|
||||
} | null;
|
||||
@@ -106,9 +127,11 @@ export async function fetchRunContext(params: {
|
||||
setupScript: data.settings?.setupScript ?? null,
|
||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
||||
prepushScript: data.settings?.prepushScript ?? null,
|
||||
stopScript: data.settings?.stopScript ?? null,
|
||||
},
|
||||
apiToken: data.apiToken,
|
||||
oss: data.oss ?? false,
|
||||
plan: data.plan ?? "none",
|
||||
proxyModel: data.proxyModel,
|
||||
dbSecrets: data.dbSecrets,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Octokit } from "@octokit/rest";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "./cli.ts";
|
||||
import { type OctokitWithPlugins, parseRepoContext } from "./github.ts";
|
||||
import { fetchRunContext, type RepoSettings } from "./runContext.ts";
|
||||
import { type AccountPlan, fetchRunContext, type RepoSettings } from "./runContext.ts";
|
||||
|
||||
export interface RunContextData {
|
||||
repo: {
|
||||
@@ -14,6 +14,7 @@ export interface RunContextData {
|
||||
repoSettings: RepoSettings;
|
||||
apiToken: string;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
dbSecrets?: Record<string, string> | undefined;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export async function resolveRunContextData(
|
||||
repoSettings: runContext.settings,
|
||||
apiToken: runContext.apiToken,
|
||||
oss: runContext.oss,
|
||||
plan: runContext.plan,
|
||||
proxyModel: runContext.proxyModel,
|
||||
dbSecrets: runContext.dbSecrets,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { log } from "./cli.ts";
|
||||
import { getDevDependencyVersion } from "./version.ts";
|
||||
|
||||
const skillsVersion = getDevDependencyVersion("skills");
|
||||
|
||||
/**
|
||||
* skills bundled with the action runtime. the SKILL.md files live in
|
||||
* `action/skills/<name>/SKILL.md` and are read at runtime — no esbuild loader,
|
||||
* no codegen. this matters because the preview / oss path runs `cli.ts` from
|
||||
* source (see `runCli.ts#runLocalCli`) where esbuild loaders don't apply.
|
||||
*/
|
||||
const BUNDLED_SKILL_NAMES = ["git-archaeology"] as const;
|
||||
|
||||
/**
|
||||
* resolve the on-disk path of a bundled SKILL.md by checking the two locations
|
||||
* the file may live in:
|
||||
* - source mode (`runLocalCli`): `<actionRoot>/skills/<name>/SKILL.md`,
|
||||
* reached as `../skills/...` from `utils/skills.ts`.
|
||||
* - bundled mode (npx published package): `<distDir>/skills/<name>/SKILL.md`,
|
||||
* reached as `./skills/...` from `dist/cli.mjs`.
|
||||
*
|
||||
* the bundled-mode copy is produced by an esbuild post-build step in
|
||||
* `esbuild.config.js`.
|
||||
*/
|
||||
function resolveSkillPath(name: string): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(here, "..", "skills", name, "SKILL.md"),
|
||||
join(here, "skills", name, "SKILL.md"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`bundled skill not found: ${name} (looked in ${candidates.join(", ")})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* each agent has its own auto-scan dir under HOME. we write to all of them so
|
||||
* the same `installBundledSkills` call works regardless of which agent is
|
||||
* running, without coupling skills.ts to agent identity.
|
||||
*
|
||||
* verified empirically (PR #565):
|
||||
* - OpenCode registers skills from `$HOME/.agents/skills/` and `.opencode/skills/`.
|
||||
* - Claude Code only registers skills from `$HOME/.claude/skills/` —
|
||||
* it does NOT scan `.agents/skills/`, so writing only there leaves the
|
||||
* skill on disk but invisible to Claude's `Skill` tool.
|
||||
*/
|
||||
const SKILL_TARGET_DIRS = [".opencode/skills", ".claude/skills", ".agents/skills"] as const;
|
||||
|
||||
/**
|
||||
* write all bundled skills into the fake HOME so OpenCode / Claude Code discover
|
||||
* them via their auto-scan directories.
|
||||
*
|
||||
* called once per agent run from each agent's `run()`. cheap (small file
|
||||
* writes), no network, idempotent.
|
||||
*/
|
||||
export function installBundledSkills(params: { home: string }): void {
|
||||
for (const name of BUNDLED_SKILL_NAMES) {
|
||||
const content = readFileSync(resolveSkillPath(name), "utf8");
|
||||
for (const targetDir of SKILL_TARGET_DIRS) {
|
||||
const skillDir = join(params.home, targetDir, name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
}
|
||||
log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* install a skill globally via the `skills` CLI.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user