@pi9/todo 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -1,87 +1,17 @@
1
1
  # @pi9/todo
2
2
 
3
- A phased, branch-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
3
+ A phased, session-aware todo tool for the [Pi coding agent](https://github.com/earendil-works/pi-mono), with adaptive system reminders and a persistent widget that keeps the current plan visible while you work.
4
4
 
5
- ## Features
6
-
7
- - Concise phased plans with immutable task names
8
- - Destructive plan replacement and non-destructive task addition
9
- - Atomic status transitions addressed by exact phase and task names
10
- - Explicit `pending`, `in_progress`, `completed`, and `cancelled` statuses
11
- - State restored from the active Pi session branch
12
- - A persistent, configurable todo widget above or below the editor
13
- - Rich tool rendering with active-task summaries and expandable phase progress
14
- - Native-style self-rendered tool shells with no extra spacing for hidden activity
15
-
16
- Todo snapshots are stored in tool-result details, so `/tree` navigation restores the plan associated with that branch.
17
-
18
- ## Tool contract
19
-
20
- The provider-facing schema is one flat object rather than a union, which keeps it compatible across Pi providers. Action-specific requirements are validated atomically by the tool.
5
+ ![A phased todo plan showing completed, active, and pending tasks](media/todo-plan.png)
21
6
 
22
- ### Set the complete plan
23
-
24
- `set` discards the complete current plan and creates exactly the supplied phases and tasks. Every task starts `pending`. An empty `phases` array clears the plan.
25
-
26
- ```json
27
- {
28
- "action": "set",
29
- "phases": [
30
- {
31
- "name": "Build",
32
- "tasks": ["Implement session restoration", "Add integration coverage"]
33
- }
34
- ]
35
- }
36
- ```
37
-
38
- ### Add newly discovered work
39
-
40
- `add` creates missing phases or appends tasks to existing phases without changing current tasks or statuses. New tasks start `pending`.
41
-
42
- ```json
43
- {
44
- "action": "add",
45
- "phases": [
46
- {
47
- "name": "Verify",
48
- "tasks": ["Run the complete test suite"]
49
- }
50
- ]
51
- }
52
- ```
53
-
54
- ### Transition task statuses
55
-
56
- `transition` applies status changes atomically using exact phase and task names.
57
-
58
- ```json
59
- {
60
- "action": "transition",
61
- "transitions": [
62
- {
63
- "phase": "Build",
64
- "task": "Implement session restoration",
65
- "status": "completed"
66
- },
67
- {
68
- "phase": "Verify",
69
- "task": "Run the complete test suite",
70
- "status": "in_progress"
71
- }
72
- ]
73
- }
74
- ```
75
-
76
- Phase names and task names are immutable. Task names must be unique within their phase. Cancel obsolete tasks instead of removing them; cancel and add a corrected task when its name needs to change. All `in_progress` tasks must belong to one phase.
77
-
78
- ### View the plan
79
-
80
- `view` returns the complete plan or one exact phase:
7
+ ## Features
81
8
 
82
- ```json
83
- { "action": "view", "phase": "Build" }
84
- ```
9
+ - **Phased planning.** Organize work into concise phases with immutable task names, detailed descriptions, and explicit `pending`, `in_progress`, `completed`, or `cancelled` statuses.
10
+ - **Context-efficient tool.** A compact, purpose-built tool description gives the model clear planning controls while consuming minimal context-window space.
11
+ - **Session-aware state.** Todo snapshots travel with Pi session branches, so `/tree` navigation restores the plan associated with each branch.
12
+ - **Persistent widget.** Keep active work visible above or below the editor, with configurable placement, task limits, and status glyphs.
13
+ - **Adaptive reminders.** System reminders refresh the model's awareness of stale plans during long runs and restore the full plan after context compaction.
14
+ - **Native tool rendering.** Compact tool output summarizes active work and expands into phase progress while respecting configurable visibility settings.
85
15
 
86
16
  ## Install
87
17
 
@@ -104,7 +34,12 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
104
34
  "widgetPlacement": "aboveEditor",
105
35
  "maxVisibleTasks": 5,
106
36
  "fallbackGlyphs": false,
107
- "toolVisibility": "set-only"
37
+ "toolVisibility": "set-only",
38
+ "dynamicReminders": true,
39
+ "reminderMinTurns": 4,
40
+ "reminderMaxTurns": 8,
41
+ "reminderOutputTokens": 16000,
42
+ "reminderMaxPerRun": 2
108
43
  }
