@shanesaravia/hive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +417 -0
  4. package/dist/bin/hive-emit.js +75 -0
  5. package/dist/bin/hive.js +506 -0
  6. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  7. package/node_modules/@hive/shared/dist/index.js +2 -0
  8. package/node_modules/@hive/shared/dist/status.d.ts +12 -0
  9. package/node_modules/@hive/shared/dist/status.js +52 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +384 -0
  11. package/node_modules/@hive/shared/dist/types.js +14 -0
  12. package/node_modules/@hive/shared/package.json +18 -0
  13. package/package.json +72 -0
  14. package/packages/server/dist/api/rest.js +793 -0
  15. package/packages/server/dist/api/ws.js +37 -0
  16. package/packages/server/dist/config.js +24 -0
  17. package/packages/server/dist/control/codexRuntime.js +169 -0
  18. package/packages/server/dist/control/killer.js +25 -0
  19. package/packages/server/dist/control/launcher.js +114 -0
  20. package/packages/server/dist/control/messaging.js +75 -0
  21. package/packages/server/dist/control/nativeCommands.js +29 -0
  22. package/packages/server/dist/control/permissionPark.js +23 -0
  23. package/packages/server/dist/control/providerModels.js +53 -0
  24. package/packages/server/dist/events/eventsStore.js +55 -0
  25. package/packages/server/dist/health/deriveAlerts.js +55 -0
  26. package/packages/server/dist/hooks/hookIngest.js +90 -0
  27. package/packages/server/dist/hooks/hookSpool.js +33 -0
  28. package/packages/server/dist/hooks/setupHooks.js +102 -0
  29. package/packages/server/dist/index.js +88 -0
  30. package/packages/server/dist/messages/messagesStore.js +211 -0
  31. package/packages/server/dist/missions/missionsStore.js +283 -0
  32. package/packages/server/dist/paths/pathResolver.js +167 -0
  33. package/packages/server/dist/plans/plansStore.js +212 -0
  34. package/packages/server/dist/policies/policiesStore.js +61 -0
  35. package/packages/server/dist/reports/githubPublisher.js +21 -0
  36. package/packages/server/dist/reports/missionReport.js +16 -0
  37. package/packages/server/dist/roster/rosterBuilder.js +243 -0
  38. package/packages/server/dist/security/originPolicy.js +31 -0
  39. package/packages/server/dist/skills/skillDiscovery.js +69 -0
  40. package/packages/server/dist/templates/templateDiscovery.js +97 -0
  41. package/packages/server/dist/watch/jobsWatcher.js +224 -0
  42. package/packages/server/dist/watch/sessionsWatcher.js +65 -0
  43. package/packages/web/dist/assets/index-CrKMFCkZ.js +11 -0
  44. package/packages/web/dist/assets/index-gEGU_lr3.css +2 -0
  45. package/packages/web/dist/favicon.svg +12 -0
  46. package/packages/web/dist/index.html +14 -0
  47. package/templates/agents/hive-orchestrator.md +42 -0
