@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/tool.ts CHANGED
@@ -9,6 +9,9 @@
9
9
  * AgentTool class + ToolSession → ToolDefinition + closure deps
10
10
  * concurrency: "exclusive" → executionMode: "sequential"
11
11
  * session getTodoPhases/setTodoPhases → getPhases/setPhases/persist/broadcast
12
+ * eager-todo promptGuidelines → dropped (cognitive-neutral notepad: the
13
+ * tool description documents the mechanics; the system prompt is not
14
+ * steered)
12
15
  */
13
16
 
14
17
  import { readFileSync } from "node:fs";
@@ -22,7 +25,7 @@ import {
22
25
  applyParams,
23
26
  clonePhases,
24
27
  formatSummary,
25
- getCompletionTransitions,
28
+ getStatusTransitions,
26
29
  inferTodoOp,
27
30
  type TodoOperation,
28
31
  type TodoOpEntry,
@@ -66,13 +69,6 @@ export const TodoParamsSchema = Type.Object({
66
69
 
67
70
  export type TodoParams = Static<typeof TodoParamsSchema>;
68
71
 
69
- /** Advisory nudge appended to the default system prompt while the tool is active. */
70
- export const TODO_PROMPT_GUIDELINES = [
71
- "Consider calling the todo tool first to lay out a phased plan with a single `init` op. A good list covers the whole request — investigation through implementation and verification — not just the next step, with specific task descriptions a future turn could execute without re-planning.",
72
- "A useful list keeps each task to a concise, specific 5-10 word label; the `init` op only accepts phase names and task-label strings, so don't invent extra task metadata fields.",
73
- "If you create the list, continue the request in the same turn and avoid re-calling the todo tool unless task state materially changes.",
74
- ];
75
-
76
72
  export interface TodoToolDeps {
77
73
  /** Current in-memory phases (already cloned on write). */
78
74
  getPhases(): TodoPhase[];
@@ -84,16 +80,23 @@ export interface TodoToolDeps {
84
80
  broadcast(phases: TodoPhase[]): void;
85
81
  }
86
82
 
83
+ /** The one write-back invariant: replace state, persist, broadcast — in that
84
+ * order. Shared by the tool path and the /todo command path so the two can
85
+ * never drift (persist/broadcast semantics live in index.ts's deps). */
86
+ export function commitPhases(deps: TodoToolDeps, next: TodoPhase[]): void {
87
+ deps.setPhases(next);
88
+ deps.persist(next);
89
+ deps.broadcast(next);
90
+ }
91
+
87
92
  export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> {
88
93
  const definition: ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> = {
89
94
  name: "todo",
90
95
  label: "Todo",
91
96
  description: todoDescription,
92
- promptSnippet: "Write a structured todo list to track progress within a session",
93
- promptGuidelines: TODO_PROMPT_GUIDELINES,
94
97
  parameters: TodoParamsSchema,
95
98
  executionMode: "sequential",
96
- renderCall: (args, theme) => renderTodoCall(args, theme),
99
+ renderCall: renderTodoCall,
97
100
  // omp's renderer folds call args into the result view (touched-phase
98
101
  // diffing); pi passes them through the render context.
99
102
  renderResult: (result, options, theme, context) => renderTodoResult(result, options, theme, context?.args),
@@ -118,7 +121,7 @@ export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoPa
118
121
  _onUpdate: undefined,
119
122
  _ctx: ExtensionContext,
120
123
  ): Promise<AgentToolResult<TodoToolDetails>> {
121
- const previousPhases = clonePhases(deps.getPhases());
124
+ const previousPhases = deps.getPhases();
122
125
  const op: TodoOperation = params.op;
123
126
  // Pure-view calls are reads: no normalization, no state write.
124
127
  const readOnly = op === "view";
@@ -130,21 +133,47 @@ export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoPa
130
133
  // the ops that did land. State stays at previous. pi's tool contract
131
134
  // is throw-on-failure (no isError field on AgentToolResult), and the
132
135
  // thrown message carries the errors plus the unchanged list so the
133
- // model can retry with correct content.
136
+ // model can retry with correct content. formatSummary does not fold
137
+ // error results (the fold is gated on empty errors), so the full list
138
+ // is present even on big lists.
134
139
  if (errors.length > 0) {
135
140
  throw new Error(formatSummary(previousPhases, errors, readOnly));
136
141
  }
137
142
  if (!readOnly) {
138
- deps.setPhases(clonePhases(updated));
139
- deps.persist(clonePhases(updated));
140
- deps.broadcast(clonePhases(updated));
143
+ // `updated` is a fresh clone nothing else references: hand ownership
144
+ // to the closure, and let persist/broadcast make their own
145
+ // entry-shaped snapshots (clonePhases inside index.ts).
146
+ commitPhases(deps, updated);
141
147
  }
142
- const completedTasks = readOnly ? [] : getCompletionTransitions(previousPhases, updated);
148
+ // Per-write confirmation, independent of summary folding: on big lists
149
+ // the folded summary may not show the operated tasks at all, so the
150
+ // model needs an explicit record of what changed (status + blocker note).
151
+ const transitions = readOnly ? [] : getStatusTransitions(previousPhases, updated);
143
152
  const details: TodoToolDetails = { op, phases: clonePhases(updated), storage: "session" };
153
+ const completedTasks = transitions
154
+ .filter(transition => transition.to === "completed")
155
+ .map(({ phase, content }) => ({ phase, content }));
144
156
  if (completedTasks.length > 0) details.completedTasks = completedTasks;
145
157
 
158
+ let text = formatSummary(updated, [], readOnly);
159
+ if (transitions.length > 0) {
160
+ const changed = transitions
161
+ .map(transition => {
162
+ const blocker =
163
+ transition.to === "blocked"
164
+ ? updated
165
+ .find(phase => phase.name === transition.phase)
166
+ ?.tasks.find(task => task.content === transition.content)?.blocker
167
+ : undefined;
168
+ const note = blocker ? ` (blocked: ${blocker})` : "";
169
+ return ` - ${transition.content} [${transition.from} → ${transition.to}]${note} (${transition.phase})`;
170
+ })
171
+ .join("\n");
172
+ text = `Changed:\n${changed}\n\n${text}`;
173
+ }
174
+
146
175
  return {
147
- content: [{ type: "text", text: formatSummary(updated, [], readOnly) }],
176
+ content: [{ type: "text", text }],
148
177
  details,
149
178
  };
150
179
  },
@@ -11,31 +11,33 @@
11
11
  * MarkdownPath | escaped brackets, errors)
12
12
  * tools/todo.ts phaseRomanNumeral | algorithmic roman numerals
13
13
  * tools/todo.ts selectCollapsedTodos / | collapsed walking viewport
14
- * selectWithinCap / todoMatchesAny- |
15
- * Description |
14
+ * selectWithinCap |
16
15
  * tools/todo.ts formatSummary | exact summary text
17
16
  * tools/todo.ts formatMoreItems / pluralize | ellipsis summaries
18
- * session/todo-tracker.ts checkCompletion / | reminder text, attempt
19
- * isAwaitingUserAnswer | cycle, question guard
20
17
  * modes/controllers/todo-command-controller.ts | /todo verb surface, fuzzy
21
- * | matching, system reminder
18
+ * | matching
22
19
  *
23
- * Every expected string is copied verbatim from the omp sources listed.
20
+ * Expected strings are copied verbatim from the omp sources listed, except
21
+ * where the deviations note below applies.
22
+ *
23
+ * Deliberate omp deviations NOT pinned here (cognitive-neutral refactor):
24
+ * normalizeInProgressTask / auto-promotion, stop-reminder machinery, and the
25
+ * subagent description matcher were removed; formatSummary's worked-ahead
26
+ * wording and the >20-task fold with its `view` hints are pi-todo deviations
27
+ * from omp (assertions below pin the pi-todo wording, not omp's). See
28
+ * src/state.ts header.
24
29
  */
25
30
 
26
31
  import { homedir } from "node:os";
27
32
  import { describe, expect, it } from "vitest";
28
33
  import {
29
34
  formatSummary,
30
- isAwaitingUserAnswer,
31
35
  markdownToPhases,
32
- normalizeInProgressTask,
33
36
  phaseRomanNumeral,
34
37
  phasesToMarkdown,
35
38
  pluralize,
36
39
  resolveTodoMarkdownPath,
37
40
  selectCollapsedTodos,
38
- todoMatchesAnyDescription,
39
41
  type TodoItem,
40
42
  type TodoPhase,
41
43
  } from "../src/state.ts";
@@ -126,9 +128,9 @@ describe("markdown round-trip matches omp", () => {
126
128
  ]);
127
129
  });
128
130
 
129
- it("normalizeInProgressTask runs on parse (omp markdownToPhases tail)", () => {
131
+ it("markdownToPhases parses statuses literally (no normalization)", () => {
130
132
  const { phases } = markdownToPhases("- [ ] one\n- [ ] two");
131
- expect(phases[0]?.tasks[0]?.status).toBe("in_progress");
133
+ expect(phases[0]?.tasks[0]?.status).toBe("pending");
132
134
  expect(phases[0]?.tasks[1]?.status).toBe("pending");
133
135
  });
134
136
 
@@ -169,11 +171,9 @@ function task(content: string, status: TodoItem["status"]): TodoItem {
169
171
  }
170
172
 
171
173
  describe("selectCollapsedTodos matches omp", () => {
172
- const never = () => false;
173
-
174
174
  it("returns everything within the cap", () => {
175
175
  const tasks = [task("a", "in_progress"), task("b", "pending")];
176
- const selection = selectCollapsedTodos(tasks, never, 8);
176
+ const selection = selectCollapsedTodos(tasks, 8);
177
177
  expect(selection.items).toEqual(tasks);
178
178
  expect(selection.summary).toBe("");
179
179
  });
@@ -191,7 +191,7 @@ describe("selectCollapsedTodos matches omp", () => {
191
191
  task("g", "pending"),
192
192
  task("h", "pending"),
193
193
  ];
194
- const selection = selectCollapsedTodos(tasks, never, 8);
194
+ const selection = selectCollapsedTodos(tasks, 8);
195
195
  // Open work = 8 (a..h) ≤ cap; lead adds the LAST closed row additively.
196
196
  expect(selection.items.map(t => t.content)).toEqual([
197
197
  "closed-new",
@@ -209,7 +209,7 @@ describe("selectCollapsedTodos matches omp", () => {
209
209
 
210
210
  it("counts hidden open work with the omp ellipsis summary", () => {
211
211
  const tasks = [task("done", "completed"), ...Array.from({ length: 10 }, (_, i) => task(`t${i}`, "pending"))];
212
- const selection = selectCollapsedTodos(tasks, never, 8);
212
+ const selection = selectCollapsedTodos(tasks, 8);
213
213
  expect(selection.items.filter(t => t.content === "done")).toHaveLength(1);
214
214
  expect(selection.summary).toBe("… 2 more todos");
215
215
  });
@@ -221,54 +221,19 @@ describe("selectCollapsedTodos matches omp", () => {
221
221
  task("w3", "in_progress"),
222
222
  task("p", "pending"),
223
223
  ];
224
- const selection = selectCollapsedTodos(tasks, never, 2);
224
+ const selection = selectCollapsedTodos(tasks, 2);
225
225
  expect(selection.items.map(t => t.content)).toEqual(["w1", "w2"]);
226
226
  expect(selection.summary).toBe("… 1 more active todo");
227
227
  });
228
228
 
229
229
  it("a settled phase selects over its closed tasks", () => {
230
230
  const tasks = [task("a", "completed"), task("b", "abandoned"), task("c", "completed")];
231
- const selection = selectCollapsedTodos(tasks, never, 8);
231
+ const selection = selectCollapsedTodos(tasks, 8);
232
232
  expect(selection.items).toEqual(tasks);
233
233
  expect(selection.summary).toBe("");
234
234
  });
235
235
  });
236
236
 
237
- describe("todoMatchesAnyDescription matches omp", () => {
238
- it("matches normalized equality and ≥6-char substrings both ways", () => {
239
- expect(todoMatchesAnyDescription("Fix the build", ["fix the build!"])).toBe(true);
240
- expect(todoMatchesAnyDescription("Sonnet #2: bug scan", ["Sonnet #2"])).toBe(true);
241
- expect(todoMatchesAnyDescription("review", ["code review phase"])).toBe(true);
242
- expect(todoMatchesAnyDescription("test", ["testing"])).toBe(false); // <6 overlap
243
- expect(todoMatchesAnyDescription("", ["anything"])).toBe(false);
244
- });
245
- });
246
-
247
- // ---------------------------------------------------------------------------
248
- // Stop-reminder guards — omp TodoTracker
249
- // ---------------------------------------------------------------------------
250
-
251
- describe("isAwaitingUserAnswer matches omp", () => {
252
- it("detects question endings (word gates + non-ASCII)", () => {
253
- expect(isAwaitingUserAnswer("Done for now.\nWhich database should I use?")).toBe(true);
254
- expect(isAwaitingUserAnswer("Should I proceed?")).toBe(true);
255
- expect(isAwaitingUserAnswer("要继续吗?")).toBe(true); // CJK question
256
- expect(isAwaitingUserAnswer("Q1: ready?")).toBe(true); // prompt label
257
- });
258
-
259
- it("detects response cues", () => {
260
- expect(isAwaitingUserAnswer("Please confirm the plan.")).toBe(true);
261
- expect(isAwaitingUserAnswer("Let me know if that works")).toBe(true);
262
- });
263
-
264
- it("ignores prose with incidental question marks", () => {
265
- // No question word/pronoun/non-ASCII gate fires; omp treats it as prose.
266
- expect(isAwaitingUserAnswer("Fixed the optional foo?: string handling.")).toBe(false);
267
- expect(isAwaitingUserAnswer("All checks pass.")).toBe(false);
268
- expect(isAwaitingUserAnswer("")).toBe(false);
269
- });
270
- });
271
-
272
237
  // ---------------------------------------------------------------------------
273
238
  // formatSummary — omp tools/todo.ts (tool result body)
274
239
  // ---------------------------------------------------------------------------
@@ -313,14 +278,14 @@ describe("formatSummary matches omp verbatim", () => {
313
278
  );
314
279
  });
315
280
 
316
- it("renders the worked-ahead explanation (omp backward-pointer note)", () => {
281
+ it("renders the worked-ahead explanation (out-of-order completion note)", () => {
317
282
  const phases: TodoPhase[] = [
318
283
  { name: "A", tasks: [{ content: "a", status: "pending" }] },
319
284
  { name: "B", tasks: [{ content: "b", status: "completed" }] },
320
285
  ];
321
286
  const out = formatSummary(phases, []);
322
287
  expect(out).toContain(
323
- 'Active phase 1/2 "A" (0/1) — 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).',
288
+ 'Active phase 1/2 "A" (0/1) — earliest phase with open tasks; later phases may already hold completed work from out-of-order execution (nothing was un-completed).',
324
289
  );
325
290
  });
326
291
 
@@ -343,27 +308,3 @@ describe("pluralize matches omp", () => {
343
308
  expect(pluralize("entry", 2)).toBe("entries");
344
309
  });
345
310
  });
346
-
347
- // ---------------------------------------------------------------------------
348
- // normalizeInProgressTask invariant (omp applyParams tail)
349
- // ---------------------------------------------------------------------------
350
-
351
- describe("normalizeInProgressTask matches omp", () => {
352
- it("demotes surplus in-progress tasks", () => {
353
- const phases: TodoPhase[] = [
354
- { name: "A", tasks: [task("a", "in_progress"), task("b", "in_progress")] },
355
- ];
356
- normalizeInProgressTask(phases);
357
- expect(phases[0]?.tasks.map(t => t.status)).toEqual(["in_progress", "pending"]);
358
- });
359
-
360
- it("auto-promotes the earliest pending when none is in progress", () => {
361
- const phases: TodoPhase[] = [
362
- { name: "A", tasks: [task("a", "completed"), task("b", "pending")] },
363
- { name: "B", tasks: [task("c", "pending")] },
364
- ];
365
- normalizeInProgressTask(phases);
366
- expect(phases[0]?.tasks[1]?.status).toBe("in_progress");
367
- expect(phases[1]?.tasks[0]?.status).toBe("pending");
368
- });
369
- });
@@ -140,7 +140,8 @@ describe("todo render: result (omp renderer)", () => {
140
140
  });
141
141
 
142
142
  it("computeTouchedPhases consumes the call args via the render context (omp args fold)", () => {
143
- // done on phase Two: One is untouched → one-line summary; Two expands.
143
+ // done on phase Two: Two is touched (completion + op phase); One is the
144
+ // earliest phase with open work, so it stays expanded too (no pointer).
144
145
  const phases = [phase("One", ["pending"]), phase("Two", ["completed", "pending"])];
145
146
  const completed: NonNullable<TodoToolDetails["completedTasks"]> = [{ phase: "Two", content: "two-1" }];
146
147
  const out = text(
@@ -152,7 +153,8 @@ describe("todo render: result (omp renderer)", () => {
152
153
  ),
153
154
  );
154
155
  expect(out).toContain("I. One 0/1");
155
- expect(out).not.toContain("one-1");
156
+ // Active phase (earliest open work) never collapses to a summary line.
157
+ expect(out).toContain("one-1");
156
158
  expect(out).toContain("II. Two 1/2");
157
159
  expect(out).toContain("two-2");
158
160
  });
@@ -7,8 +7,6 @@ import {
7
7
  inferTodoOp,
8
8
  isTodoPhase,
9
9
  isTodoPhaseSnapshot,
10
- normalizeInProgressTask,
11
- openTasks,
12
10
  type TodoOpEntry,
13
11
  type TodoPhase,
14
12
  } from "../src/state.ts";
@@ -18,7 +16,7 @@ function phasesWithTasks(...entries: Array<[string, ...string[]]>): TodoPhase[]
18
16
  }
19
17
 
20
18
  describe("todo state: init", () => {
21
- it("installs a phased list, all tasks pending, in order", () => {
19
+ it("installs a phased list, all tasks pending, in order (no auto-promotion)", () => {
22
20
  const { phases, errors } = applyParams(
23
21
  [],
24
22
  {
@@ -31,8 +29,8 @@ describe("todo state: init", () => {
31
29
  );
32
30
  expect(errors).toEqual([]);
33
31
  expect(phases.map(p => p.name)).toEqual(["Foundation", "Verify"]);
34
- // normalizeInProgressTask auto-promotes the earliest pending task.
35
- expect(phases[0].tasks[0]).toEqual({ content: "Scaffold", status: "in_progress" });
32
+ // Statuses change only through explicit ops: nothing auto-promotes.
33
+ expect(phases[0].tasks[0]).toEqual({ content: "Scaffold", status: "pending" });
36
34
  expect(phases[0].tasks[1].status).toBe("pending");
37
35
  expect(phases[1].tasks[0].status).toBe("pending");
38
36
  });
@@ -73,7 +71,7 @@ describe("todo state: init", () => {
73
71
  {
74
72
  name: "Tasks",
75
73
  tasks: [
76
- { content: "one", status: "in_progress" },
74
+ { content: "one", status: "pending" },
77
75
  { content: "two", status: "pending" },
78
76
  ],
79
77
  },
@@ -82,14 +80,15 @@ describe("todo state: init", () => {
82
80
  });
83
81
 
84
82
  describe("todo state: start", () => {
85
- it("demotes every other in_progress task back to pending", () => {
83
+ it("marks the target in progress without touching other tasks (no invariant)", () => {
86
84
  const phases: TodoPhase[] = [
87
85
  { name: "A", tasks: [{ content: "a1", status: "in_progress" }] },
88
86
  { name: "B", tasks: [{ content: "b1", status: "pending" }] },
89
87
  ];
90
88
  const { phases: updated, errors } = applyParams(phases, { op: "start", task: "b1" });
91
89
  expect(errors).toEqual([]);
92
- expect(updated[0].tasks[0].status).toBe("pending");
90
+ // Multiple in_progress tasks are allowed: statuses are explicit-only.
91
+ expect(updated[0].tasks[0].status).toBe("in_progress");
93
92
  expect(updated[1].tasks[0].status).toBe("in_progress");
94
93
  });
95
94
 
@@ -274,60 +273,30 @@ describe("todo state: batch application", () => {
274
273
  ];
275
274
  const { phases: updated, errors } = applyOpsToPhases(phases, [{ op: "rm", phase: "A" }, { op: "append", phase: "A", items: ["fresh"] }]);
276
275
  expect(errors).toEqual([]);
277
- expect(updated[0].tasks).toEqual([{ content: "fresh", status: "in_progress" }]);
276
+ expect(updated[0].tasks).toEqual([{ content: "fresh", status: "pending" }]);
278
277
  });
279
278
  });
280
279
 
281
- describe("todo state: normalization", () => {
282
- it("demotes surplus in_progress tasks", () => {
283
- const phases: TodoPhase[] = [
284
- {
285
- name: "A",
286
- tasks: [
287
- { content: "a1", status: "in_progress" },
288
- { content: "a2", status: "in_progress" },
289
- ],
290
- },
291
- ];
292
- normalizeInProgressTask(phases);
293
- expect(phases[0].tasks.map(t => t.status)).toEqual(["in_progress", "pending"]);
294
- });
295
-
296
- it("auto-promotes the earliest pending task when none is in progress", () => {
280
+ describe("todo state: explicit-status semantics (no normalization)", () => {
281
+ it("completion never auto-promotes another task", () => {
297
282
  const phases: TodoPhase[] = [
298
283
  { name: "A", tasks: [{ content: "a1", status: "completed" }] },
299
284
  { name: "B", tasks: [{ content: "b1", status: "pending" }] },
300
285
  ];
301
- normalizeInProgressTask(phases);
302
- expect(phases[1].tasks[0].status).toBe("in_progress");
286
+ const { phases: updated } = applyParams(clonePhases(phases), { op: "done", task: "b1" });
287
+ expect(updated[1].tasks[0].status).toBe("completed");
288
+ // No hidden pointer: no task was touched besides the target.
289
+ expect(updated[0].tasks[0].status).toBe("completed");
303
290
  });
304
291
 
305
- it("out-of-order completion can move the pointer back to an earlier phase", () => {
306
- let { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "done", task: "a1" });
307
- ({ phases } = applyParams(phases, { op: "done", task: "b1" }));
308
- // b1 done; pointer returns to a2 (earliest open task).
309
- expect(phases[0].tasks[1].status).toBe("in_progress");
292
+ it("out-of-order completion leaves every other status untouched", () => {
293
+ let { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "done", task: "b1" });
294
+ expect(phases[0].tasks.map(t => t.status)).toEqual(["pending", "pending"]);
310
295
  expect(phases[1].tasks[0].status).toBe("completed");
311
296
  });
312
297
  });
313
298
 
314
299
  describe("todo state: helpers", () => {
315
- it("counts actionable open tasks, excluding settled and blocked work", () => {
316
- const phases: TodoPhase[] = [
317
- {
318
- name: "A",
319
- tasks: [
320
- { content: "a1", status: "completed" },
321
- { content: "a2", status: "abandoned" },
322
- { content: "a3", status: "pending" },
323
- { content: "a4", status: "in_progress" },
324
- { content: "a5", status: "blocked", blocker: "ci" },
325
- ],
326
- },
327
- ];
328
- expect(openTasks(phases).map(t => t.content)).toEqual(["a3", "a4"]);
329
- });
330
-
331
300
  it("validates phase snapshots for restore", () => {
332
301
  expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "pending" }] })).toBe(true);
333
302
  expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "bogus" }] })).toBe(false);
@@ -363,6 +332,36 @@ describe("todo state: summary text", () => {
363
332
  expect(formatSummary([], ["boom"])).toBe("Errors: boom");
364
333
  expect(formatSummary([], [])).toBe("Todo list cleared.");
365
334
  });
335
+
336
+ it("folds the per-task dumps on big mutation results but view still echoes in full", () => {
337
+ // 25 tasks (> SUMMARY_FULL_LIST_LIMIT): 5 done, 20 open.
338
+ const statuses: TodoPhase[] = [
339
+ {
340
+ name: "Big",
341
+ tasks: Array.from({ length: 25 }, (_, i) => ({
342
+ content: `task ${i + 1}`,
343
+ status: (i < 5 ? "completed" : "pending") as TodoPhase["tasks"][number]["status"],
344
+ })),
345
+ },
346
+ ];
347
+
348
+ // Mutation result: remaining capped at 10 + fold hints, no phase checklist.
349
+ const mutation = formatSummary(statuses, []);
350
+ expect(mutation).toContain("Remaining items (20):");
351
+ expect(mutation).toContain("- task 6 [pending] (Big)");
352
+ expect(mutation).toContain("… 10 more open — call `view` for the full list.");
353
+ expect(mutation).toContain("Overall: 5/25 done, 20 open.");
354
+ expect(mutation).toContain("Full checklist omitted (25 tasks) — `view` echoes it.");
355
+ expect(mutation).not.toContain(" Big:");
356
+ expect(mutation).not.toContain("- task 16 [pending]");
357
+
358
+ // view (readOnly): full echo, no folding, no omission notice.
359
+ const view = formatSummary(statuses, [], true);
360
+ expect(view).toContain("- task 25 [pending] (Big)");
361
+ expect(view).toContain("[X] task 1");
362
+ expect(view).not.toContain("Full checklist omitted");
363
+ expect(view).not.toContain("more open — call");
364
+ });
366
365
  });
367
366
 
368
367
  describe("todo state: params type", () => {