@fyeeme/pi-todo 1.0.0 → 1.0.1

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/render.ts CHANGED
@@ -7,14 +7,16 @@
7
7
  * call line and result lines separately. Strings, glyphs, colors, roman
8
8
  * numerals, per-phase progress, touched-phase collapsing, and the collapsed
9
9
  * walking viewport all match omp:
10
- * - call: ⏳(muted) Todo · <op> <task> · <phase> · N items (dim meta)
10
+ * - call: ⏳(muted) Todo · <op> <task> <phase> N items (dim meta; the
11
+ * op's fields join with spaces, multiple ops join with ·)
11
12
  * - result: ☑(accent) Todo · N tasks + per-phase tree
12
13
  * - tasks: ☑ success strikethrough / ☐ accent in-progress / ☐ error
13
14
  * strikethrough abandoned / ☐ warning blocked (note) / ☐ dim pending
14
15
  * - tree: ├─ / └─ dim branch glyphs with the muted trailing summary
15
16
  * Dropped (host-internal, no pi counterpart): strike animations and spinner
16
- * frames (final strike state renders), subagent-match lighting (the
17
- * description provider stays empty outside omp), framed block chrome.
17
+ * frames (final strike state renders), subagent-match lighting (matcher
18
+ * removed with the cognitive-neutral refactor; pending renders dim), framed
19
+ * block chrome.
18
20
  */
19
21
 
20
22
  import { Text, type Component } from "@earendil-works/pi-tui";
@@ -24,9 +26,8 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
24
26
  import {
25
27
  type TodoCompletionTransition,
26
28
  type TodoItem,
27
- formatMoreItems,
29
+ isClosedTodo,
28
30
  phaseRomanNumeral,
29
- phasesToMarkdown,
30
31
  selectCollapsedTodos,
31
32
  type TodoPhase,
32
33
  type TodoToolDetails,
@@ -83,7 +84,7 @@ function formatPhaseDisplayName(name: string, oneBasedIndex: number): string {
83
84
 
84
85
  /** Dim `closed/total` suffix — counts completed + abandoned (omp isClosedTodo). */
85
86
  function formatPhaseProgress(phase: TodoPhase, theme: Theme): string {
86
- const done = phase.tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
87
+ const done = phase.tasks.filter(isClosedTodo).length;
87
88
  return theme.fg("dim", ` ${done}/${phase.tasks.length}`);
88
89
  }
89
90
 
@@ -100,7 +101,7 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, theme: Them
100
101
  /** omp theme.checkbox unicode set (checkbox.checked / checkbox.unchecked). */
101
102
  const CHECKBOX = { checked: "☑", unchecked: "☐" } as const;
102
103
 
103
- function formatTodoLine(item: TodoItem, theme: Theme, matched = false): string {
104
+ function formatTodoLine(item: TodoItem, theme: Theme): string {
104
105
  const label = clip(item.content);
105
106
  const box = CHECKBOX.unchecked;
106
107
  switch (item.status) {
@@ -115,9 +116,7 @@ function formatTodoLine(item: TodoItem, theme: Theme, matched = false): string {
115
116
  return theme.fg("warning", `${box} ${label} (${note})`);
116
117
  }
117
118
  default:
118
- // A pending todo lit by a live subagent match renders accent (omp #5873);
119
- // the subagent description provider is host-internal, so pending stays dim.
120
- return theme.fg(matched ? "accent" : "dim", `${box} ${label}`);
119
+ return theme.fg("dim", `${box} ${label}`);
121
120
  }
122
121
  }
123
122
 
@@ -136,8 +135,7 @@ interface TreeLineOptions {
136
135
  theme: Theme;
137
136
  }
138
137
 
139
- function renderTreeLines({ items, trailingSummary, renderItem, theme }: TreeLineOptions): string[] {
140
- const summary = trailingSummary;
138
+ function renderTreeLines({ items, trailingSummary: summary, renderItem, theme }: TreeLineOptions): string[] {
141
139
  const lines: string[] = [];
142
140
  for (let i = 0; i < items.length; i++) {
143
141
  const rendered = renderItem(items[i]);
@@ -157,9 +155,11 @@ function renderTreeLines({ items, trailingSummary, renderItem, theme }: TreeLine
157
155
  // =============================================================================
158
156
 
159
157
  /**
160
- * Phases the latest update touched, plus the active (in_progress) phase.
161
- * Returns `null` when there is no usable signal, meaning "render every phase
162
- * fully" — this preserves the legacy view and the manual-expand path.
158
+ * Phases the latest update touched, plus the phase where the work now sits:
159
+ * the earliest phase still holding open work (the same "active phase" the
160
+ * summary text reports). Returns `null` when there is no usable signal,
161
+ * meaning "render every phase fully" — this preserves the legacy view and the
162
+ * manual-expand path.
163
163
  */
164
164
  function computeTouchedPhases(
165
165
  args: TodoRenderArgs | undefined,
@@ -167,18 +167,23 @@ function computeTouchedPhases(
167
167
  completedTasks: TodoCompletionTransition[],
168
168
  ): Set<string> | null {
169
169
  const touched = new Set<string>();
170
- // The phase holding the in_progress task is where attention sits after the
171
- // auto-promotion that follows every completion.
170
+ // Explicitly started work stays expanded.
172
171
  for (const phase of phases) {
173
172
  if (phase.tasks.some(task => task.status === "in_progress")) touched.add(phase.name);
174
173
  }
174
+ // Without the omp auto-promotion pointer there may be no in_progress task at
175
+ // all; keep the earliest open-work phase expanded so the phase the agent
176
+ // works on next never collapses to a one-line summary.
177
+ const activePhase = phases.find(phase =>
178
+ phase.tasks.some(task => task.status === "pending" || task.status === "in_progress"),
179
+ );
180
+ if (activePhase) touched.add(activePhase.name);
175
181
  // Phases with a task that just transitioned to completed in this update.
176
182
  for (const transition of completedTasks) touched.add(transition.phase);
177
183
  // Phases explicitly named by the ops that ran. `init` replaces the whole
178
184
  // list, so the entire plan is fresh and every phase counts as touched.
179
185
  const ops = normalizeTodoArg(args);
180
186
  for (const op of ops) {
181
- if (!op || typeof op !== "object") continue;
182
187
  if (op.op === "init") {
183
188
  for (const phase of phases) touched.add(phase.name);
184
189
  break;
@@ -198,11 +203,7 @@ function computeTouchedPhases(
198
203
  // Call / result renderers
199
204
  // =============================================================================
200
205
 
201
- export interface TodoRenderArgsPublic {
202
- op?: string;
203
- task?: string;
204
- phase?: string;
205
- }
206
+ export type TodoRenderArgsPublic = Omit<TodoRenderOp, "items">;
206
207
 
207
208
  export function renderTodoCall(args: TodoRenderArgsPublic, theme: Theme): Component {
208
209
  // omp renderCall: renderStatusLine({icon:"pending", title:"Todo", meta})
@@ -253,8 +254,6 @@ export function renderTodoResult(
253
254
  // single task flip doesn't redraw every phase's full task list. The manual
254
255
  // expand toggle (and the no-signal fallback) still shows all.
255
256
  const touched = options.expanded || !multiPhase ? null : computeTouchedPhases(args as TodoRenderArgs, phases, completedTasks);
256
- // Subagent description matching is host-internal; pending renders dim.
257
- const isMatched = (_task: TodoItem): boolean => false;
258
257
 
259
258
  for (let p = 0; p < phases.length; p++) {
260
259
  const phase = phases[p];
@@ -274,11 +273,11 @@ export function renderTodoResult(
274
273
  const treeLines = options.expanded
275
274
  ? renderTreeLines({ items: phase.tasks, renderItem: todo => formatTodoLine(todo, theme), theme })
276
275
  : (() => {
277
- const selection = selectCollapsedTodos(phase.tasks, isMatched, COLLAPSED_ITEMS);
276
+ const selection = selectCollapsedTodos(phase.tasks, COLLAPSED_ITEMS);
278
277
  return renderTreeLines({
279
278
  items: selection.items,
280
279
  trailingSummary: selection.summary,
281
- renderItem: todo => formatTodoLine(todo, theme, isMatched(todo)),
280
+ renderItem: todo => formatTodoLine(todo, theme),
282
281
  theme,
283
282
  });
284
283
  })();
@@ -286,10 +285,6 @@ export function renderTodoResult(
286
285
  bodyLines.push(`${indent}${line}`);
287
286
  }
288
287
  }
289
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
290
288
 
291
289
  return new Text([header, ...bodyLines].join("\n"), 0, 0);
292
290
  }
293
-
294
- // Re-exported for consumers that compose the pieces (tests).
295
- export { formatMoreItems, phasesToMarkdown };
package/src/restore.ts CHANGED
@@ -13,9 +13,6 @@ import { clonePhases, isTodoPhaseSnapshot, type TodoPhase } from "./state.ts";
13
13
  /** Session entry customType for todo snapshots (see pi.appendEntry). */
14
14
  export const TODO_PHASES_ENTRY_TYPE = "todo-phases";
15
15
 
16
- /** Session entry customType for stop-time reminders. */
17
- export const TODO_REMINDER_ENTRY_TYPE = "todo-reminder";
18
-
19
16
  /**
20
17
  * Reconstruct todo state from session entries: the latest valid
21
18
  * `todo-phases` snapshot reachable on the current branch wins; malformed
package/src/state.ts CHANGED
@@ -4,11 +4,17 @@
4
4
  * Ported from oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
5
5
  * `packages/coding-agent/src/tools/todo.ts` — the data model, the nine
6
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)
7
+ * over behavior-for-behavior, with deliberate deviations (cognitive-neutral
8
+ * notepad refactor):
9
+ * - no single-`in_progress` invariant / auto-promotion pointer: statuses are
10
+ * set only by explicit ops (`start`/`done`/`drop`/`block`/`unblock`)
11
+ * - no stop-reminder machinery (question guards, nag cycle)
12
+ * Dropped from the omp source (host-internal surfaces with no pi counterpart):
13
+ * - prompt-line analysis machines - subagent description matching
14
+ * - nextActionableTask (prewalk consumer) - tool examples (omp-only field)
15
+ * Kept despite the omp header listing them as host-internal: the markdown
16
+ * round-trip (commands.ts /todo edit|import) and the collapsed-viewport
17
+ * selection (render.ts) — both live below.
12
18
  */
13
19
 
14
20
  import * as os from "node:os";
@@ -112,7 +118,15 @@ function todoTransitionKey(phase: string, content: string): string {
112
118
  return `${phase}\u0000${content}`;
113
119
  }
114
120
 
115
- export function getCompletionTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoCompletionTransition[] {
121
+ /** A task whose status changed in a mutation (old → new). */
122
+ export interface TodoStatusTransition {
123
+ phase: string;
124
+ content: string;
125
+ from: TodoStatus;
126
+ to: TodoStatus;
127
+ }
128
+
129
+ export function getStatusTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoStatusTransition[] {
116
130
  const previousStatuses = new Map<string, TodoStatus>();
117
131
  for (const phase of previous) {
118
132
  for (const task of phase.tasks) {
@@ -120,41 +134,18 @@ export function getCompletionTransitions(previous: TodoPhase[], updated: TodoPha
120
134
  }
121
135
  }
122
136
 
123
- const transitions: TodoCompletionTransition[] = [];
137
+ const transitions: TodoStatusTransition[] = [];
124
138
  for (const phase of updated) {
125
139
  for (const task of phase.tasks) {
126
- if (task.status !== "completed") continue;
127
140
  const previousStatus = previousStatuses.get(todoTransitionKey(phase.name, task.content));
128
- if (previousStatus && previousStatus !== "completed") {
129
- transitions.push({ phase: phase.name, content: task.content });
141
+ if (previousStatus && previousStatus !== task.status) {
142
+ transitions.push({ phase: phase.name, content: task.content, from: previousStatus, to: task.status });
130
143
  }
131
144
  }
132
145
  }
133
146
  return transitions;
134
147
  }
135
148
 
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
149
  /** Whether a todo is settled: completed or deliberately abandoned. Shared so
159
150
  * the collapsed viewport and the summary counters can never disagree about
160
151
  * what "done" hides. */
@@ -166,39 +157,6 @@ export function isClosedTodo<T extends { status: TodoStatus }>(task: T): boolean
166
157
  // Collapsed-viewport selection (omp #5873 walking viewport)
167
158
  // =============================================================================
168
159
 
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
160
  /** omp pluralize (packages/utils/src/format.ts). */
203
161
  export function pluralize(label: string, count: number): string {
204
162
  if (count === 1) return label;
@@ -214,9 +172,10 @@ export function formatMoreItems(remaining: number, itemType: string): string {
214
172
  }
215
173
 
216
174
  /** 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));
175
+ * `in_progress` task. (omp also matched pending tasks a live subagent was
176
+ * executing; that matcher was removed with the cognitive-neutral refactor.) */
177
+ function isActiveTodo<T extends { status: TodoStatus }>(task: T): boolean {
178
+ return task.status === "in_progress";
220
179
  }
221
180
 
222
181
  /** Result of {@link selectCollapsedTodos}: the rows to render plus an optional
@@ -230,14 +189,10 @@ export interface CollapsedTodoSelection<T> {
230
189
  * visible as it happens (omp COLLAPSED_CLOSED_CONTEXT). */
231
190
  const COLLAPSED_CLOSED_CONTEXT = 1;
232
191
 
233
- function selectWithinCap<T extends { status: TodoStatus }>(
234
- base: T[],
235
- isMatched: (task: T) => boolean,
236
- cap: number,
237
- ): CollapsedTodoSelection<T> {
192
+ function selectWithinCap<T extends { status: TodoStatus }>(base: T[], cap: number): CollapsedTodoSelection<T> {
238
193
  if (base.length <= cap) return { items: base, summary: "" };
239
194
 
240
- const active = base.filter(task => isActiveTodo(task, isMatched));
195
+ const active = base.filter(task => isActiveTodo(task));
241
196
  // Only when active work strictly exceeds the cap do we drop pending rows and
242
197
  // count hidden *actives*. At exactly `cap` actives, fall through so the normal
243
198
  // branch still surfaces any following pending work in the summary.
@@ -250,12 +205,12 @@ function selectWithinCap<T extends { status: TodoStatus }>(
250
205
  }
251
206
 
252
207
  // Fill trailing rows with tasks following the first active one, so the
253
- // promoted/current task leads and its successors follow in todo order.
208
+ // active task leads and its successors follow in todo order.
254
209
  const firstActiveIdx = active.length > 0 ? base.indexOf(active[0]) : 0;
255
210
  const fill: T[] = [];
256
211
  for (let i = firstActiveIdx; i < base.length && active.length + fill.length < cap; i++) {
257
212
  const task = base[i];
258
- if (isActiveTodo(task, isMatched)) continue;
213
+ if (isActiveTodo(task)) continue;
259
214
  fill.push(task);
260
215
  }
261
216
  const items = [...active, ...fill];
@@ -273,79 +228,17 @@ function selectWithinCap<T extends { status: TodoStatus }>(
273
228
  */
274
229
  export function selectCollapsedTodos<T extends { status: TodoStatus }>(
275
230
  tasks: T[],
276
- isMatched: (task: T) => boolean,
277
231
  cap: number,
278
232
  ): CollapsedTodoSelection<T> {
279
233
  const open = tasks.filter(task => !isClosedTodo(task));
280
234
  // Closed tasks are never active, so a settled phase selects over itself.
281
- if (open.length === 0) return selectWithinCap(tasks, isMatched, cap);
235
+ if (open.length === 0) return selectWithinCap(tasks, cap);
282
236
  // `done` accepts any named task, so closed tasks are not necessarily a prefix.
283
237
  const lead = tasks.filter(isClosedTodo).slice(-COLLAPSED_CLOSED_CONTEXT);
284
- const selected = selectWithinCap(open, isMatched, cap);
238
+ const selected = selectWithinCap(open, cap);
285
239
  return { items: [...lead, ...selected.items], summary: selected.summary };
286
240
  }
287
241
 
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
242
  // =============================================================================
350
243
  // Phase numbering (display-only, omp phaseRomanNumeral)
351
244
  // =============================================================================
@@ -473,7 +366,6 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
473
366
  errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
474
367
  }
475
368
 
476
- normalizeInProgressTask(phases);
477
369
  return { phases, errors };
478
370
  }
479
371
 
@@ -493,18 +385,6 @@ export function resolveTodoMarkdownPath(input: string, cwd: string): string {
493
385
  return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
494
386
  }
495
387
 
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
388
  // =============================================================================
509
389
  // Operation resolution
510
390
  // =============================================================================
@@ -655,13 +535,6 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]):
655
535
  case "start": {
656
536
  const hit = resolveTaskOrError(phases, entry.task, errors);
657
537
  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
538
  hit.task.status = "in_progress";
666
539
  return phases;
667
540
  }
@@ -745,7 +618,6 @@ export function inferTodoOp(args: Record<string, unknown>, hasExistingPhases: bo
745
618
  export function applyParams(phases: TodoPhase[], params: TodoOpEntry): { phases: TodoPhase[]; errors: string[] } {
746
619
  const errors: string[] = [];
747
620
  const next = applyEntry(phases, params, errors);
748
- normalizeInProgressTask(next);
749
621
  return { phases: next, errors };
750
622
  }
751
623
 
@@ -759,7 +631,6 @@ export function applyOpsToPhases(
759
631
  for (const op of ops) {
760
632
  next = applyEntry(next, op, errors);
761
633
  }
762
- normalizeInProgressTask(next);
763
634
  return { phases: next, errors };
764
635
  }
765
636
 
@@ -767,6 +638,16 @@ export function applyOpsToPhases(
767
638
  // Summary text (tool result body)
768
639
  // =============================================================================
769
640
 
641
+ /** Fold the per-task dumps above this size: mutation results carry the counts
642
+ * and the leading open tasks, not the whole list — on big lists every
643
+ * `done`/`start` would otherwise flood the model's context with a
644
+ * full-checklist echo. `view` (readOnly) always echoes in full; this folds
645
+ * only mutation results without errors (error results must carry the
646
+ * unchanged list for the model's retry). */
647
+ const SUMMARY_FULL_LIST_LIMIT = 20;
648
+ /** Open tasks shown when the remaining dump is folded. */
649
+ const SUMMARY_FOLDED_SHOWN = 10;
650
+
770
651
  export function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false): string {
771
652
  const tasks = phases.flatMap(phase => phase.tasks);
772
653
  if (tasks.length === 0) {
@@ -787,55 +668,60 @@ export function formatSummary(phases: TodoPhase[], errors: string[], readOnly =
787
668
  );
788
669
  if (currentIdx === -1) currentIdx = phases.length - 1;
789
670
  const current = phases[currentIdx];
790
- const done = current.tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
671
+ const done = current.tasks.filter(isClosedTodo).length;
791
672
 
792
673
  const lines: string[] = [];
674
+ const fold = !readOnly && errors.length === 0 && tasks.length > SUMMARY_FULL_LIST_LIMIT;
793
675
  if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`);
794
676
  if (remainingTasks.length === 0) {
795
677
  lines.push("Remaining items: none.");
796
678
  } else {
797
679
  lines.push(`Remaining items (${remainingTasks.length}):`);
798
- for (const task of remainingTasks) {
680
+ const shown = fold ? remainingTasks.slice(0, SUMMARY_FOLDED_SHOWN) : remainingTasks;
681
+ for (const task of shown) {
799
682
  lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
800
683
  }
684
+ if (shown.length < remainingTasks.length) {
685
+ lines.push(` … ${remainingTasks.length - shown.length} more open — call \`view\` for the full list.`);
686
+ }
801
687
  }
802
688
  // Closed = completed + abandoned, mirroring the per-phase `done` count.
803
- const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
689
+ const closedAll = tasks.filter(isClosedTodo).length;
804
690
  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
- );
691
+ // The active phase is the EARLIEST one still holding open work, so it can
692
+ // sit behind phases that already hold completed tasks from out-of-order
693
+ // execution. Say so instead of letting it read as a completed task
694
+ // reverting to pending.
695
+ const workedAhead = phases.some((phase, idx) => idx > currentIdx && phase.tasks.some(isClosedTodo));
814
696
  lines.push(
815
697
  `Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
816
698
  );
817
699
  lines.push(
818
700
  `Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
819
701
  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)."
702
+ ? " — earliest phase with open tasks; later phases may already hold completed work from out-of-order execution (nothing was un-completed)."
821
703
  : "."
822
704
  }`,
823
705
  );
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}`);
706
+ if (fold) {
707
+ lines.push(`Full checklist omitted (${tasks.length} tasks) — \`view\` echoes it.`);
708
+ } else {
709
+ for (const phase of phases) {
710
+ lines.push(` ${phase.name}:`);
711
+ for (const task of phase.tasks) {
712
+ const checkbox = task.status === "completed" ? "[X]" : "[ ]";
713
+ const tag =
714
+ task.status === "in_progress"
715
+ ? " (in progress)"
716
+ : task.status === "abandoned"
717
+ ? " (dropped)"
718
+ : task.status === "blocked"
719
+ ? task.blocker
720
+ ? ` (blocked: ${task.blocker})`
721
+ : " (blocked)"
722
+ : "";
723
+ lines.push(` - ${checkbox} ${task.content}${tag}`);
724
+ }
839
725
  }
840
726
  }
841
727
  return lines.join("\n");