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,134 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadSkills, loadSkill } from "./skill-registry.ts";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-skill-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("loadSkills", () => {
18
+ it("returns empty array when skills dir does not exist", () => {
19
+ expect(loadSkills(tmpDir)).toEqual([]);
20
+ });
21
+
22
+ it("loads skill from markdown file with YAML frontmatter", () => {
23
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
24
+ mkdirSync(skillsDir, { recursive: true });
25
+ writeFileSync(
26
+ join(skillsDir, "frontend.md"),
27
+ `---
28
+ name: frontend
29
+ specialties: [react, css, typescript]
30
+ role: teammate
31
+ description: Frontend component specialist
32
+ ---
33
+ You are a frontend developer. Focus on React components and CSS.
34
+ `,
35
+ );
36
+
37
+ const skills = loadSkills(tmpDir);
38
+ expect(skills.length).toBe(1);
39
+ expect(skills[0]!.name).toBe("frontend");
40
+ expect(skills[0]!.specialties).toEqual(["react", "css", "typescript"]);
41
+ expect(skills[0]!.role).toBe("teammate");
42
+ expect(skills[0]!.description).toBe("Frontend component specialist");
43
+ expect(skills[0]!.body).toBe(
44
+ "You are a frontend developer. Focus on React components and CSS.",
45
+ );
46
+ });
47
+
48
+ it("loads multiple skills", () => {
49
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
50
+ mkdirSync(skillsDir, { recursive: true });
51
+ writeFileSync(
52
+ join(skillsDir, "backend.md"),
53
+ `---
54
+ name: backend
55
+ specialties: api, database
56
+ role: teammate
57
+ description: Backend API specialist
58
+ ---
59
+ Backend instructions.
60
+ `,
61
+ );
62
+ writeFileSync(
63
+ join(skillsDir, "qa.md"),
64
+ `---
65
+ name: qa
66
+ role: validator
67
+ description: QA validator
68
+ ---
69
+ QA instructions.
70
+ `,
71
+ );
72
+
73
+ const skills = loadSkills(tmpDir);
74
+ expect(skills.length).toBe(2);
75
+ });
76
+
77
+ it("parses comma-separated specialties string", () => {
78
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
79
+ mkdirSync(skillsDir, { recursive: true });
80
+ writeFileSync(
81
+ join(skillsDir, "test.md"),
82
+ `---
83
+ name: test
84
+ specialties: api, database, auth
85
+ ---
86
+ Body.
87
+ `,
88
+ );
89
+
90
+ const skills = loadSkills(tmpDir);
91
+ expect(skills[0]!.specialties).toEqual(["api", "database", "auth"]);
92
+ });
93
+
94
+ it("skips files without valid frontmatter", () => {
95
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
96
+ mkdirSync(skillsDir, { recursive: true });
97
+ writeFileSync(join(skillsDir, "bad.md"), "No frontmatter here");
98
+
99
+ expect(loadSkills(tmpDir)).toEqual([]);
100
+ });
101
+
102
+ it("skips non-md files", () => {
103
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
104
+ mkdirSync(skillsDir, { recursive: true });
105
+ writeFileSync(join(skillsDir, "readme.txt"), "not a skill");
106
+
107
+ expect(loadSkills(tmpDir)).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe("loadSkill", () => {
112
+ it("returns skill by name", () => {
113
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
114
+ mkdirSync(skillsDir, { recursive: true });
115
+ writeFileSync(
116
+ join(skillsDir, "test.md"),
117
+ `---
118
+ name: test-skill
119
+ role: teammate
120
+ description: A test skill
121
+ ---
122
+ Instructions.
123
+ `,
124
+ );
125
+
126
+ const skill = loadSkill(tmpDir, "test-skill");
127
+ expect(skill).not.toBeNull();
128
+ expect(skill!.name).toBe("test-skill");
129
+ });
130
+
131
+ it("returns null for unknown skill", () => {
132
+ expect(loadSkill(tmpDir, "nonexistent")).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,86 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import yaml from "js-yaml";
5
+
6
+ export interface Skill {
7
+ name: string;
8
+ specialties: string[];
9
+ role: string;
10
+ description: string;
11
+ body: string;
12
+ }
13
+
14
+ const SKILLS_DIR = ".tmux-ide/skills";
15
+
16
+ /**
17
+ * Parse a skill markdown file with YAML frontmatter.
18
+ * Format: --- \n YAML \n --- \n body
19
+ */
20
+ function parseSkillFile(content: string): Skill | null {
21
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
22
+ if (!match) return null;
23
+
24
+ try {
25
+ const meta = yaml.load(match[1]!) as Record<string, unknown>;
26
+ const name = (meta.name as string) ?? "";
27
+ if (!name) return null;
28
+
29
+ const rawSpecialties = meta.specialties;
30
+ const specialties = Array.isArray(rawSpecialties)
31
+ ? (rawSpecialties as string[]).map((s) => String(s).toLowerCase())
32
+ : typeof rawSpecialties === "string"
33
+ ? rawSpecialties.split(",").map((s) => s.trim().toLowerCase())
34
+ : [];
35
+
36
+ return {
37
+ name,
38
+ specialties,
39
+ role: (meta.role as string) ?? "teammate",
40
+ description: (meta.description as string) ?? "",
41
+ body: (match[2] ?? "").trim(),
42
+ };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function loadSkillsFromDir(skillsDir: string): Skill[] {
49
+ if (!existsSync(skillsDir)) return [];
50
+ const files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
51
+ const skills: Skill[] = [];
52
+ for (const file of files) {
53
+ const content = readFileSync(join(skillsDir, file), "utf-8");
54
+ const skill = parseSkillFile(content);
55
+ if (skill) skills.push(skill);
56
+ }
57
+ return skills;
58
+ }
59
+
60
+ /**
61
+ * Load all skill definitions from .tmux-ide/skills/*.md (project)
62
+ * and ~/.tmux-ide/skills/*.md (personal). Project skills take precedence.
63
+ */
64
+ export function loadSkills(dir: string): Skill[] {
65
+ const projectSkills = loadSkillsFromDir(join(dir, SKILLS_DIR));
66
+ const personalDir = join(homedir(), SKILLS_DIR);
67
+ const personalSkills = loadSkillsFromDir(personalDir);
68
+
69
+ // Project skills take precedence over personal skills by name
70
+ const nameSet = new Set(projectSkills.map((s) => s.name));
71
+ for (const ps of personalSkills) {
72
+ if (!nameSet.has(ps.name)) {
73
+ projectSkills.push(ps);
74
+ }
75
+ }
76
+
77
+ return projectSkills;
78
+ }
79
+
80
+ /**
81
+ * Load a single skill by name.
82
+ */
83
+ export function loadSkill(dir: string, name: string): Skill | null {
84
+ const skills = loadSkills(dir);
85
+ return skills.find((s) => s.name === name) ?? null;
86
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { slugify } from "./slugify.ts";
3
+
4
+ describe("slugify", () => {
5
+ it("lowercases and replaces non-alphanumeric with hyphens", () => {
6
+ expect(slugify("Hello World")).toBe("hello-world");
7
+ });
8
+
9
+ it("collapses multiple non-alphanumeric chars into one hyphen", () => {
10
+ expect(slugify("foo---bar baz")).toBe("foo-bar-baz");
11
+ });
12
+
13
+ it("trims leading and trailing hyphens", () => {
14
+ expect(slugify("--hello--")).toBe("hello");
15
+ });
16
+
17
+ it("defaults to maxLen 40", () => {
18
+ const long = "a".repeat(60);
19
+ expect(slugify(long)).toHaveLength(40);
20
+ });
21
+
22
+ it("respects custom maxLen", () => {
23
+ const long = "a".repeat(60);
24
+ expect(slugify(long, 50)).toHaveLength(50);
25
+ expect(slugify(long, 30)).toHaveLength(30);
26
+ });
27
+
28
+ it("handles empty string", () => {
29
+ expect(slugify("")).toBe("");
30
+ });
31
+
32
+ it("handles special characters", () => {
33
+ expect(slugify("feat: add @auth/login!")).toBe("feat-add-auth-login");
34
+ });
35
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Convert text to a URL/filename-safe slug.
3
+ */
4
+ export function slugify(text: string, maxLen = 40): string {
5
+ return text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/^-|-$/g, "")
9
+ .slice(0, maxLen);
10
+ }
@@ -0,0 +1,352 @@
1
+ import { resolve, join } from "node:path";
2
+ import { slugify } from "./slugify.ts";
3
+ import {
4
+ readdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ mkdirSync,
8
+ existsSync,
9
+ unlinkSync,
10
+ renameSync,
11
+ } from "node:fs";
12
+ import type { ProofSchema } from "../types.ts";
13
+
14
+ const TASKS_DIR = ".tasks";
15
+ const SCHEMA_VERSION = 1;
16
+
17
+ /**
18
+ * Write JSON data to a file atomically.
19
+ * Writes to a temp file first, then renames (atomic on POSIX).
20
+ * This prevents data loss if the process crashes mid-write.
21
+ */
22
+ function atomicWriteJSON(filePath: string, data: unknown): void {
23
+ const tmpPath = filePath + ".tmp";
24
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
25
+ renameSync(tmpPath, filePath);
26
+ }
27
+
28
+ export interface Milestone {
29
+ id: string;
30
+ title: string;
31
+ description: string;
32
+ status: "locked" | "active" | "done" | "validating";
33
+ order: number;
34
+ created: string;
35
+ updated: string;
36
+ }
37
+
38
+ export interface Mission {
39
+ title: string;
40
+ description: string;
41
+ status: "planning" | "active" | "validating" | "complete";
42
+ branch: string | null;
43
+ milestones: Milestone[];
44
+ created: string;
45
+ updated: string;
46
+ }
47
+
48
+ export interface Goal {
49
+ id: string;
50
+ title: string;
51
+ description: string;
52
+ status: "todo" | "in-progress" | "done";
53
+ acceptance: string;
54
+ priority: number;
55
+ created: string;
56
+ updated: string;
57
+ assignee: string | null;
58
+ specialty: string | null;
59
+ milestone: string | null;
60
+ }
61
+
62
+ export interface Task {
63
+ id: string;
64
+ title: string;
65
+ description: string;
66
+ goal: string | null;
67
+ status: "todo" | "in-progress" | "review" | "done";
68
+ assignee: string | null;
69
+ priority: number;
70
+ created: string;
71
+ updated: string;
72
+ tags: string[];
73
+ proof: ProofSchema | null;
74
+ retryCount: number;
75
+ maxRetries: number;
76
+ lastError: string | null;
77
+ nextRetryAt: string | null;
78
+ depends_on: string[];
79
+ milestone: string | null;
80
+ specialty: string | null;
81
+ fulfills: string[];
82
+ discoveredIssues: string[];
83
+ salientSummary: string | null;
84
+ }
85
+
86
+ export function normalizeMission(raw: Record<string, unknown>): Mission {
87
+ const epoch = "1970-01-01T00:00:00.000Z";
88
+ return {
89
+ title: (raw.title as string) ?? "",
90
+ description: (raw.description as string) ?? "",
91
+ status: (raw.status as Mission["status"]) ?? "active",
92
+ branch: (raw.branch as string | null) ?? null,
93
+ milestones: Array.isArray(raw.milestones) ? (raw.milestones as Milestone[]) : [],
94
+ created: (raw.created as string) ?? epoch,
95
+ updated: (raw.updated as string) ?? epoch,
96
+ };
97
+ }
98
+
99
+ export function normalizeGoal(raw: Record<string, unknown>): Goal {
100
+ const epoch = "1970-01-01T00:00:00.000Z";
101
+ const rest = { ...raw };
102
+ delete rest._version;
103
+ return {
104
+ ...(rest as Omit<Goal, "assignee" | "specialty" | "created" | "updated">),
105
+ created: (raw.created as string) ?? epoch,
106
+ updated: (raw.updated as string) ?? epoch,
107
+ assignee: (raw.assignee as string) ?? null,
108
+ specialty: (raw.specialty as string) ?? null,
109
+ milestone: (raw.milestone as string | null) ?? null,
110
+ } as Goal;
111
+ }
112
+
113
+ export function normalizeTask(raw: Record<string, unknown>): Task {
114
+ const defaults = {
115
+ retryCount: 0,
116
+ maxRetries: 5,
117
+ lastError: null,
118
+ nextRetryAt: null,
119
+ depends_on: [] as string[],
120
+ };
121
+
122
+ // Migrate proof.note → proof.notes
123
+ const proof = raw.proof as Record<string, unknown> | null | undefined;
124
+ if (proof && "note" in proof && !("notes" in proof)) {
125
+ proof.notes = proof.note;
126
+ delete proof.note;
127
+ }
128
+
129
+ const rest = { ...raw };
130
+ delete rest._version;
131
+ return {
132
+ ...defaults,
133
+ ...(rest as Omit<
134
+ Task,
135
+ "retryCount" | "maxRetries" | "lastError" | "nextRetryAt" | "depends_on"
136
+ >),
137
+ retryCount: (raw.retryCount as number) ?? defaults.retryCount,
138
+ maxRetries: (raw.maxRetries as number) ?? defaults.maxRetries,
139
+ lastError: (raw.lastError as string | null) ?? defaults.lastError,
140
+ nextRetryAt: (raw.nextRetryAt as string | null) ?? defaults.nextRetryAt,
141
+ depends_on: Array.isArray(raw.depends_on) ? (raw.depends_on as string[]) : defaults.depends_on,
142
+ milestone: (raw.milestone as string | null) ?? null,
143
+ specialty: (raw.specialty as string | null) ?? null,
144
+ fulfills: Array.isArray(raw.fulfills) ? (raw.fulfills as string[]) : [],
145
+ discoveredIssues: Array.isArray(raw.discoveredIssues) ? (raw.discoveredIssues as string[]) : [],
146
+ salientSummary: (raw.salientSummary as string | null) ?? null,
147
+ } as Task;
148
+ }
149
+
150
+ export function getTasksRoot(dir: string): string {
151
+ return resolve(dir, TASKS_DIR);
152
+ }
153
+
154
+ export function ensureTasksDir(dir: string): void {
155
+ const root = getTasksRoot(dir);
156
+ if (!existsSync(root)) mkdirSync(root, { recursive: true });
157
+ const goalsDir = join(root, "goals");
158
+ if (!existsSync(goalsDir)) mkdirSync(goalsDir);
159
+ const tasksDir = join(root, "tasks");
160
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir);
161
+ }
162
+
163
+ // --- Mission ---
164
+
165
+ export function loadMission(dir: string): Mission | null {
166
+ const path = join(getTasksRoot(dir), "mission.json");
167
+ if (!existsSync(path)) return null;
168
+ try {
169
+ return normalizeMission(JSON.parse(readFileSync(path, "utf-8")));
170
+ } catch (err) {
171
+ console.error("[task-store] Failed to parse mission.json: %s", (err as Error).message);
172
+ return null;
173
+ }
174
+ }
175
+
176
+ export function saveMission(dir: string, mission: Mission): void {
177
+ ensureTasksDir(dir);
178
+ atomicWriteJSON(join(getTasksRoot(dir), "mission.json"), {
179
+ _version: SCHEMA_VERSION,
180
+ ...mission,
181
+ });
182
+ }
183
+
184
+ export function clearMission(dir: string): void {
185
+ const path = join(getTasksRoot(dir), "mission.json");
186
+ if (existsSync(path)) unlinkSync(path);
187
+ }
188
+
189
+ // --- Goals ---
190
+
191
+ function findFileById(directory: string, id: string): string | null {
192
+ if (!existsSync(directory)) return null;
193
+ const files = readdirSync(directory).filter((f) => f.endsWith(".json"));
194
+ for (const file of files) {
195
+ if (file.startsWith(id + "-") || file === id + ".json") {
196
+ return join(directory, file);
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ export function nextGoalId(dir: string): string {
203
+ const goalsDir = join(getTasksRoot(dir), "goals");
204
+ if (!existsSync(goalsDir)) return "01";
205
+ const files = readdirSync(goalsDir).filter((f) => f.endsWith(".json"));
206
+ if (files.length === 0) return "01";
207
+ const maxId = Math.max(...files.map((f) => parseInt(f.split("-")[0]!, 10) || 0));
208
+ return String(maxId + 1).padStart(2, "0");
209
+ }
210
+
211
+ export function loadGoals(dir: string): Goal[] {
212
+ const goalsDir = join(getTasksRoot(dir), "goals");
213
+ if (!existsSync(goalsDir)) return [];
214
+ return readdirSync(goalsDir)
215
+ .filter((f) => f.endsWith(".json"))
216
+ .sort()
217
+ .map((f) => {
218
+ try {
219
+ return normalizeGoal(JSON.parse(readFileSync(join(goalsDir, f), "utf-8")));
220
+ } catch (err) {
221
+ console.error("[task-store] Failed to parse %s: %s", f, (err as Error).message);
222
+ return null;
223
+ }
224
+ })
225
+ .filter((g): g is Goal => g !== null);
226
+ }
227
+
228
+ export function loadGoal(dir: string, id: string): Goal | null {
229
+ const file = findFileById(join(getTasksRoot(dir), "goals"), id);
230
+ if (!file) return null;
231
+ try {
232
+ return normalizeGoal(JSON.parse(readFileSync(file, "utf-8")));
233
+ } catch (err) {
234
+ console.error("[task-store] Failed to parse goal %s: %s", id, (err as Error).message);
235
+ return null;
236
+ }
237
+ }
238
+
239
+ export function saveGoal(dir: string, goal: Goal): void {
240
+ ensureTasksDir(dir);
241
+ const goalsDir = join(getTasksRoot(dir), "goals");
242
+ const existing = findFileById(goalsDir, goal.id);
243
+ const filename = `${goal.id}-${slugify(goal.title, 50)}.json`;
244
+ const newPath = join(goalsDir, filename);
245
+ // Write new file atomically first, then remove old file if slug changed
246
+ atomicWriteJSON(newPath, { _version: SCHEMA_VERSION, ...goal });
247
+ if (existing && existing !== newPath) unlinkSync(existing);
248
+ }
249
+
250
+ export function deleteGoal(dir: string, id: string): boolean {
251
+ const file = findFileById(join(getTasksRoot(dir), "goals"), id);
252
+ if (!file) return false;
253
+ unlinkSync(file);
254
+ return true;
255
+ }
256
+
257
+ // --- Tasks ---
258
+
259
+ export function nextTaskId(dir: string): string {
260
+ const tasksDir = join(getTasksRoot(dir), "tasks");
261
+ if (!existsSync(tasksDir)) return "001";
262
+ const files = readdirSync(tasksDir).filter((f) => f.endsWith(".json"));
263
+ if (files.length === 0) return "001";
264
+ const maxId = Math.max(...files.map((f) => parseInt(f.split("-")[0]!, 10) || 0));
265
+ return String(maxId + 1).padStart(3, "0");
266
+ }
267
+
268
+ export function loadTasks(dir: string): Task[] {
269
+ const tasksDir = join(getTasksRoot(dir), "tasks");
270
+ if (!existsSync(tasksDir)) return [];
271
+ return readdirSync(tasksDir)
272
+ .filter((f) => f.endsWith(".json"))
273
+ .sort()
274
+ .map((f) => {
275
+ try {
276
+ return normalizeTask(JSON.parse(readFileSync(join(tasksDir, f), "utf-8")));
277
+ } catch (err) {
278
+ console.error("[task-store] Failed to parse %s: %s", f, (err as Error).message);
279
+ return null;
280
+ }
281
+ })
282
+ .filter((t): t is Task => t !== null);
283
+ }
284
+
285
+ export function loadTask(dir: string, id: string): Task | null {
286
+ const file = findFileById(join(getTasksRoot(dir), "tasks"), id);
287
+ if (!file) return null;
288
+ try {
289
+ return normalizeTask(JSON.parse(readFileSync(file, "utf-8")));
290
+ } catch (err) {
291
+ console.error("[task-store] Failed to parse task %s: %s", id, (err as Error).message);
292
+ return null;
293
+ }
294
+ }
295
+
296
+ export function saveTask(dir: string, task: Task): void {
297
+ ensureTasksDir(dir);
298
+ const tasksDir = join(getTasksRoot(dir), "tasks");
299
+ const existing = findFileById(tasksDir, task.id);
300
+ const filename = `${task.id}-${slugify(task.title, 50)}.json`;
301
+ const newPath = join(tasksDir, filename);
302
+ // Write new file atomically first, then remove old file if slug changed
303
+ atomicWriteJSON(newPath, { _version: SCHEMA_VERSION, ...task });
304
+ if (existing && existing !== newPath) unlinkSync(existing);
305
+ }
306
+
307
+ export function deleteTask(dir: string, id: string): boolean {
308
+ const file = findFileById(join(getTasksRoot(dir), "tasks"), id);
309
+ if (!file) return false;
310
+ unlinkSync(file);
311
+ return true;
312
+ }
313
+
314
+ export function loadTasksForGoal(dir: string, goalId: string): Task[] {
315
+ return loadTasks(dir).filter((t) => t.goal === goalId);
316
+ }
317
+
318
+ /**
319
+ * Detect dependency cycles. Builds the full dependency graph, applies
320
+ * the proposed change (taskId → newDeps), then runs DFS from taskId.
321
+ * Returns the cycle path if found, null otherwise.
322
+ */
323
+ export function detectCycle(dir: string, taskId: string, newDeps: string[]): string[] | null {
324
+ const tasks = loadTasks(dir);
325
+ const depMap = new Map<string, string[]>();
326
+ for (const t of tasks) {
327
+ depMap.set(t.id, [...t.depends_on]);
328
+ }
329
+ // Apply proposed change
330
+ depMap.set(taskId, newDeps);
331
+
332
+ // DFS from taskId following depends_on edges
333
+ const visited = new Set<string>();
334
+ const path: string[] = [];
335
+
336
+ function dfs(id: string): string[] | null {
337
+ if (path.includes(id)) {
338
+ return [...path.slice(path.indexOf(id)), id];
339
+ }
340
+ if (visited.has(id)) return null;
341
+ visited.add(id);
342
+ path.push(id);
343
+ for (const dep of depMap.get(id) ?? []) {
344
+ const cycle = dfs(dep);
345
+ if (cycle) return cycle;
346
+ }
347
+ path.pop();
348
+ return null;
349
+ }
350
+
351
+ return dfs(taskId);
352
+ }