@nguyenquangthai/pi-todo 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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/package.json +60 -0
- package/src/format.ts +139 -0
- package/src/index.ts +185 -0
- package/src/overlay.ts +64 -0
- package/src/prompt-intent.ts +147 -0
- package/src/prompt.ts +77 -0
- package/src/reminder-cadence.ts +116 -0
- package/src/replay.ts +69 -0
- package/src/sanitize.ts +9 -0
- package/src/schema.ts +24 -0
- package/src/store.ts +36 -0
- package/src/tools/todoread.ts +49 -0
- package/src/tools/todowrite.ts +91 -0
- package/src/types.ts +29 -0
- package/src/validate.ts +105 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-07-15)
|
|
4
|
+
|
|
5
|
+
- First npm publish as `@nguyenquangthai/pi-todo`.
|
|
6
|
+
- Polish repo metadata (LICENSE, README, package.json) for public distribution.
|
|
7
|
+
- Expand cold-start heuristics to cover more multi-step prompts (EN/VI).
|
|
8
|
+
- Add blank line under overlay heading for visual spacing.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QuangThai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# pi-todo
|
|
2
|
+
|
|
3
|
+
OpenCode-style session todo checklist for the [pi coding agent](https://pi.dev).
|
|
4
|
+
|
|
5
|
+
Adds `todowrite` / `todoread`, a live `# Todos` overlay above the editor (`[ ]` / `[•]` / `[✓]` / `[×]`), and branch-replay persistence (survives `/reload`, tree nav, and custom-entry durability across compaction).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@nguyenquangthai/pi-todo
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or from source:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/QuangThai/pi-todo.git
|
|
17
|
+
cd pi-todo
|
|
18
|
+
pi install .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then restart pi or run `/reload`.
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
### `todowrite`
|
|
26
|
+
|
|
27
|
+
Full-replace the session todo list. Each call must pass the **complete** list.
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"todos": [
|
|
32
|
+
{ "content": "Wire overlay", "status": "completed", "priority": "high" },
|
|
33
|
+
{ "content": "Add tests", "status": "in_progress", "priority": "high" },
|
|
34
|
+
{ "content": "Write README", "status": "pending", "priority": "medium" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Rules enforced by the tool:
|
|
40
|
+
|
|
41
|
+
- Exactly **one** `in_progress` allowed (hard reject if more)
|
|
42
|
+
- `content` required (non-empty after sanitize); max **500** chars (longer values truncated)
|
|
43
|
+
- `priority` required: `high` | `medium` | `low`
|
|
44
|
+
- Status: `pending` | `in_progress` | `completed` | `cancelled`
|
|
45
|
+
- Tool text echo caps at **40** lines (`+N more` in the text body; full list still in `details` / JSON)
|
|
46
|
+
|
|
47
|
+
### `todoread`
|
|
48
|
+
|
|
49
|
+
Returns the current list as text + JSON. Prefer the overlay for at-a-glance status; avoid calling it in the same parallel batch as `todowrite`.
|
|
50
|
+
|
|
51
|
+
## Overlay
|
|
52
|
+
|
|
53
|
+
Shown above the editor while any **open** todo remains (`pending` / `in_progress`).
|
|
54
|
+
|
|
55
|
+
Hidden when the list is empty or every item is `completed` / `cancelled`.
|
|
56
|
+
|
|
57
|
+
Heading shows open + running counts, e.g. `# Todos (2 open, 1 running)`:
|
|
58
|
+
|
|
59
|
+
- **open** = `pending` + `in_progress`
|
|
60
|
+
- **running** = `in_progress` only (0 or 1 after a valid write)
|
|
61
|
+
|
|
62
|
+
There is a blank line under the heading so it is not flush against the first todo row.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/QuangThai/pi-todo.git
|
|
68
|
+
cd pi-todo
|
|
69
|
+
npm install
|
|
70
|
+
npm test
|
|
71
|
+
npm run typecheck
|
|
72
|
+
pi -e ./src/index.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
[MIT](./LICENSE) © QuangThai
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nguyenquangthai/pi-todo",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode-like session todo checklist for the pi coding agent — todowrite/todoread with a live TUI overlay",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "QuangThai",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/QuangThai/pi-todo#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/QuangThai/pi-todo.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/QuangThai/pi-todo/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"todo",
|
|
22
|
+
"todowrite",
|
|
23
|
+
"todoread",
|
|
24
|
+
"opencode",
|
|
25
|
+
"tui"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"prepublishOnly": "npm test && npm run typecheck"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": ["./src/index.ts"]
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "^0.80.7",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "^0.80.7",
|
|
54
|
+
"@earendil-works/pi-tui": "^0.80.7",
|
|
55
|
+
"@types/node": "^22.0.0",
|
|
56
|
+
"typebox": "1.1.38",
|
|
57
|
+
"typescript": "^5.8.0",
|
|
58
|
+
"vitest": "^3.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { TodoItem, TodoStatus } from "./types.js";
|
|
4
|
+
import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
|
|
5
|
+
import { countOpenTodos, countRunningTodos, hasOpenTodos, isTerminalStatus } from "./validate.js";
|
|
6
|
+
|
|
7
|
+
export function getTodoMarker(status: TodoStatus): string {
|
|
8
|
+
switch (status) {
|
|
9
|
+
case "completed":
|
|
10
|
+
return "[✓]";
|
|
11
|
+
case "in_progress":
|
|
12
|
+
return "[•]";
|
|
13
|
+
case "cancelled":
|
|
14
|
+
return "[×]";
|
|
15
|
+
default:
|
|
16
|
+
return "[ ]";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatPlainTodoLine(todo: TodoItem): string {
|
|
21
|
+
return `${getTodoMarker(todo.status)} ${todo.content}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Compact checklist for tool responses; caps lines to keep LLM context small. */
|
|
25
|
+
export function formatTodoListText(todos: readonly TodoItem[], summary: string): string {
|
|
26
|
+
if (todos.length === 0) return summary;
|
|
27
|
+
if (todos.length <= MAX_RESULT_LINES) {
|
|
28
|
+
return [summary, ...todos.map(formatPlainTodoLine)].join("\n");
|
|
29
|
+
}
|
|
30
|
+
const shown = todos.slice(0, MAX_RESULT_LINES);
|
|
31
|
+
const hidden = todos.length - MAX_RESULT_LINES;
|
|
32
|
+
return [
|
|
33
|
+
summary,
|
|
34
|
+
...shown.map(formatPlainTodoLine),
|
|
35
|
+
`… and ${hidden} more (full list in details/JSON)`,
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatThemedTodoLine(todo: TodoItem, theme: Theme): string {
|
|
40
|
+
const marker = getTodoMarker(todo.status);
|
|
41
|
+
if (todo.status === "in_progress") {
|
|
42
|
+
return `${theme.fg("warning", marker)} ${theme.fg("warning", todo.content)}`;
|
|
43
|
+
}
|
|
44
|
+
if (todo.status === "completed" || todo.status === "cancelled") {
|
|
45
|
+
return `${theme.fg("dim", marker)} ${theme.fg("dim", todo.content)}`;
|
|
46
|
+
}
|
|
47
|
+
return `${theme.fg("muted", marker)} ${theme.fg("muted", todo.content)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Show overlay while any pending/in_progress remains. */
|
|
51
|
+
export function shouldShowOverlay(todos: readonly TodoItem[]): boolean {
|
|
52
|
+
return hasOpenTodos(todos);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OverlayLayout {
|
|
56
|
+
visible: TodoItem[];
|
|
57
|
+
hiddenCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fit into maxLines (includes heading). On overflow, drop terminal first,
|
|
62
|
+
* then pending from the end; keep in_progress. Preserve original order.
|
|
63
|
+
*/
|
|
64
|
+
export function selectOverlayLayout(
|
|
65
|
+
todos: readonly TodoItem[],
|
|
66
|
+
maxLines: number = MAX_OVERLAY_LINES,
|
|
67
|
+
): OverlayLayout {
|
|
68
|
+
if (!shouldShowOverlay(todos)) {
|
|
69
|
+
return { visible: [], hiddenCount: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bodyBudget = Math.max(1, maxLines - 1);
|
|
73
|
+
if (todos.length <= bodyBudget) {
|
|
74
|
+
return { visible: [...todos], hiddenCount: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const innerBudget = Math.max(1, bodyBudget - 1);
|
|
78
|
+
const inProgress = todos.filter((t) => t.status === "in_progress");
|
|
79
|
+
const pending = todos.filter((t) => t.status === "pending");
|
|
80
|
+
const terminal = todos.filter((t) => isTerminalStatus(t.status));
|
|
81
|
+
|
|
82
|
+
const picked = new Set<TodoItem>();
|
|
83
|
+
for (const t of inProgress) {
|
|
84
|
+
if (picked.size >= innerBudget) break;
|
|
85
|
+
picked.add(t);
|
|
86
|
+
}
|
|
87
|
+
for (const t of pending) {
|
|
88
|
+
if (picked.size >= innerBudget) break;
|
|
89
|
+
picked.add(t);
|
|
90
|
+
}
|
|
91
|
+
for (const t of terminal) {
|
|
92
|
+
if (picked.size >= innerBudget) break;
|
|
93
|
+
picked.add(t);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const visible = todos.filter((t) => picked.has(t));
|
|
97
|
+
return { visible, hiddenCount: todos.length - visible.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RenderOverlayOptions {
|
|
101
|
+
maxLines?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Heading counts (when overlay is visible):
|
|
106
|
+
* - open = pending + in_progress
|
|
107
|
+
* - running = in_progress only (0 or 1 after a valid todowrite)
|
|
108
|
+
* Overlay is hidden when open === 0, so "(0 open, 0 running)" is never shown.
|
|
109
|
+
*/
|
|
110
|
+
export function renderOverlayLines(
|
|
111
|
+
todos: readonly TodoItem[],
|
|
112
|
+
theme: Theme,
|
|
113
|
+
width: number,
|
|
114
|
+
options: RenderOverlayOptions = {},
|
|
115
|
+
): string[] {
|
|
116
|
+
if (!shouldShowOverlay(todos)) return [];
|
|
117
|
+
|
|
118
|
+
const maxLines = options.maxLines ?? MAX_OVERLAY_LINES;
|
|
119
|
+
const truncate = (line: string) => truncateToWidth(line, width, "…");
|
|
120
|
+
const open = countOpenTodos(todos);
|
|
121
|
+
const running = countRunningTodos(todos);
|
|
122
|
+
const heading = truncate(
|
|
123
|
+
theme.fg("accent", theme.bold(`# Todos`)) +
|
|
124
|
+
theme.fg("dim", ` (${open} open, ${running} running)`),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Reserve heading + blank gap under heading so the title isn't flush with rows.
|
|
128
|
+
const layout = selectOverlayLayout(todos, Math.max(3, maxLines - 1));
|
|
129
|
+
const lines: string[] = [heading, ""];
|
|
130
|
+
|
|
131
|
+
for (const todo of layout.visible) {
|
|
132
|
+
lines.push(truncate(formatThemedTodoLine(todo, theme)));
|
|
133
|
+
}
|
|
134
|
+
if (layout.hiddenCount > 0) {
|
|
135
|
+
lines.push(truncate(theme.fg("dim", `+${layout.hiddenCount} more`)));
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-todo — OpenCode-like session todo checklist for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Tools: todowrite (full replace), todoread
|
|
5
|
+
* Overlay: # Todos with [ ]/[•]/[✓]/[×] above the editor
|
|
6
|
+
* Persistence: toolResult details + custom entry, replayed from branch
|
|
7
|
+
* Reminder: pi-tasks-style cadence → transient <system-reminder> via context
|
|
8
|
+
* Cold start: pi-todotools section + prompt-aware one-shot context nudge
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { formatPlainTodoLine } from "./format.js";
|
|
13
|
+
import { TodoOverlay } from "./overlay.js";
|
|
14
|
+
import {
|
|
15
|
+
buildColdStartReminder,
|
|
16
|
+
buildCompletionUpdateReminder,
|
|
17
|
+
shouldNudgeColdStart,
|
|
18
|
+
shouldNudgeCompletionUpdate,
|
|
19
|
+
} from "./prompt-intent.js";
|
|
20
|
+
import { COLD_START_BOOST, TASK_MANAGEMENT_SECTION } from "./prompt.js";
|
|
21
|
+
import {
|
|
22
|
+
buildSystemReminder,
|
|
23
|
+
createCadenceState,
|
|
24
|
+
drainReminderForContext,
|
|
25
|
+
evaluateToolResult,
|
|
26
|
+
onTurnStart,
|
|
27
|
+
REMINDER_INTERVAL,
|
|
28
|
+
resetCadenceState,
|
|
29
|
+
type CadenceConfig,
|
|
30
|
+
} from "./reminder-cadence.js";
|
|
31
|
+
import { replayFromBranch } from "./replay.js";
|
|
32
|
+
import { clearTodos, getTodos, setTodos } from "./store.js";
|
|
33
|
+
import { registerTodoReadTool } from "./tools/todoread.js";
|
|
34
|
+
import { registerTodoWriteTool } from "./tools/todowrite.js";
|
|
35
|
+
import { TOOL_READ, TOOL_WRITE } from "./types.js";
|
|
36
|
+
import { hasOpenTodos, isOpenTodo } from "./validate.js";
|
|
37
|
+
|
|
38
|
+
const TODO_TOOL_NAMES = new Set([TOOL_WRITE, TOOL_READ]);
|
|
39
|
+
|
|
40
|
+
const cadenceConfig: CadenceConfig = {
|
|
41
|
+
reminderInterval: REMINDER_INTERVAL,
|
|
42
|
+
todoToolNames: TODO_TOOL_NAMES,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function isStaleCtxError(e: unknown): boolean {
|
|
46
|
+
return /stale after session replacement/i.test(String(e));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function syncFromSession(ctx: ExtensionContext, overlay: TodoOverlay | undefined): void {
|
|
50
|
+
try {
|
|
51
|
+
setTodos(replayFromBranch(ctx));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (!isStaleCtxError(e)) throw e;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (ctx.hasUI && overlay) {
|
|
57
|
+
overlay.setUICtx(ctx.ui);
|
|
58
|
+
overlay.update();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function injectReminderMessage(
|
|
63
|
+
messages: unknown[],
|
|
64
|
+
text: string,
|
|
65
|
+
): { messages: unknown[] } {
|
|
66
|
+
return {
|
|
67
|
+
messages: [
|
|
68
|
+
...messages,
|
|
69
|
+
{
|
|
70
|
+
role: "user" as const,
|
|
71
|
+
content: [{ type: "text" as const, text }],
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function (pi: ExtensionAPI): void {
|
|
79
|
+
let overlay: TodoOverlay | undefined;
|
|
80
|
+
const cadence = createCadenceState();
|
|
81
|
+
/** One-shot transient nudge for the next LLM context (cold start or completion). */
|
|
82
|
+
let pendingIntentNudge: string | null = null;
|
|
83
|
+
|
|
84
|
+
const refreshOverlay = (): void => {
|
|
85
|
+
overlay?.update();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
registerTodoWriteTool(pi, { onCommit: refreshOverlay });
|
|
89
|
+
registerTodoReadTool(pi);
|
|
90
|
+
|
|
91
|
+
// Cold-start + completion nudges. Tool description alone is often ignored;
|
|
92
|
+
// prompt-aware section + one-shot context reminder (pi-tasks style) is stronger.
|
|
93
|
+
pi.on("before_agent_start", async (event) => {
|
|
94
|
+
const prompt = typeof event.prompt === "string" ? event.prompt : "";
|
|
95
|
+
const open = hasOpenTodos(getTodos());
|
|
96
|
+
let systemPrompt = `${event.systemPrompt}\n${TASK_MANAGEMENT_SECTION}`;
|
|
97
|
+
|
|
98
|
+
if (shouldNudgeColdStart(prompt, open)) {
|
|
99
|
+
systemPrompt += `\n${COLD_START_BOOST}`;
|
|
100
|
+
pendingIntentNudge = buildColdStartReminder(prompt);
|
|
101
|
+
} else if (shouldNudgeCompletionUpdate(prompt, open)) {
|
|
102
|
+
const openLines = getTodos().filter(isOpenTodo).map(formatPlainTodoLine);
|
|
103
|
+
pendingIntentNudge = buildCompletionUpdateReminder(openLines);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { systemPrompt };
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
110
|
+
resetCadenceState(cadence);
|
|
111
|
+
pendingIntentNudge = null;
|
|
112
|
+
if (ctx.hasUI && !overlay) {
|
|
113
|
+
overlay = new TodoOverlay();
|
|
114
|
+
}
|
|
115
|
+
syncFromSession(ctx, overlay);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
119
|
+
syncFromSession(ctx, overlay);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
123
|
+
syncFromSession(ctx, overlay);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("session_shutdown", async () => {
|
|
127
|
+
try {
|
|
128
|
+
overlay?.dispose();
|
|
129
|
+
} finally {
|
|
130
|
+
// Clear in-memory store so a stale session_start (early return) cannot
|
|
131
|
+
// briefly expose the previous session's todos to reminders/tools.
|
|
132
|
+
clearTodos();
|
|
133
|
+
overlay = undefined;
|
|
134
|
+
pendingIntentNudge = null;
|
|
135
|
+
resetCadenceState(cadence);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.on("turn_start", async () => {
|
|
140
|
+
onTurnStart(cadence);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Cadence tracking only — never mutate tool result content.
|
|
144
|
+
// Gate on open work (pending/in_progress), not all-terminal lists.
|
|
145
|
+
pi.on("tool_result", async (event) => {
|
|
146
|
+
const isTodoTool = TODO_TOOL_NAMES.has(event.toolName);
|
|
147
|
+
if (
|
|
148
|
+
!isTodoTool &&
|
|
149
|
+
cadence.currentTurn - cadence.lastTodoToolUseTurn < REMINDER_INTERVAL
|
|
150
|
+
) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!isTodoTool && cadence.reminderInjectedThisCycle) return;
|
|
154
|
+
|
|
155
|
+
const hasOpenWork = isTodoTool ? false : hasOpenTodos(getTodos());
|
|
156
|
+
evaluateToolResult(cadence, event.toolName, hasOpenWork, cadenceConfig);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Transient injection for one LLM call — not persisted in the session store.
|
|
160
|
+
// Intent nudge (cold start / user-said-done) takes priority over idle cadence.
|
|
161
|
+
// Peer typings sometimes omit "context" from the overloaded `on` signature.
|
|
162
|
+
const onEvent = pi.on.bind(pi) as (event: string, handler: (...args: never[]) => unknown) => void;
|
|
163
|
+
onEvent("context", async (event: { messages: unknown[] }) => {
|
|
164
|
+
if (pendingIntentNudge) {
|
|
165
|
+
const text = pendingIntentNudge;
|
|
166
|
+
pendingIntentNudge = null;
|
|
167
|
+
return injectReminderMessage(event.messages, text);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!drainReminderForContext(cadence)) return;
|
|
171
|
+
|
|
172
|
+
const reminder = buildSystemReminder(getTodos());
|
|
173
|
+
if (!reminder) return;
|
|
174
|
+
|
|
175
|
+
return injectReminderMessage(event.messages, reminder);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Refresh from in-memory store — do NOT replayFromBranch here (branch is stale).
|
|
179
|
+
// Clear intent nudge once todowrite succeeds (model complied; avoid double-inject).
|
|
180
|
+
pi.on("tool_execution_end", async (event) => {
|
|
181
|
+
if (event.toolName !== TOOL_WRITE || event.isError) return;
|
|
182
|
+
pendingIntentNudge = null;
|
|
183
|
+
overlay?.update();
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { renderOverlayLines, shouldShowOverlay } from "./format.js";
|
|
4
|
+
import { getTodos } from "./store.js";
|
|
5
|
+
import { WIDGET_KEY } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export class TodoOverlay {
|
|
8
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
9
|
+
private widgetRegistered = false;
|
|
10
|
+
private tui: TUI | undefined;
|
|
11
|
+
|
|
12
|
+
setUICtx(ctx: ExtensionUIContext): void {
|
|
13
|
+
if (ctx !== this.uiCtx) {
|
|
14
|
+
this.uiCtx = ctx;
|
|
15
|
+
this.widgetRegistered = false;
|
|
16
|
+
this.tui = undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
update(): void {
|
|
21
|
+
if (!this.uiCtx) return;
|
|
22
|
+
const todos = getTodos();
|
|
23
|
+
|
|
24
|
+
if (!shouldShowOverlay(todos)) {
|
|
25
|
+
if (this.widgetRegistered) {
|
|
26
|
+
this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
27
|
+
this.widgetRegistered = false;
|
|
28
|
+
this.tui = undefined;
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!this.widgetRegistered) {
|
|
34
|
+
this.uiCtx.setWidget(
|
|
35
|
+
WIDGET_KEY,
|
|
36
|
+
(tui, theme: Theme) => {
|
|
37
|
+
this.tui = tui;
|
|
38
|
+
return {
|
|
39
|
+
render: (width: number) => renderOverlayLines(getTodos(), theme, width),
|
|
40
|
+
invalidate: () => {
|
|
41
|
+
this.widgetRegistered = false;
|
|
42
|
+
this.tui = undefined;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
{ placement: "aboveEditor" },
|
|
47
|
+
);
|
|
48
|
+
this.widgetRegistered = true;
|
|
49
|
+
} else {
|
|
50
|
+
this.tui?.requestRender();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isRegistered(): boolean {
|
|
55
|
+
return this.widgetRegistered;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
dispose(): void {
|
|
59
|
+
if (this.uiCtx) this.uiCtx.setWidget(WIDGET_KEY, undefined);
|
|
60
|
+
this.widgetRegistered = false;
|
|
61
|
+
this.tui = undefined;
|
|
62
|
+
this.uiCtx = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt intent heuristics for cold-start / completion nudges.
|
|
3
|
+
*
|
|
4
|
+
* Best practice (pi-todotools + pi-tasks hybrid):
|
|
5
|
+
* - Tool description alone is easy to ignore.
|
|
6
|
+
* - Idle reminders only fire when open work already exists — so cold start
|
|
7
|
+
* needs a separate, prompt-aware path.
|
|
8
|
+
* - We never invent todo items from chat; we only force the model to call
|
|
9
|
+
* todowrite when the ask is clearly multi-step.
|
|
10
|
+
*
|
|
11
|
+
* Bias: prefer multi_step for substantive asks; keep trivial very narrow.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type PromptIntent =
|
|
15
|
+
| { kind: "multi_step"; reason: string }
|
|
16
|
+
| { kind: "completion"; reason: string }
|
|
17
|
+
| { kind: "trivial"; reason: string }
|
|
18
|
+
| { kind: "unknown"; reason: string };
|
|
19
|
+
|
|
20
|
+
/** Explicit multi-step / planning verbs (EN + VI). Broad on purpose for cold start. */
|
|
21
|
+
const MULTI_STEP_VERBS =
|
|
22
|
+
/\b(explain|explore|review|walkthrough|overview|summarize|summarise|implement|refactor|audit|analyze|analyse|debug|investigate|migrate|redesign|rewrite|rebuild|build|create|add|fix|upgrade|improve|optimize|optimise|document|compare|research|triage|harden|ship|deploy|setup|configure|wire|polish|verify|validate|check|inspect|test|e2e|dogfood|install|remove|replace|integrate|extend|support|handle|track|persist|render|design|plan|giải\s*thích|khám\s*phá|rà\s*soát|triển\s*khai|refactor|sửa|làm|xây|kiểm\s*tra|phân\s*tích|cải\s*thiện|tối\s*ưu|bổ\s*sung|chỉnh|thiết\s*kế|cài|gỡ|xem|nghiên\s*cứu|đối\s*chiếu)\b/i;
|
|
23
|
+
|
|
24
|
+
/** Scope that usually implies many files/steps. */
|
|
25
|
+
const MULTI_STEP_SCOPE =
|
|
26
|
+
/\b(codebase|code\s*base|repo|repository|project|architecture|modules?|package|feature|extension|system|apps?|overlay|components?|widgets?|tools?|session|api|ui|tui|tests?|suite|docs?|readme|config|settings?|dự\s*án|tính\s*năng|toàn\s*bộ|cả\s*repo|mọi\s*thứ|các\s*file|nhiều)\b/i;
|
|
27
|
+
|
|
28
|
+
/** Soft help / agent-request phrasing that usually precedes real work. */
|
|
29
|
+
const HELP_ASK =
|
|
30
|
+
/\b(help\s+me|can\s+you|could\s+you|please|i\s+need\s+you|giúp\s*tôi|cần\s*bạn|làm\s*giúp|hãy)\b/i;
|
|
31
|
+
|
|
32
|
+
/** User explicitly wants todos / a plan. */
|
|
33
|
+
const EXPLICIT_TODO =
|
|
34
|
+
/\b(todo|todos|task\s*list|checklist|break\s*(it|this)\s*down|step\s*by\s*step|multi[\s-]*step|kế\s*hoạch|danh\s*sách\s*việc)\b/i;
|
|
35
|
+
|
|
36
|
+
/** Numbered / bulleted multi-item asks. */
|
|
37
|
+
const LIST_MARKERS = /(?:^|\n)\s*(?:\d+[\.\)]\s+\S|[-*]\s+\S)/;
|
|
38
|
+
|
|
39
|
+
/** Completion / done signals from the user. */
|
|
40
|
+
const COMPLETION_SIGNAL =
|
|
41
|
+
/\b(done|finished|complete[d]?|ship\s*it|lgtm|approved|looks\s*good|đã\s*xong|xong\s*rồi|ok\s*ship|được\s*rồi|hoàn\s*thành)\b/i;
|
|
42
|
+
|
|
43
|
+
/** Ultra-short Q&A that should not force todos. */
|
|
44
|
+
const TRIVIAL =
|
|
45
|
+
/^(hi|hello|hey|thanks?|ok|yes|no|yep|nope|ping|help|\?+|cảm\s*ơn|chào)\s*[.!]?$/i;
|
|
46
|
+
|
|
47
|
+
/** Short factual look-ups — still unknown/skip, not forced. */
|
|
48
|
+
const FACTOID =
|
|
49
|
+
/^(what('?s| is| are)|who('?s| is)|where('?s| is)|which|bao nhiêu|là gì)\b/i;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify the latest user prompt for todo nudging.
|
|
53
|
+
* Conservative on "trivial"; bias toward multi_step for substantive work.
|
|
54
|
+
*/
|
|
55
|
+
export function classifyPrompt(prompt: string): PromptIntent {
|
|
56
|
+
const text = prompt.trim();
|
|
57
|
+
if (!text) return { kind: "unknown", reason: "empty" };
|
|
58
|
+
|
|
59
|
+
// Completion before short/trivial — "done" must not fall through as greeting.
|
|
60
|
+
if (COMPLETION_SIGNAL.test(text) && text.length < 160) {
|
|
61
|
+
return { kind: "completion", reason: "done_signal" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (TRIVIAL.test(text) || (text.length < 20 && !MULTI_STEP_VERBS.test(text) && !EXPLICIT_TODO.test(text))) {
|
|
65
|
+
return { kind: "trivial", reason: "short_or_greeting" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Short factoid Q&A without work verbs — leave alone.
|
|
69
|
+
if (FACTOID.test(text) && text.length < 60 && !MULTI_STEP_SCOPE.test(text) && !EXPLICIT_TODO.test(text)) {
|
|
70
|
+
return { kind: "unknown", reason: "factoid" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (EXPLICIT_TODO.test(text)) {
|
|
74
|
+
return { kind: "multi_step", reason: "explicit_todo" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (LIST_MARKERS.test(text)) {
|
|
78
|
+
return { kind: "multi_step", reason: "list_markers" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hasVerb = MULTI_STEP_VERBS.test(text);
|
|
82
|
+
const hasScope = MULTI_STEP_SCOPE.test(text);
|
|
83
|
+
if (hasVerb && hasScope) {
|
|
84
|
+
return { kind: "multi_step", reason: "verb_and_scope" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (hasVerb && text.length >= 28) {
|
|
88
|
+
return { kind: "multi_step", reason: "verb_and_length" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (HELP_ASK.test(text) && text.length >= 36) {
|
|
92
|
+
return { kind: "multi_step", reason: "help_ask" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Multiple clauses / sequenced work.
|
|
96
|
+
if (/\b(and then|then |after that|sau đó|rồi |đồng thời|also |và )\b/i.test(text) && text.length >= 36) {
|
|
97
|
+
return { kind: "multi_step", reason: "sequenced_clauses" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Substantive paragraph with no strong verb still often needs a plan.
|
|
101
|
+
if (text.length >= 72 && !FACTOID.test(text)) {
|
|
102
|
+
return { kind: "multi_step", reason: "substantive_length" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Two+ sentences / newlines → usually multi-step instructions.
|
|
106
|
+
const sentenceBreaks = (text.match(/[.!?\n]/g) ?? []).length;
|
|
107
|
+
if (sentenceBreaks >= 2 && text.length >= 48) {
|
|
108
|
+
return { kind: "multi_step", reason: "multi_sentence" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { kind: "unknown", reason: "no_strong_signal" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when we should force a cold-start todowrite nudge. */
|
|
115
|
+
export function shouldNudgeColdStart(prompt: string, hasOpenWork: boolean): boolean {
|
|
116
|
+
if (hasOpenWork) return false;
|
|
117
|
+
return classifyPrompt(prompt).kind === "multi_step";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when we should nudge updating completed status from a user "done" signal. */
|
|
121
|
+
export function shouldNudgeCompletionUpdate(prompt: string, hasOpenWork: boolean): boolean {
|
|
122
|
+
if (!hasOpenWork) return false;
|
|
123
|
+
return classifyPrompt(prompt).kind === "completion";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildColdStartReminder(prompt: string): string {
|
|
127
|
+
const clipped = prompt.trim().replace(/\s+/g, " ").slice(0, 160);
|
|
128
|
+
return `<system-reminder>
|
|
129
|
+
This user request is multi-step. Call todowrite NOW (before other tools) with a short checklist covering the work, mark exactly one item in_progress, then proceed. The overlay only appears after todowrite.
|
|
130
|
+
|
|
131
|
+
User ask (clipped): "${clipped}"
|
|
132
|
+
|
|
133
|
+
Do not answer the full request without a todo list first. NEVER mention this reminder to the user.
|
|
134
|
+
</system-reminder>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildCompletionUpdateReminder(openLines: string[]): string {
|
|
138
|
+
const body =
|
|
139
|
+
openLines.length > 0
|
|
140
|
+
? `\nOpen todos:\n${openLines.map((l) => `- ${l}`).join("\n")}\n`
|
|
141
|
+
: "\n";
|
|
142
|
+
return `<system-reminder>
|
|
143
|
+
The user signaled that work is done/approved. Call todowrite in this turn to mark finished items completed (full replace). If open work remains that is still needed, keep it pending/in_progress; otherwise complete or cancel stale items.
|
|
144
|
+
${body}
|
|
145
|
+
NEVER mention this reminder to the user.
|
|
146
|
+
</system-reminder>`;
|
|
147
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export const TODOWRITE_DESCRIPTION = `Create and maintain a structured task list for the current coding session. Tracks progress, organizes multi-step work, and surfaces status in an overlay above the editor.
|
|
2
|
+
|
|
3
|
+
## When to Use This Tool
|
|
4
|
+
|
|
5
|
+
Use this tool **proactively** — call todowrite **before** starting the work in these scenarios:
|
|
6
|
+
|
|
7
|
+
- Complex multi-step tasks — when work needs 3+ distinct steps
|
|
8
|
+
- Non-trivial work that benefits from planning (implement, refactor, debug, audit)
|
|
9
|
+
- **Explain / explore / review a codebase or feature** across multiple files or modules — break the walkthrough into todos first
|
|
10
|
+
- User provides multiple tasks (numbered or comma-separated) or asks for a todo list
|
|
11
|
+
- After receiving new instructions — capture requirements as todos before coding or explaining
|
|
12
|
+
- When you start a task — mark it \`in_progress\` (exactly ONE) BEFORE beginning work
|
|
13
|
+
- When you finish a task — mark it \`completed\` immediately (same turn you finish), then advance the next pending item to \`in_progress\`
|
|
14
|
+
- When the user says work is done / approved / finished — update the matching item to \`completed\` via todowrite before continuing
|
|
15
|
+
|
|
16
|
+
## When NOT to Use This Tool
|
|
17
|
+
|
|
18
|
+
Skip when:
|
|
19
|
+
- There is only a single, straightforward step (one file glance, one short answer)
|
|
20
|
+
- A one-line factual question with no multi-step plan
|
|
21
|
+
- Tracking truly adds no organizational value
|
|
22
|
+
|
|
23
|
+
Do **not** skip just because the request is "explain" or "review" — if the answer needs multiple steps or sections, use todowrite.
|
|
24
|
+
|
|
25
|
+
## States
|
|
26
|
+
|
|
27
|
+
- \`pending\` — not started
|
|
28
|
+
- \`in_progress\` — actively working (exactly ONE at a time; the tool rejects >1)
|
|
29
|
+
- \`completed\` — finished successfully
|
|
30
|
+
- \`cancelled\` — no longer needed
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
- Each call **REPLACES** the entire list (full replace). Always pass the complete todos array.
|
|
35
|
+
- Update status in real time; don't batch completions across multiple finished steps.
|
|
36
|
+
- Mark \`completed\` only after the work is actually done (including verification) — never on intent alone.
|
|
37
|
+
- Keep exactly one \`in_progress\` while actively working. Never leave a stale \`in_progress\` after that step is finished.
|
|
38
|
+
- After marking an item \`completed\`, if open work remains, set the next actionable item to \`in_progress\` in the same todowrite call when you are continuing.
|
|
39
|
+
- If blocked, keep the active item \`in_progress\` and add a follow-up todo for the blocker.
|
|
40
|
+
- Items should be specific and actionable.
|
|
41
|
+
- Do not call todowrite and todoread in the same parallel tool batch — write first, then read later if needed.
|
|
42
|
+
- Prefer the live overlay for status; use todoread only when you need the JSON snapshot.`;
|
|
43
|
+
|
|
44
|
+
export const TODOREAD_DESCRIPTION =
|
|
45
|
+
"Read the current session todo list. Returns JSON of all todos with status and priority. Prefer the overlay for at-a-glance status; call after todowrite settles (not in the same parallel batch).";
|
|
46
|
+
|
|
47
|
+
export const TODOWRITE_GUIDELINES = [
|
|
48
|
+
"For multi-step work (3+ steps), codebase explain/explore/review, or when the user gives a list of tasks, call todowrite BEFORE starting — do not skip tracking.",
|
|
49
|
+
"Pass the full list every todowrite call (full replace). Keep exactly one todo in_progress; mark completed immediately when a step finishes — never leave a stale in_progress.",
|
|
50
|
+
"When finishing a step or when the user confirms done, call todowrite in that same turn to mark completed and advance the next pending item if work continues.",
|
|
51
|
+
"Do not invent TaskCreate/TaskUpdate tools — use todowrite/todoread only.",
|
|
52
|
+
"Do not call todowrite and todoread in the same parallel tool batch.",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Appended to the system prompt on every agent start (pi-todotools pattern).
|
|
57
|
+
* Fixes cold-start: tool description alone is easy for models to ignore.
|
|
58
|
+
* Keep short — token cost every turn.
|
|
59
|
+
*/
|
|
60
|
+
export const TASK_MANAGEMENT_SECTION = `
|
|
61
|
+
<Task_Management>
|
|
62
|
+
todowrite/todoread are the coordination layer for multi-step work. The TUI overlay only appears after todowrite — without it the user cannot see plan/progress.
|
|
63
|
+
|
|
64
|
+
Required cold start: for explain/explore/review/implement/refactor/audit/debug/fix/polish/setup of a codebase, feature, UI, or multi-file ask — your FIRST tool call must be todowrite with a short checklist (WHAT/WHERE), exactly one in_progress, then continue. Do not start with read/bash/search alone on those asks.
|
|
65
|
+
|
|
66
|
+
Ongoing: mark completed immediately when a step finishes; advance the next pending item in the same todowrite; when the user says done/approved, update via todowrite that turn.
|
|
67
|
+
|
|
68
|
+
Skip only single trivial Q&A (one short fact, greeting). Multi-step Vietnamese or English asks (giải thích, chỉnh, bổ sung, help me, …) still need todos first.
|
|
69
|
+
</Task_Management>
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/** Extra systemPrompt boost when prompt-intent detects multi-step + empty list. */
|
|
73
|
+
export const COLD_START_BOOST = `
|
|
74
|
+
<Task_Management_Priority>
|
|
75
|
+
COLD START ACTIVE: the current user message is multi-step and there is no open todo list. Call todowrite before any other tool. Prefer 3–8 concrete checklist items. Keep exactly one in_progress.
|
|
76
|
+
</Task_Management_Priority>
|
|
77
|
+
`;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure cadence logic for system-reminder injection (ported from tintinweb/pi-tasks).
|
|
3
|
+
*
|
|
4
|
+
* tool_result tracks cadence only — never mutates tool output.
|
|
5
|
+
* context drains the pending reminder into a transient user message for one LLM call.
|
|
6
|
+
*
|
|
7
|
+
* Reminder body is state-aware (edxeth/meh pi-tasks pattern): list open todos only,
|
|
8
|
+
* and call out the in_progress item so the model updates completed status.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { formatPlainTodoLine } from "./format.js";
|
|
12
|
+
import type { TodoItem } from "./types.js";
|
|
13
|
+
import { hasOpenTodos, isOpenTodo } from "./validate.js";
|
|
14
|
+
|
|
15
|
+
export interface CadenceState {
|
|
16
|
+
currentTurn: number;
|
|
17
|
+
lastTodoToolUseTurn: number;
|
|
18
|
+
reminderInjectedThisCycle: boolean;
|
|
19
|
+
reminderDue: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CadenceConfig {
|
|
23
|
+
/** Turns without a todo-tool call before a reminder is considered due. */
|
|
24
|
+
reminderInterval: number;
|
|
25
|
+
/** Tool names that count as todo usage and reset cadence. */
|
|
26
|
+
todoToolNames: ReadonlySet<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createCadenceState(): CadenceState {
|
|
30
|
+
return {
|
|
31
|
+
currentTurn: 0,
|
|
32
|
+
lastTodoToolUseTurn: 0,
|
|
33
|
+
reminderInjectedThisCycle: false,
|
|
34
|
+
reminderDue: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resetCadenceState(state: CadenceState): void {
|
|
39
|
+
state.currentTurn = 0;
|
|
40
|
+
state.lastTodoToolUseTurn = 0;
|
|
41
|
+
state.reminderInjectedThisCycle = false;
|
|
42
|
+
state.reminderDue = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function onTurnStart(state: CadenceState): void {
|
|
46
|
+
state.currentTurn++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decide cadence change from a tool_result. Mutates state; returns whether
|
|
51
|
+
* the reminder should be queued for the next context event.
|
|
52
|
+
*
|
|
53
|
+
* `hasOpenWork` must reflect pending/in_progress only — all-terminal lists
|
|
54
|
+
* should not re-arm reminders (OpenCode: done means overlay gone).
|
|
55
|
+
*/
|
|
56
|
+
export function evaluateToolResult(
|
|
57
|
+
state: CadenceState,
|
|
58
|
+
toolName: string,
|
|
59
|
+
hasOpenWork: boolean,
|
|
60
|
+
config: CadenceConfig,
|
|
61
|
+
): { markDue: boolean } {
|
|
62
|
+
if (config.todoToolNames.has(toolName)) {
|
|
63
|
+
state.lastTodoToolUseTurn = state.currentTurn;
|
|
64
|
+
state.reminderInjectedThisCycle = false;
|
|
65
|
+
state.reminderDue = false;
|
|
66
|
+
return { markDue: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (state.currentTurn - state.lastTodoToolUseTurn < config.reminderInterval) {
|
|
70
|
+
return { markDue: false };
|
|
71
|
+
}
|
|
72
|
+
if (state.reminderInjectedThisCycle) return { markDue: false };
|
|
73
|
+
if (!hasOpenWork) return { markDue: false };
|
|
74
|
+
|
|
75
|
+
state.reminderDue = true;
|
|
76
|
+
return { markDue: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Drain pending reminder when `context` fires. */
|
|
80
|
+
export function drainReminderForContext(state: CadenceState): boolean {
|
|
81
|
+
if (!state.reminderDue) return false;
|
|
82
|
+
state.reminderDue = false;
|
|
83
|
+
state.reminderInjectedThisCycle = true;
|
|
84
|
+
state.lastTodoToolUseTurn = state.currentTurn;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Default: 4 turns without todowrite/todoread while open work remains. */
|
|
89
|
+
export const REMINDER_INTERVAL = 4;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a transient system-reminder from the live open-todo snapshot.
|
|
93
|
+
* Returns null when there is nothing open (caller must not inject).
|
|
94
|
+
*/
|
|
95
|
+
export function buildSystemReminder(todos: readonly TodoItem[]): string | null {
|
|
96
|
+
if (!hasOpenTodos(todos)) return null;
|
|
97
|
+
|
|
98
|
+
const open = todos.filter(isOpenTodo);
|
|
99
|
+
const inProgress = open.filter((t) => t.status === "in_progress");
|
|
100
|
+
const lines = open.map(formatPlainTodoLine);
|
|
101
|
+
|
|
102
|
+
const focus =
|
|
103
|
+
inProgress.length > 0
|
|
104
|
+
? `Active item still in_progress: "${inProgress[0].content}". If that work is finished, call todowrite immediately with it marked completed (full replace), then set the next pending item to in_progress (exactly one). Do not leave a stale [•] after finishing a step.`
|
|
105
|
+
: `Open items are still pending with none in_progress. If you are about to work, call todowrite and mark exactly one item in_progress before continuing.`;
|
|
106
|
+
|
|
107
|
+
return `<system-reminder>
|
|
108
|
+
The todo tools haven't been used recently, and open work remains:
|
|
109
|
+
|
|
110
|
+
${lines.map((l) => `- ${l}`).join("\n")}
|
|
111
|
+
|
|
112
|
+
${focus}
|
|
113
|
+
|
|
114
|
+
Only act if relevant to the current work. This is a gentle reminder — ignore if not applicable. NEVER mention this reminder to the user.
|
|
115
|
+
</system-reminder>`;
|
|
116
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { TodoItem, TodoWriteDetails } from "./types.js";
|
|
2
|
+
import { TODO_STATE_ENTRY_TYPE, TOOL_WRITE } from "./types.js";
|
|
3
|
+
import { TERMINAL_STATUSES, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
|
|
4
|
+
|
|
5
|
+
type BranchEntry = {
|
|
6
|
+
type?: string;
|
|
7
|
+
customType?: string;
|
|
8
|
+
data?: unknown;
|
|
9
|
+
message?: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return !!value && typeof value === "object";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isTodoItem(value: unknown): value is TodoItem {
|
|
17
|
+
if (!isRecord(value)) return false;
|
|
18
|
+
return (
|
|
19
|
+
typeof value.content === "string" &&
|
|
20
|
+
typeof value.status === "string" &&
|
|
21
|
+
(TODO_STATUSES as readonly string[]).includes(value.status) &&
|
|
22
|
+
typeof value.priority === "string" &&
|
|
23
|
+
(TODO_PRIORITIES as readonly string[]).includes(value.priority)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTodoList(value: unknown): value is TodoItem[] {
|
|
28
|
+
return Array.isArray(value) && value.every(isTodoItem);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isWriteDetails(value: unknown): value is TodoWriteDetails {
|
|
32
|
+
return isRecord(value) && isTodoList(value.todos);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Last-write-wins over the current branch:
|
|
37
|
+
* - custom `pi-todo.state` entries
|
|
38
|
+
* - `todowrite` toolResult details
|
|
39
|
+
*/
|
|
40
|
+
export function replayFromBranch(ctx: {
|
|
41
|
+
sessionManager: { getBranch(): Iterable<unknown> };
|
|
42
|
+
}): TodoItem[] {
|
|
43
|
+
let todos: TodoItem[] = [];
|
|
44
|
+
|
|
45
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
46
|
+
const e = entry as BranchEntry;
|
|
47
|
+
|
|
48
|
+
if (e.type === "custom" && e.customType === TODO_STATE_ENTRY_TYPE) {
|
|
49
|
+
if (isRecord(e.data) && isTodoList(e.data.todos)) {
|
|
50
|
+
todos = e.data.todos.map((t) => ({ ...t }));
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (e.type !== "message" || !isRecord(e.message)) continue;
|
|
56
|
+
const msg = e.message;
|
|
57
|
+
if (msg.role !== "toolResult" || msg.toolName !== TOOL_WRITE) continue;
|
|
58
|
+
if (!isWriteDetails(msg.details)) continue;
|
|
59
|
+
// Skip error envelopes — they did not commit
|
|
60
|
+
if (msg.details.error) continue;
|
|
61
|
+
todos = msg.details.todos.map((t) => ({ ...t }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return todos;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isTerminalList(todos: readonly TodoItem[]): boolean {
|
|
68
|
+
return todos.length === 0 || todos.every((t) => TERMINAL_STATUSES.has(t.status));
|
|
69
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Strip ANSI and control characters; collapse whitespace to a single line. */
|
|
2
|
+
export function sanitizeTodoText(text: string): string {
|
|
3
|
+
return text
|
|
4
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
5
|
+
.replace(/[\r\n]+/g, " ")
|
|
6
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
|
|
7
|
+
.replace(/\s+/g, " ")
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export const TodoItemSchema = Type.Object({
|
|
6
|
+
content: Type.String({ description: "Brief description of the task" }),
|
|
7
|
+
status: StringEnum([...TODO_STATUSES], {
|
|
8
|
+
description: "pending | in_progress | completed | cancelled",
|
|
9
|
+
}),
|
|
10
|
+
priority: StringEnum([...TODO_PRIORITIES], {
|
|
11
|
+
description: "high | medium | low",
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const TodoWriteParams = Type.Object({
|
|
16
|
+
todos: Type.Array(TodoItemSchema, {
|
|
17
|
+
description: "The complete updated todo list (full replace)",
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const TodoReadParams = Type.Object({});
|
|
22
|
+
|
|
23
|
+
export type TodoWriteInput = Static<typeof TodoWriteParams>;
|
|
24
|
+
export type TodoItemInput = Static<typeof TodoItemSchema>;
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TodoItem } from "./types.js";
|
|
2
|
+
|
|
3
|
+
let currentTodos: TodoItem[] = [];
|
|
4
|
+
/** Serializes todowrite/todoread across parallel tool batches. */
|
|
5
|
+
let storeChain: Promise<unknown> = Promise.resolve();
|
|
6
|
+
|
|
7
|
+
export function getTodos(): TodoItem[] {
|
|
8
|
+
return currentTodos.map((t) => ({ ...t }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setTodos(todos: readonly TodoItem[]): void {
|
|
12
|
+
currentTodos = todos.map((t) => ({ ...t }));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clearTodos(): void {
|
|
16
|
+
currentTodos = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run store-touching work in a single-file queue so parallel tool batches
|
|
21
|
+
* (todowrite + todoread in the same turn) cannot race.
|
|
22
|
+
*/
|
|
23
|
+
export function withStoreLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
24
|
+
const run = storeChain.then(() => fn());
|
|
25
|
+
storeChain = run.then(
|
|
26
|
+
() => undefined,
|
|
27
|
+
() => undefined,
|
|
28
|
+
);
|
|
29
|
+
return run;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Test helper — reset module state between tests. */
|
|
33
|
+
export function __resetStore(): void {
|
|
34
|
+
currentTodos = [];
|
|
35
|
+
storeChain = Promise.resolve();
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatTodoListText } from "../format.js";
|
|
4
|
+
import { TODOREAD_DESCRIPTION } from "../prompt.js";
|
|
5
|
+
import { TodoReadParams } from "../schema.js";
|
|
6
|
+
import { getTodos, withStoreLock } from "../store.js";
|
|
7
|
+
import { TOOL_READ } from "../types.js";
|
|
8
|
+
import { countOpenTodos } from "../validate.js";
|
|
9
|
+
|
|
10
|
+
export function registerTodoReadTool(pi: ExtensionAPI): void {
|
|
11
|
+
pi.registerTool({
|
|
12
|
+
name: TOOL_READ,
|
|
13
|
+
label: "Todo Read",
|
|
14
|
+
description: TODOREAD_DESCRIPTION,
|
|
15
|
+
promptSnippet: "Read the current session todo list",
|
|
16
|
+
parameters: TodoReadParams,
|
|
17
|
+
|
|
18
|
+
async execute() {
|
|
19
|
+
return withStoreLock(() => {
|
|
20
|
+
const todos = getTodos();
|
|
21
|
+
const open = countOpenTodos(todos);
|
|
22
|
+
const text =
|
|
23
|
+
todos.length === 0
|
|
24
|
+
? "No todos"
|
|
25
|
+
: formatTodoListText(todos, `${open} open / ${todos.length} total`);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "text",
|
|
31
|
+
text: `${text}\n\n${JSON.stringify(todos, null, 2)}`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
details: { todos },
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
renderCall(_args, theme) {
|
|
40
|
+
return new Text(theme.fg("toolTitle", theme.bold("todoread")), 0, 0);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
renderResult(result, _opts, theme) {
|
|
44
|
+
const details = result.details as { todos?: unknown[] } | undefined;
|
|
45
|
+
const n = Array.isArray(details?.todos) ? details.todos.length : 0;
|
|
46
|
+
return new Text(theme.fg("muted", `${n} todo(s)`), 0, 0);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatTodoListText } from "../format.js";
|
|
4
|
+
import { TODOWRITE_DESCRIPTION, TODOWRITE_GUIDELINES } from "../prompt.js";
|
|
5
|
+
import { TodoWriteParams } from "../schema.js";
|
|
6
|
+
import { getTodos, setTodos, withStoreLock } from "../store.js";
|
|
7
|
+
import type { TodoWriteDetails } from "../types.js";
|
|
8
|
+
import { TODO_STATE_ENTRY_TYPE, TOOL_WRITE } from "../types.js";
|
|
9
|
+
import { countOpenTodos, validateTodoWrite } from "../validate.js";
|
|
10
|
+
|
|
11
|
+
export function registerTodoWriteTool(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
options: { onCommit?: () => void },
|
|
14
|
+
): void {
|
|
15
|
+
pi.registerTool({
|
|
16
|
+
name: TOOL_WRITE,
|
|
17
|
+
label: "Todo Write",
|
|
18
|
+
description: TODOWRITE_DESCRIPTION,
|
|
19
|
+
promptSnippet: "Replace the session todo list (full replace); track multi-step work",
|
|
20
|
+
promptGuidelines: TODOWRITE_GUIDELINES,
|
|
21
|
+
parameters: TodoWriteParams,
|
|
22
|
+
|
|
23
|
+
async execute(_toolCallId, params) {
|
|
24
|
+
return withStoreLock(() => {
|
|
25
|
+
const current = getTodos();
|
|
26
|
+
const result = validateTodoWrite(params.todos, current);
|
|
27
|
+
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
const details: TodoWriteDetails = { todos: current, error: result.error };
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: `Error: ${result.error}` }],
|
|
32
|
+
details,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setTodos(result.todos);
|
|
37
|
+
|
|
38
|
+
// Durable custom entry (not sent to LLM) — survives compaction better than tool details alone.
|
|
39
|
+
// Skip no-op rewrites to avoid session noise.
|
|
40
|
+
if (!result.unchanged) {
|
|
41
|
+
try {
|
|
42
|
+
pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos: result.todos });
|
|
43
|
+
} catch {
|
|
44
|
+
// Host may reject; toolResult details still provide branch replay.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
options.onCommit?.();
|
|
49
|
+
|
|
50
|
+
const open = countOpenTodos(result.todos);
|
|
51
|
+
const summary = result.unchanged
|
|
52
|
+
? "No change"
|
|
53
|
+
: `${open} open / ${result.todos.length} total`;
|
|
54
|
+
const body =
|
|
55
|
+
result.todos.length === 0 ? "Cleared todos" : formatTodoListText(result.todos, summary);
|
|
56
|
+
|
|
57
|
+
const details: TodoWriteDetails = {
|
|
58
|
+
todos: result.todos,
|
|
59
|
+
...(result.unchanged ? { unchanged: true } : {}),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text", text: body }],
|
|
64
|
+
details,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
renderCall(args, theme) {
|
|
70
|
+
const n = Array.isArray(args.todos) ? args.todos.length : 0;
|
|
71
|
+
return new Text(
|
|
72
|
+
theme.fg("toolTitle", theme.bold("todowrite ")) + theme.fg("muted", `${n} item(s)`),
|
|
73
|
+
0,
|
|
74
|
+
0,
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
renderResult(result, _opts, theme) {
|
|
79
|
+
const details = result.details as TodoWriteDetails | undefined;
|
|
80
|
+
if (details?.error) {
|
|
81
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
82
|
+
}
|
|
83
|
+
if (details?.unchanged) {
|
|
84
|
+
return new Text(theme.fg("dim", "No change"), 0, 0);
|
|
85
|
+
}
|
|
86
|
+
const text = result.content[0];
|
|
87
|
+
const msg = text?.type === "text" ? text.text.split("\n")[0] ?? "Updated" : "Updated";
|
|
88
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
|
2
|
+
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
3
|
+
|
|
4
|
+
export const TODO_PRIORITIES = ["high", "medium", "low"] as const;
|
|
5
|
+
export type TodoPriority = (typeof TODO_PRIORITIES)[number];
|
|
6
|
+
|
|
7
|
+
export const TERMINAL_STATUSES: ReadonlySet<TodoStatus> = new Set(["completed", "cancelled"]);
|
|
8
|
+
|
|
9
|
+
export interface TodoItem {
|
|
10
|
+
content: string;
|
|
11
|
+
status: TodoStatus;
|
|
12
|
+
priority: TodoPriority;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TodoWriteDetails {
|
|
16
|
+
todos: TodoItem[];
|
|
17
|
+
error?: string;
|
|
18
|
+
unchanged?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const TODO_STATE_ENTRY_TYPE = "pi-todo.state";
|
|
22
|
+
export const TOOL_WRITE = "todowrite";
|
|
23
|
+
export const TOOL_READ = "todoread";
|
|
24
|
+
export const WIDGET_KEY = "pi-todo";
|
|
25
|
+
export const MAX_OVERLAY_LINES = 12;
|
|
26
|
+
/** Max characters per todo content after sanitize (context/tool safety). */
|
|
27
|
+
export const MAX_CONTENT_LENGTH = 500;
|
|
28
|
+
/** Max todo lines echoed in todowrite/todoread text (full list still in details/JSON). */
|
|
29
|
+
export const MAX_RESULT_LINES = 40;
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { sanitizeTodoText } from "./sanitize.js";
|
|
2
|
+
import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
|
|
3
|
+
import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
|
|
6
|
+
export type ValidateErr = { ok: false; error: string };
|
|
7
|
+
export type ValidateResult = ValidateOk | ValidateErr;
|
|
8
|
+
|
|
9
|
+
function isStatus(value: unknown): value is TodoStatus {
|
|
10
|
+
return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isPriority(value: unknown): value is TodoPriority {
|
|
14
|
+
return typeof value === "string" && (TODO_PRIORITIES as readonly string[]).includes(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function todosEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
|
|
18
|
+
if (a.length !== b.length) return false;
|
|
19
|
+
return a.every(
|
|
20
|
+
(item, i) =>
|
|
21
|
+
item.content === b[i].content && item.status === b[i].status && item.priority === b[i].priority,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate and normalize a full-replace payload.
|
|
27
|
+
* Hard-enforces at most one `in_progress`. Does not mutate `current`.
|
|
28
|
+
*/
|
|
29
|
+
export function validateTodoWrite(
|
|
30
|
+
rawTodos: unknown,
|
|
31
|
+
current: readonly TodoItem[],
|
|
32
|
+
): ValidateResult {
|
|
33
|
+
if (!Array.isArray(rawTodos)) {
|
|
34
|
+
return { ok: false, error: "todos must be an array" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const todos: TodoItem[] = [];
|
|
38
|
+
let inProgressCount = 0;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < rawTodos.length; i++) {
|
|
41
|
+
const item = rawTodos[i];
|
|
42
|
+
if (!item || typeof item !== "object") {
|
|
43
|
+
return { ok: false, error: `todos[${i}] must be an object` };
|
|
44
|
+
}
|
|
45
|
+
const rec = item as Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
if (typeof rec.content !== "string") {
|
|
48
|
+
return { ok: false, error: `todos[${i}].content must be a string` };
|
|
49
|
+
}
|
|
50
|
+
let content = sanitizeTodoText(rec.content);
|
|
51
|
+
if (!content) {
|
|
52
|
+
return { ok: false, error: `todos[${i}].content must be non-empty` };
|
|
53
|
+
}
|
|
54
|
+
if (content.length > MAX_CONTENT_LENGTH) {
|
|
55
|
+
content = `${content.slice(0, MAX_CONTENT_LENGTH - 1)}…`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isStatus(rec.status)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `todos[${i}].status must be one of: ${TODO_STATUSES.join(", ")}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!isPriority(rec.priority)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: `todos[${i}].priority must be one of: ${TODO_PRIORITIES.join(", ")}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (rec.status === "in_progress") inProgressCount += 1;
|
|
72
|
+
|
|
73
|
+
todos.push({ content, status: rec.status, priority: rec.priority });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (inProgressCount > 1) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `exactly one in_progress allowed (got ${inProgressCount}); keep only the active task in_progress`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { ok: true, todos, unchanged: todosEqual(todos, current) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isTerminalStatus(status: TodoStatus): boolean {
|
|
87
|
+
return status === "completed" || status === "cancelled";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isOpenTodo(todo: TodoItem): boolean {
|
|
91
|
+
return !isTerminalStatus(todo.status);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hasOpenTodos(todos: readonly TodoItem[]): boolean {
|
|
95
|
+
return todos.some(isOpenTodo);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function countOpenTodos(todos: readonly TodoItem[]): number {
|
|
99
|
+
return todos.filter(isOpenTodo).length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Count of `in_progress` items (shown as "running" in the overlay heading). */
|
|
103
|
+
export function countRunningTodos(todos: readonly TodoItem[]): number {
|
|
104
|
+
return todos.filter((t) => t.status === "in_progress").length;
|
|
105
|
+
}
|