1094 lines
34 KiB
Markdown
1094 lines
34 KiB
Markdown
# 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```markdown
|
||
---
|
||
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:
|
||
|
||
```markdown
|
||
# 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```typescript
|
||
/**
|
||
* 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```typescript
|
||
/**
|
||
* 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```typescript
|
||
/**
|
||
* 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**
|
||
|
||
```bash
|
||
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:
|
||
|
||
```typescript
|
||
/**
|
||
* 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**
|
||
|
||
```bash
|
||
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 instructions`
|
||
- `feat(skill): add error-handling reference for goal-driven-loop`
|
||
- `feat(ext): add trigger-detector for goal-driven-loop`
|
||
- `feat(ext): add state-injector for goal-driven-loop`
|
||
- `feat(ext): add ui-prompts with timed auto-defaults`
|
||
- `feat(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-loop` used consistently as skill + extension name
|
||
- `GOAL.md` used consistently as filename
|
||
- `iteration` used consistently as counter name
|
||
- `criteria` / `criterion` singular/plural used grammatically
|
||
- `exponential backoff` used consistently in both files
|
||
- `status` values: in-progress / blocked / complete — consistent across both files
|
||
- `ctx.ui.select` / `ctx.ui.notify` / `ctx.ui.confirm` used with timeout option consistently
|
||
- `readGoalState` / `buildInjectionText` / `buildContinuationPrompt` import names match exports
|
||
|
||
No inconsistencies found.
|