fix(ext): spec compliance fixes — stagnation recovery, comet fallback, quote anti-trigger, types, package.json
- trigger-detector: fix quote anti-trigger (only block if trigger exclusively in quotes) - trigger-detector: add uncertainty fallback pattern (goal is/为 + deliverable) - trigger-detector: add 'fix bug' to trivial keywords - index.ts: implement stagnation auto-recovery injection - index.ts: implement comet fallback when openspec not available - index.ts: replace 'any' types with ExtensionContext - index.ts: wire consecutiveStagnations counter - Add package.json for extension discovery
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
* auto-defaults to eliminate unnecessary user interaction.
|
* 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 { join } from "node:path";
|
||||||
import { existsSync, unlinkSync } from "node:fs";
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
import { detectTrigger, isCoarseGrained } from "./trigger-detector.js";
|
import { detectTrigger, isCoarseGrained } from "./trigger-detector.js";
|
||||||
@@ -13,19 +13,69 @@ import {
|
|||||||
readGoalState,
|
readGoalState,
|
||||||
buildInjectionText,
|
buildInjectionText,
|
||||||
buildContinuationPrompt,
|
buildContinuationPrompt,
|
||||||
|
type GoalState,
|
||||||
} from "./state-injector.js";
|
} from "./state-injector.js";
|
||||||
import {
|
import {
|
||||||
promptStaleState,
|
promptStaleState,
|
||||||
notifyCoarseGrainedDelegation,
|
notifyCoarseGrainedDelegation,
|
||||||
|
notifyMidLoopUpgrade,
|
||||||
notifyStagnation,
|
notifyStagnation,
|
||||||
shouldNotifyStagnation,
|
shouldNotifyStagnation,
|
||||||
type UIContext,
|
type UIContext,
|
||||||
} from "./ui-prompts.js";
|
} from "./ui-prompts.js";
|
||||||
|
|
||||||
const CONTEXT_TENSION_THRESHOLD = 0.8;
|
const CONTEXT_TENSION_THRESHOLD = 0.8;
|
||||||
|
const STAGNATION_KEYWORDS = [
|
||||||
|
"found",
|
||||||
|
"fixed",
|
||||||
|
"implemented",
|
||||||
|
"added",
|
||||||
|
"refactored",
|
||||||
|
"verified",
|
||||||
|
];
|
||||||
const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"];
|
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) {
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let consecutiveStagnations = 0;
|
||||||
|
|
||||||
// ---- Trigger detection on user input ----
|
// ---- Trigger detection on user input ----
|
||||||
pi.on("input", async (event, ctx) => {
|
pi.on("input", async (event, ctx) => {
|
||||||
if (event.source === "extension") {
|
if (event.source === "extension") {
|
||||||
@@ -71,9 +121,23 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (isCoarseGrained(match.goalText)) {
|
if (isCoarseGrained(match.goalText)) {
|
||||||
const ui = buildUIContext(ctx);
|
const ui = buildUIContext(ctx);
|
||||||
notifyCoarseGrainedDelegation(ui, match.goalText);
|
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}`, {
|
pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, {
|
||||||
deliverAs: "followUp",
|
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" };
|
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);
|
const injection = buildInjectionText(state);
|
||||||
return {
|
return {
|
||||||
systemPrompt: event.systemPrompt + "\n\n" + injection,
|
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "goal-driven-loop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,15 @@ const TRIGGER_PATTERNS: RegExp[] = [
|
|||||||
/current\s+goal\s+is\s+(.+)/i,
|
/current\s+goal\s+is\s+(.+)/i,
|
||||||
/my\s+goal\s+is\s+(.+)/i,
|
/my\s+goal\s+is\s+(.+)/i,
|
||||||
/the\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 = [
|
const TRIVIAL_KEYWORDS = [
|
||||||
"hotfix",
|
"hotfix",
|
||||||
"tweak",
|
"tweak",
|
||||||
"fix a bug",
|
"fix a bug",
|
||||||
|
"fix bug",
|
||||||
"small change",
|
"small change",
|
||||||
"quick fix",
|
"quick fix",
|
||||||
"修个bug",
|
"修个bug",
|
||||||
@@ -72,16 +75,15 @@ export function detectTrigger(input: string): TriggerMatch | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anti-trigger: markdown quote block
|
// Anti-trigger: markdown quote block — only block if trigger phrase appears
|
||||||
const lines = input.split("\n");
|
// exclusively inside quote lines (no trigger in non-quote content)
|
||||||
for (const line of lines) {
|
const nonQuoteLines = input.split("\n").filter((line) => !/^\s*>/.test(line));
|
||||||
if (/^\s*>/.test(line)) {
|
const nonQuoteContent = nonQuoteLines.join("\n");
|
||||||
const triggerInQuote = TRIGGER_PATTERNS.some((p) => p.test(line));
|
const hasTriggerOutsideQuote = TRIGGER_PATTERNS.some((p) => p.test(nonQuoteContent));
|
||||||
if (triggerInQuote) {
|
if (!hasTriggerOutsideQuote) {
|
||||||
|
// All trigger phrases are inside quotes — block
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anti-trigger: reported speech
|
// Anti-trigger: reported speech
|
||||||
const lowerInput = input.toLowerCase();
|
const lowerInput = input.toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user