@shanesaravia/hive 0.1.1 → 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.
- package/CHANGELOG.md +23 -0
- package/README.md +56 -10
- package/node_modules/@hive/shared/dist/directStudio.d.ts +7 -0
- package/node_modules/@hive/shared/dist/directStudio.js +17 -0
- package/node_modules/@hive/shared/dist/index.d.ts +1 -0
- package/node_modules/@hive/shared/dist/index.js +1 -0
- package/node_modules/@hive/shared/dist/status.d.ts +1 -1
- package/node_modules/@hive/shared/dist/status.js +19 -7
- package/node_modules/@hive/shared/dist/types.d.ts +5 -0
- package/node_modules/@hive/shared/package.json +3 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +165 -14
- package/packages/server/dist/control/codexRuntime.js +24 -4
- package/packages/server/dist/hooks/hookIngest.js +44 -1
- package/packages/server/dist/missions/missionsStore.js +2 -0
- package/packages/server/dist/plans/plansStore.js +35 -4
- package/packages/server/dist/roster/rosterBuilder.js +183 -19
- package/packages/server/dist/skills/skillDiscovery.js +10 -8
- package/packages/server/dist/skills/skillInvocation.js +11 -0
- package/packages/web/dist/assets/index-BrkIk6ny.js +11 -0
- package/packages/web/dist/assets/index-DJFn_ZsI.css +2 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +6 -0
- package/packages/web/dist/assets/index-CrKMFCkZ.js +0 -11
- package/packages/web/dist/assets/index-gEGU_lr3.css +0 -2
|
@@ -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(
|
|
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 ? `${
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 (
|
|
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 === "
|
|
409
|
+
if (req.body.status === "approved") {
|
|
317
410
|
try {
|
|
318
|
-
await sendToMission(req.params.missionId, `The
|
|
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
|
|
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
|
|
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.
|
|
88
|
-
job.
|
|
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
|
-
|
|
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
|
|
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);
|