@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.
@@ -21,7 +21,7 @@ const USAGE = [
21
21
  " /todo export <path> Write todos as Markdown to <path>",
22
22
  " /todo import <path> Replace todos from Markdown at <path>",
23
23
  " /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
24
- " /todo start <task> Mark task in_progress (id or fuzzy content)",
24
+ " /todo start <task> Mark task in_progress (fuzzy content match)",
25
25
  " /todo done [<task|phase>] Mark task/phase/all completed",
26
26
  " /todo drop [<task|phase>] Mark task/phase/all abandoned",
27
27
  " /todo rm [<task|phase>] Remove task/phase/all",
@@ -59,44 +59,9 @@ function tokenize(input: string): string[] {
59
59
  }
60
60
 
61
61
  // =============================================================================
62
- // Roman numerals + name normalization
62
+ // Name normalization
63
63
  // =============================================================================
64
64
 
65
- const ROMAN_PAIRS: Array<[number, string]> = [
66
- [1000, "M"],
67
- [900, "CM"],
68
- [500, "D"],
69
- [400, "CD"],
70
- [100, "C"],
71
- [90, "XC"],
72
- [50, "L"],
73
- [40, "XL"],
74
- [10, "X"],
75
- [9, "IX"],
76
- [5, "V"],
77
- [4, "IV"],
78
- [1, "I"],
79
- ];
80
-
81
- function toRoman(n: number): string {
82
- if (n <= 0) return "I";
83
- let out = "";
84
- let rem = n;
85
- for (const [value, sym] of ROMAN_PAIRS) {
86
- while (rem >= value) {
87
- out += sym;
88
- rem -= value;
89
- }
90
- }
91
- return out;
92
- }
93
-
94
- const PHASE_PREFIX_RE = /^([IVXLCDM]+|[A-Z]|\d+)\.\s*/i;
95
-
96
- function stripPrefix(name: string): string {
97
- return name.replace(PHASE_PREFIX_RE, "").trim();
98
- }
99
-
100
65
  function titleCase(s: string): string {
101
66
  return s
102
67
  .split(/\s+/)
@@ -105,13 +70,6 @@ function titleCase(s: string): string {
105
70
  .join(" ");
106
71
  }
107
72
 
108
- function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
109
- const stripped = stripPrefix(rawName.trim());
110
- if (!stripped) return `${toRoman(existingPhases.length + 1)}. Todos`;
111
- const titled = titleCase(stripped);
112
- return `${toRoman(existingPhases.length + 1)}. ${titled}`;
113
- }
114
-
115
73
  // =============================================================================
116
74
  // Fuzzy matching
117
75
  // =============================================================================
@@ -119,20 +77,13 @@ function buildPhaseName(rawName: string, existingPhases: TodoPhase[]): string {
119
77
  function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefined {
120
78
  const q = query.trim().toLowerCase();
121
79
  if (!q) return undefined;
122
- // Exact id
123
- const byId = phases.find(p => p.id.toLowerCase() === q);
124
- if (byId) return byId;
125
80
  // Exact name (case-insensitive)
126
81
  const byName = phases.find(p => p.name.toLowerCase() === q);
127
82
  if (byName) return byName;
128
- // Stripped name match
129
- const strippedQ = stripPrefix(q);
130
- const byStripped = phases.find(p => stripPrefix(p.name).toLowerCase() === strippedQ);
131
- if (byStripped) return byStripped;
132
- // Substring (prefer prefix match on stripped name)
133
- const prefixMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().startsWith(strippedQ));
83
+ // Substring (prefer prefix match)
84
+ const prefixMatches = phases.filter(p => p.name.toLowerCase().startsWith(q));
134
85
  if (prefixMatches.length === 1) return prefixMatches[0];
135
- const subMatches = phases.filter(p => stripPrefix(p.name).toLowerCase().includes(strippedQ));
86
+ const subMatches = phases.filter(p => p.name.toLowerCase().includes(q));
136
87
  if (subMatches.length === 1) return subMatches[0];
137
88
  return undefined;
138
89
  }
@@ -140,9 +91,10 @@ function findPhaseFuzzy(phases: TodoPhase[], query: string): TodoPhase | undefin
140
91
  function findTaskFuzzy(phases: TodoPhase[], query: string): { task: TodoItem; phase: TodoPhase } | undefined {
141
92
  const q = query.trim().toLowerCase();
142
93
  if (!q) return undefined;
94
+ // Exact content (case-insensitive)
143
95
  for (const phase of phases) {
144
96
  for (const task of phase.tasks) {
145
- if (task.id.toLowerCase() === q) return { task, phase };
97
+ if (task.content.toLowerCase() === q) return { task, phase };
146
98
  }
147
99
  }
148
100
  const matches: Array<{ task: TodoItem; phase: TodoPhase }> = [];
@@ -169,7 +121,7 @@ function buildSystemReminder(action: string, phases: TodoPhase[]): string {
169
121
  return [
170
122
  "<system-reminder>",
171
123
  `The user manually modified the todo list (${action}).`,
172
- "Current todo list (note task ids may have been reassigned by /todo edit):",
124
+ "Current todo list:",
173
125
  "",
174
126
  md,
175
127
  "</system-reminder>",
@@ -327,28 +279,24 @@ export class TodoCommandController {
327
279
  if (phaseName) {
328
280
  targetPhase = findPhaseFuzzy(next, phaseName);
329
281
  if (!targetPhase) {
330
- const newName = buildPhaseName(phaseName, next);
331
- targetPhase = { id: `phase-${next.length + 1}`, name: newName, tasks: [] };
282
+ targetPhase = { name: titleCase(phaseName), tasks: [] };
332
283
  next.push(targetPhase);
333
284
  }
334
285
  } else if (next.length > 0) {
335
286
  targetPhase = next[next.length - 1];
336
287
  } else {
337
- targetPhase = { id: "phase-1", name: `${toRoman(1)}. Todos`, tasks: [] };
288
+ targetPhase = { name: "Todos", tasks: [] };
338
289
  next.push(targetPhase);
339
290
  }
340
291
 
341
- const usedTaskIds = new Set(next.flatMap(p => p.tasks.map(t => t.id)));
342
- let n = 1;
343
- while (usedTaskIds.has(`task-${n}`)) n++;
292
+ const finalContent = titleCaseSentence(content);
344
293
  targetPhase.tasks.push({
345
- id: `task-${n}`,
346
- content: titleCaseSentence(content),
294
+ content: finalContent,
347
295
  status: "pending",
348
296
  });
349
297
 
350
298
  this.#commit(next, `/todo append → ${targetPhase.name}`);
351
- this.ctx.showStatus(`Appended to ${targetPhase.name}: ${content}`);
299
+ this.ctx.showStatus(`Appended to ${targetPhase.name}: ${finalContent}`);
352
300
  }
353
301
 
354
302
  // ------------------------------------------------------------- start / done / drop / rm
@@ -364,12 +312,12 @@ export class TodoCommandController {
364
312
  this.ctx.showError(`No task matched "${rest}". Use /todo to list current tasks.`);
365
313
  return;
366
314
  }
367
- const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.id }]);
315
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "start", task: hit.task.content }]);
368
316
  if (errors.length > 0) {
369
317
  this.ctx.showError(errors.join("; "));
370
318
  return;
371
319
  }
