@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,61 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { config } from "../config.js";
4
+ export const DEFAULT_POLICY = {
5
+ allowedRoots: [],
6
+ allowedTools: [],
7
+ networkAccess: true,
8
+ commits: true,
9
+ pushes: false,
10
+ pullRequests: false,
11
+ releases: false,
12
+ destructiveActions: false,
13
+ };
14
+ export class PoliciesStore {
15
+ filePath;
16
+ policy = DEFAULT_POLICY;
17
+ constructor(filePath = config.policiesPath) {
18
+ this.filePath = filePath;
19
+ }
20
+ init() {
21
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
22
+ try {
23
+ this.policy = normalize(JSON.parse(fs.readFileSync(this.filePath, "utf8")));
24
+ }
25
+ catch {
26
+ this.persist();
27
+ }
28
+ }
29
+ get() { return structuredClone(this.policy); }
30
+ set(input) { this.policy = normalize({ ...this.policy, ...input }); this.persist(); return this.get(); }
31
+ persist() { const temp = `${this.filePath}.tmp`; fs.writeFileSync(temp, JSON.stringify(this.policy, null, 2)); fs.renameSync(temp, this.filePath); }
32
+ }
33
+ export function normalize(input) {
34
+ const allowedRoots = Array.isArray(input.allowedRoots) ? [...new Set(input.allowedRoots.map((root) => path.resolve(root)).filter(Boolean))] : [];
35
+ const allowedTools = Array.isArray(input.allowedTools) ? [...new Set(input.allowedTools.map(String).map((tool) => tool.trim()).filter(Boolean))] : [];
36
+ return { ...DEFAULT_POLICY, ...input, allowedRoots, allowedTools };
37
+ }
38
+ export function enforceWorkingDirectory(cwd, policy) {
39
+ if (!policy.allowedRoots.length)
40
+ return;
41
+ const resolved = path.resolve(cwd);
42
+ if (!policy.allowedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`))) {
43
+ throw new Error(`working directory is outside the workspace policy: ${resolved}`);
44
+ }
45
+ }
46
+ export function disallowedTools(policy) {
47
+ const denied = [];
48
+ if (!policy.networkAccess)
49
+ denied.push("WebFetch", "WebSearch", "Bash(curl:*)", "Bash(wget:*)");
50
+ if (!policy.commits)
51
+ denied.push("Bash(git commit:*)");
52
+ if (!policy.pushes)
53
+ denied.push("Bash(git push:*)");
54
+ if (!policy.pullRequests)
55
+ denied.push("Bash(gh pr create:*)", "mcp__github");
56
+ if (!policy.releases)
57
+ denied.push("Bash(gh release:*)", "Bash(npm publish:*)");
58
+ if (!policy.destructiveActions)
59
+ denied.push("Bash(rm:*)", "Bash(git reset --hard:*)", "Bash(git clean:*)");
60
+ return denied;
61
+ }
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { config } from "../config.js";
7
+ const execFileAsync = promisify(execFile);
8
+ export function normalizeGitHubTarget(value) { const target = value.trim().replace(/^#/, ""); if (/^\d+$/.test(target))
9
+ return target; if (/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(issues|pull)\/\d+(?:[#?].*)?$/.test(target))
10
+ return target; throw new Error("enter a GitHub issue/PR number or full github.com URL"); }
11
+ export function githubCommentArgs(kind, target, bodyFile) { return [kind, "comment", normalizeGitHubTarget(target), "--body-file", bodyFile]; }
12
+ export async function publishGitHubReport(input) { fs.mkdirSync(config.hiveDataDir, { recursive: true }); const file = path.join(config.hiveDataDir, `report-${randomUUID()}.md`); try {
13
+ fs.writeFileSync(file, input.markdown);
14
+ await execFileAsync("gh", githubCommentArgs(input.kind, input.target, file), { cwd: input.cwd });
15
+ }
16
+ finally {
17
+ try {
18
+ fs.unlinkSync(file);
19
+ }
20
+ catch { /* best-effort cleanup */ }
21
+ } }
@@ -0,0 +1,16 @@
1
+ import { allPlanTasks } from "../plans/plansStore.js";
2
+ import { config } from "../config.js";
3
+ export function missionReport(node, decisions) {
4
+ const tasks = node.plan?.phases.flatMap((phase) => allPlanTasks(phase.tasks)) ?? [];
5
+ const missionUrl = `${config.dashboardUrl}/?mission=${encodeURIComponent(node.missionId)}`;
6
+ const changes = [...new Set(tasks.flatMap((task) => task.changedFiles ?? []))];
7
+ const branches = [...new Set(tasks.map((task) => task.branch).filter((value) => Boolean(value)))];
8
+ return {
9
+ generatedAt: new Date().toISOString(), mission: { id: node.missionId, url: missionUrl, name: node.name, request: node.mission.objective, repository: node.mission.repository, mode: node.mission.mode, lifecycle: node.lifecycleStatus, activity: node.activityStatus, summary: node.mission.compactSummary, currentState: node.mission.currentState },
10
+ plan: node.plan, decisions, workers: node.workers, changes, verification: node.plan?.gates ?? [], outputs: { pullRequests: node.prs, evidence: tasks.flatMap((task) => task.evidence.map((evidence) => ({ task: task.title, evidence }))) }, traceability: { missionUrl, files: changes.map((file) => ({ file, missionUrl })), branches: branches.map((branch) => ({ branch, missionUrl })), pullRequests: node.prs.map((pr) => ({ href: pr.href, missionUrl })) }, usage: { tokens: node.tokens, elapsedMs: Math.max(0, node.updatedAt - node.createdAt), jobs: node.jobHistory.length, workers: node.workers.length }, remainingRisks: [...node.alerts.map((alert) => `${alert.title}: ${alert.explanation}`), ...tasks.filter((task) => task.status === "blocked" || task.status === "failed").map((task) => `${task.title}: ${task.blocker ?? task.status}`), ...(node.plan?.gates ?? []).filter((gate) => gate.required && gate.status !== "satisfied" && gate.status !== "waived").map((gate) => `Unresolved gate: ${gate.label}`)], conversation: node.messages,
11
+ };
12
+ }
13
+ export function reportMarkdown(report) {
14
+ const lines = [`# ${report.mission.name} — Mission Report`, "", `Generated: ${report.generatedAt}`, `Mission: [${report.mission.id}](${report.mission.url})`, `Status: ${report.mission.lifecycle} / ${report.mission.activity}`, report.mission.repository ? `Repository: ${report.mission.repository}` : "", "", "## Request", "", report.mission.request, "", "## Summary", "", report.mission.summary ?? "No compact summary available.", report.mission.currentState ? `\nCurrent state: ${report.mission.currentState}` : "", "", "## Progress", "", report.plan ? `${report.plan.progress.completedTasks}/${report.plan.progress.totalTasks} tasks complete${report.plan.progress.percent !== undefined ? ` (${report.plan.progress.percent}%)` : ""}.` : "No structured plan.", "", "## Workers", "", ...report.workers.map((worker) => `- ${worker.label} — ${worker.doneAt ? "completed" : worker.jobState}`), "", "## Changes", "", ...(report.changes.length ? report.changes.map((file) => `- ${file} · [mission](${report.mission.url})`) : ["No changed files reported."]), ...report.traceability.branches.map(({ branch }) => `- Branch \`${branch}\` · [mission](${report.mission.url})`), "", "## Verification", "", ...(report.verification.length ? report.verification.map((gate) => `- ${gate.label}: ${gate.status}${gate.evidence.length ? ` — ${gate.evidence.join("; ")}` : ""}`) : ["No completion gates defined."]), "", "## Outputs", "", ...report.outputs.pullRequests.map((pr) => `- ${pr.kind}: ${pr.href} · [mission](${report.mission.url})`), ...report.outputs.evidence.map((item) => `- ${item.task}: ${item.evidence}`), "", "## Usage", "", `- Tokens: ${report.usage.tokens.toLocaleString()}`, `- Jobs: ${report.usage.jobs}`, `- Workers: ${report.usage.workers}`, "", "## Decisions", "", ...(report.decisions.length ? report.decisions.map((decision) => `- ${decision.question}${decision.answer ? ` → ${decision.answer}` : " (pending)"}`) : ["No decisions recorded."]), "", "## Remaining Risks", "", ...(report.remainingRisks.length ? report.remainingRisks.map((risk) => `- ${risk}`) : ["No known remaining risks."]), ""];
15
+ return lines.filter((line, index) => line !== "" || lines[index - 1] !== "").join("\n");
16
+ }
@@ -0,0 +1,243 @@
1
+ import { deriveStatus, } from "@hive/shared";
2
+ import { allPlanTasks } from "../plans/plansStore.js";
3
+ import { config } from "../config.js";
4
+ import { deriveAlerts } from "../health/deriveAlerts.js";
5
+ const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
6
+ function dateMs(value) {
7
+ const parsed = value ? Date.parse(value) : NaN;
8
+ return Number.isNaN(parsed) ? 0 : parsed;
9
+ }
10
+ function resultText(job) {
11
+ if (typeof job.output === "string")
12
+ return job.output;
13
+ if (job.output && typeof job.output === "object" && "result" in job.output) {
14
+ const result = job.output.result;
15
+ if (typeof result === "string")
16
+ return result;
17
+ }
18
+ return undefined;
19
+ }
20
+ /** Joins Claude's short-lived jobs into durable, Hive-managed missions. */
21
+ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now()) {
22
+ const orchestrators = [];
23
+ const other = [];
24
+ const sessionsByJob = new Map();
25
+ const sessionsById = new Map();
26
+ for (const session of sessions.values()) {
27
+ sessionsById.set(session.sessionId, session);
28
+ if (session.jobId)
29
+ sessionsByJob.set(session.jobId, session);
30
+ }
31
+ const groups = new Map();
32
+ const claimedSessions = new Set();
33
+ for (const [jobId, job] of jobs) {
34
+ const session = sessionsByJob.get(jobId) ??
35
+ (job.sessionId ? sessionsById.get(job.sessionId) : undefined) ??
36
+ (job.resumeSessionId ? sessionsById.get(job.resumeSessionId) : undefined);
37
+ if (missions?.isDeletedJob(jobId)) {
38
+ if (session)
39
+ claimedSessions.add(session.pid);
40
+ continue;
41
+ }
42
+ const isOrchestrator = job.template === ORCHESTRATOR_AGENT_NAME ||
43
+ session?.agent === ORCHESTRATOR_AGENT_NAME ||
44
+ Boolean(missions?.get(missions.missionFor(jobId)));
45
+ if (!isOrchestrator)
46
+ continue;
47
+ if (session)
48
+ claimedSessions.add(session.pid);
49
+ const missionId = missions?.missionFor(jobId) ?? jobId;
50
+ const group = groups.get(missionId) ?? [];
51
+ group.push({ jobId, job });
52
+ groups.set(missionId, group);
53
+ }
54
+ for (const [missionId, group] of groups) {
55
+ group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
56
+ const latest = group[group.length - 1];
57
+ const oldest = group[0];
58
+ const session = sessionsByJob.get(latest.jobId) ??
59
+ (latest.job.sessionId
60
+ ? sessionsById.get(latest.job.sessionId)
61
+ : undefined) ??
62
+ (latest.job.resumeSessionId
63
+ ? sessionsById.get(latest.job.resumeSessionId)
64
+ : undefined);
65
+ const eventIds = new Set();
66
+ for (const { job } of group) {
67
+ if (job.sessionId)
68
+ eventIds.add(job.sessionId);
69
+ if (job.resumeSessionId)
70
+ eventIds.add(job.resumeSessionId);
71
+ }
72
+ for (const { jobId } of group)
73
+ eventIds.add(jobId);
74
+ if (session)
75
+ eventIds.add(session.sessionId);
76
+ const recentEvents = [...new Map([...eventIds]
77
+ .flatMap((id) => events.recentFor(id))
78
+ .map((event) => [`${event.sessionId}:${event.jobId ?? ""}:${event.ts}:${event.phase}:${event.decisionId ?? ""}:${event.targetWorker ?? ""}:${event.detail}`, event])).values()]
79
+ .sort((a, b) => a.ts - b.ts)
80
+ .slice(-100);
81
+ const derived = deriveStatus(session, latest.job, recentEvents);
82
+ const workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).map((worker) => ({
83
+ ...worker,
84
+ jobId,
85
+ jobState: job.state,
86
+ jobUpdatedAt: dateMs(job.updatedAt) || undefined,
87
+ })));
88
+ const workerIds = new Set(workers.map((worker) => worker.id));
89
+ for (const event of recentEvents) {
90
+ if (!event.targetWorker || workerIds.has(event.targetWorker))
91
+ continue;
92
+ const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
93
+ const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart"));
94
+ if (!start)
95
+ continue;
96
+ const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop"));
97
+ workers.push({ id: event.targetWorker, label: event.targetWorker, kind: "subagent", startedAt: start.ts, doneAt: done?.ts, updatedAt: related.at(-1)?.ts, jobId: event.jobId ?? latest.jobId, jobState: done ? "done" : latest.job.state });
98
+ workerIds.add(event.targetWorker);
99
+ }
100
+ const derivedMessages = group.flatMap(({ jobId, job }) => {
101
+ const createdAt = dateMs(job.createdAt);
102
+ const updatedAt = dateMs(job.updatedAt) || createdAt;
103
+ const result = resultText(job);
104
+ const turns = [];
105
+ if (job.intent) {
106
+ turns.push({
107
+ id: `${jobId}:user`,
108
+ role: "user",
109
+ text: job.intent,
110
+ createdAt,
111
+ jobId,
112
+ });
113
+ }
114
+ if (result) {
115
+ turns.push({
116
+ id: `${jobId}:assistant`,
117
+ role: "assistant",
118
+ text: result,
119
+ createdAt: updatedAt,
120
+ jobId,
121
+ });
122
+ }
123
+ return turns;
124
+ });
125
+ const storedMessages = messageStore?.recent(missionId, 30) ?? [];
126
+ const missionMessages = storedMessages.length ? storedMessages : derivedMessages;
127
+ const prsByHref = new Map();
128
+ for (const { job } of group) {
129
+ for (const child of job.children ?? [])
130
+ prsByHref.set(child.href, child);
131
+ }
132
+ const createdAt = dateMs(oldest.job.createdAt) || session?.startedAt || 0;
133
+ const updatedAt = Math.max(...group.map(({ job }) => dateMs(job.updatedAt)), session?.updatedAt ?? 0, createdAt);
134
+ const mission = missions?.summaryFor(missionId, oldest.job.intent ?? "") ?? {
135
+ id: missionId,
136
+ objective: oldest.job.intent ?? "",
137
+ acceptanceCriteria: [],
138
+ lifecycleStatus: "active",
139
+ mode: "orchestrated",
140
+ };
141
+ const missionPlan = planStore?.get(missionId);
142
+ const inactiveForMs = Math.max(0, now - updatedAt);
143
+ let activityStatus = derived.status;
144
+ if (mission.lifecycleStatus !== "active" && !session) {
145
+ activityStatus = "offline";
146
+ }
147
+ else if (mission.lifecycleStatus === "active") {
148
+ // A Claude turn can be done while its durable mission is still ongoing.
149
+ // In that case the runtime is idle, not mission-complete.
150
+ if (activityStatus === "done")
151
+ activityStatus = "idle";
152
+ const workExpected = session?.status === "busy" || latest.job.state === "working" || latest.job.state === "busy" || workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy")) || Boolean(missionPlan?.phases.some((phase) => phase.status === "working" || phase.status === "reviewing" || allPlanTasks(phase.tasks).some((task) => task.status === "working" || task.status === "reviewing")));
153
+ if (activityStatus === "idle" && workExpected && inactiveForMs >= config.stalledAfterMs) {
154
+ activityStatus = "stalled";
155
+ }
156
+ }
157
+ const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
158
+ const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted: latest.job.state === "done", workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
159
+ orchestrators.push({
160
+ missionId,
161
+ threadId: missionId,
162
+ mission,
163
+ jobId: latest.jobId,
164
+ jobIds: group.map(({ jobId }) => jobId),
165
+ jobHistory: group.map(({ jobId, job }) => ({ jobId, sessionId: job.sessionId, resumedFrom: job.resumeSessionId, state: job.state, createdAt: job.createdAt, updatedAt: job.updatedAt, tokens: job.tokens ?? 0 })),
166
+ sessionId: session?.sessionId ??
167
+ latest.job.sessionId ??
168
+ latest.job.resumeSessionId ??
169
+ latest.jobId,
170
+ pid: session?.pid ?? -1,
171
+ name: missions?.nameFor(missionId) ??
172
+ oldest.job.name ??
173
+ latest.job.name ??
174
+ missionId,
175
+ status: activityStatus,
176
+ lifecycleStatus: mission.lifecycleStatus,
177
+ activityStatus,
178
+ turnCompleted: latest.job.state === "done",
179
+ inactiveForMs,
180
+ waitingFor: session?.waitingFor ?? latest.job.needs,
181
+ worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
182
+ worktreeBranch: latest.job.worktreeBranch ?? oldest.job.worktreeBranch,
183
+ tokens: group.reduce((total, { job }) => total + (job.tokens ?? 0), 0),
184
+ workers,
185
+ messages: missionMessages,
186
+ hasMoreMessages: (messageStore?.count(missionId) ?? missionMessages.length) >
187
+ missionMessages.length,
188
+ detail: latest.job.detail,
189
+ intent: latest.job.intent,
190
+ inFlight: latest.job.inFlight ?? { tasks: 0, queued: 0, kinds: [] },
191
+ recentEvents,
192
+ alerts,
193
+ plan: missionPlan,
194
+ prs: [...prsByHref.values()],
195
+ stale: derived.stale,
196
+ createdAt,
197
+ runStartedAt,
198
+ updatedAt,
199
+ });
200
+ }
201
+ for (const session of sessions.values()) {
202
+ if (claimedSessions.has(session.pid))
203
+ continue;
204
+ const jobEntry = session.jobId ? jobs.get(session.jobId) : undefined;
205
+ const recentEvents = events.recentFor(session.sessionId);
206
+ const { status } = deriveStatus(session, jobEntry, recentEvents);
207
+ other.push({
208
+ jobId: session.jobId,
209
+ sessionId: session.sessionId,
210
+ pid: session.pid,
211
+ name: missions?.nameFor(`session:${session.sessionId}`) ??
212
+ jobEntry?.name ??
213
+ session.name ??
214
+ session.sessionId,
215
+ status,
216
+ waitingFor: session.waitingFor,
217
+ cwd: session.cwd,
218
+ });
219
+ }
220
+ orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
221
+ const decisions = orchestrators.flatMap((node) => {
222
+ const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
223
+ const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
224
+ const fromEvents = node.recentEvents.filter((event) => event.phase === "blocked_on_user" && event.decisionId && !resolved.has(event.decisionId)).map((event) => ({
225
+ id: event.decisionId, missionId: node.missionId, missionName: node.name,
226
+ kind: event.decisionKind ?? "question", question: event.detail, context: event.context,
227
+ choices: event.choices ?? [], recommendation: event.recommendation, impact: event.impact, createdAt: event.ts, ...missionContext,
228
+ }));
229
+ // A permission prompt parks the session mid-turn (tempo blocked, the ask
230
+ // in needs) with no TTY to answer it — surface it as an answerable
231
+ // decision. It self-clears once the job is no longer parked.
232
+ const latestJob = jobs.get(node.jobId);
233
+ const parked = latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
234
+ ? [{
235
+ id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
236
+ kind: "permission", question: latestJob.needs, context: latestJob.detail,
237
+ choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
238
+ }]
239
+ : [];
240
+ return [...fromEvents, ...parked];
241
+ }).sort((a, b) => b.createdAt - a.createdAt);
242
+ return { orchestrators, decisions, other, generatedAt: Date.now() };
243
+ }
@@ -0,0 +1,31 @@
1
+ function normalizedOrigin(value) {
2
+ try {
3
+ const url = new URL(value);
4
+ if (url.protocol !== "http:" && url.protocol !== "https:")
5
+ return undefined;
6
+ return url.origin;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ export function allowedBrowserOrigins(options) {
13
+ const candidates = [
14
+ options.dashboardUrl,
15
+ `http://127.0.0.1:${options.serverPort}`,
16
+ `http://localhost:${options.serverPort}`,
17
+ "http://127.0.0.1:4318",
18
+ "http://localhost:4318",
19
+ ...(options.additionalOrigins?.split(",") ?? []),
20
+ ];
21
+ return new Set(candidates.map((value) => normalizedOrigin(value.trim())).filter((value) => Boolean(value)));
22
+ }
23
+ export function browserOriginAllowed(origin, allowed) {
24
+ // Provider hooks and local CLI calls do not send Origin. Browsers do, so an
25
+ // absent header remains available to trusted local processes while foreign
26
+ // pages and WebSocket connections must match an explicit Hive origin.
27
+ if (origin === undefined)
28
+ return true;
29
+ const normalized = normalizedOrigin(origin);
30
+ return normalized !== undefined && allowed.has(normalized);
31
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { requireWorkingDirectory } from "../paths/pathResolver.js";
5
+ function metadata(file) {
6
+ const text = fs.readFileSync(file, "utf8").slice(0, 8192);
7
+ const frontmatter = text.match(/^---\s*\n([\s\S]*?)\n---/);
8
+ const field = (name) => frontmatter?.[1].match(new RegExp(`^${name}:\\s*(.+)$`, "m"))?.[1]?.trim().replace(/^['"]|['"]$/g, "");
9
+ return { description: field("description"), argumentHint: field("argument-hint") ?? field("argument_hint") ?? field("usage") };
10
+ }
11
+ function collect(root, source) {
12
+ if (!fs.existsSync(root))
13
+ return [];
14
+ return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
15
+ if (entry.name.startsWith("."))
16
+ return [];
17
+ const file = entry.isDirectory()
18
+ ? path.join(root, entry.name, "SKILL.md")
19
+ : entry.isFile() && entry.name.endsWith(".md") ? path.join(root, entry.name) : "";
20
+ if (!file || !fs.existsSync(file))
21
+ return [];
22
+ return [{ name: entry.isDirectory() ? entry.name : path.basename(entry.name, ".md"), ...metadata(file), source }];
23
+ });
24
+ }
25
+ function collectPluginSkills(root) {
26
+ if (!fs.existsSync(root))
27
+ return [];
28
+ const output = [];
29
+ const queue = [{ directory: root, depth: 0 }];
30
+ let visited = 0;
31
+ while (queue.length && visited < 2_000) {
32
+ const item = queue.shift();
33
+ visited += 1;
34
+ let entries;
35
+ try {
36
+ entries = fs.readdirSync(item.directory, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ const skillFile = entries.find((entry) => entry.isFile() && entry.name === "SKILL.md");
42
+ if (skillFile) {
43
+ output.push({ name: path.basename(item.directory), ...metadata(path.join(item.directory, skillFile.name)), source: item.directory.includes(`${path.sep}built-in${path.sep}`) ? "built-in" : "plugin" });
44
+ continue;
45
+ }
46
+ if (item.depth < 7)
47
+ for (const entry of entries)
48
+ if (entry.isDirectory() && !entry.name.startsWith("."))
49
+ queue.push({ directory: path.join(item.directory, entry.name), depth: item.depth + 1 });
50
+ }
51
+ return output;
52
+ }
53
+ export function discoverSkills(cwd) {
54
+ const project = requireWorkingDirectory(cwd);
55
+ const all = [
56
+ ...collect(path.join(project, ".claude", "skills"), "project"),
57
+ ...collect(path.join(project, ".claude", "commands"), "project"),
58
+ ...collect(path.join(os.homedir(), ".claude", "skills"), "user"),
59
+ ...collect(path.join(os.homedir(), ".claude", "commands"), "user"),
60
+ ...collectPluginSkills(path.join(os.homedir(), ".claude", "plugins")),
61
+ ...collect(path.join(project, ".codex", "skills"), "project"),
62
+ ...collect(path.join(os.homedir(), ".codex", "skills"), "user"),
63
+ ...collectPluginSkills(path.join(os.homedir(), ".codex", "plugins", "cache")),
64
+ ];
65
+ const unique = new Map();
66
+ for (const skill of all.reverse())
67
+ unique.set(skill.name, skill);
68
+ return [...unique.values()].sort((a, b) => a.name.localeCompare(b.name));
69
+ }
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { config } from "../config.js";
4
+ import { requireWorkingDirectory } from "../paths/pathResolver.js";
5
+ const definitions = [
6
+ ["feature", "Feature implementation", "Build and verify a product feature.", ["investigation", "implementation", "testing", "review"], ["test", "typecheck", "review"]], ["bugfix", "Bug fixing", "Reproduce, fix, and regress a defect.", ["investigation", "implementation", "testing"], ["test", "review"]], ["pr-review", "PR review", "Perform an evidence-backed pull-request review.", ["review", "testing"], ["review"]], ["incident", "Incident investigation", "Investigate impact, cause, mitigation, and follow-up.", ["investigation", "testing", "documentation"], ["documentation"]], ["security", "Security audit", "Assess threats, validate findings, and recommend fixes.", ["investigation", "review", "testing"], ["test", "review"]], ["refactor", "Refactor", "Improve structure while preserving behavior.", ["investigation", "implementation", "testing", "review"], ["test", "typecheck"]], ["dependency", "Dependency upgrade", "Upgrade dependencies and verify compatibility.", ["investigation", "implementation", "testing"], ["test", "typecheck"]], ["ui", "UI implementation", "Implement and visually verify an interface change.", ["implementation", "browser_qa", "testing", "review"], ["test", "screenshot", "review"]],
7
+ ];
8
+ const permissions = { commits: true, pushes: false, pullRequests: false, releases: false, destructiveActions: false, network: true };
9
+ export const BUILT_IN_TEMPLATES = definitions.map(([id, name, description, roles, gates]) => ({ id, name, description, version: 1, source: "built-in", mode: "orchestrated", worktree: true, managerInstructions: `${name}: delegate independent stages, require concrete evidence, and stop for user approval before external or destructive actions.`, stages: roles.map((role, index) => ({ id: `${id}-${index + 1}`, title: role.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase()), role })), gates: gates.map((type) => ({ id: `${id}-${type}`, label: `${type.replaceAll("_", " ")} complete`, type, status: "pending", required: true, evidence: [] })), budgets: { maxWorkers: 8, maxConcurrentWorkers: 4 }, permissions }));
10
+ const roles = ["investigation", "implementation", "testing", "review", "browser_qa", "documentation", "custom"];
11
+ const gateTypes = ["test", "typecheck", "lint", "ci", "review", "pr", "screenshot", "documentation", "user_approval", "custom"];
12
+ const permissionKeys = ["commits", "pushes", "pullRequests", "releases", "destructiveActions", "network"];
13
+ export function validateTemplate(value, source) {
14
+ if (!value || typeof value !== "object")
15
+ throw new Error("template must be an object");
16
+ const input = value;
17
+ if (!input.id?.match(/^[a-z0-9][a-z0-9-]{1,62}$/))
18
+ throw new Error("template ID must be 2–63 lowercase letters, numbers, or hyphens");
19
+ if (!input.name?.trim() || !input.description?.trim() || !input.managerInstructions?.trim())
20
+ throw new Error("name, description, and manager instructions are required");
21
+ if (!Number.isInteger(input.version) || Number(input.version) < 1)
22
+ throw new Error("template version must be a positive integer");
23
+ if (input.mode !== "direct" && input.mode !== "orchestrated")
24
+ throw new Error("template mode must be direct or orchestrated");
25
+ if (typeof input.worktree !== "boolean")
26
+ throw new Error("template worktree setting must be boolean");
27
+ if (!Array.isArray(input.stages) || !input.stages.length || input.stages.some((stage) => !stage?.id || !stage.title?.trim() || !roles.includes(stage.role)))
28
+ throw new Error("template requires valid stages and worker roles");
29
+ if (new Set(input.stages.map((stage) => stage.id)).size !== input.stages.length)
30
+ throw new Error("template stage IDs must be unique");
31
+ const stageIds = new Set(input.stages.map((stage) => stage.id));
32
+ if (input.stages.some((stage) => (stage.dependsOn ?? []).some((dependency) => !stageIds.has(dependency) || dependency === stage.id)))
33
+ throw new Error("stage dependencies must reference another stage in the template");
34
+ const visiting = new Set();
35
+ const visited = new Set();
36
+ const byId = new Map(input.stages.map((stage) => [stage.id, stage]));
37
+ const cyclic = (id) => { if (visiting.has(id))
38
+ return true; if (visited.has(id))
39
+ return false; visiting.add(id); if ((byId.get(id)?.dependsOn ?? []).some(cyclic))
40
+ return true; visiting.delete(id); visited.add(id); return false; };
41
+ if (input.stages.some((stage) => cyclic(stage.id)))
42
+ throw new Error("stage dependencies cannot contain a cycle");
43
+ if (!Array.isArray(input.gates) || input.gates.some((gate) => !gate?.id || !gate.label?.trim() || !gateTypes.includes(gate.type)))
44
+ throw new Error("template contains an invalid completion gate");
45
+ if (!input.permissions || permissionKeys.some((key) => typeof input.permissions?.[key] !== "boolean"))
46
+ throw new Error("template requires explicit permission settings");
47
+ for (const [key, limit] of Object.entries(input.budgets ?? {}))
48
+ if (!Number.isFinite(limit) || Number(limit) <= 0)
49
+ throw new Error(`${key} must be a positive number`);
50
+ return { ...input, id: input.id, name: input.name.trim(), description: input.description.trim(), managerInstructions: input.managerInstructions.trim(), source };
51
+ }
52
+ function templateRoot(source, cwd) { return source === "user" ? path.join(config.hiveDataDir, "templates") : path.join(requireWorkingDirectory(cwd), ".hive", "templates"); }
53
+ function custom(root, source, diagnostics = []) { if (!fs.existsSync(root))
54
+ return []; return fs.readdirSync(root).filter((file) => file.endsWith(".json")).flatMap((file) => { try {
55
+ return [validateTemplate(JSON.parse(fs.readFileSync(path.join(root, file), "utf8")), source)];
56
+ }
57
+ catch (err) {
58
+ diagnostics.push({ source, file: path.join(root, file), error: err.message });
59
+ return [];
60
+ } }); }
61
+ export function discoverTemplatesDetailed(cwd) { const project = requireWorkingDirectory(cwd); const diagnostics = []; const merged = new Map(BUILT_IN_TEMPLATES.map((template) => [template.id, template])); for (const template of [...custom(path.join(config.hiveDataDir, "templates"), "user", diagnostics), ...custom(path.join(project, ".hive", "templates"), "repository", diagnostics)])
62
+ merged.set(template.id, template); return { templates: [...merged.values()], diagnostics }; }
63
+ export function discoverTemplates(cwd) { return discoverTemplatesDetailed(cwd).templates; }
64
+ export function createTemplate(value, source, cwd) {
65
+ const template = validateTemplate(value, source);
66
+ const root = templateRoot(source, cwd);
67
+ fs.mkdirSync(root, { recursive: true });
68
+ const target = path.join(root, `${template.id}.json`);
69
+ if (fs.existsSync(target))
70
+ throw new Error(`a ${source} template with ID ${template.id} already exists`);
71
+ const temp = `${target}.tmp`;
72
+ fs.writeFileSync(temp, JSON.stringify(template, null, 2));
73
+ fs.renameSync(temp, target);
74
+ return template;
75
+ }
76
+ export function updateTemplate(id, value, source, cwd) {
77
+ if (!id.match(/^[a-z0-9][a-z0-9-]{1,62}$/))
78
+ throw new Error("invalid template ID");
79
+ const template = validateTemplate(value, source);
80
+ if (template.id !== id)
81
+ throw new Error("template ID cannot be changed while editing; duplicate it with a new ID instead");
82
+ const target = path.join(templateRoot(source, cwd), `${id}.json`);
83
+ if (!fs.existsSync(target))
84
+ throw new Error(`${source} template not found: ${id}`);
85
+ const temp = `${target}.tmp`;
86
+ fs.writeFileSync(temp, JSON.stringify(template, null, 2));
87
+ fs.renameSync(temp, target);
88
+ return template;
89
+ }
90
+ export function deleteTemplate(id, source, cwd) {
91
+ if (!id.match(/^[a-z0-9][a-z0-9-]{1,62}$/))
92
+ throw new Error("invalid template ID");
93
+ const target = path.join(templateRoot(source, cwd), `${id}.json`);
94
+ if (!fs.existsSync(target))
95
+ throw new Error(`${source} template not found: ${id}`);
96
+ fs.unlinkSync(target);
97
+ }