@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for Hem.
|
|
4
|
+
*
|
|
5
|
+
* Configures Commander.js with all options, the `auth` subcommand, and
|
|
6
|
+
* dispatches to either auth handling or the main generation pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Pipeline (v2):
|
|
9
|
+
* 1. Parse CLI args
|
|
10
|
+
* 2. If auth subcommand -> dispatch and exit
|
|
11
|
+
* 3. If --api-key without --model -> exit with error
|
|
12
|
+
* 4. Load preferences
|
|
13
|
+
* 5. If --model flag -> override preferences
|
|
14
|
+
* 6. Start OpenCode server via createOpencode()
|
|
15
|
+
* 7. If --api-key -> inject via injectApiKey()
|
|
16
|
+
* 8. detectAuthState()
|
|
17
|
+
* 9. If no credentials and no model override -> handleFirstRun()
|
|
18
|
+
* 10. Pipeline: discovery -> grouping -> exploration -> generation
|
|
19
|
+
* -> organization -> crossref -> architecture -> TOC -> complete
|
|
20
|
+
*
|
|
21
|
+
* Reference: FR-001, FR-022a.
|
|
22
|
+
*/
|
|
23
|
+
import { Command } from "commander";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { resolve, join } from "node:path";
|
|
26
|
+
import { realpathSync } from "node:fs";
|
|
27
|
+
import { access, readFile } from "node:fs/promises";
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import fg from "fast-glob";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { loadPreferences, loadProjectConfig, saveProjectConfig, detectAuthState, injectApiKey, handleFirstRun, renderAndWait, handleAuthLogin, handleAuthList, handleAuthLogout, validateStoredModel, resolveActualModel, listProviderModels, INVALID_MODEL_WARNING, AuthExpiredError, INVALID_API_KEY_ERROR, ZenCatalogError, PROJECT_CONFIG_DIR, } from "./auth.js";
|
|
32
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
33
|
+
import { findFreePort, trackServer, untrackServer, startWithRetry } from "./server-utils.js";
|
|
34
|
+
import { discoverFiles, detectProjectName } from "./discovery.js";
|
|
35
|
+
import { groupFiles } from "./grouping.js";
|
|
36
|
+
import { GroupingAgent } from "./agents/grouping-agent.js";
|
|
37
|
+
import { DocumentationAgent } from "./agents/documentation-agent.js";
|
|
38
|
+
import { ArchitectureAgent } from "./agents/architecture-agent.js";
|
|
39
|
+
import { IndexAgent } from "./agents/index-agent.js";
|
|
40
|
+
import { OrganizationAgent } from "./agents/organization-agent.js";
|
|
41
|
+
import { CrossRefAgent } from "./agents/crossref-agent.js";
|
|
42
|
+
import { ExplorationAgent } from "./agents/exploration-agent.js";
|
|
43
|
+
import { OpenCodeProvider } from "./providers/opencode.js";
|
|
44
|
+
import { CopilotProvider } from "./providers/copilot.js";
|
|
45
|
+
import { generateDocumentation, getExitCode } from "./orchestrator.js";
|
|
46
|
+
import { SearchIndex } from "./search-index.js";
|
|
47
|
+
import { computeMaxConcurrency, describeResourceLimits, LARGE_PROJECT_THRESHOLD, computeAgentsPerGroup } from "./resources.js";
|
|
48
|
+
// Re-export getExitCode so existing imports from index.ts continue to work.
|
|
49
|
+
export { getExitCode };
|
|
50
|
+
import { renderDashboard, ConfigPrompt } from "./progress.js";
|
|
51
|
+
import { generateTableOfContents, generateTocLinkList, replaceTocPlaceholder, writeTableOfContents, writeArchitectureOverview, scanDocFiles, ensureDestinationDir, removeEmptyDirs, } from "./output.js";
|
|
52
|
+
import { readLastSHA, computeChangedFiles, getCurrentSHA, detectChangedDocs, writeChangelogEntry, } from "./changelog.js";
|
|
53
|
+
import { scopeToChangedFiles } from "./diff-scope.js";
|
|
54
|
+
import { getRepoRoot, getCurrentBranch, generateWorktreeBranch, ensureGitignored, setupWorktree, commitWorktree, pushWorktree, cleanupWorktree, } from "./worktree.js";
|
|
55
|
+
import React from "react";
|
|
56
|
+
// ── Version ────────────────────────────────────────────────────────────
|
|
57
|
+
const require = createRequire(import.meta.url);
|
|
58
|
+
const { version } = require("../package.json");
|
|
59
|
+
/** Default (production) dependency bag. */
|
|
60
|
+
export const defaultDeps = {
|
|
61
|
+
createOpencode,
|
|
62
|
+
findFreePort,
|
|
63
|
+
startWithRetry,
|
|
64
|
+
loadPreferences,
|
|
65
|
+
detectAuthState,
|
|
66
|
+
injectApiKey,
|
|
67
|
+
handleFirstRun,
|
|
68
|
+
handleAuthLogin,
|
|
69
|
+
handleAuthList,
|
|
70
|
+
handleAuthLogout,
|
|
71
|
+
loadProjectConfig,
|
|
72
|
+
saveProjectConfig,
|
|
73
|
+
renderAndWait,
|
|
74
|
+
validateStoredModel,
|
|
75
|
+
resolveActualModel,
|
|
76
|
+
listProviderModels,
|
|
77
|
+
checkSourceExists: (path) => access(path),
|
|
78
|
+
discoverFiles,
|
|
79
|
+
detectProjectName,
|
|
80
|
+
groupFiles,
|
|
81
|
+
createProvider: async (config) => {
|
|
82
|
+
const pid = config.model.providerID;
|
|
83
|
+
if (pid === "github-copilot" || pid === "copilot") {
|
|
84
|
+
return CopilotProvider.create(config);
|
|
85
|
+
}
|
|
86
|
+
return OpenCodeProvider.create(config);
|
|
87
|
+
},
|
|
88
|
+
generateDocumentation,
|
|
89
|
+
renderDashboard,
|
|
90
|
+
generateTableOfContents,
|
|
91
|
+
writeTableOfContents,
|
|
92
|
+
writeArchitectureOverview,
|
|
93
|
+
scanDocFiles,
|
|
94
|
+
ensureDestinationDir,
|
|
95
|
+
removeEmptyDirs,
|
|
96
|
+
readLastSHA,
|
|
97
|
+
computeChangedFiles,
|
|
98
|
+
getCurrentSHA,
|
|
99
|
+
detectChangedDocs,
|
|
100
|
+
writeChangelogEntry,
|
|
101
|
+
scopeToChangedFiles,
|
|
102
|
+
};
|
|
103
|
+
// ── Search index helpers ────────────────────────────────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* Build/update the FTS search index from `.md` files in `destinationPath`.
|
|
106
|
+
*
|
|
107
|
+
* Uses a two-pass strategy:
|
|
108
|
+
* 1. **Git-diff fast path**: if the destination is tracked by git, only
|
|
109
|
+
* re-index files that changed since HEAD (falls back to hash scan on error).
|
|
110
|
+
* 2. **Hash scan**: glob all `*.md` files, call `SearchIndex.upsertDoc` for
|
|
111
|
+
* each — the method is a no-op when the SHA-256 hash is unchanged.
|
|
112
|
+
* 3. **Deletion sweep**: remove index entries for paths no longer on disk.
|
|
113
|
+
*/
|
|
114
|
+
async function updateSearchIndex(index, destinationPath, verbose) {
|
|
115
|
+
let mdFiles;
|
|
116
|
+
try {
|
|
117
|
+
mdFiles = await fg("**/*.md", {
|
|
118
|
+
cwd: destinationPath,
|
|
119
|
+
absolute: false,
|
|
120
|
+
onlyFiles: true,
|
|
121
|
+
dot: false,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return; // destination doesn't exist yet
|
|
126
|
+
}
|
|
127
|
+
if (mdFiles.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
// Try git-diff fast path: only re-index files that changed since HEAD
|
|
130
|
+
let changedPaths = null;
|
|
131
|
+
try {
|
|
132
|
+
const output = execFileSync("git", ["-C", destinationPath, "diff", "--name-only", "HEAD", "--", "*.md"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
133
|
+
const changed = output.trim().split("\n").filter(Boolean);
|
|
134
|
+
// Only use git fast path when it returns a strict subset (otherwise full scan is equally cheap)
|
|
135
|
+
if (changed.length < mdFiles.length) {
|
|
136
|
+
changedPaths = new Set(changed);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// git unavailable or no HEAD — fall through to full hash scan
|
|
141
|
+
}
|
|
142
|
+
const toIndex = changedPaths ? mdFiles.filter((p) => changedPaths.has(p)) : mdFiles;
|
|
143
|
+
let indexed = 0;
|
|
144
|
+
let skipped = 0;
|
|
145
|
+
for (const relPath of toIndex) {
|
|
146
|
+
try {
|
|
147
|
+
const content = await readFile(join(destinationPath, relPath), "utf-8");
|
|
148
|
+
const wasUpdated = index.upsertDoc(relPath, content);
|
|
149
|
+
if (wasUpdated)
|
|
150
|
+
indexed++;
|
|
151
|
+
else
|
|
152
|
+
skipped++;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// file unreadable — skip
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Deletion sweep: remove index entries for docs no longer on disk
|
|
159
|
+
const currentPaths = new Set(mdFiles);
|
|
160
|
+
let removed = 0;
|
|
161
|
+
for (const [path] of index.getAllHashes()) {
|
|
162
|
+
if (!currentPaths.has(path)) {
|
|
163
|
+
index.removeDoc(path);
|
|
164
|
+
removed++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (verbose) {
|
|
168
|
+
verbose(`[search-index] Updated: ${indexed} indexed, ${skipped} unchanged, ${removed} removed` +
|
|
169
|
+
(changedPaths ? " (git-diff fast path)" : " (full hash scan)"));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Records source-file → doc-file mappings in the search index.
|
|
174
|
+
*
|
|
175
|
+
* For each group, finds doc files whose path shares a segment with the
|
|
176
|
+
* group's directory or file paths, then maps the group's source files
|
|
177
|
+
* to those docs.
|
|
178
|
+
*
|
|
179
|
+
* This is a best-effort heuristic; paths without clear overlap are skipped.
|
|
180
|
+
*/
|
|
181
|
+
function buildSourceDocMappings(index, docFiles, groups, _destinationPath) {
|
|
182
|
+
for (const group of groups) {
|
|
183
|
+
const groupSegments = new Set([
|
|
184
|
+
group.id,
|
|
185
|
+
...group.id.split(/[-_/]+/).filter((s) => s.length > 2),
|
|
186
|
+
...group.files.flatMap((f) => f.path.split(/[/\\]+/)).filter((s) => s.length > 2),
|
|
187
|
+
]);
|
|
188
|
+
const matchingDocs = docFiles.filter((docPath) => {
|
|
189
|
+
const docSegments = docPath.replace(/\.md$/, "").split(/[/\\]+/);
|
|
190
|
+
return docSegments.some((seg) => groupSegments.has(seg));
|
|
191
|
+
});
|
|
192
|
+
const sourcePaths = group.files.map((f) => f.path);
|
|
193
|
+
for (const docPath of matchingDocs) {
|
|
194
|
+
index.setSourceMappings(docPath, sourcePaths);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── Handlers ───────────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Handle the default generate command.
|
|
201
|
+
*
|
|
202
|
+
* Implements the full startup sequence and v2 pipeline:
|
|
203
|
+
* 1. Parse CLI flags, validate constraints.
|
|
204
|
+
* 2. Load preferences.
|
|
205
|
+
* 3. Resolve model.
|
|
206
|
+
* 4. Start OpenCode server for auth.
|
|
207
|
+
* 5. Inject API key if provided.
|
|
208
|
+
* 6. Detect auth state.
|
|
209
|
+
* 7. First-run flow if needed.
|
|
210
|
+
* 8. Resolve absolute paths.
|
|
211
|
+
* 9. Start Ink dashboard.
|
|
212
|
+
* 10. Pipeline:
|
|
213
|
+
* a. Discovery
|
|
214
|
+
* b. Start orchestrator
|
|
215
|
+
* c. Grouping (LLM + heuristic fallback)
|
|
216
|
+
* d. Exploration (parallel)
|
|
217
|
+
* e. Documentation (parallel — agents write files directly)
|
|
218
|
+
* f. Organization (post-processing)
|
|
219
|
+
* g. Cross-references (post-processing)
|
|
220
|
+
* h. Architecture overview (post-processing)
|
|
221
|
+
* i. TOC generation (programmatic, scans disk)
|
|
222
|
+
* 11. Exit with appropriate exit code.
|
|
223
|
+
*
|
|
224
|
+
* @param opts - Parsed CLI flags from Commander.
|
|
225
|
+
* @param deps - Injectable dependencies.
|
|
226
|
+
* @returns The resolved `CLIOptions`, or `null` if the user exited early.
|
|
227
|
+
*/
|
|
228
|
+
export async function handleGenerate(opts, deps = defaultDeps) {
|
|
229
|
+
// ── Step 1: Validate --api-key requires --model ────────────────────
|
|
230
|
+
if (opts.apiKey && !opts.model) {
|
|
231
|
+
process.stderr.write("--api-key requires --model to specify which provider to use.\n" +
|
|
232
|
+
" Example: npx @pruddiman/hem --model anthropic/claude-sonnet-4 --api-key sk-ant-xxx\n");
|
|
233
|
+
process.exitCode = 2;
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const cliOptions = {
|
|
237
|
+
source: opts.source,
|
|
238
|
+
destination: opts.destination,
|
|
239
|
+
files: opts.files,
|
|
240
|
+
concurrency: opts.concurrency,
|
|
241
|
+
model: opts.model,
|
|
242
|
+
apiKey: opts.apiKey,
|
|
243
|
+
name: opts.name,
|
|
244
|
+
verbose: opts.verbose ?? false,
|
|
245
|
+
full: opts.full ?? false,
|
|
246
|
+
worktree: opts.worktree ?? false,
|
|
247
|
+
command: "generate",
|
|
248
|
+
authAction: undefined,
|
|
249
|
+
authTarget: undefined,
|
|
250
|
+
};
|
|
251
|
+
// ── Step 1b: Exit if project not configured ────────────────────────
|
|
252
|
+
const projectConfig = await deps.loadProjectConfig();
|
|
253
|
+
if (!projectConfig && !cliOptions.model && !cliOptions.apiKey) {
|
|
254
|
+
process.stderr.write("This project has not been configured for Hem.\n" +
|
|
255
|
+
"Run `hem config` to select a provider and model for this project.\n");
|
|
256
|
+
process.exitCode = 2;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
// ── Step 2: Load preferences ───────────────────────────────────────
|
|
260
|
+
const preferences = await deps.loadPreferences();
|
|
261
|
+
// ── Step 3: Resolve model (--model flag > project config > preferences) ─
|
|
262
|
+
let resolvedModel;
|
|
263
|
+
let modelFromPreferences = false;
|
|
264
|
+
if (cliOptions.model) {
|
|
265
|
+
const slashIndex = cliOptions.model.indexOf("/");
|
|
266
|
+
if (slashIndex > 0) {
|
|
267
|
+
resolvedModel = {
|
|
268
|
+
providerID: cliOptions.model.slice(0, slashIndex),
|
|
269
|
+
modelID: cliOptions.model.slice(slashIndex + 1),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
resolvedModel = { providerID: cliOptions.model, modelID: "default" };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else if (projectConfig?.model) {
|
|
277
|
+
resolvedModel = projectConfig.model;
|
|
278
|
+
}
|
|
279
|
+
else if (preferences?.model) {
|
|
280
|
+
resolvedModel = preferences.model;
|
|
281
|
+
modelFromPreferences = true;
|
|
282
|
+
}
|
|
283
|
+
// ── Step 4: Start OpenCode server ──────────────────────────────────
|
|
284
|
+
const { client, server } = await deps.startWithRetry(async () => {
|
|
285
|
+
const port = await deps.findFreePort();
|
|
286
|
+
return deps.createOpencode({ port });
|
|
287
|
+
});
|
|
288
|
+
trackServer(() => server.close());
|
|
289
|
+
let authState;
|
|
290
|
+
let modelLabel = "default";
|
|
291
|
+
let providerLabel = "Default";
|
|
292
|
+
try {
|
|
293
|
+
// ── Step 5: Inject API key if provided ─────────────────────────────
|
|
294
|
+
if (cliOptions.apiKey && cliOptions.model) {
|
|
295
|
+
await deps.injectApiKey(client, cliOptions.model, cliOptions.apiKey);
|
|
296
|
+
}
|
|
297
|
+
// ── Step 6: Detect auth state ──────────────────────────────────────
|
|
298
|
+
authState = await deps.detectAuthState(client);
|
|
299
|
+
if (cliOptions.verbose) {
|
|
300
|
+
const providers = authState.connectedProviders.length > 0
|
|
301
|
+
? authState.connectedProviders.map((p) => `${p.id} (${p.authMethod})`).join(", ")
|
|
302
|
+
: "none";
|
|
303
|
+
process.stderr.write(`[auth] Detected providers: ${providers}\n`);
|
|
304
|
+
process.stderr.write(`[auth] Has credentials: ${authState.hasCredentials}\n`);
|
|
305
|
+
}
|
|
306
|
+
// ── T045: Edge case — stored model no longer available ─────────────
|
|
307
|
+
if (modelFromPreferences && resolvedModel && preferences) {
|
|
308
|
+
const isValid = await deps.validateStoredModel(client, preferences);
|
|
309
|
+
if (!isValid) {
|
|
310
|
+
const warning = INVALID_MODEL_WARNING
|
|
311
|
+
.replace("{providerID}", resolvedModel.providerID)
|
|
312
|
+
.replace("{modelID}", resolvedModel.modelID);
|
|
313
|
+
process.stderr.write(warning);
|
|
314
|
+
resolvedModel = undefined;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// ── Step 7: First-run flow if needed ───────────────────────────────
|
|
318
|
+
if ((!authState.hasCredentials && !resolvedModel) ||
|
|
319
|
+
(modelFromPreferences && !resolvedModel)) {
|
|
320
|
+
let firstRunResult;
|
|
321
|
+
try {
|
|
322
|
+
firstRunResult = await deps.handleFirstRun(client);
|
|
323
|
+
}
|
|
324
|
+
catch (firstRunError) {
|
|
325
|
+
if (firstRunError instanceof ZenCatalogError) {
|
|
326
|
+
process.stderr.write(firstRunError.message);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
const errorMsg = firstRunError instanceof Error
|
|
330
|
+
? firstRunError.message
|
|
331
|
+
: String(firstRunError);
|
|
332
|
+
process.stderr.write(errorMsg + "\n");
|
|
333
|
+
}
|
|
334
|
+
process.exitCode = 2;
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
if (firstRunResult === null) {
|
|
338
|
+
process.exitCode = 0;
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
resolvedModel = firstRunResult;
|
|
342
|
+
}
|
|
343
|
+
// ── Fallback model assignment ──────────────────────────────────────
|
|
344
|
+
if (!resolvedModel) {
|
|
345
|
+
resolvedModel = { providerID: "opencode", modelID: "default" };
|
|
346
|
+
}
|
|
347
|
+
// ── Resolve actual model/provider labels ────────────────────────────
|
|
348
|
+
const labels = await deps.resolveActualModel(client, resolvedModel);
|
|
349
|
+
modelLabel = labels.modelLabel;
|
|
350
|
+
providerLabel = labels.providerLabel;
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
server.close();
|
|
354
|
+
untrackServer();
|
|
355
|
+
}
|
|
356
|
+
if (cliOptions.verbose) {
|
|
357
|
+
const modelDesc = resolvedModel.modelID === "default"
|
|
358
|
+
? `${resolvedModel.providerID} (server default)`
|
|
359
|
+
: `${resolvedModel.providerID}/${resolvedModel.modelID}`;
|
|
360
|
+
process.stderr.write(`[auth] Provider: ${resolvedModel.providerID}, Model: ${modelDesc}\n`);
|
|
361
|
+
}
|
|
362
|
+
// ── Worktree setup (--worktree flag) ────────────────────────────────
|
|
363
|
+
let worktreeState;
|
|
364
|
+
if (cliOptions.worktree) {
|
|
365
|
+
let repoRoot;
|
|
366
|
+
try {
|
|
367
|
+
repoRoot = getRepoRoot(process.cwd());
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
process.stderr.write("Error: --worktree requires a git repository. " +
|
|
371
|
+
"Run hem from inside a git repo.\n");
|
|
372
|
+
process.exitCode = 2;
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const currentBranch = getCurrentBranch(repoRoot);
|
|
376
|
+
const branch = generateWorktreeBranch(currentBranch);
|
|
377
|
+
await ensureGitignored(repoRoot, ".hem/worktree/");
|
|
378
|
+
const wtPath = setupWorktree(repoRoot, branch);
|
|
379
|
+
worktreeState = { repoRoot, path: wtPath, branch };
|
|
380
|
+
if (cliOptions.verbose) {
|
|
381
|
+
process.stderr.write(`[worktree] Created branch ${branch} at ${wtPath}\n`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// ── Step 8: Resolve absolute paths ─────────────────────────────────
|
|
385
|
+
const base = worktreeState?.path ?? process.cwd();
|
|
386
|
+
const absoluteSource = resolve(base, cliOptions.source);
|
|
387
|
+
const absoluteDestination = resolve(base, cliOptions.destination);
|
|
388
|
+
const projectName = cliOptions.name ?? await deps.detectProjectName(absoluteSource);
|
|
389
|
+
// Propagate resolved name so downstream code (orchestrator) never re-derives it.
|
|
390
|
+
cliOptions.name = projectName;
|
|
391
|
+
// ── T043: Edge case — source directory doesn't exist ─────────────
|
|
392
|
+
try {
|
|
393
|
+
await deps.checkSourceExists(absoluteSource);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
process.stderr.write(`Source directory ${absoluteSource} does not exist.\n`);
|
|
397
|
+
process.exitCode = 2;
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
// ── Step 9: Start Ink dashboard ────────────────────────────────────
|
|
401
|
+
const startedAt = Date.now();
|
|
402
|
+
const initialState = {
|
|
403
|
+
phase: "discovery",
|
|
404
|
+
modelLabel,
|
|
405
|
+
providerLabel,
|
|
406
|
+
totalFiles: 0,
|
|
407
|
+
binaryFilesSkipped: 0,
|
|
408
|
+
totalGroups: 0,
|
|
409
|
+
featureGroups: 0,
|
|
410
|
+
layerGroups: 0,
|
|
411
|
+
totalPages: 0,
|
|
412
|
+
groupStatuses: [],
|
|
413
|
+
explorationStatuses: [],
|
|
414
|
+
explorationComplete: false,
|
|
415
|
+
indexFiles: [],
|
|
416
|
+
completedSessions: 0,
|
|
417
|
+
failedSessions: 0,
|
|
418
|
+
startedAt,
|
|
419
|
+
completedAt: undefined,
|
|
420
|
+
warnings: [],
|
|
421
|
+
};
|
|
422
|
+
const { updateState, waitUntilExit } = deps.renderDashboard(initialState, cliOptions.verbose);
|
|
423
|
+
try {
|
|
424
|
+
// ── Step 10a: Discovery phase ──────────────────────────────────────
|
|
425
|
+
const allFiles = await deps.discoverFiles(absoluteSource, cliOptions.files, absoluteDestination);
|
|
426
|
+
let textFiles = allFiles.filter((f) => !f.isBinary);
|
|
427
|
+
const binaryCount = allFiles.length - textFiles.length;
|
|
428
|
+
if (textFiles.length === 0) {
|
|
429
|
+
process.stderr.write(`No files found matching pattern "${cliOptions.files}" in ${absoluteSource}\n` +
|
|
430
|
+
" Check your --source and --files options.\n");
|
|
431
|
+
process.exitCode = 2;
|
|
432
|
+
await waitUntilExit();
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
if (cliOptions.verbose) {
|
|
436
|
+
process.stderr.write(`[discovery] Found ${allFiles.length} files (${binaryCount} binary skipped)\n`);
|
|
437
|
+
}
|
|
438
|
+
// ── Step 10a′: Diff-scoped incremental generation ──────────────────
|
|
439
|
+
// When a previous run's SHA exists in changelog.md (and --full is not
|
|
440
|
+
// set), narrow textFiles to only those that changed since the last run.
|
|
441
|
+
let prevSHA = null;
|
|
442
|
+
try {
|
|
443
|
+
prevSHA = await deps.readLastSHA(absoluteDestination);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// changelog.md unreadable — treat as first run
|
|
447
|
+
}
|
|
448
|
+
if (cliOptions.full) {
|
|
449
|
+
// Check if docs already exist to warn about unpredictable behavior
|
|
450
|
+
const existingDocs = await deps.scanDocFiles(absoluteDestination);
|
|
451
|
+
if (existingDocs.length > 0) {
|
|
452
|
+
process.stderr.write("Warning: --full forces complete re-generation. " +
|
|
453
|
+
"Behavior may be unpredictable when docs already exist.\n");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else if (prevSHA) {
|
|
457
|
+
try {
|
|
458
|
+
const changedPaths = deps.computeChangedFiles(absoluteSource, prevSHA);
|
|
459
|
+
if (changedPaths.length === 0) {
|
|
460
|
+
process.stderr.write("No source changes since last run — skipping generation.\n");
|
|
461
|
+
process.exitCode = 0;
|
|
462
|
+
await waitUntilExit();
|
|
463
|
+
return cliOptions;
|
|
464
|
+
}
|
|
465
|
+
textFiles = deps.scopeToChangedFiles(textFiles, changedPaths);
|
|
466
|
+
if (cliOptions.verbose) {
|
|
467
|
+
process.stderr.write(`[diff-scope] ${changedPaths.length} source files changed since ${prevSHA.slice(0, 8)}; ` +
|
|
468
|
+
`${textFiles.length} match discovery filter\n`);
|
|
469
|
+
}
|
|
470
|
+
// If all changed files were binary or outside the glob, skip
|
|
471
|
+
if (textFiles.length === 0) {
|
|
472
|
+
process.stderr.write("Changed files do not match the discovery filter — skipping generation.\n");
|
|
473
|
+
process.exitCode = 0;
|
|
474
|
+
await waitUntilExit();
|
|
475
|
+
return cliOptions;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (diffError) {
|
|
479
|
+
// Git failure (e.g., SHA no longer exists after rebase) — fall back to full scan
|
|
480
|
+
const msg = diffError instanceof Error ? diffError.message : String(diffError);
|
|
481
|
+
process.stderr.write(`Warning: Could not compute diff (${msg}). Falling back to full generation.\n`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
updateState({
|
|
485
|
+
phase: "grouping",
|
|
486
|
+
totalFiles: allFiles.length,
|
|
487
|
+
binaryFilesSkipped: binaryCount,
|
|
488
|
+
});
|
|
489
|
+
const verboseLog = cliOptions.verbose
|
|
490
|
+
? (msg) => {
|
|
491
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
492
|
+
process.stderr.write(`[${ts}] ${msg}\n`);
|
|
493
|
+
}
|
|
494
|
+
: undefined;
|
|
495
|
+
// ── Step 10b: Start provider ─────────────────────────────────────
|
|
496
|
+
const hemDir = PROJECT_CONFIG_DIR;
|
|
497
|
+
const searchDbPath = join(hemDir, "search-index.db");
|
|
498
|
+
let provider;
|
|
499
|
+
try {
|
|
500
|
+
provider = await deps.createProvider({
|
|
501
|
+
model: resolvedModel,
|
|
502
|
+
destinationPath: absoluteDestination,
|
|
503
|
+
verbose: verboseLog,
|
|
504
|
+
searchDbPath,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
catch (orchError) {
|
|
508
|
+
const errorMessage = orchError instanceof Error ? orchError.message : String(orchError);
|
|
509
|
+
process.stderr.write(errorMessage + "\n");
|
|
510
|
+
process.exitCode = 2;
|
|
511
|
+
await waitUntilExit();
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
// ── Step 10c: Grouping phase (LLM with heuristic fallback) ─────────
|
|
515
|
+
const groupingAgent = new GroupingAgent(provider, projectName);
|
|
516
|
+
let groups = await groupingAgent.run(textFiles, verboseLog, PROJECT_CONFIG_DIR);
|
|
517
|
+
if (!groups) {
|
|
518
|
+
if (cliOptions.verbose) {
|
|
519
|
+
verboseLog(`[grouping] LLM grouping unavailable, using heuristic fallback`);
|
|
520
|
+
}
|
|
521
|
+
groups = deps.groupFiles(textFiles);
|
|
522
|
+
}
|
|
523
|
+
const featureGroups = groups.filter((g) => g.type === "vertical").length;
|
|
524
|
+
const layerGroups = groups.filter((g) => g.type === "horizontal").length;
|
|
525
|
+
if (cliOptions.verbose) {
|
|
526
|
+
process.stderr.write(`[grouping] ${groups.length} groups (${featureGroups} feature, ${layerGroups} layer)\n`);
|
|
527
|
+
}
|
|
528
|
+
// Ensure destination directory exists before agents write to it
|
|
529
|
+
await deps.ensureDestinationDir(absoluteDestination);
|
|
530
|
+
// ── Open search index + update from existing docs ────────────────
|
|
531
|
+
// Build/refresh the FTS index over any existing .md files in the
|
|
532
|
+
// destination directory. This runs quickly on second+ runs because
|
|
533
|
+
// unchanged files are skipped via SHA-256 hash comparison.
|
|
534
|
+
const searchIndex = SearchIndex.open(searchDbPath);
|
|
535
|
+
await updateSearchIndex(searchIndex, absoluteDestination, verboseLog);
|
|
536
|
+
updateState({
|
|
537
|
+
phase: "generation",
|
|
538
|
+
totalGroups: groups.length,
|
|
539
|
+
featureGroups,
|
|
540
|
+
layerGroups,
|
|
541
|
+
totalPages: groups.length,
|
|
542
|
+
groupStatuses: groups.map((group) => ({
|
|
543
|
+
groupId: group.id,
|
|
544
|
+
label: group.label,
|
|
545
|
+
status: "queued",
|
|
546
|
+
})),
|
|
547
|
+
});
|
|
548
|
+
// ── Scaling detection ──────────────────────────────────────────────
|
|
549
|
+
const totalFileCount = textFiles.length;
|
|
550
|
+
const isLargeProject = totalFileCount > LARGE_PROJECT_THRESHOLD;
|
|
551
|
+
if (cliOptions.verbose) {
|
|
552
|
+
const agentsPerGroup = isLargeProject
|
|
553
|
+
? computeAgentsPerGroup(totalFileCount)
|
|
554
|
+
: 1;
|
|
555
|
+
const maxConcurrency = computeMaxConcurrency(cliOptions.concurrency);
|
|
556
|
+
process.stderr.write(`[scaling] ${totalFileCount} source files, threshold=${LARGE_PROJECT_THRESHOLD}` +
|
|
557
|
+
` → ${isLargeProject ? "multi-agent" : "single-agent"} mode\n`);
|
|
558
|
+
if (isLargeProject) {
|
|
559
|
+
process.stderr.write(`[scaling] Exploration & documentation: ${agentsPerGroup} agents per group, ` +
|
|
560
|
+
`${groups.length} groups, concurrency=${maxConcurrency}\n`);
|
|
561
|
+
process.stderr.write(`[scaling] Post-processing: organization (dynamic workers), ` +
|
|
562
|
+
`cross-references (parallel if >8 docs), ` +
|
|
563
|
+
`architecture & index (chunked if needed)\n`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// ── Step 10d+e: Exploration + Documentation phases ──────────────────
|
|
567
|
+
let results;
|
|
568
|
+
let explorationFindings = [];
|
|
569
|
+
try {
|
|
570
|
+
const docAgent = new DocumentationAgent(provider);
|
|
571
|
+
const exploreAgent = new ExplorationAgent(provider);
|
|
572
|
+
const genResult = await deps.generateDocumentation(docAgent, groups, cliOptions, (partial) => updateState(partial), exploreAgent, searchIndex);
|
|
573
|
+
results = genResult.results;
|
|
574
|
+
explorationFindings = genResult.explorationFindings;
|
|
575
|
+
// Collect all doc files from disk (not from agent results, which may
|
|
576
|
+
// under-report when sessions time out but still write files).
|
|
577
|
+
let allDocFiles = await deps.scanDocFiles(absoluteDestination);
|
|
578
|
+
allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
|
|
579
|
+
// ── Update index with newly written docs + source-doc mappings ───
|
|
580
|
+
// Re-index any docs written during this run (hash check skips unchanged).
|
|
581
|
+
// Then record source-file → doc-file mappings for each group.
|
|
582
|
+
await updateSearchIndex(searchIndex, absoluteDestination, verboseLog);
|
|
583
|
+
buildSourceDocMappings(searchIndex, allDocFiles, groups, absoluteDestination);
|
|
584
|
+
// ── Step 10f: Organization phase ──────────────────────────────────
|
|
585
|
+
try {
|
|
586
|
+
updateState({ phase: "organization" });
|
|
587
|
+
const orgAgent = new OrganizationAgent(provider);
|
|
588
|
+
await orgAgent.run({
|
|
589
|
+
projectName,
|
|
590
|
+
destinationPath: absoluteDestination,
|
|
591
|
+
allDocFiles,
|
|
592
|
+
}, verboseLog);
|
|
593
|
+
// Re-scan disk to discover what files exist after organization
|
|
594
|
+
allDocFiles = await deps.scanDocFiles(absoluteDestination);
|
|
595
|
+
allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
|
|
596
|
+
}
|
|
597
|
+
catch (orgError) {
|
|
598
|
+
if (orgError instanceof AuthExpiredError) {
|
|
599
|
+
throw orgError;
|
|
600
|
+
}
|
|
601
|
+
const orgMessage = orgError instanceof Error ? orgError.message : String(orgError);
|
|
602
|
+
updateState({
|
|
603
|
+
warnings: [`Organization phase failed: ${orgMessage}`],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
// ── Step 10g: Cross-references phase ──────────────────────────────
|
|
607
|
+
try {
|
|
608
|
+
updateState({ phase: "crossref" });
|
|
609
|
+
const xrefAgent = new CrossRefAgent(provider);
|
|
610
|
+
await xrefAgent.run({
|
|
611
|
+
projectName,
|
|
612
|
+
destinationPath: absoluteDestination,
|
|
613
|
+
allDocFiles,
|
|
614
|
+
}, verboseLog);
|
|
615
|
+
// Re-scan disk to discover what files exist after cross-referencing
|
|
616
|
+
allDocFiles = await deps.scanDocFiles(absoluteDestination);
|
|
617
|
+
allDocFiles = allDocFiles.filter(f => f !== "index.md" && f !== "architecture.md" && f !== "changelog.md");
|
|
618
|
+
}
|
|
619
|
+
catch (xrefError) {
|
|
620
|
+
if (xrefError instanceof AuthExpiredError) {
|
|
621
|
+
throw xrefError;
|
|
622
|
+
}
|
|
623
|
+
const xrefMessage = xrefError instanceof Error ? xrefError.message : String(xrefError);
|
|
624
|
+
updateState({
|
|
625
|
+
warnings: [`Cross-reference phase failed: ${xrefMessage}`],
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
// ── Steps 10h+10i: Architecture overview + Index page (parallel) ───
|
|
629
|
+
// Both agents write independent files (architecture.md and index.md)
|
|
630
|
+
// and neither reads the other's output, so they can run concurrently.
|
|
631
|
+
updateState({ phase: "architecture" });
|
|
632
|
+
const archAgent = new ArchitectureAgent(provider);
|
|
633
|
+
const indexAgent = new IndexAgent(provider);
|
|
634
|
+
// The index agent's context mirrors what it would see in the sequential
|
|
635
|
+
// pipeline (where arch ran first): include architecture.md in the list
|
|
636
|
+
// even though it may not be written yet when index starts.
|
|
637
|
+
const allDocFilesWithArch = [...allDocFiles, "architecture.md"];
|
|
638
|
+
const [archSettled, indexSettled] = await Promise.allSettled([
|
|
639
|
+
archAgent.run({
|
|
640
|
+
projectName,
|
|
641
|
+
sourceRoot: resolve(cliOptions.source),
|
|
642
|
+
destinationPath: absoluteDestination,
|
|
643
|
+
allFindings: explorationFindings,
|
|
644
|
+
allGroupSummaries: groups.map((group) => ({
|
|
645
|
+
id: group.id,
|
|
646
|
+
label: group.label,
|
|
647
|
+
files: group.files.map((f) => f.path),
|
|
648
|
+
})),
|
|
649
|
+
allDocFiles,
|
|
650
|
+
}, verboseLog),
|
|
651
|
+
indexAgent.run({
|
|
652
|
+
projectName,
|
|
653
|
+
destinationPath: absoluteDestination,
|
|
654
|
+
allDocFiles: allDocFilesWithArch,
|
|
655
|
+
allFindings: explorationFindings,
|
|
656
|
+
allGroupSummaries: groups.map((group) => ({
|
|
657
|
+
id: group.id,
|
|
658
|
+
label: group.label,
|
|
659
|
+
files: group.files.map((f) => f.path),
|
|
660
|
+
})),
|
|
661
|
+
}, verboseLog),
|
|
662
|
+
]);
|
|
663
|
+
// Handle architecture result
|
|
664
|
+
if (archSettled.status === "rejected") {
|
|
665
|
+
if (archSettled.reason instanceof AuthExpiredError) {
|
|
666
|
+
throw archSettled.reason;
|
|
667
|
+
}
|
|
668
|
+
const msg = archSettled.reason instanceof Error
|
|
669
|
+
? archSettled.reason.message
|
|
670
|
+
: String(archSettled.reason);
|
|
671
|
+
updateState({
|
|
672
|
+
warnings: [`Architecture overview generation failed: ${msg}`],
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
// Handle index result + TOC post-processing
|
|
676
|
+
{
|
|
677
|
+
updateState({ phase: "indexing" });
|
|
678
|
+
const indexFiles = [];
|
|
679
|
+
try {
|
|
680
|
+
// Scan disk AFTER both agents complete so the TOC includes architecture.md
|
|
681
|
+
const diskFiles = await deps.scanDocFiles(absoluteDestination);
|
|
682
|
+
let indexWritten = false;
|
|
683
|
+
if (indexSettled.status === "fulfilled") {
|
|
684
|
+
try {
|
|
685
|
+
// Read back what the agent wrote
|
|
686
|
+
const indexPath = join(absoluteDestination, "index.md");
|
|
687
|
+
const agentContent = await readFile(indexPath, "utf-8");
|
|
688
|
+
// Replace <!-- TOC --> placeholder with procedural link list
|
|
689
|
+
const tocLinkList = generateTocLinkList(projectName, diskFiles);
|
|
690
|
+
const finalContent = replaceTocPlaceholder(agentContent, tocLinkList);
|
|
691
|
+
await deps.writeTableOfContents(absoluteDestination, finalContent);
|
|
692
|
+
indexWritten = true;
|
|
693
|
+
}
|
|
694
|
+
catch (agentErr) {
|
|
695
|
+
if (agentErr instanceof AuthExpiredError) {
|
|
696
|
+
throw agentErr;
|
|
697
|
+
}
|
|
698
|
+
const msg = agentErr instanceof Error ? agentErr.message : String(agentErr);
|
|
699
|
+
if (verboseLog) {
|
|
700
|
+
verboseLog(`[index-agent] Failed to post-process index.md, falling back to procedural TOC: ${msg}`);
|
|
701
|
+
}
|
|
702
|
+
updateState({
|
|
703
|
+
warnings: [`Index page post-processing failed (using fallback): ${msg}`],
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
if (indexSettled.reason instanceof AuthExpiredError) {
|
|
709
|
+
throw indexSettled.reason;
|
|
710
|
+
}
|
|
711
|
+
const msg = indexSettled.reason instanceof Error
|
|
712
|
+
? indexSettled.reason.message
|
|
713
|
+
: String(indexSettled.reason);
|
|
714
|
+
if (verboseLog) {
|
|
715
|
+
verboseLog(`[index-agent] Failed, falling back to procedural TOC: ${msg}`);
|
|
716
|
+
}
|
|
717
|
+
updateState({
|
|
718
|
+
warnings: [`Index page agent failed (using fallback): ${msg}`],
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
// Fallback: procedural TOC generation
|
|
722
|
+
if (!indexWritten) {
|
|
723
|
+
const tocContent = deps.generateTableOfContents(projectName, diskFiles);
|
|
724
|
+
await deps.writeTableOfContents(absoluteDestination, tocContent);
|
|
725
|
+
}
|
|
726
|
+
indexFiles.push("index.md");
|
|
727
|
+
}
|
|
728
|
+
catch (tocErr) {
|
|
729
|
+
if (tocErr instanceof AuthExpiredError) {
|
|
730
|
+
throw tocErr;
|
|
731
|
+
}
|
|
732
|
+
const msg = tocErr instanceof Error ? tocErr.message : String(tocErr);
|
|
733
|
+
updateState({
|
|
734
|
+
warnings: [`Table of contents generation failed: ${msg}`],
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (indexFiles.length > 0) {
|
|
738
|
+
updateState({ indexFiles });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// ── Step 10j: Remove empty directories left behind by agents ─────
|
|
742
|
+
await deps.removeEmptyDirs(absoluteDestination);
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
try {
|
|
746
|
+
await provider.cleanup();
|
|
747
|
+
}
|
|
748
|
+
catch (cleanupErr) {
|
|
749
|
+
console.error("Provider cleanup failed:", cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
searchIndex.close();
|
|
753
|
+
}
|
|
754
|
+
catch (closeErr) {
|
|
755
|
+
console.error("Search index close failed:", closeErr instanceof Error ? closeErr.message : String(closeErr));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// ── Step 10k: Complete phase ──────────────────────────────────────
|
|
759
|
+
// Final disk scan to get the actual page count (includes all agent output).
|
|
760
|
+
// This runs AFTER orchestrator shutdown — it's just a filesystem call.
|
|
761
|
+
const finalDocFiles = await deps.scanDocFiles(absoluteDestination);
|
|
762
|
+
// ── Step 10l: Write changelog entry ────────────────────────────────
|
|
763
|
+
// Detect which doc files were created/updated via git status, then
|
|
764
|
+
// record a changelog entry with the current HEAD SHA.
|
|
765
|
+
try {
|
|
766
|
+
const currentSHA = deps.getCurrentSHA(absoluteSource);
|
|
767
|
+
const changedDocs = deps.detectChangedDocs(absoluteDestination);
|
|
768
|
+
await deps.writeChangelogEntry(absoluteDestination, currentSHA, changedDocs, prevSHA === null);
|
|
769
|
+
if (cliOptions.verbose) {
|
|
770
|
+
process.stderr.write(`[changelog] Recorded entry at ${currentSHA.slice(0, 8)} ` +
|
|
771
|
+
`(${changedDocs.length} doc${changedDocs.length === 1 ? "" : "s"} changed)\n`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch (changelogError) {
|
|
775
|
+
// Changelog is best-effort — don't fail the pipeline
|
|
776
|
+
const msg = changelogError instanceof Error ? changelogError.message : String(changelogError);
|
|
777
|
+
if (cliOptions.verbose) {
|
|
778
|
+
process.stderr.write(`[changelog] Warning: Could not write changelog (${msg})\n`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const totalPages = finalDocFiles.filter((f) => f !== "index.md" && f !== "changelog.md").length;
|
|
782
|
+
const completedAt = Date.now();
|
|
783
|
+
const failedResults = results.filter((r) => r.status === "failed");
|
|
784
|
+
const warnings = failedResults.map((r) => `Failed to generate: ${r.groupId}${r.error ? ` (${r.error})` : ""}`);
|
|
785
|
+
updateState({
|
|
786
|
+
phase: "complete",
|
|
787
|
+
completedAt,
|
|
788
|
+
totalPages,
|
|
789
|
+
completedSessions: results.length - failedResults.length,
|
|
790
|
+
failedSessions: failedResults.length,
|
|
791
|
+
warnings,
|
|
792
|
+
});
|
|
793
|
+
await waitUntilExit();
|
|
794
|
+
// ── Step 11: Set exit code ─────────────────────────────────────────
|
|
795
|
+
const exitCode = getExitCode(results);
|
|
796
|
+
if (exitCode !== 0) {
|
|
797
|
+
process.exitCode = exitCode;
|
|
798
|
+
}
|
|
799
|
+
// ── Worktree: commit, push, and clean up ───────────────────────────
|
|
800
|
+
if (worktreeState) {
|
|
801
|
+
try {
|
|
802
|
+
commitWorktree(worktreeState.path, "docs: generated by hem");
|
|
803
|
+
pushWorktree(worktreeState.path, worktreeState.branch);
|
|
804
|
+
process.stdout.write(`\n Branch pushed: ${worktreeState.branch}\n` +
|
|
805
|
+
` Review and merge when ready.\n`);
|
|
806
|
+
}
|
|
807
|
+
catch (wtErr) {
|
|
808
|
+
const msg = wtErr instanceof Error ? wtErr.message : String(wtErr);
|
|
809
|
+
process.stderr.write(`Warning: worktree commit/push failed: ${msg}\n`);
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
cleanupWorktree(worktreeState.repoRoot, worktreeState.path);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return cliOptions;
|
|
816
|
+
}
|
|
817
|
+
catch (pipelineError) {
|
|
818
|
+
if (worktreeState) {
|
|
819
|
+
cleanupWorktree(worktreeState.repoRoot, worktreeState.path);
|
|
820
|
+
}
|
|
821
|
+
if (pipelineError instanceof AuthExpiredError && cliOptions.apiKey) {
|
|
822
|
+
const providerID = cliOptions.model
|
|
823
|
+
? cliOptions.model.split("/")[0]
|
|
824
|
+
: pipelineError.providerName;
|
|
825
|
+
const message = INVALID_API_KEY_ERROR.replace("{providerID}", providerID);
|
|
826
|
+
process.stderr.write(message);
|
|
827
|
+
process.exitCode = 2;
|
|
828
|
+
await waitUntilExit();
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
if (pipelineError instanceof AuthExpiredError) {
|
|
832
|
+
process.stderr.write(pipelineError.message);
|
|
833
|
+
process.exitCode = 2;
|
|
834
|
+
await waitUntilExit();
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
const completedAt = Date.now();
|
|
838
|
+
const errorMessage = pipelineError instanceof Error
|
|
839
|
+
? pipelineError.message
|
|
840
|
+
: String(pipelineError);
|
|
841
|
+
updateState({
|
|
842
|
+
phase: "error",
|
|
843
|
+
completedAt,
|
|
844
|
+
warnings: [errorMessage],
|
|
845
|
+
});
|
|
846
|
+
await waitUntilExit();
|
|
847
|
+
process.exitCode = 2;
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Handle the `auth` subcommand.
|
|
853
|
+
*/
|
|
854
|
+
export async function handleAuth(action, target, deps = defaultDeps) {
|
|
855
|
+
const validActions = ["login", "list", "logout"];
|
|
856
|
+
if (action && !validActions.includes(action)) {
|
|
857
|
+
process.stderr.write(`Unknown auth action: "${action}". Valid actions: login, list, logout\n`);
|
|
858
|
+
process.exitCode = 2;
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
const cliOptions = {
|
|
862
|
+
source: "./src",
|
|
863
|
+
destination: "./docs",
|
|
864
|
+
files: "**/*",
|
|
865
|
+
concurrency: computeMaxConcurrency(),
|
|
866
|
+
model: undefined,
|
|
867
|
+
apiKey: undefined,
|
|
868
|
+
name: undefined,
|
|
869
|
+
verbose: false,
|
|
870
|
+
full: false,
|
|
871
|
+
worktree: false,
|
|
872
|
+
command: "auth",
|
|
873
|
+
authAction: action ?? undefined,
|
|
874
|
+
authTarget: target,
|
|
875
|
+
};
|
|
876
|
+
const { client, server } = await deps.startWithRetry(async () => {
|
|
877
|
+
const port = await deps.findFreePort();
|
|
878
|
+
return deps.createOpencode({ port });
|
|
879
|
+
});
|
|
880
|
+
trackServer(() => server.close());
|
|
881
|
+
try {
|
|
882
|
+
const resolvedAction = cliOptions.authAction ?? "login";
|
|
883
|
+
switch (resolvedAction) {
|
|
884
|
+
case "login":
|
|
885
|
+
await deps.handleAuthLogin(client);
|
|
886
|
+
break;
|
|
887
|
+
case "list":
|
|
888
|
+
await deps.handleAuthList(client);
|
|
889
|
+
break;
|
|
890
|
+
case "logout":
|
|
891
|
+
await deps.handleAuthLogout(client, cliOptions.authTarget);
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
finally {
|
|
896
|
+
server.close();
|
|
897
|
+
untrackServer();
|
|
898
|
+
}
|
|
899
|
+
return cliOptions;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Two-step interactive provider + model selection used by handleConfig.
|
|
903
|
+
* Step 1: Select a provider from the list of connected providers.
|
|
904
|
+
* Step 2: If the provider has models, select one; otherwise use "default".
|
|
905
|
+
* @internal
|
|
906
|
+
*/
|
|
907
|
+
async function selectProviderAndModel(deps, client, providers) {
|
|
908
|
+
const providerOptions = providers.map((p) => ({
|
|
909
|
+
value: { providerID: p.id, modelID: "default" },
|
|
910
|
+
label: p.id === "opencode" ? "OpenCode" : p.name,
|
|
911
|
+
description: `(${p.authMethod})`,
|
|
912
|
+
}));
|
|
913
|
+
const providerSelection = await deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
|
|
914
|
+
options: providerOptions,
|
|
915
|
+
onSelect: resolve,
|
|
916
|
+
title: "Select a provider:",
|
|
917
|
+
}));
|
|
918
|
+
const availableModels = await deps.listProviderModels(client, providerSelection.providerID);
|
|
919
|
+
if (availableModels.length === 0) {
|
|
920
|
+
return providerSelection;
|
|
921
|
+
}
|
|
922
|
+
const modelOptions = [
|
|
923
|
+
{
|
|
924
|
+
value: { providerID: providerSelection.providerID, modelID: "default" },
|
|
925
|
+
label: "Default",
|
|
926
|
+
description: "(let provider decide)",
|
|
927
|
+
},
|
|
928
|
+
...availableModels.map((m) => ({
|
|
929
|
+
value: {
|
|
930
|
+
providerID: providerSelection.providerID,
|
|
931
|
+
// When a sub-provider is present (opencode meta-provider case), encode it
|
|
932
|
+
// in the modelID so OpenCodeProvider can route correctly. Without this,
|
|
933
|
+
// m.providerID ("github-copilot") would replace "opencode" as the top-level
|
|
934
|
+
// provider, causing CopilotProvider to be used instead of OpenCodeProvider.
|
|
935
|
+
modelID: m.providerID && m.providerID !== providerSelection.providerID
|
|
936
|
+
? `${m.providerID}/${m.id}`
|
|
937
|
+
: m.id,
|
|
938
|
+
},
|
|
939
|
+
label: m.name,
|
|
940
|
+
})),
|
|
941
|
+
];
|
|
942
|
+
return deps.renderAndWait((resolve) => React.createElement(ConfigPrompt, {
|
|
943
|
+
options: modelOptions,
|
|
944
|
+
onSelect: resolve,
|
|
945
|
+
title: "Select a model:",
|
|
946
|
+
}));
|
|
947
|
+
}
|
|
948
|
+
/** Print the confirmation message after saving config. @internal */
|
|
949
|
+
function printConfigSaved(model) {
|
|
950
|
+
console.log(`\n \u2713 Configuration saved to .hem/config.json\n`);
|
|
951
|
+
console.log(` Provider: ${model.providerID}`);
|
|
952
|
+
console.log(` Model: ${model.modelID}\n`);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Handle the `config` subcommand.
|
|
956
|
+
*
|
|
957
|
+
* Interactively prompts the user to select a provider/model and saves
|
|
958
|
+
* the configuration to `{cwd}/.hem/config.json`.
|
|
959
|
+
*/
|
|
960
|
+
export async function handleConfig(deps = defaultDeps) {
|
|
961
|
+
const cliOptions = {
|
|
962
|
+
source: "./src",
|
|
963
|
+
destination: "./docs",
|
|
964
|
+
files: "**/*",
|
|
965
|
+
concurrency: computeMaxConcurrency(),
|
|
966
|
+
model: undefined,
|
|
967
|
+
apiKey: undefined,
|
|
968
|
+
name: undefined,
|
|
969
|
+
verbose: false,
|
|
970
|
+
full: false,
|
|
971
|
+
worktree: false,
|
|
972
|
+
command: "config",
|
|
973
|
+
authAction: undefined,
|
|
974
|
+
authTarget: undefined,
|
|
975
|
+
};
|
|
976
|
+
const { client, server } = await deps.startWithRetry(async () => {
|
|
977
|
+
const port = await deps.findFreePort();
|
|
978
|
+
return deps.createOpencode({ port });
|
|
979
|
+
});
|
|
980
|
+
trackServer(() => server.close());
|
|
981
|
+
try {
|
|
982
|
+
// Detect auth state to find connected providers.
|
|
983
|
+
const authState = await deps.detectAuthState(client);
|
|
984
|
+
// If no providers are connected, run auth first.
|
|
985
|
+
if (authState.connectedProviders.length === 0) {
|
|
986
|
+
const firstRunResult = await deps.handleFirstRun(client);
|
|
987
|
+
if (firstRunResult === null) {
|
|
988
|
+
// User chose to exit.
|
|
989
|
+
process.exitCode = 0;
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
// Re-detect auth state after first-run flow.
|
|
993
|
+
const updatedAuthState = await deps.detectAuthState(client);
|
|
994
|
+
if (updatedAuthState.connectedProviders.length === 0) {
|
|
995
|
+
process.stderr.write("No providers connected. Run `npx @pruddiman/hem auth login` to set up authentication.\n");
|
|
996
|
+
process.exitCode = 2;
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
// Present config prompt with updated providers.
|
|
1000
|
+
const updatedModel = await selectProviderAndModel(deps, client, updatedAuthState.connectedProviders);
|
|
1001
|
+
await deps.saveProjectConfig({ model: updatedModel });
|
|
1002
|
+
printConfigSaved(updatedModel);
|
|
1003
|
+
return cliOptions;
|
|
1004
|
+
}
|
|
1005
|
+
// Present config prompt with connected providers.
|
|
1006
|
+
const model = await selectProviderAndModel(deps, client, authState.connectedProviders);
|
|
1007
|
+
await deps.saveProjectConfig({ model });
|
|
1008
|
+
printConfigSaved(model);
|
|
1009
|
+
return cliOptions;
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
server.close();
|
|
1013
|
+
untrackServer();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// ── Program builder ────────────────────────────────────────────────────
|
|
1017
|
+
/**
|
|
1018
|
+
* Build and return a configured Commander program.
|
|
1019
|
+
*/
|
|
1020
|
+
export function buildProgram(deps = defaultDeps) {
|
|
1021
|
+
const prog = new Command();
|
|
1022
|
+
prog
|
|
1023
|
+
.name("hem")
|
|
1024
|
+
.description("AI-powered documentation generator. Scans your source code and produces a complete, cross-referenced documentation site.")
|
|
1025
|
+
.version(version, "-V, --version")
|
|
1026
|
+
.option("-s, --source <path>", "Source directory to scan for files", "./src")
|
|
1027
|
+
.option("-d, --destination <path>", "Output directory for generated documentation", "./docs")
|
|
1028
|
+
.option("-f, --files <glob>", "Glob pattern to match source files", "**/*")
|
|
1029
|
+
.option("-c, --concurrency <n>", "Maximum parallel documentation sessions (auto-detected from system resources if omitted)", (v) => {
|
|
1030
|
+
const n = parseInt(v, 10);
|
|
1031
|
+
if (Number.isNaN(n) || n < 1) {
|
|
1032
|
+
throw new Error("concurrency must be a positive integer");
|
|
1033
|
+
}
|
|
1034
|
+
const effective = computeMaxConcurrency(n);
|
|
1035
|
+
if (effective < n) {
|
|
1036
|
+
process.stderr.write(`Warning: requested concurrency ${n} exceeds resource limit. ` +
|
|
1037
|
+
`Clamping to ${effective} (${describeResourceLimits(n)}).\n`);
|
|
1038
|
+
}
|
|
1039
|
+
return effective;
|
|
1040
|
+
}, computeMaxConcurrency())
|
|
1041
|
+
.option("-m, --model <id>", "Model to use (e.g., opencode/gpt-5-nano, anthropic/claude-sonnet-4). Overrides stored preferences.")
|
|
1042
|
+
.option("-k, --api-key <key>", "API key for the provider specified in --model. Bypasses OAuth and interactive prompts.")
|
|
1043
|
+
.option("-n, --name <name>", "Project name (used in page titles and headings). Auto-detected from package.json / Cargo.toml / etc. if omitted.")
|
|
1044
|
+
.option("-v, --verbose", "Show detailed logs and LLM output on stderr", false)
|
|
1045
|
+
.option("--full", "Force full re-generation of all docs (behavior may be unpredictable when docs already exist)", false)
|
|
1046
|
+
.option("--worktree", "Run in an isolated git worktree on a new branch, then commit and push for review", false)
|
|
1047
|
+
.action(async (opts) => {
|
|
1048
|
+
await handleGenerate(opts, deps);
|
|
1049
|
+
});
|
|
1050
|
+
prog
|
|
1051
|
+
.command("auth [action] [target]")
|
|
1052
|
+
.description("Manage authentication. Actions: login, list, logout. Use 'logout <id>' to remove a specific provider.")
|
|
1053
|
+
.action(async (action, target) => {
|
|
1054
|
+
await handleAuth(action, target, deps);
|
|
1055
|
+
});
|
|
1056
|
+
prog
|
|
1057
|
+
.command("config")
|
|
1058
|
+
.description("Configure Hem for this project. Interactively select a provider/model and save to .hem/config.json.")
|
|
1059
|
+
.action(async () => {
|
|
1060
|
+
await handleConfig(deps);
|
|
1061
|
+
});
|
|
1062
|
+
return prog;
|
|
1063
|
+
}
|
|
1064
|
+
// ── Parse & run ────────────────────────────────────────────────────────
|
|
1065
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
1066
|
+
const isDirectRun = process.argv[1] !== undefined &&
|
|
1067
|
+
(() => {
|
|
1068
|
+
try {
|
|
1069
|
+
return realpathSync(process.argv[1]) === scriptPath;
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
// Fallback for broken symlinks or permission errors
|
|
1073
|
+
return process.argv[1] === scriptPath;
|
|
1074
|
+
}
|
|
1075
|
+
})();
|
|
1076
|
+
if (isDirectRun) {
|
|
1077
|
+
const prog = buildProgram();
|
|
1078
|
+
prog
|
|
1079
|
+
.parseAsync(process.argv)
|
|
1080
|
+
.then(() => {
|
|
1081
|
+
process.exit(process.exitCode ?? 0);
|
|
1082
|
+
})
|
|
1083
|
+
.catch((err) => {
|
|
1084
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
});
|
|
1087
|
+
}
|