@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/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [1.0.1] - 2026-09-12
4
+
5
+ ### Breaking Changes
6
+
7
+ - Cognitive-neutral refactor: pi-todo is now a neutral stateful notepad — statuses change only through explicit ops, and the list never steers the agent. Removed: eager-todo `promptGuidelines` (no more system-prompt nudges to plan first), the stop-reminder nag loop (`checkCompletion` / `⚠ N incomplete todos` transcript box / 3-attempt auto-continuation cycle), and the manual-edit `<system-reminder>` injection after `/todo` mutations. Motivation: the reminder loop plus the "or mark them complete" wording gave the agent a cheap escape hatch (tick boxes to silence the nag), and the plan-first nudges encouraged executing shallow 5–10-word labels without re-derivation.
8
+ - Removed `promptSnippet` from the tool definition (host docs allow omission): the tool no longer appears in the default system prompt's `Available tools` section, making the "no prompt steering" claim literal.
9
+ - Removed the single-`in_progress` invariant and the auto-promotion pointer (`normalizeInProgressTask`): `start` no longer demotes other `in_progress` tasks, completions never auto-promote a pending task, and multiple `in_progress` tasks are allowed. The five-status enum is unchanged (pi-goal's progress counting is unaffected).
10
+ - Removed the unused subagent description matcher (`todoMatchesAnyDescription`) and the `openTasks` helper, plus the now-parameterless `isMatched` viewport pipeline (`selectCollapsedTodos(tasks, cap)`).
11
+
12
+ ### Changed
13
+
14
+ - Mutation results prepend a `Changed:` block listing every status transition (`- task [pending → blocked] (blocked: note) (phase)`), so a write is confirmed per-task even when the summary folds on lists over 20 tasks.
15
+ - Error results no longer fold: the thrown batch-error message always carries the full unchanged list, restoring the documented retry contract on big lists.
16
+ - The transcript renderer keeps the earliest open-work phase expanded (the active phase the summary reports) instead of relying on the removed auto-promotion pointer; stale pointer wording in renderer comments updated.
17
+ - Tool description (`todo.md`) reduced to mechanical documentation (ops, anatomy, lookup-key rules); all behavioral steering wording removed, including the earlier anti-laziness rules (evidence-gated `done`, re-plan license) — with the nag loop gone their premise no longer holds, and the notepad stays neutral.
18
+ - `formatSummary`'s worked-ahead note no longer references the removed auto-advancing pointer.
19
+ - Markdown round-trip no longer normalizes statuses on parse (`markdownToPhases`): `/todo edit` and `import` now restore statuses exactly as written.
20
+ - `formatSummary` folds its per-task dumps on mutation results once the list exceeds 20 tasks: remaining items capped at the first 10 with a `… N more open — call view` hint plus a single "Full checklist omitted" line. `view` (readOnly) still echoes the whole list. On big lists every `done`/`start` previously re-flooded the model context with the full checklist.
21
+ - `execute` clones phases 4 times per mutation instead of 8 (ownership handoff to the closure; persist/broadcast make their own entry-shaped snapshots); `getCompletionTransitions` generalized to `getStatusTransitions` whose result is now rendered instead of only feeding `completedTasks`.
22
+ - pi-goal's `goal-todo-context.md` no longer references the removed pointer/"stale in_progress" semantics (paired fix in @fyeeme/pi-goal).
4
23
 
5
24
  ## [1.0.0] - 2026-09-09
6
25
 
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Phased todo lists for [pi](https://github.com/earendil-works/pi-coding-agent) — the oh-my-pi todo tool migrated to a pi extension.
4
4
 
5
- The agent gets a `todo` tool: a phased task list (phases → tasks with lifecycle statuses) persisted with the session. You get a `/todo` command, a transcript reminder when the agent stops with unfinished work, and a `todo_updated` event other extensions can consume (pi-goal uses it to attach live progress state to goal continuations).
5
+ The agent gets a `todo` tool: a phased task list (phases → tasks with lifecycle statuses) persisted with the session. You get a `/todo` command and a `todo_updated` event other extensions can consume (pi-goal uses it to attach live progress state to goal continuations).
6
+
7
+ The extension is a **cognitive-neutral notepad**: statuses change only through explicit ops, and none of the omp behavior-engineering survives — no system-prompt nudges, no stop-time nag loop, no hidden reminders. The list records state; it never steers the agent.
6
8
 
7
9
  ## Install
8
10
 
@@ -19,7 +21,7 @@ Nine operations over `phases: [{ phase, items: string[] }]` state:
19
21
  | `op` | Fields | Effect |
20
22
  | ---------- | ------------------------- | --------------------------------------------------------- |
21
23
  | `init` | `list` (or flat `items`) | Replace the whole list; all tasks start `pending` |
22
- | `start` | `task` | Mark `in_progress`; demotes every other `in_progress` task |
24
+ | `start` | `task` | Mark `in_progress` (explicit; other tasks untouched) |
23
25
  | `done` | `task` or `phase` | Mark `completed` |
24
26
  | `drop` | `task` or `phase` | Mark `abandoned` (never deleted) |
25
27
  | `block` | `task` or `phase`, `reason?` | Park open work awaiting external input; one-line reason |
@@ -28,7 +30,7 @@ Nine operations over `phases: [{ phase, items: string[] }]` state:
28
30
  | `append` | `phase`, `items` | Add `pending` tasks; lazily creates the phase |
29
31
  | `view` | — | Read-only snapshot |
30
32
 
31
- Semantics kept verbatim from oh-my-pi: batch-atomic duplicate rejection (a failing op applies nothing), a single `in_progress` invariant with the earliest pending task auto-promoted on every mutation, `block` never reopens finished work, `view` never writes.
33
+ Semantics ported from oh-my-pi: batch-atomic duplicate rejection (a failing op applies nothing), `block` never reopens finished work, `view` never writes. Deliberate deviation: the single-`in_progress` invariant and the auto-promotion pointer are removed — statuses are set only by explicit ops, and multiple `in_progress` tasks are allowed.
32
34
 
33
35
  ## Command
34
36
 
@@ -47,12 +49,6 @@ The full oh-my-pi `/todo` verb set:
47
49
  /todo rm [<task|phase>] Remove task/phase/all
48
50
  ```
49
51
 
50
- Every mutation commits a `<system-reminder>` hidden message telling the agent the user manually modified the list (with explicit intent notes after removals, so it never rebuilds cleared items).
51
-
52
- ## Stop reminders
53
-
54
- When an agent run ends while tasks are still `pending`/`in_progress`, oh-my-pi's reminder loop kicks in: a hidden `<system-reminder>` message ("You stopped with N incomplete todo item(s)... (Reminder X/3)") is queued as a follow-up turn so the agent continues or marks work done, and a `⚠ N incomplete todos - reminder X/3` note is anchored in the transcript. The cycle allows 3 reminders, restarts on each new user prompt, and stays silent when the assistant's last line is a question to the user (the ball is in your court) or when only `blocked` tasks remain — those are parked awaiting external input.
55
-
56
52
  ## Event contract
57
53
 
58
54
  After every successful mutation:
@@ -76,9 +72,12 @@ Source: `oh-my-pi/packages/coding-agent/src/tools/todo.ts` (plus reminder, slash
76
72
  | omp surface | Status in pi-todo |
77
73
  | ------------------------------------------ | -------------------------------------------------------- |
78
74
  | mid-run todo nudge (tool-choice queue) | Dropped — omp host-internal |
79
- | eager-todo / prewalk system-prompt arming | Replaced by `promptGuidelines` on the tool definition |
80
- | `/todo edit` + markdown round-trip | Dropped — no host editor surface |
81
- | Sticky HUD / collapsed viewport / animations| Dropped — omp TUI-internal |
75
+ | eager-todo / prewalk system-prompt arming | Dropped — cognitive-neutral refactor (no prompt steering) |
76
+ | Stop-reminder loop (checkCompletion, ×3 nag)| Dropped — cognitive-neutral refactor (no auto-continuation)|
77
+ | Manual-edit `<system-reminder>` injection | Dropped — cognitive-neutral refactor |
78
+ | Single `in_progress` invariant / auto-promotion pointer | Dropped — statuses are explicit-only |
79
+ | `/todo edit` + markdown round-trip | Kept — via `ctx.ui.editor` |
80
+ | Sticky HUD / collapsed viewport / animations| Kept (viewport) in the transcript renderer; HUD dropped |
82
81
  | Subagent todo-match lighting | Dropped — no subagent HUD contract |
83
82
  | `todo.enabled` settings gate | Dropped — no settings API by design: installed means active |
84
83
 
package/index.ts CHANGED
@@ -6,9 +6,6 @@
6
6
  * - packages/coding-agent/src/tools/todo.ts (TodoTool) → src/tool.ts
7
7
  * - packages/coding-agent/src/tools/todo.ts (renderer) → src/render.ts
8
8
  * - packages/coding-agent/src/prompts/tools/todo.md → src/prompts/todo.md
9
- * - packages/coding-agent/src/session/todo-tracker.ts (checkCompletion)
10
- * → agent_end reminder loop
11
- * - packages/coding-agent/src/modes/components/todo-reminder.ts → reminder entry renderer
12
9
  * - packages/coding-agent/src/modes/controllers/todo-command-controller.ts
13
10
  * → src/commands.ts
14
11
  *
@@ -28,65 +25,28 @@
28
25
  * (missing-op inference before validation)
29
26
  * concurrency: "exclusive" → executionMode: "sequential"
30
27
  * mergeCallAndResult renderer → split renderCall/renderResult slots
31
- * eager-todo system-prompt prewalk → promptGuidelines on the tool definition
32
- * developer-role injected messages → pi.sendMessage display:false (custom
33
- * role; both /todo edits and stop
34
- * reminders)
35
- * TodoTracker.checkCompletion → agent_end handler: same reminder text,
36
- * 3-attempt cycle, question guard, and
37
- * followUp-triggered continuation
38
- * TodoReminderComponent (TUI box) → "todo-reminder" entry renderer
39
- * (transcript-anchored, same text)
40
- * $EDITOR round-trip → ctx.ui.editor (Markdown round-trip)
41
- * mid-run nudge (tool-choice queue) → dropped (omp host-internal)
42
- * subagent-match lighting → dropped (omp host-internal provider)
43
28
  *
44
- * omp operation semantics are kept verbatim: batch-atomic duplicate
45
- * rejection, single in_progress invariant with earliest-pending auto-promotion,
46
- * drop = abandoned (never delete), block skips finished work, view is a read.
29
+ * Removed in the cognitive-neutral refactor (agent-steering logic, not
30
+ * bookkeeping): eager-todo promptGuidelines, the stop-reminder nag loop
31
+ * (checkCompletion / TodoReminderComponent), and the manual-edit
32
+ * `<system-reminder>` injection. The todo list is a neutral stateful notepad:
33
+ * statuses change only through explicit ops.
47
34
  *
48
35
  * Integration contract for other extensions:
49
36
  * - `pi.events.emit("todo_updated", { phases })` after every successful
50
- * mutation (never on view or failure)
51
- * - stop reminders count pending + in_progress only (blocked is parked)
37
+ * mutation (never on view or failure); full snapshot, five-status enum
38
+ * - every mutation appends a "todo-phases" snapshot entry (pi-goal's
39
+ * todo bridge and restore both read it)
52
40
  */
53
41
 
54
42
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
55
- import { Text, type Component } from "@earendil-works/pi-tui";
56
43
  import { createTodoCommand } from "./src/commands.ts";
57
- import { isAwaitingUserAnswer, type TodoItem, type TodoPhase } from "./src/state.ts";
44
+ import { clonePhases, type TodoPhase } from "./src/state.ts";
58
45
  import { createTodoTool } from "./src/tool.ts";
59
- import {
60
- TODO_PHASES_ENTRY_TYPE,
61
- TODO_REMINDER_ENTRY_TYPE,
62
- restorePhasesFromEntries,
63
- } from "./src/restore.ts";
64
-
65
- /** omp settings todo.remindersMax default (3) — extensions have no settings. */
66
- const REMINDERS_MAX = 3;
67
-
68
- interface AssistantTextLike {
69
- role?: string;
70
- content?: unknown;
71
- }
72
-
73
- function assistantText(message: AssistantTextLike): string {
74
- if (!Array.isArray(message.content)) return "";
75
- return message.content
76
- .filter((block): block is { type: string; text?: string } =>
77
- !!block && typeof block === "object" && (block as { type?: unknown }).type === "text",
78
- )
79
- .map(block => block.text ?? "")
80
- .join("\n")
81
- .trim();
82
- }
46
+ import { TODO_PHASES_ENTRY_TYPE, restorePhasesFromEntries } from "./src/restore.ts";
83
47
 
84
48
  export default function piTodoExtension(pi: ExtensionAPI): void {
85
49
  let phases: TodoPhase[] = [];
86
- /** omp TodoTracker #reminderCount: reminders sent in the current cycle. */
87
- let reminderCount = 0;
88
- /** Last assistant reply text, for omp's awaiting-user-answer guard. */
89
- let lastAssistantText = "";
90
50
 
91
51
  const deps = {
92
52
  getPhases: () => phases,
@@ -94,14 +54,10 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
94
54
  phases = next;
95
55
  },
96
56
  persist: (next: TodoPhase[]) => {
97
- pi.appendEntry(TODO_PHASES_ENTRY_TYPE, { phases: clonePhasesForEntry(next) });
57
+ pi.appendEntry(TODO_PHASES_ENTRY_TYPE, { phases: clonePhases(next) });
98
58
  },
99
59
  broadcast: (next: TodoPhase[]) => {
100
- pi.events.emit("todo_updated", { phases: clonePhasesForEntry(next) });
101
- },
102
- // omp #commit injects a developer system-reminder after user edits.
103
- sendHiddenMessage: (content: string) => {
104
- pi.sendMessage({ customType: "todo-user-edit", content, display: false });
60
+ pi.events.emit("todo_updated", { phases: clonePhases(next) });
105
61
  },
106
62
  };
107
63
 
@@ -118,105 +74,5 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
118
74
  // /fork or /tree navigation; getEntries() would let a later snapshot
119
75
  // from an abandoned branch win the backward scan.
120
76
  phases = restorePhasesFromEntries(ctx.sessionManager.getBranch());
121
- reminderCount = 0;
122
- });
123
-
124
- pi.on("message_start", (event) => {
125
- // omp resetCycle: a fresh user prompt starts a new reminder cycle.
126
- if ((event.message as { role?: string }).role === "user") {
127
- reminderCount = 0;
128
- }
129
77
  });
130
-
131
- pi.on("agent_end", (event) => {
132
- // Capture the last assistant text for the question guard BEFORE the
133
- // reminder decision (omp checkCompletion sees the terminal message).
134
- const messages = (event.messages ?? []) as AssistantTextLike[];
135
- for (let i = messages.length - 1; i >= 0; i--) {
136
- const message = messages[i];
137
- if (message?.role === "assistant") {
138
- lastAssistantText = assistantText(message);
139
- break;
140
- }
141
- }
142
- checkCompletion();
143
- });
144
-
145
- /** omp TodoTracker.checkCompletion: nag + auto-continue on open todos. */
146
- function checkCompletion(): void {
147
- if (reminderCount >= REMINDERS_MAX) return;
148
- if (phases.length === 0) {
149
- reminderCount = 0;
150
- return;
151
- }
152
- const incompleteByPhase = phases
153
- .map(phase => ({
154
- name: phase.name,
155
- tasks: phase.tasks.filter(task => task.status === "pending" || task.status === "in_progress"),
156
- }))
157
- .filter(phase => phase.tasks.length > 0);
158
- const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
159
- if (incomplete.length === 0) {
160
- reminderCount = 0;
161
- return;
162
- }
163
- // omp isAwaitingUserAnswer: the assistant ended by asking the user
164
- // something — skip the reminder, the ball is in the user's court.
165
- if (isAwaitingUserAnswer(lastAssistantText)) return;
166
-
167
- reminderCount++;
168
- const todoList = incompleteByPhase
169
- .map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
170
- .join("\n");
171
- const reminder =
172
- `<system-reminder>\n` +
173
- `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
174
- `Please continue working on these tasks or mark them complete if finished.\n` +
175
- `(Reminder ${reminderCount}/${REMINDERS_MAX})\n` +
176
- `</system-reminder>`;
177
- // omp: append developer message + scheduleAgentContinue.
178
- pi.sendMessage(
179
- { customType: "todo-reminder", content: reminder, display: false },
180
- { triggerTurn: true, deliverAs: "followUp" },
181
- );
182
- pi.appendEntry(TODO_REMINDER_ENTRY_TYPE, {
183
- count: incomplete.length,
184
- attempt: reminderCount,
185
- maxAttempts: REMINDERS_MAX,
186
- todos: incomplete.map(task => ({ content: task.content, status: task.status })),
187
- });
188
- }
189
-
190
- // omp TodoReminderComponent: warning box committed into the transcript —
191
- // `⚠ N incomplete todos - reminder X/Y` + italic unchecked list.
192
- pi.registerEntryRenderer<{
193
- count?: number;
194
- openCount?: number;
195
- attempt?: number;
196
- maxAttempts?: number;
197
- todos?: TodoItem[];
198
- }>(TODO_REMINDER_ENTRY_TYPE, (entry, _options, theme) => {
199
- const data = entry.data;
200
- const count = typeof data?.count === "number" ? data.count : typeof data?.openCount === "number" ? data.openCount : 0;
201
- const attempt = typeof data?.attempt === "number" ? data.attempt : 1;
202
- const maxAttempts = typeof data?.maxAttempts === "number" ? data.maxAttempts : REMINDERS_MAX;
203
- const label = count === 1 ? "todo" : "todos";
204
- const header = `⚠ ${count} incomplete ${label} - reminder ${attempt}/${maxAttempts}`;
205
- const todos = Array.isArray(data?.todos) ? data.todos : [];
206
- const list = todos.length > 0 ? `\n\n${theme.italic(todos.map(todo => ` ☐ ${todo.content}`).join("\n"))}` : "";
207
- const component: Component = new Text(theme.fg("warning", header) + list, 0, 0);
208
- return component;
209
- });
210
- }
211
-
212
- /** Defensive clone for entries/events (omp TodoTracker.#clonePhases). */
213
- function clonePhasesForEntry(phases: TodoPhase[]): TodoPhase[] {
214
- return phases.map(phase => ({
215
- name: phase.name,
216
- tasks: phase.tasks.map(task =>
217
- task.blocker !== undefined
218
- ? { content: task.content, status: task.status, blocker: task.blocker }
219
- : { content: task.content, status: task.status },
220
- ),
221
- }));
222
78
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-todo",
3
- "version": "1.0.0",
4
- "description": "Phased todo list tool for pi — nine operations (init/start/done/rm/drop/block/unblock/append/view) over phase/task state persisted with the session, a /todo command, stop-time reminders for unfinished work, and a todo_updated event bus broadcast for other extensions.",
3
+ "version": "1.0.1",
4
+ "description": "Phased todo list tool for pi — nine operations (init/start/done/rm/drop/block/unblock/append/view) over phase/task state persisted with the session, a /todo command, and a todo_updated event bus broadcast for other extensions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "fyeeme",
package/src/commands.ts CHANGED
@@ -3,15 +3,14 @@
3
3
  *
4
4
  * Ported from oh-my-pi `todo-command-controller.ts` (+ its ACP helper in
5
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.
6
+ * start, done, drop, rm, help — with quote-aware tokenizing and fuzzy
7
+ * task/phase matching. The omp developer system-reminder injection after
8
+ * manual edits was removed in the cognitive-neutral refactor.
9
9
  *
10
10
  * Host-internal surfaces map to pi:
11
11
  * $EDITOR round-trip → ctx.ui.editor (prefilled Markdown)
12
12
  * clipboard copy → printed (extension API has no clipboard write;
13
13
  * mirrors omp's own ACP fallback text)
14
- * developer message → pi.sendMessage display:false (custom role)
15
14
  * user_todo_edit entry → "todo-phases" snapshot (pi-todo's documented
16
15
  * entry contract, read back by restore and
17
16
  * pi-goal's todo bridge)
@@ -21,21 +20,18 @@ import { readFileSync, writeFileSync } from "node:fs";
21
20
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
21
  import {
23
22
  type TodoItem,
23
+ type TodoOpEntry,
24
24
  applyOpsToPhases,
25
+ clonePhases,
25
26
  type TodoPhase,
26
27
  markdownToPhases,
27
28
  phasesToMarkdown,
28
29
  resolveTodoMarkdownPath,
29
30
  } from "./state.ts";
31
+ import { commitPhases, type TodoToolDeps } from "./tool.ts";
30
32
 
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
- }
33
+ /** Same dependency contract as the tool path (single write-back invariant). */
34
+ export type TodoCommandDeps = TodoToolDeps;
39
35
 
40
36
  const USAGE = [
41
37
  "Usage: /todo <verb> [args]",
@@ -144,24 +140,6 @@ function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | unde
144
140
  return undefined;
145
141
  }
146
142
 
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
143
  // =============================================================================
166
144
  // Command handler
167
145
  // =============================================================================
@@ -226,15 +204,6 @@ function emit(ctx: ExtensionCommandContext, text: string, severity: "info" | "wa
226
204
  console.error(text);
227
205
  }
228
206
 
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
207
  function showCurrent(deps: TodoCommandDeps, ctx: ExtensionCommandContext): void {
239
208
  const phases = deps.getPhases();
240
209
  if (phases.length === 0) {
@@ -282,7 +251,7 @@ async function importFromFile(deps: TodoCommandDeps, ctx: ExtensionCommandContex
282
251
  emit(ctx, `Could not parse ${source}:\n ${errors.join("\n ")}`, "error");
283
252
  return;
284
253
  }
285
- commit(deps, phases, `/todo import ${source}`);
254
+ commitPhases(deps, phases);
286
255
  const taskCount = phases.reduce((sum, p) => sum + p.tasks.length, 0);
287
256
  emit(ctx, `Imported ${phases.length} phase(s), ${taskCount} task(s) from ${source}.`);
288
257
  }
@@ -305,7 +274,7 @@ function append(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
305
274
  content = tokens.slice(1).join(" ");
306
275
  }
307
276
 
308
- const next = current.map(phase => ({ ...phase, tasks: phase.tasks.slice() }));
277
+ const next = clonePhases(current);
309
278
  let targetPhase: TodoPhase;
310
279
 
311
280
  if (phaseName) {
@@ -322,7 +291,7 @@ function append(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
322
291
  const finalContent = titleCaseSentence(content);
323
292
  targetPhase.tasks.push({ content: finalContent, status: "pending" });
324
293
 
325
- commit(deps, next, `/todo append → ${targetPhase.name}`);
294
+ commitPhases(deps, next);
326
295
  emit(ctx, `Appended to ${targetPhase.name}: ${finalContent}`);
327
296
  }
328
297
 
@@ -331,19 +300,30 @@ function start(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string
331
300
  emit(ctx, "Usage: /todo start <task>", "error");
332
301
  return;
333
302
  }
334
- const current = deps.getPhases();
335
- const hit = findTaskFuzzy(current, rest);
303
+ const hit = findTaskFuzzy(deps.getPhases(), rest);
336
304
  if (!hit) {
337
305
  emit(ctx, `No task matched "${rest}". Use /todo to list current tasks.`, "error");
338
306
  return;
339
307
  }
340
- const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
308
+ applyAndCommit(deps, ctx, [{ op: "start", task: hit.task.content }], `Started: ${hit.task.content}`);
309
+ }
310
+
311
+ /** Shared tail of every mutating verb: apply ops, commit on success, report.
312
+ * A failing batch is discarded wholesale (state unchanged) and its errors
313
+ * surface as one error line. */
314
+ function applyAndCommit(
315
+ deps: TodoCommandDeps,
316
+ ctx: ExtensionCommandContext,
317
+ ops: TodoOpEntry[],
318
+ successMessage: string,
319
+ ): void {
320
+ const { phases, errors } = applyOpsToPhases(deps.getPhases(), ops);
341
321
  if (errors.length > 0) {
342
322
  emit(ctx, errors.join("; "), "error");
343
323
  return;
344
324
  }
345
- commit(deps, phases, `/todo start ${hit.task.content}`);
346
- emit(ctx, `Started: ${hit.task.content}`);
325
+ commitPhases(deps, phases);
326
+ emit(ctx, successMessage);
347
327
  }
348
328
 
349
329
  function mutateStatus(
@@ -357,37 +337,19 @@ function mutateStatus(
357
337
  const trimmedArg = rest.trim();
358
338
  if (!trimmedArg) {
359
339
  // 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}.`);
340
+ applyAndCommit(deps, ctx, [{ op }], `Marked all tasks ${target}.`);
367
341
  return;
368
342
  }
369
343
 
370
344
  const taskHit = findTaskFuzzy(current, trimmedArg);
371
345
  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}`);
346
+ applyAndCommit(deps, ctx, [{ op, task: taskHit.task.content }], `Marked ${target}: ${taskHit.task.content}`);
379
347
  return;
380
348
  }
381
349
 
382
350
  const phaseHit = findPhaseFuzzy(current, trimmedArg);
383
351
  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}.`);
352
+ applyAndCommit(deps, ctx, [{ op, phase: phaseHit.name }], `Marked phase ${phaseHit.name} ${target}.`);
391
353
  return;
392
354
  }
393
355
 
@@ -398,30 +360,18 @@ function remove(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
398
360
  const current = deps.getPhases();
399
361
  const trimmedArg = rest.trim();
400
362
  if (!trimmedArg) {
401
- commit(deps, [], "/todo rm (all)", { removed: true });
363
+ commitPhases(deps, []);
402
364
  emit(ctx, "Cleared all todos.");
403
365
  return;
404
366
  }
405
367
  const taskHit = findTaskFuzzy(current, trimmedArg);
406
368
  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}`);
369
+ applyAndCommit(deps, ctx, [{ op: "rm", task: taskHit.task.content }], `Removed: ${taskHit.task.content}`);
414
370
  return;
415
371
  }
416
372
  const phaseHit = findPhaseFuzzy(current, trimmedArg);
417
373
  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}`);
374
+ applyAndCommit(deps, ctx, [{ op: "rm", phase: phaseHit.name }], `Removed phase: ${phaseHit.name}`);
425
375
  return;
426
376
  }
427
377
  emit(ctx, `No task or phase matched "${trimmedArg}".`, "error");
@@ -451,7 +401,7 @@ async function editInEditor(deps: TodoCommandDeps, ctx: ExtensionCommandContext)
451
401
  emit(ctx, `Could not parse Markdown:\n ${errors.join("\n ")}`, "error");
452
402
  return;
453
403
  }
