@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.
- package/LICENSE +21 -0
- package/dist/agents/arbiter-agent.d.ts +72 -0
- package/dist/agents/arbiter-agent.js +149 -0
- package/dist/agents/architecture-agent.d.ts +148 -0
- package/dist/agents/architecture-agent.js +459 -0
- package/dist/agents/base-agent.d.ts +44 -0
- package/dist/agents/base-agent.js +57 -0
- package/dist/agents/crossref-agent.d.ts +140 -0
- package/dist/agents/crossref-agent.js +560 -0
- package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
- package/dist/agents/crossref-arbiter-agent.js +147 -0
- package/dist/agents/documentation-agent.d.ts +55 -0
- package/dist/agents/documentation-agent.js +159 -0
- package/dist/agents/exploration-agent.d.ts +58 -0
- package/dist/agents/exploration-agent.js +102 -0
- package/dist/agents/grouping-agent.d.ts +167 -0
- package/dist/agents/grouping-agent.js +557 -0
- package/dist/agents/index-agent.d.ts +86 -0
- package/dist/agents/index-agent.js +360 -0
- package/dist/agents/organization-agent.d.ts +144 -0
- package/dist/agents/organization-agent.js +607 -0
- package/dist/auth.d.ts +372 -0
- package/dist/auth.js +1072 -0
- package/dist/broadcast-mcp.d.ts +21 -0
- package/dist/broadcast-mcp.js +59 -0
- package/dist/changelog.d.ts +85 -0
- package/dist/changelog.js +223 -0
- package/dist/decision-queue.d.ts +173 -0
- package/dist/decision-queue.js +265 -0
- package/dist/diff-scope.d.ts +24 -0
- package/dist/diff-scope.js +28 -0
- package/dist/discovery.d.ts +54 -0
- package/dist/discovery.js +405 -0
- package/dist/grouping.d.ts +37 -0
- package/dist/grouping.js +343 -0
- package/dist/helpers/format.d.ts +5 -0
- package/dist/helpers/format.js +13 -0
- package/dist/helpers/index.d.ts +11 -0
- package/dist/helpers/index.js +11 -0
- package/dist/helpers/parsing.d.ts +52 -0
- package/dist/helpers/parsing.js +128 -0
- package/dist/helpers/paths.d.ts +41 -0
- package/dist/helpers/paths.js +67 -0
- package/dist/helpers/strings.d.ts +45 -0
- package/dist/helpers/strings.js +97 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +1087 -0
- package/dist/merge-utils.d.ts +22 -0
- package/dist/merge-utils.js +34 -0
- package/dist/orchestrator.d.ts +194 -0
- package/dist/orchestrator.js +1169 -0
- package/dist/output.d.ts +106 -0
- package/dist/output.js +243 -0
- package/dist/progress.d.ts +228 -0
- package/dist/progress.js +644 -0
- package/dist/providers/copilot.d.ts +247 -0
- package/dist/providers/copilot.js +598 -0
- package/dist/providers/index.d.ts +15 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/opencode.d.ts +156 -0
- package/dist/providers/opencode.js +416 -0
- package/dist/providers/types.d.ts +156 -0
- package/dist/providers/types.js +16 -0
- package/dist/resources.d.ts +76 -0
- package/dist/resources.js +151 -0
- package/dist/search-index.d.ts +71 -0
- package/dist/search-index.js +187 -0
- package/dist/search-mcp.d.ts +25 -0
- package/dist/search-mcp.js +100 -0
- package/dist/server-utils.d.ts +56 -0
- package/dist/server-utils.js +135 -0
- package/dist/session.d.ts +227 -0
- package/dist/session.js +370 -0
- package/dist/types.d.ts +272 -0
- package/dist/types.js +5 -0
- package/dist/worktree.d.ts +82 -0
- package/dist/worktree.js +187 -0
- 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
|
+
}
|