@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.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.
Files changed (76) hide show
  1. package/AGENTS.md +114 -9
  2. package/README.md +218 -97
  3. package/docs/architecture.md +107 -7
  4. package/package.json +9 -4
  5. package/packages/extension/package.json +1 -1
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  8. package/packages/extension/src/ask-user-tool.ts +289 -20
  9. package/packages/extension/src/bridge.ts +38 -4
  10. package/packages/extension/src/command-handler.ts +34 -39
  11. package/packages/extension/src/prompt-expander.ts +25 -4
  12. package/packages/server/package.json +2 -1
  13. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  14. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  15. package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
  16. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  17. package/packages/server/src/__tests__/cors.test.ts +34 -2
  18. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  19. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  20. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  21. package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
  22. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  23. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  24. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  25. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  26. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  27. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
  28. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  29. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  30. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  31. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  32. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  33. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  34. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  35. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  36. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  37. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  38. package/packages/server/src/__tests__/tunnel.test.ts +91 -0
  39. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  40. package/packages/server/src/browse.ts +100 -6
  41. package/packages/server/src/browser-gateway.ts +16 -3
  42. package/packages/server/src/editor-manager.ts +20 -1
  43. package/packages/server/src/editor-pid-registry.ts +198 -0
  44. package/packages/server/src/fix-pty-permissions.ts +44 -0
  45. package/packages/server/src/headless-pid-registry.ts +9 -0
  46. package/packages/server/src/npm-search-proxy.ts +71 -0
  47. package/packages/server/src/openspec-tasks.ts +158 -0
  48. package/packages/server/src/package-manager-wrapper.ts +31 -0
  49. package/packages/server/src/pi-core-checker.ts +290 -0
  50. package/packages/server/src/pi-core-updater.ts +166 -0
  51. package/packages/server/src/pi-gateway.ts +7 -0
  52. package/packages/server/src/process-manager.ts +1 -1
  53. package/packages/server/src/routes/file-routes.ts +30 -3
  54. package/packages/server/src/routes/openspec-routes.ts +83 -1
  55. package/packages/server/src/routes/pi-core-routes.ts +117 -0
  56. package/packages/server/src/routes/provider-auth-routes.ts +4 -2
  57. package/packages/server/src/routes/provider-routes.ts +12 -2
  58. package/packages/server/src/routes/recommended-routes.ts +227 -0
  59. package/packages/server/src/routes/system-routes.ts +10 -1
  60. package/packages/server/src/server.ts +151 -15
  61. package/packages/server/src/terminal-manager.ts +4 -0
  62. package/packages/server/src/test-env-guard.ts +26 -0
  63. package/packages/server/src/test-support/test-server.ts +63 -0
  64. package/packages/server/src/tunnel.ts +132 -8
  65. package/packages/shared/package.json +1 -1
  66. package/packages/shared/src/__tests__/config.test.ts +3 -3
  67. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  68. package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
  69. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  70. package/packages/shared/src/browser-protocol.ts +23 -1
  71. package/packages/shared/src/openspec-poller.ts +8 -3
  72. package/packages/shared/src/recommended-extensions.ts +180 -0
  73. package/packages/shared/src/rest-api.ts +71 -0
  74. package/packages/shared/src/source-matching.ts +126 -0
  75. package/packages/shared/src/test-support/setup-home.ts +74 -0
  76. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import {
8
+ createEditorPidRegistry,
9
+ isDashboardOwnedCodeServer,
10
+ type PersistedEditorEntry,
11
+ } from "../editor-pid-registry.js";
12
+
13
+ function tempPidFile(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), "editor-pid-reg-"));
15
+ return join(dir, "editor-pids.json");
16
+ }
17
+
18
+ function readEntries(file: string): PersistedEditorEntry[] {
19
+ if (!existsSync(file)) return [];
20
+ return JSON.parse(readFileSync(file, "utf-8")).entries ?? [];
21
+ }
22
+
23
+ const VALID_CMDLINE = `/usr/local/bin/code-server --auth none --bind-addr 127.0.0.1:63584 --user-data-dir ${path.join(os.homedir(), ".pi", "dashboard", "editors", "abc123def456")} /Users/me/project`;
24
+
25
+ const UNRELATED_CMDLINE = "/usr/local/bin/code-server --user-data-dir /Users/me/.config/Code";
26
+
27
+ describe("isDashboardOwnedCodeServer", () => {
28
+ it("returns true for a dashboard-owned code-server cmdline", () => {
29
+ expect(isDashboardOwnedCodeServer(VALID_CMDLINE)).toBe(true);
30
+ });
31
+
32
+ it("returns false for an unrelated code-server", () => {
33
+ expect(isDashboardOwnedCodeServer(UNRELATED_CMDLINE)).toBe(false);
34
+ });
35
+
36
+ it("returns false for null cmdline", () => {
37
+ expect(isDashboardOwnedCodeServer(null)).toBe(false);
38
+ });
39
+
40
+ it("returns false when --user-data-dir is missing", () => {
41
+ expect(isDashboardOwnedCodeServer("/usr/local/bin/code-server --bind-addr 127.0.0.1:1234")).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("createEditorPidRegistry — register/remove/persist", () => {
46
+ it("register writes an entry to the JSON file", () => {
47
+ const file = tempPidFile();
48
+ const reg = createEditorPidRegistry({ pidFilePath: file });
49
+ reg.register({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
50
+ expect(reg.size()).toBe(1);
51
+ const entries = readEntries(file);
52
+ expect(entries).toHaveLength(1);
53
+ expect(entries[0]).toMatchObject({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
54
+ expect(entries[0].spawnedAt).toBeDefined();
55
+ });
56
+
57
+ it("remove deletes the entry from the JSON file", () => {
58
+ const file = tempPidFile();
59
+ const reg = createEditorPidRegistry({ pidFilePath: file });
60
+ reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" });
61
+ reg.register({ id: "editor-bbb", pid: 2, port: 2, cwd: "/b", dataDir: "/d2" });
62
+ reg.remove("editor-aaa");
63
+ expect(reg.size()).toBe(1);
64
+ const entries = readEntries(file);
65
+ expect(entries).toHaveLength(1);
66
+ expect(entries[0].id).toBe("editor-bbb");
67
+ });
68
+
69
+ it("persistence write failure does not throw from register", () => {
70
+ // Portable way to force writeJsonFile to fail: make the target path a
71
+ // directory so fs.writeFileSync (to path + ".tmp") succeeds but rename()
72
+ // onto a directory fails with EISDIR/EPERM on every platform.
73
+ const file = tempPidFile();
74
+ mkdirSync(file, { recursive: true }); // target exists as a directory
75
+ const reg = createEditorPidRegistry({ pidFilePath: file });
76
+ expect(() => reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" })).not.toThrow();
77
+ // In-memory entry still tracked even when disk write failed.
78
+ expect(reg.size()).toBe(1);
79
+ });
80
+ });
81
+
82
+ describe("createEditorPidRegistry — cleanupOrphans", () => {
83
+ it("returns without throwing when file does not exist", async () => {
84
+ const file = join(mkdtempSync(join(tmpdir(), "missing-")), "nope.json");
85
+ const reg = createEditorPidRegistry({ pidFilePath: file });
86
+ await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
87
+ });
88
+
89
+ it("returns without throwing when file is corrupt", async () => {
90
+ const file = tempPidFile();
91
+ writeFileSync(file, "{not json");
92
+ const reg = createEditorPidRegistry({ pidFilePath: file });
93
+ await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
94
+ });
95
+
96
+ it("skips dead PIDs", async () => {
97
+ const file = tempPidFile();
98
+ writeFileSync(file, JSON.stringify({
99
+ entries: [{ id: "editor-x", pid: 999999, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
100
+ }));
101
+ const killed: Array<{ pid: number; sig: string }> = [];
102
+ const reg = createEditorPidRegistry({
103
+ pidFilePath: file,
104
+ isProcessAlive: () => false,
105
+ getCmdline: () => VALID_CMDLINE,
106
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
107
+ graceMs: 1,
108
+ });
109
+ await reg.cleanupOrphans();
110
+ expect(killed).toEqual([]);
111
+ });
112
+
113
+ it("does NOT signal a live PID whose cmdline doesn't match (PID reuse)", async () => {
114
+ const file = tempPidFile();
115
+ writeFileSync(file, JSON.stringify({
116
+ entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
117
+ }));
118
+ const killed: Array<{ pid: number; sig: string }> = [];
119
+ const reg = createEditorPidRegistry({
120
+ pidFilePath: file,
121
+ isProcessAlive: () => true,
122
+ getCmdline: () => UNRELATED_CMDLINE,
123
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
124
+ graceMs: 1,
125
+ });
126
+ await reg.cleanupOrphans();
127
+ expect(killed).toEqual([]);
128
+ });
129
+
130
+ it("does NOT signal when cmdline lookup fails (cannot verify)", async () => {
131
+ const file = tempPidFile();
132
+ writeFileSync(file, JSON.stringify({
133
+ entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
134
+ }));
135
+ const killed: Array<{ pid: number; sig: string }> = [];
136
+ const reg = createEditorPidRegistry({
137
+ pidFilePath: file,
138
+ isProcessAlive: () => true,
139
+ getCmdline: () => null,
140
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
141
+ graceMs: 1,
142
+ });
143
+ await reg.cleanupOrphans();
144
+ expect(killed).toEqual([]);
145
+ });
146
+
147
+ it("SIGTERMs verified live orphans then SIGKILLs survivors", async () => {
148
+ const file = tempPidFile();
149
+ writeFileSync(file, JSON.stringify({
150
+ entries: [
151
+ { id: "editor-x", pid: 5961, port: 63584, cwd: "/a", dataDir: "/d1", spawnedAt: new Date().toISOString() },
152
+ { id: "editor-y", pid: 5962, port: 63585, cwd: "/b", dataDir: "/d2", spawnedAt: new Date().toISOString() },
153
+ ],
154
+ }));
155
+ const killed: Array<{ pid: number; sig: string }> = [];
156
+ // First call: alive. After SIGTERM grace, simulate that 5961 died, 5962 survived.
157
+ let phase: "before" | "after" = "before";
158
+ const reg = createEditorPidRegistry({
159
+ pidFilePath: file,
160
+ isProcessAlive: (pid) => phase === "before" ? true : pid === 5962,
161
+ getCmdline: () => VALID_CMDLINE,
162
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
163
+ graceMs: 1,
164
+ });
165
+ // Toggle phase right after SIGTERMs are sent.
166
+ const origSetTimeout = setTimeout;
167
+ const promise = reg.cleanupOrphans();
168
+ // microtask flip
169
+ queueMicrotask(() => { phase = "after"; });
170
+ await promise;
171
+
172
+ expect(killed.filter((k) => k.sig === "SIGTERM").map((k) => k.pid).sort()).toEqual([5961, 5962]);
173
+ expect(killed.filter((k) => k.sig === "SIGKILL").map((k) => k.pid)).toEqual([5962]);
174
+ });
175
+
176
+ it("rewrites the registry file empty after sweep", async () => {
177
+ const file = tempPidFile();
178
+ writeFileSync(file, JSON.stringify({
179
+ entries: [{ id: "editor-x", pid: 5961, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
180
+ }));
181
+ const reg = createEditorPidRegistry({
182
+ pidFilePath: file,
183
+ isProcessAlive: () => true,
184
+ getCmdline: () => VALID_CMDLINE,
185
+ kill: () => true,
186
+ graceMs: 1,
187
+ });
188
+ await reg.cleanupOrphans();
189
+ expect(readEntries(file)).toEqual([]);
190
+ });
191
+ });
@@ -77,8 +77,9 @@ describe("editor-registry", () => {
77
77
  mockedExecSync.mockImplementation((cmd) => {
78
78
  const s = String(cmd);
79
79
  if (s.includes("pgrep")) {
80
- // Only Zed is running
81
- if (s.includes("Zed")) return Buffer.from("12345\n");
80
+ // Only Zed is running — match on both the macOS pattern
81
+ // ("/Applications/Zed.app") and the Linux pattern ("zed").
82
+ if (s.includes("Zed") || s.includes("zed")) return Buffer.from("12345\n");
82
83
  throw new Error("not found");
83
84
  }
84
85
  if (s.includes("which")) {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Regression test for the postinstall `fix-pty-permissions.cjs` script.
3
+ *
4
+ * Ensures that after `npm install` the native `spawn-helper` binary in the
5
+ * current platform's `node-pty` prebuild directory has at least one execute
6
+ * bit set. Without this, `pty.spawn(...)` fails with "posix_spawnp failed."
7
+ * and the dashboard's "New Terminal" button appears dead.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { existsSync, statSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { createRequire } from "node:module";
13
+
14
+ describe("fix-pty-permissions", () => {
15
+ it.skipIf(process.platform === "win32")(
16
+ "spawn-helper for current platform is executable",
17
+ () => {
18
+ const require = createRequire(import.meta.url);
19
+ const ptyPkg = require.resolve("node-pty/package.json");
20
+ const ptyRoot = dirname(ptyPkg);
21
+ const platformDir =
22
+ process.platform === "darwin"
23
+ ? process.arch === "arm64"
24
+ ? "darwin-arm64"
25
+ : "darwin-x64"
26
+ : process.arch === "arm64"
27
+ ? "linux-arm64"
28
+ : "linux-x64";
29
+
30
+ // node-pty ships pre-packed binaries under `prebuilds/<platform>-<arch>/`
31
+ // when the tarball includes a prebuild for the host platform (macOS,
32
+ // Windows, some Linux builds). Otherwise node-pty's install script
33
+ // falls back to `node-gyp rebuild`, producing artifacts under
34
+ // `build/Release/` instead. Accept either location so this test is
35
+ // stable across local dev (prebuilt) and Linux CI (built from source).
36
+ const candidates = [
37
+ join(ptyRoot, "prebuilds", platformDir, "spawn-helper"),
38
+ join(ptyRoot, "build", "Release", "spawn-helper"),
39
+ ];
40
+ const helper = candidates.find((p) => existsSync(p));
41
+
42
+ if (!helper) {
43
+ // No spawn-helper anywhere means node-pty's install step did not
44
+ // produce the binary on this host (e.g. missing build toolchain).
45
+ // The fix-permissions script is defensive — it silently skips when
46
+ // the prebuild dir is absent — so skipping here matches runtime
47
+ // behavior rather than masking a real regression.
48
+ console.warn(
49
+ `[fix-pty-permissions.test] no spawn-helper found at any of: ${candidates.join(", ")} — skipping`,
50
+ );
51
+ return;
52
+ }
53
+
54
+ const mode = statSync(helper).mode;
55
+ // At least one execute bit (owner/group/other) must be set.
56
+ expect(mode & 0o111).not.toBe(0);
57
+ },
58
+ );
59
+ });
@@ -18,7 +18,9 @@ function git(cmd: string, cwd: string) {
18
18
 
19
19
  function makeRepo(): string {
20
20
  const dir = mkdtempSync(join(tmpdir(), "git-ops-test-"));
21
- git("init", dir);
21
+ // Force `main` as the default branch so tests are deterministic regardless
22
+ // of the host user's `init.defaultBranch` config.
23
+ git("-c init.defaultBranch=main init", dir);
22
24
  git("config user.email test@test.com", dir);
23
25
  git("config user.name Test", dir);
24
26
  // Initial commit so we have a branch
@@ -122,7 +124,7 @@ describe("git-operations", () => {
122
124
  writeFileSync(join(repo, "remote.txt"), "data");
123
125
  git("add .", repo);
124
126
  git("commit -m remote-only", repo);
125
- git("checkout master", repo);
127
+ git("checkout main", repo);
126
128
 
127
129
  // Fetch in clone
128
130
  git("fetch origin", clone);
@@ -151,7 +153,7 @@ describe("git-operations", () => {
151
153
  describe("checkoutBranch", () => {
152
154
  it("checks out a local branch on clean repo", () => {
153
155
  git("checkout -b feature-x", repo);
154
- git("checkout master", repo);
156
+ git("checkout main", repo);
155
157
  const result = checkoutBranch(repo, "feature-x", false);
156
158
  expect(result.success).toBe(true);
157
159
  const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
@@ -160,7 +162,7 @@ describe("git-operations", () => {
160
162
 
161
163
  it("returns dirty when working tree is dirty and stash=false", () => {
162
164
  git("checkout -b feature-y", repo);
163
- git("checkout master", repo);
165
+ git("checkout main", repo);
164
166
  writeFileSync(join(repo, "README.md"), "dirty");
165
167
  const result = checkoutBranch(repo, "feature-y", false);
166
168
  expect(result.success).toBe(false);
@@ -172,7 +174,7 @@ describe("git-operations", () => {
172
174
 
173
175
  it("stashes and checks out when stash=true", () => {
174
176
  git("checkout -b feature-z", repo);
175
- git("checkout master", repo);
177
+ git("checkout main", repo);
176
178
  writeFileSync(join(repo, "README.md"), "dirty");
177
179
  const result = checkoutBranch(repo, "feature-z", true);
178
180
  expect(result.success).toBe(true);
@@ -184,7 +186,7 @@ describe("git-operations", () => {
184
186
  });
185
187
 
186
188
  it("returns success when already on target branch", () => {
187
- const result = checkoutBranch(repo, "master", false);
189
+ const result = checkoutBranch(repo, "main", false);
188
190
  expect(result.success).toBe(true);
189
191
  });
190
192
 
@@ -194,7 +196,7 @@ describe("git-operations", () => {
194
196
  writeFileSync(join(repo, "r.txt"), "data");
195
197
  git("add .", repo);
196
198
  git("commit -m r", repo);
197
- git("checkout master", repo);
199
+ git("checkout main", repo);
198
200
 
199
201
  const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
200
202
  try {
@@ -2,28 +2,26 @@
2
2
  * Tests for GET /api/health endpoint.
3
3
  */
4
4
  import { describe, it, expect, afterEach } from "vitest";
5
- import { createServer, type DashboardServer } from "../server.js";
5
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
6
+ import type { DashboardServer } from "../server.js";
6
7
 
7
- const httpPort = 19090;
8
- const piPort = 19091;
9
- let server: DashboardServer;
8
+ let handle: TestServerHandle | undefined;
9
+ let server: DashboardServer | undefined;
10
10
 
11
11
  describe("GET /api/health", () => {
12
12
  afterEach(async () => {
13
- if (server) {
14
- try { await server.stop(); } catch { /* already stopped */ }
13
+ if (handle) {
14
+ try { await handle.stop(); } catch { /* already stopped */ }
15
+ handle = undefined;
16
+ server = undefined;
15
17
  }
16
18
  });
17
19
 
18
20
  it("should return ok, pid, and uptime", async () => {
19
- server = await createServer({
20
- port: httpPort, piPort, dev: true,
21
- autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
22
- editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
23
- });
24
- await server.start();
21
+ handle = await createTestServer();
22
+ server = handle.server;
25
23
 
26
- const res = await fetch(`http://localhost:${httpPort}/api/health`);
24
+ const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
27
25
  expect(res.status).toBe(200);
28
26
 
29
27
  const body = await res.json();
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import {
6
+ parseTasksMarkdown,
7
+ readTasks,
8
+ toggleTask,
9
+ NotFoundError,
10
+ LineMismatchError,
11
+ NotACheckboxError,
12
+ } from "../openspec-tasks.js";
13
+
14
+ describe("parseTasksMarkdown", () => {
15
+ it("parses ticked + unticked mix and tracks groups", () => {
16
+ const md = [
17
+ "## 1. Setup",
18
+ "",
19
+ "- [ ] 1.1 Create module",
20
+ "- [x] 1.2 Add dep",
21
+ "",
22
+ "## 2. Tests",
23
+ "- [x] 2.1 Write vitest",
24
+ "- [ ] 2.2 Write e2e",
25
+ ].join("\n");
26
+ const tasks = parseTasksMarkdown(md);
27
+ expect(tasks).toEqual([
28
+ { id: "1.1", text: "Create module", done: false, line: 3, group: "1. Setup" },
29
+ { id: "1.2", text: "Add dep", done: true, line: 4, group: "1. Setup" },
30
+ { id: "2.1", text: "Write vitest", done: true, line: 7, group: "2. Tests" },
31
+ { id: "2.2", text: "Write e2e", done: false, line: 8, group: "2. Tests" },
32
+ ]);
33
+ });
34
+
35
+ it("ignores unparseable lines without failing", () => {
36
+ const md = [
37
+ "## Misc",
38
+ "- foo bar",
39
+ "- [ ] 1.1 Valid task",
40
+ "random text",
41
+ " - [ ] 1.2 Indented, not top-level",
42
+ "- [x] 1.3 Another valid",
43
+ ].join("\n");
44
+ const tasks = parseTasksMarkdown(md);
45
+ expect(tasks.map((t) => t.id)).toEqual(["1.1", "1.3"]);
46
+ expect(tasks.map((t) => t.done)).toEqual([false, true]);
47
+ });
48
+
49
+ it("handles capital X as done", () => {
50
+ const tasks = parseTasksMarkdown("## G\n- [X] 1.1 Done-uppercase");
51
+ expect(tasks).toHaveLength(1);
52
+ expect(tasks[0].done).toBe(true);
53
+ });
54
+
55
+ it("handles CRLF line endings", () => {
56
+ const md = "## 1. G\r\n- [ ] 1.1 Test\r\n- [x] 1.2 Done\r\n";
57
+ const tasks = parseTasksMarkdown(md);
58
+ expect(tasks).toHaveLength(2);
59
+ expect(tasks[0].group).toBe("1. G");
60
+ expect(tasks[0].line).toBe(2);
61
+ expect(tasks[1].done).toBe(true);
62
+ });
63
+
64
+ it("returns empty list for no tasks", () => {
65
+ expect(parseTasksMarkdown("# Title\n\nJust prose.\n")).toEqual([]);
66
+ });
67
+
68
+ it("handles tasks without a preceding heading (empty group)", () => {
69
+ const tasks = parseTasksMarkdown("- [ ] 1.1 Loose task");
70
+ expect(tasks[0].group).toBe("");
71
+ });
72
+ });
73
+
74
+ describe("readTasks + toggleTask (writer)", () => {
75
+ let tmpDir: string;
76
+ let changeDir: string;
77
+ let tasksFile: string;
78
+ const CWD_CHANGE = ["my-cwd-placeholder", "demo-change"] as const;
79
+
80
+ const initialMd = [
81
+ "## 1. Setup",
82
+ "",
83
+ "- [ ] 1.1 First task",
84
+ "- [x] 1.2 Second task",
85
+ "",
86
+ "## 2. Docs",
87
+ "- [ ] 2.1 Third task",
88
+ "",
89
+ ].join("\n");
90
+
91
+ beforeEach(() => {
92
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openspec-tasks-test-"));
93
+ changeDir = path.join(tmpDir, "openspec", "changes", CWD_CHANGE[1]);
94
+ fs.mkdirSync(changeDir, { recursive: true });
95
+ tasksFile = path.join(changeDir, "tasks.md");
96
+ fs.writeFileSync(tasksFile, initialMd, "utf-8");
97
+ });
98
+ afterEach(() => {
99
+ fs.rmSync(tmpDir, { recursive: true, force: true });
100
+ });
101
+
102
+ it("readTasks returns parsed entries", async () => {
103
+ const tasks = await readTasks(tmpDir, CWD_CHANGE[1]);
104
+ expect(tasks.map((t) => t.id)).toEqual(["1.1", "1.2", "2.1"]);
105
+ });
106
+
107
+ it("readTasks throws NotFoundError when file is missing", async () => {
108
+ await expect(readTasks(tmpDir, "does-not-exist")).rejects.toBeInstanceOf(NotFoundError);
109
+ });
110
+
111
+ it("toggle ticks an unticked task and preserves other lines", async () => {
112
+ const result = await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3);
113
+ expect(result.done).toBe(true);
114
+ expect(result.id).toBe("1.1");
115
+ expect(result.line).toBe(3);
116
+ expect(result.group).toBe("1. Setup");
117
+
118
+ const after = fs.readFileSync(tasksFile, "utf-8");
119
+ const expected = initialMd.replace("- [ ] 1.1 First task", "- [x] 1.1 First task");
120
+ expect(after).toBe(expected);
121
+ });
122
+
123
+ it("toggle unticks a ticked task", async () => {
124
+ const result = await toggleTask(tmpDir, CWD_CHANGE[1], "1.2", false, 4);
125
+ expect(result.done).toBe(false);
126
+ const after = fs.readFileSync(tasksFile, "utf-8");
127
+ expect(after).toBe(initialMd.replace("- [x] 1.2 Second task", "- [ ] 1.2 Second task"));
128
+ });
129
+
130
+ it("toggle raises LineMismatchError when line is already in the target state", async () => {
131
+ // Line 4 is already done=true; requesting done=true again → mismatch
132
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.2", true, 4)).rejects.toBeInstanceOf(
133
+ LineMismatchError,
134
+ );
135
+ // File untouched
136
+ expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
137
+ });
138
+
139
+ it("toggle raises LineMismatchError when id does not match target line", async () => {
140
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "9.9", true, 3)).rejects.toBeInstanceOf(
141
+ LineMismatchError,
142
+ );
143
+ });
144
+
145
+ it("toggle raises LineMismatchError for out-of-range line", async () => {
146
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 9999)).rejects.toBeInstanceOf(
147
+ LineMismatchError,
148
+ );
149
+ });
150
+
151
+ it("toggle raises NotACheckboxError when target line is a heading", async () => {
152
+ // Line 1 is "## 1. Setup"
153
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 1)).rejects.toBeInstanceOf(
154
+ NotACheckboxError,
155
+ );
156
+ });
157
+
158
+ it("toggle raises NotFoundError when file is absent", async () => {
159
+ await expect(toggleTask(tmpDir, "missing", "1.1", true, 3)).rejects.toBeInstanceOf(
160
+ NotFoundError,
161
+ );
162
+ });
163
+
164
+ it("toggle writes atomically (no .tmp left behind)", async () => {
165
+ await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3);
166
+ const files = fs.readdirSync(changeDir);
167
+ expect(files.some((f) => f.endsWith(".tmp"))).toBe(false);
168
+ });
169
+
170
+ it("toggle preserves byte-for-byte all other lines", async () => {
171
+ const weirdMd =
172
+ "# Title line\n\n## 1. Group\n- [ ] 1.1 Task one\n> quote line\n\n indented code\n- [x] 1.2 Task two\n";
173
+ fs.writeFileSync(tasksFile, weirdMd, "utf-8");
174
+ await toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 4);
175
+ const after = fs.readFileSync(tasksFile, "utf-8");
176
+ expect(after).toBe(weirdMd.replace("- [ ] 1.1 Task one", "- [x] 1.1 Task one"));
177
+ });
178
+ });