@shanesaravia/hive 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/types.d.ts +29 -0
  10. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  11. package/node_modules/@hive/shared/dist/workers.js +22 -0
  12. package/package.json +1 -1
  13. package/packages/server/dist/api/rest.js +65 -10
  14. package/packages/server/dist/api/ws.js +25 -8
  15. package/packages/server/dist/control/launcher.js +50 -11
  16. package/packages/server/dist/control/messaging.js +3 -2
  17. package/packages/server/dist/health/deriveAlerts.js +1 -2
  18. package/packages/server/dist/hooks/hookIngest.js +69 -10
  19. package/packages/server/dist/index.js +20 -4
  20. package/packages/server/dist/messages/messagesStore.js +25 -12
  21. package/packages/server/dist/missions/missionsStore.js +9 -0
  22. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  23. package/packages/server/dist/plans/planReconcile.js +114 -0
  24. package/packages/server/dist/plans/plansStore.js +46 -3
  25. package/packages/server/dist/roster/missionReplay.js +82 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +37 -142
  27. package/packages/server/dist/roster/workerIdentity.js +886 -0
  28. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  29. package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
  30. package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
  31. package/packages/web/dist/index.html +2 -2
  32. package/templates/agents/hive-orchestrator.md +1 -0
  33. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  34. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -1,3 +1,4 @@
1
+ import { ORCHESTRATOR_AGENT_NAME } from "@hive/shared";
1
2
  const SUMMARY_LIMIT = 140;
2
3
  const RAW_STRING_LIMIT = 2_000;
3
4
  const SECRET_KEY = /(token|secret|password|authorization|cookie|api[_-]?key)/i;
@@ -57,11 +58,64 @@ export function skillPromptEvents(payload, jobId) {
57
58
  }];
58
59
  });
59
60
  }
