tmux-ide 1.2.1 → 2.0.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,1466 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { writeConfig } from "./lib/yaml-io.ts";
7
+ import { _setExecutor as setPaneExecutor, type PaneInfo } from "./widgets/lib/pane-comms.ts";
8
+ import { _setExecutor as setTmuxExecutor } from "./lib/tmux.ts";
9
+ import { saveResearchState } from "./lib/research.ts";
10
+ import { makeTask } from "./__tests__/support.ts";
11
+ import {
12
+ ensureTasksDir,
13
+ loadMission,
14
+ saveMission,
15
+ clearMission,
16
+ loadGoals,
17
+ loadGoal,
18
+ saveGoal,
19
+ deleteGoal,
20
+ nextGoalId,
21
+ loadTasks,
22
+ loadTask,
23
+ saveTask,
24
+ deleteTask,
25
+ nextTaskId,
26
+ loadTasksForGoal,
27
+ detectCycle,
28
+ normalizeTask,
29
+ normalizeGoal,
30
+ normalizeMission,
31
+ type Task,
32
+ } from "./lib/task-store.ts";
33
+ import { parseProof, taskCommand } from "./task.ts";
34
+
35
+ let tmpDir: string;
36
+ let restorePaneTmux: (() => void) | null = null;
37
+ let restoreSessionTmux: (() => void) | null = null;
38
+ let mockPanes: PaneInfo[] = [];
39
+
40
+ beforeEach(() => {
41
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-task-test-"));
42
+ mockPanes = [];
43
+ });
44
+
45
+ afterEach(() => {
46
+ restorePaneTmux?.();
47
+ restorePaneTmux = null;
48
+ restoreSessionTmux?.();
49
+ restoreSessionTmux = null;
50
+ rmSync(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ function captureLogs(run: () => void | Promise<void>): Promise<string[]> | string[] {
54
+ const logs: string[] = [];
55
+ const origLog = console.log;
56
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
57
+ try {
58
+ const result = run();
59
+ if (result instanceof Promise) {
60
+ return result
61
+ .finally(() => {
62
+ console.log = origLog;
63
+ })
64
+ .then(() => logs);
65
+ }
66
+ console.log = origLog;
67
+ return logs;
68
+ } catch (error) {
69
+ console.log = origLog;
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ describe("ensureTasksDir", () => {
75
+ it("creates .tasks/, goals/, and tasks/ directories", () => {
76
+ ensureTasksDir(tmpDir);
77
+ expect(existsSync(join(tmpDir, ".tasks"))).toBeTruthy();
78
+ expect(existsSync(join(tmpDir, ".tasks", "goals"))).toBeTruthy();
79
+ expect(existsSync(join(tmpDir, ".tasks", "tasks"))).toBeTruthy();
80
+ });
81
+
82
+ it("is idempotent", () => {
83
+ ensureTasksDir(tmpDir);
84
+ ensureTasksDir(tmpDir);
85
+ expect(existsSync(join(tmpDir, ".tasks"))).toBeTruthy();
86
+ });
87
+ });
88
+
89
+ describe("mission", () => {
90
+ it("returns null when no mission exists", () => {
91
+ ensureTasksDir(tmpDir);
92
+ expect(loadMission(tmpDir)).toBe(null);
93
+ });
94
+
95
+ it("saves and loads a mission", () => {
96
+ const mission = {
97
+ title: "Ship the auth overhaul",
98
+ description: "Before Q2 audit",
99
+ status: "active" as const,
100
+ branch: null,
101
+ milestones: [],
102
+ created: "2026-01-01T00:00:00Z",
103
+ updated: "2026-01-01T00:00:00Z",
104
+ };
105
+ saveMission(tmpDir, mission);
106
+ const loaded = loadMission(tmpDir);
107
+ expect(loaded).toEqual(mission);
108
+ });
109
+
110
+ it("clears a mission", () => {
111
+ saveMission(tmpDir, {
112
+ title: "Test",
113
+ description: "",
114
+ status: "active",
115
+ branch: null,
116
+ milestones: [],
117
+ created: "2026-01-01T00:00:00Z",
118
+ updated: "2026-01-01T00:00:00Z",
119
+ });
120
+ clearMission(tmpDir);
121
+ expect(loadMission(tmpDir)).toBe(null);
122
+ });
123
+
124
+ it("clear is safe when no mission exists", () => {
125
+ ensureTasksDir(tmpDir);
126
+ clearMission(tmpDir);
127
+ expect(loadMission(tmpDir)).toBe(null);
128
+ });
129
+
130
+ it("plan-complete only activates the first milestone and locks the rest", async () => {
131
+ saveMission(tmpDir, {
132
+ title: "Test",
133
+ description: "",
134
+ status: "planning",
135
+ branch: null,
136
+ milestones: [
137
+ {
138
+ id: "M1",
139
+ title: "First",
140
+ description: "",
141
+ status: "active",
142
+ order: 1,
143
+ created: "2026-01-01T00:00:00Z",
144
+ updated: "2026-01-01T00:00:00Z",
145
+ },
146
+ {
147
+ id: "M2",
148
+ title: "Second",
149
+ description: "",
150
+ status: "active",
151
+ order: 2,
152
+ created: "2026-01-01T00:00:00Z",
153
+ updated: "2026-01-01T00:00:00Z",
154
+ },
155
+ {
156
+ id: "M3",
157
+ title: "Third",
158
+ description: "",
159
+ status: "done",
160
+ order: 3,
161
+ created: "2026-01-01T00:00:00Z",
162
+ updated: "2026-01-01T00:00:00Z",
163
+ },
164
+ ],
165
+ created: "2026-01-01T00:00:00Z",
166
+ updated: "2026-01-01T00:00:00Z",
167
+ });
168
+
169
+ await taskCommand(tmpDir, {
170
+ action: "mission",
171
+ sub: "plan-complete",
172
+ args: [],
173
+ values: {},
174
+ json: true,
175
+ });
176
+
177
+ const mission = loadMission(tmpDir)!;
178
+ expect(mission.status).toBe("active");
179
+ expect(mission.milestones.find((m) => m.id === "M1")?.status).toBe("active");
180
+ expect(mission.milestones.find((m) => m.id === "M2")?.status).toBe("locked");
181
+ expect(mission.milestones.find((m) => m.id === "M3")?.status).toBe("locked");
182
+ });
183
+
184
+ it("reports research status as JSON", async () => {
185
+ ensureTasksDir(tmpDir);
186
+ saveTask(
187
+ tmpDir,
188
+ makeTask({
189
+ id: "010",
190
+ title: "Research: mission start",
191
+ status: "done",
192
+ updated: "2026-01-02T00:00:00Z",
193
+ tags: ["research", "mission_start"],
194
+ salientSummary: "Initial audit completed",
195
+ }),
196
+ );
197
+ saveTask(
198
+ tmpDir,
199
+ makeTask({
200
+ id: "011",
201
+ title: "Research: periodic",
202
+ status: "in-progress",
203
+ updated: "2026-01-03T00:00:00Z",
204
+ tags: ["research", "periodic"],
205
+ }),
206
+ );
207
+ saveResearchState(tmpDir, {
208
+ lastResearchAt: { periodic: "2026-01-03T00:00:00Z" },
209
+ missionStartAnalyzed: true,
210
+ milestoneTaskCounts: {},
211
+ activeResearchTaskId: "011",
212
+ retryWindow: [],
213
+ });
214
+
215
+ const logs = (await captureLogs(() =>
216
+ taskCommand(tmpDir, {
217
+ action: "research",
218
+ sub: "status",
219
+ args: [],
220
+ values: {},
221
+ json: true,
222
+ }),
223
+ )) as string[];
224
+ const body = JSON.parse(logs.join("\n")) as {
225
+ activeTask: Task | null;
226
+ recentFindings: Array<{ id: string }>;
227
+ missionStartAnalyzed: boolean;
228
+ };
229
+
230
+ expect(body.activeTask?.id).toBe("011");
231
+ expect(body.recentFindings.map((finding) => finding.id)).toEqual(["010"]);
232
+ expect(body.missionStartAnalyzed).toBe(true);
233
+ });
234
+
235
+ it("manually dispatches research tasks from the CLI handler", async () => {
236
+ ensureTasksDir(tmpDir);
237
+ writeConfig(tmpDir, {
238
+ name: "test-project",
239
+ rows: [],
240
+ orchestrator: {
241
+ enabled: true,
242
+ dispatch_mode: "missions",
243
+ research: { enabled: true },
244
+ },
245
+ });
246
+ mockPanes = [
247
+ {
248
+ id: "%2",
249
+ index: 1,
250
+ title: "Researcher",
251
+ currentCommand: "zsh",
252
+ width: 100,
253
+ height: 30,
254
+ active: false,
255
+ role: "researcher",
256
+ name: null,
257
+ type: null,
258
+ },
259
+ ];
260
+ restorePaneTmux = setPaneExecutor((_cmd: string, args: string[]) => {
261
+ if (args[0] === "list-panes") {
262
+ return mockPanes
263
+ .map(
264
+ (pane) =>
265
+ `${pane.id}\t${pane.index}\t${pane.title}\t${pane.currentCommand}\t${pane.width}\t${pane.height}\t${pane.active ? "1" : "0"}\t${pane.role ?? ""}\t${pane.name ?? ""}\t${pane.type ?? ""}`,
266
+ )
267
+ .join("\n");
268
+ }
269
+ return "";
270
+ });
271
+ restoreSessionTmux = setTmuxExecutor((_cmd, args) => {
272
+ if (args[0] === "has-session" && args[2] === "test-project") {
273
+ return Buffer.from("");
274
+ }
275
+ return Buffer.from("");
276
+ });
277
+
278
+ const logs = (await captureLogs(() =>
279
+ taskCommand(tmpDir, {
280
+ action: "research",
281
+ sub: "trigger",
282
+ args: ["periodic"],
283
+ values: {},
284
+ json: true,
285
+ }),
286
+ )) as string[];
287
+ const task = JSON.parse(logs.join("\n")) as Task;
288
+
289
+ expect(task.tags).toEqual(["research", "periodic"]);
290
+ expect(task.specialty).toBe("researcher");
291
+ expect(loadTask(tmpDir, task.id)?.status).toBe("in-progress");
292
+ });
293
+ });
294
+
295
+ describe("goals", () => {
296
+ it("returns empty array when no goals exist", () => {
297
+ ensureTasksDir(tmpDir);
298
+ expect(loadGoals(tmpDir)).toEqual([]);
299
+ });
300
+
301
+ it("auto-increments goal IDs", () => {
302
+ ensureTasksDir(tmpDir);
303
+ expect(nextGoalId(tmpDir)).toBe("01");
304
+ saveGoal(tmpDir, {
305
+ id: "01",
306
+ title: "First goal",
307
+ description: "",
308
+ status: "todo",
309
+ acceptance: "",
310
+ priority: 1,
311
+ created: "2026-01-01T00:00:00Z",
312
+ updated: "2026-01-01T00:00:00Z",
313
+ assignee: null,
314
+ specialty: null,
315
+ milestone: null,
316
+ });
317
+ expect(nextGoalId(tmpDir)).toBe("02");
318
+ });
319
+
320
+ it("saves with slugified filename", () => {
321
+ ensureTasksDir(tmpDir);
322
+ saveGoal(tmpDir, {
323
+ id: "01",
324
+ title: "Replace Session Storage",
325
+ description: "",
326
+ status: "todo",
327
+ acceptance: "",
328
+ priority: 1,
329
+ created: "2026-01-01T00:00:00Z",
330
+ updated: "2026-01-01T00:00:00Z",
331
+ assignee: null,
332
+ specialty: null,
333
+ milestone: null,
334
+ });
335
+ const files = readdirSync(join(tmpDir, ".tasks", "goals"));
336
+ expect(files.length).toBe(1);
337
+ expect(files[0]!.startsWith("01-")).toBeTruthy();
338
+ expect(files[0]!.includes("replace-session-storage")).toBeTruthy();
339
+ });
340
+
341
+ it("loads a goal by ID", () => {
342
+ ensureTasksDir(tmpDir);
343
+ const goal = {
344
+ id: "01",
345
+ title: "Test Goal",
346
+ description: "desc",
347
+ status: "todo" as const,
348
+ acceptance: "acc",
349
+ priority: 1,
350
+ created: "2026-01-01T00:00:00Z",
351
+ updated: "2026-01-01T00:00:00Z",
352
+ assignee: null,
353
+ specialty: null,
354
+ milestone: null,
355
+ };
356
+ saveGoal(tmpDir, goal);
357
+ expect(loadGoal(tmpDir, "01")).toEqual(goal);
358
+ });
359
+
360
+ it("returns null for non-existent goal", () => {
361
+ ensureTasksDir(tmpDir);
362
+ expect(loadGoal(tmpDir, "99")).toBe(null);
363
+ });
364
+
365
+ it("updates a goal in place", () => {
366
+ ensureTasksDir(tmpDir);
367
+ saveGoal(tmpDir, {
368
+ id: "01",
369
+ title: "Original",
370
+ description: "",
371
+ status: "todo",
372
+ acceptance: "",
373
+ priority: 1,
374
+ created: "2026-01-01T00:00:00Z",
375
+ updated: "2026-01-01T00:00:00Z",
376
+ assignee: null,
377
+ specialty: null,
378
+ milestone: null,
379
+ });
380
+ const goal = loadGoal(tmpDir, "01")!;
381
+ goal.status = "done";
382
+ goal.title = "Updated Title";
383
+ saveGoal(tmpDir, goal);
384
+ const files = readdirSync(join(tmpDir, ".tasks", "goals"));
385
+ expect(files.length).toBe(1); // no duplicate
386
+ expect(loadGoal(tmpDir, "01")!.status).toBe("done");
387
+ });
388
+
389
+ it("deletes a goal", () => {
390
+ ensureTasksDir(tmpDir);
391
+ saveGoal(tmpDir, {
392
+ id: "01",
393
+ title: "To Delete",
394
+ description: "",
395
+ status: "todo",
396
+ acceptance: "",
397
+ priority: 1,
398
+ created: "2026-01-01T00:00:00Z",
399
+ updated: "2026-01-01T00:00:00Z",
400
+ assignee: null,
401
+ specialty: null,
402
+ milestone: null,
403
+ });
404
+ expect(deleteGoal(tmpDir, "01")).toBe(true);
405
+ expect(loadGoal(tmpDir, "01")).toBe(null);
406
+ });
407
+
408
+ it("returns false when deleting non-existent goal", () => {
409
+ ensureTasksDir(tmpDir);
410
+ expect(deleteGoal(tmpDir, "99")).toBe(false);
411
+ });
412
+
413
+ it("lists goals sorted by filename", () => {
414
+ ensureTasksDir(tmpDir);
415
+ const now = "2026-01-01T00:00:00Z";
416
+ saveGoal(tmpDir, {
417
+ id: "02",
418
+ title: "Second",
419
+ description: "",
420
+ status: "todo",
421
+ acceptance: "",
422
+ priority: 2,
423
+ created: now,
424
+ updated: now,
425
+ assignee: null,
426
+ specialty: null,
427
+ milestone: null,
428
+ });
429
+ saveGoal(tmpDir, {
430
+ id: "01",
431
+ title: "First",
432
+ description: "",
433
+ status: "todo",
434
+ acceptance: "",
435
+ priority: 1,
436
+ created: now,
437
+ updated: now,
438
+ assignee: null,
439
+ specialty: null,
440
+ milestone: null,
441
+ });
442
+ const goals = loadGoals(tmpDir);
443
+ expect(goals.length).toBe(2);
444
+ expect(goals[0]!.id).toBe("01");
445
+ expect(goals[1]!.id).toBe("02");
446
+ });
447
+ });
448
+
449
+ describe("tasks", () => {
450
+ it("returns empty array when no tasks exist", () => {
451
+ ensureTasksDir(tmpDir);
452
+ expect(loadTasks(tmpDir)).toEqual([]);
453
+ });
454
+
455
+ it("auto-increments task IDs with 3-digit padding", () => {
456
+ ensureTasksDir(tmpDir);
457
+ expect(nextTaskId(tmpDir)).toBe("001");
458
+ saveTask(tmpDir, {
459
+ id: "001",
460
+ title: "First task",
461
+ description: "",
462
+ goal: null,
463
+ status: "todo",
464
+ assignee: null,
465
+ priority: 1,
466
+ created: "2026-01-01T00:00:00Z",
467
+ updated: "2026-01-01T00:00:00Z",
468
+
469
+ tags: [],
470
+ proof: null,
471
+ retryCount: 0,
472
+ maxRetries: 5,
473
+ lastError: null,
474
+ nextRetryAt: null,
475
+ depends_on: [],
476
+ milestone: null,
477
+ specialty: null,
478
+ fulfills: [],
479
+ discoveredIssues: [],
480
+ salientSummary: null,
481
+ });
482
+ expect(nextTaskId(tmpDir)).toBe("002");
483
+ });
484
+
485
+ it("creates task with all fields including retry", () => {
486
+ ensureTasksDir(tmpDir);
487
+ const task: Task = {
488
+ id: "001",
489
+ title: "Fix auth middleware",
490
+ description: "Token storage issue",
491
+ goal: "01",
492
+ status: "todo",
493
+ assignee: "Agent 1",
494
+ priority: 1,
495
+ created: "2026-01-01T00:00:00Z",
496
+ updated: "2026-01-01T00:00:00Z",
497
+ tags: ["security", "compliance"],
498
+ proof: null,
499
+ retryCount: 2,
500
+ maxRetries: 3,
501
+ lastError: "timeout",
502
+ nextRetryAt: "2026-01-02T00:00:00Z",
503
+ depends_on: [],
504
+ milestone: null,
505
+ specialty: null,
506
+ fulfills: [],
507
+ discoveredIssues: [],
508
+ salientSummary: null,
509
+ };
510
+ saveTask(tmpDir, task);
511
+ expect(loadTask(tmpDir, "001")).toEqual(task);
512
+ });
513
+
514
+ it("loads tasks for a specific goal", () => {
515
+ ensureTasksDir(tmpDir);
516
+ const now = "2026-01-01T00:00:00Z";
517
+ const base = {
518
+ description: "",
519
+ assignee: null,
520
+ priority: 1,
521
+ created: now,
522
+ updated: now,
523
+
524
+ tags: [],
525
+ proof: null,
526
+ retryCount: 0,
527
+ maxRetries: 5,
528
+ lastError: null,
529
+ nextRetryAt: null,
530
+ depends_on: [],
531
+ milestone: null,
532
+ specialty: null,
533
+ fulfills: [],
534
+ discoveredIssues: [],
535
+ salientSummary: null,
536
+ } as const;
537
+ saveTask(tmpDir, { ...base, id: "001", title: "Task for goal 01", goal: "01", status: "todo" });
538
+ saveTask(tmpDir, { ...base, id: "002", title: "Task for goal 02", goal: "02", status: "todo" });
539
+ saveTask(tmpDir, {
540
+ ...base,
541
+ id: "003",
542
+ title: "Another for goal 01",
543
+ goal: "01",
544
+ status: "done",
545
+ priority: 2,
546
+ });
547
+ const goal01Tasks = loadTasksForGoal(tmpDir, "01");
548
+ expect(goal01Tasks.length).toBe(2);
549
+ expect(goal01Tasks.every((t) => t.goal === "01")).toBeTruthy();
550
+ });
551
+
552
+ it("deletes a task", () => {
553
+ ensureTasksDir(tmpDir);
554
+ saveTask(tmpDir, {
555
+ id: "001",
556
+ title: "To Delete",
557
+ description: "",
558
+ goal: null,
559
+ status: "todo",
560
+ assignee: null,
561
+ priority: 1,
562
+ created: "2026-01-01T00:00:00Z",
563
+ updated: "2026-01-01T00:00:00Z",
564
+
565
+ tags: [],
566
+ proof: null,
567
+ retryCount: 0,
568
+ maxRetries: 5,
569
+ lastError: null,
570
+ nextRetryAt: null,
571
+ depends_on: [],
572
+ milestone: null,
573
+ specialty: null,
574
+ fulfills: [],
575
+ discoveredIssues: [],
576
+ salientSummary: null,
577
+ });
578
+ expect(deleteTask(tmpDir, "001")).toBe(true);
579
+ expect(loadTask(tmpDir, "001")).toBe(null);
580
+ });
581
+
582
+ it("returns false when deleting non-existent task", () => {
583
+ ensureTasksDir(tmpDir);
584
+ expect(deleteTask(tmpDir, "999")).toBe(false);
585
+ });
586
+
587
+ it("updates task in place without duplicating file", () => {
588
+ ensureTasksDir(tmpDir);
589
+ saveTask(tmpDir, {
590
+ id: "001",
591
+ title: "Original Title",
592
+ description: "",
593
+ goal: null,
594
+ status: "todo",
595
+ assignee: null,
596
+ priority: 1,
597
+ created: "2026-01-01T00:00:00Z",
598
+ updated: "2026-01-01T00:00:00Z",
599
+
600
+ tags: [],
601
+ proof: null,
602
+ retryCount: 0,
603
+ maxRetries: 5,
604
+ lastError: null,
605
+ nextRetryAt: null,
606
+ depends_on: [],
607
+ milestone: null,
608
+ specialty: null,
609
+ fulfills: [],
610
+ discoveredIssues: [],
611
+ salientSummary: null,
612
+ });
613
+ const task = loadTask(tmpDir, "001")!;
614
+ task.title = "New Title";
615
+ task.status = "in-progress";
616
+ saveTask(tmpDir, task);
617
+ const files = readdirSync(join(tmpDir, ".tasks", "tasks"));
618
+ expect(files.length).toBe(1);
619
+ expect(loadTask(tmpDir, "001")!.title).toBe("New Title");
620
+ });
621
+
622
+ it("applies retry defaults when loading legacy JSON without retry fields", () => {
623
+ ensureTasksDir(tmpDir);
624
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
625
+ // Write a legacy task JSON that has no retry fields
626
+ const legacyTask = {
627
+ id: "001",
628
+ title: "Legacy task",
629
+ description: "",
630
+ goal: null,
631
+ status: "todo",
632
+ assignee: null,
633
+ priority: 1,
634
+ created: "2026-01-01T00:00:00Z",
635
+ updated: "2026-01-01T00:00:00Z",
636
+
637
+ tags: [],
638
+ proof: null,
639
+ };
640
+ writeFileSync(
641
+ join(tasksDir, "001-legacy-task.json"),
642
+ JSON.stringify(legacyTask, null, 2) + "\n",
643
+ );
644
+
645
+ const loaded = loadTask(tmpDir, "001")!;
646
+ expect(loaded.retryCount).toBe(0);
647
+ expect(loaded.maxRetries).toBe(5);
648
+ expect(loaded.lastError).toBe(null);
649
+ expect(loaded.nextRetryAt).toBe(null);
650
+ expect(loaded.depends_on).toEqual([]);
651
+ });
652
+
653
+ it("preserves explicit retry values from JSON", () => {
654
+ ensureTasksDir(tmpDir);
655
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
656
+ const taskWithRetry = {
657
+ id: "002",
658
+ title: "Retried task",
659
+ description: "",
660
+ goal: null,
661
+ status: "in-progress",
662
+ assignee: null,
663
+ priority: 1,
664
+ created: "2026-01-01T00:00:00Z",
665
+ updated: "2026-01-01T00:00:00Z",
666
+
667
+ tags: [],
668
+ proof: null,
669
+ retryCount: 3,
670
+ maxRetries: 10,
671
+ lastError: "Connection refused",
672
+ nextRetryAt: "2026-01-01T01:00:00Z",
673
+ depends_on: ["001"],
674
+ };
675
+ writeFileSync(
676
+ join(tasksDir, "002-retried-task.json"),
677
+ JSON.stringify(taskWithRetry, null, 2) + "\n",
678
+ );
679
+
680
+ const loaded = loadTask(tmpDir, "002")!;
681
+ expect(loaded.retryCount).toBe(3);
682
+ expect(loaded.maxRetries).toBe(10);
683
+ expect(loaded.lastError).toBe("Connection refused");
684
+ expect(loaded.nextRetryAt).toBe("2026-01-01T01:00:00Z");
685
+ });
686
+
687
+ it("saves and loads task with depends_on", () => {
688
+ ensureTasksDir(tmpDir);
689
+ const task = {
690
+ id: "001",
691
+ title: "Depends on others",
692
+ description: "",
693
+ goal: null,
694
+ status: "todo" as const,
695
+ assignee: null,
696
+ priority: 1,
697
+ created: "2026-01-01T00:00:00Z",
698
+ updated: "2026-01-01T00:00:00Z",
699
+
700
+ tags: [],
701
+ proof: null,
702
+ retryCount: 0,
703
+ maxRetries: 5,
704
+ lastError: null,
705
+ nextRetryAt: null,
706
+ depends_on: ["002", "003"],
707
+ };
708
+ saveTask(tmpDir, task);
709
+ const loaded = loadTask(tmpDir, "001")!;
710
+ expect(loaded.depends_on).toEqual(["002", "003"]);
711
+ });
712
+
713
+ it("defaults depends_on to empty array for legacy tasks without the field", () => {
714
+ ensureTasksDir(tmpDir);
715
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
716
+ writeFileSync(
717
+ join(tasksDir, "001-legacy.json"),
718
+ JSON.stringify({
719
+ id: "001",
720
+ title: "No deps field",
721
+ description: "",
722
+ goal: null,
723
+ status: "todo",
724
+ assignee: null,
725
+ priority: 1,
726
+ created: "2026-01-01T00:00:00Z",
727
+ updated: "2026-01-01T00:00:00Z",
728
+
729
+ tags: [],
730
+ proof: null,
731
+ }) + "\n",
732
+ );
733
+ const loaded = loadTask(tmpDir, "001")!;
734
+ expect(loaded.depends_on).toEqual([]);
735
+ });
736
+
737
+ it("loadTasks defaults depends_on for all legacy tasks", () => {
738
+ ensureTasksDir(tmpDir);
739
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
740
+ for (const id of ["001", "002"]) {
741
+ writeFileSync(
742
+ join(tasksDir, `${id}-task.json`),
743
+ JSON.stringify({
744
+ id,
745
+ title: `Task ${id}`,
746
+ description: "",
747
+ goal: null,
748
+ status: "todo",
749
+ assignee: null,
750
+ priority: 1,
751
+ created: "2026-01-01T00:00:00Z",
752
+ updated: "2026-01-01T00:00:00Z",
753
+
754
+ tags: [],
755
+ proof: null,
756
+ }) + "\n",
757
+ );
758
+ }
759
+ const tasks = loadTasks(tmpDir);
760
+ expect(tasks.length).toBe(2);
761
+ for (const t of tasks) {
762
+ expect(t.retryCount).toBe(0);
763
+ expect(t.maxRetries).toBe(5);
764
+ expect(t.lastError).toBe(null);
765
+ expect(t.nextRetryAt).toBe(null);
766
+ expect(t.depends_on).toEqual([]);
767
+ }
768
+ });
769
+ });
770
+
771
+ describe("parseProof", () => {
772
+ it("treats plain string as proof.notes", () => {
773
+ const result = parseProof("all tests pass", null);
774
+ expect(result).toEqual({ notes: "all tests pass" });
775
+ });
776
+
777
+ it("merges plain string into existing proof", () => {
778
+ const existing = { tests: { passed: 10, total: 10 } };
779
+ const result = parseProof("verified manually", existing);
780
+ expect(result).toEqual({
781
+ tests: { passed: 10, total: 10 },
782
+ notes: "verified manually",
783
+ });
784
+ });
785
+
786
+ it("parses JSON with tests field", () => {
787
+ const result = parseProof('{"tests":{"passed":5,"total":6}}', null);
788
+ expect(result).toEqual({ tests: { passed: 5, total: 6 } });
789
+ });
790
+
791
+ it("parses JSON with pr field", () => {
792
+ const result = parseProof(
793
+ '{"pr":{"number":42,"url":"https://github.com/pr/42","status":"merged"}}',
794
+ null,
795
+ );
796
+ expect(result).toEqual({
797
+ pr: { number: 42, url: "https://github.com/pr/42", status: "merged" },
798
+ });
799
+ });
800
+
801
+ it("parses JSON with ci field", () => {
802
+ const result = parseProof('{"ci":{"status":"passing","url":"https://ci/123"}}', null);
803
+ expect(result).toEqual({
804
+ ci: { status: "passing", url: "https://ci/123" },
805
+ });
806
+ });
807
+
808
+ it("parses JSON with notes field", () => {
809
+ const result = parseProof('{"notes":"done"}', null);
810
+ expect(result).toEqual({ notes: "done" });
811
+ });
812
+
813
+ it("parses JSON with all fields", () => {
814
+ const input = JSON.stringify({
815
+ tests: { passed: 10, total: 10 },
816
+ pr: { number: 1 },
817
+ ci: { status: "green" },
818
+ notes: "ship it",
819
+ });
820
+ const result = parseProof(input, null);
821
+ expect(result).toEqual({
822
+ tests: { passed: 10, total: 10 },
823
+ pr: { number: 1 },
824
+ ci: { status: "green" },
825
+ notes: "ship it",
826
+ });
827
+ });
828
+
829
+ it("merges JSON proof into existing proof", () => {
830
+ const existing = { notes: "old note", tests: { passed: 3, total: 5 } };
831
+ const result = parseProof('{"tests":{"passed":5,"total":5}}', existing);
832
+ expect(result).toEqual({
833
+ notes: "old note",
834
+ tests: { passed: 5, total: 5 },
835
+ });
836
+ });
837
+
838
+ it("falls back to notes for invalid JSON starting with {", () => {
839
+ const result = parseProof("{not valid json", null);
840
+ expect(result).toEqual({ notes: "{not valid json" });
841
+ });
842
+
843
+ it("ignores invalid tests shape in JSON", () => {
844
+ const result = parseProof('{"tests":"not an object","notes":"ok"}', null);
845
+ expect(result).toEqual({ notes: "ok" });
846
+ });
847
+
848
+ it("saves and loads proof schema through task store", () => {
849
+ ensureTasksDir(tmpDir);
850
+ const task: Task = {
851
+ id: "001",
852
+ title: "With proof",
853
+ description: "",
854
+ goal: null,
855
+ status: "done",
856
+ assignee: null,
857
+ priority: 1,
858
+ created: "2026-01-01T00:00:00Z",
859
+ updated: "2026-01-01T00:00:00Z",
860
+
861
+ tags: [],
862
+ proof: {
863
+ tests: { passed: 10, total: 10 },
864
+ pr: { number: 42, url: "https://github.com/pr/42" },
865
+ ci: { status: "passing" },
866
+ notes: "all good",
867
+ },
868
+ retryCount: 0,
869
+ maxRetries: 5,
870
+ lastError: null,
871
+ nextRetryAt: null,
872
+ depends_on: [],
873
+ milestone: null,
874
+ specialty: null,
875
+ fulfills: [],
876
+ discoveredIssues: [],
877
+ salientSummary: null,
878
+ };
879
+ saveTask(tmpDir, task);
880
+ const loaded = loadTask(tmpDir, "001")!;
881
+ expect(loaded.proof).toEqual(task.proof);
882
+ });
883
+
884
+ it("loads legacy Record<string,string> proof from JSON without error", () => {
885
+ ensureTasksDir(tmpDir);
886
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
887
+ writeFileSync(
888
+ join(tasksDir, "001-legacy-proof.json"),
889
+ JSON.stringify({
890
+ id: "001",
891
+ title: "Legacy proof",
892
+ description: "",
893
+ goal: null,
894
+ status: "done",
895
+ assignee: null,
896
+ priority: 1,
897
+ created: "2026-01-01T00:00:00Z",
898
+ updated: "2026-01-01T00:00:00Z",
899
+
900
+ tags: [],
901
+ proof: { note: "old format proof" },
902
+ }) + "\n",
903
+ );
904
+ const loaded = loadTask(tmpDir, "001")!;
905
+ expect(loaded.proof !== null).toBeTruthy();
906
+ expect(loaded.proof!.notes).toBe("old format proof");
907
+ });
908
+ });
909
+
910
+ describe("normalizer snapshots", () => {
911
+ it("normalizeTask produces exact default shape", () => {
912
+ const raw = {
913
+ id: "001",
914
+ title: "Test",
915
+ description: "desc",
916
+ goal: null,
917
+ status: "todo",
918
+ assignee: null,
919
+ priority: 1,
920
+ created: "2026-01-01T00:00:00Z",
921
+ updated: "2026-01-01T00:00:00Z",
922
+
923
+ tags: [],
924
+ proof: null,
925
+ };
926
+ const result = normalizeTask(raw as Record<string, unknown>);
927
+ expect(result).toEqual({
928
+ id: "001",
929
+ title: "Test",
930
+ description: "desc",
931
+ goal: null,
932
+ status: "todo",
933
+ assignee: null,
934
+ priority: 1,
935
+ created: "2026-01-01T00:00:00Z",
936
+ updated: "2026-01-01T00:00:00Z",
937
+
938
+ tags: [],
939
+ proof: null,
940
+ retryCount: 0,
941
+ maxRetries: 5,
942
+ lastError: null,
943
+ nextRetryAt: null,
944
+ depends_on: [],
945
+ milestone: null,
946
+ specialty: null,
947
+ fulfills: [],
948
+ discoveredIssues: [],
949
+ salientSummary: null,
950
+ });
951
+ });
952
+
953
+ it("normalizeTask migrates proof.note to proof.notes", () => {
954
+ const raw = {
955
+ id: "001",
956
+ title: "Test",
957
+ description: "",
958
+ goal: null,
959
+ status: "todo",
960
+ assignee: null,
961
+ priority: 1,
962
+ created: "2026-01-01T00:00:00Z",
963
+ updated: "2026-01-01T00:00:00Z",
964
+
965
+ tags: [],
966
+ proof: { note: "legacy note" },
967
+ };
968
+ const result = normalizeTask(raw as Record<string, unknown>);
969
+ expect(result.proof?.notes).toBe("legacy note");
970
+ expect((result.proof as Record<string, unknown>).note).toBe(undefined);
971
+ });
972
+
973
+ it("normalizeGoal defaults missing fields", () => {
974
+ const raw = {
975
+ id: "01",
976
+ title: "G",
977
+ description: "",
978
+ status: "todo",
979
+ acceptance: "",
980
+ priority: 1,
981
+ };
982
+ const result = normalizeGoal(raw as Record<string, unknown>);
983
+ expect(result.assignee).toBe(null);
984
+ expect(result.specialty).toBe(null);
985
+ expect(result.milestone).toBe(null);
986
+ expect(result.created).toBe("1970-01-01T00:00:00.000Z");
987
+ expect(result.updated).toBe("1970-01-01T00:00:00.000Z");
988
+ });
989
+
990
+ it("normalizeMission defaults missing timestamps", () => {
991
+ const raw = { title: "M", description: "desc" };
992
+ const result = normalizeMission(raw as Record<string, unknown>);
993
+ expect(result).toEqual({
994
+ title: "M",
995
+ description: "desc",
996
+ status: "active",
997
+ branch: null,
998
+ milestones: [],
999
+ created: "1970-01-01T00:00:00.000Z",
1000
+ updated: "1970-01-01T00:00:00.000Z",
1001
+ });
1002
+ });
1003
+
1004
+ it("normalizeTask preserves explicit retry values", () => {
1005
+ const raw = {
1006
+ id: "001",
1007
+ title: "Test",
1008
+ description: "",
1009
+ goal: null,
1010
+ status: "todo",
1011
+ assignee: null,
1012
+ priority: 1,
1013
+ created: "2026-01-01T00:00:00Z",
1014
+ updated: "2026-01-01T00:00:00Z",
1015
+
1016
+ tags: [],
1017
+ proof: null,
1018
+ retryCount: 3,
1019
+ maxRetries: 10,
1020
+ lastError: "timeout",
1021
+ nextRetryAt: "2026-01-02T00:00:00Z",
1022
+ depends_on: ["002"],
1023
+ };
1024
+ const result = normalizeTask(raw as Record<string, unknown>);
1025
+ expect(result.retryCount).toBe(3);
1026
+ expect(result.maxRetries).toBe(10);
1027
+ expect(result.lastError).toBe("timeout");
1028
+ expect(result.nextRetryAt).toBe("2026-01-02T00:00:00Z");
1029
+ expect(result.depends_on).toEqual(["002"]);
1030
+ });
1031
+ });
1032
+
1033
+ describe("schema version", () => {
1034
+ it("saveTask writes _version: 1 to JSON but loadTask strips it", () => {
1035
+ ensureTasksDir(tmpDir);
1036
+ const task = {
1037
+ id: "001",
1038
+ title: "Versioned task",
1039
+ description: "",
1040
+ goal: null,
1041
+ status: "todo" as const,
1042
+ assignee: null,
1043
+ priority: 1,
1044
+ created: "2026-01-01T00:00:00Z",
1045
+ updated: "2026-01-01T00:00:00Z",
1046
+
1047
+ tags: [],
1048
+ proof: null,
1049
+ retryCount: 0,
1050
+ maxRetries: 5,
1051
+ lastError: null,
1052
+ nextRetryAt: null,
1053
+ depends_on: [],
1054
+ milestone: null,
1055
+ specialty: null,
1056
+ fulfills: [],
1057
+ discoveredIssues: [],
1058
+ salientSummary: null,
1059
+ };
1060
+ saveTask(tmpDir, task);
1061
+
1062
+ // Raw JSON should contain _version
1063
+ const files = readdirSync(join(tmpDir, ".tasks", "tasks"));
1064
+ const raw = JSON.parse(readFileSync(join(tmpDir, ".tasks", "tasks", files[0]!), "utf-8"));
1065
+ expect(raw._version).toBe(1);
1066
+
1067
+ // Loaded domain object should NOT contain _version
1068
+ const loaded = loadTask(tmpDir, "001")!;
1069
+ expect("_version" in loaded).toBe(false);
1070
+ expect(loaded).toEqual(task);
1071
+ });
1072
+
1073
+ it("saveGoal writes _version: 1 to JSON but loadGoal strips it", () => {
1074
+ ensureTasksDir(tmpDir);
1075
+ const goal = {
1076
+ id: "01",
1077
+ title: "Versioned goal",
1078
+ description: "",
1079
+ status: "todo" as const,
1080
+ acceptance: "",
1081
+ priority: 1,
1082
+ created: "2026-01-01T00:00:00Z",
1083
+ updated: "2026-01-01T00:00:00Z",
1084
+ assignee: null,
1085
+ specialty: null,
1086
+ milestone: null,
1087
+ };
1088
+ saveGoal(tmpDir, goal);
1089
+
1090
+ const files = readdirSync(join(tmpDir, ".tasks", "goals"));
1091
+ const raw = JSON.parse(readFileSync(join(tmpDir, ".tasks", "goals", files[0]!), "utf-8"));
1092
+ expect(raw._version).toBe(1);
1093
+
1094
+ const loaded = loadGoal(tmpDir, "01")!;
1095
+ expect("_version" in loaded).toBe(false);
1096
+ expect(loaded).toEqual(goal);
1097
+ });
1098
+
1099
+ it("saveMission writes _version: 1 to JSON but loadMission strips it", () => {
1100
+ const mission = {
1101
+ title: "Versioned mission",
1102
+ description: "desc",
1103
+ status: "active" as const,
1104
+ branch: null,
1105
+ milestones: [],
1106
+ created: "2026-01-01T00:00:00Z",
1107
+ updated: "2026-01-01T00:00:00Z",
1108
+ };
1109
+ saveMission(tmpDir, mission);
1110
+
1111
+ const raw = JSON.parse(readFileSync(join(tmpDir, ".tasks", "mission.json"), "utf-8"));
1112
+ expect(raw._version).toBe(1);
1113
+
1114
+ const loaded = loadMission(tmpDir)!;
1115
+ expect("_version" in loaded).toBe(false);
1116
+ expect(loaded).toEqual(mission);
1117
+ });
1118
+
1119
+ it("loads legacy files without _version field (version 0)", () => {
1120
+ ensureTasksDir(tmpDir);
1121
+ // Write a task with no _version (pre-versioning format)
1122
+ writeFileSync(
1123
+ join(tmpDir, ".tasks", "tasks", "001-legacy.json"),
1124
+ JSON.stringify({
1125
+ id: "001",
1126
+ title: "Legacy",
1127
+ description: "",
1128
+ goal: null,
1129
+ status: "todo",
1130
+ assignee: null,
1131
+ priority: 1,
1132
+ created: "2026-01-01T00:00:00Z",
1133
+ updated: "2026-01-01T00:00:00Z",
1134
+
1135
+ tags: [],
1136
+ proof: null,
1137
+ }) + "\n",
1138
+ );
1139
+ const loaded = loadTask(tmpDir, "001")!;
1140
+ expect(loaded.id).toBe("001");
1141
+ expect("_version" in loaded).toBe(false);
1142
+ });
1143
+ });
1144
+
1145
+ describe("corrupted file resilience", () => {
1146
+ it("loadTasks skips corrupted files and returns valid tasks", () => {
1147
+ ensureTasksDir(tmpDir);
1148
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
1149
+ // Write one valid task
1150
+ writeFileSync(
1151
+ join(tasksDir, "001-valid.json"),
1152
+ JSON.stringify({
1153
+ id: "001",
1154
+ title: "Valid task",
1155
+ description: "",
1156
+ goal: null,
1157
+ status: "todo",
1158
+ assignee: null,
1159
+ priority: 1,
1160
+ created: "2026-01-01T00:00:00Z",
1161
+ updated: "2026-01-01T00:00:00Z",
1162
+
1163
+ tags: [],
1164
+ proof: null,
1165
+ }) + "\n",
1166
+ );
1167
+ // Write a corrupted file
1168
+ writeFileSync(join(tasksDir, "002-broken.json"), "{ not valid json !!!");
1169
+
1170
+ const tasks = loadTasks(tmpDir);
1171
+ expect(tasks.length).toBe(1);
1172
+ expect(tasks[0]!.id).toBe("001");
1173
+ });
1174
+
1175
+ it("loadTask returns null for corrupted file", () => {
1176
+ ensureTasksDir(tmpDir);
1177
+ writeFileSync(join(tmpDir, ".tasks", "tasks", "001-broken.json"), "corrupt");
1178
+ const result = loadTask(tmpDir, "001");
1179
+ expect(result).toBe(null);
1180
+ });
1181
+
1182
+ it("loadGoals skips corrupted files and returns valid goals", () => {
1183
+ ensureTasksDir(tmpDir);
1184
+ const goalsDir = join(tmpDir, ".tasks", "goals");
1185
+ writeFileSync(
1186
+ join(goalsDir, "01-valid.json"),
1187
+ JSON.stringify({
1188
+ id: "01",
1189
+ title: "Valid goal",
1190
+ description: "",
1191
+ status: "todo",
1192
+ acceptance: "",
1193
+ priority: 1,
1194
+ }) + "\n",
1195
+ );
1196
+ writeFileSync(join(goalsDir, "02-broken.json"), "{{bad json");
1197
+
1198
+ const goals = loadGoals(tmpDir);
1199
+ expect(goals.length).toBe(1);
1200
+ expect(goals[0]!.id).toBe("01");
1201
+ });
1202
+
1203
+ it("loadGoal returns null for corrupted file", () => {
1204
+ ensureTasksDir(tmpDir);
1205
+ writeFileSync(join(tmpDir, ".tasks", "goals", "01-broken.json"), "nope");
1206
+ const result = loadGoal(tmpDir, "01");
1207
+ expect(result).toBe(null);
1208
+ });
1209
+
1210
+ it("loadMission returns null for corrupted file", () => {
1211
+ ensureTasksDir(tmpDir);
1212
+ writeFileSync(join(tmpDir, ".tasks", "mission.json"), "not json");
1213
+ const result = loadMission(tmpDir);
1214
+ expect(result).toBe(null);
1215
+ });
1216
+ });
1217
+
1218
+ describe("corrupted file resilience", () => {
1219
+ it("loadTasks skips corrupted files and returns valid tasks", () => {
1220
+ ensureTasksDir(tmpDir);
1221
+ const tasksDir = join(tmpDir, ".tasks", "tasks");
1222
+ // Write one valid task
1223
+ writeFileSync(
1224
+ join(tasksDir, "001-valid.json"),
1225
+ JSON.stringify({
1226
+ id: "001",
1227
+ title: "Valid task",
1228
+ description: "",
1229
+ goal: null,
1230
+ status: "todo",
1231
+ assignee: null,
1232
+ priority: 1,
1233
+ created: "2026-01-01T00:00:00Z",
1234
+ updated: "2026-01-01T00:00:00Z",
1235
+
1236
+ tags: [],
1237
+ proof: null,
1238
+ }) + "\n",
1239
+ );
1240
+ // Write a corrupted file
1241
+ writeFileSync(join(tasksDir, "002-broken.json"), "{ not valid json !!!");
1242
+
1243
+ const tasks = loadTasks(tmpDir);
1244
+ assert.strictEqual(tasks.length, 1);
1245
+ assert.strictEqual(tasks[0]!.id, "001");
1246
+ });
1247
+
1248
+ it("loadTask returns null for corrupted file", () => {
1249
+ ensureTasksDir(tmpDir);
1250
+ writeFileSync(join(tmpDir, ".tasks", "tasks", "001-broken.json"), "corrupt");
1251
+ const result = loadTask(tmpDir, "001");
1252
+ assert.strictEqual(result, null);
1253
+ });
1254
+
1255
+ it("loadGoals skips corrupted files and returns valid goals", () => {
1256
+ ensureTasksDir(tmpDir);
1257
+ const goalsDir = join(tmpDir, ".tasks", "goals");
1258
+ writeFileSync(
1259
+ join(goalsDir, "01-valid.json"),
1260
+ JSON.stringify({
1261
+ id: "01",
1262
+ title: "Valid goal",
1263
+ description: "",
1264
+ status: "todo",
1265
+ acceptance: "",
1266
+ priority: 1,
1267
+ }) + "\n",
1268
+ );
1269
+ writeFileSync(join(goalsDir, "02-broken.json"), "{{bad json");
1270
+
1271
+ const goals = loadGoals(tmpDir);
1272
+ assert.strictEqual(goals.length, 1);
1273
+ assert.strictEqual(goals[0]!.id, "01");
1274
+ });
1275
+
1276
+ it("loadGoal returns null for corrupted file", () => {
1277
+ ensureTasksDir(tmpDir);
1278
+ writeFileSync(join(tmpDir, ".tasks", "goals", "01-broken.json"), "nope");
1279
+ const result = loadGoal(tmpDir, "01");
1280
+ assert.strictEqual(result, null);
1281
+ });
1282
+
1283
+ it("loadMission returns null for corrupted file", () => {
1284
+ ensureTasksDir(tmpDir);
1285
+ writeFileSync(join(tmpDir, ".tasks", "mission.json"), "not json");
1286
+ const result = loadMission(tmpDir);
1287
+ assert.strictEqual(result, null);
1288
+ });
1289
+ });
1290
+
1291
+ describe("normalizer snapshots", () => {
1292
+ it("normalizeTask produces exact default shape", () => {
1293
+ const raw = {
1294
+ id: "001",
1295
+ title: "Test",
1296
+ description: "desc",
1297
+ goal: null,
1298
+ status: "todo",
1299
+ assignee: null,
1300
+ priority: 1,
1301
+ created: "2026-01-01T00:00:00Z",
1302
+ updated: "2026-01-01T00:00:00Z",
1303
+
1304
+ tags: [],
1305
+ proof: null,
1306
+ };
1307
+ const result = normalizeTask(raw as Record<string, unknown>);
1308
+ assert.deepStrictEqual(result, {
1309
+ id: "001",
1310
+ title: "Test",
1311
+ description: "desc",
1312
+ goal: null,
1313
+ status: "todo",
1314
+ assignee: null,
1315
+ priority: 1,
1316
+ created: "2026-01-01T00:00:00Z",
1317
+ updated: "2026-01-01T00:00:00Z",
1318
+
1319
+ tags: [],
1320
+ proof: null,
1321
+ retryCount: 0,
1322
+ maxRetries: 5,
1323
+ lastError: null,
1324
+ nextRetryAt: null,
1325
+ depends_on: [],
1326
+ milestone: null,
1327
+ specialty: null,
1328
+ fulfills: [],
1329
+ discoveredIssues: [],
1330
+ salientSummary: null,
1331
+ });
1332
+ });
1333
+
1334
+ it("normalizeTask migrates proof.note to proof.notes", () => {
1335
+ const raw = {
1336
+ id: "001",
1337
+ title: "Test",
1338
+ description: "",
1339
+ goal: null,
1340
+ status: "todo",
1341
+ assignee: null,
1342
+ priority: 1,
1343
+ created: "2026-01-01T00:00:00Z",
1344
+ updated: "2026-01-01T00:00:00Z",
1345
+
1346
+ tags: [],
1347
+ proof: { note: "legacy note" },
1348
+ };
1349
+ const result = normalizeTask(raw as Record<string, unknown>);
1350
+ assert.strictEqual(result.proof?.notes, "legacy note");
1351
+ assert.strictEqual((result.proof as Record<string, unknown>).note, undefined);
1352
+ });
1353
+
1354
+ it("normalizeGoal defaults missing fields", () => {
1355
+ const raw = {
1356
+ id: "01",
1357
+ title: "G",
1358
+ description: "",
1359
+ status: "todo",
1360
+ acceptance: "",
1361
+ priority: 1,
1362
+ };
1363
+ const result = normalizeGoal(raw as Record<string, unknown>);
1364
+ assert.strictEqual(result.assignee, null);
1365
+ assert.strictEqual(result.specialty, null);
1366
+ assert.strictEqual(result.milestone, null);
1367
+ assert.strictEqual(result.created, "1970-01-01T00:00:00.000Z");
1368
+ assert.strictEqual(result.updated, "1970-01-01T00:00:00.000Z");
1369
+ });
1370
+
1371
+ it("normalizeMission defaults missing timestamps", () => {
1372
+ const raw = { title: "M", description: "desc" };
1373
+ const result = normalizeMission(raw as Record<string, unknown>);
1374
+ assert.deepStrictEqual(result, {
1375
+ title: "M",
1376
+ description: "desc",
1377
+ status: "active",
1378
+ branch: null,
1379
+ milestones: [],
1380
+ created: "1970-01-01T00:00:00.000Z",
1381
+ updated: "1970-01-01T00:00:00.000Z",
1382
+ });
1383
+ });
1384
+
1385
+ it("normalizeTask preserves explicit retry values", () => {
1386
+ const raw = {
1387
+ id: "001",
1388
+ title: "Test",
1389
+ description: "",
1390
+ goal: null,
1391
+ status: "todo",
1392
+ assignee: null,
1393
+ priority: 1,
1394
+ created: "2026-01-01T00:00:00Z",
1395
+ updated: "2026-01-01T00:00:00Z",
1396
+
1397
+ tags: [],
1398
+ proof: null,
1399
+ retryCount: 3,
1400
+ maxRetries: 10,
1401
+ lastError: "timeout",
1402
+ nextRetryAt: "2026-01-02T00:00:00Z",
1403
+ depends_on: ["002"],
1404
+ };
1405
+ const result = normalizeTask(raw as Record<string, unknown>);
1406
+ assert.strictEqual(result.retryCount, 3);
1407
+ assert.strictEqual(result.maxRetries, 10);
1408
+ assert.strictEqual(result.lastError, "timeout");
1409
+ assert.strictEqual(result.nextRetryAt, "2026-01-02T00:00:00Z");
1410
+ assert.deepStrictEqual(result.depends_on, ["002"]);
1411
+ });
1412
+ });
1413
+
1414
+ describe("detectCycle", () => {
1415
+ function makeMinimalTask(id: string, deps: string[] = []): Task {
1416
+ const now = new Date().toISOString();
1417
+ return {
1418
+ id,
1419
+ title: `Task ${id}`,
1420
+ description: "",
1421
+ goal: null,
1422
+ status: "todo",
1423
+ assignee: null,
1424
+ priority: 2,
1425
+ created: now,
1426
+ updated: now,
1427
+
1428
+ tags: [],
1429
+ proof: null,
1430
+ depends_on: deps,
1431
+ retryCount: 0,
1432
+ maxRetries: 5,
1433
+ lastError: null,
1434
+ nextRetryAt: null,
1435
+ };
1436
+ }
1437
+
1438
+ it("detects A -> B -> A cycle", () => {
1439
+ ensureTasksDir(tmpDir);
1440
+ saveTask(tmpDir, makeMinimalTask("001", ["002"]));
1441
+ saveTask(tmpDir, makeMinimalTask("002"));
1442
+
1443
+ const cycle = detectCycle(tmpDir, "002", ["001"]);
1444
+ expect(cycle).toEqual(["002", "001", "002"]);
1445
+ });
1446
+
1447
+ it("detects A -> B -> C -> A cycle", () => {
1448
+ ensureTasksDir(tmpDir);
1449
+ saveTask(tmpDir, makeMinimalTask("001", ["002"]));
1450
+ saveTask(tmpDir, makeMinimalTask("002", ["003"]));
1451
+ saveTask(tmpDir, makeMinimalTask("003"));
1452
+
1453
+ const cycle = detectCycle(tmpDir, "003", ["001"]);
1454
+ expect(cycle).toEqual(["003", "001", "002", "003"]);
1455
+ });
1456
+
1457
+ it("returns null for valid DAG", () => {
1458
+ ensureTasksDir(tmpDir);
1459
+ saveTask(tmpDir, makeMinimalTask("001"));
1460
+ saveTask(tmpDir, makeMinimalTask("002", ["001"]));
1461
+ saveTask(tmpDir, makeMinimalTask("003"));
1462
+
1463
+ const cycle = detectCycle(tmpDir, "003", ["001"]);
1464
+ expect(cycle).toBeNull();
1465
+ });
1466
+ });