@pi9/todo 0.1.0 → 0.2.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,6 +1,6 @@
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).
4
4
 
5
5
  ## Features
6
6
 
@@ -15,74 +15,6 @@ A phased, branch-aware todo tool for the [Pi coding agent](https://github.com/ea
15
15
 
16
16
  Todo snapshots are stored in tool-result details, so `/tree` navigation restores the plan associated with that branch.
17
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.
21
-
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:
81
-
82
- ```json
83
- { "action": "view", "phase": "Build" }
84
- ```
85
-
86
18
  ## Install
87
19
 
88
20
  ```bash
@@ -104,7 +36,12 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
104
36
  "widgetPlacement": "aboveEditor",
105
37
  "maxVisibleTasks": 5,
106
38
  "fallbackGlyphs": false,
107
- "toolVisibility": "set-only"
39
+ "toolVisibility": "set-only",
40
+ "dynamicReminders": true,
41
+ "reminderMinTurns": 4,
42
+ "reminderMaxTurns": 8,
43
+ "reminderOutputTokens": 16000,
44
+ "reminderMaxPerRun": 2
108
45
  }
109
46
  ```
110
47
 
@@ -118,6 +55,10 @@ The settings loader reads global settings from `~/.pi/agent/todo/settings.json`.
118
55
 
119
56
  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.
120
57
 
58
+ Dynamic reminders are transient user-role context messages: they are supplied to the model only for the current request and are never added to session history. A reminder is due only after `reminderMinTurns`, then when either `reminderMaxTurns` or `reminderOutputTokens` is reached, up to `reminderMaxPerRun` times per agent run. This guarded-OR cadence prevents reminders during short bursts while still catching either many small turns or a few output-heavy turns. Output tokens are counted because model-generated work, rather than prompt size, is the useful signal that a plan may have become stale. Any successful Todo action, including `view`, resets the turn/token window at the end of that turn; failed actions do not. Set `dynamicReminders` to `false` to disable reminders.
59
+
60
+ After a successful manual, threshold, or overflow Pi compaction, the extension injects a one-shot transient full phased plan into the next model context build. It includes every phase and task with literal statuses, including `completed` and `cancelled` tasks and terminal-only plans; plans with zero tasks are skipped. This does not change Pi's compaction summary or session history. The injection takes priority over a due dynamic cadence reminder and resets its staleness window, regardless of `dynamicReminders`. Its pending state is in memory only and is cleared on session start/reload or `/tree` navigation, so it is not persisted across an extension reload before delivery.
61
+
121
62
  Settings load when a session starts. The widget refreshes after todo changes and `/tree` navigation. Set `widgetPlacement` to `"off"` to disable it.
122
63
 
123
64
  ## Development
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.2.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",
package/src/format.ts CHANGED
@@ -1,6 +1,4 @@
1
- import type { Todo, TodoState } from "./types.js";
2
-
3
- export type PhasedTodo = Todo & { phase: string };
1
+ import type { Todo, TodoState, TodoStatus } from "./types.js";
4
2
 
5
3
  export interface TodoCounts {
6
4
  open: number;
@@ -8,6 +6,8 @@ export interface TodoCounts {
8
6
  cancelled: number;
9
7
  }
10
8
 
9
+ export type TodoStatusCounts = Record<TodoStatus, number>;
10
+
11
11
  /** A small, plain-text representation used in tool results and model context. */
12
12
  export function formatTodoSummary(state: TodoState | undefined): string {
13
13
  const tasks = todoTasks(state);
@@ -24,7 +24,8 @@ export function formatTodoSummary(state: TodoState | undefined): string {
24
24
  }
25
25
 
26
26
  export function formatTodoTaskLines(state: TodoState | undefined): string[] {
27
- if (!state || state.phases.every((phase) => phase.tasks.length === 0)) return [];
27
+ if (!state) return [];
28
+
28
29
  const lines: string[] = [];
29
30
  for (const phase of state.phases) {
30
31
  if (phase.tasks.length === 0) continue;
@@ -34,17 +35,50 @@ export function formatTodoTaskLines(state: TodoState | undefined): string[] {
34
35
  return lines;
35
36
  }
36
37
 
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 };
38
+ /** Formats the complete one-shot Todo snapshot injected after compaction. */
39
+ export function formatTodoCompactionContext(state: TodoState): string | undefined {
40
+ if (!state.phases.some((phase) => phase.tasks.length > 0)) return undefined;
41
+
42
+ const plan = state.phases.flatMap((phase) => [
43
+ `${phase.name}:`,
44
+ ...(phase.tasks.length === 0
45
+ ? [" (no tasks)"]
46
+ : phase.tasks.map((task) => ` [${task.status}] ${task.name}`)),
47
+ ]);
48
+ return [
49
+ "<system-reminder source=\"todo-post-compaction\">",
50
+ "Todo plan after compaction:",
51
+ ...plan,
52
+ "Continue using this plan and keep task statuses current.",
53
+ "Do not mention this reminder to the user.",
54
+ "</system-reminder>",
55
+ ].join("\n");
56
+ }
57
+
58
+ export function countTodoStatuses(tasks: readonly Todo[]): TodoStatusCounts {
59
+ const counts: TodoStatusCounts = { pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
60
+ for (const task of tasks) counts[task.status] += 1;
61
+ return counts;
62
+ }
63
+
64
+ export function countTodos(tasks: readonly Todo[]): TodoCounts {
65
+ const counts = countTodoStatuses(tasks);
66
+ return {
67
+ open: counts.pending + counts.in_progress,
68
+ completed: counts.completed,
69
+ cancelled: counts.cancelled,
70
+ };
71
+ }
72
+
73
+ export function formatTodoProgress(label: string, tasks: readonly Todo[]): string {
74
+ const counts = countTodoStatuses(tasks);
75
+ return [
76
+ label,
77
+ ...(counts.in_progress ? [`${counts.in_progress} active`] : []),
78
+ ...(counts.pending ? [`${counts.pending} pending`] : []),
79
+ ...(counts.completed ? [`${counts.completed} completed`] : []),
80
+ ...(counts.cancelled ? [`${counts.cancelled} cancelled`] : []),
81
+ ].join(" · ");
48
82
  }
49
83
 
50
84
  export function taskMarker(task: Pick<Todo, "status">): string {
@@ -56,6 +90,6 @@ export function taskMarker(task: Pick<Todo, "status">): string {
56
90
  }
57
91
  }
58
92
 
59
- export function todoTasks(state: TodoState | undefined): PhasedTodo[] {
60
- return state?.phases.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name }))) ?? [];
93
+ export function todoTasks(state: TodoState | undefined): readonly Todo[] {
94
+ return state?.phases.flatMap((phase) => phase.tasks) ?? [];
61
95
  }
@@ -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 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 activeTasks = activePhase.tasks
11
+ .filter((task) => task.status === "in_progress")
12
+ .map((task) => task.name);
13
+ const counts = countTodoStatuses(todoTasks(state));
14
+
15
+ return [
16
+ "<system-reminder>",
17
+ `Active phase: ${activePhase.name}`,
18
+ activeTasks.length > 0 ? `In progress: ${activeTasks.join("; ")}` : "No task is in_progress.",
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 type { Todo, TodoAddress, 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,47 +47,13 @@ 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
59
  .sort((left, right) => taskPriority(left.task) - taskPriority(right.task) || left.index - right.index)
package/src/schema.ts CHANGED
@@ -1,18 +1,18 @@
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
4
 
5
5
  export const TodoPhaseSchema = Type.Object({
6
- name: Type.String({ minLength: 1, description: "Immutable phase name, 1 or 2 words, unique." }),
6
+ name: Type.String({ minLength: 1, description: "Unique immutable phase name (12 words)." }),
7
7
  tasks: Type.Array(Type.String({
8
8
  minLength: 1,
9
- description: "Immutable task name, ideally 5–10 words, what not how, unique within its phase.",
9
+ description: "Unique immutable task name within its phase; ideally 5–10 words describing what, not how.",
10
10
  })),
11
11
  }, { additionalProperties: false });
12
12
 
13
13
  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." }),
14
+ phase: Type.String({ minLength: 1, description: "Exact phase name." }),
15
+ task: Type.String({ minLength: 1, description: "Exact task name within the phase." }),
16
16
  status: StringEnum(TODO_STATUSES, { description: "New task status." }),
17
17
  }, { additionalProperties: false });
18
18
 
@@ -21,15 +21,5 @@ export const TodoParamsSchema = Type.Object({
21
21
  action: StringEnum(TODO_ACTIONS),
22
22
  phases: Type.Optional(Type.Array(TodoPhaseSchema)),
23
23
  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." })),
24
+ phase: Type.Optional(Type.String({ minLength: 1, description: "Exact phase to view; omit for the full plan." })),
25
25
  }, { 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;