diff --git a/.pi/extensions/goal-driven-loop/index.ts b/.pi/extensions/goal-driven-loop/index.ts index f91eadb..b57870a 100644 --- a/.pi/extensions/goal-driven-loop/index.ts +++ b/.pi/extensions/goal-driven-loop/index.ts @@ -5,7 +5,7 @@ * auto-defaults to eliminate unnecessary user interaction. */ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; import { existsSync, unlinkSync } from "node:fs"; import { detectTrigger, isCoarseGrained } from "./trigger-detector.js"; @@ -13,19 +13,69 @@ import { readGoalState, buildInjectionText, buildContinuationPrompt, + type GoalState, } from "./state-injector.js"; import { promptStaleState, notifyCoarseGrainedDelegation, + notifyMidLoopUpgrade, notifyStagnation, shouldNotifyStagnation, type UIContext, } from "./ui-prompts.js"; const CONTEXT_TENSION_THRESHOLD = 0.8; +const STAGNATION_KEYWORDS = [ + "found", + "fixed", + "implemented", + "added", + "refactored", + "verified", +]; const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"]; +function buildUIContext(ctx: ExtensionContext): UIContext { + return { + hasUI: ctx.hasUI, + select: ctx.ui.select.bind(ctx.ui), + notify: ctx.ui.notify.bind(ctx.ui), + confirm: ctx.ui.confirm.bind(ctx.ui), + }; +} + +function handleAbandon(ctx: ExtensionContext): void { + const goalPath = join(ctx.cwd, ".agents", "goal", "GOAL.md"); + try { + if (existsSync(goalPath)) { + unlinkSync(goalPath); + } + } catch { + // best effort + } + if (ctx.hasUI) { + ctx.ui.notify("目标已放弃", "info"); + } +} + +/** + * Check if the progress log shows stagnation (dual signal). + * Stagnation = criteria passing count unchanged for 3 iterations + * AND no progress keywords in the last 3 iterations. + */ +function detectStagnation(state: GoalState): boolean { + // Signal 1: We can't compare passing counts across iterations + // from the file alone without history. Instead, check if the + // last 3 iteration headers exist but have no progress keywords. + // For a more robust check, the skill should track passing count + // history in GOAL.md. For now, we rely on the skill's own + // stagnation detection and the extension's stagnation counter. + return false; // Placeholder — skill-side detection is authoritative +} + export default function (pi: ExtensionAPI) { + let consecutiveStagnations = 0; + // ---- Trigger detection on user input ---- pi.on("input", async (event, ctx) => { if (event.source === "extension") { @@ -71,9 +121,23 @@ export default function (pi: ExtensionAPI) { if (isCoarseGrained(match.goalText)) { const ui = buildUIContext(ctx); notifyCoarseGrainedDelegation(ui, match.goalText); - pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, { - deliverAs: "followUp", - }); + // Check if comet is available (has openspec directory) + const hasOpenspec = existsSync(join(ctx.cwd, "openspec")); + if (hasOpenspec) { + pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, { + deliverAs: "followUp", + }); + } else { + // Fallback: inject multi-stage mode instruction + pi.sendMessage( + { + customType: "goal-driven-loop-coarse-fallback", + content: `Coarse-grained goal detected but comet unavailable. Running in multi-stage mode. Split the goal into phases and verify each one:\n\n${match.goalText}`, + display: true, + }, + { triggerTurn: true, deliverAs: "followUp" } + ); + } return { action: "continue" }; } @@ -105,33 +169,38 @@ export default function (pi: ExtensionAPI) { }; } - // Inject goal state + // Stagnation auto-recovery injection + if (state.status === "blocked") { + consecutiveStagnations++; + if (shouldNotifyStagnation(consecutiveStagnations)) { + const ui = buildUIContext(ctx); + notifyStagnation( + ui, + state.goal, + state.passedCriteria, + state.totalCriteria, + state.iteration + ); + } else { + // Auto-inject strategy-change prompt + return { + systemPrompt: + event.systemPrompt + + "\n\n" + + buildInjectionText(state) + + "\n\n[goal-driven-loop STAGNATION RECOVERY] Previous approach not making progress. Try: a different tool/library, refactor before retry, or split the failing criterion into sub-steps.", + }; + } + } + + // Normal goal state injection + if (state.status !== "blocked") { + consecutiveStagnations = 0; + } + const injection = buildInjectionText(state); return { systemPrompt: event.systemPrompt + "\n\n" + injection, }; }); } - -function buildUIContext(ctx: any): UIContext { - return { - hasUI: ctx.hasUI, - select: ctx.ui.select.bind(ctx.ui), - notify: ctx.ui.notify.bind(ctx.ui), - confirm: ctx.ui.confirm.bind(ctx.ui), - }; -} - -function handleAbandon(ctx: any): void { - const goalPath = join(ctx.cwd, ".agents", "goal", "GOAL.md"); - try { - if (existsSync(goalPath)) { - unlinkSync(goalPath); - } - } catch { - // best effort - } - if (ctx.hasUI) { - ctx.ui.notify("目标已放弃", "info"); - } -} diff --git a/.pi/extensions/goal-driven-loop/package.json b/.pi/extensions/goal-driven-loop/package.json new file mode 100644 index 0000000..526f07d --- /dev/null +++ b/.pi/extensions/goal-driven-loop/package.json @@ -0,0 +1,8 @@ +{ + "name": "goal-driven-loop", + "version": "1.0.0", + "private": true, + "pi": { + "extensions": ["./index.ts"] + } +} diff --git a/.pi/extensions/goal-driven-loop/trigger-detector.ts b/.pi/extensions/goal-driven-loop/trigger-detector.ts index 65d85e6..126dc22 100644 --- a/.pi/extensions/goal-driven-loop/trigger-detector.ts +++ b/.pi/extensions/goal-driven-loop/trigger-detector.ts @@ -25,12 +25,15 @@ const TRIGGER_PATTERNS: RegExp[] = [ /current\s+goal\s+is\s+(.+)/i, /my\s+goal\s+is\s+(.+)/i, /the\s+goal\s+is\s+(.+)/i, + // Uncertainty fallback: commitment language + deliverable + /(?:goal|目标)\s*(?:is|为|是)\s+(?:(?:to|去)\s+)?(.+)/i, ]; const TRIVIAL_KEYWORDS = [ "hotfix", "tweak", "fix a bug", + "fix bug", "small change", "quick fix", "修个bug", @@ -72,15 +75,14 @@ export function detectTrigger(input: string): TriggerMatch | null { return null; } - // Anti-trigger: markdown quote block - const lines = input.split("\n"); - for (const line of lines) { - if (/^\s*>/.test(line)) { - const triggerInQuote = TRIGGER_PATTERNS.some((p) => p.test(line)); - if (triggerInQuote) { - return null; - } - } + // Anti-trigger: markdown quote block — only block if trigger phrase appears + // exclusively inside quote lines (no trigger in non-quote content) + const nonQuoteLines = input.split("\n").filter((line) => !/^\s*>/.test(line)); + const nonQuoteContent = nonQuoteLines.join("\n"); + const hasTriggerOutsideQuote = TRIGGER_PATTERNS.some((p) => p.test(nonQuoteContent)); + if (!hasTriggerOutsideQuote) { + // All trigger phrases are inside quotes — block + return null; } // Anti-trigger: reported speech