@shanesaravia/hive 0.1.1 → 0.2.1

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,20 +1,24 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { canClearDirectStudioMission } from "@hive/shared";
2
4
  import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
3
5
  import { allPlanTasks } from "../plans/plansStore.js";
4
6
  import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
5
- import { toHiveEvent } from "../hooks/hookIngest.js";
7
+ import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
6
8
  import { startOrchestrator } from "../control/launcher.js";
7
9
  import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
8
10
  import { sendMessage } from "../control/messaging.js";
9
11
  import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
10
12
  import { forceStopSession, stopSession } from "../control/killer.js";
11
13
  import { discoverSkills } from "../skills/skillDiscovery.js";
14
+ import { translateSkillInvocations } from "../skills/skillInvocation.js";
12
15
  import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
13
16
  import { missionReport, reportMarkdown } from "../reports/missionReport.js";
14
17
  import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDetailed, updateTemplate } from "../templates/templateDiscovery.js";
15
18
  import { publishGitHubReport } from "../reports/githubPublisher.js";
16
19
  import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
17
20
  import { detectProviderModels } from "../control/providerModels.js";
21
+ import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
18
22
  export function registerRest(app, deps) {
19
23
  const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
20
24
  const missionSendTails = new Map();
@@ -25,6 +29,47 @@ export function registerRest(app, deps) {
25
29
  const sessionId = missions.latestSessionFor(missionId) ?? latest?.[1].sessionId ?? latest?.[1].resumeSessionId;
26
30
  return latest && sessionId ? { jobId: latest[0], sessionId } : undefined;
27
31
  }
32
+ /**
33
+ * Worktree identity lives on the provider job records, not the durable
34
+ * mission, so it must be read while those jobs are still linked — callers
35
+ * that also delete the mission have to resolve this first.
36
+ */
37
+ function missionWorktree(missionId) {
38
+ const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
39
+ const carrier = jobs.find(([, job]) => job.worktreePath)?.[1];
40
+ return { path: carrier?.worktreePath, branch: carrier?.worktreeBranch };
41
+ }
42
+ /**
43
+ * The working directory to resume a mission in. Normally the recorded
44
+ * repository; when that was a worktree Hive has since reclaimed, the base
45
+ * repository the worktree was cut from.
46
+ */
47
+ function resumeRepository(missionId, repository) {
48
+ if (!repository || fs.existsSync(repository))
49
+ return repository;
50
+ const jobs = [...jobsWatcher.getAll()].filter(([jobId]) => missions.missionFor(jobId) === missionId);
51
+ const origin = jobs.map(([, job]) => job.originCwd).find((cwd) => cwd && fs.existsSync(cwd));
52
+ return origin ?? repository;
53
+ }
54
+ /**
55
+ * Reclaims the mission's worktree and records the outcome on the timeline.
56
+ * Never throws: losing a worktree cleanup must not fail the lifecycle
57
+ * change the user actually asked for.
58
+ */
59
+ function reclaimMissionWorktree(missionId, trigger, target, worktree = missionWorktree(missionId)) {
60
+ if (!worktree.path)
61
+ return;
62
+ let detail;
63
+ try {
64
+ detail = reclaimEventDetail(reclaimWorktree(worktree, trigger), trigger);
65
+ }
66
+ catch (error) {
67
+ detail = `Worktree reclaim failed: ${error.message}`;
68
+ }
69
+ if (detail && target) {
70
+ events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail });
71
+ }
72
+ }
28
73
  async function withMissionQueue(missionId, run) {
29
74
  const previous = missionSendTails.get(missionId) ?? Promise.resolve();
30
75
  const operation = previous.catch(() => undefined).then(run);
@@ -45,27 +90,64 @@ export function registerRest(app, deps) {
45
90
  throw new Error("mission not found");
46
91
  const summary = missions.summaryFor(missionId);
47
92
  const policy = summary.policy ?? policies.get();
93
+ const provider = summary.provider ?? "claude";
94
+ const providerText = translateSkillInvocations(text, provider, new Set(discoverSkills(summary.repository, provider).map((skill) => skill.name)));
48
95
  // "add this to monorepo" should just work: repos mentioned by name get
49
96
  // --add-dir access, persisted so every later turn keeps the grant.
50
- const mentioned = detectRepositoryMentions(text, summary.repository);
97
+ const mentioned = detectRepositoryMentions(providerText, summary.repository);
51
98
  const additionalRepos = [...new Set([...(summary.additionalRepositories ?? []), ...mentioned])];
52
99
  if (mentioned.length)
53
100
  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);
101
+ const prompt = additionalRepos.length ? `${providerText}\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : providerText;
102
+ // Move the manager out of the shared review hall before asking the
103
+ // provider to resume. Fast subagents can start and finish while the CLI
104
+ // launch call is still pending; reopening afterward leaves their FIFO
105
+ // stories assigned to a room with no worker desks.
106
+ const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
107
+ if (reopening)
108
+ missions.setLifecycleStatus(missionId, "active");
109
+ // A follow-up implicitly reopens a closed mission, but reaching a
110
+ // terminal state reclaims its worktree — so the recorded repository can
111
+ // be a directory that no longer exists. Fall back to the repository the
112
+ // worktree was cut from rather than resuming into a dead path.
113
+ const repository = resumeRepository(missionId, summary.repository);
114
+ if (repository && repository !== summary.repository) {
115
+ missions.setRepository(missionId, repository);
116
+ events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission worktree was reclaimed; resuming in ${repository}` });
117
+ }
118
+ let result;
119
+ try {
120
+ result = provider === "codex"
121
+ ? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
122
+ : await sendMessage(target.sessionId, prompt, policy, additionalRepos);
123
+ }
124
+ catch (error) {
125
+ if (reopening)
126
+ missions.setLifecycleStatus(missionId, summary.lifecycleStatus);
127
+ throw error;
128
+ }
58
129
  missions.linkJob(missionId, result.jobId, result.sessionId);
