pi-task-manager 0.1.0 → 0.1.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/package.json CHANGED
@@ -1,28 +1,53 @@
1
1
  {
2
- "name": "pi-task-manager",
3
- "version": "0.1.0",
4
- "description": "Task/todo management extension for pi",
5
- "keywords": ["pi-package"],
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/gilgil/pi-task-manager.git"
9
- },
10
- "type": "module",
11
- "pi": {
12
- "extensions": ["./index.ts"],
13
- "skills": ["./skills"]
14
- },
15
- "scripts": {
16
- "test": "node --test tests/*.test.ts",
17
- "typecheck": "tsc --noEmit"
18
- },
19
- "peerDependencies": {
20
- "@earendil-works/pi-ai": "*",
21
- "@earendil-works/pi-coding-agent": "*",
22
- "typebox": "*"
23
- },
24
- "devDependencies": {
25
- "@types/node": "^22",
26
- "typescript": "^5"
27
- }
2
+ "name": "pi-task-manager",
3
+ "version": "0.1.1",
4
+ "description": "Hierarchical task/todo management extension for Pi. Tasks live in a TODO.md file as an indented tree.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "extension",
9
+ "task",
10
+ "todo",
11
+ "task-manager",
12
+ "hierarchical"
13
+ ],
14
+ "author": "Gil <gil.assayag@gmail.com>",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/gilgil/pi-task-manager.git"
19
+ },
20
+ "homepage": "https://github.com/gilgil/pi-task-manager#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/gilgil/pi-task-manager/issues"
23
+ },
24
+ "type": "module",
25
+ "files": [
26
+ "index.ts",
27
+ "lib",
28
+ "skills",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ],
36
+ "skills": [
37
+ "./skills"
38
+ ]
39
+ },
40
+ "scripts": {
41
+ "test": "node --test tests/*.test.ts",
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "typebox": "*"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22",
51
+ "typescript": "^5"
52
+ }
28
53
  }
