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.
@@ -0,0 +1,158 @@
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
+ });
@@ -0,0 +1,194 @@
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 ADDED
@@ -0,0 +1,21 @@
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
+ }