@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.
- package/CHANGELOG.md +44 -0
- package/README.md +70 -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 +229 -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 +12 -0
- package/packages/server/dist/plans/plansStore.js +35 -4
- package/packages/server/dist/roster/rosterBuilder.js +212 -39
- package/packages/server/dist/skills/skillDiscovery.js +10 -8
- package/packages/server/dist/skills/skillInvocation.js +11 -0
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +2 -0
- package/packages/web/dist/assets/index-C6AY0vYC.js +11 -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,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(
|
|
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 ? `${
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 (
|
|
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 === "
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
|
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);
|