@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 +25 -0
- package/package.json +1 -1
- package/src/once.ts +16 -0
- package/src/todo.test.ts +9 -1
- package/src/todo.ts +146 -142
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
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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("
|
|
125
|
-
Type.Literal("
|
|
126
|
-
Type.Literal("
|
|
127
|
-
Type.Literal("
|
|
107
|
+
Type.Literal("list"),
|
|
108
|
+
Type.Literal("set"),
|
|
109
|
+
Type.Literal("add"),
|
|
110
|
+
Type.Literal("update"),
|
|
111
|
+
Type.Literal("clear"),
|
|
128
112
|
],
|
|
129
|
-
{ description: "
|
|
113
|
+
{ description: "Operation to perform" },
|
|
130
114
|
),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
}
|