59
130
  messages.add({ id: `${result.jobId}:user`, missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
60
131
  // 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");
132
+ if (reopening) {
63
133
  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
134
  }
65
135
  return result;
66
136
  });
67
137
  }
68
138
  app.get("/api/fleet", async () => buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans));
139
+ app.post("/api/direct-studio/clear-completed", async () => {
140
+ const snapshot = buildFleetSnapshot(sessionsWatcher.getAll(), jobsWatcher.getAll(), events, missions, messages, plans);
141
+ const eligible = snapshot.orchestrators.filter(canClearDirectStudioMission);
142
+ for (const node of eligible) {
143
+ missions.setLifecycleStatus(node.missionId, "completed");
144
+ 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" });
145
+ // Bulk completion reclaims on the same clean-only terms as a single
146
+ // completion: uncommitted work in any desk's worktree survives.
147
+ reclaimMissionWorktree(node.missionId, "completed", { jobId: node.jobId, sessionId: node.sessionId });
148
+ }
149
+ return { ok: true, cleared: eligible.map((node) => node.missionId), remaining: snapshot.orchestrators.filter((node) => node.mission.mode === "direct" && node.lifecycleStatus === "active").length - eligible.length };
150
+ });
69
151
  app.get("/api/policies", async () => ({ policy: policies.get() }));
70
152
  app.get("/api/providers/models", async () => ({ providers: detectProviderModels() }));
