@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 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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
4
+ [![pi package](https://img.shields.io/badge/pi-extension-5b54d6.svg)](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
+ }