@shanesaravia/hive 0.2.0 → 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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to Hive will be documented in this file.
4
4
 
5
+ ## 0.2.1 — 2026-08-25
6
+
7
+ ### Added
8
+
9
+ - Plan tab shows a "Phases" / "Tasks" section header, clarifying that task numbers are global across phases.
10
+ - Pending decisions appear in the top notice slot as a compact strip and slide-over, visible from every tab and both presentations, replacing the Conversation-tab-only placement.
11
+ - Office desks are click targets that behave exactly like their avatar, across manager, worker, and direct seats in both the fleet floor and the mission room.
12
+ - Shared `DetailDrawer` shell so worker inspection and pending decisions match in width, background, header rhythm, and dismissal.
13
+
14
+ ### Changed
15
+
16
+ - Mission room collapses its goal to one line, compacts summary tiles, drops duplicate side-panel titles, and lets the room and chat grow with the viewport, adding roughly 100px of chat at 900px tall and 320px on tall displays.
17
+ - Widened the mission room viewBox so the whole floor stays inside the canvas, with height capped to avoid empty canvas beneath it.
18
+
19
+ ### Fixed
20
+
21
+ - Reclaim a mission's worktree and branch when it is deleted, archived, or completed, instead of leaving every worktree-isolated mission behind on disk forever. Completion keeps a worktree that holds uncommitted changes, since a follow-up message reopens a completed mission and **Clear completed desks** completes many at once.
22
+ - Resume a reopened mission in its base repository when its worktree has already been reclaimed, rather than resuming into a path that no longer exists.
23
+ - An unanswered decision keeps a mission in `waiting_on_you` instead of decaying to idle or stalled once the session record goes quiet, so office lighting, desks, status pill, and the attention badge stay in agreement.
24
+ - Suppressed the duplicate "Waiting for your reply" banner when a structured decision already covers it.
25
+
5
26
  ## 0.2.0 — 2026-08-25
6
27
 
7
28
  Adds the visual office, a second presentation of the same fleet state alongside the existing cards.
package/README.md CHANGED
@@ -311,6 +311,20 @@ Hive deliberately separates the durable mission lifecycle from current runtime a
311
311
 
312
312
  A Claude job ending does **not** automatically mean its mission succeeded. Orchestrators move missions to **Awaiting acceptance**; the user accepts completion or requests changes. Manual lifecycle controls are available in the mission header.
313
313
 
314
+ ### Worktree reclamation
315
+
316
+ Missions launched with worktree isolation get a checked-out worktree under `.claude/worktrees/` and a matching `worktree-` branch. Hive reclaims both when a mission reaches a terminal state, so repositories do not accumulate abandoned checkouts.
317
+
318
+ | Mission reaches | Worktree |
319
+ | --- | --- |
320
+ | Deleted | Removed |
321
+ | Archived | Removed |
322
+ | Completed | Removed when clean; **kept** when it holds uncommitted changes |
323
+
324
+ Completion is treated more cautiously than deletion or archiving because it is reversible — sending a follow-up message to a completed mission reopens it — and because **Clear completed desks** completes many Direct missions at once. Uncommitted work in a worktree therefore survives completion, and a worktree left behind for that reason is recorded in the mission's Activity tab.
325
+
326
+ Reclamation only ever touches worktrees in Hive's managed layout; a repository or a hand-made directory is never removed, whatever a stale job record claims. If a reopened mission's worktree has already been reclaimed, Hive resumes it in the repository the worktree was cut from and records the change.
327
+
314
328
  ## MCP servers, skills, and project configuration
315
329
 
316
330
  Hive launches the normal local provider CLI rather than a separate container or account.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shanesaravia/hive",
3
3
  "private": false,
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "description": "Provider-neutral local mission control for Claude Code, Codex, and agent fleets.",
7
7
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
2
3
  import { canClearDirectStudioMission } from "@hive/shared";
