@shanesaravia/hive 0.1.0 → 0.2.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.
@@ -1,14 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { canClearDirectStudioMission } from "@hive/shared";
2
3
  import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
3
4
  import { allPlanTasks } from "../plans/plansStore.js";
4
5
  import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
5
- import { toHiveEvent } from "../hooks/hookIngest.js";
6
+ import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
6
7
  import { startOrchestrator } from "../control/launcher.js";
7
8
  import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
8
9
  import { sendMessage } from "../control/messaging.js";
9
10
  import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
10
11
  import { forceStopSession, stopSession } from "../control/killer.js";
11
12
  import { discoverSkills } from "../skills/skillDiscovery.js";
13
+ import { translateSkillInvocations } from "../skills/skillInvocation.js";
12
14
  import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
13
15
  import { missionReport, reportMarkdown } from "../reports/missionReport.js";
14
16
  import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDetailed, updateTemplate } from "../templates/templateDiscovery.js";
@@ -45,27 +47,52 @@ export function registerRest(app, deps) {
45
47
  throw new Error("mission not found");
46
48
  const summary = missions.summaryFor(missionId);
47
49
  const policy = summary.policy ?? policies.get();
50
+ const provider = summary.provider ?? "claude";
51
+ const providerText = translateSkillInvocations(text, provider, new Set(discoverSkills(summary.repository, provider).map((skill) => skill.name)));
48
52
  // "add this to monorepo" should just work: repos mentioned by name get
49
53
  // --add-dir access, persisted so every later turn keeps the grant.
50
- const mentioned = detectRepositoryMentions(text, summary.repository);
54
+ const mentioned = detectRepositoryMentions(providerText, summary.repository);
51
55
  const additionalRepos = [...new Set([...(summary.additionalRepositories ?? []), ...mentioned])];
52
56
  if (mentioned.length)
53
57
  missions.addRepositories(missionId, mentioned);
54
- const prompt = additionalRepos.length ? `${text}\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : text;
55
- const result = summary.provider === "codex"
56
- ? await codex.start({ task: prompt, cwd: summary.repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
57
- : await sendMessage(target.sessionId, prompt, policy, additionalRepos);
58
+ const prompt = additionalRepos.length ? `${providerText}\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : providerText;
59
+ // Move the manager out of the shared review hall before asking the
60
+ // provider to resume. Fast subagents can start and finish while the CLI
61
+ // launch call is still pending; reopening afterward leaves their FIFO
62
+ // stories assigned to a room with no worker desks.
63
+ const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
64
+ if (reopening)
65
+ missions.setLifecycleStatus(missionId, "active");
66
+ let result;
67
+ try {
68
+ result = provider === "codex"
69
+ ? await codex.start({ task: prompt, cwd: summary.repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
70
+ : await sendMessage(target.sessionId, prompt, policy, additionalRepos);
71
+ }
72
+ catch (error) {
73
+ if (reopening)
74
+ missions.setLifecycleStatus(missionId, summary.lifecycleStatus);
75
+ throw error;
76
+ }
58
77
  missions.linkJob(missionId, result.jobId, result.sessionId);
59
78
  messages.add({ id: `${result.jobId}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
60
79
  // A follow-up to a closed mission is an implicit reopen — work resumed.
61
- if (["completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus)) {
62
- missions.setLifecycleStatus(missionId, "active");
80
+ if (reopening) {
63
81
  events.add({ ts: Date.now(), sessionId: result.sessionId ?? target.sessionId, jobId: result.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission reopened from ${summary.lifecycleStatus} by a new message` });
64
82
  }
65
83
  return result;
66
84
  });
67
85
  }
68
86
  app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans));
87
+ app.post("/api/direct-studio/clear-completed", async () => {
88
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
89
+ const eligible = snapshot.orchestrators.filter(canClearDirectStudioMission);
90
+ for (const node of eligible) {
91
+ missions.setLifecycleStatus(node.missionId, "completed");
92
+ events.add({ ts: Date.now(), sessionId: node.sessionId, jobId: node.jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: "Direct mission completed through Clear completed desks" });
93
+ }
94
+ return { ok: true, cleared: eligible.map((node) => node.missionId), remaining: snapshot.orchestrators.filter((node) => node.mission.mode === "direct" && node.lifecycleStatus === "active").length - eligible.length };
95
+ });
69
96
  app.get("/api/policies", async () => ({ policy: policies.get() }));
