@xynogen/pix-todo 0.1.0 → 0.1.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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # pix-todo
2
+
3
+ Pi tool — durable execution checklist (`todo`).
4
+
5
+ ## What it does
6
+
7
+ Registers the `todo` tool, which gives the agent a persistent task checklist that survives context compaction and session restore. The checklist is seeded by the model via the `set` action and tracks items through four statuses: `pending` (○), `in_progress` (◐), `done` (●), and `blocked` (⊘). State is persisted via Pi's `appendEntry("todo-state")` so the agent can recover its position after long runs or compaction events. The agent calls `todo(action:"list")` to resume where it left off. Actions: `list`, `set`, `add`, `update`, `clear`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@xynogen/pix-todo
13
+ ```
14
+
15
+ ## Full distro
16
+
17
+ To install the complete pix suite (all packages + Pi itself):
18
+
19
+ ```bash
20
+ curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
21
+ ```
22
+
23
+ ## License
24
+
25
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/once.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Idempotency guard for extension activation.
3
+ *
4
+ * pix-core (the meta-package) invokes this package's factory in addition to a
5
+ * possible direct install. Pi's loader uses jiti with `moduleCache: false`, so
6
+ * each load pass re-evaluates modules — a plain module-level flag would not be
7
+ * shared. The dedupe key therefore lives on `globalThis`, which persists for
8
+ * the lifetime of the process across all load passes.
9
+ */
10
+ export function once(key: string, fn: () => void): void {
11
+ const g = globalThis as { __pixLoaded?: Set<string> };
12
+ const loaded = (g.__pixLoaded ??= new Set<string>());
13
+ if (loaded.has(key)) return;
14
+ loaded.add(key);
15
+ fn();
16
+ }
package/src/todo.test.ts CHANGED
@@ -1,6 +1,14 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
2
  import registerTodo, { renderTodoLines, type TodoItem } from "./todo.ts";
3
3
 
4
+ // registerTodo wraps its body in once("pix-todo") — a process-wide globalThis
5
+ // guard that dedupes activation across pix-core + a standalone install. Tests
6
+ // re-register a fresh host per case, so clear the registry first to let each
7
+ // registerTodo run (mirrors pix-core/once.test.ts clearing __pixLoaded).
8
+ beforeEach(() => {
9
+ delete (globalThis as { __pixLoaded?: Set<string> }).__pixLoaded;
10
+ });
11
+
4
12
  // Stub theme tags each fragment with its color/bold so assertions can verify
5
13
  // which status got which tint, without depending on real ANSI codes.
6
14
  const tagTheme = {
package/src/todo.ts CHANGED
@@ -13,6 +13,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
  import { Type } from "typebox";
15
15
 
16
+ import { once } from "./once.ts";
17
+
16
18
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
17
19
 
18
20
  export interface TodoItem {
@@ -67,152 +69,154 @@ const parseItems = (raw: string): string[] =>
67
69
  .filter(Boolean);
68
70
 
69
71
  export default function registerTodo(pi: ExtensionAPI): void {
70
- let todos: TodoItem[] = [];
71
- let nextTodoId = 1;
72
-
73
- function persistTodos() {
74
- pi.appendEntry("todo-state", { todos, nextTodoId });
75
- }
76
-
77
- function todoSummary(): string {
78
- if (!todos.length) return "(no todos)";
79
- const done = todos.filter((t) => t.status === "done").length;
80
- const lines = todos.map(
81
- (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
82
- );
83
- return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
84
- }
85
-
86
- // Durable execution checklist for BUILD mode. Survives context compaction
87
- // and session restore. Workflows like plan instruct the model to seed it
88
- // from a plan's "Implementation Phases" so it stays anchored to plan.md.
89
- pi.registerTool({
90
- name: "todo",
91
- label: "Todo",
92
- description:
93
- "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
94
- promptSnippet:
95
- "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
96
- promptGuidelines: [
97
- "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
98
- "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
99
- "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
100
- ],
101
- parameters: Type.Object({
102
- action: Type.Union(
103
- [
104
- Type.Literal("list"),
105
- Type.Literal("set"),
106
- Type.Literal("add"),
107
- Type.Literal("update"),
108
- Type.Literal("clear"),
109
- ],
110
- { description: "Operation to perform" },
111
- ),
112
- items: Type.Optional(
113
- Type.String({
114
- description:
115
- "For set/add: newline-separated or numbered list of todo texts.",
116
- }),
117
- ),
118
- id: Type.Optional(
119
- Type.Number({ description: "For update: target todo id." }),
120
- ),
121
- status: Type.Optional(
122
- Type.Union(
72
+ once("pix-todo", () => {
73
+ let todos: TodoItem[] = [];
74
+ let nextTodoId = 1;
75
+
76
+ function persistTodos() {
77
+ pi.appendEntry("todo-state", { todos, nextTodoId });
78
+ }
79
+
80
+ function todoSummary(): string {
81
+ if (!todos.length) return "(no todos)";
82
+ const done = todos.filter((t) => t.status === "done").length;
83
+ const lines = todos.map(
84
+ (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
85
+ );
86
+ return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
87
+ }
88
+
89
+ // Durable execution checklist for BUILD mode. Survives context compaction
90
+ // and session restore. Workflows like plan instruct the model to seed it
91
+ // from a plan's "Implementation Phases" so it stays anchored to plan.md.
92
+ pi.registerTool({
93
+ name: "todo",
94
+ label: "Todo",
95
+ description:
96
+ "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
97
+ promptSnippet:
98
+ "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
99
+ promptGuidelines: [
100
+ "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
101
+ "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
102
+ "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
103
+ ],
104
+ parameters: Type.Object({
105
+ action: Type.Union(
123
106
  [
124
- Type.Literal("pending"),
125
- Type.Literal("in_progress"),
126
- Type.Literal("done"),
127
- Type.Literal("blocked"),
107
+ Type.Literal("list"),
108
+ Type.Literal("set"),
109
+ Type.Literal("add"),
110
+ Type.Literal("update"),
111
+ Type.Literal("clear"),
128
112
  ],
129
- { description: "For update: new status." },
113
+ { description: "Operation to perform" },
130
114
  ),
131
- ),
132
- text: Type.Optional(
133
- Type.String({
134
- description: "For update: replacement text (optional).",
135
- }),
136
- ),
137
- }),
138
- renderResult(_result, _options, theme) {
139
- return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
140
- },
141
-
142
- async execute(_id, params) {
143
- // AgentToolResult now requires a `details` field. These todo results have
144
- // no structured details, so emit `undefined` via small local helpers.
145
- const ok = (text: string) => ({
146
- content: [{ type: "text" as const, text }],
147
- details: undefined,
148
- });
149
- const fail = (text: string) => ({
150
- content: [{ type: "text" as const, text }],
151
- details: undefined,
152
- isError: true,
153
- });
154
- switch (params.action) {
155
- case "list":
156
- return ok(todoSummary());
157
-
158
- case "set": {
159
- const texts = parseItems(params.items ?? "");
160
- if (!texts.length) return fail("set requires non-empty `items`.");
161
- nextTodoId = 1;
162
- todos = texts.map((text) => ({
163
- id: nextTodoId++,
164
- text,
165
- status: "pending" as TodoStatus,
166
- }));
167
- persistTodos();
168
- return ok(todoSummary());
169
- }
170
-
171
- case "add": {
172
- const texts = parseItems(params.items ?? "");
173
- if (!texts.length) return fail("add requires non-empty `items`.");
174
- for (const text of texts)
175
- todos.push({ id: nextTodoId++, text, status: "pending" });
176
- persistTodos();
177
- return ok(todoSummary());
178
- }
179
-
180
- case "update": {
181
- const t = todos.find((x) => x.id === params.id);
182
- if (!t) return fail(`No todo with id ${params.id}.`);
183
- if (params.status) t.status = params.status;
184
- if (params.text) t.text = params.text;
185
- persistTodos();
186
- return ok(todoSummary());
115
+ items: Type.Optional(
116
+ Type.String({
117
+ description:
118
+ "For set/add: newline-separated or numbered list of todo texts.",
119
+ }),
120
+ ),
121
+ id: Type.Optional(
122
+ Type.Number({ description: "For update: target todo id." }),
123
+ ),
124
+ status: Type.Optional(
125
+ Type.Union(
126
+ [
127
+ Type.Literal("pending"),
128
+ Type.Literal("in_progress"),
129
+ Type.Literal("done"),
130
+ Type.Literal("blocked"),
131
+ ],
132
+ { description: "For update: new status." },
133
+ ),
134
+ ),
135
+ text: Type.Optional(
136
+ Type.String({
137
+ description: "For update: replacement text (optional).",
138
+ }),
139
+ ),
140
+ }),
141
+ renderResult(_result, _options, theme) {
142
+ return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
143
+ },
144
+
145
+ async execute(_id, params) {
146
+ // AgentToolResult now requires a `details` field. These todo results have
147
+ // no structured details, so emit `undefined` via small local helpers.
148
+ const ok = (text: string) => ({
149
+ content: [{ type: "text" as const, text }],
150
+ details: undefined,
151
+ });
152
+ const fail = (text: string) => ({
153
+ content: [{ type: "text" as const, text }],
154
+ details: undefined,
155
+ isError: true,
156
+ });
157
+ switch (params.action) {
158
+ case "list":
159
+ return ok(todoSummary());
160
+
161
+ case "set": {
162
+ const texts = parseItems(params.items ?? "");
163
+ if (!texts.length) return fail("set requires non-empty `items`.");
164
+ nextTodoId = 1;
165
+ todos = texts.map((text) => ({
166
+ id: nextTodoId++,
167
+ text,
168
+ status: "pending" as TodoStatus,
169
+ }));
170
+ persistTodos();
171
+ return ok(todoSummary());
172
+ }
173
+
174
+ case "add": {
175
+ const texts = parseItems(params.items ?? "");
176
+ if (!texts.length) return fail("add requires non-empty `items`.");
177
+ for (const text of texts)
178
+ todos.push({ id: nextTodoId++, text, status: "pending" });
179
+ persistTodos();
180
+ return ok(todoSummary());
181
+ }
182
+
183
+ case "update": {
184
+ const t = todos.find((x) => x.id === params.id);
185
+ if (!t) return fail(`No todo with id ${params.id}.`);
186
+ if (params.status) t.status = params.status;
187
+ if (params.text) t.text = params.text;
188
+ persistTodos();
189
+ return ok(todoSummary());
190
+ }
191
+
192
+ case "clear":
193
+ todos = [];
194
+ nextTodoId = 1;
195
+ persistTodos();
196
+ return ok("Todos cleared.");
197
+
198
+ default:
199
+ return fail(`Unknown action: ${String(params.action)}`);
187
200
  }
188
-
189
- case "clear":
190
- todos = [];
191
- nextTodoId = 1;
192
- persistTodos();
193
- return ok("Todos cleared.");
194
-
195
- default:
196
- return fail(`Unknown action: ${String(params.action)}`);
201
+ },
202
+ });
203
+
204
+ // Restore the checklist from session entries so it survives restart.
205
+ pi.on("session_start", async (_event, ctx) => {
206
+ const entries = ctx.sessionManager.getEntries() as Array<{
207
+ type: string;
208
+ customType?: string;
209
+ data?: { todos?: TodoItem[]; nextTodoId?: number };
210
+ }>;
211
+ const lastTodo = entries
212
+ .filter((e) => e.type === "custom" && e.customType === "todo-state")
213
+ .pop();
214
+ if (Array.isArray(lastTodo?.data?.todos)) {
215
+ todos = lastTodo.data.todos;
216
+ nextTodoId =
217
+ lastTodo.data.nextTodoId ??
218
+ todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
197
219
  }
198
- },
199
- });
200
-
201
- // Restore the checklist from session entries so it survives restart.
202
- pi.on("session_start", async (_event, ctx) => {
203
- const entries = ctx.sessionManager.getEntries() as Array<{
204
- type: string;
205
- customType?: string;
206
- data?: { todos?: TodoItem[]; nextTodoId?: number };
207
- }>;
208
- const lastTodo = entries
209
- .filter((e) => e.type === "custom" && e.customType === "todo-state")
210
- .pop();
211
- if (Array.isArray(lastTodo?.data?.todos)) {
212
- todos = lastTodo.data.todos;
213
- nextTodoId =
214
- lastTodo.data.nextTodoId ??
215
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
216
- }
220
+ });
217
221
  });
218
222
  }