From 1eb98cdb3faae5684fabedfc42ef8696f222c0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=97=E5=B9=B3?= Date: Wed, 10 Jun 2026 21:04:28 +0800 Subject: [PATCH] chore: add extensions/ directory (real location for goal-driven-loop extension) Skill lives at .agents/skills/goal-driven-loop (symlinked from .pi/skills/) Extension lives at extensions/goal-driven-loop (symlinked from .pi/extensions/) --- extensions/goal-driven-loop/index.ts | 206 +++++++++++++++++ extensions/goal-driven-loop/package.json | 8 + extensions/goal-driven-loop/state-injector.ts | 141 ++++++++++++ .../goal-driven-loop/trigger-detector.ts | 214 ++++++++++++++++++ extensions/goal-driven-loop/ui-prompts.ts | 106 +++++++++ 5 files changed, 675 insertions(+) create mode 100644 extensions/goal-driven-loop/index.ts create mode 100644 extensions/goal-driven-loop/package.json create mode 100644 extensions/goal-driven-loop/state-injector.ts create mode 100644 extensions/goal-driven-loop/trigger-detector.ts create mode 100644 extensions/goal-driven-loop/ui-prompts.ts diff --git a/extensions/goal-driven-loop/index.ts b/extensions/goal-driven-loop/index.ts new file mode 100644 index 0000000..b57870a --- /dev/null +++ b/extensions/goal-driven-loop/index.ts @@ -0,0 +1,206 @@ +/** + * goal-driven-loop extension for Pi. + * + * Wires together trigger detection, state injection, and timed + * auto-defaults to eliminate unnecessary user interaction. + */ + +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"; +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") { + return { action: "continue" }; + } + + // Check for explicit abandon first + const lowerText = event.text.toLowerCase(); + const isAbandon = ABANDON_KEYWORDS.some((kw) => + lowerText.includes(kw.toLowerCase()) + ); + if (isAbandon) { + const state = readGoalState(ctx.cwd); + if (state) { + handleAbandon(ctx); + return { action: "continue" }; + } + } + + // Try to detect a goal-setting trigger + const match = detectTrigger(event.text); + if (!match) { + return { action: "continue" }; + } + + // Trigger detected — check stale state + const existingState = readGoalState(ctx.cwd); + if (existingState && existingState.status === "in-progress") { + const ui = buildUIContext(ctx); + const choice = await promptStaleState(ui); + if (choice === "abandon") { + handleAbandon(ctx); + return { action: "continue" }; + } + if (choice === "replace") { + // Let the skill handle replacement on the next turn + return { action: "continue" }; + } + // "continue" or undefined → let the skill resume + } + + // Granularity check + if (isCoarseGrained(match.goalText)) { + const ui = buildUIContext(ctx); + notifyCoarseGrainedDelegation(ui, match.goalText); + // 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" }; + } + + // Fine-grained — let the skill handle it + return { action: "continue" }; + }); + + // ---- State injection on every agent start ---- + pi.on("before_agent_start", async (event, ctx) => { + const state = readGoalState(ctx.cwd); + if (!state) { + return; + } + + // Context tension check + const usage = ctx.getContextUsage(); + if ( + usage && + usage.contextWindow > 0 && + usage.tokens / usage.contextWindow > CONTEXT_TENSION_THRESHOLD + ) { + const continuationMsg = buildContinuationPrompt(state); + return { + systemPrompt: + event.systemPrompt + + "\n\n" + + continuationMsg + + "\n\nContext is tight. Finalize .agents/goal/GOAL.md updates before completing this turn.", + }; + } + + // 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, + }; + }); +} diff --git a/extensions/goal-driven-loop/package.json b/extensions/goal-driven-loop/package.json new file mode 100644 index 0000000..526f07d --- /dev/null +++ b/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/extensions/goal-driven-loop/state-injector.ts b/extensions/goal-driven-loop/state-injector.ts new file mode 100644 index 0000000..8a8cbde --- /dev/null +++ b/extensions/goal-driven-loop/state-injector.ts @@ -0,0 +1,141 @@ +/** + * State injection for goal-driven-loop. + * + * Reads .agents/goal/GOAL.md and injects goal state into + * the agent's context via before_agent_start. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +export interface GoalState { + goal: string; + status: "in-progress" | "blocked" | "complete"; + iteration: number; + totalCriteria: number; + passedCriteria: number; + created: string; + updated: string; + blocker?: string; + progressLog: string[]; +} + +/** + * Parse GOAL.md frontmatter and body to extract goal state. + */ +export function readGoalState(cwd: string): GoalState | null { + const goalPath = join(cwd, ".agents", "goal", "GOAL.md"); + if (!existsSync(goalPath)) { + return null; + } + + const content = readFileSync(goalPath, "utf-8"); + + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) { + return null; + } + + const frontmatter = frontmatterMatch[1]; + const goal = extractField(frontmatter, "goal") || ""; + const status = + (extractField(frontmatter, "status") as GoalState["status"]) || + "in-progress"; + const iteration = parseInt( + extractField(frontmatter, "iteration") || "0", + 10 + ); + const created = extractField(frontmatter, "created") || ""; + const updated = extractField(frontmatter, "updated") || ""; + + const criteriaSection = extractSection(content, "Acceptance Criteria"); + const totalCriteria = countCheckboxes(criteriaSection); + const passedCriteria = countCheckedBoxes(criteriaSection); + + const blockerSection = extractSection(content, "Blocked"); + const blockerRaw = blockerSection.trim() || undefined; + const blocker = blockerRaw + ? blockerRaw.replace(/^[-*]\s*/, "").trim() || undefined + : undefined; + + const progressSection = extractSection(content, "Progress Log"); + const progressLog = extractIterations(progressSection); + + return { + goal, + status, + iteration, + totalCriteria, + passedCriteria, + created, + updated, + blocker, + progressLog, + }; +} + +/** + * Build the injection text for the system prompt or message. + */ +export function buildInjectionText(state: GoalState): string { + if (state.status === "complete") { + return `[goal-driven-loop] GOAL COMPLETE +${state.goal} +Final: ${state.passedCriteria}/${state.totalCriteria} criteria passed across ${state.iteration} iterations. +GOAL.md is being cleaned up.`; + } + + if (state.status === "blocked" && state.blocker) { + return `[goal-driven-loop] GOAL BLOCKED +${state.goal} +Blocker: ${state.blocker} +Progress: ${state.passedCriteria}/${state.totalCriteria} criteria passed, iteration ${state.iteration}. +Continue by trying a different strategy. If still blocked after 2 attempts, surface to the user.`; + } + + return `[goal-driven-loop] ACTIVE GOAL +${state.goal} +Progress: ${state.passedCriteria}/${state.totalCriteria} criteria passed, iteration ${state.iteration} +Next step: read .agents/goal/GOAL.md and continue the loop.`; +} + +/** + * Build a continuation prompt for context tension situations. + */ +export function buildContinuationPrompt(state: GoalState): string { + return `🔄 目标状态已保存至 .agents/goal/GOAL.md +当前进度:${state.passedCriteria}/${state.totalCriteria} criteria 通过,第 ${state.iteration} 轮迭代。`; +} + +// ---- helpers ---- + +function extractField(frontmatter: string, field: string): string | null { + const match = frontmatter.match(new RegExp(`^${field}:\\s*(.*)$`, "m")); + if (!match) return null; + let value = match[1].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return value; +} + +function extractSection(content: string, sectionName: string): string { + const regex = new RegExp(`## ${sectionName}\\n([\\s\\S]*?)(?=\\n## |$)`); + const match = content.match(regex); + return match ? match[1] : ""; +} + +function countCheckboxes(section: string): number { + return (section.match(/^- \[[ x]\]/gm) || []).length; +} + +function countCheckedBoxes(section: string): number { + return (section.match(/^- \[x\]/gim) || []).length; +} + +function extractIterations(section: string): string[] { + return section.match(/### Iteration \d+/g) || []; +} diff --git a/extensions/goal-driven-loop/trigger-detector.ts b/extensions/goal-driven-loop/trigger-detector.ts new file mode 100644 index 0000000..681b23e --- /dev/null +++ b/extensions/goal-driven-loop/trigger-detector.ts @@ -0,0 +1,214 @@ +/** + * Trigger detection for goal-driven-loop. + * + * Detects goal-setting phrases in user input and validates them + * against anti-trigger rules. + */ + +export interface TriggerMatch { + goalText: string; + rawCriteria?: string; +} + +/** + * Trigger phrases — order matters (longer patterns first to avoid + * "当前目标是" matching "是" alone). + */ +const TRIGGER_PATTERNS: RegExp[] = [ + // Chinese + /设定当前目标为\s*(.+)/, + /把当前目标设为\s*(.+)/, + /当前目标是\s*(.+)/, + /我的目标是\s*(.+)/, + // English + /set\s+(?:the\s+)?current\s+goal\s+to\s+(.+)/i, + /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", + "小改", +]; + +const ARCHITECTURAL_KEYWORDS = [ + "architecture", + "architectural", + "module", + "system", + "refactor", + "integration", + "重构", + "模块", + "系统", + "集成", +]; + +const REPORTED_SPEECH_MARKERS = [ + "he said", + "she said", + "they said", + "he says", + "she says", + "they say", + "他说", + "她说", + "他们说", +]; + +/** + * Detect if the input is a goal-setting trigger and extract the goal text. + * Returns null if no trigger or if anti-trigger rules match. + */ +export function detectTrigger(input: string): TriggerMatch | null { + // Anti-trigger: code block — remove code blocks from input before checking + // for trigger phrases, so triggers outside code blocks still fire. + const inputWithoutCode = input.replace(/```[\s\S]*?```/g, ""); + + // Anti-trigger: markdown quote block — only block if trigger phrase appears + // exclusively inside quote lines (no trigger in non-quote content) + const nonQuoteLines = inputWithoutCode + .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 or absent — block + return null; + } + + // Use the code-stripped, non-quote content for further checks + const effectiveInput = nonQuoteContent; + + // Anti-trigger: reported speech — check per-sentence + const lowerInput = effectiveInput.toLowerCase(); + const hasReportedSpeech = REPORTED_SPEECH_MARKERS.some((marker) => + lowerInput.includes(marker) + ); + if (hasReportedSpeech) { + const sentences = effectiveInput.split(/[.!?。!?]/); + for (const sentence of sentences) { + const lowerSentence = sentence.toLowerCase(); + const hasMarker = REPORTED_SPEECH_MARKERS.some((m) => + lowerSentence.includes(m) + ); + const hasTrigger = TRIGGER_PATTERNS.some((p) => p.test(sentence)); + if (hasMarker && hasTrigger) { + return null; + } + } + } + + // Anti-trigger: trivial task keywords + const hasTrivialKeyword = TRIVIAL_KEYWORDS.some((kw) => + lowerInput.includes(kw.toLowerCase()) + ); + if (hasTrivialKeyword) { + return null; + } + + // Try to match trigger pattern + for (const pattern of TRIGGER_PATTERNS) { + const match = effectiveInput.match(pattern); + if (match) { + const fullText = match[1].trim(); + + // Validate: must be non-empty and not a short fragment + if (fullText.length < 3) { + return null; + } + + // Anti-trigger: short description without architectural keywords + if (fullText.length <= 10) { + const hasArchitectural = ARCHITECTURAL_KEYWORDS.some((kw) => + fullText.toLowerCase().includes(kw.toLowerCase()) + ); + if (!hasArchitectural) { + return null; + } + } + + // Try to extract acceptance criteria + const criteriaMatch = fullText.match( + /(?:验收条件[是为::]\s*|acceptance\s*(?:criteria)?\s*[::]\s*)(.+)$/i + ); + + if (criteriaMatch) { + const goalText = fullText.substring(0, criteriaMatch.index).trim(); + return { + goalText: cleanGoalText(goalText), + rawCriteria: criteriaMatch[1].trim(), + }; + } + + return { + goalText: cleanGoalText(fullText), + }; + } + } + + return null; +} + +function cleanGoalText(text: string): string { + return text.replace(/[.,;:。;,]+$/, "").trim(); +} + +/** + * Determine if a goal is coarse-grained (should delegate to comet). + */ +export function isCoarseGrained(goalText: string): boolean { + const lower = goalText.toLowerCase(); + + const multiModuleSignals = [ + "across", + "throughout", + "整个", + "全", + "all modules", + "multiple", + "multiple files", + "多模块", + ]; + for (const signal of multiModuleSignals) { + if (lower.includes(signal)) { + return true; + } + } + + const fileCountMatch = goalText.match(/(\d+)\s*(?:个)?\s*(?:file|files|文件)/i); + if (fileCountMatch) { + const count = parseInt(fileCountMatch[1], 10); + if (count > 5) { + return true; + } + } + + const architecturalSignals = [ + "redesign", + "rewrite", + "migrate", + "重构", + "迁移", + "重写", + "重新设计", + ]; + for (const signal of architecturalSignals) { + if (lower.includes(signal.toLowerCase())) { + return true; + } + } + + return false; +} diff --git a/extensions/goal-driven-loop/ui-prompts.ts b/extensions/goal-driven-loop/ui-prompts.ts new file mode 100644 index 0000000..3f8355e --- /dev/null +++ b/extensions/goal-driven-loop/ui-prompts.ts @@ -0,0 +1,106 @@ +/** + * UI prompts for goal-driven-loop. + * + * Wraps Pi's UI methods with timed auto-defaults to avoid + * blocking the user forever. + */ + +export interface UIContext { + hasUI: boolean; + select: ( + title: string, + options: string[], + opts?: { timeout?: number } + ) => Promise; + notify: (message: string, level?: "info" | "warn" | "error") => void; + confirm: ( + title: string, + message: string, + opts?: { timeout?: number } + ) => Promise; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const STAGNATION_NOTIFY_THRESHOLD = 2; + +/** + * Prompt the user to resolve a stale goal state. + * Returns the choice or undefined on timeout. + * Auto-default: "continue" + */ +export async function promptStaleState( + ctx: UIContext +): Promise<"continue" | "replace" | "abandon" | undefined> { + if (!ctx.hasUI) { + return "continue"; + } + + const choice = await ctx.select( + "已有目标在进行中", + ["继续当前目标", "替换为新目标", "放弃当前目标"], + { timeout: DEFAULT_TIMEOUT_MS } + ); + + switch (choice) { + case "继续当前目标": + return "continue"; + case "替换为新目标": + return "replace"; + case "放弃当前目标": + return "abandon"; + default: + return "continue"; + } +} + +/** + * Notify the user that the goal is being delegated to comet. + */ +export function notifyCoarseGrainedDelegation( + ctx: UIContext, + goal: string +): void { + if (!ctx.hasUI) return; + ctx.notify( + `目标粒度较大,已委托给 comet 全流程处理:${goal}`, + "info" + ); +} + +/** + * Notify the user about mid-loop granularity upgrade. + */ +export function notifyMidLoopUpgrade(ctx: UIContext, goal: string): void { + if (!ctx.hasUI) return; + ctx.notify( + `执行中发现目标比预期复杂,已升级到 comet:${goal}`, + "info" + ); +} + +/** + * Notify the user about stagnation that auto-recovery couldn't fix. + * Called after STAGNATION_NOTIFY_THRESHOLD auto-recovery attempts. + */ +export function notifyStagnation( + ctx: UIContext, + goal: string, + passedCount: number, + totalCount: number, + iteration: number +): void { + if (!ctx.hasUI) return; + ctx.notify( + `目标停滞:${goal} (${passedCount}/${totalCount} 通过, 第 ${iteration} 轮)。自动策略调整未奏效,请人工介入。`, + "warn" + ); +} + +/** + * Check if we should notify the user about stagnation yet. + */ +export function shouldNotifyStagnation( + consecutiveStagnations: number +): boolean { + return consecutiveStagnations > STAGNATION_NOTIFY_THRESHOLD; +}