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/lib/task.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Task model — a tree node. The tree (parent/children links) is the
3
+ * single source of truth for hierarchy; depth, position, parent_id, and
4
+ * children_ids are all derived from it.
5
+ */
6
+
7
+ export interface Task {
8
+ id: string;
9
+ description: string;
10
+ /** ' ' = open, 'x' = done, '>' = in progress, '!' = failed, '-' = cancelled */
11
+ status: string;
12
+ /** lowest | low | normal | medium | high | highest */
13
+ priority: string | null;
14
+ dateCreated: string | null;
15
+ dateModified: string | null;
16
+ dateScheduled: string | null;
17
+ dateStart: string | null;
18
+ dateDue: string | null;
19
+ dateDone: string | null;
20
+ dateCancelled: string | null;
21
+ recurrence: string | null;
22
+ /** keep | delete (null = keep, the default) */
23
+ onCompletion: string | null;
24
+ dependsOn: string[];
25
+ hasSpec: boolean;
26
+ parent: Task | null;
27
+ children: Task[];
28
+ }
29
+
30
+ export function newTask(id: string, description: string): Task {
31
+ return {
32
+ id,
33
+ description,
34
+ status: " ",
35
+ priority: null,
36
+ dateCreated: null,
37
+ dateModified: null,
38
+ dateScheduled: null,
39
+ dateStart: null,
40
+ dateDue: null,
41
+ dateDone: null,
42
+ dateCancelled: null,
43
+ recurrence: null,
44
+ onCompletion: null,
45
+ dependsOn: [],
46
+ hasSpec: false,
47
+ parent: null,
48
+ children: [],
49
+ };
50
+ }
51
+
52
+ /** Depth of a task (0 = top level). */
53
+ export function depthOf(task: Task): number {
54
+ let depth = 0;
55
+ for (let p = task.parent; p; p = p.parent) depth++;
56
+ return depth;
57
+ }
package/lib/tools.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Tool schemas for the task manager (8 tools).
3
+ * Parameter names are snake_case to match the Python tool contract.
4
+ */
5
+
6
+ import { StringEnum } from "@earendil-works/pi-ai";
7
+ import { Type, type TSchema } from "typebox";
8
+
9
+ const StatusEnum = StringEnum([" ", "x", ">", "!", "-"] as const);
10
+ const PriorityEnum = StringEnum([
11
+ "lowest",
12
+ "low",
13
+ "normal",
14
+ "medium",
15
+ "high",
16
+ "highest",
17
+ "null",
18
+ ] as const);
19
+ const OnCompletionEnum = StringEnum(["keep", "delete", "null"] as const);
20
+
21
+ const date = (what: string) =>
22
+ Type.Optional(Type.String({ description: `${what}, YYYY-MM-DD` }));
23
+
24
+ const TaskOpenParams = Type.Object({
25
+ path: Type.String({
26
+ description: "Workspace directory containing TODO.md (created if missing)",
27
+ }),
28
+ });
29
+
30
+ const TaskAddParams = Type.Object({
31
+ description: Type.String({ description: "Task description" }),
32
+ parent_id: Type.Optional(
33
+ Type.String({ description: "Add as last child of this task" }),
34
+ ),
35
+ before_id: Type.Optional(
36
+ Type.String({ description: "Insert before this task (same level)" }),
37
+ ),
38
+ after_id: Type.Optional(
39
+ Type.String({
40
+ description: "Insert after this task (same level)",
41
+ }),
42
+ ),
43
+ priority: Type.Optional(PriorityEnum),
44
+ scheduled: date("Scheduled date"),
45
+ start: date("Start date"),
46
+ due: date("Due date"),
47
+ recurrence: Type.Optional(
48
+ Type.String({
49
+ description: "Recurrence rule, e.g. 'weekly' or 'every 2 weeks on Monday'",
50
+ }),
51
+ ),
52
+ on_completion: Type.Optional(OnCompletionEnum),
53
+ depends_on: Type.Optional(
54
+ Type.Array(Type.String(), {
55
+ description: "IDs of tasks this task depends on",
56
+ }),
57
+ ),
58
+ spec: Type.Optional(
59
+ Type.Boolean({ description: "Also create a task-<id>.md spec file" }),
60
+ ),
61
+ });
62
+
63
+ const TaskEditParams = Type.Object({
64
+ task_id: Type.String({ description: "ID of the task to edit" }),
65
+ description: Type.Optional(Type.String()),
66
+ status: Type.Optional(StatusEnum),
67
+ priority: Type.Optional(PriorityEnum),
68
+ scheduled: date("Scheduled date"),
69
+ start: date("Start date"),
70
+ due: date("Due date"),
71
+ recurrence: Type.Optional(Type.String()),
72
+ on_completion: Type.Optional(OnCompletionEnum),
73
+ depends_on: Type.Optional(
74
+ Type.Array(Type.String(), {
75
+ description: "New full dependency list (replaces existing)",
76
+ }),
77
+ ),
78
+ });
79
+
80
+ const TaskMoveParams = Type.Object({
81
+ task_id: Type.String({ description: "ID of the task to move" }),
82
+ under_id: Type.Optional(
83
+ Type.String({
84
+ description: "Make this task a child of the target (last child)",
85
+ }),
86
+ ),
87
+ before_id: Type.Optional(
88
+ Type.String({ description: "Place before this task (same level)" }),
89
+ ),
90
+ after_id: Type.Optional(
91
+ Type.String({
92
+ description: "Place after this task (same level)",
93
+ }),
94
+ ),
95
+ });
96
+
97
+ const TaskGetParams = Type.Object({
98
+ task_id: Type.String({ description: "ID of the task to fetch" }),
99
+ });
100
+
101
+ const TaskListParams = Type.Object({
102
+ parent_id: Type.Optional(
103
+ Type.String({ description: "Only tasks under this parent" }),
104
+ ),
105
+ status: Type.Optional(StatusEnum),
106
+ priority: Type.Optional(PriorityEnum),
107
+ include_subtasks: Type.Optional(
108
+ Type.Boolean({
109
+ description: "With parent_id: include the whole subtree",
110
+ }),
111
+ ),
112
+ });
113
+
114
+ const TaskSaveParams = Type.Object({});
115
+ const TaskCloseParams = Type.Object({});
116
+
117
+ export interface ToolDef {
118
+ name: string;
119
+ label: string;
120
+ description: string;
121
+ parameters: TSchema;
122
+ }
123
+
124
+ export const TOOLS: ToolDef[] = [
125
+ {
126
+ name: "task_open",
127
+ label: "Task Open",
128
+ description:
129
+ "Open a TODO.md task file in a workspace directory. Call once before other task tools.",
130
+ parameters: TaskOpenParams,
131
+ },
132
+ {
133
+ name: "task_add",
134
+ label: "Task Add",
135
+ description:
136
+ "Add a task. Hierarchy: parent_id (last child), before_id/after_id (sibling placement). Returns the new 6-char task ID.",
137
+ parameters: TaskAddParams,
138
+ },
139
+ {
140
+ name: "task_edit",
141
+ label: "Task Edit",
142
+ description:
143
+ "Edit fields of an existing task. Only provided fields change. Status 'x' stamps date_done, '-' stamps date_cancelled.",
144
+ parameters: TaskEditParams,
145
+ },
146
+ {
147
+ name: "task_move",
148
+ label: "Task Move",
149
+ description:
150
+ "Move a task (with its subtree) under/before/after another task. Omit all destinations to delete the task and its subtree.",
151
+ parameters: TaskMoveParams,
152
+ },
153
+ {
154
+ name: "task_get",
155
+ label: "Task Get",
156
+ description: "Get full details of one task by ID.",
157
+ parameters: TaskGetParams,
158
+ },
159
+ {
160
+ name: "task_list",
161
+ label: "Task List",
162
+ description:
163
+ "List tasks, optionally filtered by parent, status, or priority. include_subtasks=true returns the whole subtree under parent_id.",
164
+ parameters: TaskListParams,
165
+ },
166
+ {
167
+ name: "task_save",
168
+ label: "Task Save",
169
+ description:
170
+ "Force a save of the task file to disk (changes are also auto-saved after each mutation).",
171
+ parameters: TaskSaveParams,
172
+ },
173
+ {
174
+ name: "task_close",
175
+ label: "Task Close",
176
+ description: "Save and close the current task file.",
177
+ parameters: TaskCloseParams,
178
+ },
179
+ ];
package/package.json ADDED
@@ -0,0 +1,28 @@
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
+ }
28
+ }
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: task-manager
3
+ description: Manage tasks in a TODO.md tree using the task_* tools (task_open, task_add, task_edit, task_move, task_list, task_get, task_save, task_close). Use when tracking TODOs or tasks in a pi session.
4
+ ---
5
+
6
+ # Task Manager
7
+
8
+ Manage tasks in a `TODO.md` tree using the `task_*` tools.
9
+
10
+ ## Workflow
11
+
12
+ 1. `task_open(path)` — open `<path>/TODO.md` (created if missing). Call once before anything else.
13
+ 2. `task_list()` — see what exists.
14
+ 3. `task_add(description, ...)` — add tasks. Returns the new 6-char ID.
15
+ 4. `task_edit(task_id, ...)` — change fields (only provided fields).
16
+ 5. `task_move(task_id, ...)` — reposition a task (with its subtree).
17
+ 6. `task_save()` / `task_close()` — save and close when done.
18
+
19
+ Mutations auto-save; `task_save` is a manual force-save.
20
+
21
+ ## Hierarchy
22
+
23
+ Tasks form a tree via indentation. Placement parameters:
24
+
25
+ - `parent_id` — add/move as **last child** of that task
26
+ - `before_id` / `after_id` — insert at the **same level**, before/after that
27
+ sibling (it must share the target's parent)
28
+ - `task_move` with **no** destination deletes the task and its subtree
29
+
30
+ Example: add "Organic" under task `5Tvc0d`:
31
+ `task_add("Organic", parent_id: "5Tvc0d")`
32
+
33
+ ## Fields
34
+
35
+ - Descriptions must be single-line and must not contain the annotation
36
+ emojis (⏬🔽🔼⏫🔺⏳🛫📅✅❌➕🖊️🔁🗑️🏁⛔📎🆔) — they are reserved for metadata.
37
+ - `priority`: `lowest` `low` `normal` `medium` `high` `highest`
38
+ - `status`: ` ` open · `x` done · `>` in-progress · `!` failed · `-` cancelled
39
+ (setting `x` / `-` stamps `date_done` / `date_cancelled`)
40
+ - dates `scheduled` / `start` / `due`: `YYYY-MM-DD`
41
+ - `recurrence`: e.g. `weekly`, `every 2 weeks on Monday`
42
+ - `depends_on`: list of task IDs (circular dependencies are rejected)
43
+ - `spec: true` on add — also create a `task-<id>.md` spec file
44
+
45
+ ## Tips
46
+
47
+ - Always `task_open` first; `task_list` before adding to find `parent_id`s.
48
+ - Use `task_list(parent_id, include_subtasks: true)` to inspect a subtree.
49
+ - `task_get(task_id)` for full details of one task.
50
+ - IDs are stable 6-char strings — reuse them across calls in a session.
@@ -0,0 +1,120 @@
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
+ });
@@ -0,0 +1,154 @@
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
+ });