@@ -0,0 +1,283 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { config } from "../config.js";
5
+ const emptyData = () => ({
6
+ version: 1,
7
+ missions: {},
8
+ jobMissions: {},
9
+ latestSessions: {},
10
+ sessionNames: {},
11
+ deletedJobIds: [],
12
+ });
13
+ /** Durable Hive mission identity and metadata, independent of Claude jobs. */
14
+ export class MissionsStore {
15
+ filePath;
16
+ legacyThreadsPath;
17
+ data = emptyData();
18
+ listeners = new Set();
19
+ idMigrations = new Map();
20
+ constructor(filePath = config.missionsPath, legacyThreadsPath = config.threadsPath) {
21
+ this.filePath = filePath;
22
+ this.legacyThreadsPath = legacyThreadsPath;
23
+ }
24
+ init() {
25
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
28
+ this.data = {
29
+ version: 1,
30
+ missions: parsed.missions ?? {},
31
+ jobMissions: parsed.jobMissions ?? {},
32
+ latestSessions: parsed.latestSessions ?? {},
33
+ sessionNames: parsed.sessionNames ?? {},
34
+ deletedJobIds: parsed.deletedJobIds ?? [],
35
+ };
36
+ this.migrateLegacyIds();
37
+ if (this.idMigrations.size)
38
+ this.persist();
39
+ return;
40
+ }
41
+ catch {
42
+ this.migrateLegacyThreads();
43
+ this.migrateLegacyIds();
44
+ this.persist();
45
+ }
46
+ }
47
+ migrations() { return this.idMigrations; }
48
+ adoptLegacyOrchestrators(jobs) {
49
+ const pending = [...jobs].filter(([jobId, job]) => job.template === "hive-orchestrator" && !this.get(this.missionFor(jobId)));
50
+ const groups = [];
51
+ for (const entry of pending) {
52
+ const ids = new Set([entry[1].sessionId, entry[1].resumeSessionId].filter(Boolean));
53
+ const matches = groups.filter((group) => group.some(([, job]) => ids.has(job.sessionId) || ids.has(job.resumeSessionId)));
54
+ if (!matches.length)
55
+ groups.push([entry]);
56
+ else {
57
+ matches[0].push(entry);
58
+ for (const extra of matches.slice(1)) {
59
+ matches[0].push(...extra);
60
+ groups.splice(groups.indexOf(extra), 1);
61
+ }
62
+ }
63
+ }
64
+ for (const group of groups) {
65
+ group.sort((a, b) => Date.parse(a[1].createdAt ?? "") - Date.parse(b[1].createdAt ?? ""));
66
+ const [firstId, first] = group[0];
67
+ const latest = group.at(-1);
68
+ const mission = this.create({ name: latest[1].name ?? first.name, objective: first.intent ?? first.detail ?? "Recovered orchestrated mission", repository: first.originCwd ?? first.cwd, jobId: firstId, sessionId: latest[1].sessionId ?? latest[1].resumeSessionId, mode: "orchestrated" });
69
+ for (const [jobId, job] of group.slice(1))
70
+ this.linkJob(mission.id, jobId, job.sessionId ?? job.resumeSessionId);
71
+ }
72
+ return groups.length;
73
+ }
74
+ create(input) {
75
+ const id = randomUUID();
76
+ const now = Date.now();
77
+ const mission = {
78
+ id,
79
+ name: input.name?.trim() || undefined,
80
+ objective: input.objective.trim(),
81
+ acceptanceCriteria: input.acceptanceCriteria ?? [],
82
+ repository: input.repository,
83
+ compactSummary: input.compactSummary,
84
+ currentState: input.currentState,
85
+ lifecycleStatus: "active",
86
+ mode: input.mode ?? "orchestrated",
87
+ budgets: input.budgets,
88
+ template: input.template,
89
+ policy: input.policy,
90
+ provider: input.provider ?? "claude",
91
+ model: input.model?.trim() || undefined,
92
+ additionalRepositories: input.additionalRepositories?.length ? [...new Set(input.additionalRepositories)] : undefined,
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ };
96
+ this.data.missions[id] = mission;
97
+ this.data.jobMissions[input.jobId] = id;
98
+ if (input.sessionId)
99
+ this.data.latestSessions[id] = input.sessionId;
100
+ this.changed();
101
+ return mission;
102
+ }
103
+ missionFor(jobId) {
104
+ return this.data.jobMissions[jobId] ?? jobId;
105
+ }
106
+ /** Records extra repositories the mission touches; grants ride every later turn. */
107
+ addRepositories(missionId, repositories) {
108
+ const mission = this.data.missions[missionId];
109
+ if (!mission || !repositories.length)
110
+ return;
111
+ const merged = [...new Set([...(mission.additionalRepositories ?? []), ...repositories])];
112
+ if (merged.length === (mission.additionalRepositories?.length ?? 0))
113
+ return;
114
+ mission.additionalRepositories = merged;
115
+ mission.updatedAt = Date.now();
116
+ this.changed();
117
+ }
118
+ isDeletedJob(jobId) { return this.data.deletedJobIds.includes(jobId); }
119
+ remove(missionId) {
120
+ if (!this.data.missions[missionId])
121
+ return false;
122
+ for (const [jobId, linkedMission] of Object.entries(this.data.jobMissions))
123
+ if (linkedMission === missionId) {
124
+ if (!this.data.deletedJobIds.includes(jobId))
125
+ this.data.deletedJobIds.push(jobId);
126
+ delete this.data.jobMissions[jobId];
127
+ }
128
+ delete this.data.latestSessions[missionId];
129
+ delete this.data.missions[missionId];
130
+ this.changed();
131
+ return true;
132
+ }
133
+ get(missionId) {
134
+ return this.data.missions[missionId];
135
+ }
136
+ summaryFor(missionId, fallbackObjective = "") {
137
+ const mission = this.get(missionId);
138
+ return {
139
+ id: missionId,
140
+ objective: mission?.objective || fallbackObjective,
141
+ acceptanceCriteria: mission?.acceptanceCriteria ?? [],
142
+ repository: mission?.repository,
143
+ compactSummary: mission?.compactSummary,
144
+ currentState: mission?.currentState,
145
+ lifecycleStatus: mission?.lifecycleStatus ?? "active",
146
+ mode: mission?.mode ?? "orchestrated",
147
+ budgets: mission?.budgets,
148
+ template: mission?.template,
149
+ ...(mission?.policy ? { policy: mission.policy } : {}),
150
+ provider: mission?.provider ?? "claude",
151
+ ...(mission?.model ? { model: mission.model } : {}),
152
+ ...(mission?.additionalRepositories?.length ? { additionalRepositories: mission.additionalRepositories } : {}),
153
+ };
154
+ }
155
+ nameFor(missionId) {
156
+ if (missionId.startsWith("session:")) {
157
+ return this.data.sessionNames[missionId.slice("session:".length)];
158
+ }
159
+ return this.get(missionId)?.name;
160
+ }
161
+ rename(missionId, name) {
162
+ const trimmed = name.trim();
163
+ if (missionId.startsWith("session:")) {
164
+ const sessionId = missionId.slice("session:".length);
165
+ if (trimmed)
166
+ this.data.sessionNames[sessionId] = trimmed;
167
+ else
168
+ delete this.data.sessionNames[sessionId];
169
+ }
170
+ else {
171
+ const mission = this.ensureLegacyMission(missionId);
172
+ mission.name = trimmed || undefined;
173
+ mission.updatedAt = Date.now();
174
+ }
175
+ this.changed();
176
+ }
177
+ latestSessionFor(missionId) {
178
+ return this.data.latestSessions[missionId];
179
+ }
180
+ updateContext(missionId, context) {
181
+ const mission = this.ensureLegacyMission(missionId);
182
+ if (context.compactSummary !== undefined) {
183
+ mission.compactSummary = context.compactSummary.trim() || undefined;
184
+ }
185
+ if (context.currentState !== undefined) {
186
+ mission.currentState = context.currentState.trim() || undefined;
187
+ }
188
+ mission.updatedAt = Date.now();
189
+ this.changed();
190
+ }
191
+ setLifecycleStatus(missionId, status) {
192
+ const mission = this.ensureLegacyMission(missionId);
193
+ mission.lifecycleStatus = status;
194
+ mission.updatedAt = Date.now();
195
+ this.changed();
196
+ }
197
+ setPolicy(missionId, policy) {
198
+ const mission = this.ensureLegacyMission(missionId);
199
+ mission.policy = policy;
200
+ mission.updatedAt = Date.now();
201
+ this.changed();
202
+ }
203
+ linkJob(missionId, jobId, sessionId) {
204
+ this.ensureLegacyMission(missionId);
205
+ this.data.jobMissions[jobId] = missionId;
206
+ if (sessionId)
207
+ this.data.latestSessions[missionId] = sessionId;
208
+ const mission = this.data.missions[missionId];
209
+ if (mission)
210
+ mission.updatedAt = Date.now();
211
+ this.changed();
212
+ }
213
+ onChange(listener) {
214
+ this.listeners.add(listener);
215
+ }
216
+ migrateLegacyThreads() {
217
+ let legacy = {};
218
+ try {
219
+ legacy = JSON.parse(fs.readFileSync(this.legacyThreadsPath, "utf8"));
220
+ }
221
+ catch {
222
+ return;
223
+ }
224
+ this.data.jobMissions = { ...(legacy.jobThreads ?? {}) };
225
+ this.data.latestSessions = { ...(legacy.latestSessions ?? {}) };
226
+ for (const [id, name] of Object.entries(legacy.names ?? {})) {
227
+ if (id.startsWith("session:")) {
228
+ this.data.sessionNames[id.slice("session:".length)] = name;
229
+ }
230
+ else {
231
+ const mission = this.ensureLegacyMission(id);
232
+ mission.name = name;
233
+ }
234
+ }
235
+ for (const missionId of new Set(Object.values(this.data.jobMissions))) {
236
+ this.ensureLegacyMission(missionId);
237
+ }
238
+ }
239
+ migrateLegacyIds() {
240
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
241
+ for (const [oldId, mission] of Object.entries({ ...this.data.missions })) {
242
+ if (uuid.test(oldId))
243
+ continue;
244
+ const nextId = randomUUID();
245
+ this.idMigrations.set(oldId, nextId);
246
+ this.data.missions[nextId] = { ...mission, id: nextId };
247
+ delete this.data.missions[oldId];
248
+ for (const [jobId, missionId] of Object.entries(this.data.jobMissions))
249
+ if (missionId === oldId)
250
+ this.data.jobMissions[jobId] = nextId;
251
+ this.data.jobMissions[oldId] ??= nextId;
252
+ if (this.data.latestSessions[oldId]) {
253
+ this.data.latestSessions[nextId] = this.data.latestSessions[oldId];
254
+ delete this.data.latestSessions[oldId];
255
+ }
256
+ }
257
+ }
258
+ ensureLegacyMission(id) {
259
+ const existing = this.data.missions[id];
260
+ if (existing)
261
+ return existing;
262
+ const mission = {
263
+ id,
264
+ objective: "",
265
+ acceptanceCriteria: [],
266
+ lifecycleStatus: "active",
267
+ mode: "orchestrated",
268
+ createdAt: Date.now(),
269
+ updatedAt: Date.now(),
270
+ };
271
+ this.data.missions[id] = mission;
272
+ this.data.jobMissions[id] ??= id;
273
+ return mission;
274
+ }
275
+ changed() {
276
+ this.persist();
277
+ for (const listener of this.listeners)
278
+ listener();
279
+ }
280
+ persist() {
281
+ fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
282
+ }
283
+ }
@@ -0,0 +1,167 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ export function defaultWorkingDirectory() {
6
+ const candidate = process.env.INIT_CWD || process.cwd();
7
+ try {
8
+ if (fs.statSync(candidate).isDirectory())
9
+ return path.resolve(candidate);
10
+ }
11
+ catch { /* fall through */ }
12
+ return process.cwd();
13
+ }
14
+ export function resolveWorkingDirectory(input) {
15
+ const value = input?.trim();
16
+ if (!value)
17
+ return defaultWorkingDirectory();
18
+ if (value === "~")
19
+ return os.homedir();
20
+ if (value.startsWith("~/"))
21
+ return path.resolve(os.homedir(), value.slice(2));
22
+ return path.resolve(defaultWorkingDirectory(), value);
23
+ }
24
+ export function requireWorkingDirectory(input) {
25
+ const resolved = resolveWorkingDirectory(input);
26
+ try {
27
+ if (fs.statSync(resolved).isDirectory())
28
+ return resolved;
29
+ }
30
+ catch { /* use friendly error below */ }
31
+ throw new Error(`Directory does not exist: ${resolved}`);
32
+ }
33
+ function displayPath(value) {
34
+ const home = os.homedir();
35
+ return value === home ? "~" : value.startsWith(`${home}${path.sep}`) ? `~/${path.relative(home, value)}` : value;
36
+ }
37
+ export function suggestDirectories(input) {
38
+ const raw = input?.trim() ?? "";
39
+ if (!raw) {
40
+ const candidates = [defaultWorkingDirectory(), os.homedir(), path.join(os.homedir(), "Development")];
41
+ return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index && fs.existsSync(candidate)).map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
42
+ }
43
+ const expanded = resolveWorkingDirectory(raw);
44
+ let parent = path.dirname(expanded);
45
+ let prefix = path.basename(expanded).toLowerCase();
46
+ if (raw.endsWith("/") || raw === "~") {
47
+ parent = expanded;
48
+ prefix = "";
49
+ }
50
+ const suggestions = [];
51
+ if (fs.existsSync(expanded))
52
+ suggestions.push({ path: displayPath(expanded), resolvedPath: expanded });
53
+ try {
54
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
55
+ if (!entry.isDirectory() || entry.name.startsWith(".") || !entry.name.toLowerCase().startsWith(prefix))
56
+ continue;
57
+ const resolvedPath = path.join(parent, entry.name);
58
+ if (!suggestions.some((item) => item.resolvedPath === resolvedPath))
59
+ suggestions.push({ path: displayPath(resolvedPath), resolvedPath });
60
+ if (suggestions.length >= 10)
61
+ break;
62
+ }
63
+ }
64
+ catch { /* an incomplete path simply has no suggestions */ }
65
+ return suggestions;
66
+ }
67
+ function stripWorktree(value) {
68
+ return value.split(`${path.sep}.claude${path.sep}worktrees${path.sep}`)[0];
69
+ }
70
+ /** Git repositories that sit beside the mission's repo (and the Hive default). */
71
+ export function knownRepositories(baseRepository) {
72
+ const roots = new Set();
73
+ if (baseRepository)
74
+ roots.add(path.dirname(stripWorktree(baseRepository)));
75
+ roots.add(path.dirname(defaultWorkingDirectory()));
76
+ const found = new Map();
77
+ for (const root of roots) {
78
+ try {
79
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
80
+ if (!entry.isDirectory() || entry.name.startsWith("."))
81
+ continue;
82
+ const full = path.join(root, entry.name);
83
+ if (fs.existsSync(path.join(full, ".git")))
84
+ found.set(entry.name.toLowerCase(), full);
85
+ }
86
+ }
87
+ catch { /* a root may have been removed */ }
88
+ }
89
+ return [...found.entries()].map(([name, resolvedPath]) => ({ name, path: resolvedPath }));
90
+ }
91
+ /**
92
+ * Repositories a message refers to by name ("add this to monorepo"), so a
93
+ * mission can be granted cross-repo access without any explicit form field.
94
+ * The mission's own repository never counts as a mention.
95
+ */
96
+ export function detectRepositoryMentions(text, ownRepository) {
97
+ const own = ownRepository ? path.basename(stripWorktree(ownRepository)).toLowerCase() : undefined;
98
+ const lower = text.toLowerCase();
99
+ return knownRepositories(ownRepository)
100
+ .filter(({ name }) => name !== own && new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(lower))
101
+ .map((repo) => repo.path);
102
+ }
103
+ /**
104
+ * The known repository containing filePath, excluding the mission's own repo
105
+ * (worktree paths count as their base repo). Used to record which other
106
+ * repositories a mission has actually touched.
107
+ */
108
+ export function foreignRepositoryForPath(filePath, ownRepository) {
109
+ const own = ownRepository ? stripWorktree(ownRepository) : undefined;
110
+ if (own && (filePath === own || filePath.startsWith(`${own}${path.sep}`)))
111
+ return undefined;
112
+ return knownRepositories(ownRepository).find(({ path: repo }) => filePath === repo || filePath.startsWith(`${repo}${path.sep}`))?.path;
113
+ }
114
+ function git(directory, ...args) {
115
+ try {
116
+ return execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1_000 }).trim() || undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ export function inspectWorkingDirectory(input) {
123
+ const resolvedPath = resolveWorkingDirectory(input);
124
+ let exists = false;
125
+ try {
126
+ exists = fs.statSync(resolvedPath).isDirectory();
127
+ }
128
+ catch { /* invalid path */ }
129
+ const repositoryRoot = exists ? git(resolvedPath, "rev-parse", "--show-toplevel") : undefined;
130
+ const gitDir = repositoryRoot ? git(resolvedPath, "rev-parse", "--git-dir") : undefined;
131
+ const commonDir = repositoryRoot ? git(resolvedPath, "rev-parse", "--git-common-dir") : undefined;
132
+ const configurationRoot = repositoryRoot ?? resolvedPath;
133
+ return {
134
+ input: input?.trim() || displayPath(defaultWorkingDirectory()), resolvedPath, exists, repositoryRoot,
135
+ branch: repositoryRoot ? git(resolvedPath, "branch", "--show-current") ?? "detached HEAD" : undefined,
136
+ isWorktree: Boolean(repositoryRoot && gitDir && commonDir && path.resolve(resolvedPath, gitDir) !== path.resolve(resolvedPath, commonDir)),
137
+ configuration: [
138
+ { label: "CLAUDE.md", available: exists && fs.existsSync(path.join(configurationRoot, "CLAUDE.md")) },
139
+ { label: ".mcp.json", available: exists && fs.existsSync(path.join(configurationRoot, ".mcp.json")) },
140
+ { label: "Project settings", available: exists && fs.existsSync(path.join(configurationRoot, ".claude", "settings.json")) },
141
+ { label: "Project skills", available: exists && fs.existsSync(path.join(configurationRoot, ".claude", "skills")) },
142
+ ],
143
+ };
144
+ }
145
+ export function recentRepositories(limit = 8) {
146
+ const roots = [defaultWorkingDirectory(), path.join(os.homedir(), "Development")];
147
+ const candidates = new Set();
148
+ for (const root of roots) {
149
+ try {
150
+ if (git(root, "rev-parse", "--show-toplevel"))
151
+ candidates.add(git(root, "rev-parse", "--show-toplevel"));
152
+ for (const entry of fs.readdirSync(root, { withFileTypes: true }))
153
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
154
+ const candidate = path.join(root, entry.name);
155
+ if (fs.existsSync(path.join(candidate, ".git")))
156
+ candidates.add(candidate);
157
+ }
158
+ }
159
+ catch { /* unavailable discovery root */ }
160
+ }
161
+ return [...candidates].sort((a, b) => { try {
162
+ return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
163
+ }
164
+ catch {
165
+ return 0;
166
+ } }).slice(0, limit).map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
167
+ }
@@ -0,0 +1,212 @@
1
+ import Database from "better-sqlite3";
2
+ import { config } from "../config.js";
3
+ const STATUSES = new Set(["queued", "working", "blocked", "reviewing", "completed", "failed", "cancelled"]);
4
+ const GATE_STATUSES = new Set(["pending", "satisfied", "failed", "waived"]);
5
+ const GATE_TYPES = new Set(["test", "typecheck", "lint", "ci", "review", "pr", "screenshot", "documentation", "user_approval", "custom"]);
6
+ function calculateProgress(phases) {
7
+ const tasks = phases.flatMap((phase) => leafTasks(phase.tasks)).filter((task) => task.status !== "cancelled");
8
+ const finished = tasks.filter((task) => task.status === "completed");
9
+ const totalWeight = tasks.reduce((sum, task) => sum + task.weight, 0);
10
+ const completedWeight = finished.reduce((sum, task) => sum + task.weight, 0);
11
+ return { completedWeight, totalWeight, percent: totalWeight ? Math.round(completedWeight / totalWeight * 100) : undefined, completedTasks: finished.length, totalTasks: tasks.length };
12
+ }
13
+ function leafTasks(tasks) { return tasks.flatMap((task) => task.subtasks?.length ? leafTasks(task.subtasks) : [task]); }
14
+ export function allPlanTasks(tasks) { return tasks.flatMap((task) => [task, ...allPlanTasks(task.subtasks ?? [])]); }
15
+ function normalize(input) {
16
+ if (input.tasks && input.phases?.length)
17
+ throw new Error("plan must use either flat tasks or phases, not both");
18
+ const source = input.tasks ? [{ id: "flat", title: "Tasks", status: flatStatus(input.tasks), acceptanceCriteria: [], dependsOn: [], tasks: input.tasks }] : input.phases;
19
+ if (!Array.isArray(source))
20
+ throw new Error("plan phases or flat tasks must be an array");
21
+ const ids = new Set();
22
+ return source.map((phase, phaseIndex) => {
23
+ const id = String(phase.id || `phase-${phaseIndex + 1}`).trim();
24
+ if (!id || ids.has(id))
25
+ throw new Error(`duplicate or empty plan item id: ${id}`);
26
+ ids.add(id);
27
+ if (!phase.title?.trim())
28
+ throw new Error(`phase ${id} requires a title`);
29
+ if (!STATUSES.has(phase.status))
30
+ throw new Error(`invalid phase status: ${phase.status}`);
31
+ const normalizeTasks = (sourceTasks, parentId) => sourceTasks.map((task, taskIndex) => {
32
+ const taskId = String(task.id || `${parentId}-task-${taskIndex + 1}`).trim();
33
+ if (!taskId || ids.has(taskId))
34
+ throw new Error(`duplicate or empty plan item id: ${taskId}`);
35
+ ids.add(taskId);
36
+ if (!task.title?.trim())
37
+ throw new Error(`task ${taskId} requires a title`);
38
+ if (!STATUSES.has(task.status))
39
+ throw new Error(`invalid task status: ${task.status}`);
40
+ const subtasks = normalizeTasks(task.subtasks ?? [], taskId);
41
+ if (task.status === "completed" && subtasks.some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled"))
42
+ throw new Error(`task ${taskId} cannot be completed while required subtasks remain`);
43
+ return { ...task, id: taskId, title: task.title.trim(), weight: Number.isFinite(task.weight) && task.weight > 0 ? task.weight : 1, dependsOn: task.dependsOn ?? [], evidence: task.evidence ?? [], subtasks };
44
+ });
45
+ const tasks = normalizeTasks(phase.tasks ?? [], id);
46
+ if (phase.status === "completed" && tasks.some((task) => task.status !== "completed" && task.status !== "cancelled")) {
47
+ throw new Error(`phase ${id} cannot be completed while required tasks remain`);
48
+ }
49
+ return { ...phase, id, title: phase.title.trim(), acceptanceCriteria: phase.acceptanceCriteria ?? [], dependsOn: phase.dependsOn ?? [], tasks };
50
+ });
51
+ }
52
+ function flatStatus(tasks) { if (tasks.length && tasks.every((task) => task.status === "completed" || task.status === "cancelled"))
53
+ return "completed"; if (tasks.some((task) => task.status === "blocked"))
54
+ return "blocked"; if (tasks.some((task) => task.status === "reviewing"))
55
+ return "reviewing"; if (tasks.some((task) => task.status === "working"))
56
+ return "working"; return "queued"; }
57
+ function normalizeGates(gates) {
58
+ const ids = new Set();
59
+ return (gates ?? []).map((gate, index) => {
60
+ const id = String(gate.id || `gate-${index + 1}`).trim();
61
+ if (!id || ids.has(id))
62
+ throw new Error(`duplicate or empty completion gate id: ${id}`);
63
+ ids.add(id);
64
+ if (!gate.label?.trim())
65
+ throw new Error(`completion gate ${id} requires a label`);
66
+ if (!GATE_TYPES.has(gate.type))
67
+ throw new Error(`invalid completion gate type: ${gate.type}`);
68
+ if (!GATE_STATUSES.has(gate.status))
69
+ throw new Error(`invalid completion gate status: ${gate.status}`);
70
+ if (gate.status === "waived" && !gate.waiver?.reason?.trim())
71
+ throw new Error(`waived completion gate ${id} requires a reason`);
72
+ return { ...gate, id, label: gate.label.trim(), required: gate.required !== false, evidence: gate.evidence ?? [], waiver: gate.waiver ? { ...gate.waiver, reason: gate.waiver.reason.trim() } : undefined };
73
+ });
74
+ }
75
+ /** Durable current plan plus immutable JSON snapshots for revision history. */
76
+ export class PlansStore {
77
+ db;
78
+ listeners = new Set();
79
+ constructor(filePath = config.databasePath) {
80
+ this.db = new Database(filePath);
81
+ this.db.pragma("journal_mode = WAL");
82
+ this.db.exec(`
83
+ CREATE TABLE IF NOT EXISTS mission_plans (mission_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, plan_json TEXT NOT NULL);
84
+ CREATE TABLE IF NOT EXISTS mission_plan_revisions (mission_id TEXT NOT NULL, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, plan_json TEXT NOT NULL, PRIMARY KEY (mission_id, revision));
85
+ CREATE INDEX IF NOT EXISTS idx_plan_revisions ON mission_plan_revisions (mission_id, revision DESC);
86
+ `);
87
+ }
88
+ replace(missionId, input) {
89
+ const phases = normalize(input);
90
+ const current = this.get(missionId);
91
+ const gates = input.gates === undefined ? current?.gates ?? [] : normalizeGates(input.gates);
92
+ const plan = { missionId, revision: (current?.revision ?? 0) + 1, updatedAt: Date.now(), phases, gates, layout: input.tasks ? "flat" : "phased", approvalStatus: input.approvalStatus ?? "proposed", approvalReason: input.approvalReason?.trim() || undefined, progress: calculateProgress(phases) };
93
+ const json = JSON.stringify(plan);
94
+ this.db.transaction(() => {
95
+ this.db.prepare("INSERT INTO mission_plans (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?) ON CONFLICT(mission_id) DO UPDATE SET revision=excluded.revision, updated_at=excluded.updated_at, plan_json=excluded.plan_json").run(missionId, plan.revision, plan.updatedAt, json);
96
+ this.db.prepare("INSERT INTO mission_plan_revisions (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?)").run(missionId, plan.revision, plan.updatedAt, json);
97
+ })();
98
+ for (const listener of this.listeners)
99
+ listener();
100
+ return plan;
101
+ }
102
+ /**
103
+ * Best-effort reconciliation from orchestrator activity events. The plan is
104
+ * authored by the orchestrator via plan_updated, but orchestrators reliably
105
+ * emit delegating/worker_reported while often forgetting to republish the
106
+ * plan — without this, tasks sit at "queued"/"working" forever while the
107
+ * workers finish. Matches by task id or worker name; transitions are
108
+ * forward-only, and any later plan_updated fully overrides the result.
109
+ */
110
+ applyTaskEvent(missionId, input) {
111
+ const plan = this.get(missionId);
112
+ if (!plan || (!input.targetTask && !input.targetWorker))
113
+ return undefined;
114
+ const nextStatus = input.phase === "delegating" ? "working" : input.phase === "reviewing" ? "reviewing" : "completed";
115
+ const allowedFrom = {
116
+ working: ["queued", "blocked"],
117
+ reviewing: ["queued", "working"],
118
+ completed: ["queued", "working", "reviewing"],
119
+ };
120
+ let changed = false;
121
+ const now = Date.now();
122
+ const mapTasks = (tasks) => tasks.map((task) => {
123
+ const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
124
+ const matched = (input.targetTask !== undefined && task.id === input.targetTask)
125
+ || (input.targetWorker !== undefined && (task.workerId === input.targetWorker || task.owner === input.targetWorker));
126
+ // A parent cannot complete ahead of its subtasks (normalize rejects it).
127
+ const blockedBySubtasks = nextStatus === "completed" && (subtasks ?? []).some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled");
128
+ if (!matched || blockedBySubtasks || !allowedFrom[nextStatus].includes(task.status)) {
129
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
130
+ }
131
+ changed = true;
132
+ return {
133
+ ...task,
134
+ subtasks,
135
+ status: nextStatus,
136
+ updatedAt: now,
137
+ startedAt: task.startedAt ?? now,
138
+ owner: task.owner ?? input.targetWorker,
139
+ workerId: task.workerId ?? input.targetWorker,
140
+ evidence: nextStatus === "completed" && input.evidence?.trim() ? [...task.evidence, input.evidence.trim().slice(0, 300)] : task.evidence,
141
+ };
142
+ });
143
+ const phases = plan.phases.map((phase) => {
144
+ const tasks = mapTasks(phase.tasks);
145
+ const statuses = allPlanTasks(tasks).map((task) => task.status);
146
+ let status = phase.status;
147
+ if (statuses.length && statuses.every((s) => s === "completed" || s === "cancelled"))
148
+ status = "completed";
149
+ else if ((phase.status === "queued" || phase.status === "completed") && statuses.some((s) => s === "working" || s === "reviewing"))
150
+ status = "working";
151
+ return { ...phase, status, tasks };
152
+ });
153
+ if (!changed)
154
+ return undefined;
155
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
156
+ }
157
+ removeMission(missionId) { this.db.transaction(() => { this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(missionId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(missionId); })(); for (const listener of this.listeners)
158
+ listener(); }
159
+ migrateMission(oldId, newId) { this.db.transaction(() => { const current = this.get(oldId); if (current) {
160
+ const migrated = JSON.stringify({ ...current, missionId: newId });
161
+ this.db.prepare("INSERT OR REPLACE INTO mission_plans (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?)").run(newId, current.revision, current.updatedAt, migrated);
162
+ } for (const revision of this.revisions(oldId))
163
+ this.db.prepare("INSERT OR REPLACE INTO mission_plan_revisions (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?)").run(newId, revision.revision, revision.updatedAt, JSON.stringify({ ...revision, missionId: newId })); this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(oldId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(oldId); })(); }
164
+ get(missionId) {
165
+ const row = this.db.prepare("SELECT plan_json FROM mission_plans WHERE mission_id = ?").get(missionId);
166
+ return row ? JSON.parse(row.plan_json) : undefined;
167
+ }
168
+ revisions(missionId) {
169
+ return this.db.prepare("SELECT plan_json FROM mission_plan_revisions WHERE mission_id = ? ORDER BY revision DESC").all(missionId).map((row) => JSON.parse(row.plan_json));
170
+ }
171
+ waiveGate(missionId, gateId, reason) {
172
+ const plan = this.get(missionId);
173
+ if (!plan)
174
+ throw new Error("mission plan not found");
175
+ const gate = plan.gates?.find((item) => item.id === gateId);
176
+ if (!gate)
177
+ throw new Error("completion gate not found");
178
+ if (!reason.trim())
179
+ throw new Error("waiver reason is required");
180
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan.phases }), gates: (plan.gates ?? []).map((item) => item.id === gateId ? { ...item, status: "waived", waiver: { reason: reason.trim(), waivedAt: Date.now() } } : item), approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
181
+ }
182
+ unresolvedRequiredGates(missionId) {
183
+ return (this.get(missionId)?.gates ?? []).filter((gate) => gate.required && gate.status !== "satisfied" && gate.status !== "waived");
184
+ }
185
+ satisfyUserApprovalGates(missionId) {
186
+ const plan = this.get(missionId);
187
+ if (!plan)
188
+ return undefined;
189
+ const pending = (plan.gates ?? []).some((gate) => gate.type === "user_approval" && gate.status !== "satisfied" && gate.status !== "waived");
190
+ if (!pending)
191
+ return plan;
192
+ return this.replace(missionId, {
193
+ ...(plan.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan.phases }),
194
+ gates: (plan.gates ?? []).map((gate) => gate.type === "user_approval" && gate.status !== "satisfied" && gate.status !== "waived" ? { ...gate, status: "satisfied", evidence: [...gate.evidence, "Mission accepted by user"] } : gate),
195
+ approvalStatus: plan.approvalStatus ?? "proposed",
196
+ approvalReason: plan.approvalReason,
197
+ });
198
+ }
199
+ setApproval(missionId, status, reason) {
200
+ const plan = this.get(missionId);
201
+ if (!plan)
202
+ throw new Error("mission plan not found");
203
+ const executionStarted = plan.phases.some((phase) => phase.status !== "queued" || allPlanTasks(phase.tasks).some((task) => task.status !== "queued"));
204
+ if (executionStarted)
205
+ throw new Error("plan approval is only available before execution begins");
206
+ if (status === "rejected" && !reason?.trim())
207
+ throw new Error("rejection reason is required");
208
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan.phases }), gates: plan.gates ?? [], approvalStatus: status, approvalReason: reason });
209
+ }
210
+ onChange(listener) { this.listeners.add(listener); }
211
+ close() { this.db.close(); }
212
+ }