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:
张宗平
2026-06-10 21:04:28 +08:00
parent 1d5ab5c00b
commit 1eb98cdb3f
5 changed files with 675 additions and 0 deletions
+206
View File
@@ -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,
};
});
}
+8
View File
@@ -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;
}
+106
View File
@@ -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;
}