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/)
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "goal-driven-loop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./index.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) || [];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string | undefined>;
|
||||||
|
notify: (message: string, level?: "info" | "warn" | "error") => void;
|
||||||
|
confirm: (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
opts?: { timeout?: number }
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user