@oh-my-pi/pi-coding-agent 14.5.9 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +11 -16
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.ts +50 -19
  9. package/src/edit/modes/hashline.ts +171 -110
  10. package/src/export/html/template.generated.ts +1 -1
  11. package/src/export/html/template.js +14 -2
  12. package/src/extensibility/extensions/runner.ts +34 -1
  13. package/src/extensibility/extensions/types.ts +8 -0
  14. package/src/internal-urls/docs-index.generated.ts +54 -54
  15. package/src/lsp/client.ts +27 -35
  16. package/src/memories/index.ts +5 -0
  17. package/src/modes/components/settings-defs.ts +1 -1
  18. package/src/modes/controllers/selector-controller.ts +2 -2
  19. package/src/modes/controllers/todo-command-controller.ts +22 -74
  20. package/src/modes/interactive-mode.ts +36 -9
  21. package/src/modes/theme/theme.ts +10 -1
  22. package/src/modes/types.ts +1 -3
  23. package/src/modes/utils/ui-helpers.ts +19 -6
  24. package/src/prompts/system/auto-continue.md +1 -0
  25. package/src/prompts/system/eager-todo.md +1 -1
  26. package/src/prompts/tools/github.md +3 -3
  27. package/src/prompts/tools/todo-write.md +19 -19
  28. package/src/sdk.ts +13 -2
  29. package/src/session/agent-session.ts +196 -96
  30. package/src/session/session-manager.ts +19 -2
  31. package/src/tools/bash.ts +9 -4
  32. package/src/tools/gh.ts +267 -119
  33. package/src/tools/todo-write.ts +157 -195
  34. package/src/utils/git.ts +61 -2
  35. package/src/web/search/providers/searxng.ts +71 -13
  36. package/examples/custom-tools/todo/index.ts +0 -211
  37. package/examples/extensions/todo.ts +0 -295
