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:
@@ -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[],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user