@shanesaravia/hive 0.3.0 → 0.5.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 (42) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +16 -1
  3. package/node_modules/@hive/shared/dist/status.js +9 -0
  4. package/node_modules/@hive/shared/dist/types.d.ts +170 -1
  5. package/node_modules/@hive/shared/dist/types.js +35 -0
  6. package/package.json +1 -1
  7. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  8. package/packages/server/dist/api/rest.js +574 -25
  9. package/packages/server/dist/api/ws.js +66 -2
  10. package/packages/server/dist/control/missionQuiesce.js +70 -0
  11. package/packages/server/dist/health/deriveAlerts.js +8 -0
  12. package/packages/server/dist/index.js +26 -2
  13. package/packages/server/dist/loops/loopCommand.js +76 -0
  14. package/packages/server/dist/loops/loopNoop.js +38 -0
  15. package/packages/server/dist/loops/loopScheduler.js +58 -0
  16. package/packages/server/dist/loops/loopStore.js +118 -0
  17. package/packages/server/dist/loops/monitors.js +38 -0
  18. package/packages/server/dist/messages/attachmentStore.js +92 -0
  19. package/packages/server/dist/messages/messagesStore.js +119 -31
  20. package/packages/server/dist/missions/missionsStore.js +1 -1
  21. package/packages/server/dist/missions/reopenOnWork.js +7 -0
  22. package/packages/server/dist/paths/pathResolver.js +112 -17
  23. package/packages/server/dist/plans/plansStore.js +32 -2
  24. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  25. package/packages/server/dist/roster/replyAsk.js +62 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +41 -5
  27. package/packages/server/dist/roster/workerIdentity.js +135 -30
  28. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  29. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  30. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  31. package/packages/server/dist/terminals/providerDetection.js +27 -0
  32. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  33. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  34. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  35. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  36. package/packages/server/dist/terminals/terminalStream.js +30 -0
  37. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  38. package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
  39. package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
  40. package/packages/web/dist/index.html +2 -2
  41. package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
  42. package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
@@ -1,6 +1,7 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import Database from "better-sqlite3";
3
3
  import { config } from "../config.js";
4
+ import { stripLoopInstructions, stripNoopMarker } from "../loops/loopNoop.js";
4
5
  const DEFAULT_PAGE_SIZE = 30;
5
6
  const MAX_PAGE_SIZE = 100;
6
7
  export const MODEL_CONTEXT_MESSAGE_LIMIT = 12;
@@ -16,6 +17,14 @@ function outputText(job) {
16
17
  }
17
18
  return undefined;
18
19
  }
20
+ /**
21
+ * Stable suffix derived from the text itself, so re-running a sync over the
22
+ * same content is a no-op while genuinely different content lands under its own
23
+ * id instead of overwriting what is already on screen.
24
+ */
25
+ export function contentKey(text) {
26
+ return createHash("sha1").update(text).digest("hex").slice(0, 12);
27
+ }
19
28
  function timestamp(value) {
20
29
  const parsed = value ? Date.parse(value) : NaN;
21
30
  return Number.isNaN(parsed) ? Date.now() : parsed;
@@ -28,6 +37,8 @@ function toMessage(row) {
28
37
  createdAt: row.created_at,
29
38
  jobId: row.job_id ?? "",
30
39
  ...(row.kind === "update" ? { kind: "update" } : {}),
40
+ ...(row.attachments ? { attachments: JSON.parse(row.attachments) } : {}),
41
+ ...(row.noop ? { noop: true } : {}),
31
42
  };
32
43
  }
33
44
  /** Indexed, paginated transcript storage. History is never prompt context by default. */
@@ -46,7 +57,9 @@ export class MessagesStore {
46
57
  text TEXT NOT NULL,
47
58
  created_at INTEGER NOT NULL,
48
59
  job_id TEXT,
49
- kind TEXT
60
+ kind TEXT,
61
+ attachments TEXT,
62
+ noop INTEGER
50
63
  );
51
64
  CREATE INDEX IF NOT EXISTS idx_mission_messages_page
52
65
  ON mission_messages (mission_id, created_at DESC, id DESC);