70
97
  app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
71
98
  app.put("/api/policies", async (req, reply) => {
@@ -172,6 +199,8 @@ export function registerRest(app, deps) {
172
199
  const jobId = sessionForId(sessionsWatcher, payload.session_id)?.jobId;
173
200
  const event = toHiveEvent(payload, jobId);
174
201
  events.add(event);
202
+ for (const promptEvent of skillPromptEvents(payload, jobId))
203
+ events.add(promptEvent);
175
204
  // A write landing in another known repo marks that repo as touched by
176
205
  // the mission, so its footprint chips reflect reality, not just intent.
177
206
  if (event.activityKind === "file_write" && event.filePath && jobId) {
@@ -303,6 +332,70 @@ export function registerRest(app, deps) {
303
332
  return { error: err.message };
304
333
  }
305
334
  });
335
+ app.post("/api/mission/:missionId/gate", async (req, reply) => {
336
+ if (!missions.get(req.params.missionId)) {
337
+ reply.code(404);
338
+ return { error: "mission not found" };
339
+ }
340
+ try {
341
+ const plan = plans.addUserGate(req.params.missionId, req.body);
342
+ const gate = plan.gates.find((item) => item.source === "user" && item.label === req.body.label && item.type === req.body.type);
343
+ let notified = true;
344
+ try {
345
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
346
+ A ${gate.required ? "required" : "optional"} ${gate.type.replaceAll("_", " ")} gate was added by the user: “${gate.label}”.
347
+ Update the structured plan to include this durable requirement. Continue or delegate any work needed to satisfy it, publish concrete evidence when complete, and do not remove or weaken this user-created gate.`);
348
+ }
349
+ catch {
350
+ notified = false;
351
+ }
352
+ return { plan, notified };
353
+ }
354
+ catch (err) {
355
+ reply.code(400);
356
+ return { error: err.message };
357
+ }
358
+ });
359
+ app.put("/api/mission/:missionId/gate/:gateId", async (req, reply) => {
360
+ try {
361
+ const plan = plans.updateUserGate(req.params.missionId, req.params.gateId, req.body);
362
+ const gate = plan.gates.find((item) => item.id === req.params.gateId);
363
+ let notified = true;
364
+ try {
365
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
366
+ The user changed a durable completion gate. It is now a ${gate.required ? "required" : "optional"} ${gate.type.replaceAll("_", " ")} gate: “${gate.label}”.
367
+ Revise the structured plan immediately, reconcile existing work against this requirement, and continue or delegate the work needed to satisfy it with concrete evidence.`);
368
+ }
369
+ catch {
370
+ notified = false;
371
+ }
372
+ return { plan, notified };
373
+ }
374
+ catch (err) {
375
+ reply.code(400);
376
+ return { error: err.message };
377
+ }
378
+ });
379
+ app.delete("/api/mission/:missionId/gate/:gateId", async (req, reply) => {
380
+ try {
381
+ const previous = plans.get(req.params.missionId)?.gates.find((item) => item.id === req.params.gateId);
382
+ const plan = plans.removeUserGate(req.params.missionId, req.params.gateId);
383
+ let notified = true;
384
+ try {
385
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
386
+ The user removed the completion gate “${previous?.label ?? req.params.gateId}”.
387
+ Revise the structured plan to remove that requirement and stop work that was needed only for that gate. Preserve all other user-created gates.`);
388
+ }
389
+ catch {
390
+ notified = false;
391
+ }
392
+ return { plan, notified };
393
+ }
394
+ catch (err) {
395
+ reply.code(400);
396
+ return { error: err.message };
397
+ }
398
+ });
306
399
  app.post("/api/mission/:missionId/plan/approval", async (req, reply) => {
307
400
  if (req.body?.status !== "approved" && req.body?.status !== "rejected") {
308
401
  reply.code(400);
@@ -313,9 +406,20 @@ export function registerRest(app, deps) {
313
406
  const target = latestMissionTarget(req.params.missionId);
314
407
  if (target)
315
408
  events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "decision", detail: req.body.status === "approved" ? `Plan revision ${plan.revision - 1} approved` : `Plan rejected: ${plan.approvalReason}` });
316
- if (req.body.status === "rejected") {
409
+ if (req.body.status === "approved") {
317
410
  try {
318
- await sendToMission(req.params.missionId, `The current plan was rejected. Revise it before continuing. Feedback: ${plan.approvalReason}`);
411
+ const result = await sendToMission(req.params.missionId, `The proposed plan is approved. Begin implementation now, following the approved plan. You may spawn and delegate to workers as appropriate. Keep the structured plan current and attach concrete evidence as work completes.`);
412
+ const missionEvents = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === req.params.missionId).flatMap(([, job]) => [job.sessionId, job.resumeSessionId].filter((value) => Boolean(value))).flatMap((sessionId) => events.recentFor(sessionId, 200));
413
+ const resolved = new Set(missionEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
414
+ for (const pending of missionEvents.filter((event) => event.phase === "blocked_on_user" && event.decisionId && !resolved.has(event.decisionId) && (event.decisionId === "plan-approval" || /approve the proposed plan/i.test(event.detail)))) {
415
+ events.add({ ts: Date.now(), sessionId: result.sessionId ?? pending.sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: "Plan approved", decisionId: pending.decisionId });
416
+ }
417
+ }
418
+ catch { /* approval remains recorded even when no live job can resume */ }
419
+ }
420
+ else {
421
+ try {
422
+ await sendToMission(req.params.missionId, `The current plan was rejected. Do not begin implementation. Revise and republish the plan with every task still queued, then stop again for approval. Feedback: ${plan.approvalReason}`);
319
423
  }
320
424
  catch { /* rejection remains recorded even when no live job can resume */ }
321
425
  }
@@ -360,9 +464,12 @@ export function registerRest(app, deps) {
360
464
  const policyPrompt = `\n\nHive permission policy: ${JSON.stringify(policy)}. These are hard boundaries where enforced by CLI tool restrictions. Before any action outside the policy, stop and emit blocked_on_user with the exact access needed; do not attempt to bypass the restriction.`;
361
465
  const provider = req.body.provider ?? "claude";
362
466
  const model = req.body.model?.trim() || undefined;
467
+ const planFirst = mode === "orchestrated" && req.body.planFirst === true;
363
468
  const additionalRepos = detectRepositoryMentions(task, resolvedCwd);
364
469
  const repoPrompt = additionalRepos.length ? `\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : "";
365
- const prompt = task + templatePrompt + limits + policyPrompt + repoPrompt;
470
+ const planFirstPrompt = planFirst ? `\n\nHive Plan First checkpoint (mandatory): Before editing files, running implementation commands, or spawning/delegating to any workers, investigate only enough to propose a concrete structured plan. Publish that plan with every task and phase still queued and approvalStatus \"proposed\". Then emit blocked_on_user with decision id \"plan-approval\", kind \"scope\", and question \"Approve the proposed plan before implementation begins?\" Stop the turn after emitting it. Do not begin implementation until Hive resumes you with explicit plan approval. If changes are requested, revise and republish the queued plan, emit the same plan-approval decision again, and stop.` : "";
471
+ const providerTask = translateSkillInvocations(task, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
472
+ const prompt = providerTask + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
366
473
  const result = provider === "codex"
367
474
  ? await codex.start({ task: prompt, cwd: resolvedCwd, name, model, mode, policy })
368
475
  : await startOrchestrator({ task: prompt, worktree, name, cwd: resolvedCwd, mode, policy, model, addDirs: additionalRepos });
@@ -378,6 +485,7 @@ export function registerRest(app, deps) {
378
485
  policy,
379
486
  provider,
380
487
  model,
488
+ planFirst,
381
489
  additionalRepositories: additionalRepos,
382
490
  });
383
491
  messages.add({
@@ -389,7 +497,7 @@ export function registerRest(app, deps) {
389
497
  jobId: result.jobId,
390
498
  });
391
499
  if (template)
392
- plans.replace(mission.id, { phases: [{ id: "template", title: template.name, description: template.description, status: "queued", acceptanceCriteria: template.gates.map((gate) => gate.label), dependsOn: [], tasks: template.stages.map((stage, index) => ({ id: stage.id, title: stage.title, role: stage.role, status: "queued", weight: 1, dependsOn: stage.dependsOn ?? (index ? [template.stages[index - 1].id] : []), evidence: [] })) }], gates: template.gates });
500
+ plans.replace(mission.id, { phases: [{ id: "template", title: template.name, description: template.description, status: "queued", acceptanceCriteria: template.gates.map((gate) => gate.label), dependsOn: [], tasks: template.stages.map((stage, index) => ({ id: stage.id, title: stage.title, role: stage.role, status: "queued", weight: 1, dependsOn: stage.dependsOn ?? (index ? [template.stages[index - 1].id] : []), evidence: [] })) }], gates: template.gates.map((gate) => ({ ...gate, source: "template" })) });
393
501
  return { ...result, missionId: mission.id };
394
502
  }
395
503
  catch (err) {
@@ -399,7 +507,7 @@ export function registerRest(app, deps) {
399
507
  });
400
508
  app.post("/api/skills/discover", async (req, reply) => {
401
509
  try {
402
- return { skills: discoverSkills(req.body?.cwd) };
510
+ return { skills: discoverSkills(req.body?.cwd, req.body?.provider === "codex" ? "codex" : "claude") };
403
511
  }
404
512
  catch (err) {
405
513
  reply.code(400);
@@ -507,6 +615,22 @@ export function registerRest(app, deps) {
507
615
  reply.code(404);
508
616
  return { error: "mission not found" };
509
617
  }
618
+ if (req.params.decisionId === "plan-approval") {
619
+ const approved = /^(approve|approved|begin|proceed|yes)/i.test(answer);
620
+ try {
621
+ plans.setApproval(req.params.missionId, approved ? "approved" : "rejected", approved ? undefined : answer);
622
+ const result = await sendToMission(req.params.missionId, approved
623
+ ? "The proposed plan is approved. Begin implementation now, delegate to workers as appropriate, and keep the structured plan current with concrete evidence."
624
+ : `The proposed plan is not approved. Do not begin implementation. Revise and republish it with all work still queued, then stop again for approval. Feedback: ${answer}`);
625
+ if (approved)
626
+ events.add({ ts: Date.now(), sessionId: result.sessionId ?? latestMissionTarget(req.params.missionId)?.sessionId ?? "plan-approval", jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: "Plan approved", decisionId: req.params.decisionId });
627
+ return { ok: true };
628
+ }
629
+ catch (err) {
630
+ reply.code(409);
631
+ return { error: err.message };
632
+ }
633
+ }
510
634
  // Parked permission prompts (decision id perm:<jobId>): a background
511
635
  // session cannot answer its own prompt, so end the parked turn and
512
636
  // resume the conversation with the verdict — pre-allowing the asked
@@ -545,6 +669,23 @@ export function registerRest(app, deps) {
545
669
  return { error: err.message };
546
670
  }
547
671
  }
672
+ if (req.params.decisionId.startsWith("prompt:")) {
673
+ const jobId = req.params.decisionId.slice(7);
674
+ const job = jobsWatcher.getAll().get(jobId);
675
+ if (!job || job.tempo !== "blocked" || !job.needs) {
676
+ reply.code(409);
677
+ return { error: "provider prompt is no longer pending" };
678
+ }
679
+ try {
680
+ const result = await sendToMission(req.params.missionId, `Answer to the skill's pending question (${job.needs}): ${answer}\n\nContinue the skill from the point where it requested this input.`);
681
+ events.add({ ts: Date.now(), sessionId: result.sessionId ?? job.sessionId ?? jobId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
682
+ return { ok: true };
683
+ }
684
+ catch (err) {
685
+ reply.code(500);
686
+ return { error: err.message };
687
+ }
688
+ }
548
689
  const nodeEvents = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === req.params.missionId).flatMap(([, job]) => [job.sessionId, job.resumeSessionId].filter((value) => Boolean(value))).flatMap((sessionId) => events.recentFor(sessionId, 200));
549
690
  const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
550
691
  const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
@@ -553,7 +694,17 @@ export function registerRest(app, deps) {
553
694
  return { error: "decision is no longer pending" };
554
695
  }
555
696
  try {
556
- const result = await sendToMission(req.params.missionId, `Answer to your pending question: ${answer}`);
697
+ const skillPrefix = req.params.decisionId.startsWith("skill-prompt:") ? req.params.decisionId.slice(0, req.params.decisionId.lastIndexOf(":")) : undefined;
698
+ const siblings = skillPrefix ? nodeEvents.filter((event) => event.phase === "blocked_on_user" && event.decisionId?.startsWith(`${skillPrefix}:`)) : [pending];
699
+ const resolvedAnswers = new Map(nodeEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => [event.decisionId, event.detail]));
700
+ resolvedAnswers.set(req.params.decisionId, answer);
701
+ const remaining = siblings.filter((event) => event.decisionId && !resolvedAnswers.has(event.decisionId));
702
+ if (remaining.length) {
703
+ events.add({ ts: Date.now(), sessionId: pending.sessionId, jobId: pending.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
704
+ return { ok: true };
705
+ }
706
+ const combined = siblings.map((event) => `${event.detail}\nAnswer: ${resolvedAnswers.get(event.decisionId)}`).join("\n\n");
707
+ const result = await sendToMission(req.params.missionId, skillPrefix ? `Answers to the skill's pending questions:\n\n${combined}\n\nContinue the skill from the point where it requested this input.` : `Answer to your pending question: ${answer}`);
557
708
  events.add({ ts: Date.now(), sessionId: result.sessionId ?? pending.sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
558
709
  return { ok: true };
559
710
  }
@@ -7,6 +7,22 @@ import { config } from "../config.js";
7
7
  import { enforceWorkingDirectory } from "../policies/policiesStore.js";
8
8
  import { requireWorkingDirectory } from "../paths/pathResolver.js";
9
9
  const HIVE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
10
+ export function parseCodexSkillPrompt(message) {
11
+ if (!message)
12
+ return undefined;
13
+ const match = message.match(/\[HIVE_USER_PROMPT\]\s*(\{[^\n]+\})/);
14
+ if (!match)
15
+ return undefined;
16
+ try {
17
+ const value = JSON.parse(match[1]);
18
+ if (typeof value.question !== "string" || !value.question.trim())
19
+ return undefined;
20
+ return { question: value.question.trim(), choices: Array.isArray(value.choices) ? value.choices.slice(0, 8).map(String) : [], cleanMessage: message.replace(match[0], "").trim() };
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
10
26
  export class CodexRuntime {
11
27
  monitor;
12
28
  init() { fs.mkdirSync(config.codexJobsDir, { recursive: true }); fs.mkdirSync(config.codexSessionsDir, { recursive: true }); this.reconcile(); this.monitor = setInterval(() => this.reconcile(), 1_000); this.monitor.unref(); }
@@ -74,7 +90,7 @@ export class CodexRuntime {
74
90
  manager = "You are the manager for a Hive mission. Delegate independent work to subagents when useful, keep a structured plan current, and synthesize their evidence.\n\n";
75
91
  }
76
92
  }
77
- return `${manager}${options.task}\n\nHive policy: ${JSON.stringify(options.policy)}. Work only inside the selected repository and workspace sandbox. Do not commit, push, open PRs, release, publish, use network access, or perform destructive actions when the corresponding policy value is false. If access or budget expansion is needed, stop and clearly ask the user for approval.`;
93
+ return `${manager}${options.task}\n\nHive policy: ${JSON.stringify(options.policy)}. Work only inside the selected repository and workspace sandbox. Do not commit, push, open PRs, release, publish, use network access, or perform destructive actions when the corresponding policy value is false. If access or budget expansion is needed, stop and clearly ask the user for approval. If an invoked skill explicitly requires user input, do not guess or skip it. End the turn with exactly one single-line marker [HIVE_USER_PROMPT]{"question":"the question to surface","choices":["choice 1","choice 2"]}; use an empty choices array for free text. Hive will resume this same mission with the user's answer.`;
78
94
  }
79
95
  complete(jobId, pid, sessionId, code) {
80
96
  const job = this.readJob(jobId);
@@ -82,10 +98,14 @@ export class CodexRuntime {
82
98
  return;
83
99
  const events = readJsonLines(path.join(config.codexJobsDir, jobId, "timeline.jsonl"));
84
100
  const message = events.filter((event) => event.type === "item.completed" && object(event.item)?.type === "agent_message").map((event) => String(object(event.item)?.text ?? "")).filter(Boolean).at(-1);
101
+ const skillPrompt = parseCodexSkillPrompt(message);
85
102
  const usage = object([...events].reverse().find((event) => event.type === "turn.completed")?.usage);
86
- job.state = code === 0 ? "done" : "error";
87
- job.detail = code === 0 ? "Codex turn completed" : `Codex exited with code ${code}`;
88
- job.output = message;
103
+ job.state = skillPrompt ? "blocked" : code === 0 ? "done" : "error";
104
+ job.tempo = skillPrompt ? "blocked" : job.tempo;
105
+ job.needs = skillPrompt?.question;
106
+ job.promptChoices = skillPrompt?.choices;
107
+ job.detail = skillPrompt ? "Skill is waiting for user input" : code === 0 ? "Codex turn completed" : `Codex exited with code ${code}`;
108
+ job.output = skillPrompt?.cleanMessage || message;
89
109
  job.tokens = Number(usage?.input_tokens ?? 0) + Number(usage?.output_tokens ?? 0);
90
110
  job.sessionId = sessionId;
91
111
  job.updatedAt = new Date().toISOString();
@@ -21,12 +21,55 @@ function stringField(input, ...keys) {
21
21
  return input[key];
22
22
  return undefined;
23
23
  }
24
+ function stablePromptKey(payload) {
25
+ const explicit = stringField(record(payload), "tool_use_id", "toolUseId", "prompt_id", "promptId");
26
+ if (explicit)
27
+ return explicit.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 80);
28
+ const source = `${payload.session_id}:${JSON.stringify(payload.tool_input ?? {})}`;
29
+ let hash = 2166136261;
30
+ for (let index = 0; index < source.length; index += 1)
31
+ hash = Math.imul(hash ^ source.charCodeAt(index), 16777619);
32
+ return (hash >>> 0).toString(36);
33
+ }
34
+ /** Turns native Claude skill questions into Hive's durable decision model. */
35
+ export function skillPromptEvents(payload, jobId) {
36
+ if (payload.hook_event_name !== "PreToolUse" || payload.tool_name !== "AskUserQuestion")
37
+ return [];
38
+ const questions = record(payload.tool_input).questions;
39
+ if (!Array.isArray(questions))
40
+ return [];
41
+ const group = stablePromptKey(payload);
42
+ return questions.slice(0, 8).flatMap((value, index) => {
43
+ const question = record(value);
44
+ const text = stringField(question, "question")?.trim();
45
+ if (!text)
46
+ return [];
47
+ const options = Array.isArray(question.options) ? question.options.slice(0, 8).map(record) : [];
48
+ const choices = options.map((option) => stringField(option, "label")?.trim()).filter((label) => Boolean(label));
49
+ const descriptions = options.flatMap((option) => { const label = stringField(option, "label")?.trim(); const description = stringField(option, "description")?.trim(); return label && description ? [`${label}: ${description}`] : []; });
50
+ const header = stringField(question, "header")?.trim();
51
+ return [{
52
+ ts: typeof payload._hive_spooled_at === "number" ? payload._hive_spooled_at : Date.now(), sessionId: payload.session_id, jobId, source: "hook",
53
+ hookEventName: payload.hook_event_name, toolName: payload.tool_name, phase: "blocked_on_user", activityKind: "decision",
54
+ detail: text, decisionId: `skill-prompt:${group}:${index}`, decisionKind: "question", choices,
55
+ context: [header ? `Skill prompt · ${header}` : "Skill prompt", ...descriptions].join("\n"),
56
+ targetWorker: stringField(record(payload), "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name"),
57
+ }];
58
+ });
59
+ }
24
60
  function evidence(payload) {
25
61
  const input = record(payload.tool_input);
26
62
  const tool = payload.tool_name ?? "";
27
63
  const command = stringField(input, "command");
28
64
  const filePath = stringField(input, "file_path", "path", "notebook_path");
29
- const targetWorker = stringField(payload, "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name");
65
+ let targetWorker = stringField(payload, "agent_id", "subagent_id", "agent_name", "agent_type", "teammate_name");
66
+ // Agent launches are emitted by the manager, so the payload's top-level
67
+ // agent_type is `hive-orchestrator`. Claude returns the durable worker id in
68
+ // tool_response.agentId. Attribute the launch to that id immediately; this
69
+ // prevents the fan-file race from creating an empty/duplicate entrance.
70
+ if (payload.hook_event_name === "PostToolUse" && tool === "Agent") {
71
+ targetWorker = stringField(record(payload.tool_response), "agentId", "agent_id") ?? targetWorker;
72
+ }
30
73
  if (["TaskCreated", "TaskCompleted", "TeammateIdle"].includes(payload.hook_event_name))
31
74
  return { activityKind: "agent", targetWorker };
32
75
  if (["PermissionRequest", "PermissionDenied", "Elicitation", "ElicitationResult", "Notification"].includes(payload.hook_event_name))
@@ -89,6 +89,7 @@ export class MissionsStore {
89
89
  policy: input.policy,
90
90
  provider: input.provider ?? "claude",
91
91
  model: input.model?.trim() || undefined,
92
+ planFirst: input.planFirst || undefined,
92
93
  additionalRepositories: input.additionalRepositories?.length ? [...new Set(input.additionalRepositories)] : undefined,
93
94
  createdAt: now,
94
95
  updatedAt: now,
@@ -149,6 +150,7 @@ export class MissionsStore {
149
150
  ...(mission?.policy ? { policy: mission.policy } : {}),
150
151
  provider: mission?.provider ?? "claude",
151
152
  ...(mission?.model ? { model: mission.model } : {}),
153
+ ...(mission?.planFirst ? { planFirst: true } : {}),
152
154
  ...(mission?.additionalRepositories?.length ? { additionalRepositories: mission.additionalRepositories } : {}),
153
155
  };
154
156
  }
@@ -85,10 +85,13 @@ export class PlansStore {
85
85
  CREATE INDEX IF NOT EXISTS idx_plan_revisions ON mission_plan_revisions (mission_id, revision DESC);
86
86
  `);
87
87
  }
88
- replace(missionId, input) {
88
+ replace(missionId, input, preserveUserGates = true) {
89
89
  const phases = normalize(input);
90
90
  const current = this.get(missionId);
91
- const gates = input.gates === undefined ? current?.gates ?? [] : normalizeGates(input.gates);
91
+ const incoming = input.gates === undefined ? current?.gates ?? [] : normalizeGates(input.gates);
92
+ const protectedUserGates = preserveUserGates ? (current?.gates ?? []).filter((gate) => gate.source === "user") : [];
93
+ const protectedIds = new Set(protectedUserGates.map((gate) => gate.id));
94
+ const gates = [...incoming.filter((gate) => !protectedIds.has(gate.id)), ...protectedUserGates];
92
95
  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
96
  const json = JSON.stringify(plan);
94
97
  this.db.transaction(() => {
@@ -177,7 +180,35 @@ export class PlansStore {
177
180
  throw new Error("completion gate not found");
178
181
  if (!reason.trim())
179
182
  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 });
183
+ 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 }, false);
184
+ }
185
+ addUserGate(missionId, input) {
186
+ const plan = this.get(missionId);
187
+ const id = `user-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
188
+ const gate = { id, label: input.label, type: input.type, required: input.required, status: "pending", evidence: [], source: "user" };
189
+ return this.replace(missionId, { ...(plan?.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan?.phases ?? [] }), gates: [...(plan?.gates ?? []), gate], approvalStatus: plan?.approvalStatus ?? "approved", approvalReason: plan?.approvalReason }, false);
190
+ }
191
+ updateUserGate(missionId, gateId, input) {
192
+ const plan = this.get(missionId);
193
+ if (!plan)
194
+ throw new Error("mission plan not found");
195
+ const gate = plan.gates.find((item) => item.id === gateId);
196
+ if (!gate)
197
+ throw new Error("completion gate not found");
198
+ if (gate.source !== "user")
199
+ throw new Error("only user-created gates can be edited manually");
200
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan.phases }), gates: plan.gates.map((item) => item.id === gateId ? { ...item, ...input } : item), approvalStatus: plan.approvalStatus, approvalReason: plan.approvalReason }, false);
201
+ }
202
+ removeUserGate(missionId, gateId) {
203
+ const plan = this.get(missionId);
204
+ if (!plan)
205
+ throw new Error("mission plan not found");
206
+ const gate = plan.gates.find((item) => item.id === gateId);
207
+ if (!gate)
208
+ throw new Error("completion gate not found");
209
+ if (gate.source !== "user")
210
+ throw new Error("only user-created gates can be removed manually");
211
+ return this.replace(missionId, { ...(plan.layout === "flat" ? { tasks: plan.phases[0]?.tasks ?? [] } : { phases: plan.phases }), gates: plan.gates.filter((item) => item.id !== gateId), approvalStatus: plan.approvalStatus, approvalReason: plan.approvalReason }, false);
181
212
  }
182
213
  unresolvedRequiredGates(missionId) {
183
214
  return (this.get(missionId)?.gates ?? []).filter((gate) => gate.required && gate.status !== "satisfied" && gate.status !== "waived");
@@ -194,7 +225,7 @@ export class PlansStore {
194
225
  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
226
  approvalStatus: plan.approvalStatus ?? "proposed",
196
227
  approvalReason: plan.approvalReason,
197
- });
228
+ }, false);
198
229
  }
199
230
  setApproval(missionId, status, reason) {
200
231
  const plan = this.get(missionId);