372
- this.#commit(phases, `/todo start ${hit.task.id}`);
320
+ this.#commit(phases, `/todo start ${hit.task.content}`);
373
321
  this.ctx.showStatus(`Started: ${hit.task.content}`);
374
322
  }
375
323
 
@@ -391,19 +339,19 @@ export class TodoCommandController {
391
339
 
392
340
  const taskHit = findTaskFuzzy(current, trimmed);
393
341
  if (taskHit) {
394
- const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.id }]);
342
+ const { phases, errors } = applyOpsToPhases(current, [{ op, task: taskHit.task.content }]);
395
343
  if (errors.length > 0) {
396
344
  this.ctx.showError(errors.join("; "));
397
345
  return;
398
346
  }
399
- this.#commit(phases, `/todo ${op} ${taskHit.task.id}`);
347
+ this.#commit(phases, `/todo ${op} ${taskHit.task.content}`);
400
348
  this.ctx.showStatus(`Marked ${target}: ${taskHit.task.content}`);
401
349
  return;
402
350
  }
403
351
 
404
352
  const phaseHit = findPhaseFuzzy(current, trimmed);
405
353
  if (phaseHit) {
406
- const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.id }]);
354
+ const { phases, errors } = applyOpsToPhases(current, [{ op, phase: phaseHit.name }]);
407
355
  if (errors.length > 0) {
408
356
  this.ctx.showError(errors.join("; "));
409
357
  return;
@@ -426,18 +374,18 @@ export class TodoCommandController {
426
374
  }
427
375
  const taskHit = findTaskFuzzy(current, trimmed);
428
376
  if (taskHit) {
429
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.id }]);
377
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", task: taskHit.task.content }]);
430
378
  if (errors.length > 0) {
431
379
  this.ctx.showError(errors.join("; "));
432
380
  return;
433
381
  }
