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 +51 -26
- package/PLAN.md +0 -106
- package/tests/parser.test.ts +0 -120
- package/tests/robustness.test.ts +0 -154
- package/tests/task-manager.test.ts +0 -158
- package/tests/validation.test.ts +0 -194
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,28 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
package/tests/parser.test.ts
DELETED
|
@@ -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
|
-
});
|
package/tests/robustness.test.ts
DELETED
|
@@ -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
|
-
});
|
package/tests/validation.test.ts
DELETED
|
@@ -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
|
-
}
|