109
44
  ```
110
45
 
@@ -116,9 +51,13 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
116
51
  - `"set-only"` shows only `set` operations.
117
52
  - `"none"` hides normal Todo activity.
118
53
 
119
- Errors are always shown. Todo output uses native-style self-rendered shells, and hidden successful operations render zero lines. When expanded, the latest rendered `set` result on the active branch follows later additions and transitions; historical details and collapsed rendering remain unchanged.
54
+ Errors are always shown, while hidden successful operations take up no terminal space. Expanded output stays synchronized with the latest plan on the active branch.
55
+
56
+ Dynamic reminders keep the model aware of the plan during longer runs without adding messages to session history. The turn, output-token, and per-run settings control their cadence; set `dynamicReminders` to `false` to disable them.
57
+
58
+ After Pi compacts the context window, the extension supplies the full phased plan once on the next turn so work can continue without losing task state. This does not alter Pi's compaction summary or session history.
120
59
 
121
- Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Set `widgetPlacement` to `"off"` to disable it.
60
+ Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Active tasks keep their normal status glyph; when work is active, a separate indented current-work line below the plan uses Pi's standard spinner and dim text. After all tasks become terminal, the widget shows the final phase summary for five seconds before clearing. Set `widgetPlacement` to `"off"` to disable the widget.
122
61
 
123
62
  ## Development
124
63
 
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi9/todo",
3
- "version": "0.1.0",
4
- "description": "Pi extension for managing agent todo tasks.",
3
+ "version": "0.3.0",
4
+ "description": "Phased, session-aware todo planning for Pi agents.",
5
5
  "license": "MIT",
6
6
  "author": "Chase Cummings <chaseecummings@gmail.com>",
7
7
  "type": "module",
@@ -18,6 +18,7 @@
18
18
  "homepage": "https://github.com/Chase-C/pi9/tree/main/packages/todo#readme",
19
19
  "files": [
20
20
  "src",
21
+ "media",
21
22
  "README.md",
22
23
  "LICENSE"
23
24
  ],
package/src/format.ts CHANGED
@@ -1,6 +1,5 @@
1
- import type { Todo, TodoState } from "./types.js";
2
-
3
- export type PhasedTodo = Todo & { phase: string };
1
+ import { todoGlyph } from "./glyphs.js";
2
+ import type { Todo, TodoState, TodoStatus } from "./types.js";
4
3
 
5
4
  export interface TodoCounts {
6
5
  open: number;
@@ -8,8 +7,10 @@ export interface TodoCounts {
8
7
  cancelled: number;
9
8
  }
10
9
 
10
+ export type TodoStatusCounts = Record<TodoStatus, number>;
11
+
11
12
  /** A small, plain-text representation used in tool results and model context. */
12
- export function formatTodoSummary(state: TodoState | undefined): string {
13
+ export function formatTodoSummary(state: TodoState | undefined, includeDescriptions = false): string {
13
14
  const tasks = todoTasks(state);
14
15
  if (tasks.length === 0) return "No todo tasks.";
15
16
 
@@ -20,42 +21,78 @@ export function formatTodoSummary(state: TodoState | undefined): string {
20
21
  ...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
21
22
  ].join(" · ");
22
23
 
23
- return [`Todo: ${summary}`, ...formatTodoTaskLines(state)].join("\n");
24
+ return [
25
+ `Todo: ${summary}`,
26
+ ...(includeDescriptions && state?.workingOn ? [`Working on: ${state.workingOn}`] : []),
27
+ ...formatTodoTaskLines(state, includeDescriptions),
28
+ ].join("\n");
24
29
  }
25
30
 
26
- export function formatTodoTaskLines(state: TodoState | undefined): string[] {
27
- if (!state || state.phases.every((phase) => phase.tasks.length === 0)) return [];
31
+ export function formatTodoTaskLines(state: TodoState | undefined, includeDescriptions = false): string[] {
32
+ if (!state) return [];
33
+
28
34
  const lines: string[] = [];
29
35
  for (const phase of state.phases) {
30
36
  if (phase.tasks.length === 0) continue;
31
37
  lines.push(`${phase.name}:`);
32
- lines.push(...phase.tasks.map((task) => ` ${taskMarker(task)} ${task.name}`));
38
+ lines.push(...phase.tasks.map((task) =>
39
+ ` ${taskMarker(task)} ${task.name}${includeDescriptions ? ` — ${task.description}` : ""}`,
40
+ ));
33
41
  }
34
42
  return lines;
35
43
  }
36
44
 
37
- export function countTodos(state: TodoState | readonly Todo[] | undefined): TodoCounts {
38
- const tasks = Array.isArray(state) ? state : todoTasks(state as TodoState | undefined);
39
- let open = 0;
40
- let completed = 0;
41
- let cancelled = 0;
42
- for (const task of tasks) {
43
- if (task.status === "completed") completed++;
44
- else if (task.status === "cancelled") cancelled++;
45
- else open++;
46
- }
47
- return { open, completed, cancelled };
45
+ /** Formats the complete one-shot Todo snapshot injected after compaction. */
46
+ export function formatTodoCompactionContext(state: TodoState): string | undefined {
47
+ if (!state.phases.some((phase) => phase.tasks.length > 0)) return undefined;
48
+
49
+ const plan = state.phases.flatMap((phase) => [
50
+ `${phase.name}:`,
51
+ ...(phase.tasks.length === 0
52
+ ? [" (no tasks)"]
53
+ : phase.tasks.map((task) => ` [${task.status}] ${task.name}: ${task.description}`)),
54
+ ]);
55
+ return [
56
+ "<system-reminder source=\"todo-post-compaction\">",
57
+ "Todo plan after compaction:",
58
+ ...(state.workingOn ? [`Current work: ${state.workingOn}`] : []),
59
+ ...plan,
60
+ "Continue using this plan and keep task statuses current.",
61
+ "Do not mention this reminder to the user.",
62
+ "</system-reminder>",
63
+ ].join("\n");
64
+ }
65
+
66
+ export function countTodoStatuses(tasks: readonly Todo[]): TodoStatusCounts {
67
+ const counts: TodoStatusCounts = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
68
+ for (const task of tasks) counts[task.status] += 1;
69
+ return counts;
70
+ }
71
+
72
+ export function countTodos(tasks: readonly Todo[]): TodoCounts {
73
+ const counts = countTodoStatuses(tasks);
74
+ return {
75
+ open: counts.pending + counts.in_progress,
76
+ completed: counts.completed,
77
+ cancelled: counts.cancelled,
78
+ };
79
+ }
80
+
81
+ export function formatTodoProgress(label: string, tasks: readonly Todo[]): string {
82
+ const counts = countTodoStatuses(tasks);
83
+ return [
84
+ label,
85
+ ...(counts.in_progress ? [`${counts.in_progress} active`] : []),
86
+ ...(counts.pending ? [`${counts.pending} pending`] : []),
87
+ ...(counts.completed ? [`${counts.completed} completed`] : []),
88
+ ...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
89
+ ].join(" · ");
48
90
  }
49
91
 
50
92
  export function taskMarker(task: Pick<Todo, "status">): string {
51
- switch (task.status) {
52
- case "completed": return "✓";
53
- case "in_progress": return "▶";
54
- case "cancelled": return "×";
55
- default: return "○";
56
- }
93
+ return todoGlyph(task.status, true);
57
94
  }
58
95
 
59
- export function todoTasks(state: TodoState | undefined): PhasedTodo[] {
60
- return state?.phases.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name }))) ?? [];
96
+ export function todoTasks(state: TodoState | undefined): readonly Todo[] {
97
+ return state?.phases.flatMap((phase) => phase.tasks) ?? [];
61
98
  }
@@ -1,79 +1,50 @@
1
- import { TODO_ACTIONS, TODO_STATUSES, type TodoState, type TodoToolDetails } from "./types.js";
1
+ import { cloneTodoState, createTodoState, isTodoState } from "./state.js";
2
+ import { isTodoActionName, type TodoActionName, type TodoState } from "./types.js";
2
3
 
3
4
  export const TODO_TOOL_NAME = "todo";
4
5
 
5
- export const createEmptyTodoState = (): TodoState => ({ phases: [] });
6
-
7
- export function cloneTodoState(state: TodoState): TodoState {
8
- return structuredClone(state);
9
- }
10
-
11
6
  type BranchContext = {
12
7
  sessionManager: {
13
8
  getBranch(): readonly unknown[];
14
9
  };
15
10
  };
16
11
 
17
- type ToolResultEntry = {
12
+ type TodoSnapshotDetails = {
13
+ action: TodoActionName;
14
+ state: TodoState;
15
+ };
16
+
17
+ type TodoResultEntry = {
18
18
  type: "message";
19
19
  message: {
20
20
  role: "toolResult";
21
- toolName?: unknown;
21
+ toolName: typeof TODO_TOOL_NAME;
22
22
  isError?: unknown;
23
- details?: unknown;
23
+ details: TodoSnapshotDetails;
24
24
  };
25
25
  };
26
26
 
27
- function isTodoState(value: unknown): value is TodoState {
28
- if (typeof value !== "object" || value === null) return false;
29
- const state = value as { phases?: unknown };
30
- if (!Array.isArray(state.phases)) return false;
31
-
32
- const phaseNames = new Set<string>();
33
- let activePhase: string | undefined;
34
- for (const value of state.phases) {
35
- if (typeof value !== "object" || value === null) return false;
36
- const phase = value as { name?: unknown; tasks?: unknown };
37
- if (!validName(phase.name) || phaseNames.has(phase.name) || !Array.isArray(phase.tasks)) return false;
38
- phaseNames.add(phase.name);
39
-
40
- const taskNames = new Set<string>();
41
- for (const value of phase.tasks) {
42
- if (typeof value !== "object" || value === null) return false;
43
- const task = value as { name?: unknown; status?: unknown };
44
- if (!validName(task.name) || taskNames.has(task.name) || !(TODO_STATUSES as readonly unknown[]).includes(task.status)) return false;
45
- if (task.status === "in_progress") {
46
- if (activePhase !== undefined && activePhase !== phase.name) return false;
47
- activePhase = phase.name;
48
- }
49
- taskNames.add(task.name);
50
- }
51
- }
52
- return true;
53
- }
54
-
55
- function validName(value: unknown): value is string {
56
- return typeof value === "string" && value !== "" && value === value.trim();
57
- }
58
-
59
- function isTodoToolDetails(value: unknown): value is TodoToolDetails {
60
- if (typeof value !== "object" || value === null) return false;
27
+ function isTodoSnapshotDetails(value: unknown): value is TodoSnapshotDetails {
28
+ if (!value || typeof value !== "object") return false;
61
29
  const details = value as { action?: unknown; state?: unknown };
62
- return typeof details.action === "string"
63
- && (TODO_ACTIONS as readonly string[]).includes(details.action)
64
- && isTodoState(details.state);
30
+ return isTodoActionName(details.action) && isTodoState(details.state);
65
31
  }
66
32
 
67
- function isSuccessfulTodoResult(value: unknown): value is ToolResultEntry {
68
- if (typeof value !== "object" || value === null) return false;
69
- const entry = value as Partial<ToolResultEntry>;
70
- return entry.type === "message"
71
- && typeof entry.message === "object"
72
- && entry.message !== null
73
- && entry.message.role === "toolResult"
74
- && entry.message.toolName === TODO_TOOL_NAME
75
- && entry.message.isError !== true
76
- && isTodoToolDetails(entry.message.details);
33
+ function isSuccessfulTodoResult(value: unknown): value is TodoResultEntry {
34
+ if (!value || typeof value !== "object") return false;
35
+ const entry = value as { type?: unknown; message?: unknown };
36
+ if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return false;
37
+
38
+ const message = entry.message as {
39
+ role?: unknown;
40
+ toolName?: unknown;
41
+ isError?: unknown;
42
+ details?: unknown;
43
+ };
44
+ return message.role === "toolResult"
45
+ && message.toolName === TODO_TOOL_NAME
46
+ && message.isError !== true
47
+ && isTodoSnapshotDetails(message.details);
77
48
  }
78
49
 
79
50
  /** Restores the latest successful todo snapshot from the current session branch. */
@@ -81,7 +52,7 @@ export function restoreTodoState(ctx: BranchContext): TodoState {
81
52
  const branch = ctx.sessionManager.getBranch();
82
53
  for (let index = branch.length - 1; index >= 0; index -= 1) {
83
54
  const entry = branch[index];
84
- if (isSuccessfulTodoResult(entry)) return cloneTodoState((entry.message.details as TodoToolDetails).state);
55
+ if (isSuccessfulTodoResult(entry)) return cloneTodoState(entry.message.details.state);
85
56
  }
86
- return createEmptyTodoState();
57
+ return createTodoState();
87
58
  }
@@ -0,0 +1,59 @@
1
+ export interface ReminderCadenceConfig {
2
+ readonly minTurns: number;
3
+ readonly maxTurns: number;
4
+ readonly outputTokens: number;
5
+ readonly maxPerRun: number;
6
+ }
7
+
8
+ export interface ReminderCadenceState {
9
+ readonly turns: number;
10
+ readonly outputTokens: number;
11
+ readonly remindersThisRun: number;
12
+ }
13
+
14
+ export function createReminderCadenceState(): ReminderCadenceState {
15
+ return { turns: 0, outputTokens: 0, remindersThisRun: 0 };
16
+ }
17
+
18
+ export function beginReminderAgentRun(state: ReminderCadenceState): ReminderCadenceState {
19
+ return { ...state, remindersThisRun: 0 };
20
+ }
21
+
22
+ export function noteReminderTurn(
23
+ state: ReminderCadenceState,
24
+ outputTokens: number = 0,
25
+ ): ReminderCadenceState {
26
+ return {
27
+ ...state,
28
+ turns: state.turns + 1,
29
+ outputTokens: state.outputTokens + validTokenCount(outputTokens),
30
+ };
31
+ }
32
+
33
+ export function noteTodoInteraction(state: ReminderCadenceState): ReminderCadenceState {
34
+ return { ...state, turns: 0, outputTokens: 0 };
35
+ }
36
+
37
+ export function consumeDueReminder(
38
+ state: ReminderCadenceState,
39
+ config: ReminderCadenceConfig,
40
+ ): { due: boolean; state: ReminderCadenceState } {
41
+ const minimumMet = state.turns >= config.minTurns;
42
+ const triggerMet = state.turns >= config.maxTurns || state.outputTokens >= config.outputTokens;
43
+ const belowCap = state.remindersThisRun < config.maxPerRun;
44
+
45
+ if (!minimumMet || !triggerMet || !belowCap) return { due: false, state };
46
+
47
+ return {
48
+ due: true,
49
+ state: {
50
+ turns: 0,
51
+ outputTokens: 0,
52
+ remindersThisRun: state.remindersThisRun + 1,
53
+ },
54
+ };
55
+ }
56
+
57
+ function validTokenCount(value: number): number {
58
+ return Number.isFinite(value) && value >= 0 ? value : 0;
59
+ }
@@ -0,0 +1,24 @@
1
+ import { countTodoStatuses, todoTasks } from "./format.js";
2
+ import { currentTodoPhaseIndex } from "./state.js";
3
+ import { isTerminalTodo, type TodoState } from "./types.js";
4
+
5
+ /** Formats the transient model-context reminder for an unfinished todo plan. */
6
+ export function formatTodoReminder(state: TodoState): string | undefined {
7
+ const activePhase = state.phases[currentTodoPhaseIndex(state.phases)];
8
+ if (!activePhase) return undefined;
9
+
10
+ const openTasks = activePhase.tasks.filter((task) => !isTerminalTodo(task));
11
+ const counts = countTodoStatuses(todoTasks(state));
12
+
13
+ return [
14
+ "<system-reminder>",
15
+ `Active phase: ${activePhase.name}`,
16
+ ...(state.workingOn ? [`Current work: ${state.workingOn}`] : []),
17
+ "Open tasks in this phase:",
18
+ ...openTasks.map((task) => `- [${task.status}] ${task.name}: ${task.description}`),
19
+ `Counts: ${counts.in_progress} in_progress, ${counts.pending} pending, ${counts.completed} completed, ${counts.cancelled} cancelled.`,
20
+ "Review and update the todo if task status has changed.",
21
+ "Do not mention this reminder to the user.",
22
+ "</system-reminder>",
23
+ ].join("\n");
24
+ }
package/src/renderer.ts CHANGED
@@ -1,23 +1,16 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
 
4
- import { countTodos, formatTodoSummary, todoTasks, type PhasedTodo } from "./format.js";
4
+ import { countTodos, formatTodoProgress, todoTasks } from "./format.js";
5
5
  import { todoGlyph } from "./glyphs.js";
6
- import type { TodoParams } from "./schema.js";
7
- import { todoAddressKey } from "./state.js";
8
- import type { Todo, TodoAddress, TodoPhase, TodoState, TodoToolDetails } from "./types.js";
6
+ import { currentTodoPhaseIndex, todoAddressKey } from "./state.js";
7
+ import { isTerminalTodo, todoTaskPriority, type Todo, type TodoAddress, type TodoToolDetails } from "./types.js";
9
8
 
10
9
  type ThemeLike = Partial<Pick<Theme, "fg" | "bold" | "strikethrough">>;
11
10
  type ThemeColor = Parameters<Theme["fg"]>[0];
12
11
 
13
12
  export type TodoRendererOptions = { fallbackGlyphs?: boolean };
14
13
 
15
- export function renderCall(params: TodoParams | undefined, theme?: ThemeLike): Text {
16
- const action = params?.action;
17
- const label = typeof action === "string" && action ? `todo ${action}` : "todo";
18
- return new Text(paint(theme, "toolTitle", label), 0, 0);
19
- }
20
-
21
14
  export function renderResult(
22
15
  result: { details?: TodoToolDetails; content?: readonly { type?: string; text?: string }[] },
23
16
  options: { expanded?: boolean } = {},
@@ -30,15 +23,14 @@ export function renderResult(
30
23
  const tasks = todoTasks(state);
31
24
  if (tasks.length === 0 && state.phases.length === 0) return new Text(paint(theme, "muted", "No todo tasks."), 0, 0);
32
25
 
33
- const counts = countTodos(state);
34
- const header = todoHeader(counts);
26
+ const header = todoHeader(countTodos(tasks));
35
27
  if (options.expanded !== true) return new Text(collapsedText(header, tasks, theme, rendererOptions), 0, 0);
36
28
 
37
29
  const changed = new Set((result.details?.changedTasks ?? []).map(addressKey));
38
- const selectedPhase = selectedPhaseIndex(state.phases);
39
- const lines = [toolTitle(todoSummary(tasks), theme)];
30
+ const selectedPhase = currentTodoPhaseIndex(state.phases);
31
+ const lines = [toolTitle(formatTodoProgress("Todos", tasks), theme)];
40
32
  for (const [index, phase] of state.phases.entries()) {
41
- const heading = ` ${index + 1}. ${phaseSummary(phase)}`;
33
+ const heading = ` ${index + 1}. ${formatTodoProgress(phase.name, phase.tasks)}`;
42
34
  lines.push(index === selectedPhase ? toolTitle(heading, theme) : paint(theme, "dim", heading));
43
35
  for (const task of orderedTasks(phase.tasks)) {
44
36
  lines.push(renderTask(phase.name, task, changed, theme, rendererOptions));
@@ -47,10 +39,6 @@ export function renderResult(
47
39
  return new Text(lines.join("\n"), 0, 0);
48
40
  }
49
41
 
50
- export function formatResultText(details: TodoToolDetails | undefined): string {
51
- return formatTodoSummary(details?.state);
52
- }
53
-
54
42
  function todoHeader(counts: ReturnType<typeof countTodos>, title = "Todo"): string {
55
43
  return [
56
44
  `${title} · ${counts.open} open`,
@@ -59,70 +47,26 @@ function todoHeader(counts: ReturnType<typeof countTodos>, title = "Todo"): stri
59
47
  ].join(" · ");
60
48
  }
61
49
 
62
- function collapsedText(header: string, tasks: PhasedTodo[], theme: ThemeLike | undefined, options: TodoRendererOptions): string {
50
+ function collapsedText(header: string, tasks: readonly Todo[], theme: ThemeLike | undefined, options: TodoRendererOptions): string {
63
51
  const active = tasks.find((task) => task.status === "in_progress");
64
52
  const activeText = active ? `Active: ${todoGlyph(active.status, options.fallbackGlyphs)} ${active.name}` : undefined;
65
53
  return [paint(theme, "muted", header), ...(activeText ? [paint(theme, "warning", activeText)] : []), paint(theme, "dim", "↵ expand")].join(" · ");
66
54
  }
67
55
 
68
- function todoSummary(tasks: Todo[]): string {
69
- const active = tasks.filter((task) => task.status === "in_progress").length;
70
- const pending = tasks.filter((task) => task.status === "pending").length;
71
- const completed = tasks.filter((task) => task.status === "completed").length;
72
- const cancelled = tasks.filter((task) => task.status === "cancelled").length;
73
- return [
74
- "Todos",
75
- ...(active ? [`${active} active`] : []),
76
- ...(pending ? [`${pending} pending`] : []),
77
- ...(completed ? [`${completed} completed`] : []),
78
- ...(cancelled ? [`${cancelled} cancelled`] : []),
79
- ].join(" · ");
80
- }
81
-
82
- function phaseSummary(phase: TodoPhase): string {
83
- const active = phase.tasks.filter((task) => task.status === "in_progress").length;
84
- const pending = phase.tasks.filter((task) => task.status === "pending").length;
85
- const completed = phase.tasks.filter((task) => task.status === "completed").length;
86
- const cancelled = phase.tasks.filter((task) => task.status === "cancelled").length;
87
- return [
88
- phase.name,
89
- ...(active ? [`${active} active`] : []),
90
- ...(pending ? [`${pending} pending`] : []),
91
- ...(completed ? [`${completed} completed`] : []),
92
- ...(cancelled ? [`${cancelled} cancelled`] : []),
93
- ].join(" · ");
94
- }
95
-
96
- function selectedPhaseIndex(phases: TodoPhase[]): number {
97
- const active = phases.findIndex((phase) => phase.tasks.some((task) => task.status === "in_progress"));
98
- if (active !== -1) return active;
99
- return phases.findIndex((phase) => phase.tasks.some((task) => task.status === "pending"));
100
- }
101
-
102
- function orderedTasks(tasks: Todo[]): Todo[] {
56
+ function orderedTasks(tasks: readonly Todo[]): Todo[] {
103
57
  return tasks
104
58
  .map((task, index) => ({ task, index }))
105
- .sort((left, right) => taskPriority(left.task) - taskPriority(right.task) || left.index - right.index)
59
+ .sort((left, right) => todoTaskPriority(left.task) - todoTaskPriority(right.task) || left.index - right.index)
106
60
  .map(({ task }) => task);
107
61
  }
108
62
 
109
- function taskPriority(task: Todo): number {
110
- if (task.status === "in_progress") return 0;
111
- if (task.status === "pending") return 1;
112
- return 2;
113
- }
114
-
115
63
  function renderTask(phase: string, task: Todo, changed: Set<string>, theme: ThemeLike | undefined, options: TodoRendererOptions): string {
116
- const text = isTerminal(task) && theme?.strikethrough ? theme.strikethrough(task.name) : task.name;
64
+ const text = isTerminalTodo(task) && theme?.strikethrough ? theme.strikethrough(task.name) : task.name;
117
65
  let line = ` ${todoGlyph(task.status, options.fallbackGlyphs)} ${text}`;
118
66
  if ((task.status === "in_progress" || changed.has(todoAddressKey(phase, task.name))) && theme?.bold) line = theme.bold(line);
119
67
  return paint(theme, statusColor(task.status), line);
120
68
  }
121
69
 
122
- function isTerminal(task: Todo): boolean {
123
- return task.status === "completed" || task.status === "cancelled";
124
- }
125
-
126
70
  function addressKey(address: TodoAddress): string {
127
71
  return todoAddressKey(address.phase, address.task);
128
72
  }
package/src/schema.ts CHANGED
@@ -1,35 +1,31 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
- import { TODO_ACTIONS, TODO_STATUSES, type TodoActionName, type TodoPhaseInput, type TodoTransitionInput } from "./types.js";
3
+ import { TODO_ACTIONS, TODO_STATUSES } from "./types.js";
4
+
5
+ export const TodoTaskSchema = Type.Object({
6
+ name: Type.String({
7
+ description: "Unique immutable task name within the phase; ~5–10 words describing what, not how.",
8
+ }),
9
+ description: Type.String({
10
+ description: "1–3 sentences expanding on the name with relevant context, constraints, or expected outcome.",
11
+ }),
12
+ }, { additionalProperties: false });
4
13
 
5
14
  export const TodoPhaseSchema = Type.Object({
6
- name: Type.String({ minLength: 1, description: "Immutable phase name, 1 or 2 words, unique." }),
7
- tasks: Type.Array(Type.String({
8
- minLength: 1,
9
- description: "Immutable task name, ideally 5–10 words, what not how, unique within its phase.",
10
- })),
15
+ name: Type.String({ description: "Unique immutable phase name; 12 words." }),
16
+ tasks: Type.Array(TodoTaskSchema, { minItems: 1 }),
11
17
  }, { additionalProperties: false });
12
18
 
13
19
  export const TodoTransitionSchema = Type.Object({
14
- phase: Type.String({ minLength: 1, description: "Exact immutable phase name." }),
15
- task: Type.String({ minLength: 1, description: "Exact immutable task name within the phase." }),
16
- status: StringEnum(TODO_STATUSES, { description: "New task status." }),
20
+ phase: Type.String({ description: "Exact name of an existing phase." }),
21
+ task: Type.String({ description: "Exact name of an existing task within the phase." }),
22
+ status: StringEnum(TODO_STATUSES, { description: "Status to assign to the task." }),
17
23
  }, { additionalProperties: false });
18
24
 
19
25
  /** Flat provider-facing schema. Action-specific requirements are enforced by the transition. */
20
26
  export const TodoParamsSchema = Type.Object({
21
27
  action: StringEnum(TODO_ACTIONS),
22
- phases: Type.Optional(Type.Array(TodoPhaseSchema)),
28
+ phases: Type.Optional(Type.Array(TodoPhaseSchema, { minItems: 1 })),
23
29
  transitions: Type.Optional(Type.Array(TodoTransitionSchema, { minItems: 1 })),
24
- phase: Type.Optional(Type.String({ minLength: 1, description: "Optional exact phase name used to filter view." })),
30
+ workingOn: Type.Optional(Type.String({ description: "Concise summary of the current work." })),
25
31
  }, { additionalProperties: false });
26
-
27
- /** Broad parameter view used by tool render hooks. */
28
- export type TodoParams = {
29
- action: TodoActionName;
30
- phases?: TodoPhaseInput[];
31
- transitions?: TodoTransitionInput[];
32
- phase?: string;
33
- };
34
-
35
- export const TodoSchema = TodoParamsSchema;