@fyeeme/pi-todo 1.0.0 → 1.0.1
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/CHANGELOG.md +20 -1
- package/README.md +11 -12
- package/index.ts +12 -156
- package/package.json +2 -2
- package/src/commands.ts +34 -84
- package/src/prompts/todo.md +2 -20
- package/src/render.ts +26 -31
- package/src/restore.ts +0 -3
- package/src/state.ts +76 -190
- package/src/tool.ts +47 -18
- package/test/omp-alignment.test.ts +20 -79
- package/test/render.test.ts +4 -2
- package/test/state.test.ts +47 -48
- package/test/tool.test.ts +58 -91
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [1.0.1] - 2026-09-12
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
|
|
7
|
+
- Cognitive-neutral refactor: pi-todo is now a neutral stateful notepad — statuses change only through explicit ops, and the list never steers the agent. Removed: eager-todo `promptGuidelines` (no more system-prompt nudges to plan first), the stop-reminder nag loop (`checkCompletion` / `⚠ N incomplete todos` transcript box / 3-attempt auto-continuation cycle), and the manual-edit `<system-reminder>` injection after `/todo` mutations. Motivation: the reminder loop plus the "or mark them complete" wording gave the agent a cheap escape hatch (tick boxes to silence the nag), and the plan-first nudges encouraged executing shallow 5–10-word labels without re-derivation.
|
|
8
|
+
- Removed `promptSnippet` from the tool definition (host docs allow omission): the tool no longer appears in the default system prompt's `Available tools` section, making the "no prompt steering" claim literal.
|
|
9
|
+
- Removed the single-`in_progress` invariant and the auto-promotion pointer (`normalizeInProgressTask`): `start` no longer demotes other `in_progress` tasks, completions never auto-promote a pending task, and multiple `in_progress` tasks are allowed. The five-status enum is unchanged (pi-goal's progress counting is unaffected).
|
|
10
|
+
- Removed the unused subagent description matcher (`todoMatchesAnyDescription`) and the `openTasks` helper, plus the now-parameterless `isMatched` viewport pipeline (`selectCollapsedTodos(tasks, cap)`).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Mutation results prepend a `Changed:` block listing every status transition (`- task [pending → blocked] (blocked: note) (phase)`), so a write is confirmed per-task even when the summary folds on lists over 20 tasks.
|
|
15
|
+
- Error results no longer fold: the thrown batch-error message always carries the full unchanged list, restoring the documented retry contract on big lists.
|
|
16
|
+
- The transcript renderer keeps the earliest open-work phase expanded (the active phase the summary reports) instead of relying on the removed auto-promotion pointer; stale pointer wording in renderer comments updated.
|
|
17
|
+
- Tool description (`todo.md`) reduced to mechanical documentation (ops, anatomy, lookup-key rules); all behavioral steering wording removed, including the earlier anti-laziness rules (evidence-gated `done`, re-plan license) — with the nag loop gone their premise no longer holds, and the notepad stays neutral.
|
|
18
|
+
- `formatSummary`'s worked-ahead note no longer references the removed auto-advancing pointer.
|
|
19
|
+
- Markdown round-trip no longer normalizes statuses on parse (`markdownToPhases`): `/todo edit` and `import` now restore statuses exactly as written.
|
|
20
|
+
- `formatSummary` folds its per-task dumps on mutation results once the list exceeds 20 tasks: remaining items capped at the first 10 with a `… N more open — call view` hint plus a single "Full checklist omitted" line. `view` (readOnly) still echoes the whole list. On big lists every `done`/`start` previously re-flooded the model context with the full checklist.
|
|
21
|
+
- `execute` clones phases 4 times per mutation instead of 8 (ownership handoff to the closure; persist/broadcast make their own entry-shaped snapshots); `getCompletionTransitions` generalized to `getStatusTransitions` whose result is now rendered instead of only feeding `completedTasks`.
|
|
22
|
+
- pi-goal's `goal-todo-context.md` no longer references the removed pointer/"stale in_progress" semantics (paired fix in @fyeeme/pi-goal).
|
|
4
23
|
|
|
5
24
|
## [1.0.0] - 2026-09-09
|
|
6
25
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Phased todo lists for [pi](https://github.com/earendil-works/pi-coding-agent) — the oh-my-pi todo tool migrated to a pi extension.
|
|
4
4
|
|
|
5
|
-
The agent gets a `todo` tool: a phased task list (phases → tasks with lifecycle statuses) persisted with the session. You get a `/todo` command
|
|
5
|
+
The agent gets a `todo` tool: a phased task list (phases → tasks with lifecycle statuses) persisted with the session. You get a `/todo` command and a `todo_updated` event other extensions can consume (pi-goal uses it to attach live progress state to goal continuations).
|
|
6
|
+
|
|
7
|
+
The extension is a **cognitive-neutral notepad**: statuses change only through explicit ops, and none of the omp behavior-engineering survives — no system-prompt nudges, no stop-time nag loop, no hidden reminders. The list records state; it never steers the agent.
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
@@ -19,7 +21,7 @@ Nine operations over `phases: [{ phase, items: string[] }]` state:
|
|
|
19
21
|
| `op` | Fields | Effect |
|
|
20
22
|
| ---------- | ------------------------- | --------------------------------------------------------- |
|
|
21
23
|
| `init` | `list` (or flat `items`) | Replace the whole list; all tasks start `pending` |
|
|
22
|
-
| `start` | `task` | Mark `in_progress
|
|
24
|
+
| `start` | `task` | Mark `in_progress` (explicit; other tasks untouched) |
|
|
23
25
|
| `done` | `task` or `phase` | Mark `completed` |
|
|
24
26
|
| `drop` | `task` or `phase` | Mark `abandoned` (never deleted) |
|
|
25
27
|
| `block` | `task` or `phase`, `reason?` | Park open work awaiting external input; one-line reason |
|
|
@@ -28,7 +30,7 @@ Nine operations over `phases: [{ phase, items: string[] }]` state:
|
|
|
28
30
|
| `append` | `phase`, `items` | Add `pending` tasks; lazily creates the phase |
|
|
29
31
|
| `view` | — | Read-only snapshot |
|
|
30
32
|
|
|
31
|
-
Semantics
|
|
33
|
+
Semantics ported from oh-my-pi: batch-atomic duplicate rejection (a failing op applies nothing), `block` never reopens finished work, `view` never writes. Deliberate deviation: the single-`in_progress` invariant and the auto-promotion pointer are removed — statuses are set only by explicit ops, and multiple `in_progress` tasks are allowed.
|
|
32
34
|
|
|
33
35
|
## Command
|
|
34
36
|
|
|
@@ -47,12 +49,6 @@ The full oh-my-pi `/todo` verb set:
|
|
|
47
49
|
/todo rm [<task|phase>] Remove task/phase/all
|
|
48
50
|
```
|
|
49
51
|
|
|
50
|
-
Every mutation commits a `<system-reminder>` hidden message telling the agent the user manually modified the list (with explicit intent notes after removals, so it never rebuilds cleared items).
|
|
51
|
-
|
|
52
|
-
## Stop reminders
|
|
53
|
-
|
|
54
|
-
When an agent run ends while tasks are still `pending`/`in_progress`, oh-my-pi's reminder loop kicks in: a hidden `<system-reminder>` message ("You stopped with N incomplete todo item(s)... (Reminder X/3)") is queued as a follow-up turn so the agent continues or marks work done, and a `⚠ N incomplete todos - reminder X/3` note is anchored in the transcript. The cycle allows 3 reminders, restarts on each new user prompt, and stays silent when the assistant's last line is a question to the user (the ball is in your court) or when only `blocked` tasks remain — those are parked awaiting external input.
|
|
55
|
-
|
|
56
52
|
## Event contract
|
|
57
53
|
|
|
58
54
|
After every successful mutation:
|
|
@@ -76,9 +72,12 @@ Source: `oh-my-pi/packages/coding-agent/src/tools/todo.ts` (plus reminder, slash
|
|
|
76
72
|
| omp surface | Status in pi-todo |
|
|
77
73
|
| ------------------------------------------ | -------------------------------------------------------- |
|
|
78
74
|
| mid-run todo nudge (tool-choice queue) | Dropped — omp host-internal |
|
|
79
|
-
| eager-todo / prewalk system-prompt arming |
|
|
80
|
-
|
|
|
81
|
-
|
|
|
75
|
+
| eager-todo / prewalk system-prompt arming | Dropped — cognitive-neutral refactor (no prompt steering) |
|
|
76
|
+
| Stop-reminder loop (checkCompletion, ×3 nag)| Dropped — cognitive-neutral refactor (no auto-continuation)|
|
|
77
|
+
| Manual-edit `<system-reminder>` injection | Dropped — cognitive-neutral refactor |
|
|
78
|
+
| Single `in_progress` invariant / auto-promotion pointer | Dropped — statuses are explicit-only |
|
|
79
|
+
| `/todo edit` + markdown round-trip | Kept — via `ctx.ui.editor` |
|
|
80
|
+
| Sticky HUD / collapsed viewport / animations| Kept (viewport) in the transcript renderer; HUD dropped |
|
|
82
81
|
| Subagent todo-match lighting | Dropped — no subagent HUD contract |
|
|
83
82
|
| `todo.enabled` settings gate | Dropped — no settings API by design: installed means active |
|
|
84
83
|
|
package/index.ts
CHANGED
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
* - packages/coding-agent/src/tools/todo.ts (TodoTool) → src/tool.ts
|
|
7
7
|
* - packages/coding-agent/src/tools/todo.ts (renderer) → src/render.ts
|
|
8
8
|
* - packages/coding-agent/src/prompts/tools/todo.md → src/prompts/todo.md
|
|
9
|
-
* - packages/coding-agent/src/session/todo-tracker.ts (checkCompletion)
|
|
10
|
-
* → agent_end reminder loop
|
|
11
|
-
* - packages/coding-agent/src/modes/components/todo-reminder.ts → reminder entry renderer
|
|
12
9
|
* - packages/coding-agent/src/modes/controllers/todo-command-controller.ts
|
|
13
10
|
* → src/commands.ts
|
|
14
11
|
*
|
|
@@ -28,65 +25,28 @@
|
|
|
28
25
|
* (missing-op inference before validation)
|
|
29
26
|
* concurrency: "exclusive" → executionMode: "sequential"
|
|
30
27
|
* mergeCallAndResult renderer → split renderCall/renderResult slots
|
|
31
|
-
* eager-todo system-prompt prewalk → promptGuidelines on the tool definition
|
|
32
|
-
* developer-role injected messages → pi.sendMessage display:false (custom
|
|
33
|
-
* role; both /todo edits and stop
|
|
34
|
-
* reminders)
|
|
35
|
-
* TodoTracker.checkCompletion → agent_end handler: same reminder text,
|
|
36
|
-
* 3-attempt cycle, question guard, and
|
|
37
|
-
* followUp-triggered continuation
|
|
38
|
-
* TodoReminderComponent (TUI box) → "todo-reminder" entry renderer
|
|
39
|
-
* (transcript-anchored, same text)
|
|
40
|
-
* $EDITOR round-trip → ctx.ui.editor (Markdown round-trip)
|
|
41
|
-
* mid-run nudge (tool-choice queue) → dropped (omp host-internal)
|
|
42
|
-
* subagent-match lighting → dropped (omp host-internal provider)
|
|
43
28
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
29
|
+
* Removed in the cognitive-neutral refactor (agent-steering logic, not
|
|
30
|
+
* bookkeeping): eager-todo promptGuidelines, the stop-reminder nag loop
|
|
31
|
+
* (checkCompletion / TodoReminderComponent), and the manual-edit
|
|
32
|
+
* `<system-reminder>` injection. The todo list is a neutral stateful notepad:
|
|
33
|
+
* statuses change only through explicit ops.
|
|
47
34
|
*
|
|
48
35
|
* Integration contract for other extensions:
|
|
49
36
|
* - `pi.events.emit("todo_updated", { phases })` after every successful
|
|
50
|
-
* mutation (never on view or failure)
|
|
51
|
-
* -
|
|
37
|
+
* mutation (never on view or failure); full snapshot, five-status enum
|
|
38
|
+
* - every mutation appends a "todo-phases" snapshot entry (pi-goal's
|
|
39
|
+
* todo bridge and restore both read it)
|
|
52
40
|
*/
|
|
53
41
|
|
|
54
42
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
55
|
-
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
56
43
|
import { createTodoCommand } from "./src/commands.ts";
|
|
57
|
-
import {
|
|
44
|
+
import { clonePhases, type TodoPhase } from "./src/state.ts";
|
|
58
45
|
import { createTodoTool } from "./src/tool.ts";
|
|
59
|
-
import {
|
|
60
|
-
TODO_PHASES_ENTRY_TYPE,
|
|
61
|
-
TODO_REMINDER_ENTRY_TYPE,
|
|
62
|
-
restorePhasesFromEntries,
|
|
63
|
-
} from "./src/restore.ts";
|
|
64
|
-
|
|
65
|
-
/** omp settings todo.remindersMax default (3) — extensions have no settings. */
|
|
66
|
-
const REMINDERS_MAX = 3;
|
|
67
|
-
|
|
68
|
-
interface AssistantTextLike {
|
|
69
|
-
role?: string;
|
|
70
|
-
content?: unknown;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function assistantText(message: AssistantTextLike): string {
|
|
74
|
-
if (!Array.isArray(message.content)) return "";
|
|
75
|
-
return message.content
|
|
76
|
-
.filter((block): block is { type: string; text?: string } =>
|
|
77
|
-
!!block && typeof block === "object" && (block as { type?: unknown }).type === "text",
|
|
78
|
-
)
|
|
79
|
-
.map(block => block.text ?? "")
|
|
80
|
-
.join("\n")
|
|
81
|
-
.trim();
|
|
82
|
-
}
|
|
46
|
+
import { TODO_PHASES_ENTRY_TYPE, restorePhasesFromEntries } from "./src/restore.ts";
|
|
83
47
|
|
|
84
48
|
export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
85
49
|
let phases: TodoPhase[] = [];
|
|
86
|
-
/** omp TodoTracker #reminderCount: reminders sent in the current cycle. */
|
|
87
|
-
let reminderCount = 0;
|
|
88
|
-
/** Last assistant reply text, for omp's awaiting-user-answer guard. */
|
|
89
|
-
let lastAssistantText = "";
|
|
90
50
|
|
|
91
51
|
const deps = {
|
|
92
52
|
getPhases: () => phases,
|
|
@@ -94,14 +54,10 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
94
54
|
phases = next;
|
|
95
55
|
},
|
|
96
56
|
persist: (next: TodoPhase[]) => {
|
|
97
|
-
pi.appendEntry(TODO_PHASES_ENTRY_TYPE, { phases:
|
|
57
|
+
pi.appendEntry(TODO_PHASES_ENTRY_TYPE, { phases: clonePhases(next) });
|
|
98
58
|
},
|
|
99
59
|
broadcast: (next: TodoPhase[]) => {
|
|
100
|
-
pi.events.emit("todo_updated", { phases:
|
|
101
|
-
},
|
|
102
|
-
// omp #commit injects a developer system-reminder after user edits.
|
|
103
|
-
sendHiddenMessage: (content: string) => {
|
|
104
|
-
pi.sendMessage({ customType: "todo-user-edit", content, display: false });
|
|
60
|
+
pi.events.emit("todo_updated", { phases: clonePhases(next) });
|
|
105
61
|
},
|
|
106
62
|
};
|
|
107
63
|
|
|
@@ -118,105 +74,5 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
118
74
|
// /fork or /tree navigation; getEntries() would let a later snapshot
|
|
119
75
|
// from an abandoned branch win the backward scan.
|
|
120
76
|
phases = restorePhasesFromEntries(ctx.sessionManager.getBranch());
|
|
121
|
-
reminderCount = 0;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
pi.on("message_start", (event) => {
|
|
125
|
-
// omp resetCycle: a fresh user prompt starts a new reminder cycle.
|
|
126
|
-
if ((event.message as { role?: string }).role === "user") {
|
|
127
|
-
reminderCount = 0;
|
|
128
|
-
}
|
|
129
77
|
});
|
|
130
|
-
|
|
131
|
-
pi.on("agent_end", (event) => {
|
|
132
|
-
// Capture the last assistant text for the question guard BEFORE the
|
|
133
|
-
// reminder decision (omp checkCompletion sees the terminal message).
|
|
134
|
-
const messages = (event.messages ?? []) as AssistantTextLike[];
|
|
135
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
136
|
-
const message = messages[i];
|
|
137
|
-
if (message?.role === "assistant") {
|
|
138
|
-
lastAssistantText = assistantText(message);
|
|
139
|
-
break;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
checkCompletion();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
/** omp TodoTracker.checkCompletion: nag + auto-continue on open todos. */
|
|
146
|
-
function checkCompletion(): void {
|
|
147
|
-
if (reminderCount >= REMINDERS_MAX) return;
|
|
148
|
-
if (phases.length === 0) {
|
|
149
|
-
reminderCount = 0;
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const incompleteByPhase = phases
|
|
153
|
-
.map(phase => ({
|
|
154
|
-
name: phase.name,
|
|
155
|
-
tasks: phase.tasks.filter(task => task.status === "pending" || task.status === "in_progress"),
|
|
156
|
-
}))
|
|
157
|
-
.filter(phase => phase.tasks.length > 0);
|
|
158
|
-
const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
|
|
159
|
-
if (incomplete.length === 0) {
|
|
160
|
-
reminderCount = 0;
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
// omp isAwaitingUserAnswer: the assistant ended by asking the user
|
|
164
|
-
// something — skip the reminder, the ball is in the user's court.
|
|
165
|
-
if (isAwaitingUserAnswer(lastAssistantText)) return;
|
|
166
|
-
|
|
167
|
-
reminderCount++;
|
|
168
|
-
const todoList = incompleteByPhase
|
|
169
|
-
.map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
|
|
170
|
-
.join("\n");
|
|
171
|
-
const reminder =
|
|
172
|
-
`<system-reminder>\n` +
|
|
173
|
-
`You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
|
|
174
|
-
`Please continue working on these tasks or mark them complete if finished.\n` +
|
|
175
|
-
`(Reminder ${reminderCount}/${REMINDERS_MAX})\n` +
|
|
176
|
-
`</system-reminder>`;
|
|
177
|
-
// omp: append developer message + scheduleAgentContinue.
|
|
178
|
-
pi.sendMessage(
|
|
179
|
-
{ customType: "todo-reminder", content: reminder, display: false },
|
|
180
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
181
|
-
);
|
|
182
|
-
pi.appendEntry(TODO_REMINDER_ENTRY_TYPE, {
|
|
183
|
-
count: incomplete.length,
|
|
184
|
-
attempt: reminderCount,
|
|
185
|
-
maxAttempts: REMINDERS_MAX,
|
|
186
|
-
todos: incomplete.map(task => ({ content: task.content, status: task.status })),
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// omp TodoReminderComponent: warning box committed into the transcript —
|
|
191
|
-
// `⚠ N incomplete todos - reminder X/Y` + italic unchecked list.
|
|
192
|
-
pi.registerEntryRenderer<{
|
|
193
|
-
count?: number;
|
|
194
|
-
openCount?: number;
|
|
195
|
-
attempt?: number;
|
|
196
|
-
maxAttempts?: number;
|
|
197
|
-
todos?: TodoItem[];
|
|
198
|
-
}>(TODO_REMINDER_ENTRY_TYPE, (entry, _options, theme) => {
|
|
199
|
-
const data = entry.data;
|
|
200
|
-
const count = typeof data?.count === "number" ? data.count : typeof data?.openCount === "number" ? data.openCount : 0;
|
|
201
|
-
const attempt = typeof data?.attempt === "number" ? data.attempt : 1;
|
|
202
|
-
const maxAttempts = typeof data?.maxAttempts === "number" ? data.maxAttempts : REMINDERS_MAX;
|
|
203
|
-
const label = count === 1 ? "todo" : "todos";
|
|
204
|
-
const header = `⚠ ${count} incomplete ${label} - reminder ${attempt}/${maxAttempts}`;
|
|
205
|
-
const todos = Array.isArray(data?.todos) ? data.todos : [];
|
|
206
|
-
const list = todos.length > 0 ? `\n\n${theme.italic(todos.map(todo => ` ☐ ${todo.content}`).join("\n"))}` : "";
|
|
207
|
-
const component: Component = new Text(theme.fg("warning", header) + list, 0, 0);
|
|
208
|
-
return component;
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Defensive clone for entries/events (omp TodoTracker.#clonePhases). */
|
|
213
|
-
function clonePhasesForEntry(phases: TodoPhase[]): TodoPhase[] {
|
|
214
|
-
return phases.map(phase => ({
|
|
215
|
-
name: phase.name,
|
|
216
|
-
tasks: phase.tasks.map(task =>
|
|
217
|
-
task.blocker !== undefined
|
|
218
|
-
? { content: task.content, status: task.status, blocker: task.blocker }
|
|
219
|
-
: { content: task.content, status: task.status },
|
|
220
|
-
),
|
|
221
|
-
}));
|
|
222
78
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fyeeme/pi-todo",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Phased todo list tool for pi — nine operations (init/start/done/rm/drop/block/unblock/append/view) over phase/task state persisted with the session, a /todo command,
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Phased todo list tool for pi — nine operations (init/start/done/rm/drop/block/unblock/append/view) over phase/task state persisted with the session, a /todo command, and a todo_updated event bus broadcast for other extensions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "fyeeme",
|
package/src/commands.ts
CHANGED
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ported from oh-my-pi `todo-command-controller.ts` (+ its ACP helper in
|
|
5
5
|
* slash-commands/helpers/todo.ts): view, edit, copy, export, import, append,
|
|
6
|
-
* start, done, drop, rm, help — with quote-aware tokenizing
|
|
7
|
-
* task/phase matching
|
|
8
|
-
*
|
|
6
|
+
* start, done, drop, rm, help — with quote-aware tokenizing and fuzzy
|
|
7
|
+
* task/phase matching. The omp developer system-reminder injection after
|
|
8
|
+
* manual edits was removed in the cognitive-neutral refactor.
|
|
9
9
|
*
|
|
10
10
|
* Host-internal surfaces map to pi:
|
|
11
11
|
* $EDITOR round-trip → ctx.ui.editor (prefilled Markdown)
|
|
12
12
|
* clipboard copy → printed (extension API has no clipboard write;
|
|
13
13
|
* mirrors omp's own ACP fallback text)
|
|
14
|
-
* developer message → pi.sendMessage display:false (custom role)
|
|
15
14
|
* user_todo_edit entry → "todo-phases" snapshot (pi-todo's documented
|
|
16
15
|
* entry contract, read back by restore and
|
|
17
16
|
* pi-goal's todo bridge)
|
|
@@ -21,21 +20,18 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
21
20
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
22
21
|
import {
|
|
23
22
|
type TodoItem,
|
|
23
|
+
type TodoOpEntry,
|
|
24
24
|
applyOpsToPhases,
|
|
25
|
+
clonePhases,
|
|
25
26
|
type TodoPhase,
|
|
26
27
|
markdownToPhases,
|
|
27
28
|
phasesToMarkdown,
|
|
28
29
|
resolveTodoMarkdownPath,
|
|
29
30
|
} from "./state.ts";
|
|
31
|
+
import { commitPhases, type TodoToolDeps } from "./tool.ts";
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
setPhases(phases: TodoPhase[]): void;
|
|
34
|
-
persist(phases: TodoPhase[]): void;
|
|
35
|
-
broadcast(phases: TodoPhase[]): void;
|
|
36
|
-
/** Latest event context so the reminder can reach the agent mid-session. */
|
|
37
|
-
sendHiddenMessage?(content: string): void;
|
|
38
|
-
}
|
|
33
|
+
/** Same dependency contract as the tool path (single write-back invariant). */
|
|
34
|
+
export type TodoCommandDeps = TodoToolDeps;
|
|
39
35
|
|
|
40
36
|
const USAGE = [
|
|
41
37
|
"Usage: /todo <verb> [args]",
|
|
@@ -144,24 +140,6 @@ function findTaskFuzzy(phases: TodoPhase[], query: string): TodoTaskMatch | unde
|
|
|
144
140
|
return undefined;
|
|
145
141
|
}
|
|
146
142
|
|
|
147
|
-
// =============================================================================
|
|
148
|
-
// System reminder (omp buildSystemReminder) — tells the agent the list changed
|
|
149
|
-
// =============================================================================
|
|
150
|
-
|
|
151
|
-
function buildSystemReminder(action: string, phases: TodoPhase[], removed = false): string {
|
|
152
|
-
const md = phases.length === 0 ? "(empty)" : phasesToMarkdown(phases).trimEnd();
|
|
153
|
-
const lines = ["<system-reminder>", `The user manually modified the todo list (${action}).`];
|
|
154
|
-
if (removed) {
|
|
155
|
-
lines.push(
|
|
156
|
-
phases.length === 0
|
|
157
|
-
? "The user intentionally cleared the todo list. Do NOT recreate or re-populate it unless the user explicitly asks; continue the current request without a todo list."
|
|
158
|
-
: "The user intentionally removed the entries no longer shown below. Do NOT re-add them unless the user explicitly asks.",
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
lines.push("Current todo list:", "", md, "</system-reminder>");
|
|
162
|
-
return lines.join("\n");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
143
|
// =============================================================================
|
|
166
144
|
// Command handler
|
|
167
145
|
// =============================================================================
|
|
@@ -226,15 +204,6 @@ function emit(ctx: ExtensionCommandContext, text: string, severity: "info" | "wa
|
|
|
226
204
|
console.error(text);
|
|
227
205
|
}
|
|
228
206
|
|
|
229
|
-
function commit(deps: TodoCommandDeps, nextPhases: TodoPhase[], action: string, opts?: { removed?: boolean }): void {
|
|
230
|
-
deps.setPhases(nextPhases);
|
|
231
|
-
deps.persist(nextPhases);
|
|
232
|
-
deps.broadcast(nextPhases);
|
|
233
|
-
// omp #commit step 3: inject the system reminder so the agent learns about
|
|
234
|
-
// the change next turn. Removals carry explicit intent (omp issue #5258).
|
|
235
|
-
deps.sendHiddenMessage?.(buildSystemReminder(action, nextPhases, opts?.removed ?? false));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
207
|
function showCurrent(deps: TodoCommandDeps, ctx: ExtensionCommandContext): void {
|
|
239
208
|
const phases = deps.getPhases();
|
|
240
209
|
if (phases.length === 0) {
|
|
@@ -282,7 +251,7 @@ async function importFromFile(deps: TodoCommandDeps, ctx: ExtensionCommandContex
|
|
|
282
251
|
emit(ctx, `Could not parse ${source}:\n ${errors.join("\n ")}`, "error");
|
|
283
252
|
return;
|
|
284
253
|
}
|
|
285
|
-
|
|
254
|
+
commitPhases(deps, phases);
|
|
286
255
|
const taskCount = phases.reduce((sum, p) => sum + p.tasks.length, 0);
|
|
287
256
|
emit(ctx, `Imported ${phases.length} phase(s), ${taskCount} task(s) from ${source}.`);
|
|
288
257
|
}
|
|
@@ -305,7 +274,7 @@ function append(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
|
|
|
305
274
|
content = tokens.slice(1).join(" ");
|
|
306
275
|
}
|
|
307
276
|
|
|
308
|
-
const next = current
|
|
277
|
+
const next = clonePhases(current);
|
|
309
278
|
let targetPhase: TodoPhase;
|
|
310
279
|
|
|
311
280
|
if (phaseName) {
|
|
@@ -322,7 +291,7 @@ function append(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
|
|
|
322
291
|
const finalContent = titleCaseSentence(content);
|
|
323
292
|
targetPhase.tasks.push({ content: finalContent, status: "pending" });
|
|
324
293
|
|
|
325
|
-
|
|
294
|
+
commitPhases(deps, next);
|
|
326
295
|
emit(ctx, `Appended to ${targetPhase.name}: ${finalContent}`);
|
|
327
296
|
}
|
|
328
297
|
|
|
@@ -331,19 +300,30 @@ function start(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: string
|
|
|
331
300
|
emit(ctx, "Usage: /todo start <task>", "error");
|
|
332
301
|
return;
|
|
333
302
|
}
|
|
334
|
-
const
|
|
335
|
-
const hit = findTaskFuzzy(current, rest);
|
|
303
|
+
const hit = findTaskFuzzy(deps.getPhases(), rest);
|
|
336
304
|
if (!hit) {
|
|
337
305
|
emit(ctx, `No task matched "${rest}". Use /todo to list current tasks.`, "error");
|
|
338
306
|
return;
|
|
339
307
|
}
|
|
340
|
-
|
|
308
|
+
applyAndCommit(deps, ctx, [{ op: "start", task: hit.task.content }], `Started: ${hit.task.content}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Shared tail of every mutating verb: apply ops, commit on success, report.
|
|
312
|
+
* A failing batch is discarded wholesale (state unchanged) and its errors
|
|
313
|
+
* surface as one error line. */
|
|
314
|
+
function applyAndCommit(
|
|
315
|
+
deps: TodoCommandDeps,
|
|
316
|
+
ctx: ExtensionCommandContext,
|
|
317
|
+
ops: TodoOpEntry[],
|
|
318
|
+
successMessage: string,
|
|
319
|
+
): void {
|
|
320
|
+
const { phases, errors } = applyOpsToPhases(deps.getPhases(), ops);
|
|
341
321
|
if (errors.length > 0) {
|
|
342
322
|
emit(ctx, errors.join("; "), "error");
|
|
343
323
|
return;
|
|
344
324
|
}
|
|
345
|
-
|
|
346
|
-
emit(ctx,
|
|
325
|
+
commitPhases(deps, phases);
|
|
326
|
+
emit(ctx, successMessage);
|
|
347
327
|
}
|
|
348
328
|
|
|
349
329
|
function mutateStatus(
|
|
@@ -357,37 +337,19 @@ function mutateStatus(
|
|
|
357
337
|
const trimmedArg = rest.trim();
|
|
358
338
|
if (!trimmedArg) {
|
|
359
339
|
// no-arg: apply to all
|
|
360
|
-
|
|
361
|
-
if (errors.length > 0) {
|
|
362
|
-
emit(ctx, errors.join("; "), "error");
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
commit(deps, phases, `/todo ${op} (all)`);
|
|
366
|
-
emit(ctx, `Marked all tasks ${target}.`);
|
|
340
|
+
applyAndCommit(deps, ctx, [{ op }], `Marked all tasks ${target}.`);
|
|
367
341
|
return;
|
|
368
342
|
}
|
|
369
343
|
|
|
370
344
|
const taskHit = findTaskFuzzy(current, trimmedArg);
|
|
371
345
|
if (taskHit) {
|
|
372
|
-
|
|
373
|
-
if (errors.length > 0) {
|
|
374
|
-
emit(ctx, errors.join("; "), "error");
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
commit(deps, phases, `/todo ${op} ${taskHit.task.content}`);
|
|
378
|
-
emit(ctx, `Marked ${target}: ${taskHit.task.content}`);
|
|
346
|
+
applyAndCommit(deps, ctx, [{ op, task: taskHit.task.content }], `Marked ${target}: ${taskHit.task.content}`);
|
|
379
347
|
return;
|
|
380
348
|
}
|
|
381
349
|
|
|
382
350
|
const phaseHit = findPhaseFuzzy(current, trimmedArg);
|
|
383
351
|
if (phaseHit) {
|
|
384
|
-
|
|
385
|
-
if (errors.length > 0) {
|
|
386
|
-
emit(ctx, errors.join("; "), "error");
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
commit(deps, phases, `/todo ${op} ${phaseHit.name}`);
|
|
390
|
-
emit(ctx, `Marked phase ${phaseHit.name} ${target}.`);
|
|
352
|
+
applyAndCommit(deps, ctx, [{ op, phase: phaseHit.name }], `Marked phase ${phaseHit.name} ${target}.`);
|
|
391
353
|
return;
|
|
392
354
|
}
|
|
393
355
|
|
|
@@ -398,30 +360,18 @@ function remove(deps: TodoCommandDeps, ctx: ExtensionCommandContext, rest: strin
|
|
|
398
360
|
const current = deps.getPhases();
|
|
399
361
|
const trimmedArg = rest.trim();
|
|
400
362
|
if (!trimmedArg) {
|
|
401
|
-
|
|
363
|
+
commitPhases(deps, []);
|
|
402
364
|
emit(ctx, "Cleared all todos.");
|
|
403
365
|
return;
|
|
404
366
|
}
|
|
405
367
|
const taskHit = findTaskFuzzy(current, trimmedArg);
|
|
406
368
|
if (taskHit) {
|
|
407
|
-
|
|
408
|
-
if (errors.length > 0) {
|
|
409
|
-
emit(ctx, errors.join("; "), "error");
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
commit(deps, phases, `/todo rm ${taskHit.task.content}`, { removed: true });
|
|
413
|
-
emit(ctx, `Removed: ${taskHit.task.content}`);
|
|
369
|
+
applyAndCommit(deps, ctx, [{ op: "rm", task: taskHit.task.content }], `Removed: ${taskHit.task.content}`);
|
|
414
370
|
return;
|
|
415
371
|
}
|
|
416
372
|
const phaseHit = findPhaseFuzzy(current, trimmedArg);
|
|
417
373
|
if (phaseHit) {
|
|
418
|
-
|
|
419
|
-
if (errors.length > 0) {
|
|
420
|
-
emit(ctx, errors.join("; "), "error");
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
commit(deps, phases, `/todo rm ${phaseHit.name}`, { removed: true });
|
|
424
|
-
emit(ctx, `Removed phase: ${phaseHit.name}`);
|
|
374
|
+
applyAndCommit(deps, ctx, [{ op: "rm", phase: phaseHit.name }], `Removed phase: ${phaseHit.name}`);
|
|
425
375
|
return;
|
|
426
376
|
}
|
|
427
377
|
emit(ctx, `No task or phase matched "${trimmedArg}".`, "error");
|
|
@@ -451,7 +401,7 @@ async function editInEditor(deps: TodoCommandDeps, ctx: ExtensionCommandContext)
|
|
|
451
401
|
emit(ctx, `Could not parse Markdown:\n ${errors.join("\n ")}`, "error");
|
|
452
402
|
return;
|
|
453
403
|
}
|
|
454
|
-
|
|
404
|
+
commitPhases(deps, parsed);
|
|
455
405
|
const taskCount = parsed.reduce((sum, p) => sum + p.tasks.length, 0);
|
|
456
406
|
emit(ctx, `Todos updated from editor: ${parsed.length} phase(s), ${taskCount} task(s).`);
|
|
457
407
|
}
|
package/src/prompts/todo.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
**Tasks: verbatim content strings, NEVER auto-generated IDs; no "task-1"/"task-N". Pass content in `task`.**
|
|
2
2
|
|
|
3
|
-
Each completion: earliest still-open task (phase order) auto-promotes to `in_progress`. Out-of-order completion may move pointer back to an earlier phase—expected; completed tasks NEVER revert.
|
|
4
|
-
|
|
5
3
|
## Operations
|
|
6
4
|
|
|
7
5
|
|`op`|Fields|Effect|
|
|
@@ -11,7 +9,7 @@ Each completion: earliest still-open task (phase order) auto-promotes to `in_pro
|
|
|
11
9
|
|`start`|`task`|Mark in progress|
|
|
12
10
|
|`done`|`task` or `phase`|Mark completed|
|
|
13
11
|
|`drop`|`task` or `phase`|Mark abandoned|
|
|
14
|
-
|`block`|`task` or `phase`; optional `reason`|Mark blocked: open, awaiting external input
|
|
12
|
+
|`block`|`task` or `phase`; optional `reason`|Mark blocked: open, awaiting external input|
|
|
15
13
|
|`unblock`|`task` or `phase`|Blocked task → `pending`|
|
|
16
14
|
|`rm`|optional `task` or `phase`|Remove task/phase; omit both → clear|
|
|
17
15
|
|`append`|`phase`; `items: string[]`|Append tasks to phase; lazily creates phase|
|
|
@@ -24,21 +22,5 @@ Each completion: earliest still-open task (phase order) auto-promotes to `in_pro
|
|
|
24
22
|
|
|
25
23
|
## Rules
|
|
26
24
|
|
|
27
|
-
-
|
|
28
|
-
- NEVER make a todo call the turn's only tool call. Batch with real work: `init` with first reads/edits; each `done`/`start` with next action. Solo todo turns waste a round trip.
|
|
29
|
-
- Waiting on something you can't act on—a user decision, another agent, external service: `block` task (optional `reason`); remains tracked but avoids stop reminder. `unblock` when actionable. If blocker agent-actionable, `append` an unblocking task instead.
|
|
30
|
-
- Keep introduced `task`/`phase` strings stable.
|
|
25
|
+
- Keep introduced `task`/`phase` strings stable: they are the lookup keys for every later op.
|
|
31
26
|
- Lost exact task text: `view` echoes list; NEVER guess from memory.
|
|
32
|
-
|
|
33
|
-
## Create a list
|
|
34
|
-
|
|
35
|
-
- Task requires 3+ distinct steps.
|
|
36
|
-
- User explicitly requests one.
|
|
37
|
-
- User provides a set of tasks.
|
|
38
|
-
- New instructions arrive mid-task: capture before proceeding.
|
|
39
|
-
|
|
40
|
-
<critical>
|
|
41
|
-
User gives multi-step plan—phased todo, numbered/bulleted checklist, or "N bugs/items/tasks":
|
|
42
|
-
- MUST `init` every item as its own task before working.
|
|
43
|
-
- Enumerate all; NEVER summarize into fewer tasks, sample "the important ones", drop items, or track the rest from memory.
|
|
44
|
-
</critical>
|