@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 +21 -82
- package/media/todo-plan.png +0 -0
- package/package.json +3 -2
- package/src/format.ts +64 -27
- package/src/persistence.ts +30 -59
- package/src/reminder-cadence.ts +59 -0
- package/src/reminder.ts +24 -0
- package/src/renderer.ts +11 -67
- package/src/schema.ts +17 -21
- package/src/settings.ts +85 -66
- package/src/state.ts +163 -69
- package/src/tool.ts +100 -34
- package/src/types.ts +46 -22
- package/src/visibility.ts +1 -5
- package/src/widget-component.ts +17 -19
- package/src/widget-layout.ts +43 -74
- package/src/widget.ts +73 -28
package/README.md
CHANGED
|
@@ -1,87 +1,17 @@
|
|
|
1
1
|
# @pi9/todo
|
|
2
2
|
|
|
3
|
-
A phased,
|
|
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
|
-
|
|
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
|
+

|
|
21
6
|
|
|
22
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
|
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 [
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return
|
|
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
|
-
|
|
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):
|
|
60
|
-
return state?.phases.flatMap((phase) => phase.tasks
|
|
96
|
+
export function todoTasks(state: TodoState | undefined): readonly Todo[] {
|
|
97
|
+
return state?.phases.flatMap((phase) => phase.tasks) ?? [];
|
|
61
98
|
}
|
package/src/persistence.ts
CHANGED
|
@@ -1,79 +1,50 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
21
|
+
toolName: typeof TODO_TOOL_NAME;
|
|
22
22
|
isError?: unknown;
|
|
23
|
-
details
|
|
23
|
+
details: TodoSnapshotDetails;
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
if (typeof value !== "object"
|
|
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
|
|
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
|
|
68
|
-
if (typeof value !== "object"
|
|
69
|
-
const entry = value as
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
55
|
+
if (isSuccessfulTodoResult(entry)) return cloneTodoState(entry.message.details.state);
|
|
85
56
|
}
|
|
86
|
-
return
|
|
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
|
+
}
|
package/src/reminder.ts
ADDED
|
@@ -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,
|
|
4
|
+
import { countTodos, formatTodoProgress, todoTasks } from "./format.js";
|
|
5
5
|
import { todoGlyph } from "./glyphs.js";
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
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
|
|
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 =
|
|
39
|
-
const lines = [toolTitle(
|
|
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}. ${
|
|
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:
|
|
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
|
|
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) =>
|
|
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 =
|
|
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
|
|
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({
|
|
7
|
-
tasks: Type.Array(
|
|
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; 1–2 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({
|
|
15
|
-
task: Type.String({
|
|
16
|
-
status: StringEnum(TODO_STATUSES, { description: "
|
|
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
|
-
|
|
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;
|