61
+ /**
62
+ * Structured worker attribution. Only a provider agent id may name a person:
63
+ * `agent_type` is a role shared by many workers and previously landed in the
64
+ * same field, which could merge two workers or split one in two.
65
+ *
66
+ * For an Agent launch the payload's top-level ids belong to the *launching*
67
+ * agent, so they become the parent and the new worker's id comes from the tool
68
+ * response. That is what lets a worker's own sub-agent roll up into it instead
69
+ * of taking a desk of its own.
70
+ */
71
+ function workerRef(payload) {
72
+ const top = record(payload);
73
+ const response = record(payload.tool_response);
74
+ const input = record(payload.tool_input);
75
+ // A launch that errored still launched: the provider returns the child id in
76
+ // the failure response too, and the child may already have started.
77
+ const launch = (payload.hook_event_name === "PostToolUse" || payload.hook_event_name === "PostToolUseFailure") && payload.tool_name === "Agent";
78
+ const agentId = launch
79
+ ? stringField(response, "agentId", "agent_id")
80
+ : stringField(top, "agent_id", "subagent_id");
81
+ const parentAgentId = launch
82
+ ? stringField(top, "agent_id", "subagent_id")
83
+ : stringField(top, "parent_agent_id", "parent_id", "parent_agent");
84
+ const ref = {
85
+ agentId,
86
+ agentType: stringField(top, "agent_type"),
87
+ label: launch
88
+ ? stringField(response, "description") ?? stringField(input, "description") ?? stringField(input, "subagent_type")
89
+ : stringField(top, "agent_name", "teammate_name", "description"),
90
+ // A worker's parent is only meaningful when it is another worker.
91
+ parentAgentId: parentAgentId && parentAgentId !== agentId && parentAgentId !== ORCHESTRATOR_AGENT_NAME ? parentAgentId : undefined,
92
+ };
93
+ return Object.values(ref).some(Boolean) ? ref : undefined;
94
+ }
95
+ /**
96
+ * A tool call that leaves work running past the end of the agent's turn.
97
+ *
98
+ * Three shapes: a backgrounded shell command; `Monitor`, which is the sanctioned
99
+ * way to wait (foreground sleep is blocked) and always outlives the turn; and an
100
+ * `Agent` launch the provider answered `isAsync`, which leaves the *launcher*
101
+ * waiting on a child. Missing the last two made a waiting worker look retired.
102
+ */
103
+ function backgrounded(payload) {
104
+ const input = record(payload.tool_input);
105
+ if (input.run_in_background === true || input.runInBackground === true)
106
+ return true;
107
+ if (payload.tool_name === "Monitor" && (payload.hook_event_name === "PostToolUse" || payload.hook_event_name === "PreToolUse"))
108
+ return true;
109
+ if (payload.tool_name === "Agent" && payload.hook_event_name === "PostToolUse" && record(payload.tool_response).isAsync === true)
110
+ return true;
111
+ return undefined;
112
+ }
60
113
  function evidence(payload) {
61
114
  const input = record(payload.tool_input);
62
115
  const tool = payload.tool_name ?? "";
63
116
  const command = stringField(input, "command");
64
117
  const filePath = stringField(input, "file_path", "path", "notebook_path");
118
+ const ref = workerRef(payload);
65
119
  let targetWorker = stringField(payload, "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name");
66
120
  // Agent launches are emitted by the manager, so the payload's top-level
67
121
  // agent_type is `hive-orchestrator`. Claude returns the durable worker id in
@@ -71,28 +125,32 @@ function evidence(payload) {
71
125
  targetWorker = stringField(record(payload.tool_response), "agentId", "agent_id") ?? targetWorker;
72
126
  }
73
127
  if (["TaskCreated", "TaskCompleted", "TeammateIdle"].includes(payload.hook_event_name))
74
- return { activityKind: "agent", targetWorker };
128
+ return { activityKind: "agent", targetWorker, workerRef: ref };
129
+ // The provider's idle notification fires after every turn; it means "the
130
+ // session is idle", not "the assistant asked you something".
131
+ if (payload.hook_event_name === "Notification" && payload.notification_type === "idle_prompt")
132
+ return { activityKind: "agent", targetWorker, workerRef: ref };
75
133
  if (["PermissionRequest", "PermissionDenied", "Elicitation", "ElicitationResult", "Notification"].includes(payload.hook_event_name))
76
- return { activityKind: "decision", targetWorker };
134
+ return { activityKind: "decision", targetWorker, workerRef: ref };
77
135
  if (["InstructionsLoaded", "ConfigChange"].includes(payload.hook_event_name))
78
- return { activityKind: "file_read", filePath: stringField(payload, "file_path", "path"), targetWorker };
136
+ return { activityKind: "file_read", filePath: stringField(payload, "file_path", "path"), targetWorker, workerRef: ref };
79
137
  if (["WorktreeCreate", "WorktreeRemove"].includes(payload.hook_event_name))
80
- return { activityKind: "output", artifactPath: stringField(payload, "worktree_path", "path"), targetWorker };
138
+ return { activityKind: "output", artifactPath: stringField(payload, "worktree_path", "path"), targetWorker, workerRef: ref };
81
139
  if (payload.hook_event_name === "SubagentStart" || payload.hook_event_name === "SubagentStop")
82
- return { activityKind: "agent", targetWorker };
140
+ return { activityKind: "agent", targetWorker, workerRef: ref };
83
141
  if (tool === "Read" || tool === "Glob" || tool === "Grep")
84
- return { activityKind: "file_read", filePath, targetWorker };
142
+ return { activityKind: "file_read", filePath, targetWorker, workerRef: ref };
85
143
  if (["Edit", "Write", "NotebookEdit"].includes(tool)) {
86
144
  const artifactPath = filePath && /\.(png|jpe?g|webp|gif|svg|pdf|docx?|xlsx?|pptx?|zip)$/i.test(filePath) ? filePath : undefined;
87
- return { activityKind: artifactPath ? "output" : "file_write", filePath, artifactPath, targetWorker };
145
+ return { activityKind: artifactPath ? "output" : "file_write", filePath, artifactPath, targetWorker, workerRef: ref };
88
146
  }
89
147
  if (tool === "Bash") {
90
148
  const isTest = Boolean(command && /(^|\s)(test|pytest|vitest|jest|mocha|cargo test|go test|npm test|npm run test|typecheck|lint)(\s|$)/i.test(command));
91
- return { activityKind: isTest ? "test" : "command", command: command?.slice(0, 500), targetWorker };
149
+ return { activityKind: isTest ? "test" : "command", command: command?.slice(0, 500), targetWorker, workerRef: ref };
92
150
  }
93
151
  if (payload.hook_event_name === "StopFailure")
94
- return { activityKind: "lifecycle", targetWorker };
95
- return { activityKind: payload.tool_name ? "tool" : "lifecycle", targetWorker };
152
+ return { activityKind: "lifecycle", targetWorker, workerRef: ref };
153
+ return { activityKind: payload.tool_name ? "tool" : "lifecycle", targetWorker, workerRef: ref };
96
154
  }
