pi-task-manager 0.1.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/LICENSE +21 -0
- package/PLAN.md +106 -0
- package/README.md +85 -0
- package/index.ts +89 -0
- package/lib/parser.ts +345 -0
- package/lib/task-manager.ts +714 -0
- package/lib/task.ts +57 -0
- package/lib/tools.ts +179 -0
- package/package.json +28 -0
- package/skills/task-manager/SKILL.md +50 -0
- package/tests/parser.test.ts +120 -0
- package/tests/robustness.test.ts +154 -0
- package/tests/task-manager.test.ts +158 -0
- package/tests/validation.test.ts +194 -0
- package/tsconfig.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gil Assayag
|
|
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 OF THE SOFTWARE, EVEN IF
|
|
21
|
+
NOT ADVISED OF THE POSSIBILITY OF DAMAGE.
|
package/PLAN.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# pi-task-manager — Plan
|
|
2
|
+
|
|
3
|
+
A pi extension that provides task/todo management via custom tools, with the task logic ported from Python to TypeScript.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
**TypeScript extension** that registers tools with TypeBox schemas. The task management logic (parsing, editing, saving) is ported from `task_manager.py` to TypeScript — no Python subprocess needed.
|
|
8
|
+
|
|
9
|
+
### Why migrate to TypeScript instead of wrapping Python?
|
|
10
|
+
|
|
11
|
+
- **No subprocess overhead** — no spawn, no JSON serialization, no process lifecycle management
|
|
12
|
+
- **Native pi integration** — runs in the same process, shares `ctx`, `signal`, etc.
|
|
13
|
+
- **Simpler debugging** — one language, one runtime, one stack trace
|
|
14
|
+
- **Better state management** — the singleton `_manager` pattern works naturally in TypeScript (no per-call process restart)
|
|
15
|
+
- **No Python dependency** — users don't need Python installed
|
|
16
|
+
|
|
17
|
+
## Project Structure
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pi-task-manager/
|
|
21
|
+
├── package.json # pi package manifest + dependencies
|
|
22
|
+
├── README.md # User-facing docs
|
|
23
|
+
├── PLAN.md # This file
|
|
24
|
+
├── extension/
|
|
25
|
+
│ ├── index.ts # Extension entry: register tools, commands
|
|
26
|
+
│ └── lib/
|
|
27
|
+
│ ├── task-manager.ts # Core TaskManager class (ported from Python)
|
|
28
|
+
│ ├── task.ts # Task class / dataclass equivalent
|
|
29
|
+
│ ├── parser.ts # Markdown line parser (ported from _parse_task_line)
|
|
30
|
+
│ └── tools.ts # Tool definitions with TypeBox schemas
|
|
31
|
+
└── skills/
|
|
32
|
+
└── task-manager/
|
|
33
|
+
└── SKILL.md # Usage instructions for the LLM
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Porting from Python
|
|
37
|
+
|
|
38
|
+
### Source files to reference
|
|
39
|
+
|
|
40
|
+
- `~/agent/tools/task_manager.py` — Main implementation (TaskManager class, Task dataclass, parsing)
|
|
41
|
+
- `~/agent/tools/task_manager_design.md` — Design rationale
|
|
42
|
+
- `~/agent/tools/tasks_emojis_format.md` — Emoji conventions for tasks
|
|
43
|
+
|
|
44
|
+
### Key Python constructs to port
|
|
45
|
+
|
|
46
|
+
1. **Task dataclass** → TypeScript class/interface with fields:
|
|
47
|
+
- `id`, `text`, `completed`, `emoji`, `category`, `priority`, `project`, `context`, `tags`, `due`, `created`, `modified`, `indent`
|
|
48
|
+
|
|
49
|
+
2. **TaskManager class** → TypeScript class with methods:
|
|
50
|
+
- `openFile(path)`, `addTask()`, `editTask()`, `moveTask()`, `getTask()`, `listTasks()`, `save()`, `closeFile()`
|
|
51
|
+
|
|
52
|
+
3. **_parse_task_line** → TypeScript function returning parsed Task fields from a markdown line
|
|
53
|
+
|
|
54
|
+
4. **Emoji handling** → Same emoji map, just TypeScript objects
|
|
55
|
+
|
|
56
|
+
## Tool Definitions
|
|
57
|
+
|
|
58
|
+
Each Python action maps to a pi tool:
|
|
59
|
+
|
|
60
|
+
| Tool Name | Action | Description |
|
|
61
|
+
|-----------|--------|-------------|
|
|
62
|
+
| `task_open` | `open_file` | Open a TODO.md file for editing |
|
|
63
|
+
| `task_add` | `add_task` | Add a new task |
|
|
64
|
+
| `task_edit` | `edit_task` | Edit an existing task |
|
|
65
|
+
| `task_move` | `move_task` | Move a task to a different category |
|
|
66
|
+
| `task_get` | `get_task` | Get details of a single task |
|
|
67
|
+
| `task_list` | `list_tasks` | List tasks (with filters) |
|
|
68
|
+
| `task_save` | `save` | Save current state to disk |
|
|
69
|
+
| `task_close` | `close_file` | Close the current file |
|
|
70
|
+
|
|
71
|
+
## Implementation Steps
|
|
72
|
+
|
|
73
|
+
### Phase 1: Core (Day 1)
|
|
74
|
+
|
|
75
|
+
1. Create project structure with `package.json`
|
|
76
|
+
2. Port `Task` dataclass → TypeScript interface
|
|
77
|
+
3. Port `_parse_task_line` → TypeScript parser function
|
|
78
|
+
4. Port `TaskManager` class → TypeScript (core methods only)
|
|
79
|
+
5. Write basic tests for parser
|
|
80
|
+
|
|
81
|
+
### Phase 2: Extension (Day 2)
|
|
82
|
+
|
|
83
|
+
6. Create `extension/index.ts` with tool registrations
|
|
84
|
+
7. Define TypeBox schemas for each tool
|
|
85
|
+
8. Wire tool execute handlers to TaskManager methods
|
|
86
|
+
9. Test extension in pi (load via `pi -e`)
|
|
87
|
+
|
|
88
|
+
### Phase 3: Polish (Day 3)
|
|
89
|
+
|
|
90
|
+
10. Create `skills/task-manager/SKILL.md` with usage instructions
|
|
91
|
+
11. Add `resources_discover` handler for skill auto-discovery
|
|
92
|
+
12. Write `README.md`
|
|
93
|
+
13. Test full workflow end-to-end
|
|
94
|
+
|
|
95
|
+
### Phase 4: Optional
|
|
96
|
+
|
|
97
|
+
14. Add custom TUI rendering (status bar showing task counts)
|
|
98
|
+
15. Add `/tasks` command for quick status
|
|
99
|
+
16. Add `session_start` hook to auto-open TODO.md if present in cwd
|
|
100
|
+
|
|
101
|
+
## Design Decisions
|
|
102
|
+
|
|
103
|
+
- **State management**: The TaskManager singleton persists across tool calls within a session. `open_file` sets the active file, subsequent actions operate on it, `close_file` clears it. This matches the Python behavior.
|
|
104
|
+
- **No subprocess**: Pure TypeScript, no Python dependency.
|
|
105
|
+
- **TypeBox schemas**: Use `Type.Object()` for parameters, `StringEnum` for fixed choices (category, priority).
|
|
106
|
+
- **Skill + Extension**: The extension provides the tools; the skill provides the LLM with usage instructions and conventions.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# pi-task-manager
|
|
2
|
+
|
|
3
|
+
A pi extension for hierarchical task management. Tasks live in a `TODO.md`
|
|
4
|
+
file as an indented tree; the extension exposes 8 `task_*` tools backed by a
|
|
5
|
+
pure-TypeScript port of the Python task manager — no subprocess, no Python
|
|
6
|
+
dependency.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pi install git:github.com/gilgil/pi-task-manager
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Any git source works: `pi install https://github.com/gilgil/pi-task-manager`,
|
|
15
|
+
`pi install /local/path`, or `pi install npm:pi-task-manager` once
|
|
16
|
+
published to npm. Pin a tag for stability:
|
|
17
|
+
`pi install git:github.com/gilgil/pi-task-manager@v0.1.0`;
|
|
18
|
+
`pi update --extensions` reconciles the clone to the pinned ref.
|
|
19
|
+
|
|
20
|
+
The extension registers the tools and the `task-manager` skill automatically.
|
|
21
|
+
If a `TODO.md` exists in the working directory, it is opened automatically at
|
|
22
|
+
session start (with a notification).
|
|
23
|
+
|
|
24
|
+
## Task file format
|
|
25
|
+
|
|
26
|
+
```markdown
|
|
27
|
+
# TODO
|
|
28
|
+
|
|
29
|
+
- [ ] Buy milk ➕ 2026-08-15 🖊️ 2026-08-15 (ID: `5Tvc0d`)
|
|
30
|
+
- [ ] Organic 📅 2026-08-20 (ID: `VpLDzY`)
|
|
31
|
+
- [x] Oat milk ✅ 2026-08-14 (ID: `Ab3x9Z`)
|
|
32
|
+
- [> ] Write report ⏳ 2026-08-16 (ID: `Qw7m2K`)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **Indentation** (2 spaces) defines the tree — each task's children are the
|
|
36
|
+
indented lines beneath it.
|
|
37
|
+
- **Status** in the checkbox: ` ` open · `x` done · `>` in-progress ·
|
|
38
|
+
`!` failed · `-` cancelled
|
|
39
|
+
- **Emoji annotations** between description and ID:
|
|
40
|
+
`⏳` scheduled · `🛫` start · `📅` due · `✅` done · `❌` cancelled ·
|
|
41
|
+
`➕` created · `🖊️` modified
|
|
42
|
+
- **ID**: 6-char base62, stable, referenced by `parent_id`, `depends_on`, etc.
|
|
43
|
+
- `depth`, `position`, and `parent_id` are always derived from the tree —
|
|
44
|
+
never stored.
|
|
45
|
+
|
|
46
|
+
## Tools
|
|
47
|
+
|
|
48
|
+
| Tool | Purpose |
|
|
49
|
+
|------|---------|
|
|
50
|
+
| `task_open(path)` | Open `<path>/TODO.md` (created if missing). Call once first. |
|
|
51
|
+
| `task_add(description, ...)` | Add a task. Returns the new ID. |
|
|
52
|
+
| `task_edit(task_id, ...)` | Change fields; only provided fields change. |
|
|
53
|
+
| `task_move(task_id, ...)` | Move a task **with its subtree**. No destination = delete task + subtree. |
|
|
54
|
+
| `task_get(task_id)` | Full details of one task. |
|
|
55
|
+
| `task_list(...)` | List with optional `parent_id` / `status` / `priority` filters. |
|
|
56
|
+
| `task_save()` | Force save (mutations are auto-saved anyway). |
|
|
57
|
+
| `task_close()` | Save and close. |
|
|
58
|
+
|
|
59
|
+
### Hierarchy parameters
|
|
60
|
+
|
|
61
|
+
- `parent_id` — add/move as **last child** of this task
|
|
62
|
+
- `before_id` / `after_id` — insert at the **same level**, before/after that
|
|
63
|
+
sibling (must share the target's parent)
|
|
64
|
+
- `task_move` with no `under_id`/`before_id`/`after_id` deletes the task and
|
|
65
|
+
its subtree
|
|
66
|
+
|
|
67
|
+
### Other fields
|
|
68
|
+
|
|
69
|
+
- `priority`: `lowest` `low` `normal` `medium` `high` `highest`
|
|
70
|
+
- dates (`scheduled`, `start`, `due`): `YYYY-MM-DD`
|
|
71
|
+
- `recurrence`: e.g. `weekly`, `every 2 weeks on Monday`
|
|
72
|
+
- `depends_on`: list of task IDs (circular dependencies rejected)
|
|
73
|
+
- `spec: true` — also create a `task-<id>.md` spec file
|
|
74
|
+
- setting status to `x` / `-` stamps `date_done` / `date_cancelled`
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npm install
|
|
80
|
+
node --test tests/parser.test.ts tests/task-manager.test.ts
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Layout: `index.ts` (tool registration) · `lib/`
|
|
84
|
+
(`task.ts` tree node, `parser.ts` line ⇄ tree, `task-manager.ts` mutations,
|
|
85
|
+
`tools.ts` TypeBox schemas) · `skills/task-manager/SKILL.md` (LLM usage guide).
|
package/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-task-manager — hierarchical task manager for pi.
|
|
3
|
+
*
|
|
4
|
+
* Registers 8 tools (task_open, task_add, task_edit, task_move, task_get,
|
|
5
|
+
* task_list, task_save, task_close) backed by a single TaskManager instance
|
|
6
|
+
* that manages one TODO.md file per session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { TaskManager } from "./lib/task-manager.ts";
|
|
13
|
+
import { TOOLS } from "./lib/tools.ts";
|
|
14
|
+
|
|
15
|
+
const manager = new TaskManager();
|
|
16
|
+
|
|
17
|
+
type Result = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
const handlers: Record<string, (a: any) => Result> = {
|
|
20
|
+
task_open: (a) => manager.openFile(a.path),
|
|
21
|
+
task_add: (a) =>
|
|
22
|
+
manager.addTask(
|
|
23
|
+
a.description,
|
|
24
|
+
a.parent_id,
|
|
25
|
+
a.before_id,
|
|
26
|
+
a.after_id,
|
|
27
|
+
a.priority,
|
|
28
|
+
a.scheduled,
|
|
29
|
+
a.start,
|
|
30
|
+
a.due,
|
|
31
|
+
a.recurrence,
|
|
32
|
+
a.on_completion,
|
|
33
|
+
a.depends_on,
|
|
34
|
+
a.spec,
|
|
35
|
+
),
|
|
36
|
+
task_edit: (a) =>
|
|
37
|
+
manager.editTask(
|
|
38
|
+
a.task_id,
|
|
39
|
+
a.description,
|
|
40
|
+
a.status,
|
|
41
|
+
a.priority,
|
|
42
|
+
a.scheduled,
|
|
43
|
+
a.start,
|
|
44
|
+
a.due,
|
|
45
|
+
a.recurrence,
|
|
46
|
+
a.on_completion,
|
|
47
|
+
a.depends_on,
|
|
48
|
+
),
|
|
49
|
+
task_move: (a) => manager.moveTask(a.task_id, a.under_id, a.before_id, a.after_id),
|
|
50
|
+
task_get: (a) => manager.getTask(a.task_id),
|
|
51
|
+
task_list: (a) =>
|
|
52
|
+
manager.listTasks(a.parent_id, a.status, a.priority, a.include_subtasks),
|
|
53
|
+
task_save: () => manager.save(),
|
|
54
|
+
task_close: () => manager.closeFile(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function format(result: Result): string {
|
|
58
|
+
if (result.status === "error") return `Error: ${result.error}`;
|
|
59
|
+
return JSON.stringify(result, null, 2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function (pi: ExtensionAPI) {
|
|
63
|
+
pi.on("session_start", (_event, ctx) => {
|
|
64
|
+
const todoPath = join(ctx.cwd, "TODO.md");
|
|
65
|
+
if (!existsSync(todoPath)) return;
|
|
66
|
+
const result = manager.openFile(ctx.cwd);
|
|
67
|
+
if (!ctx.hasUI) return;
|
|
68
|
+
if (result.status === "ok")
|
|
69
|
+
ctx.ui.notify(`Tasks: opened ${todoPath} (${result.task_count} tasks)`, "info");
|
|
70
|
+
else
|
|
71
|
+
ctx.ui.notify(`Tasks: failed to open ${todoPath}: ${result.error}`, "warning");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const tool of TOOLS) {
|
|
75
|
+
pi.registerTool({
|
|
76
|
+
name: tool.name,
|
|
77
|
+
label: tool.label,
|
|
78
|
+
description: tool.description,
|
|
79
|
+
parameters: tool.parameters,
|
|
80
|
+
execute: async (_toolCallId, params: any) => {
|
|
81
|
+
const result = handlers[tool.name](params ?? {});
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text" as const, text: format(result) }],
|
|
84
|
+
details: result,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
package/lib/parser.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO.md parser and serializer — ported from task_manager.py.
|
|
3
|
+
*
|
|
4
|
+
* Line format:
|
|
5
|
+
* - [x] Description (ID: `abc123`)
|
|
6
|
+
* - [ ] Setup ⏳ 2026-01-15 📅 2026-01-20 🔁 weekly (ID: `abc456`)
|
|
7
|
+
*
|
|
8
|
+
* 2 spaces of indent per hierarchy level. Annotations are inline emoji.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { newTask, type Task } from "./task.ts";
|
|
12
|
+
|
|
13
|
+
export const MAX_DEPTH = 8;
|
|
14
|
+
export const TODO_FILENAME = "TODO.md";
|
|
15
|
+
|
|
16
|
+
export const BASE62_CHARS =
|
|
17
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
18
|
+
|
|
19
|
+
/** Priority emoji mapping (normal = no emoji, stored as null) */
|
|
20
|
+
export const PRIORITY_EMOJI: Record<string, string> = {
|
|
21
|
+
lowest: "⏬",
|
|
22
|
+
low: "🔽",
|
|
23
|
+
medium: "🔼",
|
|
24
|
+
high: "⏫",
|
|
25
|
+
highest: "🔺",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const PRIORITY_FROM_EMOJI: Record<string, string> = Object.fromEntries(
|
|
29
|
+
Object.entries(PRIORITY_EMOJI).map(([k, v]) => [v, k]),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** Date emoji mapping */
|
|
33
|
+
export const DATE_EMOJI: Record<string, string> = {
|
|
34
|
+
scheduled: "⏳",
|
|
35
|
+
start: "🛫",
|
|
36
|
+
due: "📅",
|
|
37
|
+
done: "✅",
|
|
38
|
+
cancelled: "❌",
|
|
39
|
+
created: "➕",
|
|
40
|
+
modified: "🖊️",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type DateKey =
|
|
44
|
+
| "scheduled"
|
|
45
|
+
| "start"
|
|
46
|
+
| "due"
|
|
47
|
+
| "done"
|
|
48
|
+
| "cancelled"
|
|
49
|
+
| "created"
|
|
50
|
+
| "modified";
|
|
51
|
+
|
|
52
|
+
export const DATE_FROM_EMOJI: Record<string, DateKey> = Object.fromEntries(
|
|
53
|
+
Object.entries(DATE_EMOJI).map(([k, v]) => [v, k] as [string, DateKey]),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** Status characters: ' '=open, 'x'=done, '>'=in-progress, '!'=failed, '-'=cancelled */
|
|
57
|
+
export const STATUS_CHARS = [" ", "x", ">", "!", "-"];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* All emojis the parser treats as annotations. Descriptions must not
|
|
61
|
+
* contain them, or they get stripped/misparsed on reload.
|
|
62
|
+
*/
|
|
63
|
+
export const ANNOTATION_EMOJIS: string[] = [
|
|
64
|
+
...Object.values(PRIORITY_EMOJI),
|
|
65
|
+
...Object.values(DATE_EMOJI),
|
|
66
|
+
"🔁",
|
|
67
|
+
"🗑️",
|
|
68
|
+
"🏁",
|
|
69
|
+
"⛔",
|
|
70
|
+
"📎",
|
|
71
|
+
"🆔",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Returns the first annotation emoji found in text, or null. */
|
|
75
|
+
export function findAnnotationEmoji(text: string): string | null {
|
|
76
|
+
for (const e of ANNOTATION_EMOJIS) if (text.includes(e)) return e;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const TASK_LINE_RE = /^( *)-\s+\[([ x>!-])\]\s+(.*?)\s*$/;
|
|
81
|
+
const ID_RE = /\(ID:\s*`([A-Za-z0-9]{6})`\)\s*$/;
|
|
82
|
+
const SPEC_RE = /📎\s*\[spec\]\(task-[A-Za-z0-9]+\.md\)\s*/;
|
|
83
|
+
const DEPS_RE = /⛔\s*([A-Za-z0-9]+(?:,[A-Za-z0-9]+)*)\s*/;
|
|
84
|
+
const RECUR_BEFORE_RE =
|
|
85
|
+
/🔁\s+(.+?)(?=\s+(?:⏬|🔽|🔼|⏫|🔺|⏳|🛫|📅|✅|❌|➕|🖊️|🗑️|🏁|⛔|📎|🆔))/;
|
|
86
|
+
const RECUR_END_RE = /🔁\s+(.+?)(?=\s+$)/;
|
|
87
|
+
const ID_REF_RE = /🆔\s*([A-Za-z0-9]+)\s*/;
|
|
88
|
+
|
|
89
|
+
function escapeRegExp(s: string): string {
|
|
90
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Annotations = {
|
|
94
|
+
priority?: string;
|
|
95
|
+
scheduled?: string;
|
|
96
|
+
start?: string;
|
|
97
|
+
due?: string;
|
|
98
|
+
done?: string;
|
|
99
|
+
cancelled?: string;
|
|
100
|
+
created?: string;
|
|
101
|
+
modified?: string;
|
|
102
|
+
recurrence?: string;
|
|
103
|
+
on_completion?: string;
|
|
104
|
+
depends_on?: string[];
|
|
105
|
+
spec?: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Parse emoji annotations from the end of a task line. */
|
|
109
|
+
export function parseAnnotations(text: string): [Annotations, string] {
|
|
110
|
+
const annotations: Annotations = {};
|
|
111
|
+
let remaining = text;
|
|
112
|
+
|
|
113
|
+
// 📎 spec link
|
|
114
|
+
const specMatch = remaining.match(SPEC_RE);
|
|
115
|
+
if (specMatch) {
|
|
116
|
+
annotations.spec = true;
|
|
117
|
+
remaining =
|
|
118
|
+
remaining.slice(0, specMatch.index) +
|
|
119
|
+
remaining.slice((specMatch.index ?? 0) + specMatch[0].length);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ⛔ depends_on
|
|
123
|
+
const depMatch = remaining.match(DEPS_RE);
|
|
124
|
+
if (depMatch) {
|
|
125
|
+
annotations.depends_on = depMatch[1].split(",");
|
|
126
|
+
remaining =
|
|
127
|
+
remaining.slice(0, depMatch.index) +
|
|
128
|
+
remaining.slice((depMatch.index ?? 0) + depMatch[0].length);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 🗑️ / 🏁 on_completion
|
|
132
|
+
if (remaining.includes("🗑️")) {
|
|
133
|
+
annotations.on_completion = "delete";
|
|
134
|
+
remaining = remaining.replace("🗑️", "").trim();
|
|
135
|
+
}
|
|
136
|
+
if (remaining.includes("🏁")) {
|
|
137
|
+
annotations.on_completion = "keep";
|
|
138
|
+
remaining = remaining.replace("🏁", "").trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 🔁 recurrence (rule extends to the next emoji or end of text)
|
|
142
|
+
let recurMatch = remaining.match(RECUR_BEFORE_RE);
|
|
143
|
+
if (!recurMatch) recurMatch = remaining.match(RECUR_END_RE);
|
|
144
|
+
if (recurMatch) {
|
|
145
|
+
annotations.recurrence = recurMatch[1].trim();
|
|
146
|
+
remaining =
|
|
147
|
+
remaining.slice(0, recurMatch.index) +
|
|
148
|
+
remaining.slice((recurMatch.index ?? 0) + recurMatch[0].length);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Date emojis followed by YYYY-MM-DD (in DATE_EMOJI order)
|
|
152
|
+
for (const [emoji, key] of Object.entries(DATE_FROM_EMOJI)) {
|
|
153
|
+
const re = new RegExp(`${escapeRegExp(emoji)}\\s*(\\d{4}-\\d{2}-\\d{2})\\s*`);
|
|
154
|
+
const m = remaining.match(re);
|
|
155
|
+
if (m) {
|
|
156
|
+
annotations[key] = m[1];
|
|
157
|
+
remaining =
|
|
158
|
+
remaining.slice(0, m.index) +
|
|
159
|
+
remaining.slice((m.index ?? 0) + m[0].length);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Priority emojis
|
|
164
|
+
for (const [emoji, priority] of Object.entries(PRIORITY_FROM_EMOJI)) {
|
|
165
|
+
if (remaining.includes(emoji)) {
|
|
166
|
+
annotations.priority = priority;
|
|
167
|
+
remaining = remaining.replace(emoji, "").trim();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 🆔 self-reference (informational only)
|
|
172
|
+
const idRef = remaining.match(ID_REF_RE);
|
|
173
|
+
if (idRef) {
|
|
174
|
+
remaining =
|
|
175
|
+
remaining.slice(0, idRef.index) +
|
|
176
|
+
remaining.slice((idRef.index ?? 0) + idRef[0].length);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return [annotations, remaining.trim()];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse a single task line. Returns null if the line is not a valid task. */
|
|
183
|
+
export function parseTaskLine(line: string): Task | null {
|
|
184
|
+
const m = line.match(TASK_LINE_RE);
|
|
185
|
+
if (!m) return null;
|
|
186
|
+
|
|
187
|
+
const status = m[2];
|
|
188
|
+
let rest = m[3];
|
|
189
|
+
|
|
190
|
+
const idMatch = rest.match(ID_RE);
|
|
191
|
+
if (!idMatch) return null;
|
|
192
|
+
const id = idMatch[1];
|
|
193
|
+
rest = rest.slice(0, idMatch.index).trim();
|
|
194
|
+
|
|
195
|
+
const [annotations, description] = parseAnnotations(rest);
|
|
196
|
+
|
|
197
|
+
const task = newTask(id, description);
|
|
198
|
+
task.status = status;
|
|
199
|
+
if (annotations.priority) task.priority = annotations.priority;
|
|
200
|
+
if (annotations.scheduled) task.dateScheduled = annotations.scheduled;
|
|
201
|
+
if (annotations.start) task.dateStart = annotations.start;
|
|
202
|
+
if (annotations.due) task.dateDue = annotations.due;
|
|
203
|
+
if (annotations.done) task.dateDone = annotations.done;
|
|
204
|
+
if (annotations.cancelled) task.dateCancelled = annotations.cancelled;
|
|
205
|
+
if (annotations.created) task.dateCreated = annotations.created;
|
|
206
|
+
if (annotations.modified) task.dateModified = annotations.modified;
|
|
207
|
+
if (annotations.recurrence) task.recurrence = annotations.recurrence;
|
|
208
|
+
if (annotations.on_completion) task.onCompletion = annotations.on_completion;
|
|
209
|
+
if (annotations.depends_on) task.dependsOn = annotations.depends_on;
|
|
210
|
+
if (annotations.spec) task.hasSpec = true;
|
|
211
|
+
|
|
212
|
+
return task;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse entire TODO.md content into a tree of tasks (returns the roots).
|
|
217
|
+
* Hierarchy comes from indentation: each task is appended as a child of
|
|
218
|
+
* the most recent task at one shallower depth.
|
|
219
|
+
*/
|
|
220
|
+
export function parseTodoFile(content: string): Task[] {
|
|
221
|
+
const roots: Task[] = [];
|
|
222
|
+
const stack: (Task | null)[] = []; // stack[d] = most recent task at depth d
|
|
223
|
+
|
|
224
|
+
for (const line of content.split("\n")) {
|
|
225
|
+
const stripped = line.trim();
|
|
226
|
+
if (!stripped || stripped.startsWith("#") || !stripped.startsWith("-"))
|
|
227
|
+
continue;
|
|
228
|
+
|
|
229
|
+
const match = line.match(/^( *)-\s+\[/);
|
|
230
|
+
if (!match) continue;
|
|
231
|
+
|
|
232
|
+
const depth = Math.floor(match[1].length / 2);
|
|
233
|
+
if (depth > MAX_DEPTH) continue;
|
|
234
|
+
|
|
235
|
+
const task = parseTaskLine(line);
|
|
236
|
+
if (!task) continue;
|
|
237
|
+
|
|
238
|
+
const parent = depth === 0 ? null : stack[depth - 1] ?? null;
|
|
239
|
+
task.parent = parent;
|
|
240
|
+
(parent ? parent.children : roots).push(task);
|
|
241
|
+
stack[depth] = task;
|
|
242
|
+
stack.length = depth + 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return roots;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A structural problem found while scanning TODO.md content. */
|
|
249
|
+
export type TodoIssue =
|
|
250
|
+
| { kind: "orphan"; line: number; id: string; depth: number }
|
|
251
|
+
| { kind: "tab"; line: number; id: string }
|
|
252
|
+
| { kind: "duplicate"; line: number; id: string; firstLine: number };
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Task line with tab (or mixed) indentation. The parser's indent regex only
|
|
256
|
+
* matches spaces, so such a line is silently dropped; we detect it here.
|
|
257
|
+
*/
|
|
258
|
+
const TAB_TASK_RE =
|
|
259
|
+
/^([\t ]+)-\s+\[([ x>!-])\]\s+.*\(ID:\s*`([A-Za-z0-9]{6})`\)\s*$/;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Scan TODO.md content for structural problems the parser would silently
|
|
263
|
+
* mishandle: orphaned indented lines (no ancestor at the expected level),
|
|
264
|
+
* tab indentation (line dropped), and duplicate task IDs. Returns 1-based
|
|
265
|
+
* line numbers and task IDs for each problem.
|
|
266
|
+
*/
|
|
267
|
+
export function findTodoIssues(content: string): TodoIssue[] {
|
|
268
|
+
const issues: TodoIssue[] = [];
|
|
269
|
+
const stack: (Task | null)[] = [];
|
|
270
|
+
const idLines = new Map<string, number>();
|
|
271
|
+
|
|
272
|
+
content.split("\n").forEach((line, i) => {
|
|
273
|
+
const n = i + 1;
|
|
274
|
+
const stripped = line.trim();
|
|
275
|
+
if (!stripped || stripped.startsWith("#") || !stripped.startsWith("-"))
|
|
276
|
+
return;
|
|
277
|
+
|
|
278
|
+
const tabMatch = line.match(TAB_TASK_RE);
|
|
279
|
+
if (tabMatch && tabMatch[1].includes("\t")) {
|
|
280
|
+
issues.push({ kind: "tab", line: n, id: tabMatch[3] });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const match = line.match(/^( *)-\s+\[/);
|
|
285
|
+
if (!match) return;
|
|
286
|
+
const depth = Math.floor(match[1].length / 2);
|
|
287
|
+
if (depth > MAX_DEPTH) return;
|
|
288
|
+
const task = parseTaskLine(line);
|
|
289
|
+
if (!task) return;
|
|
290
|
+
|
|
291
|
+
if (depth > 0 && !stack[depth - 1])
|
|
292
|
+
issues.push({ kind: "orphan", line: n, id: task.id, depth });
|
|
293
|
+
|
|
294
|
+
const first = idLines.get(task.id);
|
|
295
|
+
if (first !== undefined)
|
|
296
|
+
issues.push({ kind: "duplicate", line: n, id: task.id, firstLine: first });
|
|
297
|
+
else idLines.set(task.id, n);
|
|
298
|
+
|
|
299
|
+
stack[depth] = task;
|
|
300
|
+
stack.length = depth + 1;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
return issues;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Serialize a task back to a markdown line (conventional annotation order). */
|
|
307
|
+
export function buildTaskLine(task: Task, depth: number = 0): string {
|
|
308
|
+
const indent = " ".repeat(depth);
|
|
309
|
+
const annotations: string[] = [];
|
|
310
|
+
|
|
311
|
+
if (task.priority && task.priority in PRIORITY_EMOJI)
|
|
312
|
+
annotations.push(PRIORITY_EMOJI[task.priority]);
|
|
313
|
+
if (task.dateScheduled) annotations.push(`⏳ ${task.dateScheduled}`);
|
|
314
|
+
if (task.dateStart) annotations.push(`🛫 ${task.dateStart}`);
|
|
315
|
+
if (task.dateDue) annotations.push(`📅 ${task.dateDue}`);
|
|
316
|
+
if (task.dateDone) annotations.push(`✅ ${task.dateDone}`);
|
|
317
|
+
if (task.dateCancelled) annotations.push(`❌ ${task.dateCancelled}`);
|
|
318
|
+
if (task.recurrence) annotations.push(`🔁 ${task.recurrence}`);
|
|
319
|
+
if (task.onCompletion === "delete") annotations.push("🗑️");
|
|
320
|
+
else if (task.onCompletion === "keep") annotations.push("🏁");
|
|
321
|
+
if (task.dependsOn.length > 0)
|
|
322
|
+
annotations.push(`⛔ ${task.dependsOn.join(",")}`);
|
|
323
|
+
if (task.hasSpec) annotations.push(`📎 [spec](task-${task.id}.md)`);
|
|
324
|
+
if (task.dateCreated) annotations.push(`➕ ${task.dateCreated}`);
|
|
325
|
+
if (task.dateModified) annotations.push(`🖊️ ${task.dateModified}`);
|
|
326
|
+
|
|
327
|
+
const annotationStr = annotations.join(" ");
|
|
328
|
+
if (annotationStr)
|
|
329
|
+
return `${indent}- [${task.status}] ${task.description} ${annotationStr} (ID: \`${task.id}\`)`;
|
|
330
|
+
return `${indent}- [${task.status}] ${task.description} (ID: \`${task.id}\`)`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Convert a task tree to TODO.md markdown content (recursive DFS). */
|
|
334
|
+
export function tasksToMarkdown(roots: Task[]): string {
|
|
335
|
+
const lines: string[] = ["# TODO", ""];
|
|
336
|
+
const walk = (tasks: Task[], depth: number): void => {
|
|
337
|
+
for (const task of tasks) {
|
|
338
|
+
lines.push(buildTaskLine(task, depth));
|
|
339
|
+
walk(task.children, depth + 1);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
walk(roots, 0);
|
|
343
|
+
lines.push("");
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|