package/PLAN.md DELETED
@@ -1,106 +0,0 @@
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.
@@ -1,120 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import {
4
- parseTaskLine,
5
- parseTodoFile,
6
- buildTaskLine,
7
- tasksToMarkdown,
8
- } from "../lib/parser.ts";
9
- import { depthOf, type Task } from "../lib/task.ts";
10
-
11
- /** Flatten a tree to DFS order (for assertions). */
12
- function flatten(roots: Task[]): Task[] {
13
- const out: Task[] = [];
14
- const walk = (tasks: Task[]): void => {
15
- for (const t of tasks) {
16
- out.push(t);
17
- walk(t.children);
18
- }
19
- };
20
- walk(roots);
21
- return out;
22
- }
23
-
24
- test("parse simple task line", () => {
25
- const t = parseTaskLine("- [ ] Buy milk (ID: `abc123`)")!;
26
- assert.equal(t.id, "abc123");
27
- assert.equal(t.description, "Buy milk");
28
- assert.equal(t.status, " ");
29
- assert.equal(t.parent, null);
30
- assert.deepEqual(t.children, []);
31
- });
32
-
33
- test("parse indented task line", () => {
34
- const t = parseTaskLine(" - [x] Done thing (ID: `def456`)")!;
35
- assert.equal(t.id, "def456");
36
- assert.equal(t.status, "x");
37
- });
38
-
39
- test("parse all annotations", () => {
40
- const line =
41
- "- [>] Roof 🔺 ⏳ 2026-01-01 🛫 2026-01-02 📅 2026-01-03 ✅ 2026-01-04 ❌ 2026-01-05 🔁 weekly 🗑️ ⛔ abc123,def456 📎 [spec](task-ghi789.md) ➕ 2026-01-01 🖊️ 2026-01-02 (ID: `ghi789`)";
42
- const t = parseTaskLine(line)!;
43
- assert.equal(t.description, "Roof");
44
- assert.equal(t.priority, "highest");
45
- assert.equal(t.dateScheduled, "2026-01-01");
46
- assert.equal(t.dateStart, "2026-01-02");
47
- assert.equal(t.dateDue, "2026-01-03");
48
- assert.equal(t.dateDone, "2026-01-04");
49
- assert.equal(t.dateCancelled, "2026-01-05");
50
- assert.equal(t.recurrence, "weekly");
51
- assert.equal(t.onCompletion, "delete");
52
- assert.deepEqual(t.dependsOn, ["abc123", "def456"]);
53
- assert.equal(t.hasSpec, true);
54
- assert.equal(t.dateCreated, "2026-01-01");
55
- assert.equal(t.dateModified, "2026-01-02");
56
- });
57
-
58
- test("recurrence rule extends to next emoji", () => {
59
- const t = parseTaskLine("- [ ] Meet 🔁 every 2 weeks on Monday 📅 2026-02-01 (ID: `abc123`)")!;
60
- assert.equal(t.recurrence, "every 2 weeks on Monday");
61
- assert.equal(t.dateDue, "2026-02-01");
62
- });
63
-
64
- test("invalid lines return null", () => {
65
- assert.equal(parseTaskLine("- [ ] No id here"), null);
66
- assert.equal(parseTaskLine("- [q] Bad status (ID: `abc123`)"), null);
67
- assert.equal(parseTaskLine("# comment"), null);
68
- assert.equal(parseTaskLine(""), null);
69
- });
70
-
71
- test("hierarchy: depth, parent, children", () => {
72
- const content = [
73
- "# TODO",
74
- "",
75
- "- [ ] A (ID: `aaaaaa`)",
76
- " - [ ] B (ID: `bbbbbb`)",
77
- " - [ ] C (ID: `cccccc`)",
78
- " - [ ] D (ID: `dddddd`)",
79
- "- [ ] E (ID: `eeeeee`)",
80
- "",
81
- ].join("\n");
82
- const roots = parseTodoFile(content);
83
- assert.deepEqual(roots.map((t) => t.id), ["aaaaaa", "eeeeee"]);
84
- const byId = Object.fromEntries(flatten(roots).map((t) => [t.id, t]));
85
- assert.equal(byId["bbbbbb"].parent?.id, "aaaaaa");
86
- assert.equal(depthOf(byId["bbbbbb"]), 1);
87
- assert.equal(byId["cccccc"].parent?.id, "bbbbbb");
88
- assert.equal(depthOf(byId["cccccc"]), 2);
89
- assert.equal(byId["dddddd"].parent?.id, "aaaaaa");
90
- assert.equal(byId["aaaaaa"].children.indexOf(byId["dddddd"]), 1);
91
- assert.deepEqual(byId["aaaaaa"].children.map((c) => c.id), [
92
- "bbbbbb",
93
- "dddddd",
94
- ]);
95
- assert.deepEqual(byId["bbbbbb"].children.map((c) => c.id), ["cccccc"]);
96
- });
97
-
98
- test("round-trip: parse -> build -> parse is stable", () => {
99
- const content = [
100
- "# TODO",
101
- "",
102
- "- [ ] A ⏫ ⏳ 2026-09-01 📅 2026-12-01 ➕ 2026-08-14 🖊️ 2026-08-14 (ID: `aaaaaa`)",
103
- " - [>] B 🔺 🔁 monthly 🗑️ ⛔ aaaaaa ➕ 2026-08-14 🖊️ 2026-08-14 (ID: `bbbbbb`)",
104
- "",
105
- ].join("\n");
106
- const once = parseTodoFile(content);
107
- const rebuilt = tasksToMarkdown(once);
108
- assert.equal(rebuilt, content);
109
- const twice = parseTodoFile(rebuilt);
110
- assert.deepEqual(
111
- flatten(twice).map((t) => t.id),
112
- flatten(once).map((t) => t.id),
113
- );
114
- assert.equal(tasksToMarkdown(twice), content);
115
- });
116
-
117
- test("buildTaskLine: no annotations", () => {
118
- const t = parseTaskLine("- [ ] Plain (ID: `aaaaaa`)")!;
119
- assert.equal(buildTaskLine(t), "- [ ] Plain (ID: `aaaaaa`)");
120
- });
@@ -1,154 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { TaskManager } from "../lib/task-manager.ts";
7
-
8
- function setup() {
9
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-rob-"));
10
- const tm = new TaskManager();
11
- tm.openFile(dir);
12
- return { tm, dir };
13
- }
14
-
15
- const id = (r: Record<string, unknown>) => r.task_id as string;
16
-
17
- // ── annotation emoji in descriptions (CdqV8G) ─────────────────────────
18
-
19
- test("addTask: rejects description containing priority emoji", () => {
20
- const { tm } = setup();
21
- const r = tm.addTask("Fix 🔺 icon");
22
- assert.equal(r.status, "error");
23
- assert.match(r.error as string, /emoji/i);
24
- });
25
-
26
- test("addTask: rejects description containing date emoji", () => {
27
- const { tm } = setup();
28
- const r = tm.addTask("done ✅ today");
29
- assert.equal(r.status, "error");
30
- assert.match(r.error as string, /emoji/i);
31
- });
32
-
33
- test("addTask: rejects description containing recurrence emoji", () => {
34
- const { tm } = setup();
35
- const r = tm.addTask("repeats 🔁 forever");
36
- assert.equal(r.status, "error");
37
- assert.match(r.error as string, /emoji/i);
38
- });
39
-
40
- test("editTask: rejects description containing annotation emoji", () => {
41
- const { tm } = setup();
42
- const a = id(tm.addTask("A"));
43
- const r = tm.editTask(a, "Fix 🔺 icon");
44
- assert.equal(r.status, "error");
45
- assert.match(r.error as string, /emoji/i);
46
- });
47
-
48
- test("addTask: accepts description with non-annotation emoji (round-trips)", () => {
49
- const { tm, dir } = setup();
50
- const a = id(tm.addTask("Fix the 🐛 bug"));
51
- tm.closeFile();
52
- const tm2 = new TaskManager();
53
- tm2.openFile(dir);
54
- const t = tm2.getTask(a) as any;
55
- assert.equal(t.task.description, "Fix the 🐛 bug");
56
- });
57
-
58
- // ── status revert (hjKhGg) ───────────────────────────────────────────────────
59
-
60
- test("editTask: reverting status from x clears date_done", () => {
61
- const { tm } = setup();
62
- const a = id(tm.addTask("A"));
63
- tm.editTask(a, undefined, "x");
64
- assert.ok((tm.getTask(a) as any).task.date_done, "date_done stamped on x");
65
- tm.editTask(a, undefined, " ");
66
- assert.equal((tm.getTask(a) as any).task.date_done, null);
67
- });
68
-
69
- test("editTask: reverting status from - clears date_cancelled", () => {
70
- const { tm } = setup();
71
- const a = id(tm.addTask("A"));
72
- tm.editTask(a, undefined, "-");
73
- assert.ok((tm.getTask(a) as any).task.date_cancelled, "date_cancelled stamped on -");
74
- tm.editTask(a, undefined, " ");
75
- assert.equal((tm.getTask(a) as any).task.date_cancelled, null);
76
- });
77
-
78
- test("editTask: x to - clears date_done and stamps date_cancelled", () => {
79
- const { tm } = setup();
80
- const a = id(tm.addTask("A"));
81
- tm.editTask(a, undefined, "x");
82
- tm.editTask(a, undefined, "-");
83
- const t = (tm.getTask(a) as any).task;
84
- assert.equal(t.date_done, null);
85
- assert.ok(t.date_cancelled);
86
- });
87
-
88
- // ── openFile error handling (3Dqwgr) ──────────────────────────────────
89
-
90
- test("openFile: bad path returns error Result instead of throwing", () => {
91
- const tm = new TaskManager();
92
- const r = tm.openFile("/nonexistent/definitely/missing");
93
- assert.equal(r.status, "error");
94
- assert.match(r.error as string, /open/i);
95
- assert.equal(tm.isOpen, false);
96
- });
97
-
98
- test("openFile: path is a file, not a directory → error Result", () => {
99
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-rob-"));
100
- const file = path.join(dir, "notadir");
101
- fs.writeFileSync(file, "hi");
102
- const tm = new TaskManager();
103
- const r = tm.openFile(file);
104
- assert.equal(r.status, "error");
105
- assert.equal(tm.isOpen, false);
106
- });
107
-
108
- // ── save failure surfacing (gtoVfN) ───────────────────────────────────
109
-
110
- test("save: returns error when directory is read-only", () => {
111
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-rob-"));
112
- const tm = new TaskManager();
113
- tm.openFile(dir);
114
- const a = id(tm.addTask("A"));
115
- fs.chmodSync(dir, 0o555);
116
- try {
117
- const r = tm.save();
118
- assert.equal(r.status, "error");
119
- assert.match(r.error as string, /save/i);
120
- } finally {
121
- fs.chmodSync(dir, 0o755);
122
- }
123
- });
124
-
125
- test("addTask: reports warning when auto-save fails", () => {
126
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-rob-"));
127
- const tm = new TaskManager();
128
- tm.openFile(dir);
129
- id(tm.addTask("A"));
130
- fs.chmodSync(dir, 0o555);
131
- try {
132
- const r = tm.addTask("B");
133
- assert.equal(r.status, "ok");
134
- assert.match(r.warning as string, /save failed/i);
135
- } finally {
136
- fs.chmodSync(dir, 0o755);
137
- }
138
- });
139
-
140
- test("closeFile: save failure returns error and keeps file open", () => {
141
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-rob-"));
142
- const tm = new TaskManager();
143
- tm.openFile(dir);
144
- fs.chmodSync(dir, 0o555);
145
- try {
146
- tm.addTask("A"); // auto-save fails, stays dirty
147
- const r = tm.closeFile();
148
- assert.equal(r.status, "error");
149
- assert.match(r.error as string, /save/i);
150
- assert.equal(tm.isOpen, true);
151
- } finally {
152
- fs.chmodSync(dir, 0o755);
153
- }
154
- });
@@ -1,158 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { TaskManager } from "../lib/task-manager.ts";
7
-
8
- function setup() {
9
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-test-"));
10
- const tm = new TaskManager();
11
- tm.openFile(dir);
12
- return { tm, dir };
13
- }
14
-
15
- const id = (r: Record<string, unknown>) => r.task_id as string;
16
-
17
- test("open creates TODO.md", () => {
18
- const { tm, dir } = setup();
19
- assert.ok(fs.existsSync(path.join(dir, "TODO.md")));
20
- assert.equal(tm.isOpen, true);
21
- tm.closeFile();
22
- });
23
-
24
- test("add: hierarchy and positions", () => {
25
- const { tm } = setup();
26
- const a = id(tm.addTask("A"));
27
- const b = id(tm.addTask("B", a));
28
- const c = id(tm.addTask("C", a, b));
29
- const d = id(tm.addTask("D", a));
30
- const e = id(tm.addTask("E"));
31
- const list = tm.listTasks() as any;
32
- assert.deepEqual(
33
- list.tasks.map((t: any) => t.id),
34
- [a, c, b, d, e],
35
- );
36
- const byId = Object.fromEntries(list.tasks.map((t: any) => [t.id, t]));
37
- assert.equal(byId[b].parent_id, a);
38
- assert.equal(byId[b].depth, 1);
39
- assert.equal(byId[c].position, 0);
40
- assert.equal(byId[b].position, 1);
41
- });
42
-
43
- test("add: invalid parent", () => {
44
- const { tm } = setup();
45
- const r = tm.addTask("X", "nope12");
46
- assert.equal(r.status, "error");
47
- });
48
-
49
- test("edit: no fields is an error", () => {
50
- const { tm } = setup();
51
- const a = id(tm.addTask("A"));
52
- const r = tm.editTask(a);
53
- assert.equal(r.status, "error");
54
- assert.equal(r.error, "No fields to edit.");
55
- });
56
-
57
- test("edit: invalid status and priority", () => {
58
- const { tm } = setup();
59
- const a = id(tm.addTask("A"));
60
- assert.equal((tm.editTask(a, undefined, "z") as any).status, "error");
61
- assert.equal((tm.editTask(a, undefined, undefined, "urgent") as any).status, "error");
62
- const ok = tm.editTask(a, "A2", "x", "null") as any;
63
- assert.equal(ok.status, "ok");
64
- assert.equal(ok.task.description, "A2");
65
- assert.equal(ok.task.status, "x");
66
- assert.equal(ok.task.priority, null);
67
- assert.ok(ok.task.date_done);
68
- });
69
-
70
- test("edit: circular dependency rejected", () => {
71
- const { tm } = setup();
72
- const a = id(tm.addTask("A"));
73
- const b = id(tm.addTask("B"));
74
- tm.editTask(b, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [a]);
75
- const r = tm.editTask(a, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [b]);
76
- assert.equal(r.status, "error");
77
- assert.match(r.error as string, /Circular/);
78
- });
79
-
80
- test("move: under own descendant rejected", () => {
81
- const { tm } = setup();
82
- const a = id(tm.addTask("A"));
83
- const b = id(tm.addTask("B", a));
84
- const r = tm.moveTask(a, b);
85
- assert.equal(r.status, "error");
86
- });
87
-
88
- test("move: before later sibling keeps order", () => {
89
- const { tm } = setup();
90
- const a = id(tm.addTask("A"));
91
- const b = id(tm.addTask("B", a));
92
- const c = id(tm.addTask("C", a));
93
- // b already sits before c; re-inserting "before c" must not swap them
94
- // (the index must be computed after detaching b).
95
- const r = tm.moveTask(b, a, c);
96
- assert.equal(r.status, "ok");
97
- const ids = (tm.listTasks().tasks as any[]).map((t) => t.id);
98
- assert.deepEqual(ids, [a, b, c]);
99
- });
100
-
101
- test("move: with subtree, positions recomputed", () => {
102
- const { tm } = setup();
103
- const a = id(tm.addTask("A"));
104
- const b = id(tm.addTask("B"));
105
- const c = id(tm.addTask("C", b));
106
- const d = id(tm.addTask("D"));
107
- tm.moveTask(b, a);
108
- const list = tm.listTasks() as any;
109
- assert.deepEqual(
110
- list.tasks.map((t: any) => t.id),
111
- [a, b, c, d],
112
- );
113
- const byId = Object.fromEntries(list.tasks.map((t: any) => [t.id, t]));
114
- assert.equal(byId[b].parent_id, a);
115
- assert.equal(byId[c].parent_id, b);
116
- assert.equal(byId[c].depth, 2);
117
- });
118
-
119
- test("move: no destination deletes task and subtree", () => {
120
- const { tm } = setup();
121
- const a = id(tm.addTask("A"));
122
- const b = id(tm.addTask("B", a));
123
- const c = id(tm.addTask("C", b));
124
- const r = tm.moveTask(a);
125
- assert.equal(r.status, "ok");
126
- assert.match(r.message as string, /2 sub-task/);
127
- const list = tm.listTasks() as any;
128
- assert.equal(list.count, 0);
129
- });
130
-
131
- test("list: filters", () => {
132
- const { tm } = setup();
133
- const a = id(tm.addTask("A", undefined, undefined, undefined, "high"));
134
- tm.addTask("B");
135
- tm.editTask(a, undefined, "x");
136
- const byStatus = tm.listTasks(undefined, "x") as any;
137
- assert.equal(byStatus.count, 1);
138
- assert.equal(byStatus.tasks[0].id, a);
139
- const byPriority = tm.listTasks(undefined, undefined, "high") as any;
140
- assert.equal(byPriority.count, 1);
141
- const subtree = tm.listTasks(a, undefined, undefined, true) as any;
142
- assert.equal(subtree.count, 1);
143
- assert.equal(subtree.tasks[0].id, a);
144
- });
145
-
146
- test("save and close", () => {
147
- const { tm, dir } = setup();
148
- tm.addTask("A");
149
- const s = tm.save() as any;
150
- assert.equal(s.status, "ok");
151
- assert.equal(s.task_count, 1);
152
- const c = tm.closeFile() as any;
153
- assert.equal(c.status, "ok");
154
- assert.equal(tm.isOpen, false);
155
- const r = tm.save();
156
- assert.equal(r.status, "error");
157
- assert.ok(fs.existsSync(path.join(dir, "TODO.md.bak")));
158
- });
@@ -1,194 +0,0 @@
1
- import { test } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { TaskManager } from "../lib/task-manager.ts";
7
-
8
- function setup() {
9
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
10
- const tm = new TaskManager();
11
- tm.openFile(dir);
12
- return { tm, dir };
13
- }
14
-
15
- const id = (r: Record<string, unknown>) => r.task_id as string;
16
-
17
- // ── addTask validation ────────────────────────────────────────────────
18
-
19
- test("addTask: rejects multiline description", () => {
20
- const { tm } = setup();
21
- const r = tm.addTask("line1\nline2");
22
- assert.equal(r.status, "error");
23
- assert.match(r.error as string, /newline/i);
24
- });
25
-
26
- test("addTask: rejects invalid scheduled date", () => {
27
- const { tm } = setup();
28
- const r = tm.addTask("A", undefined, undefined, undefined, undefined, "tomorrow");
29
- assert.equal(r.status, "error");
30
- assert.match(r.error as string, /scheduled/i);
31
- });
32
-
33
- test("addTask: rejects invalid start date", () => {
34
- const { tm } = setup();
35
- const r = tm.addTask("A", undefined, undefined, undefined, undefined, undefined, "2026-1-1");
36
- assert.equal(r.status, "error");
37
- assert.match(r.error as string, /start/i);
38
- });
39
-
40
- test("addTask: rejects invalid due date", () => {
41
- const { tm } = setup();
42
- const r = tm.addTask("A", undefined, undefined, undefined, undefined, undefined, undefined, "15/08/2026");
43
- assert.equal(r.status, "error");
44
- assert.match(r.error as string, /due/i);
45
- });
46
-
47
- test("addTask: rejects invalid priority", () => {
48
- const { tm } = setup();
49
- const r = tm.addTask("A", undefined, undefined, undefined, "urgent");
50
- assert.equal(r.status, "error");
51
- assert.match(r.error as string, /priority/i);
52
- });
53
-
54
- test("addTask: accepts valid dates and priority", () => {
55
- const { tm } = setup();
56
- const r = tm.addTask(
57
- "A",
58
- undefined,
59
- undefined,
60
- undefined,
61
- "high",
62
- "2026-09-01",
63
- "2026-09-02",
64
- "2026-09-03",
65
- );
66
- assert.equal(r.status, "ok");
67
- const t = tm.getTask(id(r)) as any;
68
- assert.equal(t.task.priority, "high");
69
- assert.equal(t.task.date_scheduled, "2026-09-01");
70
- assert.equal(t.task.date_start, "2026-09-02");
71
- assert.equal(t.task.date_due, "2026-09-03");
72
- });
73
-
74
- // ── editTask validation ───────────────────────────────────────────────
75
-
76
- test("editTask: rejects multiline description", () => {
77
- const { tm } = setup();
78
- const a = id(tm.addTask("A"));
79
- const r = tm.editTask(a, "line1\nline2");
80
- assert.equal(r.status, "error");
81
- assert.match(r.error as string, /newline/i);
82
- });
83
-
84
- test("editTask: rejects invalid dates", () => {
85
- const { tm } = setup();
86
- const a = id(tm.addTask("A"));
87
- assert.equal((tm.editTask(a, undefined, undefined, undefined, "tomorrow") as any).status, "error");
88
- assert.equal((tm.editTask(a, undefined, undefined, undefined, undefined, "tomorrow") as any).status, "error");
89
- assert.equal((tm.editTask(a, undefined, undefined, undefined, undefined, undefined, "tomorrow") as any).status, "error");
90
- });
91
-
92
- // ── openFile validation: orphaned indented lines ─────────────────────
93
-
94
- test("openFile: rejects orphaned indented lines (missing ancestor)", () => {
95
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
96
- const orphan = `# TODO
97
-
98
- - [ ] Parent (ID: \`a1b2c3\`)
99
- - [ ] Orphan at depth 2 (ID: \`x1y2z3\`)
100
- `;
101
- fs.writeFileSync(path.join(dir, "TODO.md"), orphan);
102
- const tm = new TaskManager();
103
- const r = tm.openFile(dir);
104
- assert.equal(r.status, "error");
105
- assert.match(r.error as string, /orphan/i);
106
- assert.equal(tm.isOpen, false);
107
- });
108
-
109
- test("openFile: rejects indented line with no root at all", () => {
110
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
111
- const orphan = `# TODO
112
-
113
- - [ ] Indented but no ancestor (ID: \`q1w2e3\`)
114
- `;
115
- fs.writeFileSync(path.join(dir, "TODO.md"), orphan);
116
- const tm = new TaskManager();
117
- const r = tm.openFile(dir);
118
- assert.equal(r.status, "error");
119
- assert.match(r.error as string, /orphan/i);
120
- assert.equal(tm.isOpen, false);
121
- });
122
-
123
- test("openFile: rejects tab indentation", () => {
124
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
125
- const tabbed = `# TODO
126
-
127
- - [ ] Root (ID: \`a1b2c3\`)
128
- \t- [ ] Tab child (ID: \`x1y2z3\`)
129
- `;
130
- fs.writeFileSync(path.join(dir, "TODO.md"), tabbed);
131
- const tm = new TaskManager();
132
- const r = tm.openFile(dir);
133
- assert.equal(r.status, "error");
134
- assert.match(r.error as string, /tab/i);
135
- assert.equal(tm.isOpen, false);
136
- });
137
-
138
- test("openFile: rejects duplicate task IDs", () => {
139
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
140
- const dups = `# TODO
141
-
142
- - [ ] A (ID: \`a1b2c3\`)
143
- - [ ] B (ID: \`a1b2c3\`)
144
- `;
145
- fs.writeFileSync(path.join(dir, "TODO.md"), dups);
146
- const tm = new TaskManager();
147
- const r = tm.openFile(dir);
148
- assert.equal(r.status, "error");
149
- assert.match(r.error as string, /duplicate/i);
150
- assert.equal(tm.isOpen, false);
151
- });
152
-
153
- test("openFile: accepts properly nested file", () => {
154
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tm-val-"));
155
- const valid = `# TODO
156
-
157
- - [ ] A (ID: \`a1b2c3\`)
158
- - [ ] B (ID: \`d4e5f6\`)
159
- - [ ] C (ID: \`g7h8i9\`)
160
- - [ ] D (ID: \`j1k2l3\`)
161
- `;
162
- fs.writeFileSync(path.join(dir, "TODO.md"), valid);
163
- const tm = new TaskManager();
164
- const r = tm.openFile(dir);
165
- assert.equal(r.status, "ok");
166
- assert.equal((r as any).task_count, 4);
167
- tm.closeFile();
168
- });
169
-
170
- // ── round-trip guard: valid data survives save/reopen ─────────────────
171
-
172
- test("round-trip: valid dates + priority survive save/reopen", () => {
173
- const { tm, dir } = setup();
174
- const a = id(
175
- tm.addTask(
176
- "A",
177
- undefined,
178
- undefined,
179
- undefined,
180
- "high",
181
- "2026-09-01",
182
- "2026-09-02",
183
- "2026-09-03",
184
- ),
185
- );
186
- tm.closeFile();
187
- const tm2 = new TaskManager();
188
- tm2.openFile(dir);
189
- const t = tm2.getTask(a) as any;
190
- assert.equal(t.task.priority, "high");
191
- assert.equal(t.task.date_scheduled, "2026-09-01");
192
- assert.equal(t.task.date_start, "2026-09-02");
193
- assert.equal(t.task.date_due, "2026-09-03");
194
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "noEmit": true,
8
- "skipLibCheck": true,
9
- "allowImportingTsExtensions": true,
10
- "verbatimModuleSyntax": true,
11
- "paths": {
12
- "@earendil-works/pi-ai": [
13
- "/home/gil/.local/share/pi-node/node-v22.23.2-linux-x64/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/index.d.ts"
14
- ],
15
- "@earendil-works/pi-coding-agent": [
16
- "/home/gil/.local/share/pi-node/node-v22.23.2-linux-x64/lib/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"
17
- ]
18
- }
19
- },
20
- "include": ["index.ts", "lib", "tests"]
21
- }