3
4
  import { CONTEXT_BUDGETS } from "../messages/messagesStore.js";
4
5
  import { allPlanTasks } from "../plans/plansStore.js";
@@ -17,6 +18,7 @@ import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDet
17
18
  import { publishGitHubReport } from "../reports/githubPublisher.js";
18
19
  import { enforceWorkingDirectory, normalize } from "../policies/policiesStore.js";
19
20
  import { detectProviderModels } from "../control/providerModels.js";
21
+ import { reclaimEventDetail, reclaimWorktree } from "../worktrees/worktreeReclaim.js";
20
22
  export function registerRest(app, deps) {
21
23
  const { sessionsWatcher, jobsWatcher, events, missions, messages, plans, policies, codex } = deps;
22
24
  const missionSendTails = new Map();
@@ -27,6 +29,47 @@ export function registerRest(app, deps) {
27
29
  const sessionId = missions.latestSessionFor(missionId) ?? latest?.[1].sessionId ?? latest?.[1].resumeSessionId;
28
30
  return latest && sessionId ? { jobId: latest[0], sessionId } : undefined;
29
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
+ }
30
73
  async function withMissionQueue(missionId, run) {
31
74
  const previous = missionSendTails.get(missionId) ?? Promise.resolve();
32
75
  const operation = previous.catch(() => undefined).then(run);
@@ -63,10 +106,19 @@ export function registerRest(app, deps) {
63
106
  const reopening = ["ready_for_review", "completed", "archived", "failed", "paused"].includes(summary.lifecycleStatus);
64
107
  if (reopening)
65
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
+ }
66
118
  let result;
67
119
  try {
68
120
  result = provider === "codex"
69
- ? await codex.start({ task: prompt, cwd: summary.repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
121
+ ? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
70
122
  : await sendMessage(target.sessionId, prompt, policy, additionalRepos);
71
123
  }
72
124
  catch (error) {
@@ -90,6 +142,9 @@ export function registerRest(app, deps) {
90
142
  for (const node of eligible) {
91
143
  missions.setLifecycleStatus(node.missionId, "completed");
92
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 });
93
148
  }
94
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 };
95
150
  });
@@ -816,6 +871,11 @@ Revise the structured plan to remove that requirement and stop work that was nee
816
871
  const target = latestMissionTarget(req.params.missionId);
817
872
  if (target)
818
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
+ }
819
879
  return { ok: true };
820
880
  });
821
881
  app.post("/api/mission/:missionId/action", async (req, reply) => {
@@ -900,6 +960,9 @@ Revise the structured plan to remove that requirement and stop work that was nee
900
960
  });
