@shanesaravia/hive 0.2.0 → 0.3.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -0
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/types.d.ts +29 -0
  10. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  11. package/node_modules/@hive/shared/dist/workers.js +22 -0
  12. package/package.json +1 -1
  13. package/packages/server/dist/api/rest.js +130 -11
  14. package/packages/server/dist/api/ws.js +25 -8
  15. package/packages/server/dist/control/launcher.js +50 -11
  16. package/packages/server/dist/control/messaging.js +3 -2
  17. package/packages/server/dist/health/deriveAlerts.js +1 -2
  18. package/packages/server/dist/hooks/hookIngest.js +69 -10
  19. package/packages/server/dist/index.js +20 -4
  20. package/packages/server/dist/messages/messagesStore.js +25 -12
  21. package/packages/server/dist/missions/missionsStore.js +19 -0
  22. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  23. package/packages/server/dist/plans/planReconcile.js +114 -0
  24. package/packages/server/dist/plans/plansStore.js +46 -3
  25. package/packages/server/dist/roster/missionReplay.js +82 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +93 -189
  27. package/packages/server/dist/roster/workerIdentity.js +886 -0
  28. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  29. package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
  30. package/packages/web/dist/assets/index-BpEYVjCF.css +2 -0
  31. package/packages/web/dist/assets/index-rIAIJyuF.js +12 -0
  32. package/packages/web/dist/index.html +2 -2
  33. package/templates/agents/hive-orchestrator.md +1 -0
  34. package/packages/web/dist/assets/index-BrkIk6ny.js +0 -11
  35. package/packages/web/dist/assets/index-DJFn_ZsI.css +0 -2