434
- this.#commit(phases, `/todo rm ${taskHit.task.id}`);
382
+ this.#commit(phases, `/todo rm ${taskHit.task.content}`);
435
383
  this.ctx.showStatus(`Removed: ${taskHit.task.content}`);
436
384
  return;
437
385
  }
438
386
  const phaseHit = findPhaseFuzzy(current, trimmed);
439
387
  if (phaseHit) {
440
- const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.id }]);
388
+ const { phases, errors } = applyOpsToPhases(current, [{ op: "rm", phase: phaseHit.name }]);
441
389
  if (errors.length > 0) {
442
390
  this.ctx.showError(errors.join("; "));
443
391
  return;
@@ -460,7 +408,7 @@ export class TodoCommandController {
460
408
 
461
409
  const current = this.#currentPhases();
462
410
  const initialMarkdown =
463
- current.length > 0 ? phasesToMarkdown(current) : "# I. Todos\n- [ ] (replace this with your tasks)\n";
411
+ current.length > 0 ? phasesToMarkdown(current) : "# Todos\n- [ ] (replace this with your tasks)\n";
464
412
 
465
413
  const fileHandle = await this.#openTtyHandle();
466
414
  this.ctx.ui.stop();
@@ -48,6 +48,7 @@ import { getRecentSessions } from "../session/session-manager";
48
48
  import { STTController, type SttState } from "../stt";
49
49
  import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
50
50
  import { normalizeLocalScheme } from "../tools/path-utils";
51
+ import { formatPhaseDisplayName } from "../tools/todo-write";
51
52
  import type { EventBus } from "../utils/event-bus";
52
53
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
53
54
  import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color";
@@ -707,9 +708,12 @@ export class InteractiveMode implements InteractiveModeContext {
707
708
  const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
708
709
 
709
710
  if (!this.todoExpanded) {
710
- const activePhase = this.#getActivePhase(phases);
711
+ const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
712
+ const activePhase = phases[activeIdx];
711
713
  if (!activePhase) return;
712
- lines.push(`${indent}${theme.fg("accent", `${hook} ${activePhase.name}`)}`);
714
+ lines.push(
715
+ `${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
716
+ );
713
717
  const visibleTasks = activePhase.tasks.slice(0, 5);
714
718
  visibleTasks.forEach((todo, index) => {
715
719
  const prefix = `${indent}${index === 0 ? hook : " "} `;
@@ -723,13 +727,13 @@ export class InteractiveMode implements InteractiveModeContext {
723
727
  return;
724
728
  }
725
729
 
726
- for (const phase of phases) {
727
- lines.push(`${indent}${theme.fg("accent", `${hook} ${phase.name}`)}`);
730
+ phases.forEach((phase, phaseIndex) => {
731
+ lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
728
732
  phase.tasks.forEach((todo, index) => {
729
733
  const prefix = `${indent}${index === 0 ? hook : " "} `;
730
734
  lines.push(this.#formatTodoLine(todo, prefix));
731
735
  });
732
- }
736
+ });
733
737
 
734
738
  this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
735
739
  }
@@ -1712,7 +1716,6 @@ export class InteractiveMode implements InteractiveModeContext {
1712
1716
  } else {
1713
1717
  this.todoPhases = [
1714
1718
  {
1715
- id: "default",
1716
1719
  name: "Todos",
1717
1720
  tasks: todos as TodoItem[],
1718
1721
  },
@@ -42,7 +42,6 @@ export type SubmittedUserInput = {
42
42
  export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
43
43
 
44
44
  export type TodoItem = {
45
- id: string;
46
45
  content: string;
47
46
  status: TodoStatus;
48
47
  details?: string;
@@ -50,7 +49,6 @@ export type TodoItem = {
50
49
  };
51
50
 
52
51
  export type TodoPhase = {
53
- id: string;
54
52
  name: string;
55
53
  tasks: TodoItem[];
56
54
  };
@@ -2,7 +2,7 @@
2
2
  Before doing substantive work on the upcoming user request, create a comprehensive phased todo first.
3
3
 
4
4
  You **MUST** call `todo_write` first in this turn.
5
- You **MUST** initialize the todo list with a single `replace` op.
5
+ You **MUST** initialize the todo list with a single `init` op.
6
6
  You **MUST** cover the entire request from investigation through implementation and verification — not just the next immediate step.
7
7
  You **MUST** make task descriptions specific enough that a future turn can execute them without re-planning.
8
8
  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.
5
5
 
6
6
  |`op`|Required fields|Effect|
7
7
  |---|---|---|
8
- |`replace`|`phases`|Replace the full list (initial setup, full restructure)|
9
- |`start`|`task`|Set task to `in_progress`|
10
- |`done`|`task` or `phase` (or neither = all)|Mark completed|
11
- |`drop`|`task` or `phase` (or neither = all)|Mark abandoned|
12
- |`rm`|`task` or `phase` (or neither = all)|Remove|
13
- |`append`|`phase`, `items: {id, label}[]`|Append tasks; creates phase if missing|
14
- |`note`|`task`, `text`|Append a note to `task.notes`. Only use to leave reminders for future-you.|
8
+ |`init`|`list`|Initialize the full list|
9
+ |`start`|`task`|Mark in progress|
10
+ |`done`|`task` or `phase`|Mark completed|
11
+ |`drop`|`task` or `phase`|Mark abandoned|
12
+ |`rm`|`task` or `phase`|Remove|
13
+ |`append`|`phase`, `items: string[]`|Append tasks; lazily creates phase|
14
+ |`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.|
15
15
 
16
16
  ## Anatomy
17
- - **Task `label`**: 5–10 words, what is being done, not how.
18
- - **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.
17
+ - **Task content**: 5–10 words, what is being done, not how. Used as the task identifier — unique.
18
+ - **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.
19
19
 
20
20
  ## Rules
21
- - Mark tasks done immediately after finishing — never defer.
21
+ - Mark tasks done immediately after finishing.
22
22
  - Complete phases in order.
23
- - On blockers, `append` a new task to the active phase.
24
- - Keep ids stable once introduced.
23
+ - On blockers, `append` a new task to the active phase to unblock yourself, or `drop`.
24
+ - `task` and `phase` fields reference content/name verbatim; keep them stable once introduced.
25
25
 
26
26
  ## When to create a list
27
27
  - Task requires 3+ distinct steps
@@ -31,17 +31,17 @@ The next pending task is auto-promoted to `in_progress` after each completion.
31
31
 
32
32
  <examples>
33
33
  # Initial setup (multi-phase)
34
- `{"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"}]}]}]}`
35
- # Initial setup (single phase — still prefixed)
36
- `{"ops":[{"op":"replace","phases":[{"name":"I. Implementation","tasks":[{"content":"Apply fix"},{"content":"Run tests"}]}]}]}`
34
+ `{"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"]}]}]}`
35
+ # Initial setup (single phase)
36
+ `{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}`
37
37
  # Complete one task
38
- `{"ops":[{"op":"done","task":"task-2"}]}`
38
+ `{"ops":[{"op":"done","task":"Wire workspace"}]}`
39
39
  # Complete a whole phase
40
- `{"ops":[{"op":"done","phase":"II. Auth"}]}`
40
+ `{"ops":[{"op":"done","phase":"Auth"}]}`
41
41
  # Remove all tasks
42
42
  `{"ops":[{"op":"rm"}]}`
43
43
  # Drop one task
44
- `{"ops":[{"op":"drop","task":"task-7"}]}`
44
+ `{"ops":[{"op":"drop","task":"Run cargo test"}]}`
45
45
  # Append tasks to a phase
46
- `{"ops":[{"op":"append","phase":"II. Auth","items":[{"id":"task-8","label":"Handle retries"},{"id":"task-9","label":"Run tests"}]}]}`
46
+ `{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
47
47
  </examples>
@@ -387,6 +387,11 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
387
387
  return `${selector.provider}/${selector.id}`;
388
388
  }
389
389
 
390
+ /** Composite key for auto-clear timers, keyed by phase name + task content. */
391
+ function todoClearKey(phaseName: string, taskContent: string): string {
392
+ return `${phaseName}\u0000${taskContent}`;
393
+ }
394
+
390
395
  const noOpUIContext: ExtensionUIContext = {
391
396
  select: async (_title, _options, _dialogOptions) => undefined,
392
397
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -3347,10 +3352,9 @@ export class AgentSession {
3347
3352
 
3348
3353
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
3349
3354
  return phases.map(phase => ({
3350
- id: phase.id,
3351
3355
  name: phase.name,
3352
3356
  tasks: phase.tasks.map(task => {
3353
- const out: TodoItem = { id: task.id, content: task.content, status: task.status };
3357
+ const out: TodoItem = { content: task.content, status: task.status };
3354
3358
  if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
3355
3359
  return out;
3356
3360
  }),
@@ -3362,43 +3366,43 @@ export class AgentSession {
3362
3366
  const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
3363
3367
  if (delaySec < 0) return; // "Never" — no auto-clear
3364
3368
  const delayMs = delaySec * 1000;
3365
- const doneTaskIds = new Set<string>();
3369
+ const doneKeys = new Set<string>();
3366
3370
  for (const phase of phases) {
3367
3371
  for (const task of phase.tasks) {
3368
3372
  if (task.status === "completed" || task.status === "abandoned") {
3369
- doneTaskIds.add(task.id);
3373
+ doneKeys.add(todoClearKey(phase.name, task.content));
3370
3374
  }
3371
3375
  }
3372
3376
  }
3373
3377
 
3374
3378
  // Cancel timers for tasks that are no longer done (e.g. status was reverted)
3375
- for (const [id, timer] of this.#todoClearTimers) {
3376
- if (!doneTaskIds.has(id)) {
3379
+ for (const [key, timer] of this.#todoClearTimers) {
3380
+ if (!doneKeys.has(key)) {
3377
3381
  clearTimeout(timer);
3378
- this.#todoClearTimers.delete(id);
3382
+ this.#todoClearTimers.delete(key);
3379
3383
  }
3380
3384
  }
3381
3385
 
3382
3386
  // Schedule new timers for newly-done tasks
3383
- for (const id of doneTaskIds) {
3384
- if (this.#todoClearTimers.has(id)) continue;
3387
+ for (const key of doneKeys) {
3388
+ if (this.#todoClearTimers.has(key)) continue;
3385
3389
  if (delayMs === 0) {
3386
3390
  // Instant — run synchronously on next microtask to batch removals
3387
- const timer = setTimeout(() => this.#runTodoAutoClear(id), 0);
3388
- this.#todoClearTimers.set(id, timer);
3391
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
3392
+ this.#todoClearTimers.set(key, timer);
3389
3393
  } else {
3390
- const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs);
3391
- this.#todoClearTimers.set(id, timer);
3394
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
3395
+ this.#todoClearTimers.set(key, timer);
3392
3396
  }
3393
3397
  }
3394
3398
  }
3395
3399
 
3396
3400
  /** Remove a single completed task and notify the UI. */
3397
- #runTodoAutoClear(taskId: string): void {
3398
- this.#todoClearTimers.delete(taskId);
3401
+ #runTodoAutoClear(key: string): void {
3402
+ this.#todoClearTimers.delete(key);
3399
3403
  let removed = false;
3400
3404
  for (const phase of this.#todoPhases) {
3401
- const idx = phase.tasks.findIndex(t => t.id === taskId);
3405
+ const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
3402
3406
  if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
3403
3407
  phase.tasks.splice(idx, 1);
3404
3408
  removed = true;
@@ -4568,7 +4572,7 @@ export class AgentSession {
4568
4572
  (task): task is TodoItem & { status: "pending" | "in_progress" } =>
4569
4573
  task.status === "pending" || task.status === "in_progress",
4570
4574
  )
4571
- .map(task => ({ id: task.id, content: task.content, status: task.status })),
4575
+ .map(task => ({ content: task.content, status: task.status })),
4572
4576
  }))
4573
4577
  .filter(phase => phase.tasks.length > 0);
4574
4578
  const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);