901
961
  app.delete("/api/mission/:missionId", async (req, reply) => {
902
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);
903
966
  if (target)
904
967
  events.add({ ts: Date.now(), ...target, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Mission permanently deleted: ${req.params.missionId}` });
905
968
  if (!missions.remove(req.params.missionId)) {
@@ -908,6 +971,7 @@ Revise the structured plan to remove that requirement and stop work that was nee
908
971
  }
909
972
  messages.removeMission(req.params.missionId);
910
973
  plans.removeMission(req.params.missionId);
974
+ reclaimMissionWorktree(req.params.missionId, "deleted", target, worktree);
911
975
  return { ok: true };
912
976
  });
913
977
  app.post("/api/session/:pid/stop", async (req, reply) => {
@@ -190,6 +190,16 @@ export class MissionsStore {
190
190
  mission.updatedAt = Date.now();
191
191
  this.changed();
192
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
+ }
193
203
  setLifecycleStatus(missionId, status) {
194
204
  const mission = this.ensureLegacyMission(missionId);
195
205
  mission.lifecycleStatus = status;
@@ -52,6 +52,51 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
52
52
  group.push({ jobId, job });
53
53
  groups.set(missionId, group);
54
54
  }
55
+ // Per-mission pending decisions. Used both for the global inbox and, inside the
56
+ // mission loop, to keep a mission reading as waiting_on_you for as long as a
57
+ // decision is unanswered — even after the provider session record has gone
58
+ // quiet — so the office lighting, desk, status pill, and "?" badge agree.
59
+ const pendingDecisionsFor = (node) => {
60
+ if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
61
+ return [];
62
+ const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
63
+ const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
64
+ // Only the current, mid-turn blocker belongs in the decision inbox. An
65
+ // unresolved event from an older/completed turn is history: the mission
66
+ // may remain open for optional follow-up, but it is not waiting on the
67
+ // user. Requiring the blocker to be the latest observed event also clears
68
+ // it as soon as provider activity resumes.
69
+ const latestEvent = node.recentEvents.at(-1);
70
+ const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
71
+ && latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
72
+ && (!latestEvent.jobId || latestEvent.jobId === node.jobId)
73
+ ? [{
74
+ id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
75
+ kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
76
+ choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
77
+ }]
78
+ : [];
79
+ // A permission prompt parks the session mid-turn (tempo blocked, the ask
80
+ // in needs) with no TTY to answer it — surface it as an answerable
81
+ // decision. It self-clears once the job is no longer parked.
82
+ const latestJob = jobs.get(node.jobId);
83
+ const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
84
+ ? [{
85
+ id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
86
+ kind: "permission", question: latestJob.needs, context: latestJob.detail,
87
+ choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
88
+ }]
89
+ : [];
90
+ const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
91
+ const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
92
+ ? [{
93
+ id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
94
+ kind: "question", question: latestJob.needs, context: latestJob.detail,
95
+ choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
96
+ }]
97
+ : [];
98
+ return [...fromEvents, ...parked, ...providerPrompt];
99
+ };
55
100
  for (const [missionId, group] of groups) {
56
101
  group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
57
102
  const latest = group[group.length - 1];
@@ -298,8 +343,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
298
343
  activityStatus = "stalled";
299
344
  }
300
345
  }
346
+ const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
347
+ const turnCompleted = latest.job.state === "done";
348
+ const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
349
+ // An unanswered decision is, by definition, waiting on the user. Do not let the
350
+ // runtime's quiet session decay it to idle/stalled while the question stands.
351
+ if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
352
+ activityStatus = "waiting_on_you";
301
353
  const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
302
- const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted: latest.job.state === "done", workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
354
+ const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
303
355
  orchestrators.push({
304
356
  missionId,
305
357
  threadId: missionId,
@@ -312,14 +364,11 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
312
364
  latest.job.resumeSessionId ??
313
365
  latest.jobId,
314
366
  pid: session?.pid ?? -1,
315
- name: missions?.nameFor(missionId) ??
316
- oldest.job.name ??
317
- latest.job.name ??
318
- missionId,
367
+ name,
319
368
  status: activityStatus,
320
369
  lifecycleStatus: mission.lifecycleStatus,
321
370
  activityStatus,
322
- turnCompleted: latest.job.state === "done",
371
+ turnCompleted,
323
372
  inactiveForMs,
324
373
  waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
325
374
  worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
@@ -362,46 +411,6 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
362
411
  });
363
412
  }
364
413
  orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
365
- const decisions = orchestrators.flatMap((node) => {
366
- if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
367
- return [];
368
- const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
369
- const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
370
- // Only the current, mid-turn blocker belongs in the decision inbox. An
371
- // unresolved event from an older/completed turn is history: the mission
372
- // may remain open for optional follow-up, but it is not waiting on the
373
- // user. Requiring the blocker to be the latest observed event also clears
374
- // it as soon as provider activity resumes.
375
- const latestEvent = node.recentEvents.at(-1);
376
- const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
377
- && latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
378
- && (!latestEvent.jobId || latestEvent.jobId === node.jobId)
379
- ? [{
380
- id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
381
- kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
382
- choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
383
- }]
384
- : [];
385
- // A permission prompt parks the session mid-turn (tempo blocked, the ask
386
- // in needs) with no TTY to answer it — surface it as an answerable
387
- // decision. It self-clears once the job is no longer parked.
388
- const latestJob = jobs.get(node.jobId);
389
- const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
390
- ? [{
391
- id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
392
- kind: "permission", question: latestJob.needs, context: latestJob.detail,
393
- choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
394
- }]
395
- : [];
396
- const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
397
- const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
398
- ? [{
399
- id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
400
- kind: "question", question: latestJob.needs, context: latestJob.detail,
401
- choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
402
- }]
403
- : [];
404
- return [...fromEvents, ...parked, ...providerPrompt];
405
- }).sort((a, b) => b.createdAt - a.createdAt);
414
+ const decisions = orchestrators.flatMap((node) => pendingDecisionsFor(node)).sort((a, b) => b.createdAt - a.createdAt);
406
415
  return { orchestrators, decisions, other, generatedAt: Date.now() };
407
416
  }
@@ -0,0 +1,156 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /**
5
+ * Hive launches missions through the provider CLI's native `--worktree`
6
+ * primitive (see control/launcher.ts) but nothing ever reclaimed the result,
7
+ * so every templated mission leaked a checked-out worktree and a branch.
8
+ *
9
+ * Reclamation is deliberately scoped to worktrees Hive itself caused to
10
+ * exist: the CLI creates them under `<repo>/.claude/worktrees/<name>` on a
11
+ * `worktree-<name>` branch. Anything outside that layout is left alone, so a
12
+ * stale or wrong `worktreePath` on a job record can never delete a real
13
+ * checkout.
14
+ */
15
+ /** Path segment the provider CLI uses for its managed worktrees. */
16
+ const MANAGED_SEGMENT = path.join(".claude", "worktrees");
17
+ function git(cwd, args) {
18
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
19
+ }
20
+ /**
21
+ * git may answer --git-common-dir with either an absolute or a
22
+ * repository-relative path depending on where it runs, and resolving a
23
+ * relative answer against the wrong base silently yields a bogus path.
24
+ * --path-format is authoritative where available (git 2.31+).
25
+ */
26
+ function commonGitDir(cwd) {
27
+ try {
28
+ return git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
29
+ }
30
+ catch {
31
+ return path.resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"]));
32
+ }
33
+ }
34
+ /**
35
+ * A managed worktree lives under `.claude/worktrees/` AND reports a git common
36
+ * directory different from its own git dir. Both checks matter: the path shape
37
+ * alone would trust unverified job metadata, and the git check alone would
38
+ * happily remove a worktree the user created by hand somewhere else.
39
+ */
40
+ export function isManagedWorktree(worktreePath) {
41
+ if (!worktreePath.includes(MANAGED_SEGMENT))
42
+ return false;
43
+ let gitEntry;
44
+ try {
45
+ gitEntry = fs.statSync(path.join(worktreePath, ".git"));
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ // A linked worktree records its git directory in a `.git` FILE; a main
51
+ // checkout has a `.git` DIRECTORY. That distinction is what stops a real
52
+ // repository from ever being reclaimed, and unlike comparing
53
+ // --git-dir against --git-common-dir it cannot be fooled by git mixing
54
+ // absolute and relative path output for a nested directory.
55
+ if (!gitEntry.isFile())
56
+ return false;
57
+ try {
58
+ return git(worktreePath, ["rev-parse", "--is-inside-work-tree"]) === "true";
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Uncommitted tracked or untracked changes in the worktree. */
65
+ export function isWorktreeDirty(worktreePath) {
66
+ try {
67
+ return git(worktreePath, ["status", "--porcelain"]).length > 0;
68
+ }
69
+ catch {
70
+ // Unreadable means unverifiable, and unverifiable must not be discarded.
71
+ return true;
72
+ }
73
+ }
74
+ /**
75
+ * `completed` is reversible — a follow-up message implicitly reopens a
76
+ * completed mission (see rest.ts), and "Clear completed desks" completes many
77
+ * missions at once — so uncommitted work survives it. `deleted` and `archived`
78
+ * are explicit, per-mission, terminal gestures and reclaim unconditionally.
79
+ */
80
+ function discardsUncommittedWork(trigger) {
81
+ return trigger !== "completed";
82
+ }
83
+ /**
84
+ * Removes a mission's worktree and its branch. Safe to call for any mission:
85
+ * missions without a worktree, and worktrees already gone, report "absent".
86
+ */
87
+ export function reclaimWorktree(target, trigger) {
88
+ const worktreePath = target.path;
89
+ if (!worktreePath)
90
+ return { status: "absent" };
91
+ if (!fs.existsSync(worktreePath))
92
+ return { status: "absent", path: worktreePath, branch: target.branch };
93
+ if (!isManagedWorktree(worktreePath)) {
94
+ return { status: "unmanaged", path: worktreePath, branch: target.branch, detail: "not a Hive-managed worktree" };
95
+ }
96
+ if (!discardsUncommittedWork(trigger) && isWorktreeDirty(worktreePath)) {
97
+ return { status: "kept_dirty", path: worktreePath, branch: target.branch, detail: "uncommitted changes preserved" };
98
+ }
99
+ // The main checkout owns the worktree administrative data, so removal and
100
+ // branch deletion must run from the common repository, not from inside the
101
+ // directory being deleted.
102
+ let repositoryRoot;
103
+ try {
104
+ repositoryRoot = path.dirname(commonGitDir(worktreePath));
105
+ }
106
+ catch (error) {
107
+ return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
108
+ }
109
+ try {
110
+ // Locked worktrees are Hive's own (the CLI locks some during launch);
111
+ // unlock is a no-op when the worktree was never locked.
112
+ try {
113
+ git(repositoryRoot, ["worktree", "unlock", worktreePath]);
114
+ }
115
+ catch { /* not locked */ }
116
+ git(repositoryRoot, ["worktree", "remove", "--force", worktreePath]);
117
+ }
118
+ catch (error) {
119
+ return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
120
+ }
121
+ let detail;
122
+ if (target.branch) {
123
+ const branch = target.branch.replace(/^refs\/heads\//, "");
124
+ try {
125
+ // -D rather than -d: the branch was just verified to hold no
126
+ // uncommitted work, and an unmerged experiment branch is exactly what
127
+ // this reclaim is meant to collect. The commits stay in the reflog.
128
+ git(repositoryRoot, ["branch", "-D", branch]);
129
+ }
130
+ catch {
131
+ detail = `worktree removed; branch ${branch} retained`;
132
+ }
133
+ }
134
+ try {
135
+ git(repositoryRoot, ["worktree", "prune"]);
136
+ }
137
+ catch { /* best effort */ }
138
+ return { status: "removed", path: worktreePath, branch: target.branch, detail };
139
+ }
140
+ /** Human-readable line for the mission activity timeline. */
141
+ export function reclaimEventDetail(result, trigger) {
142
+ const name = result.path ? path.basename(result.path) : "worktree";
143
+ if (result.status === "removed") {
144
+ return `Worktree ${name} reclaimed after mission ${trigger}${result.detail ? ` · ${result.detail}` : ""}`;
145
+ }
146
+ if (result.status === "kept_dirty") {
147
+ return `Worktree ${name} kept: uncommitted changes remain after mission ${trigger}`;
148
+ }
149
+ if (result.status === "failed") {
150
+ return `Worktree ${name} could not be reclaimed: ${result.detail ?? "git error"}`;
151
+ }
152
+ if (result.status === "unmanaged") {
153
+ return `Worktree ${name} left in place: not a Hive-managed worktree`;
154
+ }
155
+ return undefined;
156
+ }