@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.
package/src/state.ts ADDED
@@ -0,0 +1,842 @@
1
+ /**
2
+ * pi-todo — pure todo state logic.
3
+ *
4
+ * Ported from oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
5
+ * `packages/coding-agent/src/tools/todo.ts` — the data model, the nine
6
+ * operations, their validation semantics, and the summary text are carried
7
+ * over behavior-for-behavior. Dropped from the omp source (host-internal
8
+ * surfaces with no pi counterpart):
9
+ * - markdown round-trip (omp /todo edit) - prompt-line analysis machines
10
+ * - collapsed-viewport selection / HUD - subagent description matching
11
+ * - nextActionableTask (prewalk consumer) - tool examples (omp-only field)
12
+ */
13
+
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
18
+
19
+ /** Operation names accepted by the todo tool and echoed in successful result details. */
20
+ export type TodoOperation = "init" | "start" | "done" | "rm" | "drop" | "block" | "unblock" | "append" | "view";
21
+
22
+ export interface TodoItem {
23
+ content: string;
24
+ status: TodoStatus;
25
+ /** When `status === "blocked"`, an optional note on what the task is waiting for. */
26
+ blocker?: string;
27
+ }
28
+
29
+ export interface TodoPhase {
30
+ name: string;
31
+ tasks: TodoItem[];
32
+ }
33
+
34
+ export interface TodoCompletionTransition {
35
+ phase: string;
36
+ content: string;
37
+ }
38
+
39
+ export interface TodoToolDetails {
40
+ /** Operation that produced this snapshot. */
41
+ op: TodoOperation;
42
+ phases: TodoPhase[];
43
+ storage: "session" | "memory";
44
+ completedTasks?: TodoCompletionTransition[];
45
+ }
46
+
47
+ /** Operation input accepted by applyEntry (schema-validated tool params). */
48
+ export interface TodoOpEntry {
49
+ op: TodoOperation;
50
+ list?: Array<{ phase: string; items: string[] }>;
51
+ task?: string;
52
+ phase?: string;
53
+ items?: string[];
54
+ reason?: string;
55
+ }
56
+
57
+ // =============================================================================
58
+ // Guards
59
+ // =============================================================================
60
+
61
+ function isRecord(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+
65
+ /** Whether an unknown value is a persisted todo phase. */
66
+ export function isTodoPhase(value: unknown): value is TodoPhase {
67
+ if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
68
+ return value.tasks.every(
69
+ task =>
70
+ isRecord(task) &&
71
+ typeof task.content === "string" &&
72
+ (task.status === "pending" ||
73
+ task.status === "in_progress" ||
74
+ task.status === "completed" ||
75
+ task.status === "abandoned" ||
76
+ task.status === "blocked"),
77
+ );
78
+ }
79
+
80
+ /** Whether an unknown value is a persisted `{ phases }` snapshot. */
81
+ export function isTodoPhaseSnapshot(value: unknown): value is { phases: TodoPhase[] } {
82
+ return isRecord(value) && Array.isArray(value.phases) && value.phases.every(isTodoPhase);
83
+ }
84
+
85
+ // =============================================================================
86
+ // State helpers
87
+ // =============================================================================
88
+
89
+ function findTaskByContent(phases: TodoPhase[], content: string): { task: TodoItem; phase: TodoPhase } | undefined {
90
+ for (const phase of phases) {
91
+ const task = phase.tasks.find(t => t.content === content);
92
+ if (task) return { task, phase };
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefined {
98
+ return phases.find(phase => phase.name === name);
99
+ }
100
+
101
+ function cloneTask(task: TodoItem): TodoItem {
102
+ return task.blocker !== undefined
103
+ ? { content: task.content, status: task.status, blocker: task.blocker }
104
+ : { content: task.content, status: task.status };
105
+ }
106
+
107
+ export function clonePhases(phases: TodoPhase[]): TodoPhase[] {
108
+ return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
109
+ }
110
+
111
+ function todoTransitionKey(phase: string, content: string): string {
112
+ return `${phase}\u0000${content}`;
113
+ }
114
+
115
+ export function getCompletionTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoCompletionTransition[] {
116
+ const previousStatuses = new Map<string, TodoStatus>();
117
+ for (const phase of previous) {
118
+ for (const task of phase.tasks) {
119
+ previousStatuses.set(todoTransitionKey(phase.name, task.content), task.status);
120
+ }
121
+ }
122
+
123
+ const transitions: TodoCompletionTransition[] = [];
124
+ for (const phase of updated) {
125
+ for (const task of phase.tasks) {
126
+ if (task.status !== "completed") continue;
127
+ const previousStatus = previousStatuses.get(todoTransitionKey(phase.name, task.content));
128
+ if (previousStatus && previousStatus !== "completed") {
129
+ transitions.push({ phase: phase.name, content: task.content });
130
+ }
131
+ }
132
+ }
133
+ return transitions;
134
+ }
135
+
136
+ /**
137
+ * Enforce the single-`in_progress` invariant after every mutation: demote
138
+ * surplus in-progress tasks, then auto-promote the earliest pending task when
139
+ * none is in progress (the "pointer" the todo prompt documents).
140
+ */
141
+ export function normalizeInProgressTask(phases: TodoPhase[]): void {
142
+ const orderedTasks = phases.flatMap(phase => phase.tasks);
143
+ if (orderedTasks.length === 0) return;
144
+
145
+ const inProgressTasks = orderedTasks.filter(task => task.status === "in_progress");
146
+ if (inProgressTasks.length > 1) {
147
+ for (const task of inProgressTasks.slice(1)) {
148
+ task.status = "pending";
149
+ }
150
+ }
151
+
152
+ if (inProgressTasks.length > 0) return;
153
+
154
+ const firstPendingTask = orderedTasks.find(task => task.status === "pending");
155
+ if (firstPendingTask) firstPendingTask.status = "in_progress";
156
+ }
157
+
158
+ /** Whether a todo is settled: completed or deliberately abandoned. Shared so
159
+ * the collapsed viewport and the summary counters can never disagree about
160
+ * what "done" hides. */
161
+ export function isClosedTodo<T extends { status: TodoStatus }>(task: T): boolean {
162
+ return task.status === "completed" || task.status === "abandoned";
163
+ }
164
+
165
+ // =============================================================================
166
+ // Collapsed-viewport selection (omp #5873 walking viewport)
167
+ // =============================================================================
168
+
169
+ /** Minimum overlap (after normalization) required for a substring match.
170
+ * Picked at six chars to admit single-word identifiers like "review" /
171
+ * "Sonnet" without admitting tiny common substrings like "test" / "fix"
172
+ * that would collide across unrelated todos. */
173
+ const TODO_DESCRIPTION_MIN_OVERLAP = 6;
174
+
175
+ function normalizeForTodoMatch(value: string): string {
176
+ return value
177
+ .toLowerCase()
178
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
179
+ .trim();
180
+ }
181
+
182
+ /**
183
+ * Report whether `content` likely names the same work as any entry in
184
+ * `descriptions`. Used by the todo renderer to light up a pending todo when an
185
+ * in-flight subagent is doing the work for it. Matching is normalize-then-equal
186
+ * first, with a substring fallback in either direction requiring at least
187
+ * {@link TODO_DESCRIPTION_MIN_OVERLAP} chars on the contained side.
188
+ */
189
+ export function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean {
190
+ const target = normalizeForTodoMatch(content);
191
+ if (!target) return false;
192
+ for (const desc of descriptions) {
193
+ const candidate = normalizeForTodoMatch(desc);
194
+ if (!candidate) continue;
195
+ if (target === candidate) return true;
196
+ if (target.length >= TODO_DESCRIPTION_MIN_OVERLAP && candidate.includes(target)) return true;
197
+ if (candidate.length >= TODO_DESCRIPTION_MIN_OVERLAP && target.includes(candidate)) return true;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /** omp pluralize (packages/utils/src/format.ts). */
203
+ export function pluralize(label: string, count: number): string {
204
+ if (count === 1) return label;
205
+ if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
206
+ if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
207
+ return `${label}s`;
208
+ }
209
+
210
+ /** omp formatMoreItems (tools/render-utils.ts). */
211
+ export function formatMoreItems(remaining: number, itemType: string): string {
212
+ const safeRemaining = Number.isFinite(remaining) ? remaining : 0;
213
+ return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
214
+ }
215
+
216
+ /** A todo the collapsed viewport treats as current work: the literal
217
+ * `in_progress` task or a pending task a live subagent is executing. */
218
+ function isActiveTodo<T extends { status: TodoStatus }>(task: T, isMatched: (task: T) => boolean): boolean {
219
+ return task.status === "in_progress" || (task.status === "pending" && isMatched(task));
220
+ }
221
+
222
+ /** Result of {@link selectCollapsedTodos}: the rows to render plus an optional
223
+ * summary line (empty string ⇒ no summary row). */
224
+ export interface CollapsedTodoSelection<T> {
225
+ items: T[];
226
+ summary: string;
227
+ }
228
+
229
+ /** Closed rows kept directly above the open window so finishing a task is
230
+ * visible as it happens (omp COLLAPSED_CLOSED_CONTEXT). */
231
+ const COLLAPSED_CLOSED_CONTEXT = 1;
232
+
233
+ function selectWithinCap<T extends { status: TodoStatus }>(
234
+ base: T[],
235
+ isMatched: (task: T) => boolean,
236
+ cap: number,
237
+ ): CollapsedTodoSelection<T> {
238
+ if (base.length <= cap) return { items: base, summary: "" };
239
+
240
+ const active = base.filter(task => isActiveTodo(task, isMatched));
241
+ // Only when active work strictly exceeds the cap do we drop pending rows and
242
+ // count hidden *actives*. At exactly `cap` actives, fall through so the normal
243
+ // branch still surfaces any following pending work in the summary.
244
+ if (active.length > cap) {
245
+ const hiddenActive = active.length - cap;
246
+ return {
247
+ items: active.slice(0, cap),
248
+ summary: `… ${hiddenActive} more active ${pluralize("todo", hiddenActive)}`,
249
+ };
250
+ }
251
+
252
+ // Fill trailing rows with tasks following the first active one, so the
253
+ // promoted/current task leads and its successors follow in todo order.
254
+ const firstActiveIdx = active.length > 0 ? base.indexOf(active[0]) : 0;
255
+ const fill: T[] = [];
256
+ for (let i = firstActiveIdx; i < base.length && active.length + fill.length < cap; i++) {
257
+ const task = base[i];
258
+ if (isActiveTodo(task, isMatched)) continue;
259
+ fill.push(task);
260
+ }
261
+ const items = [...active, ...fill];
262
+ const hidden = base.length - items.length;
263
+ return { items, summary: hidden > 0 ? formatMoreItems(hidden, "todo") : "" };
264
+ }
265
+
266
+ /**
267
+ * Walking-viewport selection for a phase's collapsed todo preview (omp
268
+ * #5873). Applied to `tasks` in todo order: the open tasks run through
269
+ * {@link selectWithinCap}, led by the last {@link COLLAPSED_CLOSED_CONTEXT}
270
+ * closed tasks in todo order so a checked row remains visible even when callers
271
+ * complete work out of sequence. `summary` counts the open tasks that did not
272
+ * fit; the closed lead is context, not part of the budget.
273
+ */
274
+ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
275
+ tasks: T[],
276
+ isMatched: (task: T) => boolean,
277
+ cap: number,
278
+ ): CollapsedTodoSelection<T> {
279
+ const open = tasks.filter(task => !isClosedTodo(task));
280
+ // Closed tasks are never active, so a settled phase selects over itself.
281
+ if (open.length === 0) return selectWithinCap(tasks, isMatched, cap);
282
+ // `done` accepts any named task, so closed tasks are not necessarily a prefix.
283
+ const lead = tasks.filter(isClosedTodo).slice(-COLLAPSED_CLOSED_CONTEXT);
284
+ const selected = selectWithinCap(open, isMatched, cap);
285
+ return { items: [...lead, ...selected.items], summary: selected.summary };
286
+ }
287
+
288
+ // =============================================================================
289
+ // Stop-reminder guards (omp TodoTracker.isAwaitingUserAnswer)
290
+ // =============================================================================
291
+
292
+ const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
293
+ const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
294
+ const QUESTION_PROMPT_RE =
295
+ /^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
296
+ const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
297
+ const USER_RESPONSE_CUE_RE =
298
+ /^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
299
+ /**
300
+ * A trailing question mark is the universal signal that a line is a question, but
301
+ * the English word/pronoun gates above exist to filter incidental "?" out of prose
302
+ * (e.g. a TypeScript `foo?: string` tail). Non-English text has no cheap word list,
303
+ * yet any non-ASCII character in a "?"/"?"-terminated line reliably marks it as
304
+ * genuine prose — so treat it as a real user-directed question (omp #7803).
305
+ */
306
+ const NON_ASCII_TEXT_RE = /[^\x00-\x7F]/;
307
+
308
+ interface PromptLine {
309
+ text: string;
310
+ hadPromptLabel: boolean;
311
+ }
312
+
313
+ function promptLine(line: string): PromptLine {
314
+ const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
315
+ const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
316
+ return {
317
+ text: withoutPromptLabel,
318
+ hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
319
+ };
320
+ }
321
+
322
+ function isQuestionPromptLine(line: string): boolean {
323
+ const candidate = promptLine(line);
324
+ if (!/[??]\s*$/.test(candidate.text)) return false;
325
+ return (
326
+ candidate.hadPromptLabel ||
327
+ QUESTION_PROMPT_RE.test(candidate.text) ||
328
+ USER_DIRECTED_PROMPT_RE.test(candidate.text) ||
329
+ NON_ASCII_TEXT_RE.test(candidate.text)
330
+ );
331
+ }
332
+
333
+ function isResponseCueLine(line: string): boolean {
334
+ const candidate = promptLine(line)
335
+ .text.replace(/[.!?。!?]+$/, "")
336
+ .trim();
337
+ return USER_RESPONSE_CUE_RE.test(candidate);
338
+ }
339
+
340
+ /** Whether an assistant reply ends by asking the user something — omp skips
341
+ * the stop reminder in that case (the ball is in the user's court). */
342
+ export function isAwaitingUserAnswer(assistantText: string): boolean {
343
+ const text = assistantText.trim();
344
+ if (!text) return false;
345
+ const lastLine = text.split(/\r?\n/).at(-1)?.trim();
346
+ return lastLine !== undefined && (isQuestionPromptLine(lastLine) || isResponseCueLine(lastLine));
347
+ }
348
+
349
+ // =============================================================================
350
+ // Phase numbering (display-only, omp phaseRomanNumeral)
351
+ // =============================================================================
352
+
353
+ const ROMAN_PAIRS: Array<[number, string]> = [
354
+ [1000, "M"],
355
+ [900, "CM"],
356
+ [500, "D"],
357
+ [400, "CD"],
358
+ [100, "C"],
359
+ [90, "XC"],
360
+ [50, "L"],
361
+ [40, "XL"],
362
+ [10, "X"],
363
+ [9, "IX"],
364
+ [5, "V"],
365
+ [4, "IV"],
366
+ [1, "I"],
367
+ ];
368
+
369
+ /** One-based ASCII roman numeral for display (I, II, III, IV, …). */
370
+ export function phaseRomanNumeral(oneBasedIndex: number): string {
371
+ if (oneBasedIndex <= 0) return "";
372
+ let out = "";
373
+ let rem = oneBasedIndex;
374
+ for (const [value, sym] of ROMAN_PAIRS) {
375
+ while (rem >= value) {
376
+ out += sym;
377
+ rem -= value;
378
+ }
379
+ }
380
+ return out;
381
+ }
382
+
383
+ // =============================================================================
384
+ // Markdown round-trip (omp /todo edit / copy / export / import)
385
+ // =============================================================================
386
+
387
+ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
388
+ pending: " ",
389
+ in_progress: "/",
390
+ completed: "x",
391
+ abandoned: "-",
392
+ blocked: "!",
393
+ };
394
+
395
+ /** Render todo phases as a Markdown checklist suitable for editing/copying. */
396
+ export function phasesToMarkdown(phases: TodoPhase[]): string {
397
+ if (phases.length === 0) return "# Todos\n";
398
+ const out: string[] = [];
399
+ for (let i = 0; i < phases.length; i++) {
400
+ if (i > 0) out.push("");
401
+ out.push(`# ${phases[i].name}`);
402
+ for (const task of phases[i].tasks) {
403
+ // A blocked task's reason rides in a trailing HTML comment: invisible in
404
+ // rendered markdown, unambiguous to parse back (task content can't
405
+ // contain the comment delimiters), so the note survives `/todo edit` and
406
+ // export/import round-trips.
407
+ const blockerNote = task.status === "blocked" && task.blocker ? ` <!-- blocker: ${task.blocker} -->` : "";
408
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}${blockerNote}`);
409
+ }
410
+ }
411
+ return `${out.join("\n")}\n`;
412
+ }
413
+
414
+ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
415
+ " ": "pending",
416
+ "": "pending",
417
+ x: "completed",
418
+ X: "completed",
419
+ "/": "in_progress",
420
+ ">": "in_progress",
421
+ "-": "abandoned",
422
+ "~": "abandoned",
423
+ "!": "blocked",
424
+ };
425
+
426
+ /** Parse a Markdown checklist back into todo phases. */
427
+ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } {
428
+ const errors: string[] = [];
429
+ const phases: TodoPhase[] = [];
430
+ let currentPhase: TodoPhase | undefined;
431
+
432
+ const lines = md.split(/\r?\n/);
433
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
434
+ const raw = lines[lineNum];
435
+
436
+ const trimmed = raw.trim();
437
+ if (!trimmed) continue;
438
+
439
+ const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
440
+ if (headingMatch) {
441
+ currentPhase = { name: headingMatch[1].trim(), tasks: [] };
442
+ phases.push(currentPhase);
443
+ continue;
444
+ }
445
+
446
+ // Tolerate backslash-escaped brackets (`- \[x\]`): some editors and
447
+ // markdown serializers escape `[` (and `]`) when round-tripping, yet the
448
+ // line still renders as a normal `[x]` checkbox. Accept either form.
449
+ const taskMatch = /^[-*+]\s*\\?\[(.?)\\?\]\s+(.+?)\s*$/.exec(trimmed);
450
+ if (taskMatch) {
451
+ if (!currentPhase) {
452
+ currentPhase = { name: "Todos", tasks: [] };
453
+ phases.push(currentPhase);
454
+ }
455
+ const marker = taskMatch[1];
456
+ const status = MARKER_TO_STATUS[marker];
457
+ if (!status) {
458
+ errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-], [!])`);
459
+ continue;
460
+ }
461
+ // Recover a blocked task's reason from its trailing HTML comment (see
462
+ // phasesToMarkdown), then strip the comment from the visible content.
463
+ const rawContent = taskMatch[2].trim();
464
+ const blockerMatch = /^(.*?)\s*<!--\s*blocker:\s*(.*?)\s*-->$/.exec(rawContent);
465
+ if (status === "blocked" && blockerMatch) {
466
+ currentPhase.tasks.push({ content: blockerMatch[1].trim(), status, blocker: blockerMatch[2].trim() });
467
+ } else {
468
+ currentPhase.tasks.push({ content: rawContent, status });
469
+ }
470
+ continue;
471
+ }
472
+
473
+ errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
474
+ }
475
+
476
+ normalizeInProgressTask(phases);
477
+ return { phases, errors };
478
+ }
479
+
480
+ /** omp normalizePathLikeInput: trim + strip outer double quotes. */
481
+ function normalizePathLikeInput(input: string): string {
482
+ const trimmed = input.trim();
483
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
484
+ return trimmed.slice(1, -1);
485
+ }
486
+ return trimmed;
487
+ }
488
+
489
+ /** omp resolveTodoMarkdownPath: default TODO.md, ~ expansion, cwd-relative. */
490
+ export function resolveTodoMarkdownPath(input: string, cwd: string): string {
491
+ const raw = normalizePathLikeInput(input) || "TODO.md";
492
+ const expanded = raw === "~" ? os.homedir() : raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
493
+ return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
494
+ }
495
+
496
+ /**
497
+ * Actionable open work (pending / in_progress): what stop-time reminders count.
498
+ * Blocked tasks are excluded — they are parked awaiting external input, so
499
+ * nagging about them would be noise (omp todo prompt: "excluded from
500
+ * stop-time incomplete-todo reminder").
501
+ */
502
+ export function openTasks(phases: TodoPhase[]): TodoItem[] {
503
+ return phases.flatMap(phase =>
504
+ phase.tasks.filter(task => task.status === "pending" || task.status === "in_progress"),
505
+ );
506
+ }
507
+
508
+ // =============================================================================
509
+ // Operation resolution
510
+ // =============================================================================
511
+
512
+ function resolveTaskOrError(
513
+ phases: TodoPhase[],
514
+ content: string | undefined,
515
+ errors: string[],
516
+ ): { task: TodoItem; phase: TodoPhase } | undefined {
517
+ if (!content) {
518
+ errors.push("Missing task content");
519
+ return undefined;
520
+ }
521
+ const hit = findTaskByContent(phases, content);
522
+ if (!hit) {
523
+ if (/^task-\d+$/.test(content)) {
524
+ errors.push(
525
+ `Task "${content}" not found. Tasks are referenced by content, not by IDs — pass the task's full text from the previous result.`,
526
+ );
527
+ } else {
528
+ const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
529
+ const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
530
+ errors.push(`Task "${content}" not found${hint}`);
531
+ }
532
+ }
533
+ return hit;
534
+ }
535
+
536
+ function resolvePhaseOrError(phases: TodoPhase[], name: string | undefined, errors: string[]): TodoPhase | undefined {
537
+ if (!name) {
538
+ errors.push("Missing phase name");
539
+ return undefined;
540
+ }
541
+ const phase = findPhaseByName(phases, name);
542
+ if (!phase) errors.push(`Phase "${name}" not found`);
543
+ return phase;
544
+ }
545
+
546
+ function getTaskTargets(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]): TodoItem[] {
547
+ if (entry.task) {
548
+ const hit = resolveTaskOrError(phases, entry.task, errors);
549
+ return hit ? [hit.task] : [];
550
+ }
551
+ if (entry.phase) {
552
+ const phase = resolvePhaseOrError(phases, entry.phase, errors);
553
+ return phase ? [...phase.tasks] : [];
554
+ }
555
+ return phases.flatMap(phase => phase.tasks);
556
+ }
557
+
558
+ /** Phase name for `init` given a flat `items` list with no explicit `phase`. */
559
+ const DEFAULT_INIT_PHASE = "Tasks";
560
+
561
+ function initPhases(entry: TodoOpEntry, errors: string[]): TodoPhase[] {
562
+ // Models routinely flatten the single-phase init into `{op:"init", items:[...]}`
563
+ // (optionally with a bare `phase`) instead of the canonical
564
+ // `list: [{phase, items}]`. Accept that shape by synthesizing a one-phase list
565
+ // so a common, recoverable mistake isn't a hard error.
566
+ const list =
567
+ entry.list ??
568
+ (entry.items && entry.items.length > 0
569
+ ? [{ phase: entry.phase ?? DEFAULT_INIT_PHASE, items: entry.items }]
570
+ : undefined);
571
+ if (!list) {
572
+ errors.push("Missing list for init operation");
573
+ return [];
574
+ }
575
+ // Duplicate phase names / task contents would be permanently unaddressable
576
+ // (every targeting op resolves the first match), so reject them up front.
577
+ const seenPhases = new Set<string>();
578
+ const seenTasks = new Set<string>();
579
+ for (const listEntry of list) {
580
+ if (seenPhases.has(listEntry.phase)) {
581
+ errors.push(`Duplicate phase "${listEntry.phase}" in init list`);
582
+ }
583
+ seenPhases.add(listEntry.phase);
584
+ for (const content of listEntry.items) {
585
+ if (seenTasks.has(content)) {
586
+ errors.push(`Duplicate task "${content}" in init list`);
587
+ }
588
+ seenTasks.add(content);
589
+ }
590
+ }
591
+ return list.map(listEntry => ({
592
+ name: listEntry.phase,
593
+ tasks: listEntry.items.map(content => ({ content, status: "pending" as const })),
594
+ }));
595
+ }
596
+
597
+ function appendItems(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]): TodoPhase[] {
598
+ if (!entry.phase) {
599
+ errors.push("Missing phase name for append operation");
600
+ return phases;
601
+ }
602
+ if (!entry.items || entry.items.length === 0) {
603
+ errors.push("Missing items for append operation");
604
+ return phases;
605
+ }
606
+
607
+ // Validate the whole batch before mutating so a failing op reports every
608
+ // duplicate and leaves nothing half-applied.
609
+ const seen = new Set<string>();
610
+ let hasDuplicate = false;
611
+ for (const content of entry.items) {
612
+ if (seen.has(content) || findTaskByContent(phases, content)) {
613
+ errors.push(`Task "${content}" already exists`);
614
+ hasDuplicate = true;
615
+ }
616
+ seen.add(content);
617
+ }
618
+ if (hasDuplicate) return phases;
619
+
620
+ let phase = findPhaseByName(phases, entry.phase);
621
+ if (!phase) {
622
+ phase = { name: entry.phase, tasks: [] };
623
+ phases.push(phase);
624
+ }
625
+
626
+ for (const content of entry.items) {
627
+ phase.tasks.push({ content, status: "pending" });
628
+ }
629
+ return phases;
630
+ }
631
+
632
+ function removeTasks(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]): TodoPhase[] {
633
+ if (entry.task) {
634
+ const hit = resolveTaskOrError(phases, entry.task, errors);
635
+ if (!hit) return phases;
636
+ hit.phase.tasks = hit.phase.tasks.filter(candidate => candidate !== hit.task);
637
+ return phases;
638
+ }
639
+ if (entry.phase) {
640
+ const phase = resolvePhaseOrError(phases, entry.phase, errors);
641
+ if (!phase) return phases;
642
+ phase.tasks = [];
643
+ return phases;
644
+ }
645
+ for (const phase of phases) {
646
+ phase.tasks = [];
647
+ }
648
+ return phases;
649
+ }
650
+
651
+ function applyEntry(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]): TodoPhase[] {
652
+ switch (entry.op) {
653
+ case "init":
654
+ return initPhases(entry, errors);
655
+ case "start": {
656
+ const hit = resolveTaskOrError(phases, entry.task, errors);
657
+ if (!hit) return phases;
658
+ for (const phase of phases) {
659
+ for (const candidate of phase.tasks) {
660
+ if (candidate.status === "in_progress" && candidate !== hit.task) {
661
+ candidate.status = "pending";
662
+ }
663
+ }
664
+ }
665
+ hit.task.status = "in_progress";
666
+ return phases;
667
+ }
668
+ case "done": {
669
+ for (const task of getTaskTargets(phases, entry, errors)) {
670
+ task.status = "completed";
671
+ }
672
+ return phases;
673
+ }
674
+ case "drop": {
675
+ for (const task of getTaskTargets(phases, entry, errors)) {
676
+ task.status = "abandoned";
677
+ }
678
+ return phases;
679
+ }
680
+ case "block": {
681
+ if (!entry.task && !entry.phase) {
682
+ errors.push("block requires a task or phase target");
683
+ return phases;
684
+ }
685
+ // Collapse whitespace runs (incl. newlines) to single spaces: a blocker
686
+ // note rides on one Markdown checklist line and one summary line, so an
687
+ // embedded newline would corrupt the rendered line. Normalizing here
688
+ // keeps every consumer one-line-safe.
689
+ const reason = entry.reason?.replace(/\s+/g, " ").trim() || undefined;
690
+ for (const task of getTaskTargets(phases, entry, errors)) {
691
+ // Only actionable open work can be blocked: blocking a phase must not
692
+ // reopen completed/abandoned tasks or erase finished progress. An
693
+ // already-blocked task stays eligible so a later block can refine its
694
+ // blocker note (e.g. first blocked without a reason, then with one).
695
+ if (task.status !== "pending" && task.status !== "in_progress" && task.status !== "blocked") continue;
696
+ task.status = "blocked";
697
+ task.blocker = reason;
698
+ }
699
+ return phases;
700
+ }
701
+ case "unblock": {
702
+ if (!entry.task && !entry.phase) {
703
+ errors.push("unblock requires a task or phase target");
704
+ return phases;
705
+ }
706
+ for (const task of getTaskTargets(phases, entry, errors)) {
707
+ if (task.status === "blocked") {
708
+ task.status = "pending";
709
+ task.blocker = undefined;
710
+ }
711
+ }
712
+ return phases;
713
+ }
714
+ case "rm":
715
+ return removeTasks(phases, entry, errors);
716
+ case "append":
717
+ return appendItems(phases, entry, errors);
718
+ case "view":
719
+ return phases;
720
+ }
721
+ }
722
+
723
+ // =============================================================================
724
+ // Missing-op inference
725
+ // =============================================================================
726
+
727
+ /**
728
+ * Infer a missing `op` from the raw argument shape. Only unambiguous shapes
729
+ * are inferred:
730
+ * - `list` → `init` (list is init-only)
731
+ * - `items` + `phase` → `append` (lazily creates the phase, so the result
732
+ * matches a single-phase init when nothing exists yet)
733
+ * - bare `items` with no existing todos → `init` (nothing to overwrite)
734
+ * Targeting args alone (`task`/`phase`) map to several ops and stay an error.
735
+ */
736
+ export function inferTodoOp(args: Record<string, unknown>, hasExistingPhases: boolean): TodoOperation | undefined {
737
+ if (Array.isArray(args.list) && args.list.length > 0) return "init";
738
+ if (Array.isArray(args.items) && args.items.length > 0) {
739
+ if (typeof args.phase === "string" && args.phase) return "append";
740
+ if (!hasExistingPhases) return "init";
741
+ }
742
+ return undefined;
743
+ }
744
+
745
+ export function applyParams(phases: TodoPhase[], params: TodoOpEntry): { phases: TodoPhase[]; errors: string[] } {
746
+ const errors: string[] = [];
747
+ const next = applyEntry(phases, params, errors);
748
+ normalizeInProgressTask(next);
749
+ return { phases: next, errors };
750
+ }
751
+
752
+ /** Apply an array of `todo`-style ops to existing phases. Used by /todo clear. */
753
+ export function applyOpsToPhases(
754
+ currentPhases: TodoPhase[],
755
+ ops: TodoOpEntry[],
756
+ ): { phases: TodoPhase[]; errors: string[] } {
757
+ const errors: string[] = [];
758
+ let next = clonePhases(currentPhases);
759
+ for (const op of ops) {
760
+ next = applyEntry(next, op, errors);
761
+ }
762
+ normalizeInProgressTask(next);
763
+ return { phases: next, errors };
764
+ }
765
+
766
+ // =============================================================================
767
+ // Summary text (tool result body)
768
+ // =============================================================================
769
+
770
+ export function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false): string {
771
+ const tasks = phases.flatMap(phase => phase.tasks);
772
+ if (tasks.length === 0) {
773
+ if (errors.length > 0) return `Errors: ${errors.join("; ")}`;
774
+ return readOnly ? "Todo list is empty." : "Todo list cleared.";
775
+ }
776
+
777
+ const remainingByPhase = phases
778
+ .map(phase => ({
779
+ name: phase.name,
780
+ tasks: phase.tasks.filter(task => task.status === "pending" || task.status === "in_progress"),
781
+ }))
782
+ .filter(phase => phase.tasks.length > 0);
783
+ const remainingTasks = remainingByPhase.flatMap(phase => phase.tasks.map(task => ({ ...task, phase: phase.name })));
784
+
785
+ let currentIdx = phases.findIndex(phase =>
786
+ phase.tasks.some(task => task.status === "pending" || task.status === "in_progress"),
787
+ );
788
+ if (currentIdx === -1) currentIdx = phases.length - 1;
789
+ const current = phases[currentIdx];
790
+ const done = current.tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
791
+
792
+ const lines: string[] = [];
793
+ if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`);
794
+ if (remainingTasks.length === 0) {
795
+ lines.push("Remaining items: none.");
796
+ } else {
797
+ lines.push(`Remaining items (${remainingTasks.length}):`);
798
+ for (const task of remainingTasks) {
799
+ lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
800
+ }
801
+ }
802
+ // Closed = completed + abandoned, mirroring the per-phase `done` count.
803
+ const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
804
+ const blockedAll = tasks.filter(task => task.status === "blocked").length;
805
+ // The active phase is the EARLIEST one still holding open work, so the
806
+ // in-progress pointer can sit in a phase whose successors already have
807
+ // completed tasks. Detect that "worked ahead" case to explain the
808
+ // otherwise-surprising backward pointer instead of letting it read as a
809
+ // completed task reverting to pending.
810
+ const workedAhead = phases.some(
811
+ (phase, idx) =>
812
+ idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
813
+ );
814
+ lines.push(
815
+ `Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
816
+ );
817
+ lines.push(
818
+ `Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
819
+ workedAhead
820
+ ? " — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed)."
821
+ : "."
822
+ }`,
823
+ );
824
+ for (const phase of phases) {
825
+ lines.push(` ${phase.name}:`);
826
+ for (const task of phase.tasks) {
827
+ const checkbox = task.status === "completed" ? "[X]" : "[ ]";
828
+ const tag =
829
+ task.status === "in_progress"
830
+ ? " (in progress)"
831
+ : task.status === "abandoned"
832
+ ? " (dropped)"
833
+ : task.status === "blocked"
834
+ ? task.blocker
835
+ ? ` (blocked: ${task.blocker})`
836
+ : " (blocked)"
837
+ : "";
838
+ lines.push(` - ${checkbox} ${task.content}${tag}`);
839
+ }
840
+ }
841
+ return lines.join("\n");
842
+ }