@fyeeme/pi-todo 1.0.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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * pi-todo — /todo command.
3
+ *
4
+ * Ported from oh-my-pi `todo-command-controller.ts` (+ its ACP helper in
5
+ * slash-commands/helpers/todo.ts): view, edit, copy, export, import, append,
6
+ * start, done, drop, rm, help — with quote-aware tokenizing, fuzzy
7
+ * task/phase matching, and the developer system-reminder injection that tells
8
+ * the agent the user manually modified the list.
9
+ *
10
+ * Host-internal surfaces map to pi:
11
+ * $EDITOR round-trip → ctx.ui.editor (prefilled Markdown)
12
+ * clipboard copy → printed (extension API has no clipboard write;
13
+ * mirrors omp's own ACP fallback text)
14
+ * developer message → pi.sendMessage display:false (custom role)
15
+ * user_todo_edit entry → "todo-phases" snapshot (pi-todo's documented
16
+ * entry contract, read back by restore and
17
+ * pi-goal's todo bridge)
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from "node:fs";
21
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ type TodoItem,
24
+ applyOpsToPhases,
25
+ type TodoPhase,
26
+ markdownToPhases,
27
+ phasesToMarkdown,
28
+ resolveTodoMarkdownPath,
29
+ } from "./state.ts";
30
+
31
+ export interface TodoCommandDeps {
32
+ getPhases(): TodoPhase[];
33
+ setPhases(phases: TodoPhase[]): void;
34
+ persist(phases: TodoPhase[]): void;
35
+ broadcast(phases: TodoPhase[]): void;
36
+ /** Latest event context so the reminder can reach the agent mid-session. */
37
+ sendHiddenMessage?(content: string): void;
38
+ }
39
+
40
+ const USAGE = [
41
+ "Usage: /todo <verb> [args]",
42
+ " /todo Show current todos",
43
+ " /todo edit Open todos in the editor (Markdown round-trip)",
44
+ " /todo copy Print todos as Markdown",
45
+ " /todo export [<path>] Write todos to file (default: TODO.md)",
46
+ " /todo import [<path>] Replace todos from file (default: TODO.md)",
47
+ " /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
48
+ " /todo start <task> Mark task in_progress (fuzzy content match)",
49
+ " /todo done [<task|phase>] Mark task/phase/all completed",
50
+ " /todo drop [<task|phase>] Mark task/phase/all abandoned",
51
+ " /todo rm [<task|phase>] Remove task/phase/all",
52
+ ].join("\n");
53
+
54
+ // =============================================================================
55
+ // Argument tokenizer (respects double-quoted strings) — omp tokenize
56
+ // =============================================================================
57
+
58
+ function tokenize(input: string): string[] {
59
+ const tokens: string[] = [];
60
+ let cur = "";
61
+ let inQuote = false;
62
+ for (let i = 0; i < input.length; i++) {
63
+ const ch = input[i];
64
+ if (ch === "\\" && i + 1 < input.length) {
65
+ cur += input[++i];
66
+ continue;
67
+ }
68
+ if (ch === '"') {
69
+ inQuote = !inQuote;
70
+ continue;
71
+ }
72
+ if (!inQuote && /\s/.test(ch)) {
73
+ if (cur) {
74
+ tokens.push(cur);
75
+ cur = "";
76
+ }
77
+ continue;
78
+ }
79
+ cur += ch;
80
+ }
81
+ if (cur) tokens.push(cur);
82
+ return tokens;
83
+ }
84
+
85
+ // =============================================================================
86
+ // Name normalization + fuzzy matching — omp #append / findPhaseFuzzy / findTaskFuzzy
87
+ // =============================================================================
88
+
89
+ function titleCase(s: string): string {
90
+ return s
91
+ .split(/\s+/)
92
+ .filter(Boolean)
93
+ .map(word => word[0].toUpperCase() + word.slice(1))
94
+ .join(" ");
95
+ }
96
+
97
+ /** Capitalize first letter only — keeps acronyms / casing in the rest intact. */
98
+ function titleCaseSentence(s: string): string {
99
+ const trimmed = s.trim();
100
+ if (!trimmed) return trimmed;
101
+ return trimmed[0].toUpperCase() + trimmed.slice(1);
102
+ }
103
+
104
+ interface TodoTaskMatch {
105
+ task: TodoItem;
106
+ phase: TodoPhase;
107
+ }
108
+
109
+ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
110
+ const q = query.trim().toLowerCase();
111
+ if (!q) return undefined;
112
+ // Exact name (case-insensitive)
113
+ const byName = phases.find(p => p.name.toLowerCase() === q);
114
+ if (byName) return byName;
115
+ // Substring (prefer prefix match)
116
+ const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q));
117
+ if (prefixMatches.length === 1) return prefixMatches[0];
118
+ const subMatches = phases.filter(p => p.name.toLowerCase().includes(q));
119
+ if (subMatches.length === 1) return subMatches[0];
120
+ return undefined;
121
+ }
122
+
123
+ function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | undefined {
124
+ const q = query.trim().toLowerCase();
125
+ if (!q) return undefined;
126
+ // Exact content (case-insensitive)
127
+ for (const phase of phases) {
128
+ for (const task of phase.tasks) {
129
+ if (task.content.toLowerCase() === q) return { task, phase };
130
+ }
131
+ }
132
+ const matches: TodoTaskMatch[] = [];
133
+ for (const phase of phases) {
134
+ for (const task of phase.tasks) {
135
+ if (task.content.toLowerCase().includes(q)) {
136
+ matches.push({ task, phase });
137
+ }
138
+ }
139
+ }
140
+ if (matches.length === 1) return matches[0];
141
+ // Prefer single in_progress/pending hit when ambiguous
142
+ const active = matches.filter(m => m.task.status === "in_progress" || m.task.status === "pending");
143
+ if (active.length === 1) return active[0];
144
+ return undefined;
145
+ }
146
+
147
+ // =============================================================================
148
+ // System reminder (omp buildSystemReminder) — tells the agent the list changed
149
+ // =============================================================================
150
+
151
+ function buildSystemReminder(action: string, phases: TodoPhase[], removed = false): string {
152
+ const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd();
153
+ const lines = ["<system-reminder>", `The user manually modified the todo list (${action}).`];
154
+ if (removed) {
155
+ lines.push(
156
+ phases.length === 0
157
+ ? "The user intentionally cleared the todo list. Do NOT recreate or re-populate it unless the user explicitly asks; continue the current request without a todo list."
158
+ : "The user intentionally removed the entries no longer shown below. Do NOT re-add them unless the user explicitly asks.",
159
+ );
160
+ }
161
+ lines.push("Current todo list:", "", md, "</system-reminder>");
162
+ return lines.join("\n");
163
+ }
164
+
165
+ // =============================================================================
166
+ // Command handler
167
+ // =============================================================================
168
+
169
+ export function createTodoCommand(deps: TodoCommandDeps): (args: string, ctx: ExtensionCommandContext) => Promise<void> {
170
+ return async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
171
+ const trimmed = args.trim();
172
+ if (!trimmed) {
173
+ showCurrent(deps, ctx);
174
+ return;
175
+ }
176
+
177
+ const spaceIdx = trimmed.search(/\s/);
178
+ const verb = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toLowerCase();
179
+ const rest = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
180
+
181
+ switch (verb) {
182
+ case "edit":
183
+ await editInEditor(deps, ctx);
184
+ return;
185
+ case "copy":
186
+ copyMarkdown(deps, ctx);
187
+ return;
188
+ case "export":
189
+ await exportToFile(deps, ctx, rest);
190
+ return;
191
+ case "import":
192
+ await importFromFile(deps, ctx, rest);
193
+ return;
194
+ case "help":
195
+ case "?":
196
+ emit(ctx, USAGE);
197
+ return;
198
+ case "append":
199
+ append(deps, ctx, rest);
200
+ return;
201
+ case "start":
202
+ start(deps, ctx, rest);
203
+ return;
204
+ case "done":
205
+ mutateStatus(deps, ctx, rest, "completed");
206
+ return;
207
+ case "drop":
208
+ mutateStatus(deps, ctx, rest, "abandoned");
209
+ return;
210
+ case "rm":
211
+ remove(deps, ctx, rest);
212
+ return;
213
+ default:
214
+ emit(ctx, `Unknown /todo verb "${verb}".\n${USAGE}`, "error");
215
+ }
216
+ };
217
+ }
218
+
219
+ /** Headless-safe output: notify in dialog-capable UIs, stderr text elsewhere. */
220
+ function emit(ctx: ExtensionCommandContext, text: string, severity: "info" | "warning" | "error" = "info"): void {
221
+ if (ctx.hasUI) {
222
+ ctx.ui.notify(text, severity);
223
+ return;
224
+ }
225
+ // stderr: stdout carries the protocol in json/rpc/print modes.
226
+ console.error(text);
227
+ }
228
+
229
+ function commit(deps: TodoCommandDeps, nextPhases: TodoPhase[], action: string, opts?: { removed?: boolean }): void {
230
+ deps.setPhases(nextPhases);
231
+ deps.persist(nextPhases);
232
+ deps.broadcast(nextPhases);
233
+ // omp #commit step 3: inject the system reminder so the agent learns about
234
+ // the change next turn. Removals carry explicit intent (omp issue #5258).
235
+ deps.sendHiddenMessage?.(buildSystemReminder(action, nextPhases, opts?.removed ?? false));
236
+ }
237
+
238
+ function showCurrent(deps: TodoCommandDeps, ctx: ExtensionCommandContext): void {
239
+ const phases = deps.getPhases();
240
+ if (phases.length === 0) {
241
+ emit(ctx, "No todos. Use /todo append <task> to start one.");
242
+ return;
243
+ }
244
+ emit(ctx, phasesToMarkdown(phases).trimEnd());
245
+ }
246
+
247
+ function copyMarkdown(deps: TodoCommandDeps, ctx: ExtensionCommandContext): void {
248
+ const phases = deps.getPhases();
249
+ // omp ACP fallback (no clipboard in the extension API either):
250
+ // "Copy not available in ACP mode; printing instead".
251
+ const markdown = phases.length === 0 ? "" : phasesToMarkdown(phases).trimEnd();
252
+ emit(ctx, `Copy not available without a clipboard integration; printing instead:\n\n${markdown || "No todos."}`);
253
+ }
254
+
255
+ async function exportToFile(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string): Promise<void> {
256
+ const phases = deps.getPhases();
257
+ if (phases.length === 0) {
258
+ emit(ctx, "No todos to export.", "warning");
259
+ return;
260
+ }
261
+ try {
262
+ const target = resolveTodoMarkdownPath(rest, ctx.cwd);
263
+ writeFileSync(target, phasesToMarkdown(phases), "utf8");
264
+ emit(ctx, `Wrote todos to ${target}`);
265
+ } catch (error) {
266
+ emit(ctx, `Failed to write todos: ${error instanceof Error ? error.message : String(error)}`, "error");
267
+ }
268
+ }
269
+
270
+ async function importFromFile(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string): Promise<void> {
271
+ let source = "";
272
+ let content: string;
273
+ try {
274
+ source = resolveTodoMarkdownPath(rest, ctx.cwd);
275
+ content = readFileSync(source, "utf8");
276
+ } catch (error) {
277
+ emit(ctx, `Failed to read todos: ${error instanceof Error ? error.message : String(error)}`, "error");
278
+ return;
279
+ }
280
+ const { phases, errors } = markdownToPhases(content);
281
+ if (errors.length > 0) {
282
+ emit(ctx, `Could not parse ${source}:\n ${errors.join("\n ")}`, "error");
283
+ return;
284
+ }
285
+ commit(deps, phases, `/todo import ${source}`);
286
+ const taskCount = phases.reduce((sum, p) => sum + p.tasks.length, 0);
287
+ emit(ctx, `Imported ${phases.length} phase(s), ${taskCount} task(s) from ${source}.`);
288
+ }
289
+
290
+ function append(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string): void {
291
+ const tokens = tokenize(rest);
292
+ if (tokens.length === 0) {
293
+ emit(ctx, "Usage: /todo append [<phase>] <task...>", "error");
294
+ return;
295
+ }
296
+
297
+ const current = deps.getPhases();
298
+ let phaseName: string | undefined;
299
+ let content: string;
300
+
301
+ if (tokens.length === 1) {
302
+ content = tokens[0];
303
+ } else {
304
+ phaseName = tokens[0];
305
+ content = tokens.slice(1).join(" ");
306
+ }
307
+
308
+ const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() }));
309
+ let targetPhase: TodoPhase;
310
+
311
+ if (phaseName) {
312
+ const existing = findPhaseFuzzy(next, phaseName);
313
+ targetPhase = existing ?? { name: titleCase(phaseName), tasks: [] };
314
+ if (!existing) next.push(targetPhase);
315
+ } else if (next.length > 0) {
316
+ targetPhase = next[next.length - 1];
317
+ } else {
318
+ targetPhase = { name: "Todos", tasks: [] };
319
+ next.push(targetPhase);
320
+ }
321
+
322
+ const finalContent = titleCaseSentence(content);
323
+ targetPhase.tasks.push({ content: finalContent, status: "pending" });
324
+
325
+ commit(deps, next, `/todo append → ${targetPhase.name}`);
326
+ emit(ctx, `Appended to ${targetPhase.name}: ${finalContent}`);
327
+ }
328
+
329
+ function start(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string): void {
330
+ if (!rest) {
331
+ emit(ctx, "Usage: /todo start <task>", "error");
332
+ return;
333
+ }
334
+ const current = deps.getPhases();
335
+ const hit = findTaskFuzzy(current, rest);
336
+ if (!hit) {
337
+ emit(ctx, `No task matched "${rest}". Use /todo to list current tasks.`, "error");
338
+ return;
339
+ }
340
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
341
+ if (errors.length > 0) {
342
+ emit(ctx, errors.join("; "), "error");
343
+ return;
344
+ }
345
+ commit(deps, phases, `/todo start ${hit.task.content}`);
346
+ emit(ctx, `Started: ${hit.task.content}`);
347
+ }
348
+
349
+ function mutateStatus(
350
+ deps: TodoCommandDeps,
351
+ ctx: ExtensionCommandContext,
352
+ rest: string,
353
+ target: "completed" | "abandoned",
354
+ ): void {
355
+ const op = target === "completed" ? "done" : "drop";
356
+ const current = deps.getPhases();
357
+ const trimmedArg = rest.trim();
358
+ if (!trimmedArg) {
359
+ // no-arg: apply to all
360
+ const { phases, errors } = applyOpsToPhases(current, [{ op }]);
361
+ if (errors.length > 0) {
362
+ emit(ctx, errors.join("; "), "error");
363
+ return;
364
+ }
365
+ commit(deps, phases, `/todo ${op} (all)`);
366
+ emit(ctx, `Marked all tasks ${target}.`);
367
+ return;
368
+ }
369
+
370
+ const taskHit = findTaskFuzzy(current, trimmedArg);
371
+ if (taskHit) {
372
+ const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]);
373
+ if (errors.length > 0) {
374
+ emit(ctx, errors.join("; "), "error");
375
+ return;
376
+ }
377
+ commit(deps, phases, `/todo ${op} ${taskHit.task.content}`);
378
+ emit(ctx, `Marked ${target}: ${taskHit.task.content}`);
379
+ return;
380
+ }
381
+
382
+ const phaseHit = findPhaseFuzzy(current, trimmedArg);
383
+ if (phaseHit) {
384
+ const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]);
385
+ if (errors.length > 0) {
386
+ emit(ctx, errors.join("; "), "error");
387
+ return;
388
+ }
389
+ commit(deps, phases, `/todo ${op} ${phaseHit.name}`);
390
+ emit(ctx, `Marked phase ${phaseHit.name} ${target}.`);
391
+ return;
392
+ }
393
+
394
+ emit(ctx, `No task or phase matched "${trimmedArg}".`, "error");
395
+ }
396
+
397
+ function remove(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string): void {
398
+ const current = deps.getPhases();
399
+ const trimmedArg = rest.trim();
400
+ if (!trimmedArg) {
401
+ commit(deps, [], "/todo rm (all)", { removed: true });
402
+ emit(ctx, "Cleared all todos.");
403
+ return;
404
+ }
405
+ const taskHit = findTaskFuzzy(current, trimmedArg);
406
+ if (taskHit) {
407
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]);
408
+ if (errors.length > 0) {
409
+ emit(ctx, errors.join("; "), "error");
410
+ return;
411
+ }
412
+ commit(deps, phases, `/todo rm ${taskHit.task.content}`, { removed: true });
413
+ emit(ctx, `Removed: ${taskHit.task.content}`);
414
+ return;
415
+ }
416
+ const phaseHit = findPhaseFuzzy(current, trimmedArg);
417
+ if (phaseHit) {
418
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]);
419
+ if (errors.length > 0) {
420
+ emit(ctx, errors.join("; "), "error");
421
+ return;
422
+ }
423
+ commit(deps, phases, `/todo rm ${phaseHit.name}`, { removed: true });
424
+ emit(ctx, `Removed phase: ${phaseHit.name}`);
425
+ return;
426
+ }
427
+ emit(ctx, `No task or phase matched "${trimmedArg}".`, "error");
428
+ }
429
+
430
+ /** omp #editInExternalEditor with pi's dialog editor as the $EDITOR surface. */
431
+ async function editInEditor(deps: TodoCommandDeps, ctx: ExtensionCommandContext): Promise<void> {
432
+ if (!ctx.hasUI) {
433
+ emit(
434
+ ctx,
435
+ "/todo edit requires the TUI editor; use /todo export then /todo import for non-interactive edits.",
436
+ "warning",
437
+ );
438
+ return;
439
+ }
440
+ const current = deps.getPhases();
441
+ const initialMarkdown =
442
+ current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
443
+
444
+ const result = await ctx.ui.editor("Todo (Markdown round-trip)", initialMarkdown);
445
+ if (result === null || result === undefined) {
446
+ emit(ctx, "Editor exited without saving; todos unchanged.", "warning");
447
+ return;
448
+ }
449
+ const { phases: parsed, errors } = markdownToPhases(result);
450
+ if (errors.length > 0) {
451
+ emit(ctx, `Could not parse Markdown:\n ${errors.join("\n ")}`, "error");
452
+ return;
453
+ }
454
+ commit(deps, parsed, "/todo edit");
455
+ const taskCount = parsed.reduce((sum, p) => sum + p.tasks.length, 0);
456
+ emit(ctx, `Todos updated from editor: ${parsed.length} phase(s), ${taskCount} task(s).`);
457
+ }
@@ -0,0 +1,44 @@
1
+ **Tasks: verbatim content strings, NEVER auto-generated IDs; no "task-1"/"task-N". Pass content in `task`.**
2
+
3
+ Each completion: earliest still-open task (phase order) auto-promotes to `in_progress`. Out-of-order completion may move pointer back to an earlier phase—expected; completed tasks NEVER revert.
4
+
5
+ ## Operations
6
+
7
+ |`op`|Fields|Effect|
8
+ |---|---|---|
9
+ |`init`|`list: [{phase, items: string[]}]`|Initialize full list; replaces existing|
10
+ |`init`|`items: string[]`|Flattened single-phase init|
11
+ |`start`|`task`|Mark in progress|
12
+ |`done`|`task` or `phase`|Mark completed|
13
+ |`drop`|`task` or `phase`|Mark abandoned|
14
+ |`block`|`task` or `phase`; optional `reason`|Mark blocked: open, awaiting external input; excluded from stop-time incomplete-todo reminder|
15
+ |`unblock`|`task` or `phase`|Blocked task → `pending`|
16
+ |`rm`|optional `task` or `phase`|Remove task/phase; omit both → clear|
17
+ |`append`|`phase`; `items: string[]`|Append tasks to phase; lazily creates phase|
18
+ |`view`|—|Read-only; echo list|
19
+
20
+ ## Anatomy
21
+
22
+ - Task content: 5–10 words; what, not how; unique identifier.
23
+ - Phase name: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`); unique identifier. NEVER prefix `1.`, `A)`, `Phase 1:`.
24
+
25
+ ## Rules
26
+
27
+ - Mark tasks done immediately after finishing; complete phases in order.
28
+ - NEVER make a todo call the turn's only tool call. Batch with real work: `init` with first reads/edits; each `done`/`start` with next action. Solo todo turns waste a round trip.
29
+ - Waiting on something you can't act on—a user decision, another agent, external service: `block` task (optional `reason`); remains tracked but avoids stop reminder. `unblock` when actionable. If blocker agent-actionable, `append` an unblocking task instead.
30
+ - Keep introduced `task`/`phase` strings stable.
31
+ - Lost exact task text: `view` echoes list; NEVER guess from memory.
32
+
33
+ ## Create a list
34
+
35
+ - Task requires 3+ distinct steps.
36
+ - User explicitly requests one.
37
+ - User provides a set of tasks.
38
+ - New instructions arrive mid-task: capture before proceeding.
39
+
40
+ <critical>
41
+ User gives multi-step plan—phased todo, numbered/bulleted checklist, or "N bugs/items/tasks":
42
+ - MUST `init` every item as its own task before working.
43
+ - Enumerate all; NEVER summarize into fewer tasks, sample "the important ones", drop items, or track the rest from memory.
44
+ </critical>