@@ -57,6 +70,14 @@ export class MessagesStore {
57
70
  this.db.exec("ALTER TABLE mission_messages ADD COLUMN kind TEXT");
58
71
  }
59
72
  catch { /* already present */ }
73
+ try {
74
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN attachments TEXT");
75
+ }
76
+ catch { /* already present */ }
77
+ try {
78
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN noop INTEGER");
79
+ }
80
+ catch { /* already present */ }
60
81
  }
61
82
  close() {
62
83
  this.db.close();
@@ -72,26 +93,44 @@ export class MessagesStore {
72
93
  createdAt: input.createdAt,
73
94
  jobId: input.jobId,
74
95
  ...(input.kind ? { kind: input.kind } : {}),
96
+ ...(input.attachments?.length ? { attachments: input.attachments } : {}),
75
97
  };
76
- this.db
77
- .prepare(`
78
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
79
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
98
+ // The job watcher can observe a newly launched turn before its request
99
+ // route returns. In that window syncJobs inserts the provider's enriched
100
+ // intent (policy, repository grants, etc.) under `<jobId>:user`. The route
101
+ // write is authoritative for what the person actually typed, so replace
102
+ // that internal prompt instead of appending a second user bubble.
103
+ const canonicalUserId = input.id === `${input.jobId}:user` && input.role === "user";
104
+ const id = canonicalUserId
105
+ ? this.replaceCanonicalUserMessage({ ...message, missionId: input.missionId, jobId: input.jobId || "" })
106
+ : this.appendSynced({ ...message, missionId: input.missionId, jobId: input.jobId || "" });
107
+ this.notify();
108
+ return { ...message, id };
109
+ }
110
+ replaceCanonicalUserMessage(message) {
111
+ this.db.transaction(() => {
112
+ // Older builds could already have appended the authoritative text under
113
+ // a content-suffixed id. Collapse any such race artifacts for this turn.
114
+ this.db.prepare("DELETE FROM mission_messages WHERE id LIKE ? AND id <> ?").run(`${message.id}:%`, message.id);
115
+ this.db.prepare(`
116
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
117
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
80
118
  ON CONFLICT(id) DO UPDATE SET
81
119
  mission_id = excluded.mission_id,
120
+ role = excluded.role,
82
121
  text = excluded.text,
83
- created_at = excluded.created_at,
84
- job_id = excluded.job_id
85
- `)
86
- .run({ ...message, kind: message.kind ?? null, missionId: input.missionId, jobId: input.jobId || null });
87
- this.notify();
88
- return message;
122
+ job_id = excluded.job_id,
123
+ kind = excluded.kind,
124
+ attachments = excluded.attachments
125
+ `).run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
126
+ })();
127
+ return message.id;
89
128
  }