454
- commit(deps, parsed, "/todo edit");
404
+ commitPhases(deps, parsed);
455
405
  const taskCount = parsed.reduce((sum, p) => sum + p.tasks.length, 0);
456
406
  emit(ctx, `Todos updated from editor: ${parsed.length} phase(s), ${taskCount} task(s).`);
457
407
  }
@@ -1,7 +1,5 @@
1
1
  **Tasks: verbatim content strings, NEVER auto-generated IDs; no "task-1"/"task-N". Pass content in `task`.**
2
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
3
  ## Operations
6
4
 
7
5
  |`op`|Fields|Effect|
@@ -11,7 +9,7 @@ Each completion: earliest still-open task (phase order) auto-promotes to `in_pro
11
9
  |`start`|`task`|Mark in progress|
12
10
  |`done`|`task` or `phase`|Mark completed|
13
11
  |`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|
12
+ |`block`|`task` or `phase`; optional `reason`|Mark blocked: open, awaiting external input|
15
13
  |`unblock`|`task` or `phase`|Blocked task → `pending`|
16
14
  |`rm`|optional `task` or `phase`|Remove task/phase; omit both → clear|
17
15
  |`append`|`phase`; `items: string[]`|Append tasks to phase; lazily creates phase|
@@ -24,21 +22,5 @@ Each completion: earliest still-open task (phase order) auto-promotes to `in_pro
24
22
 
25
23
  ## Rules
26
24
 
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.
25
+ - Keep introduced `task`/`phase` strings stable: they are the lookup keys for every later op.
31
26
  - 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>