97
155
  function outcome(payload) {
98
156
  if (payload.hook_event_name === "PostToolUseFailure" || payload.hook_event_name === "StopFailure" || payload.hook_event_name === "PermissionDenied")
@@ -129,5 +187,6 @@ export function toHiveEvent(payload, jobId) {
129
187
  outcome: outcome(payload),
130
188
  rawPayload: sanitizeRaw(payload),
131
189
  ...evidence(payload),
190
+ backgrounded: backgrounded(payload),
132
191
  };
133
192
  }
@@ -2,7 +2,7 @@ import Fastify from "fastify";
2
2
  import cors from "@fastify/cors";
3
3
  import fastifyStatic from "@fastify/static";
4
4
  import websocket from "@fastify/websocket";
5
- import { existsSync, mkdirSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readdirSync } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { config } from "./config.js";
@@ -12,6 +12,7 @@ import { EventsStore } from "./events/eventsStore.js";
12
12
  import { MissionsStore } from "./missions/missionsStore.js";
13
13
  import { MessagesStore } from "./messages/messagesStore.js";
14
14
  import { PlansStore } from "./plans/plansStore.js";
15
+ import { WorkerIdentityStore } from "./roster/workerIdentity.js";
15
16
  import { registerRest } from "./api/rest.js";
16
17
  import { registerWs } from "./api/ws.js";
17
18
  import { drainHookSpool } from "./hooks/hookSpool.js";
@@ -41,6 +42,7 @@ async function main() {
41
42
  const missions = new MissionsStore();
42
43
  const messages = new MessagesStore();
43
44
  const plans = new PlansStore();
45
+ const workerIdentity = new WorkerIdentityStore();
44
46
  const policies = new PoliciesStore();
45
47
  const codex = new CodexRuntime();
46
48
  events.init();
@@ -58,12 +60,17 @@ async function main() {
58
60
  messages.syncJobs(jobsWatcher.getAll(), missions);
59
61
  jobsWatcher.onJobsChange((jobs) => messages.syncJobs(jobs, missions));
60
62
  missions.onChange(() => messages.syncJobs(jobsWatcher.getAll(), missions));
61
- registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex });
62
- registerWs(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans });
63
- app.get("/health", async () => ({ ok: true }));
64
63
  const webRoot = process.env.HIVE_WEB_DIST
65
64
  ? path.resolve(process.env.HIVE_WEB_DIST)
66
65
  : fileURLToPath(new URL("../../web/dist/", import.meta.url));
66
+ // Identifies the web bundle this server serves. A tab that loaded an older
67
+ // bundle reconnects after a restart and keeps running old code against new
68
+ // data — a fixed bug reported again from a stale tab. The client reloads
69
+ // when this changes.
70
+ const webBuild = webBuildId(webRoot);
71
+ registerRest(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex, workerIdentity });
72
+ registerWs(app, { sessionsWatcher, jobsWatcher, events, missions, messages, plans, workerIdentity, webBuild });
73
+ app.get("/health", async () => ({ ok: true, webBuild }));
67
74
  const isCompiledRuntime = fileURLToPath(import.meta.url).includes(`${path.sep}dist${path.sep}`);
