@koltmcbride/pi-goal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +64 -0
- package/src/extension/evaluator.ts +131 -0
- package/src/extension/index.ts +169 -0
- package/src/extension/persistence.ts +74 -0
- package/src/extension/slash.ts +140 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kolt McBride
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# pi-goal
|
|
2
|
+
|
|
3
|
+
[](./LICENSE)
|
|
4
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
5
|
+
|
|
6
|
+
> Goal-directed autonomous work loops for [pi](https://github.com/earendil-works/pi-coding-agent).
|
|
7
|
+
|
|
8
|
+
Set a goal as a single completion condition. After every turn, a lightweight
|
|
9
|
+
evaluator checks whether the condition is met. If it isn't, the agent is
|
|
10
|
+
automatically prompted to keep working — turn after turn — until the goal is
|
|
11
|
+
satisfied or you stop it. It's the autonomous counterpart to a plain prompt:
|
|
12
|
+
you describe the *end state*, not each step.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
/goal all tests in test/auth pass and the lint step is clean
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Single-condition goals** — describe a verifiable end state; the agent drives toward it.
|
|
23
|
+
- **Self-evaluating loop** — after each turn the configured model judges progress with a cheap yes/no call.
|
|
24
|
+
- **Live status** — a footer timer shows elapsed time and turn count while a goal runs.
|
|
25
|
+
- **Resumes across sessions** — an in-progress goal is restored automatically when you reopen the session.
|
|
26
|
+
- **Bounded** — a hard turn cap prevents a goal that never resolves from looping forever.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- [pi](https://github.com/earendil-works/pi-coding-agent) (`@earendil-works/pi-coding-agent`).
|
|
31
|
+
- A configured model — the same model that powers your session is reused for evaluation.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
From npm:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pi install npm:@koltmcbride/pi-goal
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From git:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi install git:github.com/kolt-mcb/pi-goal@v0.1.0
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> ⚠️ Pi packages run with full system access. Review the source before installing.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
/goal <condition> Set a goal and start working immediately
|
|
53
|
+
/goal Show the active goal's status
|
|
54
|
+
/goal status Alias for /goal
|
|
55
|
+
/goal clear Clear the active goal
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`clear` also accepts the aliases `stop`, `off`, `reset`, `none`, and `cancel`.
|
|
59
|
+
The subcommands are offered as autocompletions when you type `/goal ` and press
|
|
60
|
+
Tab.
|
|
61
|
+
|
|
62
|
+
Re-issuing `/goal <condition>` while a goal is active replaces the condition
|
|
63
|
+
and restarts the loop with it.
|
|
64
|
+
|
|
65
|
+
### Writing effective conditions
|
|
66
|
+
|
|
67
|
+
A good condition has **one measurable end state** and **a clear way for the
|
|
68
|
+
agent to demonstrate it** in its output:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
/goal all tests in test/auth pass and the lint step is clean
|
|
72
|
+
/goal refactor src/database to use connection pooling, verified by tests in test/db.test.ts
|
|
73
|
+
/goal every exported symbol in src/api has a docstring
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Goals work best for substantial, verifiable work — migrating a module until
|
|
77
|
+
every call site compiles, implementing a design doc until its acceptance
|
|
78
|
+
criteria hold, or working through a backlog until the queue is empty.
|
|
79
|
+
|
|
80
|
+
### Status display
|
|
81
|
+
|
|
82
|
+
While a goal is active, the footer shows a live timer:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
⏱ 3t · 2m 15s
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`/goal` (or `/goal status`) prints the full state:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
Condition: all tests in test/auth pass and the lint step is clean
|
|
92
|
+
Running: 2m 15s
|
|
93
|
+
Turns: 3
|
|
94
|
+
Last reason: one failing test remains in test/auth/login.test.ts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
On completion the footer reports `✓ Goal achieved in 3 turns`.
|
|
98
|
+
|
|
99
|
+
## How it works
|
|
100
|
+
|
|
101
|
+
1. **Set** — `/goal <condition>` records the condition and sends it as the
|
|
102
|
+
first prompt, kicking off work immediately.
|
|
103
|
+
2. **Evaluate** — at the end of each turn, the agent's text and tool results are
|
|
104
|
+
summarized and passed to a minimal evaluator call on the same configured
|
|
105
|
+
model: *has the condition been met?*
|
|
106
|
+
3. **Continue or stop** — if not met, a hidden continuation message is queued as
|
|
107
|
+
the next turn with the latest evaluation reason, and the agent keeps working.
|
|
108
|
+
When the evaluator returns *yes*, the loop stops and the result is reported.
|
|
109
|
+
|
|
110
|
+
A goal stops automatically when the condition is met, when you run `/goal clear`,
|
|
111
|
+
or after a safety cap of **120 turns** without success. State is persisted as
|
|
112
|
+
custom session entries, so a goal that was still running when a session ended is
|
|
113
|
+
restored on resume.
|
|
114
|
+
|
|
115
|
+
## Relationship to Claude Code's `/goal`
|
|
116
|
+
|
|
117
|
+
pi-goal mirrors the `/goal` interface from Claude Code, with a few differences:
|
|
118
|
+
|
|
119
|
+
| | Claude Code | pi-goal |
|
|
120
|
+
|---|:---:|:---:|
|
|
121
|
+
| Slash interface | ✓ | ✓ |
|
|
122
|
+
| Session persistence | ✓ | ✓ |
|
|
123
|
+
| Footer timer | ✓ | ✓ |
|
|
124
|
+
| Evaluation model | separate small model | your configured model (minimal reasoning) |
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm install # install dev + peer dependencies
|
|
130
|
+
npm run typecheck # tsc --noEmit
|
|
131
|
+
npm test # run the smoke test
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The extension is plain TypeScript; pi loads the source directly via the `pi`
|
|
135
|
+
manifest in [`package.json`](./package.json), so there is no build step.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
[MIT](./LICENSE) © Kolt McBride
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@koltmcbride/pi-goal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for goal-directed autonomous work loops",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kolt McBride",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"pi-coding-agent",
|
|
15
|
+
"goal",
|
|
16
|
+
"automation"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kolt-mcb/pi-goal.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/kolt-mcb/pi-goal/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/kolt-mcb/pi-goal#readme",
|
|
26
|
+
"files": [
|
|
27
|
+
"src/**/*.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/extension/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "tsx test/smoke.test.ts"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-agent-core": "*",
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@earendil-works/pi-agent-core": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"@earendil-works/pi-ai": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@earendil-works/pi-coding-agent": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-agent-core": "^0.74.0",
|
|
58
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
60
|
+
"@types/node": "^25.9.1",
|
|
61
|
+
"tsx": "^4.22.3",
|
|
62
|
+
"typescript": "^6.0.3"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight goal evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Calls the same configured model with a minimal prompt to determine
|
|
5
|
+
* whether the goal completion condition has been satisfied based on
|
|
6
|
+
* the agent's most recent turn output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { completeSimple } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { GoalState } from "./persistence";
|
|
12
|
+
|
|
13
|
+
interface EvalResult {
|
|
14
|
+
met: boolean;
|
|
15
|
+
reason: string;
|
|
16
|
+
usage?: { input: number; output: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the evaluator prompt from the goal condition and turn evidence.
|
|
21
|
+
*/
|
|
22
|
+
function buildEvalPrompt(condition: string, turnText: string, turnCount: number): string {
|
|
23
|
+
return [
|
|
24
|
+
`CONDITION: ${condition}`,
|
|
25
|
+
`TURN: ${turnCount}`,
|
|
26
|
+
`AGENT OUTPUT SUMMARY (text + tool results from last turn):`,
|
|
27
|
+
"---",
|
|
28
|
+
truncate(turnText, 6000),
|
|
29
|
+
"---",
|
|
30
|
+
"",
|
|
31
|
+
"Has the condition been met? Reply with exactly one of:",
|
|
32
|
+
' YES — if the agent output demonstrates the condition is satisfied.',
|
|
33
|
+
' NO: <brief reason> — if not, what is still needed.',
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Evaluate whether the goal condition is met.
|
|
39
|
+
*/
|
|
40
|
+
export async function evaluateGoal(
|
|
41
|
+
model: Model<any>,
|
|
42
|
+
state: GoalState,
|
|
43
|
+
turnText: string,
|
|
44
|
+
): Promise<EvalResult> {
|
|
45
|
+
const prompt = buildEvalPrompt(state.condition, turnText, state.turnCount + 1);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const result = await completeSimple(model, {
|
|
49
|
+
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
|
50
|
+
tools: [],
|
|
51
|
+
}, {
|
|
52
|
+
// Keep the evaluator cheap: a yes/no judgement needs no deep reasoning.
|
|
53
|
+
reasoning: "minimal",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const text = (result.content ?? [])
|
|
57
|
+
.filter((c: any) => c.type === "text")
|
|
58
|
+
.map((c: any) => c.text ?? "")
|
|
59
|
+
.join(" ")
|
|
60
|
+
.trim();
|
|
61
|
+
|
|
62
|
+
if (/^YES$/i.test(text)) {
|
|
63
|
+
return { met: true, reason: "Condition satisfied" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const match = /^NO:\s*(.*)/i.exec(text);
|
|
67
|
+
return {
|
|
68
|
+
met: false,
|
|
69
|
+
reason: match?.[1]?.trim() ?? "Evaluator did not reach a conclusion",
|
|
70
|
+
};
|
|
71
|
+
} catch (err: unknown) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
return {
|
|
74
|
+
met: false,
|
|
75
|
+
reason: `Evaluator error: ${msg.slice(0, 120)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Evaluate with explicit turn text and context messages.
|
|
82
|
+
* The turnText should contain the assistant's text + tool result evidence
|
|
83
|
+
* from the most recent turn.
|
|
84
|
+
*/
|
|
85
|
+
export function buildTurnEvidence(
|
|
86
|
+
event: { message?: unknown; toolResults?: unknown[] },
|
|
87
|
+
): string {
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
|
|
90
|
+
// Assistant message text
|
|
91
|
+
const msg = event.message as { role?: string; content?: unknown[] } | undefined;
|
|
92
|
+
if (msg?.content && Array.isArray(msg.content)) {
|
|
93
|
+
for (const block of msg.content) {
|
|
94
|
+
const b = block as { type?: string; text?: string; name?: string; arguments?: unknown };
|
|
95
|
+
if (b.type === "text" && b.text) {
|
|
96
|
+
parts.push(`--- text output ---\n${b.text}`);
|
|
97
|
+
} else if (b.type === "toolCall" || b.type === "tool_use") {
|
|
98
|
+
const toolName = b.name ?? "unknown";
|
|
99
|
+
const args = typeof b.arguments === "string" ? b.arguments : JSON.stringify(b.arguments ?? "");
|
|
100
|
+
parts.push(`--- tool use: ${toolName} ---\n${args}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tool results
|
|
106
|
+
const toolResults = event.toolResults as Array<{
|
|
107
|
+
toolName?: string;
|
|
108
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
109
|
+
isError?: boolean;
|
|
110
|
+
}> | undefined;
|
|
111
|
+
if (toolResults?.length) {
|
|
112
|
+
for (const tr of toolResults) {
|
|
113
|
+
const name = tr.toolName ?? "unknown";
|
|
114
|
+
const isError = tr.isError ? " [error]" : "";
|
|
115
|
+
const text = (tr.content ?? [])
|
|
116
|
+
.filter((c: { type?: string; text?: string }) => c.type === "text")
|
|
117
|
+
.map((c: { type?: string; text?: string }) => c.text ?? "")
|
|
118
|
+
.join("\n");
|
|
119
|
+
if (text) {
|
|
120
|
+
parts.push(`--- tool result: ${name}${isError} ---\n${text}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parts.join("\n\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function truncate(s: string, max: number): string {
|
|
129
|
+
if (s.length <= max) return s;
|
|
130
|
+
return s.slice(0, max) + "\n...(truncated)";
|
|
131
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — Goal-directed autonomous work loops
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Claude Code's /goal interface:
|
|
5
|
+
* /goal <condition> — set a goal, starts working immediately
|
|
6
|
+
* /goal — show goal status
|
|
7
|
+
* /goal clear — clear the active goal
|
|
8
|
+
*
|
|
9
|
+
* After each turn, a lightweight evaluator call checks whether the condition
|
|
10
|
+
* is met. If not, a nextTurn message kicks off the next turn automatically,
|
|
11
|
+
* so the agent keeps working until the goal is satisfied or cleared.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ExtensionAPI,
|
|
16
|
+
ExtensionContext,
|
|
17
|
+
TurnEndEvent,
|
|
18
|
+
SessionStartEvent,
|
|
19
|
+
SessionShutdownEvent,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { GoalState } from "./persistence";
|
|
22
|
+
import { GOAL_STATE_TYPE, saveState, loadState, saveAchieved } from "./persistence";
|
|
23
|
+
import { evaluateGoal, buildTurnEvidence } from "./evaluator";
|
|
24
|
+
import { registerSlashCommands } from "./slash";
|
|
25
|
+
|
|
26
|
+
// ── Constants ────────────────────────────────────────────────────────────
|
|
27
|
+
const STATUS_KEY = "goal-status";
|
|
28
|
+
// Hard cap on continuation turns, so a goal that never resolves can't loop forever.
|
|
29
|
+
const MAX_TURNS = 120;
|
|
30
|
+
|
|
31
|
+
// ── Module-level state ───────────────────────────────────────────────────
|
|
32
|
+
let activeGoal: GoalState | null = null;
|
|
33
|
+
let statusTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
let lastUiCtx: ExtensionContext | null = null;
|
|
35
|
+
let extensionApi: ExtensionAPI | null = null;
|
|
36
|
+
|
|
37
|
+
// ── Status display ───────────────────────────────────────────────────────
|
|
38
|
+
function statusText(goal: GoalState): string {
|
|
39
|
+
const totalSec = Math.floor((Date.now() - goal.startedAt) / 1000);
|
|
40
|
+
const hrs = Math.floor(totalSec / 3600);
|
|
41
|
+
const mins = Math.floor((totalSec % 3600) / 60);
|
|
42
|
+
const secs = totalSec % 60;
|
|
43
|
+
const t = hrs > 0
|
|
44
|
+
? `${String(hrs).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
|
|
45
|
+
: `${mins}m ${secs}s`;
|
|
46
|
+
return `⏱ ${goal.turnCount}t · ${t}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
50
|
+
ctx.ui.setStatus(STATUS_KEY, activeGoal ? statusText(activeGoal) : undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function startStatusTick(ctx: ExtensionContext): void {
|
|
54
|
+
stopStatusTick();
|
|
55
|
+
if (!activeGoal) return;
|
|
56
|
+
updateStatus(ctx);
|
|
57
|
+
statusTimer = setInterval(() => {
|
|
58
|
+
try { updateStatus(ctx); } catch { stopStatusTick(); }
|
|
59
|
+
}, 1000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function stopStatusTick(): void {
|
|
63
|
+
if (statusTimer) { clearInterval(statusTimer); statusTimer = null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── turn_end handler ─────────────────────────────────────────────────────
|
|
67
|
+
async function handleTurnEnd(event: TurnEndEvent, ctx: ExtensionContext): Promise<void> {
|
|
68
|
+
if (!activeGoal || !extensionApi) return;
|
|
69
|
+
if (!ctx.model) {
|
|
70
|
+
// No model available — can't evaluate, so stop the goal cleanly.
|
|
71
|
+
stopStatusTick();
|
|
72
|
+
ctx.ui.setStatus(STATUS_KEY, "⚠ No model for evaluation");
|
|
73
|
+
activeGoal = null;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build evidence from this turn and ask the evaluator if the goal is met.
|
|
78
|
+
const evidence = buildTurnEvidence(event);
|
|
79
|
+
activeGoal.elapsedMs = Date.now() - activeGoal.startedAt;
|
|
80
|
+
const evalResult = await evaluateGoal(ctx.model, activeGoal, evidence);
|
|
81
|
+
activeGoal.lastReason = evalResult.reason;
|
|
82
|
+
|
|
83
|
+
if (evalResult.met) {
|
|
84
|
+
// ✓ Goal achieved
|
|
85
|
+
saveAchieved(extensionApi, activeGoal);
|
|
86
|
+
const turns = activeGoal.turnCount;
|
|
87
|
+
stopStatusTick();
|
|
88
|
+
ctx.ui.setStatus(STATUS_KEY, `✓ Goal achieved in ${turns} turns`);
|
|
89
|
+
ctx.ui.notify(`Goal achieved in ${turns} turns`, "info");
|
|
90
|
+
activeGoal = null;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Goal not met — record progress and continue the loop.
|
|
95
|
+
activeGoal.turnCount++;
|
|
96
|
+
saveState(extensionApi, activeGoal);
|
|
97
|
+
updateStatus(ctx);
|
|
98
|
+
|
|
99
|
+
if (activeGoal.turnCount > MAX_TURNS) {
|
|
100
|
+
stopStatusTick();
|
|
101
|
+
ctx.ui.setStatus(STATUS_KEY, `⚠ Goal stopped: turn limit (${MAX_TURNS})`);
|
|
102
|
+
ctx.ui.notify(`Goal stopped after ${MAX_TURNS} turns without meeting the condition.`, "warning");
|
|
103
|
+
activeGoal = null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Inject a continuation message to keep the agent focused on the goal.
|
|
108
|
+
const reminder = [
|
|
109
|
+
`[GOAL: turn ${activeGoal.turnCount}]`,
|
|
110
|
+
``,
|
|
111
|
+
`The completion condition has not been met yet.`,
|
|
112
|
+
`Last evaluation: ${evalResult.reason}`,
|
|
113
|
+
``,
|
|
114
|
+
`Goal condition: ${activeGoal.condition}`,
|
|
115
|
+
`Continue working toward it.`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
extensionApi.sendMessage({
|
|
119
|
+
customType: GOAL_STATE_TYPE,
|
|
120
|
+
content: reminder,
|
|
121
|
+
// Hidden from the TUI (the footer timer shows progress); still steers the model.
|
|
122
|
+
display: false,
|
|
123
|
+
details: activeGoal,
|
|
124
|
+
}, {
|
|
125
|
+
deliverAs: "nextTurn",
|
|
126
|
+
triggerTurn: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── session lifecycle ────────────────────────────────────────────────────
|
|
131
|
+
function handleSessionStart(_event: SessionStartEvent, ctx: ExtensionContext): void {
|
|
132
|
+
lastUiCtx = ctx;
|
|
133
|
+
|
|
134
|
+
// Restore an in-progress goal from a previous session, if any.
|
|
135
|
+
const saved = loadState(ctx.sessionManager);
|
|
136
|
+
if (saved) {
|
|
137
|
+
activeGoal = saved;
|
|
138
|
+
startStatusTick(ctx);
|
|
139
|
+
const label = saved.condition.length > 60 ? `${saved.condition.slice(0, 60)}…` : saved.condition;
|
|
140
|
+
ctx.ui.notify(`Restored goal: "${label}" (${saved.turnCount} turns so far)`, "info");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleSessionShutdown(_event: SessionShutdownEvent): void {
|
|
145
|
+
stopStatusTick();
|
|
146
|
+
activeGoal = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Extension entry point ────────────────────────────────────────────────
|
|
150
|
+
export default function registerGoalExtension(pi: ExtensionAPI): void {
|
|
151
|
+
extensionApi = pi;
|
|
152
|
+
|
|
153
|
+
registerSlashCommands(pi, {
|
|
154
|
+
get: () => activeGoal,
|
|
155
|
+
set: (state) => {
|
|
156
|
+
activeGoal = state;
|
|
157
|
+
if (lastUiCtx) startStatusTick(lastUiCtx);
|
|
158
|
+
},
|
|
159
|
+
clear: () => {
|
|
160
|
+
stopStatusTick();
|
|
161
|
+
activeGoal = null;
|
|
162
|
+
if (lastUiCtx) lastUiCtx.ui.setStatus(STATUS_KEY, undefined);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("turn_end", handleTurnEnd);
|
|
167
|
+
pi.on("session_start", handleSessionStart);
|
|
168
|
+
pi.on("session_shutdown", handleSessionShutdown);
|
|
169
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal state persistence via session entries.
|
|
3
|
+
*
|
|
4
|
+
* Goal metadata is stored as custom session entries so an in-progress goal
|
|
5
|
+
* survives session resume. Writes go through `pi.appendEntry` (ExtensionAPI);
|
|
6
|
+
* reads walk the entries exposed by the read-only session manager.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
// ReadonlySessionManager isn't exported from the package root; derive it from
|
|
12
|
+
// the context shape so loadState accepts exactly what event handlers receive.
|
|
13
|
+
type ReadonlySessionManager = ExtensionContext["sessionManager"];
|
|
14
|
+
|
|
15
|
+
/** customType for an active-goal snapshot entry. */
|
|
16
|
+
const STATE_TYPE = "pi-goal-state";
|
|
17
|
+
/** customType for a marker written when a goal is achieved. */
|
|
18
|
+
const ACHIEVED_TYPE = "pi-goal-achieved";
|
|
19
|
+
|
|
20
|
+
export interface GoalState {
|
|
21
|
+
condition: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
turnCount: number;
|
|
24
|
+
elapsedMs: number;
|
|
25
|
+
lastReason: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Re-exported so the extension can tag continuation messages consistently.
|
|
29
|
+
export { STATE_TYPE as GOAL_STATE_TYPE };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Save the current goal state as a custom session entry.
|
|
33
|
+
* The latest such entry wins on resume (see loadState).
|
|
34
|
+
*/
|
|
35
|
+
export function saveState(pi: ExtensionAPI, state: GoalState): void {
|
|
36
|
+
pi.appendEntry(STATE_TYPE, { timestamp: Date.now(), ...state });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the most recent goal state from the session.
|
|
41
|
+
*
|
|
42
|
+
* Walks entries newest-first. `pi.appendEntry` stores a CustomEntry whose
|
|
43
|
+
* `type` is always "custom" and whose `customType` carries our tag, so we
|
|
44
|
+
* match on `customType` — not `type`. A more recent "achieved" marker means
|
|
45
|
+
* the last goal finished, so there is no active goal to restore.
|
|
46
|
+
*/
|
|
47
|
+
export function loadState(
|
|
48
|
+
sessionManager: ReadonlySessionManager,
|
|
49
|
+
): GoalState | null {
|
|
50
|
+
const entries = sessionManager.getEntries() ?? [];
|
|
51
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
52
|
+
const entry = entries[i] as {
|
|
53
|
+
type?: string;
|
|
54
|
+
customType?: string;
|
|
55
|
+
data?: unknown;
|
|
56
|
+
};
|
|
57
|
+
if (entry.type !== "custom") continue;
|
|
58
|
+
if (entry.customType === STATE_TYPE && entry.data) {
|
|
59
|
+
return entry.data as GoalState;
|
|
60
|
+
}
|
|
61
|
+
if (entry.customType === ACHIEVED_TYPE) {
|
|
62
|
+
return null; // most recent goal was completed — nothing active
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mark a goal as achieved. Appends a marker entry so loadState knows the
|
|
70
|
+
* goal was completed rather than still active.
|
|
71
|
+
*/
|
|
72
|
+
export function saveAchieved(pi: ExtensionAPI, state: GoalState): void {
|
|
73
|
+
pi.appendEntry(ACHIEVED_TYPE, { timestamp: Date.now(), ...state });
|
|
74
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash commands for /goal.
|
|
3
|
+
*
|
|
4
|
+
* /goal <condition> — set (or replace) a goal, start working
|
|
5
|
+
* /goal — show goal status when active; usage info when no goal
|
|
6
|
+
* /goal clear — clear the active goal
|
|
7
|
+
* /goal status — same as /goal
|
|
8
|
+
*
|
|
9
|
+
* Mirroring Claude Code's /goal, the clear action accepts several aliases:
|
|
10
|
+
* clear, stop, off, reset, none, cancel.
|
|
11
|
+
*
|
|
12
|
+
* The command owns no state of its own: index.ts passes accessor callbacks so
|
|
13
|
+
* the command and the extension's turn-end loop share a single source of truth.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { GoalState } from "./persistence";
|
|
18
|
+
|
|
19
|
+
/** Accessors into the extension's active-goal state, supplied by index.ts. */
|
|
20
|
+
export interface GoalSlashAPI {
|
|
21
|
+
get: () => GoalState | null;
|
|
22
|
+
set: (state: GoalState) => void;
|
|
23
|
+
clear: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Words that clear an active goal. Mirrors Claude Code's /goal aliases. */
|
|
27
|
+
const CLEAR_ALIASES = ["clear", "stop", "off", "reset", "none", "cancel"];
|
|
28
|
+
|
|
29
|
+
/** Subcommands surfaced in the `/goal <Tab>` argument autocomplete. */
|
|
30
|
+
const SUBCOMMAND_COMPLETIONS = [
|
|
31
|
+
{ value: "status", label: "status", description: "Show the active goal's status" },
|
|
32
|
+
{ value: "clear", label: "clear", description: "Clear the active goal" },
|
|
33
|
+
...CLEAR_ALIASES.filter((a) => a !== "clear").map((a) => ({
|
|
34
|
+
value: a,
|
|
35
|
+
label: a,
|
|
36
|
+
description: "Clear the active goal (alias for clear)",
|
|
37
|
+
})),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** True when the argument is one of the clear/stop/off/… reserved words. */
|
|
41
|
+
function isClearCommand(arg: string): boolean {
|
|
42
|
+
return CLEAR_ALIASES.includes(arg.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerSlashCommands(pi: ExtensionAPI, api: GoalSlashAPI): void {
|
|
46
|
+
pi.registerCommand("goal", {
|
|
47
|
+
description: "Goal-directed autonomous work loop",
|
|
48
|
+
getArgumentCompletions: (prefix: string) => {
|
|
49
|
+
const p = prefix.trim().toLowerCase();
|
|
50
|
+
const matches = SUBCOMMAND_COMPLETIONS.filter((c) => c.value.startsWith(p));
|
|
51
|
+
return matches.length > 0 ? matches : null;
|
|
52
|
+
},
|
|
53
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
54
|
+
const trimmed = args.trim();
|
|
55
|
+
const goal = api.get();
|
|
56
|
+
|
|
57
|
+
if (goal) {
|
|
58
|
+
// ── Goal is active ──────────────────────────────────
|
|
59
|
+
if (isClearCommand(trimmed)) {
|
|
60
|
+
api.clear();
|
|
61
|
+
ctx.ui.notify("Goal cleared.", "info");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (trimmed === "status" || !trimmed) {
|
|
65
|
+
showStatus(goal, ctx);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Additional args on an active goal → replace the condition
|
|
69
|
+
doSetGoal(pi, api, trimmed, ctx);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── No active goal ──────────────────────────────────────
|
|
74
|
+
if (!trimmed || trimmed === "status") {
|
|
75
|
+
noActiveGoal(ctx);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (isClearCommand(trimmed)) {
|
|
79
|
+
ctx.ui.notify("No active goal to clear.", "warning");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// New goal
|
|
84
|
+
doSetGoal(pi, api, trimmed, ctx);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Set a fresh goal and start the first turn with the condition as the prompt. */
|
|
90
|
+
function doSetGoal(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
api: GoalSlashAPI,
|
|
93
|
+
condition: string,
|
|
94
|
+
ctx: ExtensionCommandContext,
|
|
95
|
+
): void {
|
|
96
|
+
api.set({
|
|
97
|
+
condition,
|
|
98
|
+
startedAt: Date.now(),
|
|
99
|
+
turnCount: 0,
|
|
100
|
+
elapsedMs: 0,
|
|
101
|
+
lastReason: "",
|
|
102
|
+
});
|
|
103
|
+
ctx.ui.notify(
|
|
104
|
+
`Goal set: "${truncate(condition, 60)}"`,
|
|
105
|
+
"info",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Kick off the first turn. Send raw (executeSlashCommands defaults to false)
|
|
109
|
+
// so a condition that happens to start with "/" is treated as plain text.
|
|
110
|
+
pi.sendUserMessage(condition);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function showStatus(goal: GoalState, ctx: ExtensionCommandContext): void {
|
|
114
|
+
const lines = [
|
|
115
|
+
`Condition: ${goal.condition}`,
|
|
116
|
+
`Running: ${formatDuration(Date.now() - goal.startedAt)}`,
|
|
117
|
+
`Turns: ${goal.turnCount}`,
|
|
118
|
+
];
|
|
119
|
+
if (goal.lastReason) {
|
|
120
|
+
lines.push(`Last reason: ${goal.lastReason}`);
|
|
121
|
+
}
|
|
122
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function noActiveGoal(ctx: ExtensionCommandContext): void {
|
|
126
|
+
ctx.ui.notify("No active goal. Use `/goal <condition>` to set one.", "info");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatDuration(ms: number): string {
|
|
130
|
+
const totalSec = Math.floor(ms / 1000);
|
|
131
|
+
const hrs = Math.floor(totalSec / 3600);
|
|
132
|
+
const mins = Math.floor((totalSec % 3600) / 60);
|
|
133
|
+
const secs = totalSec % 60;
|
|
134
|
+
if (hrs > 0) return `${hrs}h ${mins}m ${secs}s`;
|
|
135
|
+
return `${mins}m ${secs}s`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function truncate(s: string, max: number): string {
|
|
139
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
140
|
+
}
|