71
153
  app.put("/api/policies", async (req, reply) => {
@@ -172,6 +254,8 @@ export function registerRest(app, deps) {
172
254
  const jobId = sessionForId(sessionsWatcher, payload.session_id)?.jobId;
173
255
  const event = toHiveEvent(payload, jobId);
174
256
  events.add(event);
257
+ for (const promptEvent of skillPromptEvents(payload, jobId))
258
+ events.add(promptEvent);
175
259
  // A write landing in another known repo marks that repo as touched by
176
260
  // the mission, so its footprint chips reflect reality, not just intent.
177
261
  if (event.activityKind === "file_write" && event.filePath && jobId) {
@@ -303,6 +387,70 @@ export function registerRest(app, deps) {
303
387
  return { error: err.message };
304
388
  }
305
389
  });
390
+ app.post("/api/mission/:missionId/gate", async (req, reply) => {
391
+ if (!missions.get(req.params.missionId)) {
392
+ reply.code(404);
393
+ return { error: "mission not found" };
394
+ }
395
+ try {
396
+ const plan = plans.addUserGate(req.params.missionId, req.body);
397
+ const gate = plan.gates.find((item) => item.source === "user" && item.label === req.body.label && item.type === req.body.type);
398
+ let notified = true;
399
+ try {
400
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
401
+ A ${gate.required ? "required" : "optional"} ${gate.type.replaceAll("_", " ")} gate was added by the user: “${gate.label}”.
402
+ 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.`);
403
+ }
404
+ catch {
405
+ notified = false;
406
+ }
407
+ return { plan, notified };
408
+ }
409
+ catch (err) {
410
+ reply.code(400);
411
+ return { error: err.message };
412
+ }
413
+ });
414
+ app.put("/api/mission/:missionId/gate/:gateId", async (req, reply) => {
415
+ try {
416
+ const plan = plans.updateUserGate(req.params.missionId, req.params.gateId, req.body);
417
+ const gate = plan.gates.find((item) => item.id === req.params.gateId);
418
+ let notified = true;
419
+ try {
420
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
421
+ The user changed a durable completion gate. It is now a ${gate.required ? "required" : "optional"} ${gate.type.replaceAll("_", " ")} gate: “${gate.label}”.
422
+ Revise the structured plan immediately, reconcile existing work against this requirement, and continue or delegate the work needed to satisfy it with concrete evidence.`);
423
+ }
424
+ catch {
425
+ notified = false;
426
+ }
427
+ return { plan, notified };
428
+ }
429
+ catch (err) {
430
+ reply.code(400);
431
+ return { error: err.message };
432
+ }
433
+ });
434
+ app.delete("/api/mission/:missionId/gate/:gateId", async (req, reply) => {
435
+ try {
436
+ const previous = plans.get(req.params.missionId)?.gates.find((item) => item.id === req.params.gateId);
437
+ const plan = plans.removeUserGate(req.params.missionId, req.params.gateId);
438
+ let notified = true;
439
+ try {
440
+ await sendToMission(req.params.missionId, `[Hive completion gate update]
441
+ The user removed the completion gate “${previous?.label ?? req.params.gateId}”.
442
+ Revise the structured plan to remove that requirement and stop work that was needed only for that gate. Preserve all other user-created gates.`);
443
+ }
444
+ catch {
445
+ notified = false;
446
+ }
447
+ return { plan, notified };
448
+ }
449
+ catch (err) {
450
+ reply.code(400);
451
+ return { error: err.message };
452
+ }
453
+ });
306
454
  app.post("/api/mission/:missionId/plan/approval", async (req, reply) => {
307
455
  if (req.body?.status !== "approved" && req.body?.status !== "rejected") {
308
456
  reply.code(400);
@@ -313,9 +461,20 @@ export function registerRest(app, deps) {
313
461
  const target = latestMissionTarget(req.params.missionId);
314
462
  if (target)
315
463
  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") {
464
+ if (req.body.status === "approved") {
465
+ try {
466
+ 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.`);
467
+ 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));
468
+ const resolved = new Set(missionEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
469
+ 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)))) {
470
+ 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 });
471
+ }
472
+ }
473
+ catch { /* approval remains recorded even when no live job can resume */ }
474
+ }
475
+ else {
317
476
  try {
318
- await sendToMission(req.params.missionId, `The current plan was rejected. Revise it before continuing. Feedback: ${plan.approvalReason}`);
477
+ 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
478
  }
320
479
  catch { /* rejection remains recorded even when no live job can resume */ }
321
480
  }
@@ -360,9 +519,12 @@ export function registerRest(app, deps) {
360
519
  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
520
  const provider = req.body.provider ?? "claude";
362
521
  const model = req.body.model?.trim() || undefined;
522
+ const planFirst = mode === "orchestrated" && req.body.planFirst === true;
363
523
  const additionalRepos = detectRepositoryMentions(task, resolvedCwd);
364
524
  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;
525
+ 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.` : "";
526
+ const providerTask = translateSkillInvocations(task, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
527
+ const prompt = providerTask + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
366
528
  const result = provider === "codex"
367
529
  ? await codex.start({ task: prompt, cwd: resolvedCwd, name, model, mode, policy })
368
530
  : await startOrchestrator({ task: prompt, worktree, name, cwd: resolvedCwd, mode, policy, model, addDirs: additionalRepos });
@@ -378,6 +540,7 @@ export function registerRest(app, deps) {
378
540
  policy,
379
541
  provider,
380
542
  model,
543
+ planFirst,
381
544
  additionalRepositories: additionalRepos,
382
545
  });
383
546
  messages.add({
@@ -389,7 +552,7 @@ export function registerRest(app, deps) {
389
552
  jobId: result.jobId,
390
553
  });
391
554
  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 });
555
+ 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
556
  return { ...result, missionId: mission.id };
394
557
  }
395
558
  catch (err) {
@@ -399,7 +562,7 @@ export function registerRest(app, deps) {
399
562
  });
400
563
  app.post("/api/skills/discover", async (req, reply) => {
401
564
  try {
402
- return { skills: discoverSkills(req.body?.cwd) };
565
+ return { skills: discoverSkills(req.body?.cwd, req.body?.provider === "codex" ? "codex" : "claude") };
403
566
  }
404
567
  catch (err) {
405
568
  reply.code(400);
@@ -507,6 +670,22 @@ export function registerRest(app, deps) {
507
670
  reply.code(404);
508
671
  return { error: "mission not found" };
509
672
  }
673
+ if (req.params.decisionId === "plan-approval") {
674
+ const approved = /^(approve|approved|begin|proceed|yes)/i.test(answer);
675
+ try {
676
+ plans.setApproval(req.params.missionId, approved ? "approved" : "rejected", approved ? undefined : answer);
677
+ const result = await sendToMission(req.params.missionId, approved
678
+ ? "The proposed plan is approved. Begin implementation now, delegate to workers as appropriate, and keep the structured plan current with concrete evidence."
679
+ : `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}`);
680
+ if (approved)
681
+ 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 });
682
+ return { ok: true };
683
+ }
684
+ catch (err) {
685
+ reply.code(409);
686
+ return { error: err.message };
687
+ }
688
+ }
510
689
  // Parked permission prompts (decision id perm:<jobId>): a background
511
690
  // session cannot answer its own prompt, so end the parked turn and
512
691
  // resume the conversation with the verdict — pre-allowing the asked
@@ -545,6 +724,23 @@ export function registerRest(app, deps) {
545
724
  return { error: err.message };
546
725
  }
547
726
  }
727
+ if (req.params.decisionId.startsWith("prompt:")) {
728
+ const jobId = req.params.decisionId.slice(7);
729
+ const job = jobsWatcher.getAll().get(jobId);
730
+ if (!job || job.tempo !== "blocked" || !job.needs) {
731
+ reply.code(409);
732
+ return { error: "provider prompt is no longer pending" };
733
+ }
734
+ try {
735
+ 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.`);
736
+ 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 });
737
+ return { ok: true };
738
+ }
739
+ catch (err) {
740
+ reply.code(500);
741
+ return { error: err.message };
742
+ }
743
+ }
548
744
  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
745
  const pending = nodeEvents.find((event) => event.phase === "blocked_on_user" && event.decisionId === req.params.decisionId);
550
746
  const alreadyResolved = nodeEvents.some((event) => event.phase === "decision_resolved" && event.decisionId === req.params.decisionId);
@@ -553,7 +749,17 @@ export function registerRest(app, deps) {
553
749
  return { error: "decision is no longer pending" };
554
750
  }
555
751
  try {
556
- const result = await sendToMission(req.params.missionId, `Answer to your pending question: ${answer}`);
752
+ const skillPrefix = req.params.decisionId.startsWith("skill-prompt:") ? req.params.decisionId.slice(0, req.params.decisionId.lastIndexOf(":")) : undefined;
753
+ const siblings = skillPrefix ? nodeEvents.filter((event) => event.phase === "blocked_on_user" && event.decisionId?.startsWith(`${skillPrefix}:`)) : [pending];
754
+ const resolvedAnswers = new Map(nodeEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => [event.decisionId, event.detail]));
755
+ resolvedAnswers.set(req.params.decisionId, answer);
756
+ const remaining = siblings.filter((event) => event.decisionId && !resolvedAnswers.has(event.decisionId));
757
+ if (remaining.length) {
758
+ events.add({ ts: Date.now(), sessionId: pending.sessionId, jobId: pending.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
759
+ return { ok: true };
760
+ }
761
+ const combined = siblings.map((event) => `${event.detail}\nAnswer: ${resolvedAnswers.get(event.decisionId)}`).join("\n\n");
762
+ 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
763
  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
764
  return { ok: true };
559
765
  }
@@ -665,6 +871,11 @@ export function registerRest(app, deps) {
665
871
  const target = latestMissionTarget(req.params.missionId);
666
872
  if (target)
667
873
  events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission status changed from ${previous} to ${req.body.status}` });
874
+ // Reclaim only on entry into a terminal state, so re-archiving an already
875
+ // archived mission does not re-run git against a path that is long gone.
876
+ if (previous !== req.body.status && (req.body.status === "archived" || req.body.status === "completed")) {
877
+ reclaimMissionWorktree(req.params.missionId, req.body.status, target);
878
+ }
668
879
  return { ok: true };
669
880
  });
670
881
  app.post("/api/mission/:missionId/action", async (req, reply) => {
@@ -749,6 +960,9 @@ export function registerRest(app, deps) {
749
960
  });
750
961
  app.delete("/api/mission/:missionId", async (req, reply) => {
751
962
  const target = latestMissionTarget(req.params.missionId);
963
+ // Resolved before removal: missions.remove() unlinks the jobs that carry
964
+ // the worktree path, and afterwards it can no longer be found.
965
+ const worktree = missionWorktree(req.params.missionId);
752
966
  if (target)
753
967
  events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission permanently deleted: ${req.params.missionId}` });
754
968
  if (!missions.remove(req.params.missionId)) {
@@ -757,6 +971,7 @@ export function registerRest(app, deps) {
757
971
  }
758
972
  messages.removeMission(req.params.missionId);
759
973
  plans.removeMission(req.params.missionId);
974
+ reclaimMissionWorktree(req.params.missionId, "deleted", target, worktree);
760
975
  return { ok: true };
761
976
  });
762
977
  app.post("/api/session/:pid/stop", async (req, reply) => {
@@ -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
  }
@@ -188,6 +190,16 @@ export class MissionsStore {
188
190
  mission.updatedAt = Date.now();
189
191
  this.changed();
190
192
  }
193
+ /**
194
+ * Repoints a mission at a different working directory. Used when a mission's
195
+ * worktree has been reclaimed and work must continue in the base repository.
196
+ */
197
+ setRepository(missionId, repository) {
198
+ const mission = this.ensureLegacyMission(missionId);
199
+ mission.repository = repository;
200
+ mission.updatedAt = Date.now();
201
+ this.changed();
202
+ }
191
203
  setLifecycleStatus(missionId, status) {
192
204
  const mission = this.ensureLegacyMission(missionId);
193
205
  mission.lifecycleStatus = status;
@@ -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);