@pruddiman/hem 0.0.1-beta-5671db0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
@@ -0,0 +1,607 @@
1
+ /**
2
+ * LLM-assisted organization agent for Hem.
3
+ *
4
+ * Post-processing agent that runs AFTER all doc agents complete.
5
+ * Reviews all written documentation files, restructures/renames/deduplicates
6
+ * for consistency.
7
+ *
8
+ * Architecture (v3 — parallel workers with broadcast):
9
+ * - Absorbs the old DeduplicationAgent's responsibility.
10
+ * - For large file sets (>8 files), splits work across N parallel workers.
11
+ * - Workers communicate via an MCP broadcast tool + prompt injection.
12
+ * - The orchestrator intercepts broadcast tool calls via SSE and relays
13
+ * messages to all peer workers + their active subagents.
14
+ * - For small file sets (≤8 files), falls back to the single-agent path.
15
+ * - The pipeline discovers the final file set by scanning disk afterward.
16
+ */
17
+ import { AuthExpiredError } from "../auth.js";
18
+ import { BaseAgent } from "./base-agent.js";
19
+ import { ArbiterAgent } from "./arbiter-agent.js";
20
+ import { computeOrgWorkers } from "../resources.js";
21
+ import { DecisionQueue, parseDecision, } from "../decision-queue.js";
22
+ // ── Constants ───────────────────────────────────────────────────────────
23
+ /** File count threshold: use parallel workers above this, single agent below. */
24
+ export const PARALLEL_THRESHOLD = 8;
25
+ /**
26
+ * Hard ceiling on parallel org workers. The actual worker count is
27
+ * `min(MAX_WORKERS, computeOrgWorkers(fileCount), computeMaxConcurrency())`.
28
+ * The arbiter is excluded from this cap (it is lightweight).
29
+ */
30
+ export const MAX_WORKERS = 7;
31
+ /**
32
+ * MCP tool name as it appears in SSE events.
33
+ * Format: "{mcp-server-name}_{tool-name}"
34
+ */
35
+ export const BROADCAST_TOOL_NAME = "hem-broadcast_broadcast";
36
+ // ── Agent ───────────────────────────────────────────────────────────────
37
+ /**
38
+ * An agent that reviews all generated documentation and restructures
39
+ * for consistency — deduplicating, renaming, and reorganizing files.
40
+ *
41
+ * Writes/edits files directly via the edit tool. The pipeline discovers
42
+ * the final file set by scanning disk afterward.
43
+ */
44
+ export class OrganizationAgent extends BaseAgent {
45
+ constructor(provider) {
46
+ super(provider);
47
+ }
48
+ /**
49
+ * Run the organization pass over all generated documentation.
50
+ * Automatically selects single-agent or parallel mode based on file count.
51
+ *
52
+ * @param params - Organization parameters including file paths.
53
+ * @param verbose - Optional logging callback (writes to stderr).
54
+ * @throws If session creation or prompting fails.
55
+ */
56
+ async run(params, verbose) {
57
+ if (params.allDocFiles.length > PARALLEL_THRESHOLD) {
58
+ return this.runParallel(params, verbose);
59
+ }
60
+ return this.runSingle(params, verbose);
61
+ }
62
+ /**
63
+ * Single-agent organization pass (original behavior).
64
+ * Used when file count is ≤ PARALLEL_THRESHOLD.
65
+ */
66
+ async runSingle(params, verbose) {
67
+ const tag = "org-agent";
68
+ // 1. Build prompt
69
+ const prompt = OrganizationAgent.buildPrompt(params);
70
+ if (verbose) {
71
+ verbose(`[${tag}] Prompt: ${prompt.length.toLocaleString()} chars`);
72
+ }
73
+ // 2. Create a new session
74
+ const sessionId = await this.createSession("Hem: organization");
75
+ if (verbose) {
76
+ verbose(`[${tag}] Session created: ${sessionId}`);
77
+ }
78
+ // 3. Send prompt — use hem-org agent
79
+ await this.provider.prompt(sessionId, prompt, { agent: "hem-org" });
80
+ if (verbose) {
81
+ verbose(`[${tag}] Agent completed`);
82
+ }
83
+ }
84
+ /**
85
+ * Parallel organization pass using multiple workers with an arbiter.
86
+ *
87
+ * 1. Computes worker count from resource limits (arbiter excluded).
88
+ * 2. Assigns files to workers via round-robin.
89
+ * 3. Subscribes to SSE events for broadcast interception.
90
+ * 4. Creates an arbiter session (long-lived coordinator).
91
+ * 5. Creates N worker sessions in parallel.
92
+ * 6. Relays broadcasts with targeted routing:
93
+ * - Worker → arbiter only.
94
+ * - Arbiter → @tagged worker(s) only (or all if @all-workers / no tag).
95
+ * - Completed sessions are excluded from relay.
96
+ * 7. Kills worker sessions immediately on completion (abort + delete).
97
+ * 8. Intercepts RECALL: broadcasts to respawn a worker for fixes.
98
+ * 9. Sends a final prompt to the arbiter so it can wrap up.
99
+ * 10. Kills the arbiter session.
100
+ */
101
+ async runParallel(params, verbose) {
102
+ const tag = "org-parallel";
103
+ const { projectName, destinationPath, allDocFiles } = params;
104
+ // 1. Compute effective worker count — scales with file count, capped by resources
105
+ const dynamicWorkers = computeOrgWorkers(allDocFiles.length);
106
+ const effectiveMaxWorkers = Math.min(MAX_WORKERS, dynamicWorkers);
107
+ const assignments = assignFilesToWorkers(allDocFiles, effectiveMaxWorkers);
108
+ if (verbose) {
109
+ verbose(`[${tag}] Resource cap: ${effectiveMaxWorkers} sessions (arbiter excluded)`);
110
+ verbose(`[${tag}] Splitting ${allDocFiles.length} files across ${assignments.length} workers + 1 arbiter`);
111
+ for (const a of assignments) {
112
+ verbose(`[${tag}] ${a.label}: ${a.files.length} files`);
113
+ }
114
+ }
115
+ // 2. Session tracking
116
+ // allSessions: sessionId → label (live sessions only — removed on kill)
117
+ // workerSessionIds: set of worker session IDs (for routing logic)
118
+ // completedSessions: workers that finished (excluded from relay)
119
+ // childSessions: childSessionId → parentSessionId (subagent tracking)
120
+ const allSessions = new Map();
121
+ const workerSessionIds = new Set();
122
+ const completedSessions = new Set();
123
+ const childSessions = new Map();
124
+ // Store arbiter session ID at this scope so the relay can identify it
125
+ let arbiterSessionId = "";
126
+ // Keep a reference to the original assignments for RECALL
127
+ const assignmentsByLabel = new Map();
128
+ for (const a of assignments) {
129
+ assignmentsByLabel.set(a.label, a);
130
+ }
131
+ // Pending recall promises collected asynchronously
132
+ const pendingRecalls = [];
133
+ // 3a. Decision queue — sequences MERGE/DELETE to prevent race conditions
134
+ // Instantiated here so it's in scope for the SSE relay and cleanup.
135
+ const decisionQueue = new DecisionQueue({
136
+ destPath: destinationPath,
137
+ relay: async (target, text) => {
138
+ try {
139
+ await this.provider.session.promptAsync({
140
+ path: { id: target.id },
141
+ body: {
142
+ parts: [{ type: "text", text }],
143
+ agent: "hem-org",
144
+ },
145
+ });
146
+ if (verbose) {
147
+ verbose(`[${tag}] → Relayed to ${target.label} (${target.id.slice(0, 8)}…)`);
148
+ }
149
+ }
150
+ catch (err) {
151
+ if (verbose) {
152
+ verbose(`[${tag}] ✗ Relay to ${target.label} failed: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ }
155
+ },
156
+ timeoutMs: undefined, // use default (60 s)
157
+ verbose,
158
+ });
159
+ // 3b. Subscribe to SSE events for broadcast interception
160
+ let sseStream = null;
161
+ let sseLoopDone = false;
162
+ const startSseRelay = async () => {
163
+ try {
164
+ const subscribeResult = await this.provider.event.subscribe();
165
+ sseStream = subscribeResult.stream;
166
+ }
167
+ catch (err) {
168
+ if (verbose) {
169
+ verbose(`[${tag}] ⚠ Failed to subscribe to SSE events: ${err instanceof Error ? err.message : String(err)}`);
170
+ }
171
+ return;
172
+ }
173
+ if (verbose) {
174
+ verbose(`[${tag}] SSE relay started`);
175
+ }
176
+ try {
177
+ for await (const rawEvent of sseStream) {
178
+ if (sseLoopDone)
179
+ break;
180
+ const event = rawEvent;
181
+ if (!event.type || !event.properties)
182
+ continue;
183
+ if (event.type === "message.part.updated") {
184
+ const part = event.properties.part;
185
+ if (!part || part.type !== "tool")
186
+ continue;
187
+ // ── Detect broadcast tool calls ──────────────────────
188
+ if (part.tool === BROADCAST_TOOL_NAME &&
189
+ part.state?.status === "running" &&
190
+ part.sessionID &&
191
+ part.state.input) {
192
+ const message = part.state.input.message;
193
+ const senderSessionId = part.sessionID;
194
+ const senderLabel = allSessions.get(senderSessionId);
195
+ if (message && senderLabel) {
196
+ if (verbose) {
197
+ verbose(`[${tag}] 📢 Broadcast from ${senderLabel}: ${message}`);
198
+ }
199
+ // ── RECALL interception ──────────────────────────
200
+ // If the arbiter broadcasts "RECALL: @org-worker-N <instructions>",
201
+ // intercept it and spawn a new focused worker session.
202
+ const recallMatch = message.match(/^RECALL:\s*@(org-worker-\d+)\s+([\s\S]+)/i);
203
+ if (recallMatch && !workerSessionIds.has(senderSessionId)) {
204
+ const targetLabel = recallMatch[1];
205
+ const instructions = recallMatch[2];
206
+ const originalAssignment = assignmentsByLabel.get(targetLabel);
207
+ if (originalAssignment) {
208
+ if (verbose) {
209
+ verbose(`[${tag}] 🔄 Recalling ${targetLabel} for fixes`);
210
+ }
211
+ const recallPromise = this.runRecalledWorker(projectName, destinationPath, originalAssignment, allDocFiles, assignments.length, instructions, verbose).catch((err) => {
212
+ if (verbose) {
213
+ verbose(`[${tag}] ✗ Recall of ${targetLabel} failed: ${err instanceof Error ? err.message : String(err)}`);
214
+ }
215
+ });
216
+ pendingRecalls.push(recallPromise);
217
+ }
218
+ else if (verbose) {
219
+ verbose(`[${tag}] ⚠ RECALL target ${targetLabel} not found in assignments`);
220
+ }
221
+ // Don't relay RECALL messages — the orchestrator handles them
222
+ continue;
223
+ }
224
+ // ── Targeted routing ─────────────────────────────
225
+ const relayText = `Message from ${senderLabel}: ${message}`;
226
+ const relayTargets = [];
227
+ const senderIsWorker = workerSessionIds.has(senderSessionId);
228
+ if (senderIsWorker) {
229
+ // Worker → always relay to arbiter only
230
+ if (arbiterSessionId && !completedSessions.has(arbiterSessionId)) {
231
+ relayTargets.push({ id: arbiterSessionId, label: "arbiter" });
232
+ }
233
+ }
234
+ else {
235
+ // Arbiter → check for @tags in message
236
+ const tagPattern = /@(org-worker-\d+)/g;
237
+ const tags = [...message.matchAll(tagPattern)].map(m => m[1]);
238
+ const isAllWorkers = /@all-workers/i.test(message);
239
+ if (tags.length > 0 && !isAllWorkers) {
240
+ // Route to specifically tagged workers only
241
+ for (const [sessionId, label] of allSessions) {
242
+ if (sessionId === senderSessionId)
243
+ continue;
244
+ if (completedSessions.has(sessionId))
245
+ continue;
246
+ if (tags.some(t => label === t)) {
247
+ relayTargets.push({ id: sessionId, label });
248
+ }
249
+ }
250
+ }
251
+ else {
252
+ // @all-workers or no tags → broadcast to all live workers
253
+ for (const [sessionId, label] of allSessions) {
254
+ if (sessionId === senderSessionId)
255
+ continue;
256
+ if (completedSessions.has(sessionId))
257
+ continue;
258
+ if (workerSessionIds.has(sessionId)) {
259
+ relayTargets.push({ id: sessionId, label });
260
+ }
261
+ }
262
+ }
263
+ }
264
+ // Also include active subagents of each relay target
265
+ const subagentTargets = [];
266
+ for (const target of relayTargets) {
267
+ for (const [childId, parentId] of childSessions) {
268
+ if (parentId === target.id && !completedSessions.has(childId)) {
269
+ subagentTargets.push({ id: childId, label: `${target.label}/subagent` });
270
+ }
271
+ }
272
+ }
273
+ relayTargets.push(...subagentTargets);
274
+ // Fire relay prompts concurrently (fire-and-forget)
275
+ if (relayTargets.length > 0) {
276
+ // Check if this is a DECISION from the arbiter — route through queue
277
+ const parsedDecision = !senderIsWorker ? parseDecision(message) : undefined;
278
+ if (parsedDecision) {
279
+ // DECISION → route through DecisionQueue for sequencing
280
+ const decisionPromises = relayTargets.map(async (target) => {
281
+ await decisionQueue.handleDecision(parsedDecision, target, relayText);
282
+ });
283
+ void Promise.allSettled(decisionPromises);
284
+ }
285
+ else {
286
+ // Non-DECISION → relay directly (fire-and-forget)
287
+ const relayPromises = relayTargets.map(async (target) => {
288
+ try {
289
+ await this.provider.session.promptAsync({
290
+ path: { id: target.id },
291
+ body: {
292
+ parts: [{ type: "text", text: relayText }],
293
+ agent: "hem-org",
294
+ },
295
+ });
296
+ if (verbose) {
297
+ verbose(`[${tag}] → Relayed to ${target.label} (${target.id.slice(0, 8)}…)`);
298
+ }
299
+ }
300
+ catch (err) {
301
+ if (verbose) {
302
+ verbose(`[${tag}] ✗ Relay to ${target.label} failed: ${err instanceof Error ? err.message : String(err)}`);
303
+ }
304
+ }
305
+ });
306
+ // Don't await — let relays happen async while SSE loop continues
307
+ void Promise.allSettled(relayPromises);
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ // ── Track child sessions via session.created events ────
314
+ if (event.type === "session.created") {
315
+ const props = event.properties;
316
+ if (props.session?.id && props.session.parentID) {
317
+ const parentId = props.session.parentID;
318
+ // Only track if the parent is one of our sessions (worker or arbiter)
319
+ if (allSessions.has(parentId)) {
320
+ childSessions.set(props.session.id, parentId);
321
+ if (verbose) {
322
+ const parentLabel = allSessions.get(parentId);
323
+ verbose(`[${tag}] · Subagent ${props.session.id.slice(0, 8)}… spawned by ${parentLabel}`);
324
+ }
325
+ }
326
+ // Also check if parent is itself a tracked child (nested subagents)
327
+ else if (childSessions.has(parentId)) {
328
+ const rootParent = childSessions.get(parentId);
329
+ childSessions.set(props.session.id, rootParent);
330
+ if (verbose) {
331
+ const rootLabel = allSessions.get(rootParent);
332
+ verbose(`[${tag}] · Nested subagent ${props.session.id.slice(0, 8)}… (root: ${rootLabel})`);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
339
+ catch (err) {
340
+ // SSE stream can error when the server shuts down or on network issues.
341
+ // This is expected during cleanup — only log if we're still running.
342
+ if (!sseLoopDone && verbose) {
343
+ verbose(`[${tag}] ⚠ SSE stream error: ${err instanceof Error ? err.message : String(err)}`);
344
+ }
345
+ }
346
+ };
347
+ // Start SSE relay in background (don't await — runs concurrently)
348
+ const sseRelayPromise = startSseRelay();
349
+ // Start the decision queue filesystem watcher
350
+ decisionQueue.start();
351
+ try {
352
+ // 4. Create arbiter session (long-lived coordinator) via ArbiterAgent
353
+ const arbiter = new ArbiterAgent(this.provider);
354
+ const { sessionId: aId } = await arbiter.run({
355
+ projectName,
356
+ destinationPath,
357
+ allDocFiles,
358
+ workerAssignments: assignments,
359
+ }, verbose);
360
+ arbiterSessionId = aId;
361
+ allSessions.set(arbiterSessionId, "arbiter");
362
+ // 5. Create worker sessions and send prompts concurrently
363
+ const workerPromises = assignments.map(async (assignment) => {
364
+ const prompt = OrganizationAgent.buildWorkerPrompt({
365
+ projectName,
366
+ destinationPath,
367
+ assignedFiles: assignment.files,
368
+ allDocFiles,
369
+ workerLabel: assignment.label,
370
+ totalWorkers: assignments.length,
371
+ });
372
+ if (verbose) {
373
+ verbose(`[${tag}] ${assignment.label}: prompt ${prompt.length.toLocaleString()} chars`);
374
+ }
375
+ // Create session
376
+ const sessionId = await this.createSession(`Hem: ${assignment.label}`);
377
+ allSessions.set(sessionId, assignment.label);
378
+ workerSessionIds.add(sessionId);
379
+ if (verbose) {
380
+ verbose(`[${tag}] ${assignment.label}: session ${sessionId}`);
381
+ }
382
+ // Send prompt and wait for completion
383
+ await this.provider.prompt(sessionId, prompt, { agent: "hem-org" });
384
+ // ── Kill the worker session immediately ──────────────────
385
+ // Worker is done — free resources. Mark as completed first
386
+ // so the relay loop stops sending messages to it, then abort
387
+ // and delete the session. Release any queued decisions that
388
+ // were blocked on this worker's actions.
389
+ completedSessions.add(sessionId);
390
+ allSessions.delete(sessionId);
391
+ decisionQueue.releaseForWorker(assignment.label);
392
+ await this.killSession(sessionId, assignment.label, verbose);
393
+ });
394
+ // 6. Wait for all workers to complete
395
+ const results = await Promise.allSettled(workerPromises);
396
+ // 7. Check for auth errors first
397
+ for (const result of results) {
398
+ if (result.status === "rejected" &&
399
+ result.reason instanceof AuthExpiredError) {
400
+ throw result.reason;
401
+ }
402
+ }
403
+ // 8. Signal the arbiter that all workers are done
404
+ await arbiter.wrapUp(arbiterSessionId, verbose);
405
+ // 9. Wait for any pending recall sessions spawned during arbiter wrap-up
406
+ if (pendingRecalls.length > 0) {
407
+ if (verbose) {
408
+ verbose(`[${tag}] Waiting for ${pendingRecalls.length} pending recall(s)...`);
409
+ }
410
+ await Promise.allSettled(pendingRecalls);
411
+ }
412
+ // 10. Kill the arbiter session
413
+ completedSessions.add(arbiterSessionId);
414
+ allSessions.delete(arbiterSessionId);
415
+ await this.killSession(arbiterSessionId, "arbiter", verbose);
416
+ // 11. Log any worker failures (the pipeline discovers files via disk scan)
417
+ for (let i = 0; i < results.length; i++) {
418
+ const result = results[i];
419
+ const label = assignments[i].label;
420
+ if (result.status === "rejected") {
421
+ const msg = result.reason instanceof Error
422
+ ? result.reason.message
423
+ : String(result.reason);
424
+ if (verbose) {
425
+ verbose(`[${tag}] ✗ ${label} failed: ${msg}`);
426
+ }
427
+ }
428
+ else if (verbose) {
429
+ verbose(`[${tag}] ✓ ${label} completed`);
430
+ }
431
+ }
432
+ }
433
+ finally {
434
+ // 12. Stop the decision queue (releases remaining items, closes watcher)
435
+ decisionQueue.stop();
436
+ // 13. Stop the SSE relay loop
437
+ sseLoopDone = true;
438
+ // The SSE stream will naturally close when the server shuts down,
439
+ // but we set the flag so any in-flight iterations exit cleanly.
440
+ await Promise.race([
441
+ sseRelayPromise,
442
+ new Promise((resolve) => setTimeout(resolve, 1000)),
443
+ ]);
444
+ }
445
+ }
446
+ /**
447
+ * Kill a session: abort any running work, then delete the session.
448
+ * Best-effort — failures are logged but not thrown.
449
+ */
450
+ async killSession(sessionId, label, verbose) {
451
+ try {
452
+ await this.provider.session.abort({ path: { id: sessionId } });
453
+ }
454
+ catch {
455
+ // Session may already be idle — abort failing is fine
456
+ }
457
+ try {
458
+ await this.provider.session.delete({ path: { id: sessionId } });
459
+ if (verbose) {
460
+ verbose(`[org-parallel] 🗑 Killed session ${label} (${sessionId.slice(0, 8)}…)`);
461
+ }
462
+ }
463
+ catch (err) {
464
+ if (verbose) {
465
+ verbose(`[org-parallel] ⚠ Failed to delete session ${label}: ${err instanceof Error ? err.message : String(err)}`);
466
+ }
467
+ }
468
+ }
469
+ /**
470
+ * Spawn a recalled worker session to apply specific fixes.
471
+ *
472
+ * Called when the arbiter broadcasts `RECALL: @org-worker-N <instructions>`.
473
+ * Creates a new session with the worker's original file assignment and a
474
+ * focused prompt containing the fix instructions. The session is killed
475
+ * immediately after completion.
476
+ */
477
+ async runRecalledWorker(projectName, destinationPath, assignment, allDocFiles, totalWorkers, instructions, verbose) {
478
+ const tag = `recall-${assignment.label}`;
479
+ const prompt = [
480
+ `Worker **${assignment.label}** (recalled). Previously organized documentation`,
481
+ `files for **${projectName}**; the arbiter found issues that need fixing.`,
482
+ "",
483
+ "## Destination directory",
484
+ "",
485
+ `All documentation files are in: \`${destinationPath}\``,
486
+ "",
487
+ "## Your assigned files",
488
+ "",
489
+ ...assignment.files.map((f) => `- \`${destinationPath}/${f}\``),
490
+ "",
491
+ "## Fix instructions from the arbiter",
492
+ "",
493
+ instructions,
494
+ "",
495
+ "## Rules",
496
+ "",
497
+ "- Use the edit tool to make the requested fixes directly.",
498
+ "- Only modify files in the assigned list unless the arbiter specifically",
499
+ " instructed otherwise.",
500
+ "- When all fixes are complete, stop.",
501
+ ].join("\n");
502
+ if (verbose) {
503
+ verbose(`[${tag}] Recall prompt: ${prompt.length.toLocaleString()} chars`);
504
+ }
505
+ const sessionId = await this.createSession(`Hem: ${tag}`);
506
+ if (verbose) {
507
+ verbose(`[${tag}] Session ${sessionId}`);
508
+ }
509
+ try {
510
+ await this.provider.prompt(sessionId, prompt, { agent: "hem-org" });
511
+ if (verbose) {
512
+ verbose(`[${tag}] Recall completed`);
513
+ }
514
+ }
515
+ finally {
516
+ // Always kill the recall session
517
+ await this.killSession(sessionId, tag, verbose);
518
+ }
519
+ }
520
+ /**
521
+ * Builds the single-agent organization prompt (original behavior).
522
+ */
523
+ static buildPrompt(params) {
524
+ const { projectName, destinationPath, allDocFiles } = params;
525
+ const parts = [];
526
+ parts.push(`Review all generated documentation files for **${projectName}** and ensure`, `they are well-organized, consistent, and free of duplication.`, "", `**Edit files directly using the edit tool.** Do NOT return Markdown content`, `in the response text.`, "");
527
+ parts.push("## Destination directory", "", `All documentation files are in: \`${destinationPath}\``, "");
528
+ parts.push("## Documentation files to review", "");
529
+ for (const file of allDocFiles) {
530
+ parts.push(`- \`${destinationPath}/${file}\``);
531
+ }
532
+ parts.push("");
533
+ parts.push("## Your tasks", "", "Read ALL the documentation files listed above, then:", "", "1. **Deduplicate**: If multiple files cover the same topic, merge ALL content", " into a single canonical file. **Delete the duplicate entirely.** Do NOT", " leave redirect stubs, 'see also' pages, or slim placeholder files.", "", "2. **Consistent naming**: Ensure all filenames use kebab-case and are descriptive.", " Rename files if needed (create new file, delete old).", "", "3. **Consistent structure**: Ensure all pages follow a consistent heading hierarchy", " and section ordering (Overview, Architecture, Key Concepts, etc.).", "", "4. **Directory organization**: Ensure the directory structure is consistent", " and logical. The doc agents chose their own structure — preserve it if it", " makes sense, or reorganize for clarity.", "", "5. **Fix broken links**: If you rename or move files, update all internal links", " in other documents to point to the new locations.", "", "If the documentation is already well-organized, make no changes.", "");
534
+ parts.push("## When you are done", "", "After making all changes using the edit tool, stop. The pipeline will", "scan the destination directory to discover the final file set.", "", "Key rules:", "- Edit files using the edit tool — do NOT return Markdown in your response", "- When all edits are complete, stop");
535
+ return parts.join("\n");
536
+ }
537
+ /**
538
+ * Builds the prompt for a parallel org worker.
539
+ *
540
+ * Each worker gets:
541
+ * - A scoped identity (e.g. "org-worker-1 of 3")
542
+ * - Its assigned file subset
543
+ * - The full file list for cross-reference awareness
544
+ * - Instructions for using the broadcast tool
545
+ * - Scoped task list (only edit YOUR files)
546
+ */
547
+ static buildWorkerPrompt(params) {
548
+ const { projectName, destinationPath, assignedFiles, allDocFiles, workerLabel, totalWorkers, } = params;
549
+ const parts = [];
550
+ // ── Role section ──────────────────────────────────────────────────
551
+ parts.push(`Worker **${workerLabel}**: review the assigned subset of generated documentation`, `files for **${projectName}** and ensure they are well-organized, consistent,`, `and free of duplication.`, "", `There are ${totalWorkers} workers running in parallel; each owns a distinct set`, `of files. Coordinate with the arbiter via the broadcast tool.`, "", `**Edit files directly using the edit tool.** Full write access is granted`, `— use it. Do NOT delegate file edits to the arbiter or anyone else. Do NOT`, `return Markdown content in the response text. Make changes with the edit`, `tool, then stop when done.`, "", `**IMPORTANT: This session will be terminated on completion.** Complete all`, `edits first, then stop.`, "");
552
+ // ── Destination directory ─────────────────────────────────────────
553
+ parts.push("## Destination directory", "", `All documentation files are in: \`${destinationPath}\``, "");
554
+ // ── Assigned files ────────────────────────────────────────────────
555
+ parts.push("## Your assigned files", "", "The assigned files for organization are:", "");
556
+ for (const file of assignedFiles) {
557
+ parts.push(`- \`${destinationPath}/${file}\``);
558
+ }
559
+ parts.push("");
560
+ // ── All files (for cross-reference awareness) ─────────────────────
561
+ if (allDocFiles.length > assignedFiles.length) {
562
+ parts.push("## All documentation files (for reference)", "", "Other workers are handling these files. You may read them for context", "but do NOT edit files outside your assigned list.", "");
563
+ const otherFiles = allDocFiles.filter((f) => !assignedFiles.includes(f));
564
+ for (const file of otherFiles) {
565
+ parts.push(`- \`${destinationPath}/${file}\``);
566
+ }
567
+ parts.push("");
568
+ }
569
+ // ── Tasks (scoped) ────────────────────────────────────────────────
570
+ parts.push("## Your tasks", "", "Read ALL of your assigned documentation files, then:", "", "1. **Deduplicate**: If files in YOUR subset cover the same topic, merge ALL", " content into one canonical file and **delete the duplicate entirely**.", " Do NOT create redirect stubs, 'see also' pages, or slim placeholder", " files — the duplicate must be fully removed. If you spot a duplicate", " that spans another worker's files, broadcast a SUGGESTION — the arbiter", " will decide how to handle it.", "", "2. **Consistent naming**: Ensure your files use kebab-case. If you rename a", " file, broadcast the rename immediately.", "", "3. **Consistent structure**: Ensure your files follow a consistent heading", " hierarchy and section ordering.", "", "4. **Directory organization**: Organize your files logically.", " If you move files, broadcast the move.", "", "5. **Fix broken links**: Update internal links in your files. If another", " worker or the arbiter broadcasts a rename or deletion, update any", " references in your files.", "", "If your files are already well-organized, make no changes.", "");
571
+ // ── Coordination section ──────────────────────────────────────────
572
+ parts.push("## Coordination with the arbiter", "", "You can communicate using the **broadcast** tool. Your broadcasts go", "**ONLY to the arbiter** — other workers will NOT see your messages.", "The arbiter coordinates all cross-worker communication.", "", "### When to broadcast", "", "Broadcast whenever you make a change that could affect other workers' files:", "", '- **File renames/moves**:', ' `broadcast({ message: "RENAMED: auth/overview.md → auth/authentication-overview.md (kebab-case alignment)" })`', "", '- **File deletions (dedup merges)**:', ' `broadcast({ message: "DELETED: auth/api-keys.md (merged into auth/authentication-overview.md)" })`', "", '- **Structural conventions**:', ' `broadcast({ message: "CONVENTION: All API reference docs moved under api/ subdirectory" })`', "", '- **Suggestions for cross-subset issues** (the arbiter will act on these):', ' `broadcast({ message: "SUGGESTION: auth/api-keys.md and api/authentication.md look like duplicates" })`', "", '- **Acknowledgements** (only when you actually took action):', ' `broadcast({ message: "ACK: Updated 3 links to auth/authentication-overview.md" })`', "", "### When you receive messages", "", "Messages appear as user messages prefixed with the sender's label.", "You will only receive messages from **the arbiter** (DECISION messages).", "Decision types you may receive:", "", "- **MERGE**: `DECISION: @your-label MERGE <source> INTO <target>` —", " read content from the source file and merge it into the target file.", " After merging, broadcast an ACK.", "", "- **DELETE**: `DECISION: @your-label DELETE <filepath>` —", " delete the specified file using the bash tool: `rm <filepath>`.", " After deleting, broadcast an ACK.", "", "- **UPDATE-LINKS**: `DECISION: @your-label UPDATE-LINKS <filepath>` —", " update internal links in the specified file. After updating,", " broadcast an ACK.", "", "- **Free-form**: `DECISION: @your-label <instructions>` —", " execute the instructions directly. After executing, broadcast an ACK.", "", "You **MUST** execute each DECISION yourself. Use the edit tool for", "MERGE and UPDATE-LINKS decisions, and the bash tool (`rm`) for DELETE", "decisions. The arbiter decides how to resolve cross-subset issues.", "", "### Rules", "", "- Only reorganize files in YOUR assigned list. For cross-subset issues,", " broadcast a SUGGESTION and let the arbiter decide.", "- Do NOT ACK messages you cannot act on. Only ACK when you actually", " edited files or updated links. No \"noted\" or \"not my files\" messages.", "- Do NOT broadcast progress updates, questions, or file contents.", "- Keep messages short and actionable.", "- You MUST use the edit tool to make all file changes yourself.", " Do NOT ask the arbiter to edit files for you.", "");
573
+ // ── When done ──────────────────────────────────────────────────────
574
+ parts.push("## When you are done", "", "After making ALL changes (including any changes requested by the arbiter),", "stop. The pipeline will scan the destination directory to discover the", "final file set.", "", "- Make sure all edits are complete before stopping.", "- **Your session will be terminated when you finish.**");
575
+ return parts.join("\n");
576
+ }
577
+ }
578
+ // ── File Assignment ─────────────────────────────────────────────────────
579
+ /**
580
+ * Assigns documentation files to workers using round-robin distribution.
581
+ *
582
+ * Files are sorted alphabetically first so that files in the same
583
+ * directory tend to land on adjacent workers, preserving some locality.
584
+ * Then they are dealt out in order: file 0 → worker 1, file 1 → worker 2,
585
+ * …, wrapping around. This guarantees perfectly balanced workloads (±1 file).
586
+ *
587
+ * @param files - Relative file paths (e.g. "auth/overview.md").
588
+ * @param maxWorkers - Maximum number of workers to create.
589
+ * @returns An array of worker assignments, each with a label and file list.
590
+ */
591
+ export function assignFilesToWorkers(files, maxWorkers) {
592
+ if (files.length === 0)
593
+ return [];
594
+ const numWorkers = Math.min(maxWorkers, files.length);
595
+ if (numWorkers <= 1) {
596
+ return [{ label: "org-worker-1", files: [...files] }];
597
+ }
598
+ const sorted = [...files].sort();
599
+ const workers = [];
600
+ for (let i = 0; i < numWorkers; i++) {
601
+ workers.push({ label: `org-worker-${i + 1}`, files: [] });
602
+ }
603
+ for (let i = 0; i < sorted.length; i++) {
604
+ workers[i % numWorkers].files.push(sorted[i]);
605
+ }
606
+ return workers;
607
+ }