@@ -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(["replace", "start", "done", "rm", "drop", "append", "note"] as const, {
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 InputTask = Type.Object({
55
- content: Type.String({ description: "task description", examples: ["Add unit tests"] }),
56
- status: Type.Optional(
57
- StringEnum(["pending", "in_progress", "completed", "abandoned"] as const, {
58
- description: "task status",
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
- phases: Type.Optional(Type.Array(InputPhase, { description: "replacement todo list for op=replace" })),
76
- task: Type.Optional(Type.String({ description: "task id for start/done/rm/drop", examples: ["task-3"] })),
77
- phase: Type.Optional(
78
- Type.String({ description: "phase id for done/rm/drop/append", examples: ["Implementation", "phase-1"] }),
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 makeEmptyFile(): TodoFile {
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.id === id);
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 findPhase(phases: TodoPhase[], idOrName: string): TodoPhase | undefined {
124
- return phases.find(phase => phase.id === idOrName || phase.name === idOrName);
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 = { id: task.id, content: task.content, status: task.status };
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 => ({ ...phase, tasks: phase.tasks.map(cloneTask) }));
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(phases: TodoPhase[], id: string | undefined, errors: string[]): TodoItem | undefined {
224
- if (!id) {
225
- errors.push("Missing task id");
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 task = findTask(phases, id);
229
- if (!task) {
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 "${id}" not found${hint}`);
170
+ errors.push(`Task "${content}" not found${hint}`);
233
171
  }
234
- return task;
172
+ return hit;
235
173
  }
236
174
 
237
- function resolvePhaseOrError(
238
- phases: TodoPhase[],
239
- idOrName: string | undefined,
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 = findPhase(phases, idOrName);
247
- if (!phase) errors.push(`Phase "${idOrName}" not found`);
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(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): TodoItem[] {
185
+ function getTaskTargets(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoItem[] {
252
186
  if (entry.task) {
253
- const task = resolveTaskOrError(file.phases, entry.task, errors);
254
- return task ? [task] : [];
187
+ const hit = resolveTaskOrError(phases, entry.task, errors);
188
+ return hit ? [hit.task] : [];
255
189
  }
256
190
  if (entry.phase) {
257
- const phase = resolvePhaseOrError(file.phases, entry.phase, errors);
191
+ const phase = resolvePhaseOrError(phases, entry.phase, errors);
258
192
  return phase ? [...phase.tasks] : [];
259
193
  }
260
- return file.phases.flatMap(phase => phase.tasks);
194
+ return phases.flatMap(phase => phase.tasks);
261
195
  }
262
196
 
263
- function replaceFile(entry: TodoOpEntryValue, errors: string[]): TodoFile {
264
- const next = makeEmptyFile();
265
- for (const inputPhase of entry.phases ?? []) {
266
- const phaseId = `phase-${next.nextPhaseId++}`;
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
- if (!entry.phases) errors.push("Missing phases for replace operation");
272
- return next;
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(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): void {
208
+ function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
276
209
  if (!entry.phase) {
277
- errors.push("Missing phase id for append operation");
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 = findPhase(file.phases, entry.phase);
218
+ let phase = findPhaseByName(phases, entry.phase);
286
219
  if (!phase) {
287
- phase = { id: entry.phase, name: entry.phase, tasks: [] };
288
- file.phases.push(phase);
220
+ phase = { name: entry.phase, tasks: [] };
221
+ phases.push(phase);
289
222
  }
290
223
 
291
- for (const item of entry.items) {
292
- if (findTask(file.phases, item.id)) {
293
- errors.push(`Task "${item.id}" already exists`);
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({ id: item.id, content: item.label, status: "pending" });
229
+ phase.tasks.push({ content, status: "pending" });
297
230
  }
231
+ return phases;
298
232
  }
299
233
 
300
- function removeTasks(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): void {
234
+ function removeTasks(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
301
235
  if (entry.task) {
302
- const task = resolveTaskOrError(file.phases, entry.task, errors);
303
- if (!task) return;
304
- for (const phase of file.phases) {
305
- phase.tasks = phase.tasks.filter(candidate => candidate.id !== task.id);
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(file.phases, entry.phase, errors);
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 file.phases) {
247
+ for (const phase of phases) {
316
248
  phase.tasks = [];
317
249
  }
250
+ return phases;
318
251
  }
319
252
 
320
- function applyEntry(file: TodoFile, entry: TodoOpEntryValue, errors: string[]): TodoFile {
253
+ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string[]): TodoPhase[] {
321
254
  switch (entry.op) {
322
- case "replace":
323
- return replaceFile(entry, errors);
255
+ case "init":
256
+ return initPhases(entry, errors);
324
257
  case "start": {
325
- const task = resolveTaskOrError(file.phases, entry.task, errors);
326
- if (!task) return file;
327
- for (const phase of file.phases) {
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.id !== task.id) {
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 file;
267
+ hit.task.status = "in_progress";
268
+ return phases;
336
269
  }
337
270
  case "done": {
338
- for (const task of getTaskTargets(file, entry, errors)) {
271
+ for (const task of getTaskTargets(phases, entry, errors)) {
339
272
  task.status = "completed";
340
273
  }
341
- return file;
274
+ return phases;
342
275
  }
343
276
  case "drop": {
344
- for (const task of getTaskTargets(file, entry, errors)) {
277
+ for (const task of getTaskTargets(phases, entry, errors)) {
345
278
  task.status = "abandoned";
346
279
  }
347
- return file;
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 task = resolveTaskOrError(file.phases, entry.task, errors);
355
- if (!task) return file;
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 file;
290
+ return phases;
360
291
  }
361
- task.notes = task.notes ? [...task.notes, text] : [text];
362
- return file;
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(file: TodoFile, params: TodoWriteParams): { file: TodoFile; errors: string[] } {
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
- file = applyEntry(file, entry, errors);
304
+ next = applyEntry(next, entry, errors);
375
305
  }
376
- normalizeInProgressTask(file.phases);
377
- return { file, errors };
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
- const startFile = fileFromPhases(currentPhases);
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 "# I. Todos\n";
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 = { id: `phase-${nextPhaseId++}`, name: headingMatch[1].trim(), tasks: [] };
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 = { id: `phase-${nextPhaseId++}`, name: "I. Todos", tasks: [] };
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 = { id: `task-${nextTaskId++}`, content: taskMatch[2].trim(), status };
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.id} ${task.content} [${task.status}] (${task.phase})`);
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.id} ${task.content}${noteMarker}`);
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 current = fileFromPhases(previousPhases);
601
- const { file: updated, errors } = applyParams(current, params);
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.phases, errors) }],
607
- details: { phases: updated.phases, storage },
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?: Array<{ id?: string; label?: string }>;
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
  {
package/src/utils/git.ts CHANGED
@@ -150,6 +150,7 @@ const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[]
150
150
  ["core.fsmonitor", "false"],
151
151
  ["core.untrackedCache", "false"],
152
152
  ];
153
+ const REMOTE_ALREADY_EXISTS = /remote .* already exists/i;
153
154
 
154
155
  interface CommandOptions {
155
156
  readonly env?: Record<string, string | undefined>;
@@ -267,6 +268,51 @@ async function tryText(
267
268
  return result.stdout;
268
269
  }
269
270
 
271
+ // ════════════════════════════════════════════════════════════════════════════
272
+ // Internal: per-repo write serialization
273
+ // ════════════════════════════════════════════════════════════════════════════
274
+
275
+ // Git uses lock files (`.git/config.lock`, commit-graph chain locks,
276
+ // `packed-refs.lock`, …) for many of its mutating operations. Each is created
277
+ // O_EXCL with no waiter, so concurrent in-process git invocations against the
278
+ // same repository fail immediately rather than block. Worktrees share the
279
+ // primary repo's `.git` directory, so racing across worktrees has the same
280
+ // failure mode. We give callers a single per-repo serialization point keyed by
281
+ // the primary repo root: any block that mutates repo state should hold this
282
+ // lock so unrelated callers cannot collide on git's internal locks.
283
+ const repoWriteChain = new Map<string, Promise<unknown>>();
284
+
285
+ /**
286
+ * Serialize an async block that mutates a git repository against other
287
+ * in-process callers operating on the same repository. The lock is keyed by
288
+ * the primary repo root so worktrees of the same repo share a single queue.
289
+ * Failures in one block do not poison the queue for the next caller.
290
+ *
291
+ * Not reentrant: do NOT nest acquisitions for the same repo. Helpers in this
292
+ * module never auto-acquire — callers wrap the critical section themselves.
293
+ */
294
+ export async function withRepoLock<T>(cwd: string, fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
295
+ const key = (await repo.primaryRoot(cwd, signal)) ?? cwd;
296
+ const prior = repoWriteChain.get(key);
297
+ const run = (async () => {
298
+ if (prior) {
299
+ try {
300
+ await prior;
301
+ } catch {
302
+ // A prior caller failing must not block us from running.
303
+ }
304
+ }
305
+ throwIfAborted(signal);
306
+ return fn();
307
+ })();
308
+ repoWriteChain.set(key, run);
309
+ try {
310
+ return await run;
311
+ } finally {
312
+ if (repoWriteChain.get(key) === run) repoWriteChain.delete(key);
313
+ }
314
+ }
315
+
270
316
  function splitLines(text: string): string[] {
271
317
  return text
272
318
  .split("\n")
@@ -955,9 +1001,22 @@ export const remote = {
955
1001
  return trimScalar(await tryText(cwd, ["remote", "get-url", name], { readOnly: true, signal }));
956
1002
  },
957
1003
 
958
- /** Add a new remote. */
1004
+ /**
1005
+ * Add a remote pointing at `url`. Idempotent: if a remote named `name`
1006
+ * already exists with the same URL (e.g. an in-process race or a leftover
1007
+ * remote from a previous run), this is treated as success. Throws when the
1008
+ * remote exists with a different URL — that's a real conflict the caller
1009
+ * needs to resolve, not paper over.
1010
+ */
959
1011
  async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
960
- await runEffect(cwd, ["remote", "add", name, url], { signal });
1012
+ const result = await runCommand(cwd, ["remote", "add", name, url], { signal });
1013
+ if (result.exitCode === 0) return;
1014
+ if (REMOTE_ALREADY_EXISTS.test(result.stderr)) {
1015
+ const existing = await remote.url(cwd, name, signal);
1016
+ if (existing === url) return;
1017
+ throw new ToolError(`remote ${name} already exists with URL ${existing ?? "(unset)"}, expected ${url}`);
1018
+ }
1019
+ throw new GitCommandError(["remote", "add", name, url], result);
961
1020
  },
962
1021
  };
963
1022