@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,1169 @@
1
+ /**
2
+ * OpenCode server lifecycle management and documentation generation for Hem.
3
+ *
4
+ * Starts the OpenCode server via `createOpencode()` from `@opencode-ai/sdk`,
5
+ * configures model and permission settings, provides a clean shutdown
6
+ * mechanism, and orchestrates parallel documentation generation across all
7
+ * file groups using `p-limit` for concurrency control.
8
+ *
9
+ * Architecture (v2):
10
+ * - Agents that produce documents write them directly via the edit tool.
11
+ * - Agents that return structured data return strict JSON only.
12
+ * - The doc agent has full autonomy over file naming, structure, and
13
+ * how many files to create per group.
14
+ * - Permissions are scoped: `edit: "allow"` + `external_directory`
15
+ * restricted to the destination directory for writing agents.
16
+ *
17
+ * Reference: FR-005, FR-006, FR-007.
18
+ */
19
+ import { readFile } from "node:fs/promises";
20
+ import { resolve, join } from "node:path";
21
+ import pLimit from "p-limit";
22
+ import fg from "fast-glob";
23
+ import { createOpencode } from "@opencode-ai/sdk";
24
+ import { AuthExpiredError } from "./auth.js";
25
+ import { findFreePort } from "./server-utils.js";
26
+ import { computeMaxConcurrency, describeResourceLimits } from "./resources.js";
27
+ import { OpenCodeProvider } from "./providers/index.js";
28
+ // Re-export permission constants from provider module for backward compatibility.
29
+ export { READ_ONLY_BASH, ORG_AGENT_BASH } from "./providers/index.js";
30
+ // ── Multi-Agent Exploration Constants ────────────────────────────────
31
+ /**
32
+ * Total file count threshold above which multi-agent exploration is activated.
33
+ * Below this threshold, the existing 1-agent-per-group behavior is used.
34
+ */
35
+ export const LARGE_PROJECT_THRESHOLD = 200;
36
+ /**
37
+ * Hard ceiling on exploration sub-agents per group.
38
+ * Actual count is computed by `computeAgentsPerGroup()`.
39
+ */
40
+ export const MAX_EXPLORATION_AGENTS_PER_GROUP = 8;
41
+ /**
42
+ * Minimum files per sub-agent. Prevents creating sub-agents for trivially
43
+ * small partitions.
44
+ */
45
+ const MIN_FILES_PER_AGENT = 3;
46
+ /**
47
+ * Compute the number of sub-agents to use per exploration group based on
48
+ * total file count. Returns 1 (no splitting) when below the threshold.
49
+ *
50
+ * Scaling: starts at 4 agents at the threshold, grows to MAX at 2x the threshold.
51
+ */
52
+ export function computeAgentsPerGroup(totalFiles) {
53
+ if (totalFiles < LARGE_PROJECT_THRESHOLD)
54
+ return 1;
55
+ const ratio = totalFiles / LARGE_PROJECT_THRESHOLD;
56
+ // Scale from 4 to MAX_EXPLORATION_AGENTS_PER_GROUP as ratio goes from 1 to 2+
57
+ const agents = Math.min(MAX_EXPLORATION_AGENTS_PER_GROUP, Math.max(4, Math.round(4 * ratio)));
58
+ return agents;
59
+ }
60
+ /**
61
+ * Partition a group's files into N sub-groups for parallel exploration.
62
+ * Uses round-robin assignment (files sorted by path for determinism).
63
+ * Each sub-group is a new `FileGroup` with id `{group.id}:sub-{n}`.
64
+ *
65
+ * Returns the original group as a single-element array if agentCount is 1
66
+ * or the group has fewer files than MIN_FILES_PER_AGENT * agentCount.
67
+ */
68
+ export function partitionGroupFiles(group, agentCount) {
69
+ if (agentCount <= 1 || group.files.length < MIN_FILES_PER_AGENT * 2) {
70
+ return [group];
71
+ }
72
+ const effectiveAgents = Math.min(agentCount, Math.floor(group.files.length / MIN_FILES_PER_AGENT));
73
+ if (effectiveAgents <= 1)
74
+ return [group];
75
+ const sorted = [...group.files].sort((a, b) => a.path.localeCompare(b.path));
76
+ const subGroups = [];
77
+ for (let i = 0; i < effectiveAgents; i++) {
78
+ subGroups.push({
79
+ id: `${group.id}:sub-${i + 1}`,
80
+ label: `${group.label} (part ${i + 1}/${effectiveAgents})`,
81
+ type: group.type,
82
+ files: [],
83
+ directory: group.directory,
84
+ });
85
+ }
86
+ for (let i = 0; i < sorted.length; i++) {
87
+ subGroups[i % effectiveAgents].files.push(sorted[i]);
88
+ }
89
+ return subGroups;
90
+ }
91
+ /**
92
+ * Merge exploration findings from multiple sub-agents that explored
93
+ * different partitions of the same group.
94
+ *
95
+ * Strategy:
96
+ * - `groupId`: use the original group ID (not the sub-agent IDs)
97
+ * - `questions`: concatenate, deduplicate by `question` text
98
+ * - `integrations`: concatenate, deduplicate by `name`; merge `sourceLocations` and `operationalQuestions` for duplicates
99
+ * - `complexitySignals`: concatenate, deduplicate by `description`
100
+ * - `crossGroupDependencies`: merge into a Set (unique strings)
101
+ * - `styleGuides`: concatenate, deduplicate by `filePath`
102
+ * - `summary`: join all summaries with newline separator
103
+ */
104
+ export function mergeExplorationFindings(groupId, findingsArray) {
105
+ if (findingsArray.length === 0) {
106
+ return { groupId, text: "" };
107
+ }
108
+ if (findingsArray.length === 1) {
109
+ return { ...findingsArray[0], groupId };
110
+ }
111
+ const text = findingsArray
112
+ .map((f) => f.text.trim())
113
+ .filter((t) => t.length > 0)
114
+ .join("\n\n");
115
+ return { groupId, text };
116
+ }
117
+ // ── Main ────────────────────────────────────────────────────────────────
118
+ /**
119
+ * Starts the OpenCode server and returns a client with a shutdown function.
120
+ *
121
+ * Delegates to {@link OpenCodeProvider} for server lifecycle management,
122
+ * permission configuration, and MCP tool setup.
123
+ *
124
+ * @param model - The resolved model selection (provider + model ID).
125
+ * @param options - Orchestrator options including the destination path.
126
+ * @param factory - Optional `createOpencode` override for testing.
127
+ * @param findPort - Optional port finder override for testing.
128
+ * @returns An object with `client` and `shutdown`.
129
+ * @throws If the OpenCode server fails to start.
130
+ * @throws If `destinationPath` is empty or blank.
131
+ */
132
+ export async function createOrchestrator(model, options, factory = createOpencode, findPort = findFreePort) {
133
+ const provider = await OpenCodeProvider.create({
134
+ model,
135
+ destinationPath: options.destinationPath,
136
+ }, factory, findPort);
137
+ return {
138
+ client: provider,
139
+ shutdown: () => provider.cleanup(),
140
+ };
141
+ }
142
+ // ── Existing Docs Detection ────────────────────────────────────────────
143
+ /**
144
+ * Scans the destination directory for existing `.md` files and reads
145
+ * their content. Used to provide merge context to the doc agent.
146
+ *
147
+ * @param destinationPath - Absolute path to the documentation destination.
148
+ * @param verbose - Optional verbose logger.
149
+ * @returns An array of `{ path, content }` for each existing `.md` file.
150
+ */
151
+ export async function scanExistingDocs(destinationPath, verbose) {
152
+ const absoluteDestination = resolve(destinationPath);
153
+ if (verbose) {
154
+ verbose(`[orchestrator] Scanning for existing docs in: ${absoluteDestination}`);
155
+ }
156
+ let mdFiles;
157
+ try {
158
+ mdFiles = await fg("**/*.md", {
159
+ cwd: absoluteDestination,
160
+ absolute: false,
161
+ onlyFiles: true,
162
+ dot: false,
163
+ });
164
+ }
165
+ catch {
166
+ // Destination directory doesn't exist yet — no existing docs.
167
+ if (verbose) {
168
+ verbose("[orchestrator] Destination directory does not exist; skipping existing docs scan");
169
+ }
170
+ return [];
171
+ }
172
+ if (mdFiles.length === 0) {
173
+ if (verbose) {
174
+ verbose("[orchestrator] No existing .md files found in destination");
175
+ }
176
+ return [];
177
+ }
178
+ if (verbose) {
179
+ verbose(`[orchestrator] Found ${mdFiles.length} existing .md file(s)`);
180
+ }
181
+ const existingDocs = [];
182
+ for (const relPath of mdFiles) {
183
+ const absPath = join(absoluteDestination, relPath);
184
+ try {
185
+ const content = await readFile(absPath, "utf-8");
186
+ existingDocs.push({ path: relPath, content });
187
+ }
188
+ catch (err) {
189
+ if (verbose) {
190
+ const msg = err instanceof Error ? err.message : String(err);
191
+ verbose(`[orchestrator] Could not read existing doc "${relPath}": ${msg}`);
192
+ }
193
+ }
194
+ }
195
+ if (verbose) {
196
+ verbose(`[orchestrator] Successfully read ${existingDocs.length}/${mdFiles.length} existing doc(s)`);
197
+ }
198
+ return existingDocs;
199
+ }
200
+ // ── Per-group doc relevance filtering ─────────────────────────────────
201
+ /** Maximum number of existing docs to include with full content per group. */
202
+ const MAX_RELEVANT_DOCS = 5;
203
+ /**
204
+ * Extracts keywords from a string by splitting on non-alphanumeric characters,
205
+ * lowercasing, and filtering out short/common words.
206
+ */
207
+ function extractKeywords(text) {
208
+ const STOP_WORDS = new Set([
209
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
210
+ "of", "with", "by", "from", "is", "are", "was", "were", "be", "been",
211
+ "has", "have", "had", "do", "does", "did", "this", "that", "it", "its",
212
+ "not", "no", "so", "if", "as", "we", "our", "can", "will", "may",
213
+ "should", "would", "could", "each", "all", "any", "both",
214
+ ]);
215
+ const words = text
216
+ .toLowerCase()
217
+ .split(/[^a-z0-9]+/)
218
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w));
219
+ return new Set(words);
220
+ }
221
+ /**
222
+ * Scores a single existing doc's relevance to a file group.
223
+ *
224
+ * Signals (additive):
225
+ * 1. **Path overlap**: shared directory segments between the doc path and
226
+ * the group's file paths / common directory.
227
+ * 2. **Label match**: words from the group label appearing in the doc's
228
+ * first 300 characters (headings/title area).
229
+ * 3. **Topic match**: keywords from the group's exploration findings
230
+ * (summary, question texts, integration names) appearing in doc content.
231
+ */
232
+ function scoreDoc(doc, group, topicKeywords) {
233
+ let score = 0;
234
+ // 1. Path overlap — shared directory segments
235
+ const docSegments = doc.path.toLowerCase().replace(/\.md$/, "").split(/[/\\]+/);
236
+ const groupDir = group.directory.toLowerCase();
237
+ const groupDirSegments = groupDir.split(/[/\\]+/).filter(Boolean);
238
+ const groupFileSegments = group.files
239
+ .flatMap((f) => f.path.toLowerCase().split(/[/\\]+/))
240
+ .filter(Boolean);
241
+ const pathPool = new Set([...groupDirSegments, ...groupFileSegments]);
242
+ for (const seg of docSegments) {
243
+ if (seg.length > 1 && pathPool.has(seg)) {
244
+ score += 3;
245
+ }
246
+ }
247
+ // 2. Label match — group label words in doc heading area
248
+ const headingArea = doc.content.slice(0, 300).toLowerCase();
249
+ const labelWords = group.label
250
+ .toLowerCase()
251
+ .split(/[^a-z0-9]+/)
252
+ .filter((w) => w.length > 2);
253
+ for (const word of labelWords) {
254
+ if (headingArea.includes(word)) {
255
+ score += 2;
256
+ }
257
+ }
258
+ // Group ID segments (e.g., "user-auth" → ["user", "auth"])
259
+ const idSegments = group.id
260
+ .toLowerCase()
261
+ .split(/[^a-z0-9]+/)
262
+ .filter((w) => w.length > 2);
263
+ for (const seg of idSegments) {
264
+ if (headingArea.includes(seg)) {
265
+ score += 2;
266
+ }
267
+ }
268
+ // 3. Topic match — exploration keywords in doc content
269
+ if (topicKeywords.size > 0) {
270
+ const docKeywords = extractKeywords(doc.content);
271
+ let topicHits = 0;
272
+ for (const kw of topicKeywords) {
273
+ if (docKeywords.has(kw)) {
274
+ topicHits++;
275
+ }
276
+ }
277
+ // Diminishing returns: first 5 hits are worth more
278
+ score += Math.min(topicHits, 5) * 1 + Math.max(0, topicHits - 5) * 0.3;
279
+ }
280
+ return score;
281
+ }
282
+ /**
283
+ * Builds topic keywords from a group's exploration findings for relevance scoring.
284
+ */
285
+ function buildTopicKeywords(findings) {
286
+ if (!findings)
287
+ return new Set();
288
+ return extractKeywords(findings.text);
289
+ }
290
+ /**
291
+ * Filters the full set of existing docs down to the most relevant subset
292
+ * for a specific file group.
293
+ *
294
+ * Returns:
295
+ * - `relevantDocs`: up to `MAX_RELEVANT_DOCS` docs with full content,
296
+ * sorted by relevance score (highest first).
297
+ * - `mentionedPaths`: paths of remaining docs (no content) so the agent
298
+ * knows they exist.
299
+ *
300
+ * Relevance is scored by path overlap with the group's files/directory,
301
+ * label/ID keyword matches in the doc heading, and topic keyword overlap
302
+ * from the group's exploration findings.
303
+ */
304
+ export function filterRelevantDocs(allDocs, group, groupFindings) {
305
+ if (allDocs.length <= MAX_RELEVANT_DOCS) {
306
+ return { relevantDocs: allDocs, mentionedPaths: [] };
307
+ }
308
+ const topicKeywords = buildTopicKeywords(groupFindings);
309
+ // Score each doc
310
+ const scored = allDocs.map((doc) => ({
311
+ doc,
312
+ score: scoreDoc(doc, group, topicKeywords),
313
+ }));
314
+ // Sort by score descending, stable on original order for ties
315
+ scored.sort((a, b) => b.score - a.score);
316
+ const MAX_DOC_CONTENT_CHARS = 3_072;
317
+ const relevantDocs = scored.slice(0, MAX_RELEVANT_DOCS).map((s) => ({
318
+ path: s.doc.path,
319
+ content: s.doc.content.length > MAX_DOC_CONTENT_CHARS
320
+ ? s.doc.content.slice(0, MAX_DOC_CONTENT_CHARS) + "\n... [truncated]"
321
+ : s.doc.content,
322
+ }));
323
+ const mentionedPaths = scored.slice(MAX_RELEVANT_DOCS).map((s) => s.doc.path);
324
+ return { relevantDocs, mentionedPaths };
325
+ }
326
+ // ── Index-based doc resolution ────────────────────────────────────────
327
+ /** Maximum content chars to read from disk per relevant doc when using index. */
328
+ const MAX_DOC_CONTENT_CHARS_INDEX = 3_072;
329
+ /**
330
+ * Builds an FTS5 search query from a file group's label, ID, and exploration findings.
331
+ */
332
+ function buildGroupSearchQuery(group, findings) {
333
+ const parts = [
334
+ group.label,
335
+ group.id.replace(/[-_]/g, " "),
336
+ ];
337
+ if (findings?.text) {
338
+ parts.push(findings.text.slice(0, 200));
339
+ }
340
+ return parts.join(" ");
341
+ }
342
+ /**
343
+ * Resolves the most relevant existing docs for a group using a SearchIndex.
344
+ *
345
+ * Uses FTS5 search to rank docs by keyword overlap with the group's label,
346
+ * ID, and exploration findings. Reads the top results from disk (capped at
347
+ * 3 072 chars each). All other indexed paths are returned as mentionedPaths.
348
+ */
349
+ async function queryRelevantDocsFromIndex(index, destinationPath, group, groupFindings) {
350
+ const query = buildGroupSearchQuery(group, groupFindings);
351
+ const topResults = index.search(query, MAX_RELEVANT_DOCS);
352
+ const topPaths = new Set(topResults.map((r) => r.path));
353
+ const relevantDocs = await Promise.all(topResults.map(async ({ path }) => {
354
+ try {
355
+ const raw = await readFile(join(destinationPath, path), "utf-8");
356
+ const content = raw.length > MAX_DOC_CONTENT_CHARS_INDEX
357
+ ? raw.slice(0, MAX_DOC_CONTENT_CHARS_INDEX) + "\n... [truncated]"
358
+ : raw;
359
+ return { path, content };
360
+ }
361
+ catch {
362
+ return { path, content: "" };
363
+ }
364
+ }));
365
+ const mentionedPaths = [...index.getAllHashes().keys()].filter((p) => !topPaths.has(p));
366
+ return { relevantDocs: relevantDocs.filter((d) => d.content !== ""), mentionedPaths };
367
+ }
368
+ /**
369
+ * Resolves relevant existing docs for a group using either the search index
370
+ * (primary path) or the in-memory fallback (`filterRelevantDocs`).
371
+ */
372
+ async function resolveRelevantDocs(searchIndex, destinationPath, existingDocs, group, groupFindings) {
373
+ if (searchIndex) {
374
+ return queryRelevantDocsFromIndex(searchIndex, destinationPath, group, groupFindings);
375
+ }
376
+ return filterRelevantDocs(existingDocs, group, groupFindings);
377
+ }
378
+ // ── Exploration Phase ─────────────────────────────────────────────────
379
+ /**
380
+ * Runs the exploration phase for all groups in parallel.
381
+ *
382
+ * Each group gets its own ExplorationAgent session. Results are collected
383
+ * via `Promise.allSettled` — failed explorations are logged as warnings
384
+ * but do not abort the pipeline.
385
+ *
386
+ * @param explorationAgent - The exploration agent instance.
387
+ * @param groups - The file groups to explore.
388
+ * @param options - CLI options including source path and concurrency.
389
+ * @param onProgress - Callback to update progress state.
390
+ * @param onGroupComplete - Optional callback invoked each time a group's
391
+ * exploration finishes successfully. Used by the
392
+ * streaming pipeline to launch doc agents eagerly.
393
+ * @returns All successful `ExplorationFindings[]`.
394
+ */
395
+ export async function runExploration(explorationAgent, groups, options, onProgress, onGroupComplete) {
396
+ const sourceRoot = resolve(options.source);
397
+ const verbose = options.verbose
398
+ ? (msg) => {
399
+ const ts = new Date().toISOString().slice(11, 23);
400
+ process.stderr.write(`[${ts}] ${msg}\n`);
401
+ }
402
+ : undefined;
403
+ const effectiveConcurrency = computeMaxConcurrency(options.concurrency);
404
+ if (verbose) {
405
+ verbose(`[orchestrator] Resource limits: ${describeResourceLimits(options.concurrency)}`);
406
+ verbose(`[orchestrator] Starting exploration: ${groups.length} groups, concurrency=${effectiveConcurrency}`);
407
+ }
408
+ // Build allGroups summary for cross-group awareness
409
+ const allGroups = groups.map((group) => ({
410
+ id: group.id,
411
+ label: group.label,
412
+ files: group.files.map((f) => f.path),
413
+ }));
414
+ // Compute total file count to determine if multi-agent exploration is needed
415
+ const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
416
+ const agentsPerGroup = computeAgentsPerGroup(totalFiles);
417
+ const isMultiAgent = agentsPerGroup > 1;
418
+ if (verbose && isMultiAgent) {
419
+ verbose(`[orchestrator] Large project detected: ${totalFiles} files → ${agentsPerGroup} agents per group`);
420
+ }
421
+ // Initialize per-group exploration status for the dashboard
422
+ const explorationStatuses = groups.map((group) => ({
423
+ groupId: group.id,
424
+ label: group.label,
425
+ status: "queued",
426
+ ...(isMultiAgent ? { subAgentProgress: { completed: 0, total: 0 } } : {}),
427
+ }));
428
+ onProgress({
429
+ phase: "exploration",
430
+ explorationStatuses: [...explorationStatuses],
431
+ });
432
+ const limit = pLimit(effectiveConcurrency);
433
+ // ── Single-agent path (existing behavior, totalFiles < threshold) ──
434
+ if (!isMultiAgent) {
435
+ const results = await Promise.allSettled(groups.map((group, i) => limit(async () => {
436
+ explorationStatuses[i] = { ...explorationStatuses[i], status: "generating" };
437
+ onProgress({
438
+ explorationStatuses: [...explorationStatuses],
439
+ });
440
+ try {
441
+ const findings = await explorationAgent.run(group, sourceRoot, allGroups, verbose);
442
+ explorationStatuses[i] = { ...explorationStatuses[i], status: "completed" };
443
+ onProgress({
444
+ explorationStatuses: [...explorationStatuses],
445
+ });
446
+ onGroupComplete?.(group.id, findings);
447
+ return findings;
448
+ }
449
+ catch (firstError) {
450
+ if (firstError instanceof AuthExpiredError) {
451
+ throw firstError;
452
+ }
453
+ const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
454
+ if (verbose) {
455
+ verbose(`[orchestrator] Exploration failed for "${group.id}": ${firstMessage}`);
456
+ verbose(`[orchestrator] Retrying exploration for "${group.id}" (attempt 2)...`);
457
+ }
458
+ // First attempt failed — retry once
459
+ explorationStatuses[i] = { ...explorationStatuses[i], status: "generating" };
460
+ onProgress({
461
+ explorationStatuses: [...explorationStatuses],
462
+ });
463
+ try {
464
+ const findings = await explorationAgent.run(group, sourceRoot, allGroups, verbose);
465
+ explorationStatuses[i] = { ...explorationStatuses[i], status: "completed" };
466
+ onProgress({
467
+ explorationStatuses: [...explorationStatuses],
468
+ });
469
+ onGroupComplete?.(group.id, findings);
470
+ return findings;
471
+ }
472
+ catch (retryError) {
473
+ if (retryError instanceof AuthExpiredError) {
474
+ throw retryError;
475
+ }
476
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
477
+ if (verbose) {
478
+ verbose(`[orchestrator] Exploration retry failed for "${group.id}": ${retryMessage}`);
479
+ }
480
+ explorationStatuses[i] = {
481
+ ...explorationStatuses[i],
482
+ status: "failed",
483
+ error: retryMessage,
484
+ };
485
+ onProgress({
486
+ explorationStatuses: [...explorationStatuses],
487
+ });
488
+ throw retryError;
489
+ }
490
+ }
491
+ })));
492
+ // Check for AuthExpiredError in settled results
493
+ for (const result of results) {
494
+ if (result.status === "rejected" &&
495
+ result.reason instanceof AuthExpiredError) {
496
+ throw result.reason;
497
+ }
498
+ }
499
+ // Collect successful findings, log warnings for failures
500
+ const allFindings = [];
501
+ const warnings = [];
502
+ for (let i = 0; i < results.length; i++) {
503
+ const result = results[i];
504
+ if (result.status === "fulfilled") {
505
+ allFindings.push(result.value);
506
+ }
507
+ else {
508
+ const groupId = groups[i].id;
509
+ const errorMsg = result.reason instanceof Error
510
+ ? result.reason.message
511
+ : String(result.reason);
512
+ warnings.push(`Exploration failed for "${groupId}": ${errorMsg}`);
513
+ }
514
+ }
515
+ if (warnings.length > 0) {
516
+ onProgress({ warnings });
517
+ if (verbose) {
518
+ for (const warning of warnings) {
519
+ verbose(`[orchestrator] WARNING: ${warning}`);
520
+ }
521
+ }
522
+ }
523
+ if (verbose) {
524
+ verbose(`[orchestrator] Exploration complete: ${allFindings.length}/${groups.length} groups explored successfully`);
525
+ }
526
+ onProgress({ explorationComplete: true });
527
+ return allFindings;
528
+ }
529
+ // ── Multi-agent path (totalFiles >= threshold) ─────────────────────
530
+ if (verbose) {
531
+ verbose(`[orchestrator] Multi-agent exploration: ${groups.length} groups × up to ${agentsPerGroup} agents`);
532
+ }
533
+ // For each group, partition files into sub-groups and run sub-agents.
534
+ // All sub-agents across all groups share the same p-limit instance.
535
+ const results = await Promise.allSettled(groups.map((group, i) => {
536
+ const subGroups = partitionGroupFiles(group, agentsPerGroup);
537
+ const subAgentCount = subGroups.length;
538
+ // Update status with sub-agent count
539
+ explorationStatuses[i] = {
540
+ ...explorationStatuses[i],
541
+ status: "generating",
542
+ subAgentProgress: { completed: 0, total: subAgentCount },
543
+ };
544
+ onProgress({
545
+ explorationStatuses: [...explorationStatuses],
546
+ });
547
+ if (verbose) {
548
+ verbose(`[orchestrator] Group "${group.id}": ${group.files.length} files → ${subAgentCount} sub-agent(s)`);
549
+ }
550
+ // Run all sub-agents for this group, each through p-limit
551
+ return (async () => {
552
+ let subAgentsCompleted = 0;
553
+ /**
554
+ * Run a single sub-agent with retry logic.
555
+ * Returns the findings on success, or throws on double failure.
556
+ */
557
+ const runSubAgent = async (subGroup) => {
558
+ try {
559
+ return await explorationAgent.run(subGroup, sourceRoot, allGroups, verbose);
560
+ }
561
+ catch (firstError) {
562
+ if (firstError instanceof AuthExpiredError)
563
+ throw firstError;
564
+ const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
565
+ if (verbose) {
566
+ verbose(`[orchestrator] Sub-agent failed for "${subGroup.id}": ${firstMessage}`);
567
+ verbose(`[orchestrator] Retrying sub-agent "${subGroup.id}" (attempt 2)...`);
568
+ }
569
+ try {
570
+ return await explorationAgent.run(subGroup, sourceRoot, allGroups, verbose);
571
+ }
572
+ catch (retryError) {
573
+ if (retryError instanceof AuthExpiredError)
574
+ throw retryError;
575
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
576
+ if (verbose) {
577
+ verbose(`[orchestrator] Sub-agent retry failed for "${subGroup.id}": ${retryMessage}`);
578
+ }
579
+ throw retryError;
580
+ }
581
+ }
582
+ };
583
+ const subResults = await Promise.allSettled(subGroups.map((subGroup) => limit(async () => {
584
+ const findings = await runSubAgent(subGroup);
585
+ subAgentsCompleted++;
586
+ explorationStatuses[i] = {
587
+ ...explorationStatuses[i],
588
+ subAgentProgress: { completed: subAgentsCompleted, total: subAgentCount },
589
+ };
590
+ onProgress({
591
+ explorationStatuses: [...explorationStatuses],
592
+ });
593
+ return findings;
594
+ })));
595
+ // Check for AuthExpiredError in sub-results
596
+ for (const sub of subResults) {
597
+ if (sub.status === "rejected" && sub.reason instanceof AuthExpiredError) {
598
+ throw sub.reason;
599
+ }
600
+ }
601
+ // Collect successful sub-findings
602
+ const successfulFindings = [];
603
+ const subWarnings = [];
604
+ for (let j = 0; j < subResults.length; j++) {
605
+ const sub = subResults[j];
606
+ if (sub.status === "fulfilled") {
607
+ successfulFindings.push(sub.value);
608
+ }
609
+ else {
610
+ const subGroupId = subGroups[j].id;
611
+ const errorMsg = sub.reason instanceof Error ? sub.reason.message : String(sub.reason);
612
+ subWarnings.push(`Sub-agent failed for "${subGroupId}": ${errorMsg}`);
613
+ }
614
+ }
615
+ if (subWarnings.length > 0 && verbose) {
616
+ for (const w of subWarnings) {
617
+ verbose(`[orchestrator] WARNING: ${w}`);
618
+ }
619
+ }
620
+ // If ALL sub-agents failed, mark the group as failed and throw
621
+ if (successfulFindings.length === 0) {
622
+ explorationStatuses[i] = {
623
+ ...explorationStatuses[i],
624
+ status: "failed",
625
+ error: `All ${subAgentCount} sub-agents failed`,
626
+ };
627
+ onProgress({ explorationStatuses: [...explorationStatuses] });
628
+ throw new Error(`All ${subAgentCount} sub-agents failed for group "${group.id}"`);
629
+ }
630
+ // Merge sub-findings into one ExplorationFindings for this group
631
+ const merged = mergeExplorationFindings(group.id, successfulFindings);
632
+ // Mark group as completed
633
+ explorationStatuses[i] = {
634
+ ...explorationStatuses[i],
635
+ status: "completed",
636
+ subAgentProgress: { completed: subAgentsCompleted, total: subAgentCount },
637
+ };
638
+ onProgress({ explorationStatuses: [...explorationStatuses] });
639
+ // Notify streaming pipeline gate
640
+ onGroupComplete?.(group.id, merged);
641
+ if (verbose) {
642
+ verbose(`[orchestrator] Group "${group.id}": merged ${successfulFindings.length}/${subAgentCount} sub-findings → ` +
643
+ `${merged.text.length.toLocaleString()} chars`);
644
+ }
645
+ return merged;
646
+ })();
647
+ }));
648
+ // Check for AuthExpiredError in group-level results
649
+ for (const result of results) {
650
+ if (result.status === "rejected" &&
651
+ result.reason instanceof AuthExpiredError) {
652
+ throw result.reason;
653
+ }
654
+ }
655
+ // Collect successful findings, log warnings for failures
656
+ const allFindings = [];
657
+ const warnings = [];
658
+ for (let i = 0; i < results.length; i++) {
659
+ const result = results[i];
660
+ if (result.status === "fulfilled") {
661
+ allFindings.push(result.value);
662
+ }
663
+ else {
664
+ const groupId = groups[i].id;
665
+ const errorMsg = result.reason instanceof Error
666
+ ? result.reason.message
667
+ : String(result.reason);
668
+ warnings.push(`Exploration failed for "${groupId}": ${errorMsg}`);
669
+ }
670
+ }
671
+ if (warnings.length > 0) {
672
+ onProgress({ warnings });
673
+ if (verbose) {
674
+ for (const warning of warnings) {
675
+ verbose(`[orchestrator] WARNING: ${warning}`);
676
+ }
677
+ }
678
+ }
679
+ if (verbose) {
680
+ verbose(`[orchestrator] Exploration complete: ${allFindings.length}/${groups.length} groups explored successfully`);
681
+ }
682
+ onProgress({ explorationComplete: true });
683
+ return allFindings;
684
+ }
685
+ // ── Documentation Generation Phase ────────────────────────────────────
686
+ /**
687
+ * Runs the doc agent for a single file group.
688
+ *
689
+ * The agent writes documentation files directly via the edit tool.
690
+ * The pipeline discovers what was produced by scanning the destination
691
+ * directory afterward.
692
+ *
693
+ * @param agent - The documentation agent instance.
694
+ * @param group - The file group to document.
695
+ * @param context - Shared generation context.
696
+ * @param verbose - Optional verbose logger.
697
+ * @returns A `GenerationResult` indicating success or failure.
698
+ */
699
+ export async function runDocAgent(agent, group, context, verbose) {
700
+ await agent.run(group, context, verbose);
701
+ if (verbose) {
702
+ verbose(`[orchestrator] Doc agent completed for "${group.id}"`);
703
+ }
704
+ return {
705
+ groupId: group.id,
706
+ status: "generated",
707
+ error: undefined,
708
+ };
709
+ }
710
+ /**
711
+ * Generates documentation for all file groups in parallel.
712
+ *
713
+ * Implements a streaming pipeline that overlaps exploration and documentation:
714
+ * - **Exploration + existing docs scan** run in parallel.
715
+ * - **Documentation** for each group starts as soon as its exploration
716
+ * finishes, rather than waiting for all explorations to complete.
717
+ * - Each doc agent receives accumulated exploration findings at launch time.
718
+ *
719
+ * When no `explorationAgent` is provided, all doc agents launch immediately.
720
+ *
721
+ * @param agent - The documentation agent instance.
722
+ * @param groups - The file groups to document.
723
+ * @param options - CLI options including source/destination paths.
724
+ * @param onProgress - Callback to update progress state.
725
+ * @param explorationAgent - Optional exploration agent.
726
+ * @returns Results, exploration findings, and existing docs.
727
+ */
728
+ export async function generateDocumentation(agent, groups, options, onProgress, explorationAgent, searchIndex) {
729
+ const destinationPath = resolve(options.destination);
730
+ const projectName = options.name ?? resolve(options.source).split("/").pop() ?? "project";
731
+ const verbose = options.verbose
732
+ ? (msg) => {
733
+ const ts = new Date().toISOString().slice(11, 23);
734
+ process.stderr.write(`[${ts}] ${msg}\n`);
735
+ }
736
+ : undefined;
737
+ // ── Per-group exploration gates ─────────────────────────────────
738
+ // Each group gets a promise that resolves when its exploration finishes
739
+ // (or immediately if no exploration agent is provided).
740
+ const groupGates = new Map();
741
+ const groupGatePromises = new Map();
742
+ for (const group of groups) {
743
+ const gate = {};
744
+ const promise = new Promise((res, rej) => {
745
+ gate.resolve = res;
746
+ gate.reject = rej;
747
+ });
748
+ // Attach a no-op catch so Node doesn't warn about unhandled rejection
749
+ // when a gate rejects before its doc agent starts awaiting. The consumer
750
+ // still awaits this promise and re-throws, so errors aren't lost.
751
+ promise.catch((err) => {
752
+ verbose?.(`[gate:${group.id}] rejected: ${err instanceof Error ? err.message : String(err)}`);
753
+ });
754
+ groupGates.set(group.id, gate);
755
+ groupGatePromises.set(group.id, promise);
756
+ }
757
+ // Accumulated findings — grows as explorations complete.
758
+ const allFindings = [];
759
+ // ── Launch exploration + existingDocs scan in parallel ───────────
760
+ let explorationPromise;
761
+ let existingDocsPromise;
762
+ if (explorationAgent) {
763
+ if (verbose) {
764
+ verbose(`[orchestrator] Exploration phase: ${groups.length} groups`);
765
+ }
766
+ explorationPromise = runExploration(explorationAgent, groups, options, onProgress,
767
+ // Streaming callback: resolve the gate for each completed group
768
+ (groupId, findings) => {
769
+ allFindings.push(findings);
770
+ groupGates.get(groupId)?.resolve(findings);
771
+ });
772
+ // When exploration fully settles, resolve any remaining gates for groups
773
+ // whose exploration failed so their doc agents can proceed without findings.
774
+ // On AuthExpiredError, reject all remaining gates to abort doc agents.
775
+ explorationPromise.then(() => {
776
+ for (const gate of groupGates.values()) {
777
+ gate.resolve(undefined);
778
+ }
779
+ }, (err) => {
780
+ if (err instanceof AuthExpiredError) {
781
+ // Reject remaining gates — doc agents will propagate the error
782
+ for (const gate of groupGates.values()) {
783
+ gate.reject(err);
784
+ }
785
+ }
786
+ else {
787
+ // Non-auth errors: still let doc agents proceed
788
+ for (const gate of groupGates.values()) {
789
+ gate.resolve(undefined);
790
+ }
791
+ }
792
+ });
793
+ }
794
+ else {
795
+ // No exploration — resolve all gates immediately
796
+ for (const gate of groupGates.values()) {
797
+ gate.resolve(undefined);
798
+ }
799
+ }
800
+ // When a search index is available, skip the full-content scan.
801
+ // Relevant docs are fetched on demand (per group) via the index.
802
+ // Fall back to scanExistingDocs when no index is provided.
803
+ existingDocsPromise = searchIndex
804
+ ? Promise.resolve([])
805
+ : scanExistingDocs(destinationPath, verbose);
806
+ // ── Wait for existing docs (needed before launching any doc agent) ──
807
+ const existingDocs = await existingDocsPromise;
808
+ if (verbose) {
809
+ if (searchIndex) {
810
+ verbose("[orchestrator] Existing docs: using search index for per-group relevance queries");
811
+ }
812
+ else {
813
+ verbose(`[orchestrator] Existing docs: ${existingDocs.length} file(s) found` +
814
+ (existingDocs.length > 0 ? " — will provide to doc agents for merge" : ""));
815
+ }
816
+ }
817
+ // ── Documentation phase ─────────────────────────────────────────
818
+ if (verbose) {
819
+ verbose(`[orchestrator] Documentation phase: ${groups.length} groups, concurrency=${options.concurrency}`);
820
+ }
821
+ // Initialize per-group session tracking
822
+ const sessions = groups.map((group) => ({
823
+ sessionId: "",
824
+ groupId: group.id,
825
+ status: "pending",
826
+ startedAt: undefined,
827
+ completedAt: undefined,
828
+ retryCount: 0,
829
+ error: undefined,
830
+ }));
831
+ // Initialize per-group status for the dashboard
832
+ const groupStatuses = groups.map((group) => ({
833
+ groupId: group.id,
834
+ label: group.label,
835
+ status: "queued",
836
+ }));
837
+ let completedSessions = 0;
838
+ let failedSessions = 0;
839
+ onProgress({
840
+ phase: "generation",
841
+ groupStatuses: [...groupStatuses],
842
+ completedSessions: 0,
843
+ failedSessions: 0,
844
+ });
845
+ const docConcurrency = computeMaxConcurrency(options.concurrency);
846
+ // ── Multi-agent documentation detection ──────────────────────────
847
+ const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
848
+ const docAgentsPerGroup = computeAgentsPerGroup(totalFiles);
849
+ const isMultiAgentDoc = docAgentsPerGroup > 1;
850
+ if (verbose) {
851
+ verbose(`[orchestrator] Starting documentation: ${groups.length} groups, concurrency=${docConcurrency}` +
852
+ (isMultiAgentDoc ? `, multi-agent=${docAgentsPerGroup} agents/group` : ""));
853
+ }
854
+ const limit = pLimit(docConcurrency);
855
+ const results = await Promise.allSettled(groups.map((group, i) => {
856
+ if (!isMultiAgentDoc) {
857
+ // ── Single-agent path (existing behavior) ──────────────────
858
+ return limit(async () => {
859
+ // Wait for this group's exploration to finish (or resolve immediately)
860
+ const groupFindings = await groupGatePromises.get(group.id);
861
+ const session = sessions[i];
862
+ session.status = "active";
863
+ session.startedAt = Date.now();
864
+ groupStatuses[i] = { ...groupStatuses[i], status: "generating" };
865
+ onProgress({
866
+ groupStatuses: [...groupStatuses],
867
+ });
868
+ // Build per-group context with accumulated findings at this point
869
+ // Resolve relevant docs via index (preferred) or in-memory filter
870
+ const { relevantDocs, mentionedPaths } = await resolveRelevantDocs(searchIndex, destinationPath, existingDocs, group, groupFindings);
871
+ const groupContext = {
872
+ projectName,
873
+ sourceRoot: resolve(options.source),
874
+ destinationPath,
875
+ allGroups: groups.map((g) => ({
876
+ id: g.id,
877
+ label: g.label,
878
+ files: g.files.map((f) => f.path),
879
+ })),
880
+ explorationFindings: [...allFindings],
881
+ existingDocs: relevantDocs,
882
+ mentionedDocPaths: mentionedPaths,
883
+ groupFindings,
884
+ };
885
+ try {
886
+ const result = await runDocAgent(agent, group, groupContext, verbose);
887
+ session.status = "completed";
888
+ session.completedAt = Date.now();
889
+ completedSessions++;
890
+ groupStatuses[i] = { ...groupStatuses[i], status: "completed" };
891
+ onProgress({
892
+ groupStatuses: [...groupStatuses],
893
+ completedSessions,
894
+ failedSessions,
895
+ });
896
+ return result;
897
+ }
898
+ catch (firstError) {
899
+ if (firstError instanceof AuthExpiredError) {
900
+ throw firstError;
901
+ }
902
+ // First attempt failed — retry once
903
+ session.status = "retrying";
904
+ session.retryCount = 1;
905
+ if (verbose) {
906
+ verbose(`[orchestrator] Retrying "${group.id}" (attempt 2)...`);
907
+ }
908
+ groupStatuses[i] = { ...groupStatuses[i], status: "generating" };
909
+ onProgress({
910
+ groupStatuses: [...groupStatuses],
911
+ });
912
+ try {
913
+ const result = await runDocAgent(agent, group, groupContext, verbose);
914
+ session.status = "completed";
915
+ session.completedAt = Date.now();
916
+ completedSessions++;
917
+ groupStatuses[i] = { ...groupStatuses[i], status: "completed" };
918
+ onProgress({
919
+ groupStatuses: [...groupStatuses],
920
+ completedSessions,
921
+ failedSessions,
922
+ });
923
+ return result;
924
+ }
925
+ catch (retryError) {
926
+ const retryMessage = retryError instanceof Error
927
+ ? retryError.message
928
+ : String(retryError);
929
+ session.status = "failed";
930
+ session.completedAt = Date.now();
931
+ session.error = retryMessage;
932
+ failedSessions++;
933
+ groupStatuses[i] = { ...groupStatuses[i], status: "failed" };
934
+ onProgress({
935
+ groupStatuses: [...groupStatuses],
936
+ completedSessions,
937
+ failedSessions,
938
+ });
939
+ return {
940
+ groupId: group.id,
941
+ status: "failed",
942
+ error: retryMessage,
943
+ };
944
+ }
945
+ }
946
+ });
947
+ }
948
+ // ── Multi-agent path (totalFiles >= LARGE_PROJECT_THRESHOLD) ──
949
+ const subGroups = partitionGroupFiles(group, docAgentsPerGroup);
950
+ const subAgentCount = subGroups.length;
951
+ // Update status with sub-agent count
952
+ groupStatuses[i] = {
953
+ ...groupStatuses[i],
954
+ status: "generating",
955
+ subAgentProgress: { completed: 0, total: subAgentCount },
956
+ };
957
+ if (verbose) {
958
+ verbose(`[orchestrator] Doc group "${group.id}": ${group.files.length} files → ${subAgentCount} sub-agent(s)`);
959
+ }
960
+ return (async () => {
961
+ // Wait for this group's exploration to finish (or resolve immediately)
962
+ const groupFindings = await groupGatePromises.get(group.id);
963
+ const session = sessions[i];
964
+ session.status = "active";
965
+ session.startedAt = Date.now();
966
+ onProgress({
967
+ groupStatuses: [...groupStatuses],
968
+ });
969
+ // Build per-group context (shared across all sub-agents for this group)
970
+ const { relevantDocs, mentionedPaths } = await resolveRelevantDocs(searchIndex, destinationPath, existingDocs, group, groupFindings);
971
+ const groupContext = {
972
+ projectName,
973
+ sourceRoot: resolve(options.source),
974
+ destinationPath,
975
+ allGroups: groups.map((g) => ({
976
+ id: g.id,
977
+ label: g.label,
978
+ files: g.files.map((f) => f.path),
979
+ })),
980
+ explorationFindings: [...allFindings],
981
+ existingDocs: relevantDocs,
982
+ mentionedDocPaths: mentionedPaths,
983
+ groupFindings,
984
+ };
985
+ let subAgentsCompleted = 0;
986
+ /**
987
+ * Run a single doc sub-agent with retry logic.
988
+ * Returns the result on success, or throws on double failure.
989
+ */
990
+ const runSubDocAgent = async (subGroup) => {
991
+ try {
992
+ return await runDocAgent(agent, subGroup, groupContext, verbose);
993
+ }
994
+ catch (firstError) {
995
+ if (firstError instanceof AuthExpiredError)
996
+ throw firstError;
997
+ if (verbose) {
998
+ const msg = firstError instanceof Error ? firstError.message : String(firstError);
999
+ verbose(`[orchestrator] Doc sub-agent failed for "${subGroup.id}": ${msg}`);
1000
+ verbose(`[orchestrator] Retrying doc sub-agent "${subGroup.id}" (attempt 2)...`);
1001
+ }
1002
+ try {
1003
+ return await runDocAgent(agent, subGroup, groupContext, verbose);
1004
+ }
1005
+ catch (retryError) {
1006
+ if (retryError instanceof AuthExpiredError)
1007
+ throw retryError;
1008
+ if (verbose) {
1009
+ const msg = retryError instanceof Error ? retryError.message : String(retryError);
1010
+ verbose(`[orchestrator] Doc sub-agent retry failed for "${subGroup.id}": ${msg}`);
1011
+ }
1012
+ throw retryError;
1013
+ }
1014
+ }
1015
+ };
1016
+ const subResults = await Promise.allSettled(subGroups.map((subGroup) => limit(async () => {
1017
+ const result = await runSubDocAgent(subGroup);
1018
+ subAgentsCompleted++;
1019
+ groupStatuses[i] = {
1020
+ ...groupStatuses[i],
1021
+ subAgentProgress: { completed: subAgentsCompleted, total: subAgentCount },
1022
+ };
1023
+ onProgress({
1024
+ groupStatuses: [...groupStatuses],
1025
+ });
1026
+ return result;
1027
+ })));
1028
+ // Check for AuthExpiredError in sub-results
1029
+ for (const sub of subResults) {
1030
+ if (sub.status === "rejected" && sub.reason instanceof AuthExpiredError) {
1031
+ throw sub.reason;
1032
+ }
1033
+ }
1034
+ // Collect successful sub-results
1035
+ const successfulResults = [];
1036
+ const subWarnings = [];
1037
+ for (let j = 0; j < subResults.length; j++) {
1038
+ const sub = subResults[j];
1039
+ if (sub.status === "fulfilled") {
1040
+ successfulResults.push(sub.value);
1041
+ }
1042
+ else {
1043
+ const subGroupId = subGroups[j].id;
1044
+ const errorMsg = sub.reason instanceof Error ? sub.reason.message : String(sub.reason);
1045
+ subWarnings.push(`Doc sub-agent failed for "${subGroupId}": ${errorMsg}`);
1046
+ }
1047
+ }
1048
+ if (subWarnings.length > 0 && verbose) {
1049
+ for (const w of subWarnings) {
1050
+ verbose(`[orchestrator] WARNING: ${w}`);
1051
+ }
1052
+ }
1053
+ // If ALL sub-agents failed, mark the group as failed
1054
+ if (successfulResults.length === 0) {
1055
+ session.status = "failed";
1056
+ session.completedAt = Date.now();
1057
+ session.error = `All ${subAgentCount} doc sub-agents failed`;
1058
+ failedSessions++;
1059
+ groupStatuses[i] = {
1060
+ ...groupStatuses[i],
1061
+ status: "failed",
1062
+ error: `All ${subAgentCount} doc sub-agents failed`,
1063
+ };
1064
+ onProgress({
1065
+ groupStatuses: [...groupStatuses],
1066
+ completedSessions,
1067
+ failedSessions,
1068
+ });
1069
+ return {
1070
+ groupId: group.id,
1071
+ status: "failed",
1072
+ error: `All ${subAgentCount} doc sub-agents failed`,
1073
+ };
1074
+ }
1075
+ // At least one sub-agent succeeded — mark the group as completed
1076
+ session.status = "completed";
1077
+ session.completedAt = Date.now();
1078
+ completedSessions++;
1079
+ groupStatuses[i] = {
1080
+ ...groupStatuses[i],
1081
+ status: "completed",
1082
+ subAgentProgress: { completed: subAgentsCompleted, total: subAgentCount },
1083
+ };
1084
+ onProgress({
1085
+ groupStatuses: [...groupStatuses],
1086
+ completedSessions,
1087
+ failedSessions,
1088
+ });
1089
+ if (verbose) {
1090
+ verbose(`[orchestrator] Doc group "${group.id}": ${successfulResults.length}/${subAgentCount} sub-agents completed`);
1091
+ }
1092
+ return {
1093
+ groupId: group.id,
1094
+ status: "generated",
1095
+ error: undefined,
1096
+ };
1097
+ })();
1098
+ }));
1099
+ // ── Wait for exploration to fully settle ────────────────────────
1100
+ // (it may still be running for groups that didn't gate a doc agent,
1101
+ // e.g. groups whose exploration failed)
1102
+ if (explorationPromise) {
1103
+ try {
1104
+ await explorationPromise;
1105
+ }
1106
+ catch (err) {
1107
+ if (err instanceof AuthExpiredError) {
1108
+ throw err;
1109
+ }
1110
+ // Non-auth exploration errors were already handled inside runExploration
1111
+ }
1112
+ if (verbose) {
1113
+ verbose(`[orchestrator] Exploration complete: ${allFindings.length}/${groups.length} groups explored`);
1114
+ }
1115
+ }
1116
+ // Resolve any remaining gates for groups whose exploration failed
1117
+ // (they never got an onGroupComplete callback)
1118
+ for (const gate of groupGates.values()) {
1119
+ gate.resolve(undefined);
1120
+ }
1121
+ // Check for AuthExpiredError in settled results
1122
+ for (const result of results) {
1123
+ if (result.status === "rejected" &&
1124
+ result.reason instanceof AuthExpiredError) {
1125
+ throw result.reason;
1126
+ }
1127
+ }
1128
+ // Collect results from settled promises
1129
+ const generationResults = results.map((result) => {
1130
+ if (result.status === "fulfilled") {
1131
+ return result.value;
1132
+ }
1133
+ const error = result.reason instanceof Error
1134
+ ? result.reason.message
1135
+ : String(result.reason);
1136
+ return {
1137
+ groupId: "unknown",
1138
+ status: "failed",
1139
+ error,
1140
+ };
1141
+ });
1142
+ // Emit warnings for failed groups
1143
+ const failedWarnings = generationResults
1144
+ .filter((r) => r.status === "failed")
1145
+ .map((r) => `Failed to generate: ${r.groupId}${r.error ? ` (${r.error})` : ""}`);
1146
+ if (failedWarnings.length > 0) {
1147
+ onProgress({
1148
+ warnings: failedWarnings,
1149
+ });
1150
+ }
1151
+ return { results: generationResults, explorationFindings: allFindings, existingDocs };
1152
+ }
1153
+ // ── Exit Code ──────────────────────────────────────────────────────────
1154
+ /**
1155
+ * Determine the exit code from documentation generation results.
1156
+ *
1157
+ * @param results - Generation results for all groups.
1158
+ * @returns 0 if all succeeded, 1 if some failed, 2 if all failed or empty.
1159
+ */
1160
+ export function getExitCode(results) {
1161
+ if (results.length === 0)
1162
+ return 2;
1163
+ const failed = results.filter((r) => r.status === "failed").length;
1164
+ if (failed === 0)
1165
+ return 0;
1166
+ if (failed === results.length)
1167
+ return 2;
1168
+ return 1;
1169
+ }