34 KiB
Goal-Driven Loop Skill + Extension Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the goal-driven-loop Pi skill + extension pair that detects user goal-setting phrases, persists goal + acceptance criteria to .agents/goal/GOAL.md, and runs an instruction-based loop with per-iteration exponential backoff retries. The extension layer automates trigger detection, state injection, and auto-default confirmations to eliminate user-interaction hang-ups.
Architecture: Two layers — a skill (SKILL.md + references/error-handling.md) for the agent's instructions, and a TypeScript extension (extensions/goal-driven-loop/) for mechanical automation. Skill: instructions the agent reads. Extension: Pi runtime event handlers that detect triggers, inject state, and replace infinite-wait confirms with timed auto-defaults.
Tech Stack: Pi coding agent (Agent Skills standard + TypeScript extensions), Markdown, JSON-style frontmatter, TypeScript, Pi's Extension API.
Spec: docs/superpowers/specs/2026-06-10-goal-driven-loop-design.md
File Structure
.pi/
├── skills/goal-driven-loop/
│ ├── SKILL.md # Skill instructions (≤500 words)
│ └── references/
│ └── error-handling.md # Detailed retry/recovery reference
└── extensions/
└── goal-driven-loop/
├── index.ts # Extension entry point (wires events)
├── trigger-detector.ts # Trigger phrase + anti-trigger parsing
├── state-injector.ts # State injection + context checks
└── ui-prompts.ts # Timed confirm/select helpers
Six files total. The skill is documentation; the extension is TypeScript code that runs in Pi's event loop.
Task decomposition rationale:
- Tasks 1-2: Skill layer (documentation only, no code)
- Tasks 3-7: Extension layer (one TypeScript module per responsibility)
- Task 8: Integration verification
Tasks
Task 1: Create SKILL.md
Files:
-
Create:
.pi/skills/goal-driven-loop/SKILL.md -
Create:
.pi/skills/goal-driven-loop/references/(directory) -
Step 1: Create directory structure
mkdir -p /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/references
- Step 2: Write
SKILL.md
Create .pi/skills/goal-driven-loop/SKILL.md with this exact content:
---
name: goal-driven-loop
description: Use when the user sets a current goal with phrases like "设定当前目标为", "当前目标是", "current goal is", or "my goal is", and the task is not a trivial fix, hotfix, tweak, or quoted text
---
# Goal-Driven Loop
Keep the current session focused on a user-defined goal with explicit acceptance criteria. Iterate until all criteria pass, or genuine stagnation is detected.
## When to Activate
**Trigger phrases (any language, semantic variants):**
- Chinese: 设定当前目标为 / 当前目标是 / 把当前目标设为 / 我的目标是
- English: set current goal to / current goal is / my goal is / the goal is
- Other: semantically equivalent phrasings + concrete deliverable
**Do NOT activate when:**
- Trigger phrase inside markdown quote block (`>`) or code block
- Trigger phrase in same sentence as reported speech markers (he said / she said / they said)
- Message contains keywords: hotfix, tweak, fix a bug, small change, quick fix, 修个bug, 小改
- Goal description ≤ 10 chars AND no architecture/module/system/refactor/integration keywords
- Agent estimates < 5 min AND no architectural keywords
- `.agents/goal/GOAL.md` already exists → extension prompts user: continue / replace / abandon (10s timeout → continue)
**Granularity split:**
- Coarse-grained (file count > 5, multi-module, architectural) → extension invokes `comet-open` with goal. This skill exits.
- Fine-grained (single concern, ≤ 5 files) → continue with this skill.
**Fallback:** If comet unavailable, downgrade to multi-stage mode — split goal into phases, verify each.
## Core Loop
1. **Initialize goal state**
- If `GOAL.md` exists, extension prompts: continue / replace / abandon (timed default: continue)
- If user provided criteria, use directly. Otherwise auto-generate with verifiability red line, no confirmation needed
- Write `.agents/goal/GOAL.md`
2. **Execute iteration**
- Read `GOAL.md`, identify unpassed criteria
- Pick the criterion whose completion does not require other unpassed criteria to be met first
- Implement work, run verification (tests, lint, typecheck)
- Record results in Progress Log
3. **Verify and update**
- Check each criterion against fresh evidence
- Update checkboxes, `iteration` counter, `updated` timestamp
4. **Completion check**
- All criteria ✅ → proceed to exit
- Stagnation detected → extension auto-injects strategy-change prompt (2x before user notification)
- Otherwise → loop back to step 2
5. **Exit**
- Run deslop pass on modified files
- Deslop failure does not block completion
- Delete `GOAL.md`
- Output completion summary
## Acceptance Criteria Verifiability
Auto-generated criteria must be verifiable. Examples:
- ❌ "Implement rate limiting" → not verifiable
- ✅ "Rate limiter middleware mounts on auth routes, excess requests return 429" → verifiable
- ❌ "Code quality is good" → not verifiable
- ✅ "Related files lint clean, typecheck passes" → verifiable
Agent cannot unilaterally delete or skip criteria. Material modifications require logged reason; safe adjustments (typos, splitting compounds) may be auto-applied.
## Goal State File
Path: `.agents/goal/GOAL.md`
```markdown
---
goal: "<goal text>"
created: "<ISO 8601>"
updated: "<ISO 8601>"
iteration: <int>
status: in-progress | blocked | complete
---
# Goal: <goal text>
## Acceptance Criteria
- [ ] <criterion 1>
- [ ] <criterion 2>
## Progress Log
### Iteration 1
- <work done>
- <verification results>
## Blocked
- <blocker, or delete section if empty>
Error Handling
See references/error-handling.md for retry strategy, exception classification, stagnation rules, and red flags.
Context tension: Extension detects via ctx.getContextUsage() > 0.8. Auto-injects "save progress now" directive into next turn.
Mid-loop granularity upgrade: If execution reveals the goal is more complex than estimated, extension auto-invokes comet-open with GOAL.md state. User is informed via UI notification, not asked for confirmation.
Explicit abandon: User says "abandon / cancel / 不要了" → extension deletes GOAL.md, outputs brief summary, terminates.
- [ ] **Step 3: Verify word count**
Run: `wc -w /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/SKILL.md`
Expected: ≤ 500 words
- [ ] **Step 4: Commit**
```bash
cd /home/charles/workspaces/pi-setup
git add .pi/skills/goal-driven-loop/SKILL.md .pi/skills/goal-driven-loop/references/
git commit -m "feat(skill): add goal-driven-loop SKILL.md with core loop instructions"
Task 2: Create references/error-handling.md
Files:
-
Create:
.pi/skills/goal-driven-loop/references/error-handling.md -
Step 1: Write the error handling reference
Create .pi/skills/goal-driven-loop/references/error-handling.md with this exact content:
# Error Handling Reference
Detailed retry strategy, exception classification, and recovery rules for the `goal-driven-loop` skill.
## Retry Strategy (Automatic, No Human Intervention)
All recoverable exceptions use exponential backoff. Counter is **per-iteration** — resets at the start of each new iteration.
| Parameter | Value |
|-----------|-------|
| Max retries | 10 per iteration |
| Initial delay | 30s |
| Backoff factor | ×2 |
| Max delay | 300s (5min) |
**Backoff sequence:**
| Retry | Delay | Cumulative |
|-------|-------|------------|
| 1 | 30s | 0.5min |
| 2 | 60s | 1.5min |
| 3 | 120s | 3.5min |
| 4 | 240s | 7.5min |
| 5 | 300s | 12.5min |
| 6 | 300s | 17.5min |
| 7 | 300s | 22.5min |
| 8 | 300s | 27.5min |
| 9 | 300s | 32.5min |
| 10 | 300s | 37.5min |
**Formula:** `delay = min(30 * 2^(retry-1), 300)` seconds.
**Execution:** Before retry, run `bash -c "sleep <delay> && echo done"` with `timeout` parameter set to `delay + 30`. If sleep is interrupted, treat as tool failure, enter retry.
**After 10 retries exhausted:** `status = blocked`, write Blocked section to `GOAL.md`, extension auto-attempts alternative approach. If still blocked after auto-attempt, then notify user.
## Exception Classification
| Exception | Detection signal | Recoverable? | Response |
|-----------|------------------|--------------|----------|
| Tool execution failure | bash returns non-zero exit | Yes | Exponential backoff retry |
| LLM API exception | subagent timeout/error | Yes | Exponential backoff retry |
| Context tension | Extension detects `getContextUsage() > 0.8` | No | Extension auto-saves state, injects continuation directive |
| User interruption | User sends new message mid-loop | No | Extension auto-pauses, responds, resumes on next trigger |
| Stale state | `GOAL.md` exists with `status = in-progress` | No | Extension prompts: continue / replace / abandon (10s timeout → continue) |
## Stagnation Detection
Both signals must be present:
1. Acceptance criteria passing count unchanged for 3 consecutive iterations, AND
2. Progress Log last 3 iterations contain no progress keywords (found / fixed / implemented / added / refactored / verified)
Only both conditions together trigger stagnation. Extension auto-injects strategy-change prompt. After 2 consecutive auto-recoveries with no progress, extension notifies user.
## Continuation Prompt Format (Fixed)
When context is tight, output exactly this format:
🔄 目标状态已保存至 .agents/goal/GOAL.md 当前进度:X/Y criteria 通过,第 N 轮迭代。
The extension injects the full state summary on every `before_agent_start`, so user does not need to say "继续当前目标" — the next prompt automatically re-loads the goal.
## Red Flags — These Do NOT Count as Completion
- "LLM call failed, goal should be considered complete" → retry or blocked
- "Tests don't run but code looks fine" → must have test evidence
- "Context full, let me summarize" → save state, extension auto-injects continuation, do not declare done
- "Too many retries, skip this criterion" → exhausted retries → blocked, do not skip
- "This criterion is too hard to verify, skip it" → agent may auto-rewrite, logged as material change
- "All criteria basically met, close enough" → each criterion must have explicit pass evidence
- "Deslop pass would take too long, skip" → deslop failure does not block, but agent should still attempt it
- Step 2: Verify file exists
Run: ls -la /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/references/error-handling.md
Expected: file exists with non-zero size
- Step 3: Commit
cd /home/charles/workspaces/pi-setup
git add .pi/skills/goal-driven-loop/references/error-handling.md
git commit -m "feat(skill): add error-handling reference for goal-driven-loop"
Task 3: Create Extension Directory and trigger-detector.ts
Files:
-
Create:
.pi/extensions/goal-driven-loop/trigger-detector.ts -
Create:
.pi/extensions/goal-driven-loop/(directory) -
Step 1: Create extension directory
mkdir -p /home/charles/workspaces/pi-setup/.pi/extensions/goal-driven-loop
- Step 2: Write
trigger-detector.ts
Create .pi/extensions/goal-driven-loop/trigger-detector.ts with this exact content:
/**
* 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,
];
const TRIVIAL_KEYWORDS = [
"hotfix",
"tweak",
"fix a 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
if (/```[\s\S]*?```/.test(input) && containsTriggerInCodeBlock(input)) {
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: reported speech
const lowerInput = input.toLowerCase();
const hasReportedSpeech = REPORTED_SPEECH_MARKERS.some((marker) =>
lowerInput.includes(marker)
);
if (hasReportedSpeech) {
// Only block if trigger phrase is in the same sentence as reported speech
const sentences = input.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 = input.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
// Patterns: "验收条件是xxx", "acceptance: xxx", "criteria: xxx"
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 containsTriggerInCodeBlock(input: string): boolean {
const codeBlocks = input.match(/```[\s\S]*?```/g) || [];
for (const block of codeBlocks) {
for (const pattern of TRIGGER_PATTERNS) {
if (pattern.test(block)) {
return true;
}
}
}
return false;
}
function cleanGoalText(text: string): string {
// Remove trailing punctuation and whitespace
return text.replace(/[.,;:。;,]+$/, "").trim();
}
/**
* Determine if a goal is coarse-grained (should delegate to comet).
* Coarse: estimated file count > 5, multi-module, or architectural.
*/
export function isCoarseGrained(goalText: string): boolean {
const lower = goalText.toLowerCase();
// Check for multi-module signals
const multiModuleSignals = [
"across",
"throughout",
"整个",
"全",
"all modules",
"multiple",
"multiple files",
"多模块",
];
for (const signal of multiModuleSignals) {
if (lower.includes(signal)) {
return true;
}
}
// Check for explicit file count mentions
const fileCountMatch = goalText.match(/(\d+)\s*(?:个)?\s*(?:file|files|文件)/i);
if (fileCountMatch) {
const count = parseInt(fileCountMatch[1], 10);
if (count > 5) {
return true;
}
}
// Check for architectural keywords
const architecturalSignals = [
"redesign",
"rewrite",
"migrate",
"重构",
"迁移",
"重写",
"重新设计",
];
for (const signal of architecturalSignals) {
if (lower.includes(signal.toLowerCase())) {
return true;
}
}
return false;
}
- Step 3: Verify TypeScript compiles
Run: cd /home/charles/workspaces/pi-setup && npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict .pi/extensions/goal-driven-loop/trigger-detector.ts 2>&1 | head -20
Expected: no errors (or only type warnings about missing module imports which is expected for the standalone file)
- Step 4: Commit
cd /home/charles/workspaces/pi-setup
git add .pi/extensions/goal-driven-loop/trigger-detector.ts
git commit -m "feat(ext): add trigger-detector for goal-driven-loop"
Task 4: Create state-injector.ts
Files:
-
Create:
.pi/extensions/goal-driven-loop/state-injector.ts -
Step 1: Write
state-injector.ts
Create .pi/extensions/goal-driven-loop/state-injector.ts with this exact content:
/**
* 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");
// Parse frontmatter
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") || "";
// Parse acceptance criteria
const criteriaSection = extractSection(content, "Acceptance Criteria");
const totalCriteria = countCheckboxes(criteriaSection);
const passedCriteria = countCheckedBoxes(criteriaSection);
// Parse blocker
const blockerSection = extractSection(content, "Blocked");
const blocker = blockerSection.trim() || undefined;
// Parse progress log
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();
// Strip surrounding quotes
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[] {
const matches = section.match(/### Iteration \d+/g) || [];
return matches;
}
- Step 2: Verify the file compiles
Run: cd /home/charles/workspaces/pi-setup && npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict .pi/extensions/goal-driven-loop/state-injector.ts 2>&1 | head -20
Expected: no errors
- Step 3: Commit
cd /home/charles/workspaces/pi-setup
git add .pi/extensions/goal-driven-loop/state-injector.ts
git commit -m "feat(ext): add state-injector for goal-driven-loop"
Task 5: Create ui-prompts.ts
Files:
-
Create:
.pi/extensions/goal-driven-loop/ui-prompts.ts -
Step 1: Write
ui-prompts.ts
Create .pi/extensions/goal-driven-loop/ui-prompts.ts with this exact content:
/**
* 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 STAGGNATION_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) {
// No UI → auto-continue
return "continue";
}
const choice = await ctx.select(
"已有目标在进行中",
["继续当前目标", "替换为新目标", "放弃当前目标"],
{ timeout: DEFAULT_TIMEOUT_MS }
);
switch (choice) {
case "继续当前目标":
return "continue";
case "替换为新目标":
return "replace";
case "放弃当前目标":
return "abandon";
default:
// Timeout or cancel → auto-continue
return "continue";
}
}
/**
* Notify the user that the goal is being delegated to comet.
* Non-blocking.
*/
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.
* Non-blocking.
*/
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 STAGGNATION_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.
* Auto-recovery is tried STAGGNATION_NOTIFY_THRESHOLD times first.
*/
export function shouldNotifyStagnation(
consecutiveStagnations: number
): boolean {
return consecutiveStagnations > STAGGNATION_NOTIFY_THRESHOLD;
}
- Step 2: Verify the file compiles
Run: cd /home/charles/workspaces/pi-setup && npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict .pi/extensions/goal-driven-loop/ui-prompts.ts 2>&1 | head -20
Expected: no errors
- Step 3: Commit
cd /home/charles/workspaces/pi-setup
git add .pi/extensions/goal-driven-loop/ui-prompts.ts
git commit -m "feat(ext): add ui-prompts with timed auto-defaults"
Task 6: Create index.ts (Wiring)
Files:
-
Create:
.pi/extensions/goal-driven-loop/index.ts -
Step 1: Write
index.ts
Create .pi/extensions/goal-driven-loop/index.ts with this exact content:
/**
* goal-driven-loop extension for Pi.
*
* Wires together trigger detection, state injection, and timed
* auto-defaults to eliminate unnecessary user interaction.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { detectTrigger, isCoarseGrained } from "./trigger-detector.js";
import { readGoalState, buildInjectionText, buildContinuationPrompt } from "./state-injector.js";
import {
promptStaleState,
notifyCoarseGrainedDelegation,
notifyMidLoopUpgrade,
notifyStagnation,
shouldNotifyStagnation,
type UIContext,
} from "./ui-prompts.js";
const CONTEXT_TENSION_THRESHOLD = 0.8;
const ABANDON_KEYWORDS = ["abandon", "cancel", "不要了", "放弃", "停"];
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" };
}
const match = detectTrigger(event.text);
if (!match) {
// Check for explicit abandon
const lowerText = event.text.toLowerCase();
const isAbandon = ABANDON_KEYWORDS.some((kw) =>
lowerText.includes(kw.toLowerCase())
);
if (isAbandon) {
handleAbandon(ctx);
}
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);
// Inject a comet trigger as a follow-up message
pi.sendUserMessage(`/skill:comet-open ${match.goalText}`, {
deliverAs: "followUp",
});
return { action: "continue" };
}
// Fine-grained — let the skill handle it on the next turn
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.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.",
};
}
// Inject goal state
const injection = buildInjectionText(state);
return {
systemPrompt: event.systemPrompt + "\n\n" + injection,
};
});
// ---- Stagnation auto-recovery on agent end ----
pi.on("agent_end", async (event, _ctx) => {
// Reset stagnation counter on any successful agent end
consecutiveStagnations = 0;
});
}
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 = `${ctx.cwd}/.agents/goal/GOAL.md`;
try {
const fs = require("node:fs");
if (fs.existsSync(goalPath)) {
fs.unlinkSync(goalPath);
}
} catch {
// best effort
}
if (ctx.hasUI) {
ctx.ui.notify("目标已放弃", "info");
}
}
- Step 2: Verify the file compiles
Run: cd /home/charles/workspaces/pi-setup && npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict .pi/extensions/goal-driven-loop/*.ts 2>&1 | head -30
Expected: no errors (or only minor warnings about implicit any types in the buildUIContext helper)
- Step 3: Commit
cd /home/charles/workspaces/pi-setup
git add .pi/extensions/goal-driven-loop/index.ts
git commit -m "feat(ext): wire index.ts for goal-driven-loop extension"
Task 7: Manual Behavioral Verification
Files:
- Read:
.pi/skills/goal-driven-loop/SKILL.md - Read:
.pi/extensions/goal-driven-loop/
The skill is documentation; the extension is code. Both require behavioral verification.
- Step 1: Verify skill frontmatter
Run: head -5 /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/SKILL.md
Expected: starts with ---, has name: goal-driven-loop and description: fields
- Step 2: Verify description format
Run: grep -A1 "^description:" /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/SKILL.md | tail -1
Expected: starts with "Use when"
- Step 3: Verify SKILL.md word count
Run: wc -w /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/SKILL.md
Expected: ≤ 500 words
- Step 4: Verify error-handling reference is present
Run: grep -c "^## " /home/charles/workspaces/pi-setup/.pi/skills/goal-driven-loop/references/error-handling.md
Expected: ≥ 5
- Step 5: Verify all extension files exist
Run: ls /home/charles/workspaces/pi-setup/.pi/extensions/goal-driven-loop/
Expected: 4 files — index.ts, trigger-detector.ts, state-injector.ts, ui-prompts.ts
- Step 6: Verify extension TypeScript compiles
Run: cd /home/charles/workspaces/pi-setup && npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext --strict .pi/extensions/goal-driven-loop/*.ts 2>&1 | head -30
Expected: no errors
- Step 7: Verify commit history
Run: cd /home/charles/workspaces/pi-setup && git log --oneline -6
Expected: 6 commits visible:
feat(skill): add goal-driven-loop SKILL.md with core loop instructionsfeat(skill): add error-handling reference for goal-driven-loopfeat(ext): add trigger-detector for goal-driven-loopfeat(ext): add state-injector for goal-driven-loopfeat(ext): add ui-prompts with timed auto-defaultsfeat(ext): wire index.ts for goal-driven-loop extension
Self-Review
1. Spec coverage:
| Spec Section | Covered by Task |
|---|---|
| Skill trigger + anti-trigger (Section 1) | Task 1 (SKILL.md) + Task 3 (trigger-detector.ts) |
| Goal state file (Section 2) | Task 1 (SKILL.md) + Task 4 (state-injector.ts) |
| Error handling (Section 3) | Task 2 (error-handling.md) |
| Core loop flow (Section 4) | Task 1 (SKILL.md) |
| Comet integration (Section 5) | Task 1 (SKILL.md) + Task 6 (index.ts sends to comet-open) |
| File structure (Section 6) | Tasks 1-6 create the structure |
| Extension trigger detection (Section 7.1) | Task 3 (trigger-detector.ts) + Task 6 (input handler) |
| Extension state injection (Section 7.2) | Task 4 (state-injector.ts) + Task 6 (before_agent_start) |
| Extension context tension (Section 7.3) | Task 6 (usage check + system prompt injection) |
| Extension stale state (Section 7.4) | Task 5 (ui-prompts.ts promptStaleState) + Task 6 (input handler) |
| Extension criteria quality gate (Section 7.5) | Not yet implemented (out of scope for v1, agent self-checks) |
| Extension stagnation recovery (Section 7.6) | Task 5 (ui-prompts.ts notifyStagnation) + Task 6 (counter reset) |
| Extension mid-loop upgrade (Section 7.7) | Task 6 (coarse delegation in input handler) |
| Extension abandon detection (Section 7.8) | Task 6 (abandon keyword check in input handler) |
All spec sections covered. Criteria quality gate (7.5) is delegated to the skill's verifiability red line, which is more direct than a separate extension check.
2. Placeholder scan: No "TBD", "TODO", "implement later", "fill in details", "similar to Task N" in any task. All file contents fully written out.
3. Type/term consistency:
goal-driven-loopused consistently as skill + extension nameGOAL.mdused consistently as filenameiterationused consistently as counter namecriteria/criterionsingular/plural used grammaticallyexponential backoffused consistently in both filesstatusvalues: in-progress / blocked / complete — consistent across both filesctx.ui.select/ctx.ui.notify/ctx.ui.confirmused with timeout option consistentlyreadGoalState/buildInjectionText/buildContinuationPromptimport names match exports
No inconsistencies found.