diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5aa5a1d17..6b4237a43 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- `todo_write`: renamed `replace` op to `init` and reshaped its input to `list: [{phase: string, items: string[]}]`. Tasks no longer accept a `status` field; all start `pending` and the first auto-promotes to `in_progress`. The `append` op's `items` is now `string[]` (was `{id, label}[]`) +- `todo_write`: removed the synthetic `task-N` / `phase-N` ids — task identity is now its `content` and phase identity is its `name`. The `task` field on `start`/`done`/`drop`/`note` and the `phase` field on `done`/`drop`/`rm`/`append` take those values directly +- `todo_write`: phase names no longer accept a numeric/roman prefix (`I.`, `1.`, `Phase 1:`, …). The renderer numbers phases visually (Ⅰ. Ⅱ. Ⅲ. …) and the model-facing state stores the bare noun phrase + +### Changed + +- Changed `/todo` task and phase operations to target items by fuzzy content or phase name matching instead of numeric IDs +- Changed initial todo markdown export template heading from `# I. Todos` to `# Todos` + +### Fixed + +- Fixed todo auto-clear scheduling to identify completed tasks by phase and content so only the matching task is cleared after delays ## [14.5.10] - 2026-04-30 diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/custom-tools/todo/index.ts deleted file mode 100644 index d9911c30b..000000000 --- a/packages/coding-agent/examples/custom-tools/todo/index.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Todo Tool - Demonstrates state management via session entries - * - * This tool stores state in tool result details (not external files), - * which allows proper branching - when you branch, the todo state - * is automatically correct for that point in history. - * - * The onSession callback reconstructs state by scanning past tool results. - */ -import type { - CustomTool, - CustomToolContext, - CustomToolFactory, - CustomToolSessionEvent, -} from "@oh-my-pi/pi-coding-agent"; - -interface Todo { - id: number; - text: string; - done: boolean; -} - -// State stored in tool result details -interface TodoDetails { - action: "list" | "add" | "toggle" | "clear"; - todos: Todo[]; - nextId: number; - error?: string; -} - -const factory: CustomToolFactory = pi => { - const { Type } = pi.typebox; - const { StringEnum, Text } = pi.pi; - - // Define schema separately for proper type inference - const TodoParams = Type.Object({ - action: StringEnum(["list", "add", "toggle", "clear"] as const), - text: Type.Optional(Type.String({ description: "Todo text (for add)" })), - id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), - }); - // In-memory state (reconstructed from session on load) - let todos: Todo[] = []; - let nextId = 1; - - /** - * Reconstruct state from session entries. - * Scans tool results for this tool and applies them in order. - */ - const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => { - todos = []; - nextId = 1; - - // Use getBranch() to get entries on the current branch - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type !== "message") continue; - const msg = entry.message; - - // Tool results have role "toolResult" - if (msg.role !== "toolResult") continue; - if (msg.toolName !== "todo") continue; - - const details = msg.details as TodoDetails | undefined; - if (details) { - todos = details.todos; - nextId = details.nextId; - } - } - }; - - const tool: CustomTool = { - name: "todo", - label: "Todo", - description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", - parameters: TodoParams, - - // Called on session start/switch/branch/clear - onSession: reconstructState, - - async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - switch (params.action) { - case "list": - return { - content: [ - { - type: "text", - text: todos.length - ? todos.map(t => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") - : "No todos", - }, - ], - details: { action: "list", todos: [...todos], nextId }, - }; - - case "add": { - if (!params.text) { - return { - content: [{ type: "text", text: "Error: text required for add" }], - details: { action: "add", todos: [...todos], nextId, error: "text required" }, - }; - } - const newTodo: Todo = { id: nextId++, text: params.text, done: false }; - todos.push(newTodo); - return { - content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], - details: { action: "add", todos: [...todos], nextId }, - }; - } - - case "toggle": { - if (params.id === undefined) { - return { - content: [{ type: "text", text: "Error: id required for toggle" }], - details: { action: "toggle", todos: [...todos], nextId, error: "id required" }, - }; - } - const todo = todos.find(t => t.id === params.id); - if (!todo) { - return { - content: [{ type: "text", text: `Todo #${params.id} not found` }], - details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` }, - }; - } - todo.done = !todo.done; - return { - content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], - details: { action: "toggle", todos: [...todos], nextId }, - }; - } - - case "clear": { - const count = todos.length; - todos = []; - nextId = 1; - return { - content: [{ type: "text", text: `Cleared ${count} todos` }], - details: { action: "clear", todos: [], nextId: 1 }, - }; - } - - default: - return { - content: [{ type: "text", text: `Unknown action: ${params.action}` }], - details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` }, - }; - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", String(args.action)); - if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; - if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded }, theme) { - const { details } = result; - if (!details) { - const text = result.content[0]; - return new Text(text?.type === "text" ? text.text : "", 0, 0); - } - - // Error - if (details.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - const todoList = details.todos; - - switch (details.action) { - case "list": { - if (todoList.length === 0) { - return new Text(theme.fg("dim", "No todos"), 0, 0); - } - let listText = theme.fg("muted", `${todoList.length} todo(s):`); - const display = expanded ? todoList : todoList.slice(0, 5); - for (const t of display) { - const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); - const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text); - listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`; - } - if (!expanded && todoList.length > 5) { - listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`; - } - return new Text(listText, 0, 0); - } - - case "add": { - const added = todoList[todoList.length - 1]; - return new Text( - `${theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`)} ${theme.fg("muted", added.text)}`, - 0, - 0, - ); - } - - case "toggle": { - const text = result.content[0]; - const msg = text?.type === "text" ? text.text : ""; - return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); - } - - case "clear": - return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); - } - }, - }; - - return tool; -}; - -export default factory; diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts deleted file mode 100644 index ab811e3cc..000000000 --- a/packages/coding-agent/examples/extensions/todo.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Todo Extension - Demonstrates state management via session entries - * - * This extension: - * - Registers a `todo` tool for the LLM to manage todos - * - Registers a `/todos` command for users to view the list - * - * State is stored in tool result details (not external files), which allows - * proper branching - when you branch, the todo state is automatically - * correct for that point in history. - */ -import { StringEnum } from "@oh-my-pi/pi-ai"; -import type { ExtensionAPI, ExtensionContext, Theme } from "@oh-my-pi/pi-coding-agent"; -import { matchesKey, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; -import { Type } from "@sinclair/typebox"; - -interface Todo { - id: number; - text: string; - done: boolean; -} - -interface TodoDetails { - action: "list" | "add" | "toggle" | "clear"; - todos: Todo[]; - nextId: number; - error?: string; -} - -const TodoParams = Type.Object({ - action: StringEnum(["list", "add", "toggle", "clear"] as const), - text: Type.Optional(Type.String({ description: "Todo text (for add)" })), - id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), -}); - -/** - * UI component for the /todos command - */ -class TodoListComponent { - private todos: Todo[]; - private theme: Theme; - private onClose: () => void; - private cachedWidth?: number; - private cachedLines?: string[]; - - constructor(todos: Todo[], theme: Theme, onClose: () => void) { - this.todos = todos; - this.theme = theme; - this.onClose = onClose; - } - - handleInput(data: string): void { - if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { - this.onClose(); - } - } - - render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; - } - - const lines: string[] = []; - const th = this.theme; - - lines.push(""); - const title = th.fg("accent", " Todos "); - const headerLine = - th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); - lines.push(truncateToWidth(headerLine, width)); - lines.push(""); - - if (this.todos.length === 0) { - lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); - } else { - const done = this.todos.filter(t => t.done).length; - const total = this.todos.length; - lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width)); - lines.push(""); - - for (const todo of this.todos) { - const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); - const id = th.fg("accent", `#${todo.id}`); - const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); - lines.push(truncateToWidth(` ${check} ${id} ${text}`, width)); - } - } - - lines.push(""); - lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); - lines.push(""); - - this.cachedWidth = width; - this.cachedLines = lines; - return lines; - } - - invalidate(): void { - this.cachedWidth = undefined; - this.cachedLines = undefined; - } -} - -export default function (pi: ExtensionAPI) { - // In-memory state (reconstructed from session on load) - let todos: Todo[] = []; - let nextId = 1; - - /** - * Reconstruct state from session entries. - * Scans tool results for this tool and applies them in order. - */ - const reconstructState = (ctx: ExtensionContext) => { - todos = []; - nextId = 1; - - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type !== "message") continue; - const msg = (entry as { message?: { role?: string; toolName?: string; details?: unknown } }).message; - if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue; - - const details = msg.details as TodoDetails | undefined; - if (details) { - todos = details.todos; - nextId = details.nextId; - } - } - }; - - // Reconstruct state on session events - pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_branch", async (_event, ctx) => reconstructState(ctx)); - pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); - - // Register the todo tool for the LLM - pi.registerTool({ - name: "todo", - label: "Todo", - description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", - parameters: TodoParams, - - async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - switch (params.action) { - case "list": - return { - content: [ - { - type: "text", - text: todos.length - ? todos.map(t => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") - : "No todos", - }, - ], - details: { action: "list", todos: [...todos], nextId } as TodoDetails, - }; - - case "add": { - if (!params.text) { - return { - content: [{ type: "text", text: "Error: text required for add" }], - details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, - }; - } - const newTodo: Todo = { id: nextId++, text: params.text, done: false }; - todos.push(newTodo); - return { - content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], - details: { action: "add", todos: [...todos], nextId } as TodoDetails, - }; - } - - case "toggle": { - if (params.id === undefined) { - return { - content: [{ type: "text", text: "Error: id required for toggle" }], - details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails, - }; - } - const todo = todos.find(t => t.id === params.id); - if (!todo) { - return { - content: [{ type: "text", text: `Todo #${params.id} not found` }], - details: { - action: "toggle", - todos: [...todos], - nextId, - error: `#${params.id} not found`, - } as TodoDetails, - }; - } - todo.done = !todo.done; - return { - content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], - details: { action: "toggle", todos: [...todos], nextId } as TodoDetails, - }; - } - - case "clear": { - const count = todos.length; - todos = []; - nextId = 1; - return { - content: [{ type: "text", text: `Cleared ${count} todos` }], - details: { action: "clear", todos: [], nextId: 1 } as TodoDetails, - }; - } - - default: - return { - content: [{ type: "text", text: `Unknown action: ${params.action}` }], - details: { - action: "list", - todos: [...todos], - nextId, - error: `unknown action: ${params.action}`, - } as TodoDetails, - }; - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); - if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; - if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded }, theme) { - const details = result.details as TodoDetails | undefined; - if (!details) { - const text = result.content[0] as { type: string; text?: string } | undefined; - return new Text(text?.type === "text" && text.text ? text.text : "", 0, 0); - } - - if (details.error) { - return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); - } - - const todoList = details.todos; - - switch (details.action) { - case "list": { - if (todoList.length === 0) { - return new Text(theme.fg("dim", "No todos"), 0, 0); - } - let listText = theme.fg("muted", `${todoList.length} todo(s):`); - const display = expanded ? todoList : todoList.slice(0, 5); - for (const t of display) { - const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); - const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text); - listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`; - } - if (!expanded && todoList.length > 5) { - listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`; - } - return new Text(listText, 0, 0); - } - - case "add": { - const added = todoList[todoList.length - 1]; - return new Text( - `${theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`)} ${theme.fg("muted", added.text)}`, - 0, - 0, - ); - } - - case "toggle": { - const text = result.content[0] as { type: string; text?: string } | undefined; - const msg = text?.type === "text" && text.text ? text.text : ""; - return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); - } - - case "clear": - return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); - } - }, - }); - - // Register the /todos command for users - pi.registerCommand("todos", { - description: "Show all todos on the current branch", - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("/todos requires interactive mode", "error"); - return; - } - - await ctx.ui.custom((_tui, theme, done) => { - return new TodoListComponent(todos, theme, () => done()); - }); - }, - }); -} diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index 90f224dee..62603010d 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index e779b3d05..0b1adff29 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -964,6 +964,16 @@ return html; } + function todoRoman(n) { + if (n <= 0) return ''; + var pairs = [[1000,'M'],[900,'CM'],[500,'D'],[400,'CD'],[100,'C'],[90,'XC'],[50,'L'],[40,'XL'],[10,'X'],[9,'IX'],[5,'V'],[4,'IV'],[1,'I']]; + var out = '', rem = n; + for (var i = 0; i < pairs.length; i++) { + while (rem >= pairs[i][0]) { out += pairs[i][1]; rem -= pairs[i][0]; } + } + return out; + } + function renderTodoWrite(name, args, result, ctx) { let html = toolHead('todo_write'); const ops = Array.isArray(args.ops) ? args.ops : null; @@ -983,8 +993,10 @@ const phases = result?.details?.phases; if (Array.isArray(phases)) { html += '
'; - for (const phase of phases) { - html += '
' + escapeHtml(String(phase.name || '')) + '
'; + for (var __i = 0; __i < phases.length; __i++) { + var phase = phases[__i]; + var phaseLabel = todoRoman(__i + 1) + '. ' + String(phase.name || ''); + html += '
' + escapeHtml(phaseLabel) + '
'; if (Array.isArray(phase.tasks)) { for (const task of phase.tasks) { const status = task.status || 'pending'; diff --git a/packages/coding-agent/src/modes/controllers/todo-command-controller.ts b/packages/coding-agent/src/modes/controllers/todo-command-controller.ts index 61bcdfedd..b5decbc2f 100644 --- a/packages/coding-agent/src/modes/controllers/todo-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/todo-command-controller.ts @@ -21,7 +21,7 @@ const USAGE = [ " /todo export Write todos as Markdown to ", " /todo import Replace todos from Markdown at ", " /todo append [] Append a task; phase fuzzy-matched or auto-created", - " /todo start Mark task in_progress (id or fuzzy content)", + " /todo start Mark task in_progress (fuzzy content match)", " /todo done [] Mark task/phase/all completed", " /todo drop [] Mark task/phase/all abandoned", " /todo rm [] Remove task/phase/all", @@ -59,44 +59,9 @@ function tokenize(input: string): string[] { } // ============================================================================= -// Roman numerals + name normalization +// Name normalization // ============================================================================= -const ROMAN_PAIRS: Array<[number, string]> = [ - [1000, "M"], - [900, "CM"], - [500, "D"], - [400, "CD"], - [100, "C"], - [90, "XC"], - [50, "L"], - [40, "XL"], - [10, "X"], - [9, "IX"], - [5, "V"], - [4, "IV"], - [1, "I"], -]; - -function toRoman(n: number): string { - if (n <= 0) return "I"; - let out = ""; - let rem = n; - for (const [value, sym] of ROMAN_PAIRS) { - while (rem >= value) { - out += sym; - rem -= value; - } - } - return out; -} - -const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i; - -function stripPrefix(name: string): string { - return name.replace(PHASE_PREFIX_RE, "").trim(); -} - function titleCase(s: string): string { return s .split(/\s+/) @@ -105,13 +70,6 @@ function titleCase(s: string): string { .join(" "); } -function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string { - const stripped = stripPrefix(rawName.trim()); - if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`; - const titled = titleCase(stripped); - return `${toRoman(existingPhases.length + 1)}. ${titled}`; -} - // ============================================================================= // Fuzzy matching // ============================================================================= @@ -119,20 +77,13 @@ function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string { function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined { const q = query.trim().toLowerCase(); if (!q) return undefined; - // Exact id - const byId = phases.find(p => p.id.toLowerCase() === q); - if (byId) return byId; // Exact name (case-insensitive) const byName = phases.find(p => p.name.toLowerCase() === q); if (byName) return byName; - // Stripped name match - const strippedQ = stripPrefix(q); - const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ); - if (byStripped) return byStripped; - // Substring (prefer prefix match on stripped name) - const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ)); + // Substring (prefer prefix match) + const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q)); if (prefixMatches.length === 1) return prefixMatches[0]; - const subMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().includes(strippedQ)); + const subMatches = phases.filter(p => p.name.toLowerCase().includes(q)); if (subMatches.length === 1) return subMatches[0]; return undefined; } @@ -140,9 +91,10 @@ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefin function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined { const q = query.trim().toLowerCase(); if (!q) return undefined; + // Exact content (case-insensitive) for (const phase of phases) { for (const task of phase.tasks) { - if (task.id.toLowerCase() === q) return { task, phase }; + if (task.content.toLowerCase() === q) return { task, phase }; } } const matches: Array<{ task: TodoItem; phase: TodoPhase }> = []; @@ -169,7 +121,7 @@ function buildSystemReminder(action: string, phases: TodoPhase[]): string { return [ "", `The user manually modified the todo list (${action}).`, - "Current todo list (note task ids may have been reassigned by /todo edit):", + "Current todo list:", "", md, "", @@ -327,28 +279,24 @@ export class TodoCommandController { if (phaseName) { targetPhase = findPhaseFuzzy(next, phaseName); if (!targetPhase) { - const newName = buildPhaseName(phaseName, next); - targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] }; + targetPhase = { name: titleCase(phaseName), tasks: [] }; next.push(targetPhase); } } else if (next.length > 0) { targetPhase = next[next.length - 1]; } else { - targetPhase = { id: "phase-1", name: `${toRoman(1)}. Todos`, tasks: [] }; + targetPhase = { name: "Todos", tasks: [] }; next.push(targetPhase); } - const usedTaskIds = new Set(next.flatMap(p => p.tasks.map(t => t.id))); - let n = 1; - while (usedTaskIds.has(`task-${n}`)) n++; + const finalContent = titleCaseSentence(content); targetPhase.tasks.push({ - id: `task-${n}`, - content: titleCaseSentence(content), + content: finalContent, status: "pending", }); this.#commit(next, `/todo append → ${targetPhase.name}`); - this.ctx.showStatus(`Appended to ${targetPhase.name}: ${content}`); + this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`); } // ------------------------------------------------------------- start / done / drop / rm @@ -364,12 +312,12 @@ export class TodoCommandController { this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`); return; } - const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.id }]); + const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } - this.#commit(phases, `/todo start ${hit.task.id}`); + this.#commit(phases, `/todo start ${hit.task.content}`); this.ctx.showStatus(`Started: ${hit.task.content}`); } @@ -391,19 +339,19 @@ export class TodoCommandController { const taskHit = findTaskFuzzy(current, trimmed); if (taskHit) { - const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.id }]); + const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } - this.#commit(phases, `/todo ${op} ${taskHit.task.id}`); + this.#commit(phases, `/todo ${op} ${taskHit.task.content}`); this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`); return; } const phaseHit = findPhaseFuzzy(current, trimmed); if (phaseHit) { - const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.id }]); + const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; @@ -426,18 +374,18 @@ export class TodoCommandController { } const taskHit = findTaskFuzzy(current, trimmed); if (taskHit) { - const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.id }]); + const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; } - this.#commit(phases, `/todo rm ${taskHit.task.id}`); + this.#commit(phases, `/todo rm ${taskHit.task.content}`); this.ctx.showStatus(`Removed: ${taskHit.task.content}`); return; } const phaseHit = findPhaseFuzzy(current, trimmed); if (phaseHit) { - const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.id }]); + const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]); if (errors.length > 0) { this.ctx.showError(errors.join("; ")); return; @@ -460,7 +408,7 @@ export class TodoCommandController { const current = this.#currentPhases(); const initialMarkdown = - current.length > 0 ? phasesToMarkdown(current) : "# I. Todos\n- [ ] (replace this with your tasks)\n"; + current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n"; const fileHandle = await this.#openTtyHandle(); this.ctx.ui.stop(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 3e0e15cd1..b5f78556c 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -48,6 +48,7 @@ import { getRecentSessions } from "../session/session-manager"; import { STTController, type SttState } from "../stt"; import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools"; import { normalizeLocalScheme } from "../tools/path-utils"; +import { formatPhaseDisplayName } from "../tools/todo-write"; import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color"; @@ -707,9 +708,12 @@ export class InteractiveMode implements InteractiveModeContext { const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))]; if (!this.todoExpanded) { - const activePhase = this.#getActivePhase(phases); + const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]); + const activePhase = phases[activeIdx]; if (!activePhase) return; - lines.push(`${indent}${theme.fg("accent", `${hook} ${activePhase.name}`)}`); + lines.push( + `${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`, + ); const visibleTasks = activePhase.tasks.slice(0, 5); visibleTasks.forEach((todo, index) => { const prefix = `${indent}${index === 0 ? hook : " "} `; @@ -723,13 +727,13 @@ export class InteractiveMode implements InteractiveModeContext { return; } - for (const phase of phases) { - lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`); + phases.forEach((phase, phaseIndex) => { + lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`); phase.tasks.forEach((todo, index) => { const prefix = `${indent}${index === 0 ? hook : " "} `; lines.push(this.#formatTodoLine(todo, prefix)); }); - } + }); this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0)); } @@ -1712,7 +1716,6 @@ export class InteractiveMode implements InteractiveModeContext { } else { this.todoPhases = [ { - id: "default", name: "Todos", tasks: todos as TodoItem[], }, diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index ff744bff9..6e33879be 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -42,7 +42,6 @@ export type SubmittedUserInput = { export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned"; export type TodoItem = { - id: string; content: string; status: TodoStatus; details?: string; @@ -50,7 +49,6 @@ export type TodoItem = { }; export type TodoPhase = { - id: string; name: string; tasks: TodoItem[]; }; diff --git a/packages/coding-agent/src/prompts/system/eager-todo.md b/packages/coding-agent/src/prompts/system/eager-todo.md index 7209f9d4d..c00010fd5 100644 --- a/packages/coding-agent/src/prompts/system/eager-todo.md +++ b/packages/coding-agent/src/prompts/system/eager-todo.md @@ -2,7 +2,7 @@ Before doing substantive work on the upcoming user request, create a comprehensive phased todo first. You **MUST** call `todo_write` first in this turn. -You **MUST** initialize the todo list with a single `replace` op. +You **MUST** initialize the todo list with a single `init` op. You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step. You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning. You **MUST** keep task `content` to a short label (5-10 words). Put file paths, implementation steps, and specifics in `details`. diff --git a/packages/coding-agent/src/prompts/tools/todo-write.md b/packages/coding-agent/src/prompts/tools/todo-write.md index eb0c93689..45e704626 100644 --- a/packages/coding-agent/src/prompts/tools/todo-write.md +++ b/packages/coding-agent/src/prompts/tools/todo-write.md @@ -5,23 +5,23 @@ The next pending task is auto-promoted to `in_progress` after each completion. |`op`|Required fields|Effect| |---|---|---| -|`replace`|`phases`|Replace the full list (initial setup, full restructure)| -|`start`|`task`|Set task to `in_progress`| -|`done`|`task` or `phase` (or neither = all)|Mark completed| -|`drop`|`task` or `phase` (or neither = all)|Mark abandoned| -|`rm`|`task` or `phase` (or neither = all)|Remove| -|`append`|`phase`, `items: {id, label}[]`|Append tasks; creates phase if missing| -|`note`|`task`, `text`|Append a note to `task.notes`. Only use to leave reminders for future-you.| +|`init`|`list`|Initialize the full list| +|`start`|`task`|Mark in progress| +|`done`|`task` or `phase`|Mark completed| +|`drop`|`task` or `phase`|Mark abandoned| +|`rm`|`task` or `phase`|Remove| +|`append`|`phase`, `items: string[]`|Append tasks; lazily creates phase| +|`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.| ## Anatomy -- **Task `label`**: 5–10 words, what is being done, not how. -- **Phase `name`**: short noun phrase prefixed with a roman numeral — `I. Foundation`, `II. Auth`, `III. Verification`. Single-phase plans still use `I.`. Never use snake_case, arabic numerals, or letter prefixes. +- **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique. +- **Phase name**: short noun phrase (e.g. `Foundation`, `Auth`, `Verification`). Used as the phase identifier — unique. Do not add prefixes like `1.`, `A)`, `Phase 1:`, etc. ## Rules -- Mark tasks done immediately after finishing — never defer. +- Mark tasks done immediately after finishing. - Complete phases in order. -- On blockers, `append` a new task to the active phase. -- Keep ids stable once introduced. +- On blockers, `append` a new task to the active phase to unblock yourself, or `drop`. +- `task` and `phase` fields reference content/name verbatim; keep them stable once introduced. ## When to create a list - Task requires 3+ distinct steps @@ -31,17 +31,17 @@ The next pending task is auto-promoted to `in_progress` after each completion. # Initial setup (multi-phase) -`{"ops":[{"op":"replace","phases":[{"name":"I. Foundation","tasks":[{"content":"Scaffold crate"},{"content":"Wire workspace"}]},{"name":"II. Auth","tasks":[{"content":"Port credential store"},{"content":"Wire OAuth providers"}]},{"name":"III. Verification","tasks":[{"content":"Run cargo test"}]}]}]}` -# Initial setup (single phase — still prefixed) -`{"ops":[{"op":"replace","phases":[{"name":"I. Implementation","tasks":[{"content":"Apply fix"},{"content":"Run tests"}]}]}]}` +`{"ops":[{"op":"init","list":[{"phase":"Foundation","items":["Scaffold crate","Wire workspace"]},{"phase":"Auth","items":["Port credential store","Wire OAuth providers"]},{"phase":"Verification","items":["Run cargo test"]}]}]}` +# Initial setup (single phase) +`{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}` # Complete one task -`{"ops":[{"op":"done","task":"task-2"}]}` +`{"ops":[{"op":"done","task":"Wire workspace"}]}` # Complete a whole phase -`{"ops":[{"op":"done","phase":"II. Auth"}]}` +`{"ops":[{"op":"done","phase":"Auth"}]}` # Remove all tasks `{"ops":[{"op":"rm"}]}` # Drop one task -`{"ops":[{"op":"drop","task":"task-7"}]}` +`{"ops":[{"op":"drop","task":"Run cargo test"}]}` # Append tasks to a phase -`{"ops":[{"op":"append","phase":"II. Auth","items":[{"id":"task-8","label":"Handle retries"},{"id":"task-9","label":"Run tests"}]}]}` +`{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}` diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 04c3e9882..f622eda34 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -387,6 +387,11 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin return `${selector.provider}/${selector.id}`; } +/** Composite key for auto-clear timers, keyed by phase name + task content. */ +function todoClearKey(phaseName: string, taskContent: string): string { + return `${phaseName}\u0000${taskContent}`; +} + const noOpUIContext: ExtensionUIContext = { select: async (_title, _options, _dialogOptions) => undefined, confirm: async (_title, _message, _dialogOptions) => false, @@ -3347,10 +3352,9 @@ export class AgentSession { #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] { return phases.map(phase => ({ - id: phase.id, name: phase.name, tasks: phase.tasks.map(task => { - const out: TodoItem = { id: task.id, content: task.content, status: task.status }; + const out: TodoItem = { content: task.content, status: task.status }; if (task.notes && task.notes.length > 0) out.notes = [...task.notes]; return out; }), @@ -3362,43 +3366,43 @@ export class AgentSession { const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60; if (delaySec < 0) return; // "Never" — no auto-clear const delayMs = delaySec * 1000; - const doneTaskIds = new Set(); + const doneKeys = new Set(); for (const phase of phases) { for (const task of phase.tasks) { if (task.status === "completed" || task.status === "abandoned") { - doneTaskIds.add(task.id); + doneKeys.add(todoClearKey(phase.name, task.content)); } } } // Cancel timers for tasks that are no longer done (e.g. status was reverted) - for (const [id, timer] of this.#todoClearTimers) { - if (!doneTaskIds.has(id)) { + for (const [key, timer] of this.#todoClearTimers) { + if (!doneKeys.has(key)) { clearTimeout(timer); - this.#todoClearTimers.delete(id); + this.#todoClearTimers.delete(key); } } // Schedule new timers for newly-done tasks - for (const id of doneTaskIds) { - if (this.#todoClearTimers.has(id)) continue; + for (const key of doneKeys) { + if (this.#todoClearTimers.has(key)) continue; if (delayMs === 0) { // Instant — run synchronously on next microtask to batch removals - const timer = setTimeout(() => this.#runTodoAutoClear(id), 0); - this.#todoClearTimers.set(id, timer); + const timer = setTimeout(() => this.#runTodoAutoClear(key), 0); + this.#todoClearTimers.set(key, timer); } else { - const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs); - this.#todoClearTimers.set(id, timer); + const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs); + this.#todoClearTimers.set(key, timer); } } } /** Remove a single completed task and notify the UI. */ - #runTodoAutoClear(taskId: string): void { - this.#todoClearTimers.delete(taskId); + #runTodoAutoClear(key: string): void { + this.#todoClearTimers.delete(key); let removed = false; for (const phase of this.#todoPhases) { - const idx = phase.tasks.findIndex(t => t.id === taskId); + const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key); if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) { phase.tasks.splice(idx, 1); removed = true; @@ -4568,7 +4572,7 @@ export class AgentSession { (task): task is TodoItem & { status: "pending" | "in_progress" } => task.status === "pending" || task.status === "in_progress", ) - .map(task => ({ id: task.id, content: task.content, status: task.status })), + .map(task => ({ content: task.content, status: task.status })), })) .filter(phase => phase.tasks.length > 0); const incomplete = incompleteByPhase.flatMap(phase => phase.tasks); diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index 9e5e750c2..7361940ce 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -20,7 +20,6 @@ import { PREVIEW_LIMITS } from "./render-utils"; export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned"; export interface TodoItem { - id: string; content: string; status: TodoStatus; /** @@ -33,7 +32,6 @@ export interface TodoItem { } export interface TodoPhase { - id: string; name: string; tasks: TodoItem[]; } @@ -47,37 +45,31 @@ export interface TodoWriteToolDetails { // Schema // ============================================================================= -const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append", "note"] as const, { +const TodoOp = StringEnum(["init", "start", "done", "rm", "drop", "append", "note"] as const, { description: "operation to apply", }); -const InputTask = Type.Object({ - content: Type.String({ description: "task description", examples: ["Add unit tests"] }), - status: Type.Optional( - StringEnum(["pending", "in_progress", "completed", "abandoned"] as const, { - description: "task status", - }), - ), -}); - -const InputPhase = Type.Object({ - name: Type.String({ description: "phase name", examples: ["I. Foundation", "II. Auth", "III. Verification"] }), - tasks: Type.Optional(Type.Array(InputTask)), -}); - -const AppendItem = Type.Object({ - id: Type.String({ description: "task id", examples: ["task-3"] }), - label: Type.String({ description: "task label", examples: ["Run tests"] }), +const InitListEntry = Type.Object({ + phase: Type.String({ description: "phase name (short noun phrase)", examples: ["Foundation", "Auth"] }), + items: Type.Array(Type.String({ description: "task content (5-10 words)" }), { + minItems: 1, + description: "tasks for this phase, in execution order; all start as pending", + }), }); const TodoOpEntry = Type.Object({ op: TodoOp, - phases: Type.Optional(Type.Array(InputPhase, { description: "replacement todo list for op=replace" })), - task: Type.Optional(Type.String({ description: "task id for start/done/rm/drop", examples: ["task-3"] })), - phase: Type.Optional( - Type.String({ description: "phase id for done/rm/drop/append", examples: ["Implementation", "phase-1"] }), + list: Type.Optional(Type.Array(InitListEntry, { description: "phased task list for op=init" })), + task: Type.Optional( + Type.String({ description: "task content for start/done/rm/drop/note", examples: ["Run tests"] }), + ), + phase: Type.Optional(Type.String({ description: "phase name for done/rm/drop/append", examples: ["Auth"] })), + items: Type.Optional( + Type.Array(Type.String({ description: "task content (5-10 words)" }), { + minItems: 1, + description: "tasks to append to `phase` for op=append", + }), ), - items: Type.Optional(Type.Array(AppendItem, { minItems: 1, description: "items to append for op=append" })), text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })), }); @@ -94,88 +86,30 @@ const todoWriteSchema = Type.Object( type TodoWriteParams = Static; type TodoOpEntryValue = TodoWriteParams["ops"][number]; -// ============================================================================= -// File format -// ============================================================================= - -interface TodoFile { - phases: TodoPhase[]; - nextTaskId: number; - nextPhaseId: number; -} - // ============================================================================= // State helpers // ============================================================================= -function makeEmptyFile(): TodoFile { - return { phases: [], nextTaskId: 1, nextPhaseId: 1 }; -} - -function findTask(phases: TodoPhase[], id: string): TodoItem | undefined { +function findTaskByContent(phases: TodoPhase[], content: string): { task: TodoItem; phase: TodoPhase } | undefined { for (const phase of phases) { - const task = phase.tasks.find(t => t.id === id); - if (task) return task; + const task = phase.tasks.find(t => t.content === content); + if (task) return { task, phase }; } return undefined; } -function findPhase(phases: TodoPhase[], idOrName: string): TodoPhase | undefined { - return phases.find(phase => phase.id === idOrName || phase.name === idOrName); -} - -function buildPhaseFromInput( - input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus }> }, - phaseId: string, - nextTaskId: number, -): { phase: TodoPhase; nextTaskId: number } { - const tasks: TodoItem[] = []; - let tid = nextTaskId; - for (const task of input.tasks ?? []) { - tasks.push({ - id: `task-${tid++}`, - content: task.content, - status: task.status ?? "pending", - }); - } - return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid }; -} - -function getNextIds(phases: TodoPhase[]): { nextTaskId: number; nextPhaseId: number } { - let maxTaskId = 0; - let maxPhaseId = 0; - - for (const phase of phases) { - const phaseMatch = /^phase-(\d+)$/.exec(phase.id); - if (phaseMatch) { - const value = Number.parseInt(phaseMatch[1], 10); - if (Number.isFinite(value) && value > maxPhaseId) maxPhaseId = value; - } - - for (const task of phase.tasks) { - const taskMatch = /^task-(\d+)$/.exec(task.id); - if (!taskMatch) continue; - const value = Number.parseInt(taskMatch[1], 10); - if (Number.isFinite(value) && value > maxTaskId) maxTaskId = value; - } - } - - return { nextTaskId: maxTaskId + 1, nextPhaseId: maxPhaseId + 1 }; -} - -function fileFromPhases(phases: TodoPhase[]): TodoFile { - const { nextTaskId, nextPhaseId } = getNextIds(phases); - return { phases, nextTaskId, nextPhaseId }; +function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefined { + return phases.find(phase => phase.name === name); } function cloneTask(task: TodoItem): TodoItem { - const out: TodoItem = { id: task.id, content: task.content, status: task.status }; + const out: TodoItem = { content: task.content, status: task.status }; if (task.notes && task.notes.length > 0) out.notes = [...task.notes]; return out; } function clonePhases(phases: TodoPhase[]): TodoPhase[] { - return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(cloneTask) })); + return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) })); } function normalizeInProgressTask(phases: TodoPhase[]): void { @@ -220,170 +154,165 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha return []; } -function resolveTaskOrError(phases: TodoPhase[], id: string | undefined, errors: string[]): TodoItem | undefined { - if (!id) { - errors.push("Missing task id"); +function resolveTaskOrError( + phases: TodoPhase[], + content: string | undefined, + errors: string[], +): { task: TodoItem; phase: TodoPhase } | undefined { + if (!content) { + errors.push("Missing task content"); return undefined; } - const task = findTask(phases, id); - if (!task) { + const hit = findTaskByContent(phases, content); + if (!hit) { const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0); const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : ""; - errors.push(`Task "${id}" not found${hint}`); + errors.push(`Task "${content}" not found${hint}`); } - return task; + return hit; } -function resolvePhaseOrError( - phases: TodoPhase[], - idOrName: string | undefined, - errors: string[], -): TodoPhase | undefined { - if (!idOrName) { - errors.push("Missing phase id"); +function resolvePhaseOrError(phases: TodoPhase[], name: string | undefined, errors: string[]): TodoPhase | undefined { + if (!name) { + errors.push("Missing phase name"); return undefined; } - const phase = findPhase(phases, idOrName); - if (!phase) errors.push(`Phase "${idOrName}" not found`); + const phase = findPhaseByName(phases, name); + if (!phase) errors.push(`Phase "${name}" not found`); return phase; } -function getTaskTargets(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): TodoItem[] { +function getTaskTargets(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoItem[] { if (entry.task) { - const task = resolveTaskOrError(file.phases, entry.task, errors); - return task ? [task] : []; + const hit = resolveTaskOrError(phases, entry.task, errors); + return hit ? [hit.task] : []; } if (entry.phase) { - const phase = resolvePhaseOrError(file.phases, entry.phase, errors); + const phase = resolvePhaseOrError(phases, entry.phase, errors); return phase ? [...phase.tasks] : []; } - return file.phases.flatMap(phase => phase.tasks); + return phases.flatMap(phase => phase.tasks); } -function replaceFile(entry: TodoOpEntryValue, errors: string[]): TodoFile { - const next = makeEmptyFile(); - for (const inputPhase of entry.phases ?? []) { - const phaseId = `phase-${next.nextPhaseId++}`; - const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId); - next.phases.push(phase); - next.nextTaskId = nextTaskId; +function initPhases(entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { + if (!entry.list) { + errors.push("Missing list for init operation"); + return []; } - if (!entry.phases) errors.push("Missing phases for replace operation"); - return next; + return entry.list.map(listEntry => ({ + name: listEntry.phase, + tasks: listEntry.items.map(content => ({ content, status: "pending" })), + })); } -function appendItems(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): void { +function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { if (!entry.phase) { - errors.push("Missing phase id for append operation"); - return; + errors.push("Missing phase name for append operation"); + return phases; } if (!entry.items || entry.items.length === 0) { errors.push("Missing items for append operation"); - return; + return phases; } - let phase = findPhase(file.phases, entry.phase); + let phase = findPhaseByName(phases, entry.phase); if (!phase) { - phase = { id: entry.phase, name: entry.phase, tasks: [] }; - file.phases.push(phase); + phase = { name: entry.phase, tasks: [] }; + phases.push(phase); } - for (const item of entry.items) { - if (findTask(file.phases, item.id)) { - errors.push(`Task "${item.id}" already exists`); + for (const content of entry.items) { + if (findTaskByContent(phases, content)) { + errors.push(`Task "${content}" already exists`); continue; } - phase.tasks.push({ id: item.id, content: item.label, status: "pending" }); + phase.tasks.push({ content, status: "pending" }); } + return phases; } -function removeTasks(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): void { +function removeTasks(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { if (entry.task) { - const task = resolveTaskOrError(file.phases, entry.task, errors); - if (!task) return; - for (const phase of file.phases) { - phase.tasks = phase.tasks.filter(candidate => candidate.id !== task.id); - } - return; + const hit = resolveTaskOrError(phases, entry.task, errors); + if (!hit) return phases; + hit.phase.tasks = hit.phase.tasks.filter(candidate => candidate !== hit.task); + return phases; } if (entry.phase) { - const phase = resolvePhaseOrError(file.phases, entry.phase, errors); - if (!phase) return; + const phase = resolvePhaseOrError(phases, entry.phase, errors); + if (!phase) return phases; phase.tasks = []; - return; + return phases; } - for (const phase of file.phases) { + for (const phase of phases) { phase.tasks = []; } + return phases; } -function applyEntry(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): TodoFile { +function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] { switch (entry.op) { - case "replace": - return replaceFile(entry, errors); + case "init": + return initPhases(entry, errors); case "start": { - const task = resolveTaskOrError(file.phases, entry.task, errors); - if (!task) return file; - for (const phase of file.phases) { + const hit = resolveTaskOrError(phases, entry.task, errors); + if (!hit) return phases; + for (const phase of phases) { for (const candidate of phase.tasks) { - if (candidate.status === "in_progress" && candidate.id !== task.id) { + if (candidate.status === "in_progress" && candidate !== hit.task) { candidate.status = "pending"; } } } - task.status = "in_progress"; - return file; + hit.task.status = "in_progress"; + return phases; } case "done": { - for (const task of getTaskTargets(file, entry, errors)) { + for (const task of getTaskTargets(phases, entry, errors)) { task.status = "completed"; } - return file; + return phases; } case "drop": { - for (const task of getTaskTargets(file, entry, errors)) { + for (const task of getTaskTargets(phases, entry, errors)) { task.status = "abandoned"; } - return file; - } - case "rm": { - removeTasks(file, entry, errors); - return file; + return phases; } + case "rm": + return removeTasks(phases, entry, errors); case "note": { - const task = resolveTaskOrError(file.phases, entry.task, errors); - if (!task) return file; + const hit = resolveTaskOrError(phases, entry.task, errors); + if (!hit) return phases; const text = (entry.text ?? "").replace(/\s+$/u, ""); if (!text) { errors.push("Missing text for note operation"); - return file; + return phases; } - task.notes = task.notes ? [...task.notes, text] : [text]; - return file; - } - case "append": { - appendItems(file, entry, errors); - return file; + hit.task.notes = hit.task.notes ? [...hit.task.notes, text] : [text]; + return phases; } + case "append": + return appendItems(phases, entry, errors); } } -function applyParams(file: TodoFile, params: TodoWriteParams): { file: TodoFile; errors: string[] } { +function applyParams(phases: TodoPhase[], params: TodoWriteParams): { phases: TodoPhase[]; errors: string[] } { const errors: string[] = []; + let next = phases; for (const entry of params.ops) { - file = applyEntry(file, entry, errors); + next = applyEntry(next, entry, errors); } - normalizeInProgressTask(file.phases); - return { file, errors }; + normalizeInProgressTask(next); + return { phases: next, errors }; } + /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */ export function applyOpsToPhases( currentPhases: TodoPhase[], ops: TodoWriteParams["ops"], ): { phases: TodoPhase[]; errors: string[] } { - const startFile = fileFromPhases(currentPhases); - const { file, errors } = applyParams(startFile, { ops }); - return { phases: file.phases, errors }; + return applyParams(clonePhases(currentPhases), { ops }); } // ============================================================================= @@ -399,7 +328,7 @@ const STATUS_TO_MARKER: Record = { /** Render todo phases as a Markdown checklist suitable for editing/copying. */ export function phasesToMarkdown(phases: TodoPhase[]): string { - if (phases.length === 0) return "# I. Todos\n"; + if (phases.length === 0) return "# Todos\n"; const out: string[] = []; for (let i = 0; i < phases.length; i++) { if (i > 0) out.push(""); @@ -430,18 +359,13 @@ const MARKER_TO_STATUS: Record = { "~": "abandoned", }; -/** - * Parse a Markdown checklist back into todo phases. Task and phase ids are - * regenerated; the agent observes the new ids in the system reminder. - */ +/** Parse a Markdown checklist back into todo phases. */ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } { const errors: string[] = []; const phases: TodoPhase[] = []; let currentPhase: TodoPhase | undefined; let currentTask: TodoItem | undefined; let noteBuf: string[] = []; - let nextPhaseId = 1; - let nextTaskId = 1; const flushNote = () => { if (!currentTask || noteBuf.length === 0) { @@ -479,7 +403,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str if (headingMatch) { flushNote(); currentTask = undefined; - currentPhase = { id: `phase-${nextPhaseId++}`, name: headingMatch[1].trim(), tasks: [] }; + currentPhase = { name: headingMatch[1].trim(), tasks: [] }; phases.push(currentPhase); continue; } @@ -488,7 +412,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str if (taskMatch) { flushNote(); if (!currentPhase) { - currentPhase = { id: `phase-${nextPhaseId++}`, name: "I. Todos", tasks: [] }; + currentPhase = { name: "Todos", tasks: [] }; phases.push(currentPhase); } const marker = taskMatch[1]; @@ -498,7 +422,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str currentTask = undefined; continue; } - currentTask = { id: `task-${nextTaskId++}`, content: taskMatch[2].trim(), status }; + currentTask = { content: taskMatch[2].trim(), status }; currentPhase.tasks.push(currentTask); continue; } @@ -539,7 +463,7 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string { } else { lines.push(`Remaining items (${remainingTasks.length}):`); for (const task of remainingTasks) { - lines.push(` - ${task.id} ${task.content} [${task.status}] (${task.phase})`); + lines.push(` - ${task.content} [${task.status}] (${task.phase})`); } } lines.push( @@ -558,7 +482,7 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string { : "○"; const noteCount = task.notes?.length ?? 0; const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : ""; - lines.push(` ${sym} ${task.id} ${task.content}${noteMarker}`); + lines.push(` ${sym} ${task.content}${noteMarker}`); if (task.status === "in_progress" && task.notes && task.notes.length > 0) { for (let j = 0; j < task.notes.length; j++) { if (j > 0) lines.push(" ---"); @@ -596,15 +520,14 @@ export class TodoWriteTool implements AgentTool, _context?: AgentToolContext, ): Promise> { - const previousPhases = this.session.getTodoPhases?.() ?? []; - const current = fileFromPhases(previousPhases); - const { file: updated, errors } = applyParams(current, params); - this.session.setTodoPhases?.(updated.phases); + const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []); + const { phases: updated, errors } = applyParams(previousPhases, params); + this.session.setTodoPhases?.(updated); const storage = this.session.getSessionFile() ? "session" : "memory"; return { - content: [{ type: "text", text: formatSummary(updated.phases, errors) }], - details: { phases: updated.phases, storage }, + content: [{ type: "text", text: formatSummary(updated, errors) }], + details: { phases: updated, storage }, }; } } @@ -618,7 +541,7 @@ type TodoWriteRenderArgs = { op?: string; task?: string; phase?: string; - items?: Array<{ id?: string; label?: string }>; + items?: string[]; }>; }; @@ -643,6 +566,45 @@ function toSuperscript(n: number): string { .join(""); } +// ============================================================================= +// Phase numbering (display-only) +// ============================================================================= + +const ROMAN_PAIRS: Array<[number, string]> = [ + [1000, "M"], + [900, "CM"], + [500, "D"], + [400, "CD"], + [100, "C"], + [90, "XC"], + [50, "L"], + [40, "XL"], + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"], +]; + +/** One-based ASCII roman numeral for display (I, II, III, IV, …). */ +export function phaseRomanNumeral(oneBasedIndex: number): string { + if (oneBasedIndex <= 0) return ""; + let out = ""; + let rem = oneBasedIndex; + for (const [value, sym] of ROMAN_PAIRS) { + while (rem >= value) { + out += sym; + rem -= value; + } + } + return out; +} + +/** Display-only phase header: `I. Foundation`. State and prompts never see this. */ +export function formatPhaseDisplayName(name: string, oneBasedIndex: number): string { + return `${phaseRomanNumeral(oneBasedIndex)}. ${name}`; +} + function noteMarker(count: number, uiTheme: Theme): string { if (count <= 0) return ""; return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`)); @@ -718,7 +680,7 @@ export const todoWriteToolRenderer = { for (let p = 0; p < phases.length; p++) { const phase = phases[p]; if (phases.length > 1) { - lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`))); + lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`))); } const treeLines = renderTreeList( { diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index ac0d0b5fb..d12d2181f 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -202,9 +202,8 @@ describe("AgentSession auto-compaction queue resume", () => { session.setTodoPhases([ { - id: "phase-1", name: "Execution", - tasks: [{ id: "task-1", content: "Finish pending task", status: "in_progress" }], + tasks: [{ content: "Finish pending task", status: "in_progress" }], }, ]); diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index f0ee195d4..86772e6bc 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -211,13 +211,8 @@ describe("AgentSession eager todo enforcement", () => { createToolCallAssistantMessage("todo_write", { ops: [ { - op: "replace", - phases: [ - { - name: "List worktrees", - tasks: [{ content: "List all git worktrees in the current repository", status: "in_progress" }], - }, - ], + op: "init", + list: [{ phase: "List worktrees", items: ["List all git worktrees in the current repository"] }], }, ], }), diff --git a/packages/coding-agent/test/agent-session-new-session-todos.test.ts b/packages/coding-agent/test/agent-session-new-session-todos.test.ts index 4052318d6..0560b1b68 100644 --- a/packages/coding-agent/test/agent-session-new-session-todos.test.ts +++ b/packages/coding-agent/test/agent-session-new-session-todos.test.ts @@ -85,9 +85,8 @@ describe("AgentSession newSession clears todo artifacts", () => { session.setTodoPhases([ { - id: "phase-1", name: "Tasks", - tasks: [{ id: "task-1", content: "do the thing", status: "pending" }], + tasks: [{ content: "do the thing", status: "pending" }], }, ]); expect(session.getTodoPhases()).toHaveLength(1); @@ -113,9 +112,8 @@ describe("AgentSession newSession clears todo artifacts", () => { session.setTodoPhases([ { - id: "phase-1", name: "Execution", - tasks: [{ id: "task-1", content: "stale from old branch", status: "in_progress" }], + tasks: [{ content: "stale from old branch", status: "in_progress" }], }, ]); expect(session.getTodoPhases()).toHaveLength(1); diff --git a/packages/coding-agent/test/tools/todo-write.test.ts b/packages/coding-agent/test/tools/todo-write.test.ts index 66b060dba..8d3e3640c 100644 --- a/packages/coding-agent/test/tools/todo-write.test.ts +++ b/packages/coding-agent/test/tools/todo-write.test.ts @@ -19,18 +19,13 @@ function createSession(initialPhases: TodoPhase[] = []): ToolSession { } describe("TodoWriteTool auto-start behavior", () => { - it("auto-starts the first task after replace", async () => { + it("auto-starts the first task after init", async () => { const tool = new TodoWriteTool(createSession()); const result = await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [ - { - name: "Execution", - tasks: [{ content: "status" }, { content: "diagnostics" }], - }, - ], + op: "init", + list: [{ phase: "Execution", items: ["status", "diagnostics"] }], }, ], }); @@ -40,8 +35,8 @@ describe("TodoWriteTool auto-start behavior", () => { const summary = result.content.find(part => part.type === "text"); if (!summary || summary.type !== "text") throw new Error("Expected text summary from todo_write"); expect(summary.text).toContain("Remaining items (2):"); - expect(summary.text).toContain("task-1 status [in_progress] (Execution)"); - expect(summary.text).toContain("task-2 diagnostics [pending] (Execution)"); + expect(summary.text).toContain("status [in_progress] (Execution)"); + expect(summary.text).toContain("diagnostics [pending] (Execution)"); }); it("auto-promotes the next pending task when current task is completed", async () => { @@ -49,56 +44,28 @@ describe("TodoWriteTool auto-start behavior", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [ - { - name: "Execution", - tasks: [{ content: "status" }, { content: "diagnostics" }], - }, - ], + op: "init", + list: [{ phase: "Execution", items: ["status", "diagnostics"] }], }, ], }); - const result = await tool.execute("call-2", { ops: [{ op: "done", task: "task-1" }] }); + const result = await tool.execute("call-2", { ops: [{ op: "done", task: "status" }] }); const tasks = result.details?.phases[0]?.tasks ?? []; expect(tasks.map(task => task.status)).toEqual(["completed", "in_progress"]); const summary = result.content.find(part => part.type === "text"); if (!summary || summary.type !== "text") throw new Error("Expected text summary from todo_write"); expect(summary.text).toContain("Remaining items (1):"); - expect(summary.text).toContain("task-2 diagnostics [in_progress] (Execution)"); + expect(summary.text).toContain("diagnostics [in_progress] (Execution)"); - const completedResult = await tool.execute("call-3", { ops: [{ op: "done", task: "task-2" }] }); + const completedResult = await tool.execute("call-3", { ops: [{ op: "done", task: "diagnostics" }] }); const completedSummary = completedResult.content.find(part => part.type === "text"); if (!completedSummary || completedSummary.type !== "text") { throw new Error("Expected text summary from todo_write"); } expect(completedSummary.text).toContain("Remaining items: none."); }); - - it("keeps only one in_progress task when replace input contains multiples", async () => { - const tool = new TodoWriteTool(createSession()); - const result = await tool.execute("call-1", { - ops: [ - { - op: "replace", - phases: [ - { - name: "Execution", - tasks: [ - { content: "status", status: "in_progress" }, - { content: "diagnostics", status: "in_progress" }, - ], - }, - ], - }, - ], - }); - - const tasks = result.details?.phases[0]?.tasks ?? []; - expect(tasks.map(task => task.status)).toEqual(["in_progress", "pending"]); - }); }); describe("TodoWriteTool ops operations", () => { @@ -107,18 +74,13 @@ describe("TodoWriteTool ops operations", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [ - { - name: "Phase A", - tasks: [{ content: "first" }, { content: "second" }, { content: "third" }], - }, - ], + op: "init", + list: [{ phase: "Phase A", items: ["first", "second", "third"] }], }, ], }); - const result = await tool.execute("call-2", { ops: [{ op: "start", task: "task-3" }] }); + const result = await tool.execute("call-2", { ops: [{ op: "start", task: "third" }] }); const tasks = result.details?.phases[0]?.tasks ?? []; expect(tasks.map(task => task.status)).toEqual(["pending", "pending", "in_progress"]); @@ -129,16 +91,16 @@ describe("TodoWriteTool ops operations", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [ - { name: "A", tasks: [{ content: "a1" }, { content: "a2" }] }, - { name: "B", tasks: [{ content: "b1" }] }, + op: "init", + list: [ + { phase: "A", items: ["a1", "a2"] }, + { phase: "B", items: ["b1"] }, ], }, ], }); - const result = await tool.execute("call-2", { ops: [{ op: "start", task: "task-3" }] }); + const result = await tool.execute("call-2", { ops: [{ op: "start", task: "b1" }] }); const allTasks = result.details?.phases.flatMap(phase => phase.tasks) ?? []; expect(allTasks.map(task => task.status)).toEqual(["pending", "pending", "in_progress"]); @@ -147,30 +109,30 @@ describe("TodoWriteTool ops operations", () => { it("appends items to an existing phase", async () => { const tool = new TodoWriteTool(createSession()); await tool.execute("call-1", { - ops: [{ op: "replace", phases: [{ name: "Work", tasks: [{ content: "First" }] }] }], + ops: [{ op: "init", list: [{ phase: "Work", items: ["First"] }] }], }); const result = await tool.execute("call-2", { ops: [ { op: "append", - phase: "phase-1", - items: [{ id: "task-9", label: "Second" }], + phase: "Work", + items: ["Second"], }, ], }); const tasks = result.details?.phases[0]?.tasks ?? []; - expect(tasks.map(task => ({ id: task.id, content: task.content, status: task.status }))).toEqual([ - { id: "task-1", content: "First", status: "in_progress" }, - { id: "task-9", content: "Second", status: "pending" }, + expect(tasks.map(task => ({ content: task.content, status: task.status }))).toEqual([ + { content: "First", status: "in_progress" }, + { content: "Second", status: "pending" }, ]); }); it("creates a phase when append targets a missing phase", async () => { const tool = new TodoWriteTool(createSession()); await tool.execute("call-1", { - ops: [{ op: "replace", phases: [{ name: "Work", tasks: [{ content: "First" }] }] }], + ops: [{ op: "init", list: [{ phase: "Work", items: ["First"] }] }], }); const result = await tool.execute("call-2", { @@ -178,16 +140,13 @@ describe("TodoWriteTool ops operations", () => { { op: "append", phase: "Cleanup", - items: [{ id: "task-10", label: "Remove dead code" }], + items: ["Remove dead code"], }, ], }); - expect(result.details?.phases.map(phase => ({ id: phase.id, name: phase.name }))).toEqual([ - { id: "phase-1", name: "Work" }, - { id: "Cleanup", name: "Cleanup" }, - ]); - expect(result.details?.phases[1]?.tasks.map(task => task.id)).toEqual(["task-10"]); + expect(result.details?.phases.map(phase => phase.name)).toEqual(["Work", "Cleanup"]); + expect(result.details?.phases[1]?.tasks.map(task => task.content)).toEqual(["Remove dead code"]); }); it("marks all tasks in a phase done", async () => { @@ -195,16 +154,16 @@ describe("TodoWriteTool ops operations", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [ - { name: "Work", tasks: [{ content: "First" }, { content: "Second" }] }, - { name: "Later", tasks: [{ content: "Third" }] }, + op: "init", + list: [ + { phase: "Work", items: ["First", "Second"] }, + { phase: "Later", items: ["Third"] }, ], }, ], }); - const result = await tool.execute("call-2", { ops: [{ op: "done", phase: "phase-1" }] }); + const result = await tool.execute("call-2", { ops: [{ op: "done", phase: "Work" }] }); const allTasks = result.details?.phases.flatMap(phase => phase.tasks) ?? []; expect(allTasks.map(task => task.status)).toEqual(["completed", "completed", "in_progress"]); }); @@ -214,8 +173,8 @@ describe("TodoWriteTool ops operations", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [{ name: "Work", tasks: [{ content: "First" }, { content: "Second" }] }], + op: "init", + list: [{ phase: "Work", items: ["First", "Second"] }], }, ], }); @@ -232,13 +191,13 @@ describe("TodoWriteTool ops operations", () => { await tool.execute("call-1", { ops: [ { - op: "replace", - phases: [{ name: "Work", tasks: [{ content: "First" }, { content: "Second" }] }], + op: "init", + list: [{ phase: "Work", items: ["First", "Second"] }], }, ], }); - const result = await tool.execute("call-2", { ops: [{ op: "drop", phase: "phase-1" }] }); + const result = await tool.execute("call-2", { ops: [{ op: "drop", phase: "Work" }] }); const tasks = result.details?.phases[0]?.tasks ?? []; expect(tasks.map(task => task.status)).toEqual(["abandoned", "abandoned"]); });