@@ -55,8 +55,11 @@ export class JobsWatcher {
55
55
  // only a terse question in needs/detail, and done turns store a
56
56
  // one-line summary in output.result. The full reply lives in the
57
57
  // session transcript (timeline text is a 4KB tail as fallback).
58
- if (job.state === "done" || job.state === "blocked") {
59
- job.lastText = this.resolveLastText(jobId, job, directory);
58
+ if (job.state === "done" || job.state === "blocked" || job.state === "working") {
59
+ const turn = this.resolveTurnTexts(jobId, job, directory);
60
+ if (job.state !== "working")
61
+ job.lastText = turn.final;
62
+ job.progressTexts = turn.updates;
60
63
  }
61
64
  next.set(jobId, job);
62
65
  }
@@ -69,16 +72,25 @@ export class JobsWatcher {
69
72
  for (const listener of this.jobsListeners)
70
73
  listener(this.jobs);
71
74
  }
75
+ /** Cached per job by state.json's updatedAt and the transcript's size, so reloads don't re-read transcripts. */
72
76
  lastTextCache = new Map();
73
- /** Cached per job/updatedAt so reloads don't re-read transcripts. */
74
- resolveLastText(jobId, job, directory) {
77
+ resolveTurnTexts(jobId, job, directory) {
78
+ const transcript = transcriptPath(job.sessionId);
79
+ let size;
80
+ try {
81
+ size = transcript ? fs.statSync(transcript).size : undefined;
82
+ }
83
+ catch {
84
+ size = undefined;
85
+ }
75
86
  const cached = this.lastTextCache.get(jobId);
76
- if (cached && cached.updatedAt === job.updatedAt)
77
- return cached.text;
78
- const text = readTranscriptFinalText(job.sessionId)
79
- ?? readLatestTimelineText(path.join(directory, jobId, "timeline.jsonl"));
80
- this.lastTextCache.set(jobId, { updatedAt: job.updatedAt, text });
81
- return text;
87
+ if (cached && cached.updatedAt === job.updatedAt && cached.size === size)
88
+ return { final: cached.final, updates: cached.updates };
89
+ const turn = transcript ? readTranscriptTurn(transcript) : undefined;
90
+ const final = turn?.final ?? readLatestTimelineText(path.join(directory, jobId, "timeline.jsonl"));
91
+ const entry = { updatedAt: job.updatedAt, size, final, updates: turn?.updates ?? [] };
92
+ this.lastTextCache.set(jobId, entry);
93
+ return { final, updates: entry.updates };
82
94
  }
83
95
  tailTimeline(jobId, directory) {
84
96
  const timelinePath = path.join(directory, jobId, "timeline.jsonl");
@@ -116,11 +128,14 @@ export class JobsWatcher {
116
128
  }
117
129
  }
118
130
  const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
119
- /** Locates the session's transcript under ~/.claude/projects and returns the
120
- * last assistant text message — the model's actual final reply. */
121
- function readTranscriptFinalText(sessionId) {
131
+ /** Locates the session's transcript under ~/.claude/projects. */
132
+ const transcriptPaths = new Map();
133
+ function transcriptPath(sessionId) {
122
134
  if (!sessionId)
123
135
  return undefined;
136
+ const known = transcriptPaths.get(sessionId);
137
+ if (known && fs.existsSync(known))
138
+ return known;
124
139
  const root = path.join(os.homedir(), ".claude", "projects");
125
140
  let dirs;
126
141
  try {
@@ -133,17 +148,27 @@ function readTranscriptFinalText(sessionId) {
133
148
  const file = path.join(root, dir, `${sessionId}.jsonl`);
134
149
  if (!fs.existsSync(file))
135
150
  continue;
136
- return finalAssistantText(file);
151
+ transcriptPaths.set(sessionId, file);
152
+ return file;
137
153
  }
138
154
  return undefined;
139
155
  }
140
- function finalAssistantText(file) {
156
+ /** How much of what the model says between tool calls the conversation keeps. */
157
+ const PROGRESS_UPDATE_LIMIT = 8;
158
+ const PROGRESS_UPDATE_CHARS = 320;
159
+ /**
160
+ * The current turn's assistant text, from the tail of the transcript: the
161
+ * final reply, and what the model said along the way — narration between
162
+ * tool calls, oldest first, trimmed. A turn starts at the last user message
163
+ * that is not a tool result.
164
+ */
165
+ export function readTranscriptTurn(file) {
141
166
  let fd;
142
167
  try {
143
168
  fd = fs.openSync(file, "r");
144
169
  }
145
170
  catch {
146
- return undefined;
171
+ return { updates: [] };
147
172
  }
148
173
  try {
149
174
  const size = fs.fstatSync(fd).size;
@@ -151,6 +176,7 @@ function finalAssistantText(file) {
151
176
  const buf = Buffer.alloc(size - offset);
152
177
  fs.readSync(fd, buf, 0, buf.length, offset);
153
178
  const lines = buf.toString("utf-8").split("\n");
179
+ const texts = [];
154
180
  // Skip a line truncated by the tail window (index 0 when offset > 0).
155
181
  for (let i = lines.length - 1; i >= (offset > 0 ? 1 : 0); i--) {
156
182
  const line = lines[i].trim();
@@ -158,21 +184,26 @@ function finalAssistantText(file) {
158
184
  continue;
159
185
  try {
160
186
  const entry = JSON.parse(line);
161
- if (entry.type !== "assistant" || !Array.isArray(entry.message?.content))
187
+ const content = entry.message?.content;
188
+ if (entry.type === "user") {
189
+ const isPrompt = typeof content === "string" || (Array.isArray(content) && content.some((item) => item?.type === "text"));
190
+ if (isPrompt)
191
+ break;
162
192
  continue;
163
- const text = entry.message.content
164
- .filter((item) => item?.type === "text" && typeof item.text === "string")
165
- .map((item) => item.text)
166
- .join("\n")
167
- .trim();
193
+ }
194
+ if (entry.type !== "assistant" || !Array.isArray(content))
195
+ continue;
196
+ const text = content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim();
168
197
  if (text)
169
- return text;
198
+ texts.unshift(text);
170
199
  }
171
200
  catch {
172
201
  continue;
173
202
  }
174
203
  }
175
- return undefined;
204
+ const final = texts.at(-1);
205
+ const updates = texts.slice(0, -1).slice(-PROGRESS_UPDATE_LIMIT).map((text) => text.length > PROGRESS_UPDATE_CHARS ? `${text.slice(0, PROGRESS_UPDATE_CHARS - 1).trimEnd()}…` : text);
206
+ return { final, updates };
176
207
  }
177
208
  finally {
178
209
  fs.closeSync(fd);
@@ -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
+ }