68
75
  if (existsSync(path.join(webRoot, "index.html"))) {
69
76
  await app.register(fastifyStatic, { root: webRoot });
@@ -82,6 +89,15 @@ async function main() {
82
89
  if (replayedHooks)
83
90
  app.log.info(`replayed ${replayedHooks} hook event(s) captured while Hive was offline`);
84
91
  }
92
+ function webBuildId(webRoot) {
93
+ try {
94
+ const entry = readdirSync(path.join(webRoot, "assets")).find((file) => /^index-.*\.js$/.test(file));
95
+ if (entry)
96
+ return entry;
97
+ }
98
+ catch { /* no built bundle: dev server, or tests */ }
99
+ return `dev-${process.pid}-${Date.now()}`;
100
+ }
85
101
  main().catch((err) => {
86
102
  console.error(err);
87
103
  process.exit(1);
@@ -27,6 +27,7 @@ function toMessage(row) {
27
27
  text: row.text,
28
28
  createdAt: row.created_at,
29
29
  jobId: row.job_id ?? "",
30
+ ...(row.kind === "update" ? { kind: "update" } : {}),
30
31
  };
31
32
  }
32
33
  /** Indexed, paginated transcript storage. History is never prompt context by default. */
@@ -44,13 +45,18 @@ export class MessagesStore {
44
45
  role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
45
46
  text TEXT NOT NULL,
46
47
  created_at INTEGER NOT NULL,
47
- job_id TEXT
48
+ job_id TEXT,
49
+ kind TEXT
48
50
  );
49
51
  CREATE INDEX IF NOT EXISTS idx_mission_messages_page
50
52
  ON mission_messages (mission_id, created_at DESC, id DESC);
51
53
  CREATE INDEX IF NOT EXISTS idx_mission_messages_job
52
54
  ON mission_messages (job_id);
53
55
  `);
56
+ try {
57
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN kind TEXT");
58
+ }
59
+ catch { /* already present */ }
54
60
  }
55
61
  close() {
56
62
  this.db.close();
@@ -65,18 +71,19 @@ export class MessagesStore {
65
71
  text: input.text,
66
72
  createdAt: input.createdAt,
67
73
  jobId: input.jobId,
74
+ ...(input.kind ? { kind: input.kind } : {}),
68
75
  };
69
76
  this.db
70
77
  .prepare(`
71
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
72
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
78
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
79
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
73
80
  ON CONFLICT(id) DO UPDATE SET
74
81
  mission_id = excluded.mission_id,
75
82
  text = excluded.text,
76
83
  created_at = excluded.created_at,
77
84
  job_id = excluded.job_id
78
85
  `)
79
- .run({ ...message, missionId: input.missionId, jobId: input.jobId || null });
86
+ .run({ ...message, kind: message.kind ?? null, missionId: input.missionId, jobId: input.jobId || null });
80
87
  this.notify();
81
88
  return message;
82
89
  }
@@ -84,7 +91,7 @@ export class MessagesStore {
84
91
  const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
85
92
  const rows = this.db
86
93
  .prepare(`
87
- SELECT id, mission_id, role, text, created_at, job_id
94
+ SELECT id, mission_id, role, text, created_at, job_id, kind
88
95
  FROM mission_messages
89
96
  WHERE mission_id = @missionId
90
97
  AND (@before IS NULL OR created_at < @before)
@@ -123,7 +130,7 @@ export class MessagesStore {
123
130
  const terms = [...new Set(query.toLowerCase().match(/[a-z0-9_-]{4,}/g) ?? [])].slice(0, 20);
124
131
  if (!terms.length)
125
132
  return [];
126
- const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
133
+ const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id, kind FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
127
134
  let remaining = chars(CONTEXT_BUDGETS.retrievedTokens);
128
135
  return rows.filter((row) => !excludedIds.has(row.id)).map(toMessage).map((message) => ({ message, score: terms.filter((term) => message.text.toLowerCase().includes(term)).length })).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || b.message.createdAt - a.message.createdAt).slice(0, 6).flatMap(({ message }) => { if (remaining <= 0)
129
136
  return []; const text = message.text.slice(0, remaining); remaining -= text.length; return [{ ...message, text }]; }).sort((a, b) => a.createdAt - b.createdAt);
@@ -160,6 +167,12 @@ export class MessagesStore {
160
167
  // Mid-turn permission prompts: the job stays "working" but tempo
161
168
  // flips to blocked with the ask in needs (e.g. "approve Bash: …").
162
169
  ?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
170
+ // What the model said on the way — narration between tool calls — as
171
+ // compact updates, so the conversation shows the work as it goes
172
+ // rather than a single reply at the end.
173
+ const updates = (job.progressTexts ?? []).filter((text) => text !== result);
174
+ const turnStartedAt = timestamp(job.createdAt);
175
+ updates.forEach((text, index) => this.upsertSynced({ id: `${jobId}:update:${index}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
163
176
  if (result) {
164
177
  this.upsertSynced({
165
178
  id: `${jobId}:assistant`,
@@ -182,27 +195,27 @@ export class MessagesStore {
182
195
  insertSynced(message) {
183
196
  this.db
184
197
  .prepare(`
185
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
186
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
198
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
199
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
187
200
  ON CONFLICT(id) DO UPDATE SET
188
201
  mission_id = excluded.mission_id,
189
202
  created_at = excluded.created_at,
190
203
  job_id = excluded.job_id
191
204
  `)
192
- .run(message);
205
+ .run({ ...message, kind: message.kind ?? null });
193
206
  }
194
207
  upsertSynced(message) {
195
208
  this.db
196
209
  .prepare(`
197
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
198
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
210
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
211
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
199
212
  ON CONFLICT(id) DO UPDATE SET
200
213
  mission_id = excluded.mission_id,
201
214
  text = excluded.text,
202
215
  created_at = excluded.created_at,
203
216
  job_id = excluded.job_id
204
217
  `)
205
- .run(message);
218
+ .run({ ...message, kind: message.kind ?? null });
206
219
  }
207
220
  notify() {
208
221
  for (const listener of this.listeners)
@@ -11,6 +11,8 @@ const emptyData = () => ({
11
11
  deletedJobIds: [],
12
12
  });
13
13
  /** Durable Hive mission identity and metadata, independent of Claude jobs. */
14
+ /** The standing this store writes when a mission reaches review, and only that. */
15
+ const AWAITING_ACCEPTANCE = /^awaiting (user )?acceptance\.?$/i;
14
16
  export class MissionsStore {
15
17
  filePath;
16
18
  legacyThreadsPath;
@@ -203,6 +205,13 @@ export class MissionsStore {
203
205
  setLifecycleStatus(missionId, status) {
204
206
  const mission = this.ensureLegacyMission(missionId);
205
207
  mission.lifecycleStatus = status;
208
+ // Reaching review writes "Awaiting user acceptance" as the mission's
209
+ // standing. Leaving review left it there, so a reopened mission kept
210
+ // telling the board it was waiting for an acceptance nobody could give —
211
+ // and the card, having no chip to explain it, just looked stuck.
212
+ if (status !== "ready_for_review" && AWAITING_ACCEPTANCE.test(mission.currentState?.trim() ?? "")) {
213
+ mission.currentState = undefined;
214
+ }
206
215
  mission.updatedAt = Date.now();
207
216
  this.changed();
208
217
  }
@@ -0,0 +1,20 @@
1
+ export function isWorkEvent(event) {
2
+ // Something was written to the repository.
3
+ if (event.activityKind === "file_write")
4
+ return true;
5
+ // An artifact written through the same tools — an image, a PDF, a document.
6
+ if (event.activityKind === "output" && event.filePath)
7
+ return true;
8
+ // A worker started. Note the hook names rather than activityKind "agent",
9
+ // which also covers a teammate going idle — the opposite of work.
10
+ if (event.hookEventName === "SubagentStart" || event.hookEventName === "TaskCreated")
11
+ return true;
12
+ // The orchestrator saying it is handing work out.
13
+ if (event.source === "custom" && event.phase === "delegating")
14
+ return true;
15
+ return false;
16
+ }
17
+ /** The lifecycle states an observed piece of work should pull back to active. */
18
+ export function reopensOnWork(status) {
19
+ return status === "ready_for_review";
20
+ }
@@ -0,0 +1,114 @@
1
+ import { matchesWorker } from "@hive/shared";
2
+ import { allPlanTasks } from "./plansStore.js";
3
+ const key = (value) => value?.trim().toLowerCase() || undefined;
4
+ /**
5
+ * Reconciles the board against the workers that are actually running.
6
+ *
7
+ * Orchestrators reliably launch workers but often forget to republish the plan,
8
+ * and a worker launched without a `delegating` event carries no task id at all.
9
+ * Either way the board sits at "queued" while work is visibly in flight. Rather
10
+ * than warn about that, derive the link from evidence Hive already has: the
11
+ * task ids bound to the worker's identity, or an exact match between the Agent
12
+ * description and a task's title or deliverable.
13
+ *
14
+ * Deliberately one-directional. A running worker is proof work started, so
15
+ * queued/blocked tasks may advance to working. A worker *stopping* is not proof
16
+ * the task succeeded, so completion still requires the orchestrator's report or
17
+ * a gate — this never marks anything done.
18
+ */
19
+ export function reconcilePlanWorkers(plan, workers) {
20
+ if (!plan)
21
+ return [];
22
+ const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
23
+ if (!tasks.length)
24
+ return [];
25
+ const open = (task) => task.status !== "completed" && task.status !== "cancelled";
26
+ const bindings = [];
27
+ // A task already claimed by another worker is never re-pointed here.
28
+ const claimedTasks = new Set(tasks.filter((task) => task.workerId || task.owner).map((task) => task.id));
29
+ for (const worker of workers) {
30
+ const explicit = (worker.taskIds ?? []).filter((id) => tasks.some((task) => task.id === id && open(task)));
31
+ if (explicit.length) {
32
+ for (const taskId of explicit)
33
+ bindings.push({ taskId, canonicalWorker: worker.id, label: worker.label, inferred: false });
34
+ continue;
35
+ }
36
+ // No `--task` was ever emitted for this worker. Fall back to an exact,
37
+ // unambiguous text match: orchestrators normally pass the task's title or
38
+ // its deliverable as the Agent description.
39
+ const name = key(worker.label);
40
+ if (!name)
41
+ continue;
42
+ const candidates = tasks.filter((task) => open(task) && !claimedTasks.has(task.id)
43
+ && (key(task.title) === name || key(task.assignment) === name || key(task.description) === name));
44
+ if (candidates.length !== 1)
45
+ continue;
46
+ claimedTasks.add(candidates[0].id);
47
+ bindings.push({ taskId: candidates[0].id, canonicalWorker: worker.id, label: worker.label, inferred: true });
48
+ }
49
+ // Skip anything the board already reflects.
50
+ return bindings.filter(({ taskId, canonicalWorker }) => {
51
+ const task = tasks.find((item) => item.id === taskId);
52
+ if (!task)
53
+ return false;
54
+ const worker = workers.find((item) => item.id === canonicalWorker);
55
+ const named = matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker);
56
+ const started = task.status === "working" || task.status === "reviewing";
57
+ return !named || !started;
58
+ });
59
+ }
60
+ /**
61
+ * Fill each task's changed files from the writes Hive already observed.
62
+ *
63
+ * `changedFiles` appears in the orchestrator's plan schema but nothing in its
64
+ * instructions ever asks it to be populated, so in practice it stays empty and
65
+ * the acceptance view has no idea what a mission touched. Hive does not need
66
+ * to be told: every file-write hook carries the path and the worker that made
67
+ * it, and workers are already bound to tasks. Deriving beats reporting here —
68
+ * it cannot drift from what actually happened, and it needs no cooperation
69
+ * from the model.
70
+ *
71
+ * Anything the orchestrator *did* report is kept and merged, never replaced:
72
+ * it may know about writes made before Hive was watching.
73
+ */
74
+ export function deriveChangedFiles(plan, workers, events) {
75
+ if (!plan)
76
+ return plan;
77
+ const writes = events.filter((event) => event.activityKind === "file_write" && event.filePath);
78
+ if (!writes.length)
79
+ return plan;
80
+ const tasks = plan.phases.flatMap((phase) => allPlanTasks(phase.tasks));
81
+ const byTask = new Map();
82
+ const add = (taskId, filePath) => {
83
+ const set = byTask.get(taskId) ?? new Set();
84
+ set.add(filePath);
85
+ byTask.set(taskId, set);
86
+ };
87
+ for (const event of writes) {
88
+ // An explicit `--task` is the orchestrator's own attribution; trust it.
89
+ if (event.targetTask && tasks.some((task) => task.id === event.targetTask)) {
90
+ add(event.targetTask, event.filePath);
91
+ continue;
92
+ }
93
+ // Otherwise go through the worker, using the one identity comparison the
94
+ // rest of Hive uses, so an alias or a delegation label resolves the same.
95
+ const worker = workers.find((item) => matchesWorker(event.targetWorker, item));
96
+ if (!worker)
97
+ continue;
98
+ const owned = tasks.filter((task) => (worker.taskIds ?? []).includes(task.id) || matchesWorker(task.workerId, worker) || matchesWorker(task.owner, worker));
99
+ // A worker covering several tasks gives no evidence about which one a
100
+ // write belongs to, so attribute only when the answer is unambiguous.
101
+ if (owned.length === 1)
102
+ add(owned[0].id, event.filePath);
103
+ }
104
+ if (!byTask.size)
105
+ return plan;
106
+ const merge = (task) => {
107
+ const derived = byTask.get(task.id);
108
+ const subtasks = task.subtasks?.length ? task.subtasks.map(merge) : task.subtasks;
109
+ if (!derived)
110
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
111
+ return { ...task, changedFiles: [...new Set([...(task.changedFiles ?? []), ...derived])].sort(), subtasks };
112
+ };
113
+ return { ...plan, phases: plan.phases.map((phase) => ({ ...phase, tasks: phase.tasks.map(merge) })) };
114
+ }
@@ -124,8 +124,11 @@ export class PlansStore {
124
124
  const now = Date.now();
125
125
  const mapTasks = (tasks) => tasks.map((task) => {
126
126
  const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
127
+ // A worker is addressed by whichever name the orchestrator used; the plan
128
+ // stores the canonical identity so every consumer can compare exactly.
129
+ const workerNames = [input.canonicalWorker, input.targetWorker].filter((value) => Boolean(value));
127
130
  const matched = (input.targetTask !== undefined && task.id === input.targetTask)
128
- || (input.targetWorker !== undefined && (task.workerId === input.targetWorker || task.owner === input.targetWorker));
131
+ || workerNames.some((name) => task.workerId === name || task.owner === name);
129
132
  // A parent cannot complete ahead of its subtasks (normalize rejects it).
130
133
  const blockedBySubtasks = nextStatus === "completed" && (subtasks ?? []).some((subtask) => subtask.status !== "completed" && subtask.status !== "cancelled");
131
134
  if (!matched || blockedBySubtasks || !allowedFrom[nextStatus].includes(task.status)) {
@@ -138,8 +141,8 @@ export class PlansStore {
138
141
  status: nextStatus,
139
142
  updatedAt: now,
140
143
  startedAt: task.startedAt ?? now,
141
- owner: task.owner ?? input.targetWorker,
142
- workerId: task.workerId ?? input.targetWorker,
144
+ owner: task.owner ?? input.canonicalWorker ?? input.targetWorker,
145
+ workerId: input.canonicalWorker ?? task.workerId ?? input.targetWorker,
143
146
  evidence: nextStatus === "completed" && input.evidence?.trim() ? [...task.evidence, input.evidence.trim().slice(0, 300)] : task.evidence,
144
147
  };
145
148
  });
@@ -157,6 +160,46 @@ export class PlansStore {
157
160
  return undefined;
158
161
  return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
159
162
  }
163
+ /**
164
+ * Records that a worker is running a task: stamps the canonical identity and
165
+ * advances a queued/blocked task to working. Forward-only and idempotent, so
166
+ * a repeated snapshot cannot churn revisions, and never marks work complete —
167
+ * a running worker proves work started, not that it succeeded.
168
+ */
169
+ attachWorker(missionId, input) {
170
+ const plan = this.get(missionId);
171
+ if (!plan)
172
+ return undefined;
173
+ let changed = false;
174
+ const now = Date.now();
175
+ const mapTasks = (tasks) => tasks.map((task) => {
176
+ const subtasks = task.subtasks?.length ? mapTasks(task.subtasks) : task.subtasks;
177
+ if (task.id !== input.taskId)
178
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
179
+ const advancing = task.status === "queued" || task.status === "blocked";
180
+ if (task.workerId === input.canonicalWorker && !advancing)
181
+ return subtasks === task.subtasks ? task : { ...task, subtasks };
182
+ changed = true;
183
+ return {
184
+ ...task,
185
+ subtasks,
186
+ status: advancing ? "working" : task.status,
187
+ owner: task.owner ?? input.label ?? input.canonicalWorker,
188
+ workerId: input.canonicalWorker,
189
+ startedAt: task.startedAt ?? now,
190
+ updatedAt: now,
191
+ };
192
+ });
193
+ const phases = plan.phases.map((phase) => {
194
+ const tasks = mapTasks(phase.tasks);
195
+ const statuses = allPlanTasks(tasks).map((task) => task.status);
196
+ const status = (phase.status === "queued" || phase.status === "completed") && statuses.some((s) => s === "working" || s === "reviewing") ? "working" : phase.status;
197
+ return { ...phase, status, tasks };
198
+ });
199
+ if (!changed)
200
+ return undefined;
201
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: phases[0]?.tasks ?? [] } : { phases }), gates: plan.gates ?? [], approvalStatus: plan.approvalStatus ?? "proposed", approvalReason: plan.approvalReason });
202
+ }
160
203
  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)
161
204
  listener(); }
162
205
  migrateMission(oldId, newId) { this.db.transaction(() => { const current = this.get(oldId); if (current) {
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MissionsStore } from "../missions/missionsStore.js";
6
+ import { buildFleetSnapshot } from "./rosterBuilder.js";
7
+ import { WorkerIdentityStore } from "./workerIdentity.js";
8
+ const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
9
+ export function loadMissionFixture(name) {
10
+ return JSON.parse(fs.readFileSync(path.join(fixturesDir, `${name}.events.json`), "utf8"));
11
+ }
12
+ /**
13
+ * The manager's job is `done` between a Stop and the next prompt — the turn
14
+ * boundary that repeatedly got mistaken for the end of the mission.
15
+ */
16
+ function jobStateAt(events) {
17
+ let state = "working";
18
+ for (const event of events) {
19
+ if (event.hookEventName === "Stop")
20
+ state = "done";
21
+ if (event.hookEventName === "UserPromptSubmit")
22
+ state = "working";
23
+ }
24
+ return state;
25
+ }
26
+ export function replayMission(fixture, options = {}) {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-replay-${fixture.name}-`));
28
+ options.cleanup?.(() => fs.rmSync(dir, { recursive: true, force: true }));
29
+ const missions = new MissionsStore(path.join(dir, "missions.json"), path.join(dir, "threads.json"));
30
+ missions.init();
31
+ const primaryJob = fixture.jobs[0];
32
+ const mission = missions.create({
33
+ name: fixture.name, objective: fixture.objective, jobId: primaryJob?.jobId ?? fixture.missionId,
34
+ sessionId: fixture.sessionId, mode: fixture.mode,
35
+ });
36
+ for (const { jobId } of fixture.jobs.slice(1))
37
+ missions.linkJob(mission.id, jobId);
38
+ const identity = new WorkerIdentityStore(path.join(dir, "hive.db"));
39
+ // A read-only stand-in for the plans store: the recorded revisions already
40
+ // contain the worker bindings production wrote, so replaying must not write
41
+ // new ones over them.
42
+ const revisions = [...(fixture.planRevisions ?? [])].sort((a, b) => a.updatedAt - b.updatedAt);
43
+ const plansAt = (at) => ({
44
+ get: () => [...revisions].reverse().find((entry) => entry.updatedAt <= at)?.plan,
45
+ attachWorker: () => undefined,
46
+ });
47
+ const start = fixture.events[0]?.ts ?? 0;
48
+ const steps = [];
49
+ for (let index = 1; index <= fixture.events.length; index += 1) {
50
+ const seen = fixture.events.slice(0, index);
51
+ const event = seen[seen.length - 1];
52
+ const now = event.ts + 1;
53
+ // The REST ingest moves the mission to review on this event; a replay that
54
+ // left it active never showed the office the moment the manager leaves
55
+ // for the hall while workers are still on their way out (mission test41).
56
+ if (event.source === "custom" && event.phase === "ready_for_review")
57
+ missions.setLifecycleStatus(mission.id, "ready_for_review");
58
+ const state = jobStateAt(seen);
59
+ const jobs = new Map(fixture.jobs.map(({ jobId, job }) => [jobId, {
60
+ ...job,
61
+ state,
62
+ // The provider's fan file is written as workers finish; before the turn
63
+ // ends the roster only has hook evidence to go on. A recorded fixture
64
+ // holds the file's *final* contents, so entries — and their doneAt — are
65
+ // masked until the moment they were really written. Without that the
66
+ // replay hands the roster a worker's completion long before it happened.
67
+ fan: state === "done"
68
+ ? job.fan.filter((entry) => entry.startedAt <= now).map((entry) => ({ ...entry, doneAt: entry.doneAt !== undefined && entry.doneAt <= now ? entry.doneAt : undefined }))
69
+ : [],
70
+ }]));
71
+ const events = {
72
+ recentFor: (id, limit = 50) => seen.filter((item) => item.sessionId === id || item.jobId === id).slice(-limit),
73
+ sessionIds: () => [...new Set(seen.map((item) => item.sessionId))],
74
+ };
75
+ steps.push({ elapsedMs: event.ts - start, event, snapshot: buildFleetSnapshot(new Map(), jobs, events, missions, undefined, plansAt(now), now, identity) });
76
+ }
77
+ return steps;
78
+ }
79
+ /** The mission node under replay, for the one mission a fixture contains. */
80
+ export function replayedMission(step) {
81
+ return step.snapshot.orchestrators[0];
82
+ }