@fyeeme/pi-goal 1.0.0 → 1.0.1
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/CHANGELOG.md +6 -1
- package/README.md +2 -2
- package/index.ts +25 -5
- package/package.json +1 -1
- package/src/todo-bridge.ts +27 -12
- package/test/index.test.ts +79 -1
- package/test/tool.test.ts +31 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [1.0.1] - 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Footer task progress: with pi-todo live, the goal status segment appends ` · <closed>/<total> tasks` (closed = completed + abandoned, empty phases ignored) — e.g. `🎯 Goal 12K/399K · 3/10 tasks`. Restored from `todo-phases` session entries on session start/tree navigation.
|
|
8
|
+
- Missing `todo_updated` event wiring: pi-goal now subscribes to pi-todo's documented `todo_updated` broadcast (payload-validated), keeping the todo context and footer progress live between session restores. Previously `todoPhases` only refreshed on session start/tree navigation, so in-session todo mutations left the context stale.
|
|
4
9
|
|
|
5
10
|
## [1.0.0] - 2026-09-09
|
|
6
11
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Source: [oh-my-pi](https://github.com/can1357/oh-my-pi) (a fork of badlogic/pi-m
|
|
|
14
14
|
- **Interrupt safety** — Esc interrupts pause the goal (never complete it); usage accumulated so far is kept. Resuming a session auto-pauses a still-active goal, since the run that owned it is gone.
|
|
15
15
|
- **`/goal` command** — `set <objective>`, `show`, `pause`, `resume`, `drop`, `budget <N|off>`, or an interactive menu when called without arguments. Typing an objective while a goal is active (or paused) is rejected with omp's status/warning; `/goal drop` and the menu's Drop confirm before discarding.
|
|
16
16
|
- **`/guided-goal [rough objective]`** — the agent interviews you in normal chat (success criteria, verification method, attempt cap, scope boundaries, stop conditions) and then creates the goal itself via `goal create`.
|
|
17
|
-
- **Footer status** — a `<icon> Goal <used/budget>` segment (omp `renderGoalMode` unicode icon set: 🎯 active, ⏸ paused, ⚠ budget-limited) while the goal is enabled or paused. Completed goals stay visible in the transcript after reloads via a persistent `goal-completed` entry renderer.
|
|
17
|
+
- **Footer status** — a `<icon> Goal <used/budget>` segment (omp `renderGoalMode` unicode icon set: 🎯 active, ⏸ paused, ⚠ budget-limited) while the goal is enabled or paused. With [pi-todo](../pi-todo) live, a ` · <closed>/<total> tasks` suffix tracks todo progress (live via `todo_updated`, restored from session entries). Completed goals stay visible in the transcript after reloads via a persistent `goal-completed` entry renderer.
|
|
18
18
|
- **pi-todo integration** — when [pi-todo](../pi-todo) is installed, the goal context message includes a live `<todo_context>` block so continuations treat todos as current state, not stale transcript decoration.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
@@ -35,7 +35,7 @@ or copy/link this directory into `~/.pi/agent/extensions/` (global) or `.pi/exte
|
|
|
35
35
|
/goal drop
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Status line: a `<icon> Goal <used/budget>` segment appears in the footer while a goal is enabled or paused (omp footer segment semantics)
|
|
38
|
+
Status line: a `<icon> Goal <used/budget>` segment appears in the footer while a goal is enabled or paused (omp footer segment semantics); with pi-todo live it becomes `🎯 Goal 12K/399K · 3/10 tasks`.
|
|
39
39
|
|
|
40
40
|
## Session persistence
|
|
41
41
|
|
package/index.ts
CHANGED
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
* - `pi.events.emit("goal_updated", { goal, state })` after every runtime
|
|
50
50
|
* transition (goal may be null; state undefined after drop)
|
|
51
51
|
* - reads `todo_updated` events + "todo-phases" entries from pi-todo to
|
|
52
|
-
* render the goal todo context
|
|
52
|
+
* render the goal todo context and the footer task progress (works
|
|
53
|
+
* without pi-todo installed)
|
|
53
54
|
*/
|
|
54
55
|
|
|
55
56
|
// Prompt lives in a static .md asset next to this module (published with the
|
|
@@ -78,7 +79,13 @@ import { GoalRuntime } from "./src/runtime.ts";
|
|
|
78
79
|
import type { Goal, GoalModeState, GoalTokenUsage } from "./src/state.ts";
|
|
79
80
|
import { cloneGoal } from "./src/state.ts";
|
|
80
81
|
import { renderTemplate } from "./src/template.ts";
|
|
81
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
buildTodoContext,
|
|
84
|
+
parseTodoPhases,
|
|
85
|
+
restoreTodoPhases,
|
|
86
|
+
todoProgress,
|
|
87
|
+
type TodoPhase,
|
|
88
|
+
} from "./src/todo-bridge.ts";
|
|
82
89
|
import { runGoalEvaluator } from "./src/evaluator.ts";
|
|
83
90
|
import { createGoalTool, type GoalToolDeps } from "./src/tool.ts";
|
|
84
91
|
|
|
@@ -167,9 +174,10 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
167
174
|
}
|
|
168
175
|
}
|
|
169
176
|
|
|
170
|
-
|
|
177
|
+
/** omp status-line/segments.ts renderGoalMode: segment visible only while
|
|
171
178
|
* enabled or paused; text "<icon> Goal used[/budget]" (goal.statusInFooter
|
|
172
|
-
* defaults to true, so usage always renders).
|
|
179
|
+
* defaults to true, so usage always renders). pi-goal deviation: with
|
|
180
|
+
* live pi-todo phases a ` · <closed>/<total> tasks` suffix is appended. */
|
|
173
181
|
function updateStatus(): void {
|
|
174
182
|
const ctx = currentCtx;
|
|
175
183
|
if (!ctx) return;
|
|
@@ -181,7 +189,9 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
181
189
|
const goal = state.goal;
|
|
182
190
|
const used = formatNumber(goal.tokensUsed);
|
|
183
191
|
const budget = goal.tokenBudget !== undefined ? `/${formatNumber(goal.tokenBudget)}` : "";
|
|
184
|
-
|
|
192
|
+
const progress = todoProgress(todoPhases);
|
|
193
|
+
const tasks = progress ? ` · ${progress.closed}/${progress.total} tasks` : "";
|
|
194
|
+
ctx.ui.setStatus("goal", `${goalStatusIcon(goal.status)} Goal ${used}${budget}${tasks}`);
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
function notify(text: string, severity: "info" | "warning" | "error" = "info"): void {
|
|
@@ -581,6 +591,16 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
|
|
|
581
591
|
updateStatus();
|
|
582
592
|
});
|
|
583
593
|
|
|
594
|
+
// pi-todo contract: { phases } arrives over the shared event bus after
|
|
595
|
+
// every successful todo mutation. Keeps the goal todo context and the
|
|
596
|
+
// footer task progress live between session restores.
|
|
597
|
+
pi.events.on("todo_updated", (data) => {
|
|
598
|
+
const phases = parseTodoPhases((data as { phases?: unknown } | undefined)?.phases);
|
|
599
|
+
if (!phases) return;
|
|
600
|
+
todoPhases = phases;
|
|
601
|
+
updateStatus();
|
|
602
|
+
});
|
|
603
|
+
|
|
584
604
|
// Blocking ctx.ui dialogs (drop confirm, objective editor, /goal menu,
|
|
585
605
|
// budget input — plus other extensions' ask_user). While one is open the
|
|
586
606
|
// agent is not running, and a continuation must not start behind the modal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-goal",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Goal mode for pi \u2014 one persistent autonomous objective looped until verified success: goal tool (create/get/complete/resume/drop), token/time budget accounting with budget-limit steering, automatic continuation turns, /goal and /guided-goal commands, and a goal_updated event bus for other extensions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/todo-bridge.ts
CHANGED
|
@@ -89,6 +89,30 @@ export function restoreTodoPhases(entries: readonly unknown[]): TodoPhase[] {
|
|
|
89
89
|
return [];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** Validate an unknown value (e.g. a `todo_updated` event payload) as phases. */
|
|
93
|
+
export function parseTodoPhases(value: unknown): TodoPhase[] | undefined {
|
|
94
|
+
if (!Array.isArray(value) || !value.every(isTodoPhase)) return undefined;
|
|
95
|
+
return value.map((phase) => ({
|
|
96
|
+
name: phase.name,
|
|
97
|
+
tasks: phase.tasks.map((task) => ({ content: task.content, status: task.status })),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Footer progress over non-empty phases, same gate as buildTodoContext. */
|
|
102
|
+
export function todoProgress(phases: TodoPhase[]): { closed: number; total: number } | undefined {
|
|
103
|
+
const nonEmpty = phases.filter((phase) => phase.tasks.length > 0);
|
|
104
|
+
if (nonEmpty.length === 0) return undefined;
|
|
105
|
+
let closed = 0;
|
|
106
|
+
let total = 0;
|
|
107
|
+
for (const phase of nonEmpty) {
|
|
108
|
+
for (const task of phase.tasks) {
|
|
109
|
+
total++;
|
|
110
|
+
if (task.status === "completed" || task.status === "abandoned") closed++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { closed, total };
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/** omp #sanitizeGoalTodoText: XML-escape and flatten control characters. */
|
|
93
117
|
function sanitizeTodoText(text: string): string {
|
|
94
118
|
return escapeXmlText(text)
|
|
@@ -110,20 +134,11 @@ export function buildTodoContext(phases: TodoPhase[], todoToolActive: boolean):
|
|
|
110
134
|
const nonEmpty = phases.filter((phase) => phase.tasks.length > 0);
|
|
111
135
|
if (nonEmpty.length === 0) return undefined;
|
|
112
136
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let open = 0;
|
|
137
|
+
const { closed, total } = todoProgress(nonEmpty)!;
|
|
138
|
+
const open = total - closed;
|
|
116
139
|
const promptPhases = nonEmpty.map((phase) => ({
|
|
117
140
|
name: sanitizeTodoText(phase.name),
|
|
118
|
-
tasks: phase.tasks.map((task) => {
|
|
119
|
-
total++;
|
|
120
|
-
if (task.status === "completed" || task.status === "abandoned") {
|
|
121
|
-
closed++;
|
|
122
|
-
} else {
|
|
123
|
-
open++;
|
|
124
|
-
}
|
|
125
|
-
return { content: sanitizeTodoText(task.content), status: task.status };
|
|
126
|
-
}),
|
|
141
|
+
tasks: phase.tasks.map((task) => ({ content: sanitizeTodoText(task.content), status: task.status })),
|
|
127
142
|
}));
|
|
128
143
|
|
|
129
144
|
return renderTemplate(goalTodoContextPrompt, {
|
package/test/index.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ interface FakeHost {
|
|
|
30
30
|
{ handler: (args: string, ctx: unknown) => Promise<void>; getArgumentCompletions?: (prefix: string) => unknown }
|
|
31
31
|
>;
|
|
32
32
|
handlers: Map<string, Array<(event: unknown, ctx: unknown) => unknown>>;
|
|
33
|
+
busHandlers: Map<string, Array<(data: unknown) => void>>;
|
|
33
34
|
entries: Array<{ type: string; customType: string; data?: unknown; id: string }>;
|
|
34
35
|
sentMessages: Array<{
|
|
35
36
|
customType: string;
|
|
@@ -51,6 +52,7 @@ function fakeHost(): FakeHost {
|
|
|
51
52
|
tools: [],
|
|
52
53
|
commands: {},
|
|
53
54
|
handlers: new Map(),
|
|
55
|
+
busHandlers: new Map(),
|
|
54
56
|
entries: [],
|
|
55
57
|
sentMessages: [],
|
|
56
58
|
sentUserMessages: [],
|
|
@@ -101,7 +103,12 @@ function fakeHost(): FakeHost {
|
|
|
101
103
|
emit: (channel: string, data: unknown) => {
|
|
102
104
|
host.emitted.push({ channel, data });
|
|
103
105
|
},
|
|
104
|
-
on: () =>
|
|
106
|
+
on: (channel: string, handler: (data: unknown) => void) => {
|
|
107
|
+
const list = host.busHandlers.get(channel) ?? [];
|
|
108
|
+
list.push(handler);
|
|
109
|
+
host.busHandlers.set(channel, list);
|
|
110
|
+
return () => {};
|
|
111
|
+
},
|
|
105
112
|
},
|
|
106
113
|
} as unknown as ExtensionAPI;
|
|
107
114
|
return host;
|
|
@@ -141,6 +148,12 @@ async function fire(host: FakeHost, event: string, payload: unknown, ctx?: unkno
|
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
function fireBus(host: FakeHost, channel: string, data: unknown): void {
|
|
152
|
+
for (const handler of host.busHandlers.get(channel) ?? []) {
|
|
153
|
+
handler(data);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
function goalTool(host: FakeHost): Record<string, unknown> {
|
|
145
158
|
const tool = host.tools.find((t) => t.name === "goal");
|
|
146
159
|
if (!tool) throw new Error("goal tool not registered");
|
|
@@ -518,6 +531,71 @@ describe("pi-goal extension wiring", () => {
|
|
|
518
531
|
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
519
532
|
});
|
|
520
533
|
|
|
534
|
+
// ------------------------------------------------------------
|
|
535
|
+
// Footer task progress (pi-todo integration)
|
|
536
|
+
// ------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
it("shows live pi-todo task progress in the footer (todo_updated event)", async () => {
|
|
539
|
+
const host = fakeHost();
|
|
540
|
+
defaultExport(host.pi);
|
|
541
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
542
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
543
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
544
|
+
|
|
545
|
+
fireBus(host, "todo_updated", {
|
|
546
|
+
phases: [
|
|
547
|
+
{
|
|
548
|
+
name: "P1",
|
|
549
|
+
tasks: [
|
|
550
|
+
{ content: "a", status: "completed" },
|
|
551
|
+
{ content: "b", status: "in_progress" },
|
|
552
|
+
],
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
});
|
|
556
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0 · 1/2 tasks");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("ignores malformed todo_updated payloads (footer keeps the plain segment)", async () => {
|
|
560
|
+
const host = fakeHost();
|
|
561
|
+
defaultExport(host.pi);
|
|
562
|
+
await fire(host, "session_start", { type: "session_start", reason: "startup" });
|
|
563
|
+
await host.commands.goal!.handler("Do the thing", createContext(host));
|
|
564
|
+
|
|
565
|
+
fireBus(host, "todo_updated", { phases: "nope" });
|
|
566
|
+
fireBus(host, "todo_updated", {});
|
|
567
|
+
fireBus(host, "todo_updated", undefined);
|
|
568
|
+
expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("restores footer task progress from todo-phases entries on session_start", async () => {
|
|
572
|
+
const host = fakeHost();
|
|
573
|
+
defaultExport(host.pi);
|
|
574
|
+
const entries = [
|
|
575
|
+
...makeEntries(baseGoal, true),
|
|
576
|
+
{
|
|
577
|
+
type: "custom",
|
|
578
|
+
customType: "todo-phases",
|
|
579
|
+
id: "t1",
|
|
580
|
+
data: {
|
|
581
|
+
phases: [
|
|
582
|
+
{
|
|
583
|
+
name: "P1",
|
|
584
|
+
tasks: [
|
|
585
|
+
{ content: "a", status: "completed" },
|
|
586
|
+
{ content: "b", status: "pending" },
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
];
|
|
593
|
+
const ctx = createContext(host, { entries });
|
|
594
|
+
await fire(host, "session_start", { type: "session_start", reason: "resume" }, ctx);
|
|
595
|
+
// Cold resume pauses the goal; the restored progress still renders.
|
|
596
|
+
expect(host.statuses.get("goal")).toBe("⏸ Goal 0 · 1/2 tasks");
|
|
597
|
+
});
|
|
598
|
+
|
|
521
599
|
it("registers /goal argument completions (omp buildArgumentCompletions shape)", () => {
|
|
522
600
|
const host = fakeHost();
|
|
523
601
|
defaultExport(host.pi);
|
package/test/tool.test.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { IMPOSSIBLE_REPORT_CAP } from "../src/tool.ts";
|
|
|
12
12
|
import { GOAL_CLEARED_ENTRY_TYPE, GOAL_STATE_ENTRY_TYPE, restoreGoalFromEntries } from "../src/restore.ts";
|
|
13
13
|
import { GoalRuntime, type GoalRuntimeHost } from "../src/runtime.ts";
|
|
14
14
|
import type { Goal, GoalModeState, GoalRuntimeEvent, GoalTokenUsage, GoalToolDetails } from "../src/state.ts";
|
|
15
|
-
import { buildTodoContext, restoreTodoPhases } from "../src/todo-bridge.ts";
|
|
15
|
+
import { buildTodoContext, parseTodoPhases, restoreTodoPhases, todoProgress } from "../src/todo-bridge.ts";
|
|
16
16
|
import {
|
|
17
17
|
buildGoalToolResponse,
|
|
18
18
|
createGoalTool,
|
|
@@ -525,4 +525,34 @@ describe("todo bridge", () => {
|
|
|
525
525
|
expect(context).toContain("P<1>");
|
|
526
526
|
expect(context).toContain("a & b");
|
|
527
527
|
});
|
|
528
|
+
|
|
529
|
+
it("counts footer progress over non-empty phases only (completed + abandoned closed)", () => {
|
|
530
|
+
expect(
|
|
531
|
+
todoProgress([
|
|
532
|
+
{
|
|
533
|
+
name: "P1",
|
|
534
|
+
tasks: [
|
|
535
|
+
{ content: "a", status: "completed" },
|
|
536
|
+
{ content: "b", status: "abandoned" },
|
|
537
|
+
{ content: "c", status: "in_progress" },
|
|
538
|
+
],
|
|
539
|
+
},
|
|
540
|
+
{ name: "P2", tasks: [{ content: "d", status: "pending" }] },
|
|
541
|
+
{ name: "empty", tasks: [] },
|
|
542
|
+
]),
|
|
543
|
+
).toEqual({ closed: 2, total: 4 });
|
|
544
|
+
expect(todoProgress([{ name: "empty", tasks: [] }])).toBeUndefined();
|
|
545
|
+
expect(todoProgress([])).toBeUndefined();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("validates todo_updated event payloads (parseTodoPhases)", () => {
|
|
549
|
+
const phases = [{ name: "P1", tasks: [{ content: "a", status: "pending" }] }];
|
|
550
|
+
expect(parseTodoPhases(phases)).toEqual(phases);
|
|
551
|
+
// defensive clone, not the caller's objects
|
|
552
|
+
expect(parseTodoPhases(phases)).not.toBe(phases);
|
|
553
|
+
expect(parseTodoPhases(undefined)).toBeUndefined();
|
|
554
|
+
expect(parseTodoPhases("nope")).toBeUndefined();
|
|
555
|
+
expect(parseTodoPhases([{ name: "P1", tasks: [{ content: "a", status: "bogus" }] }])).toBeUndefined();
|
|
556
|
+
expect(parseTodoPhases([{ tasks: [] }])).toBeUndefined();
|
|
557
|
+
});
|
|
528
558
|
});
|