@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.
- package/CHANGELOG.md +44 -0
- package/README.md +16 -1
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +170 -1
- package/node_modules/@hive/shared/dist/types.js +35 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +574 -25
- package/packages/server/dist/api/ws.js +66 -2
- package/packages/server/dist/control/missionQuiesce.js +70 -0
- package/packages/server/dist/health/deriveAlerts.js +8 -0
- package/packages/server/dist/index.js +26 -2
- package/packages/server/dist/loops/loopCommand.js +76 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +119 -31
- package/packages/server/dist/missions/missionsStore.js +1 -1
- package/packages/server/dist/missions/reopenOnWork.js +7 -0
- package/packages/server/dist/paths/pathResolver.js +112 -17
- package/packages/server/dist/plans/plansStore.js +32 -2
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +41 -5
- package/packages/server/dist/roster/workerIdentity.js +135 -30
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
- package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
- 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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
} })
|
|
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
|
-
|
|
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
|
+
}
|