@fyeeme/pi-goal 1.0.0 → 1.0.2

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.0.2] - 2026-09-16
6
+
7
+ ### Changed
8
+
9
+ - Prompt copy in the injected `<todo_context>` block: stale-item guidance now reads "stale, already finished, or superseded" (was "no longer active pointer") and ends with "Statuses only change through explicit todo ops — call `start` on the task you are working on" instead of the stale-in-progress phrasing.
10
+
11
+ ## [1.0.1] - 2026-09-11
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+ - 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.
17
+
5
18
  ## [1.0.0] - 2026-09-09
6
19
 
7
20
  ### Added
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 (works without pi-todo installed)
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 { buildTodoContext, restoreTodoPhases, type TodoPhase } from "./src/todo-bridge.ts";
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
- /** omp status-line/segments.ts renderGoalMode: segment visible only while
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
- ctx.ui.setStatus("goal", `${goalStatusIcon(goal.status)} Goal ${used}${budget}`);
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.0",
3
+ "version": "1.0.2",
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",
@@ -1,6 +1,6 @@
1
1
  <todo_context>
2
2
  Persisted todos: live progress state for current goal, not old transcript decoration; goal continuations lack visible user nudge → treat as live state.
3
- Before substantial work: compare next action with todos. If item stale, already finished, or no longer active pointer, call `todo` first: mark done or rewrite list. Do not leave stale in_progress while working on later phases.
3
+ Before substantial work: compare next action with todos. If an item is stale, already finished, or superseded, call `todo` first: mark done or rewrite the list. Statuses only change through explicit todo ops — call `start` on the task you are working on.
4
4
 
5
5
  Overall: {{closed}}/{{total}} done, {{open}} open.
6
6
  {{#each phases}}
@@ -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
- let total = 0;
114
- let closed = 0;
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, {
@@ -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&lt;1&gt;");
526
526
  expect(context).toContain("a &amp; 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
  });