90
129
  page(missionId, options = {}) {
91
130
  const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
92
131
  const rows = this.db
93
132
  .prepare(`
94
- SELECT id, mission_id, role, text, created_at, job_id, kind
133
+ SELECT id, mission_id, role, text, created_at, job_id, kind, attachments, noop
95
134
  FROM mission_messages
96
135
  WHERE mission_id = @missionId
97
136
  AND (@before IS NULL OR created_at < @before)
@@ -147,7 +186,7 @@ export class MessagesStore {
147
186
  id: `${jobId}:user`,
148
187
  missionId,
149
188
  role: "user",
150
- text: job.intent,
189
+ text: stripLoopInstructions(job.intent),
151
190
  createdAt: timestamp(job.createdAt),
152
191
  jobId,
153
192
  });
@@ -160,21 +199,35 @@ export class MessagesStore {
160
199
  // Blocked turns (e.g. awaiting acceptance) also carry a compressed
161
200
  // output.result — the transcript text wins there too when it says more.
162
201
  const fullReply = (job.state === "done" || job.state === "blocked") && job.lastText && job.lastText.length > (output?.length ?? 0) ? job.lastText : undefined;
163
- const result = fullReply
202
+ const rawResult = fullReply
164
203
  ?? output
165
204
  ?? (job.state === "failed" ? `⚠️ This turn failed: ${job.detail ?? "the session exited before producing a reply"}` : undefined)
166
- ?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined)
167
- // Mid-turn permission prompts: the job stays "working" but tempo
168
- // flips to blocked with the ask in needs (e.g. "approve Bash: …").
169
- ?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
205
+ ?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined);
206
+ // A looping agent ends a pass that found nothing with a marker line.
207
+ // It is bookkeeping, so it is split off here and never reaches the
208
+ // transcript — only the flag it sets, which folds the run away.
209
+ const quiet = rawResult ? stripNoopMarker(rawResult) : undefined;
210
+ const result = quiet?.text || rawResult;
211
+ // Mid-turn permission prompts: the job stays "working" but tempo flips
212
+ // to blocked with the ask in needs (e.g. "approve Bash: …"). This is a
213
+ // passing event, not the turn's reply — it gets its own appended line
214
+ // rather than sitting in `:assistant`, where the real reply would later
215
+ // overwrite it and the prompt would appear to vanish.
216
+ const prompt = job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined;
170
217
  // What the model said on the way — narration between tool calls — as
171
218
  // compact updates, so the conversation shows the work as it goes
172
219
  // rather than a single reply at the end.
173
220
  const updates = (job.progressTexts ?? []).filter((text) => text !== result);
174
221
  const turnStartedAt = timestamp(job.createdAt);
175
- updates.forEach((text, index) => this.upsertSynced({ id: `${jobId}:update:${index}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
222
+ // Keyed by content, not by position: the filter above can drop an entry
223
+ // on a later sync, and index-keyed ids would then shift every following
224
+ // bubble onto different text.
225
+ updates.forEach((text, index) => this.appendSynced({ id: `${jobId}:update:${contentKey(text)}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
226
+ if (prompt) {
227
+ this.appendSynced({ id: `${jobId}:prompt:${contentKey(prompt)}`, missionId, role: "assistant", text: prompt, createdAt: timestamp(job.updatedAt ?? job.createdAt), jobId, kind: "update" });
228
+ }
176
229
  if (result) {
177
- this.upsertSynced({
230
+ this.appendSynced({
178
231
  id: `${jobId}:assistant`,
179
232
  missionId,
180
233
  role: "assistant",
@@ -183,6 +236,10 @@ export class MessagesStore {
183
236
  jobId,
184
237
  });
185
238
  }
239
+ // After the reply is written, so the run folds as a whole rather than
240
+ // leaving the newest message behind.
241
+ if (quiet?.noop)
242
+ this.markQuietRun(jobId);
186
243
  }
187
244
  });
188
245
  sync();
@@ -191,31 +248,62 @@ export class MessagesStore {
191
248
  onChange(listener) {
192
249
  this.listeners.add(listener);
193
250
  }
194
- /** Upsert that follows mission relinking but never overwrites existing text. */
251
+ /**
252
+ * Insert that follows mission relinking but never rewrites text — and never
253
+ * rewrites `created_at` either, which is the sort key: moving it would slide
254
+ * an already-read bubble to a different place in the transcript.
255
+ */
195
256
  insertSynced(message) {
196
257
  this.db
197
258
  .prepare(`
198
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
199
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
259
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
260
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
200
261
  ON CONFLICT(id) DO UPDATE SET
201
262
  mission_id = excluded.mission_id,
202
- created_at = excluded.created_at,
203
263
  job_id = excluded.job_id
204
264
  `)
205
- .run({ ...message, kind: message.kind ?? null });
265
+ .run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
266
+ return message.id;
206
267
  }
207
- upsertSynced(message) {
268
+ /**
269
+ * The append-only write, and the reason a bubble the reader has already seen
270
+ * cannot be swapped for a different one.
271
+ *
272
+ * Syncs run repeatedly over the same live job, and its text moves as the turn
273
+ * progresses — a compressed `output.result` arrives before the transcript's
274
+ * fuller `lastText`. Only one kind of in-place edit stays truthful: pure
275
+ * growth, where what is on screen is a prefix of what is arriving, so the
276
+ * message extends rather than changes. Text that genuinely differs is a
277
+ * different thing to say and lands as its own appended message.
278
+ */
279
+ appendSynced(message) {
280
+ const existing = this.db
281
+ .prepare("SELECT text FROM mission_messages WHERE id = ?")
282
+ .get(message.id);
283
+ if (existing && existing.text !== message.text && !message.text.startsWith(existing.text)) {
284
+ return this.insertSynced({ ...message, id: `${message.id}:${contentKey(message.text)}` });
285
+ }
208
286
  this.db
209
287
  .prepare(`
210
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
211
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
288
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
289
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
212
290
  ON CONFLICT(id) DO UPDATE SET
213
291
  mission_id = excluded.mission_id,
214
292
  text = excluded.text,
215
- created_at = excluded.created_at,
216
293
  job_id = excluded.job_id
217
294
  `)
218
- .run({ ...message, kind: message.kind ?? null });
295
+ .run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
296
+ return message.id;
297
+ }
298
+ /**
299
+ * Flags every message of a turn as a quiet loop pass.
300
+ *
301
+ * Display metadata only — no text is rewritten and nothing moves, so the
302
+ * append-only guarantee holds. The prompt is flagged alongside the reply so
303
+ * a run folds as one unit rather than leaving its question stranded.
304
+ */
305
+ markQuietRun(jobId) {
306
+ this.db.prepare("UPDATE mission_messages SET noop = 1 WHERE job_id = ? AND noop IS NULL").run(jobId);
219
307
  }
220
308
  notify() {
221
309
  for (const listener of this.listeners)
@@ -74,7 +74,7 @@ export class MissionsStore {
74
74
  return groups.length;
75
75
  }
76
76
  create(input) {
77
- const id = randomUUID();
77
+ const id = input.id ?? randomUUID();
78
78
  const now = Date.now();
79
79
  const mission = {
80
80
  id,
@@ -18,3 +18,10 @@ export function isWorkEvent(event) {
18
18
  export function reopensOnWork(status) {
19
19
  return status === "ready_for_review";
20
20
  }
21
+ /** A completed follow-up returns work to the review state it came from. */
22
+ export function returnsToReviewOnStop(mission, hasPendingDecision) {
23
+ return mission.lifecycleStatus === "active"
24
+ && mission.mode === "orchestrated"
25
+ && mission.currentState === "Working again after a follow-up"
26
+ && !hasPendingDecision;
27
+ }
@@ -67,6 +67,101 @@ export function suggestDirectories(input) {
67
67
  function stripWorktree(value) {
68
68
  return value.split(`${path.sep}.claude${path.sep}worktrees${path.sep}`)[0];
69
69
  }
70
+ /** Directory entries at `base` matching `prefix`, formatted by the caller and capped for a completion list. */
71
+ function listMatchingEntries(base, prefix, format) {
72
+ let entries = [];
73
+ try {
74
+ entries = fs.readdirSync(base, { withFileTypes: true })
75
+ .filter((entry) => !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(prefix))
76
+ .map((entry) => ({ path: format(entry.name, path.join(base, entry.name)), isDirectory: entry.isDirectory() }));
77
+ }
78
+ catch { /* an incomplete path simply has no suggestions yet */ }
79
+ return entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.path.localeCompare(b.path)).slice(0, 20);
80
+ }
81
+ /** "src/App.t" style — resolved under the mission's own cwd, and never escapes it. */
82
+ function suggestRelativePathEntries(cwd, trimmed) {
83
+ const slash = trimmed.lastIndexOf("/");
84
+ const dirPart = slash === -1 ? "" : trimmed.slice(0, slash);
85
+ const prefix = (slash === -1 ? trimmed : trimmed.slice(slash + 1)).toLowerCase();
86
+ const root = path.resolve(cwd);
87
+ const base = path.resolve(root, dirPart);
88
+ if (base !== root && !base.startsWith(`${root}${path.sep}`))
89
+ return [];
90
+ return listMatchingEntries(base, prefix, (name) => (dirPart ? `${dirPart}/${name}` : name));
91
+ }
92
+ /** "~/Development/other-repo/" or "/abs/path" style — browses anywhere on disk, spelled back the way it was typed. */
93
+ function suggestRootedPathEntries(trimmed) {
94
+ const expanded = resolveWorkingDirectory(trimmed);
95
+ const listChildren = trimmed.endsWith("/") || trimmed === "~";
96
+ const base = listChildren ? expanded : path.dirname(expanded);
97
+ const prefix = listChildren ? "" : path.basename(expanded).toLowerCase();
98
+ const home = os.homedir();
99
+ return listMatchingEntries(base, prefix, (_name, full) => (trimmed.startsWith("~") && (full === home || full.startsWith(`${home}${path.sep}`))
100
+ ? `~/${path.relative(home, full)}`
101
+ : full).split(path.sep).join("/"));
102
+ }
103
+ /**
104
+ * Files and directories matching `query` — the data behind live "@path"
105
+ * completion in a composer. A plain query ("packages/web/s") is resolved
106
+ * under the mission's own `cwd` and cannot escape it; a query that already
107
+ * names a home- or filesystem-rooted path ("~/Development/other-repo/",
108
+ * "/abs/path") browses from there instead, so `@` can also reach a sibling
109
+ * repository directly rather than only what `#` already covers by name.
110
+ */
111
+ export function suggestPathEntries(cwd, query) {
112
+ const trimmed = query.replace(/^\.\//, "");
113
+ return trimmed.startsWith("~") || trimmed.startsWith("/")
114
+ ? suggestRootedPathEntries(trimmed)
115
+ : suggestRelativePathEntries(cwd, trimmed);
116
+ }
117
+ /** Every git repository under `roots`, unranked — a name search must see all of them, not just the recently touched ones. */
118
+ function discoverRepositories(roots) {
119
+ const candidates = new Set();
120
+ for (const root of roots) {
121
+ try {
122
+ const topLevel = git(root, "rev-parse", "--show-toplevel");
123
+ if (topLevel)
124
+ candidates.add(topLevel);
125
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
126
+ if (!entry.isDirectory() || entry.name.startsWith("."))
127
+ continue;
128
+ const candidate = path.join(root, entry.name);
129
+ if (fs.existsSync(path.join(candidate, ".git")))
130
+ candidates.add(candidate);
131
+ }
132
+ }
133
+ catch { /* unavailable discovery root */ }
134
+ }
135
+ return [...candidates];
136
+ }
137
+ /** Every repository name Hive can resolve, mapped to its absolute path — siblings first, then recently used ones. */
138
+ function repositoryRegistry(baseRepository) {
139
+ const registry = new Map();
140
+ for (const repo of knownRepositories(baseRepository))
141
+ registry.set(repo.name, repo.path);
142
+ for (const resolvedPath of discoverRepositories([defaultWorkingDirectory(), path.join(os.homedir(), "Development")])) {
143
+ const name = path.basename(resolvedPath).toLowerCase();
144
+ if (!registry.has(name))
145
+ registry.set(name, resolvedPath);
146
+ }
147
+ return registry;
148
+ }
149
+ /**
150
+ * Repository names starting with `query`, for live "#repo" completion. The
151
+ * mission's own repository is included rather than hidden — a message can
152
+ * legitimately name it too (e.g. describing work that spans several repos)
153
+ * — but flagged as `isCurrent` so the composer can mark it and a reader
154
+ * isn't left assuming a mention always points somewhere else.
155
+ */
156
+ export function suggestRepositories(cwd, query) {
157
+ const lower = query.trim().toLowerCase();
158
+ const own = cwd ? path.basename(stripWorktree(cwd)).toLowerCase() : undefined;
159
+ return [...repositoryRegistry(cwd)]
160
+ .filter(([name]) => name.startsWith(lower))
161
+ .sort(([a], [b]) => a.localeCompare(b))
162
+ .slice(0, 10)
163
+ .map(([name, repoPath]) => ({ name, path: repoPath, isCurrent: name === own }));
164
+ }
70
165
  /** Git repositories that sit beside the mission's repo (and the Hive default). */
71
166
  export function knownRepositories(baseRepository) {
72
167
  const roots = new Set();
@@ -92,13 +187,24 @@ export function knownRepositories(baseRepository) {
92
187
  * Repositories a message refers to by name ("add this to monorepo"), so a
93
188
  * mission can be granted cross-repo access without any explicit form field.
94
189
  * The mission's own repository never counts as a mention.
190
+ *
191
+ * An explicit "#name" mention (the composer's own autocomplete syntax) is
192
+ * resolved against the full repository registry — siblings and recently
193
+ * used repositories both — while the implicit bare-word heuristic stays
194
+ * scoped to siblings only, exactly as it always has, so existing behavior
195
+ * is unaffected by adding the explicit form alongside it.
95
196
  */
96
197
  export function detectRepositoryMentions(text, ownRepository) {
97
198
  const own = ownRepository ? path.basename(stripWorktree(ownRepository)).toLowerCase() : undefined;
98
199
  const lower = text.toLowerCase();
99
- return knownRepositories(ownRepository)
200
+ const implicit = knownRepositories(ownRepository)
100
201
  .filter(({ name }) => name !== own && new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(lower))
101
202
  .map((repo) => repo.path);
203
+ const explicitNames = new Set([...text.matchAll(/#([\w.-]+)/g)].map((match) => match[1].toLowerCase()));
204
+ const explicit = explicitNames.size
205
+ ? [...repositoryRegistry(ownRepository)].filter(([name]) => name !== own && explicitNames.has(name)).map(([, repoPath]) => repoPath)
206
+ : [];
207
+ return [...new Set([...implicit, ...explicit])];
102
208
  }
103
209
  /**
104
210
  * The known repository containing filePath, excluding the mission's own repo
@@ -144,24 +250,13 @@ export function inspectWorkingDirectory(input) {
144
250
  }
145
251
  export function recentRepositories(limit = 8) {
146
252
  const roots = [defaultWorkingDirectory(), path.join(os.homedir(), "Development")];
147
- const candidates = new Set();
148
- for (const root of roots) {
149
- try {
150
- if (git(root, "rev-parse", "--show-toplevel"))
151
- candidates.add(git(root, "rev-parse", "--show-toplevel"));
152
- for (const entry of fs.readdirSync(root, { withFileTypes: true }))
153
- if (entry.isDirectory() && !entry.name.startsWith(".")) {
154
- const candidate = path.join(root, entry.name);
155
- if (fs.existsSync(path.join(candidate, ".git")))
156
- candidates.add(candidate);
157
- }
158
- }
159
- catch { /* unavailable discovery root */ }
160
- }
161
- return [...candidates].sort((a, b) => { try {
253
+ return discoverRepositories(roots)
254
+ .sort((a, b) => { try {
162
255
  return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
163
256
  }
164
257
  catch {
165
258
  return 0;
166
- } }).slice(0, limit).map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
259
+ } })
260
+ .slice(0, limit)
261
+ .map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
167
262
  }
@@ -72,6 +72,33 @@ function normalizeGates(gates) {
72
72
  return { ...gate, id, label: gate.label.trim(), required: gate.required !== false, evidence: gate.evidence ?? [], waiver: gate.waiver ? { ...gate.waiver, reason: gate.waiver.reason.trim() } : undefined };
73
73
  });
74
74
  }
75
+ function gateKey(gate) {
76
+ return `${gate.type}:${gate.label.trim().toLocaleLowerCase().replace(/\s+/g, " ")}`;
77
+ }
78
+ /**
79
+ * User-created gates are durable requirements, but the orchestrator is the
80
+ * actor that supplies evidence and reports when the requirement is satisfied.
81
+ * Reconcile an equivalent reported gate onto the protected user gate instead
82
+ * of retaining both IDs. The orchestrator may satisfy or fail the requirement,
83
+ * but only the user may waive it or weaken its definition.
84
+ */
85
+ function reconcileUserGates(gates) {
86
+ const userGates = gates.filter((gate) => gate.source === "user");
87
+ if (!userGates.length)
88
+ return gates;
89
+ const reported = gates.filter((gate) => gate.source !== "user");
90
+ const matches = (userGate) => reported.filter((gate) => gate.id === userGate.id || gateKey(gate) === gateKey(userGate));
91
+ const matchedIds = new Set(userGates.flatMap((gate) => matches(gate).map((item) => item.id)));
92
+ const reconciled = userGates.map((gate) => {
93
+ const equivalents = matches(gate);
94
+ const satisfied = equivalents.find((item) => item.status === "satisfied");
95
+ const failed = equivalents.find((item) => item.status === "failed");
96
+ const status = gate.status === "satisfied" || gate.status === "waived" ? gate.status : satisfied ? "satisfied" : failed ? "failed" : gate.status;
97
+ const evidence = [...new Set([...(gate.evidence ?? []), ...equivalents.flatMap((item) => item.evidence ?? [])])];
98
+ return { ...gate, status, evidence };
99
+ });
100
+ return [...reported.filter((gate) => !matchedIds.has(gate.id)), ...reconciled];
101
+ }
75
102
  /** Durable current plan plus immutable JSON snapshots for revision history. */
76
103
  export class PlansStore {
77
104
  db;
@@ -91,7 +118,7 @@ export class PlansStore {
91
118
  const incoming = input.gates === undefined ? current?.gates ?? [] : normalizeGates(input.gates);
92
119
  const protectedUserGates = preserveUserGates ? (current?.gates ?? []).filter((gate) => gate.source === "user") : [];
93
120
  const protectedIds = new Set(protectedUserGates.map((gate) => gate.id));
94
- const gates = [...incoming.filter((gate) => !protectedIds.has(gate.id)), ...protectedUserGates];
121
+ const gates = reconcileUserGates([...incoming.filter((gate) => !protectedIds.has(gate.id)), ...protectedUserGates]);
95
122
  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) };
96
123
  const json = JSON.stringify(plan);
97
124
  this.db.transaction(() => {
@@ -209,7 +236,10 @@ export class PlansStore {
209
236
  this.db.prepare("INSERT OR REPLACE INTO mission_plan_revisions (mission_id, revision, updated_at, plan_json) VALUES (?, ?, ?, ?)").run(newId, revision.revision, revision.updatedAt, JSON.stringify({ ...revision, missionId: newId })); this.db.prepare("DELETE FROM mission_plans WHERE mission_id = ?").run(oldId); this.db.prepare("DELETE FROM mission_plan_revisions WHERE mission_id = ?").run(oldId); })(); }
210
237
  get(missionId) {
211
238
  const row = this.db.prepare("SELECT plan_json FROM mission_plans WHERE mission_id = ?").get(missionId);
212
- return row ? JSON.parse(row.plan_json) : undefined;
239
+ if (!row)
240
+ return undefined;
241
+ const plan = JSON.parse(row.plan_json);
242
+ return { ...plan, gates: reconcileUserGates(plan.gates ?? []) };
213
243
  }
214
244
  revisions(missionId) {
215
245
  return this.db.prepare("SELECT plan_json FROM mission_plan_revisions WHERE mission_id = ? ORDER BY revision DESC").all(missionId).map((row) => JSON.parse(row.plan_json));
@@ -0,0 +1,47 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ const MAX_PATCH = 200_000;
4
+ function git(cwd, args) {
5
+ return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 5_000, maxBuffer: 2_000_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
6
+ }
7
+ function gitDiff(cwd, args) {
8
+ try {
9
+ return git(cwd, ["diff", ...args]);
10
+ }
11
+ catch (error) {
12
+ const stdout = error.stdout;
13
+ if (stdout !== undefined)
14
+ return String(stdout).trim();
15
+ throw error;
16
+ }
17
+ }
18
+ /** Read-only source-control evidence for a mission-controlled repository. */
19
+ export function reviewDiff(cwd) {
20
+ if (!cwd || !fs.existsSync(cwd))
21
+ return { available: false, stat: "", patch: "", files: [], truncated: false, error: "Repository is unavailable." };
22
+ try {
23
+ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true")
24
+ throw new Error("Not a Git worktree");
25
+ let base;
26
+ for (const candidate of ["origin/main", "main", "origin/master", "master"]) {
27
+ try {
28
+ base = git(cwd, ["merge-base", "HEAD", candidate]);
29
+ if (base)
30
+ break;
31
+ }
32
+ catch { /* try next baseline */ }
33
+ }
34
+ const range = base ? [`${base}..HEAD`] : [];
35
+ const committed = gitDiff(cwd, range);
36
+ const working = gitDiff(cwd, ["HEAD"]);
37
+ const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
38
+ const untrackedPatches = untracked.map((file) => gitDiff(cwd, ["--no-index", "--", "/dev/null", file]));
39
+ const patch = [committed, working, ...untrackedPatches].filter(Boolean).join("\n");
40
+ const stat = [git(cwd, ["diff", "--stat", ...range]), git(cwd, ["diff", "--stat", "HEAD"]), untracked.length ? `${untracked.length} untracked file${untracked.length === 1 ? "" : "s"}` : ""].filter(Boolean).join("\n");
41
+ const files = [...new Set([...git(cwd, ["diff", "--name-only", ...range]).split("\n"), ...git(cwd, ["diff", "--name-only", "HEAD"]).split("\n"), ...untracked].filter(Boolean))];
42
+ return { available: true, base, stat, patch: patch.slice(0, MAX_PATCH), files, truncated: patch.length > MAX_PATCH };
43
+ }
44
+ catch (error) {
45
+ return { available: false, stat: "", patch: "", files: [], truncated: false, error: error.message };
46
+ }
47
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Phrases that mark a reply as genuinely handing a choice back to the reader.
3
+ *
4
+ * Deliberately narrow. A turn ending "let me know if you need anything else"
5
+ * is politeness, not a decision, and surfacing those as "Needs you" would
6
+ * teach the reader to ignore the one signal that should never be ignored.
7
+ */
8
+ const ASK_PATTERNS = [
9
+ /\bfor you to decide\b/i,
10
+ /\byour (call|decision|choice)\b/i,
11
+ /\bup to you\b/i,
12
+ /\bsay the word\b/i,
13
+ /\bwhich (one )?(would|do) you (prefer|want)\b/i,
14
+ /\b(do|would) you want me to\b/i,
15
+ /\bwould you like me to\b/i,
16
+ /\bshall I\b/i,
17
+ /\bshould I\b[^?]*\?/i,
18
+ /\bplease (confirm|advise|choose)\b/i,
19
+ /\blet me know (which|whether|if you want|if you'd)\b/i,
20
+ /\bneeds? your (input|decision|sign-?off|approval|steer)\b/i,
21
+ /\bwaiting (on|for) your (call|decision|answer|input)\b/i,
22
+ ];
23
+ /** Sign-offs that look like asks but close a turn rather than pausing it. */
24
+ const CLOSING_PATTERNS = [
25
+ /^\s*(anything else|any(thing)? more)\b/i,
26
+ /\blet me know if you (need|have|spot|want) (anything|any|something)\b/i,
27
+ ];
28
+ const MAX_QUESTION_CHARS = 800;
29
+ /** Strips leading markdown emphasis and list markers so the ask reads as prose. */
30
+ function tidy(line) {
31
+ return line.replace(/^\s*[-*]\s+/, "").replace(/\*\*/g, "").trim();
32
+ }
33
+ /**
34
+ * Finds a decision the agent handed back in the prose of its reply.
35
+ *
36
+ * Hive's decision inbox is fed by structured signals — emitted decisions,
37
+ * parked permissions, provider prompts — none of which fire when an agent
38
+ * simply finishes its turn by asking a question. Those asks were reaching the
39
+ * transcript with no affordance at all: no "Needs you", nothing in the inbox,
40
+ * nothing to answer, and a mission that looked finished while it waited.
41
+ *
42
+ * Returns the ask from the first marker line onward, so an enumerated list of
43
+ * choices arrives with the question that introduced it.
44
+ */
45
+ export function detectReplyAsk(reply) {
46
+ if (!reply?.trim())
47
+ return undefined;
48
+ const lines = reply.split("\n");
49
+ const at = lines.findIndex((line) => {
50
+ if (CLOSING_PATTERNS.some((pattern) => pattern.test(line)))
51
+ return false;
52
+ return ASK_PATTERNS.some((pattern) => pattern.test(line));
53
+ });
54
+ if (at === -1)
55
+ return undefined;
56
+ // From the marker to the end: an ask is routinely a header followed by the
57
+ // options it introduces, and the header alone would be unanswerable.
58
+ const question = lines.slice(at).map(tidy).filter(Boolean).join("\n").trim();
59
+ if (!question)
60
+ return undefined;
61
+ return { question: question.length > MAX_QUESTION_CHARS ? `${question.slice(0, MAX_QUESTION_CHARS).trimEnd()}…` : question };
62
+ }