@oh-my-pi/pi-coding-agent 14.5.10 → 14.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +7 -7
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +14 -2
- package/src/internal-urls/docs-index.generated.ts +54 -54
- package/src/modes/controllers/todo-command-controller.ts +22 -74
- package/src/modes/interactive-mode.ts +9 -6
- package/src/modes/types.ts +0 -2
- package/src/prompts/system/eager-todo.md +1 -1
- package/src/prompts/tools/todo-write.md +19 -19
- package/src/session/agent-session.ts +21 -17
- package/src/tools/todo-write.ts +157 -195
- package/examples/custom-tools/todo/index.ts +0 -211
- package/examples/extensions/todo.ts +0 -295
package/src/tools/todo-write.ts
CHANGED
|
@@ -20,7 +20,6 @@ import { PREVIEW_LIMITS } from "./render-utils";
|
|
|
20
20
|
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
|
|
21
21
|
|
|
22
22
|
export interface TodoItem {
|
|
23
|
-
id: string;
|
|
24
23
|
content: string;
|
|
25
24
|
status: TodoStatus;
|
|
26
25
|
/**
|
|
@@ -33,7 +32,6 @@ export interface TodoItem {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
export interface TodoPhase {
|
|
36
|
-
id: string;
|
|
37
35
|
name: string;
|
|
38
36
|
tasks: TodoItem[];
|
|
39
37
|
}
|
|
@@ -47,37 +45,31 @@ export interface TodoWriteToolDetails {
|
|
|
47
45
|
// Schema
|
|
48
46
|
// =============================================================================
|
|
49
47
|
|
|
50
|
-
const TodoOp = StringEnum(["
|
|
48
|
+
const TodoOp = StringEnum(["init", "start", "done", "rm", "drop", "append", "note"] as const, {
|
|
51
49
|
description: "operation to apply",
|
|
52
50
|
});
|
|
53
51
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const InputPhase = Type.Object({
|
|
64
|
-
name: Type.String({ description: "phase name", examples: ["I. Foundation", "II. Auth", "III. Verification"] }),
|
|
65
|
-
tasks: Type.Optional(Type.Array(InputTask)),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const AppendItem = Type.Object({
|
|
69
|
-
id: Type.String({ description: "task id", examples: ["task-3"] }),
|
|
70
|
-
label: Type.String({ description: "task label", examples: ["Run tests"] }),
|
|
52
|
+
const InitListEntry = Type.Object({
|
|
53
|
+
phase: Type.String({ description: "phase name (short noun phrase)", examples: ["Foundation", "Auth"] }),
|
|
54
|
+
items: Type.Array(Type.String({ description: "task content (5-10 words)" }), {
|
|
55
|
+
minItems: 1,
|
|
56
|
+
description: "tasks for this phase, in execution order; all start as pending",
|
|
57
|
+
}),
|
|
71
58
|
});
|
|
72
59
|
|
|
73
60
|
const TodoOpEntry = Type.Object({
|
|
74
61
|
op: TodoOp,
|
|
75
|
-
|
|
76
|
-
task: Type.Optional(
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
list: Type.Optional(Type.Array(InitListEntry, { description: "phased task list for op=init" })),
|
|
63
|
+
task: Type.Optional(
|
|
64
|
+
Type.String({ description: "task content for start/done/rm/drop/note", examples: ["Run tests"] }),
|
|
65
|
+
),
|
|
66
|
+
phase: Type.Optional(Type.String({ description: "phase name for done/rm/drop/append", examples: ["Auth"] })),
|
|
67
|
+
items: Type.Optional(
|
|
68
|
+
Type.Array(Type.String({ description: "task content (5-10 words)" }), {
|
|
69
|
+
minItems: 1,
|
|
70
|
+
description: "tasks to append to `phase` for op=append",
|
|
71
|
+
}),
|
|
79
72
|
),
|
|
80
|
-
items: Type.Optional(Type.Array(AppendItem, { minItems: 1, description: "items to append for op=append" })),
|
|
81
73
|
text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })),
|
|
82
74
|
});
|
|
83
75
|
|
|
@@ -94,88 +86,30 @@ const todoWriteSchema = Type.Object(
|
|
|
94
86
|
type TodoWriteParams = Static<typeof todoWriteSchema>;
|
|
95
87
|
type TodoOpEntryValue = TodoWriteParams["ops"][number];
|
|
96
88
|
|
|
97
|
-
// =============================================================================
|
|
98
|
-
// File format
|
|
99
|
-
// =============================================================================
|
|
100
|
-
|
|
101
|
-
interface TodoFile {
|
|
102
|
-
phases: TodoPhase[];
|
|
103
|
-
nextTaskId: number;
|
|
104
|
-
nextPhaseId: number;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
89
|
// =============================================================================
|
|
108
90
|
// State helpers
|
|
109
91
|
// =============================================================================
|
|
110
92
|
|
|
111
|
-
function
|
|
112
|
-
return { phases: [], nextTaskId: 1, nextPhaseId: 1 };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function findTask(phases: TodoPhase[], id: string): TodoItem | undefined {
|
|
93
|
+
function findTaskByContent(phases: TodoPhase[], content: string): { task: TodoItem; phase: TodoPhase } | undefined {
|
|
116
94
|
for (const phase of phases) {
|
|
117
|
-
const task = phase.tasks.find(t => t.
|
|
118
|
-
if (task) return task;
|
|
95
|
+
const task = phase.tasks.find(t => t.content === content);
|
|
96
|
+
if (task) return { task, phase };
|
|
119
97
|
}
|
|
120
98
|
return undefined;
|
|
121
99
|
}
|
|
122
100
|
|
|
123
|
-
function
|
|
124
|
-
return phases.find(phase => phase.
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function buildPhaseFromInput(
|
|
128
|
-
input: { name: string; tasks?: Array<{ content: string; status?: TodoStatus }> },
|
|
129
|
-
phaseId: string,
|
|
130
|
-
nextTaskId: number,
|
|
131
|
-
): { phase: TodoPhase; nextTaskId: number } {
|
|
132
|
-
const tasks: TodoItem[] = [];
|
|
133
|
-
let tid = nextTaskId;
|
|
134
|
-
for (const task of input.tasks ?? []) {
|
|
135
|
-
tasks.push({
|
|
136
|
-
id: `task-${tid++}`,
|
|
137
|
-
content: task.content,
|
|
138
|
-
status: task.status ?? "pending",
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
return { phase: { id: phaseId, name: input.name, tasks }, nextTaskId: tid };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function getNextIds(phases: TodoPhase[]): { nextTaskId: number; nextPhaseId: number } {
|
|
145
|
-
let maxTaskId = 0;
|
|
146
|
-
let maxPhaseId = 0;
|
|
147
|
-
|
|
148
|
-
for (const phase of phases) {
|
|
149
|
-
const phaseMatch = /^phase-(\d+)$/.exec(phase.id);
|
|
150
|
-
if (phaseMatch) {
|
|
151
|
-
const value = Number.parseInt(phaseMatch[1], 10);
|
|
152
|
-
if (Number.isFinite(value) && value > maxPhaseId) maxPhaseId = value;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
for (const task of phase.tasks) {
|
|
156
|
-
const taskMatch = /^task-(\d+)$/.exec(task.id);
|
|
157
|
-
if (!taskMatch) continue;
|
|
158
|
-
const value = Number.parseInt(taskMatch[1], 10);
|
|
159
|
-
if (Number.isFinite(value) && value > maxTaskId) maxTaskId = value;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { nextTaskId: maxTaskId + 1, nextPhaseId: maxPhaseId + 1 };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function fileFromPhases(phases: TodoPhase[]): TodoFile {
|
|
167
|
-
const { nextTaskId, nextPhaseId } = getNextIds(phases);
|
|
168
|
-
return { phases, nextTaskId, nextPhaseId };
|
|
101
|
+
function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefined {
|
|
102
|
+
return phases.find(phase => phase.name === name);
|
|
169
103
|
}
|
|
170
104
|
|
|
171
105
|
function cloneTask(task: TodoItem): TodoItem {
|
|
172
|
-
const out: TodoItem = {
|
|
106
|
+
const out: TodoItem = { content: task.content, status: task.status };
|
|
173
107
|
if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
|
|
174
108
|
return out;
|
|
175
109
|
}
|
|
176
110
|
|
|
177
111
|
function clonePhases(phases: TodoPhase[]): TodoPhase[] {
|
|
178
|
-
return phases.map(phase => ({
|
|
112
|
+
return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
|
|
179
113
|
}
|
|
180
114
|
|
|
181
115
|
function normalizeInProgressTask(phases: TodoPhase[]): void {
|
|
@@ -220,170 +154,165 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
|
|
|
220
154
|
return [];
|
|
221
155
|
}
|
|
222
156
|
|
|
223
|
-
function resolveTaskOrError(
|
|
224
|
-
|
|
225
|
-
|
|
157
|
+
function resolveTaskOrError(
|
|
158
|
+
phases: TodoPhase[],
|
|
159
|
+
content: string | undefined,
|
|
160
|
+
errors: string[],
|
|
161
|
+
): { task: TodoItem; phase: TodoPhase } | undefined {
|
|
162
|
+
if (!content) {
|
|
163
|
+
errors.push("Missing task content");
|
|
226
164
|
return undefined;
|
|
227
165
|
}
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
166
|
+
const hit = findTaskByContent(phases, content);
|
|
167
|
+
if (!hit) {
|
|
230
168
|
const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
|
|
231
169
|
const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
|
|
232
|
-
errors.push(`Task "${
|
|
170
|
+
errors.push(`Task "${content}" not found${hint}`);
|
|
233
171
|
}
|
|
234
|
-
return
|
|
172
|
+
return hit;
|
|
235
173
|
}
|
|
236
174
|
|
|
237
|
-
function resolvePhaseOrError(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
errors: string[],
|
|
241
|
-
): TodoPhase | undefined {
|
|
242
|
-
if (!idOrName) {
|
|
243
|
-
errors.push("Missing phase id");
|
|
175
|
+
function resolvePhaseOrError(phases: TodoPhase[], name: string | undefined, errors: string[]): TodoPhase | undefined {
|
|
176
|
+
if (!name) {
|
|
177
|
+
errors.push("Missing phase name");
|
|
244
178
|
return undefined;
|
|
245
179
|
}
|
|
246
|
-
const phase =
|
|
247
|
-
if (!phase) errors.push(`Phase "${
|
|
180
|
+
const phase = findPhaseByName(phases, name);
|
|
181
|
+
if (!phase) errors.push(`Phase "${name}" not found`);
|
|
248
182
|
return phase;
|
|
249
183
|
}
|
|
250
184
|
|
|
251
|
-
function getTaskTargets(
|
|
185
|
+
function getTaskTargets(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoItem[] {
|
|
252
186
|
if (entry.task) {
|
|
253
|
-
const
|
|
254
|
-
return
|
|
187
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
188
|
+
return hit ? [hit.task] : [];
|
|
255
189
|
}
|
|
256
190
|
if (entry.phase) {
|
|
257
|
-
const phase = resolvePhaseOrError(
|
|
191
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
258
192
|
return phase ? [...phase.tasks] : [];
|
|
259
193
|
}
|
|
260
|
-
return
|
|
194
|
+
return phases.flatMap(phase => phase.tasks);
|
|
261
195
|
}
|
|
262
196
|
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const { phase, nextTaskId } = buildPhaseFromInput(inputPhase, phaseId, next.nextTaskId);
|
|
268
|
-
next.phases.push(phase);
|
|
269
|
-
next.nextTaskId = nextTaskId;
|
|
197
|
+
function initPhases(entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
|
|
198
|
+
if (!entry.list) {
|
|
199
|
+
errors.push("Missing list for init operation");
|
|
200
|
+
return [];
|
|
270
201
|
}
|
|
271
|
-
|
|
272
|
-
|
|
202
|
+
return entry.list.map(listEntry => ({
|
|
203
|
+
name: listEntry.phase,
|
|
204
|
+
tasks: listEntry.items.map<TodoItem>(content => ({ content, status: "pending" })),
|
|
205
|
+
}));
|
|
273
206
|
}
|
|
274
207
|
|
|
275
|
-
function appendItems(
|
|
208
|
+
function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
|
|
276
209
|
if (!entry.phase) {
|
|
277
|
-
errors.push("Missing phase
|
|
278
|
-
return;
|
|
210
|
+
errors.push("Missing phase name for append operation");
|
|
211
|
+
return phases;
|
|
279
212
|
}
|
|
280
213
|
if (!entry.items || entry.items.length === 0) {
|
|
281
214
|
errors.push("Missing items for append operation");
|
|
282
|
-
return;
|
|
215
|
+
return phases;
|
|
283
216
|
}
|
|
284
217
|
|
|
285
|
-
let phase =
|
|
218
|
+
let phase = findPhaseByName(phases, entry.phase);
|
|
286
219
|
if (!phase) {
|
|
287
|
-
phase = {
|
|
288
|
-
|
|
220
|
+
phase = { name: entry.phase, tasks: [] };
|
|
221
|
+
phases.push(phase);
|
|
289
222
|
}
|
|
290
223
|
|
|
291
|
-
for (const
|
|
292
|
-
if (
|
|
293
|
-
errors.push(`Task "${
|
|
224
|
+
for (const content of entry.items) {
|
|
225
|
+
if (findTaskByContent(phases, content)) {
|
|
226
|
+
errors.push(`Task "${content}" already exists`);
|
|
294
227
|
continue;
|
|
295
228
|
}
|
|
296
|
-
phase.tasks.push({
|
|
229
|
+
phase.tasks.push({ content, status: "pending" });
|
|
297
230
|
}
|
|
231
|
+
return phases;
|
|
298
232
|
}
|
|
299
233
|
|
|
300
|
-
function removeTasks(
|
|
234
|
+
function removeTasks(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
|
|
301
235
|
if (entry.task) {
|
|
302
|
-
const
|
|
303
|
-
if (!
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
return;
|
|
236
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
237
|
+
if (!hit) return phases;
|
|
238
|
+
hit.phase.tasks = hit.phase.tasks.filter(candidate => candidate !== hit.task);
|
|
239
|
+
return phases;
|
|
308
240
|
}
|
|
309
241
|
if (entry.phase) {
|
|
310
|
-
const phase = resolvePhaseOrError(
|
|
311
|
-
if (!phase) return;
|
|
242
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
243
|
+
if (!phase) return phases;
|
|
312
244
|
phase.tasks = [];
|
|
313
|
-
return;
|
|
245
|
+
return phases;
|
|
314
246
|
}
|
|
315
|
-
for (const phase of
|
|
247
|
+
for (const phase of phases) {
|
|
316
248
|
phase.tasks = [];
|
|
317
249
|
}
|
|
250
|
+
return phases;
|
|
318
251
|
}
|
|
319
252
|
|
|
320
|
-
function applyEntry(
|
|
253
|
+
function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
|
|
321
254
|
switch (entry.op) {
|
|
322
|
-
case "
|
|
323
|
-
return
|
|
255
|
+
case "init":
|
|
256
|
+
return initPhases(entry, errors);
|
|
324
257
|
case "start": {
|
|
325
|
-
const
|
|
326
|
-
if (!
|
|
327
|
-
for (const phase of
|
|
258
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
259
|
+
if (!hit) return phases;
|
|
260
|
+
for (const phase of phases) {
|
|
328
261
|
for (const candidate of phase.tasks) {
|
|
329
|
-
if (candidate.status === "in_progress" && candidate
|
|
262
|
+
if (candidate.status === "in_progress" && candidate !== hit.task) {
|
|
330
263
|
candidate.status = "pending";
|
|
331
264
|
}
|
|
332
265
|
}
|
|
333
266
|
}
|
|
334
|
-
task.status = "in_progress";
|
|
335
|
-
return
|
|
267
|
+
hit.task.status = "in_progress";
|
|
268
|
+
return phases;
|
|
336
269
|
}
|
|
337
270
|
case "done": {
|
|
338
|
-
for (const task of getTaskTargets(
|
|
271
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
339
272
|
task.status = "completed";
|
|
340
273
|
}
|
|
341
|
-
return
|
|
274
|
+
return phases;
|
|
342
275
|
}
|
|
343
276
|
case "drop": {
|
|
344
|
-
for (const task of getTaskTargets(
|
|
277
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
345
278
|
task.status = "abandoned";
|
|
346
279
|
}
|
|
347
|
-
return
|
|
348
|
-
}
|
|
349
|
-
case "rm": {
|
|
350
|
-
removeTasks(file, entry, errors);
|
|
351
|
-
return file;
|
|
280
|
+
return phases;
|
|
352
281
|
}
|
|
282
|
+
case "rm":
|
|
283
|
+
return removeTasks(phases, entry, errors);
|
|
353
284
|
case "note": {
|
|
354
|
-
const
|
|
355
|
-
if (!
|
|
285
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
286
|
+
if (!hit) return phases;
|
|
356
287
|
const text = (entry.text ?? "").replace(/\s+$/u, "");
|
|
357
288
|
if (!text) {
|
|
358
289
|
errors.push("Missing text for note operation");
|
|
359
|
-
return
|
|
290
|
+
return phases;
|
|
360
291
|
}
|
|
361
|
-
task.notes = task.notes ? [...task.notes, text] : [text];
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
case "append": {
|
|
365
|
-
appendItems(file, entry, errors);
|
|
366
|
-
return file;
|
|
292
|
+
hit.task.notes = hit.task.notes ? [...hit.task.notes, text] : [text];
|
|
293
|
+
return phases;
|
|
367
294
|
}
|
|
295
|
+
case "append":
|
|
296
|
+
return appendItems(phases, entry, errors);
|
|
368
297
|
}
|
|
369
298
|
}
|
|
370
299
|
|
|
371
|
-
function applyParams(
|
|
300
|
+
function applyParams(phases: TodoPhase[], params: TodoWriteParams): { phases: TodoPhase[]; errors: string[] } {
|
|
372
301
|
const errors: string[] = [];
|
|
302
|
+
let next = phases;
|
|
373
303
|
for (const entry of params.ops) {
|
|
374
|
-
|
|
304
|
+
next = applyEntry(next, entry, errors);
|
|
375
305
|
}
|
|
376
|
-
normalizeInProgressTask(
|
|
377
|
-
return {
|
|
306
|
+
normalizeInProgressTask(next);
|
|
307
|
+
return { phases: next, errors };
|
|
378
308
|
}
|
|
309
|
+
|
|
379
310
|
/** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
|
|
380
311
|
export function applyOpsToPhases(
|
|
381
312
|
currentPhases: TodoPhase[],
|
|
382
313
|
ops: TodoWriteParams["ops"],
|
|
383
314
|
): { phases: TodoPhase[]; errors: string[] } {
|
|
384
|
-
|
|
385
|
-
const { file, errors } = applyParams(startFile, { ops });
|
|
386
|
-
return { phases: file.phases, errors };
|
|
315
|
+
return applyParams(clonePhases(currentPhases), { ops });
|
|
387
316
|
}
|
|
388
317
|
|
|
389
318
|
// =============================================================================
|
|
@@ -399,7 +328,7 @@ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
|
|
|
399
328
|
|
|
400
329
|
/** Render todo phases as a Markdown checklist suitable for editing/copying. */
|
|
401
330
|
export function phasesToMarkdown(phases: TodoPhase[]): string {
|
|
402
|
-
if (phases.length === 0) return "#
|
|
331
|
+
if (phases.length === 0) return "# Todos\n";
|
|
403
332
|
const out: string[] = [];
|
|
404
333
|
for (let i = 0; i < phases.length; i++) {
|
|
405
334
|
if (i > 0) out.push("");
|
|
@@ -430,18 +359,13 @@ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
|
|
|
430
359
|
"~": "abandoned",
|
|
431
360
|
};
|
|
432
361
|
|
|
433
|
-
/**
|
|
434
|
-
* Parse a Markdown checklist back into todo phases. Task and phase ids are
|
|
435
|
-
* regenerated; the agent observes the new ids in the system reminder.
|
|
436
|
-
*/
|
|
362
|
+
/** Parse a Markdown checklist back into todo phases. */
|
|
437
363
|
export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } {
|
|
438
364
|
const errors: string[] = [];
|
|
439
365
|
const phases: TodoPhase[] = [];
|
|
440
366
|
let currentPhase: TodoPhase | undefined;
|
|
441
367
|
let currentTask: TodoItem | undefined;
|
|
442
368
|
let noteBuf: string[] = [];
|
|
443
|
-
let nextPhaseId = 1;
|
|
444
|
-
let nextTaskId = 1;
|
|
445
369
|
|
|
446
370
|
const flushNote = () => {
|
|
447
371
|
if (!currentTask || noteBuf.length === 0) {
|
|
@@ -479,7 +403,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
|
|
|
479
403
|
if (headingMatch) {
|
|
480
404
|
flushNote();
|
|
481
405
|
currentTask = undefined;
|
|
482
|
-
currentPhase = {
|
|
406
|
+
currentPhase = { name: headingMatch[1].trim(), tasks: [] };
|
|
483
407
|
phases.push(currentPhase);
|
|
484
408
|
continue;
|
|
485
409
|
}
|
|
@@ -488,7 +412,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
|
|
|
488
412
|
if (taskMatch) {
|
|
489
413
|
flushNote();
|
|
490
414
|
if (!currentPhase) {
|
|
491
|
-
currentPhase = {
|
|
415
|
+
currentPhase = { name: "Todos", tasks: [] };
|
|
492
416
|
phases.push(currentPhase);
|
|
493
417
|
}
|
|
494
418
|
const marker = taskMatch[1];
|
|
@@ -498,7 +422,7 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
|
|
|
498
422
|
currentTask = undefined;
|
|
499
423
|
continue;
|
|
500
424
|
}
|
|
501
|
-
currentTask = {
|
|
425
|
+
currentTask = { content: taskMatch[2].trim(), status };
|
|
502
426
|
currentPhase.tasks.push(currentTask);
|
|
503
427
|
continue;
|
|
504
428
|
}
|
|
@@ -539,7 +463,7 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
|
|
|
539
463
|
} else {
|
|
540
464
|
lines.push(`Remaining items (${remainingTasks.length}):`);
|
|
541
465
|
for (const task of remainingTasks) {
|
|
542
|
-
lines.push(` - ${task.
|
|
466
|
+
lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
|
|
543
467
|
}
|
|
544
468
|
}
|
|
545
469
|
lines.push(
|
|
@@ -558,7 +482,7 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
|
|
|
558
482
|
: "○";
|
|
559
483
|
const noteCount = task.notes?.length ?? 0;
|
|
560
484
|
const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : "";
|
|
561
|
-
lines.push(` ${sym} ${task.
|
|
485
|
+
lines.push(` ${sym} ${task.content}${noteMarker}`);
|
|
562
486
|
if (task.status === "in_progress" && task.notes && task.notes.length > 0) {
|
|
563
487
|
for (let j = 0; j < task.notes.length; j++) {
|
|
564
488
|
if (j > 0) lines.push(" ---");
|
|
@@ -596,15 +520,14 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
|
|
|
596
520
|
_onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
|
|
597
521
|
_context?: AgentToolContext,
|
|
598
522
|
): Promise<AgentToolResult<TodoWriteToolDetails>> {
|
|
599
|
-
const previousPhases = this.session.getTodoPhases?.() ?? [];
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
this.session.setTodoPhases?.(updated.phases);
|
|
523
|
+
const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
|
|
524
|
+
const { phases: updated, errors } = applyParams(previousPhases, params);
|
|
525
|
+
this.session.setTodoPhases?.(updated);
|
|
603
526
|
const storage = this.session.getSessionFile() ? "session" : "memory";
|
|
604
527
|
|
|
605
528
|
return {
|
|
606
|
-
content: [{ type: "text", text: formatSummary(updated
|
|
607
|
-
details: { phases: updated
|
|
529
|
+
content: [{ type: "text", text: formatSummary(updated, errors) }],
|
|
530
|
+
details: { phases: updated, storage },
|
|
608
531
|
};
|
|
609
532
|
}
|
|
610
533
|
}
|
|
@@ -618,7 +541,7 @@ type TodoWriteRenderArgs = {
|
|
|
618
541
|
op?: string;
|
|
619
542
|
task?: string;
|
|
620
543
|
phase?: string;
|
|
621
|
-
items?:
|
|
544
|
+
items?: string[];
|
|
622
545
|
}>;
|
|
623
546
|
};
|
|
624
547
|
|
|
@@ -643,6 +566,45 @@ function toSuperscript(n: number): string {
|
|
|
643
566
|
.join("");
|
|
644
567
|
}
|
|
645
568
|
|
|
569
|
+
// =============================================================================
|
|
570
|
+
// Phase numbering (display-only)
|
|
571
|
+
// =============================================================================
|
|
572
|
+
|
|
573
|
+
const ROMAN_PAIRS: Array<[number, string]> = [
|
|
574
|
+
[1000, "M"],
|
|
575
|
+
[900, "CM"],
|
|
576
|
+
[500, "D"],
|
|
577
|
+
[400, "CD"],
|
|
578
|
+
[100, "C"],
|
|
579
|
+
[90, "XC"],
|
|
580
|
+
[50, "L"],
|
|
581
|
+
[40, "XL"],
|
|
582
|
+
[10, "X"],
|
|
583
|
+
[9, "IX"],
|
|
584
|
+
[5, "V"],
|
|
585
|
+
[4, "IV"],
|
|
586
|
+
[1, "I"],
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
/** One-based ASCII roman numeral for display (I, II, III, IV, …). */
|
|
590
|
+
export function phaseRomanNumeral(oneBasedIndex: number): string {
|
|
591
|
+
if (oneBasedIndex <= 0) return "";
|
|
592
|
+
let out = "";
|
|
593
|
+
let rem = oneBasedIndex;
|
|
594
|
+
for (const [value, sym] of ROMAN_PAIRS) {
|
|
595
|
+
while (rem >= value) {
|
|
596
|
+
out += sym;
|
|
597
|
+
rem -= value;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Display-only phase header: `I. Foundation`. State and prompts never see this. */
|
|
604
|
+
export function formatPhaseDisplayName(name: string, oneBasedIndex: number): string {
|
|
605
|
+
return `${phaseRomanNumeral(oneBasedIndex)}. ${name}`;
|
|
606
|
+
}
|
|
607
|
+
|
|
646
608
|
function noteMarker(count: number, uiTheme: Theme): string {
|
|
647
609
|
if (count <= 0) return "";
|
|
648
610
|
return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
|
|
@@ -718,7 +680,7 @@ export const todoWriteToolRenderer = {
|
|
|
718
680
|
for (let p = 0; p < phases.length; p++) {
|
|
719
681
|
const phase = phases[p];
|
|
720
682
|
if (phases.length > 1) {
|
|
721
|
-
lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`)));
|
|
683
|
+
lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
|
|
722
684
|
}
|
|
723
685
|
const treeLines = renderTreeList(
|
|
724
686
|
{
|