feat(coding-agent): added content-based todo matching for write/commands

- Removed `id` fields from todo models/fixtures and switched session clones to content-based task identity.
- Replaced `/todo_write` `replace` with `init`, updated setup schemas to `list`/`phase`, and append content-only items.
- Updated `/todo` command flows to match phases and tasks by names/content (exact/prefix/substr, case-insensitive), with no ID targeting.
- Updated rendering/output labels to `# Todos`, `formatPhaseDisplayName`, and Roman-numeral phase headings across todo views.
- Aligned prompts, changelog, and todo tests/fixtures with the new init and content-based todo-write contract.
This commit is contained in:
can1357
2026-04-30 06:32:00 +02:00
parent 0e377c4914
commit 5003996a01
16 changed files with 301 additions and 915 deletions
+14
View File
@@ -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
@@ -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<typeof TodoParams, TodoDetails> = {
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;
@@ -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<void>((_tui, theme, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}
File diff suppressed because one or more lines are too long
@@ -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 += '<div class="todo-tree">';
for (const phase of phases) {
html += '<div class="todo-phase">' + escapeHtml(String(phase.name || '')) + '</div>';
for (var __i = 0; __i < phases.length; __i++) {
var phase = phases[__i];
var phaseLabel = todoRoman(__i + 1) + '. ' + String(phase.name || '');
html += '<div class="todo-phase">' + escapeHtml(phaseLabel) + '</div>';
if (Array.isArray(phase.tasks)) {
for (const task of phase.tasks) {
const status = task.status || 'pending';
@@ -21,7 +21,7 @@ const USAGE = [
" /todo export <path> Write todos as Markdown to <path>",
" /todo import <path> Replace todos from Markdown at <path>",
" /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
" /todo start <task> Mark task in_progress (id or fuzzy content)",
" /todo start <task> Mark task in_progress (fuzzy content match)",
" /todo done [<task|phase>] Mark task/phase/all completed",
" /todo drop [<task|phase>] Mark task/phase/all abandoned",
" /todo rm [<task|phase>] 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 [
"<system-reminder>",
`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,
"</system-reminder>",
@@ -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();
@@ -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[],
},
-2
View File
@@ -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[];
};
@@ -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`.
@@ -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.
<examples>
# 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"]}]}`
</examples>
@@ -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<string>();
const doneKeys = new Set<string>();
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);
+157 -195
View File
@@ -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<typeof todoWriteSchema>;
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<TodoItem>(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<TodoStatus, string> = {
/** 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<string, TodoStatus> = {
"~": "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<typeof todoWriteSchema, TodoWrit
_onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<TodoWriteToolDetails>> {
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(
{
@@ -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" }],
},
]);
@@ -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"] }],
},
],
}),
@@ -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);
@@ -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"]);
});