@pruddiman/hem 0.0.1-beta-5671db0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
@@ -0,0 +1,405 @@
1
+ /**
2
+ * File discovery module for Hem.
3
+ *
4
+ * Scans a source directory for files matching a glob pattern, classifies
5
+ * each file as text or binary, and returns a `FileInfo[]` array.
6
+ *
7
+ * Also provides `detectProjectName()` for inferring the project name
8
+ * from manifest files (package.json, Cargo.toml, etc.) in ancestor
9
+ * directories of the source path.
10
+ *
11
+ * Reference: FR-002 (file scanning), FR-014 (binary detection), FR-015 (destination exclusion).
12
+ */
13
+ import { resolve, relative, extname, basename, dirname, join } from "node:path";
14
+ import { stat, open, readFile, access } from "node:fs/promises";
15
+ import fg from "fast-glob";
16
+ import { isPathWithin } from "./helpers/paths.js";
17
+ /**
18
+ * Directories that are almost always irrelevant to documentation and
19
+ * should be excluded from file discovery regardless of .gitignore.
20
+ */
21
+ const DEFAULT_IGNORE_PATTERNS = [
22
+ "**/node_modules/**",
23
+ "**/.git/**",
24
+ "**/dist/**",
25
+ "**/build/**",
26
+ "**/coverage/**",
27
+ "**/.cache/**",
28
+ "**/.tmp/**",
29
+ ];
30
+ /**
31
+ * Known binary file extensions.
32
+ *
33
+ * Files with any of these extensions are classified as binary without
34
+ * reading their contents. The set covers images, fonts, audio/video,
35
+ * archives, executables, and compiled artifacts.
36
+ */
37
+ const BINARY_EXTENSIONS = new Set([
38
+ ".png",
39
+ ".jpg",
40
+ ".jpeg",
41
+ ".gif",
42
+ ".bmp",
43
+ ".ico",
44
+ ".svg",
45
+ ".woff",
46
+ ".woff2",
47
+ ".ttf",
48
+ ".eot",
49
+ ".mp3",
50
+ ".mp4",
51
+ ".avi",
52
+ ".mov",
53
+ ".zip",
54
+ ".tar",
55
+ ".gz",
56
+ ".pdf",
57
+ ".exe",
58
+ ".dll",
59
+ ".so",
60
+ ".dylib",
61
+ ".o",
62
+ ".class",
63
+ ".pyc",
64
+ ".wasm",
65
+ ]);
66
+ /**
67
+ * Detects whether a file is binary by checking the first 512 bytes for
68
+ * null bytes (`0x00`). This heuristic is used only for files whose
69
+ * extension is not in the known binary list.
70
+ *
71
+ * @param absolutePath - Absolute path to the file.
72
+ * @returns `true` if null bytes are found (likely binary).
73
+ */
74
+ async function detectBinaryByContent(absolutePath) {
75
+ let fh;
76
+ try {
77
+ fh = await open(absolutePath, "r");
78
+ const buffer = Buffer.alloc(512);
79
+ const { bytesRead } = await fh.read(buffer, 0, 512, 0);
80
+ for (let i = 0; i < bytesRead; i++) {
81
+ if (buffer[i] === 0x00) {
82
+ return true;
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ catch {
88
+ // If we can't read the file, treat it as non-binary so it still
89
+ // shows up in results (the caller can handle read errors later).
90
+ return false;
91
+ }
92
+ finally {
93
+ try {
94
+ await fh?.close();
95
+ }
96
+ catch {
97
+ // Best-effort close; ignore failure to avoid masking the main return value
98
+ // or (in the catch branch above) the original read error.
99
+ }
100
+ }
101
+ }
102
+ /**
103
+ * Determines whether a file is binary based on its extension and,
104
+ * when the extension is not in the known list, by inspecting the
105
+ * first 512 bytes for null bytes.
106
+ *
107
+ * @param absolutePath - Absolute path to the file.
108
+ * @param extension - File extension including the leading dot.
109
+ * @returns `true` if the file is binary.
110
+ */
111
+ async function isBinaryFile(absolutePath, extension) {
112
+ if (BINARY_EXTENSIONS.has(extension.toLowerCase())) {
113
+ return true;
114
+ }
115
+ // For files with no extension or an unrecognised extension, fall back
116
+ // to the content-based heuristic.
117
+ if (extension === "" || !extension.startsWith(".")) {
118
+ return detectBinaryByContent(absolutePath);
119
+ }
120
+ return false;
121
+ }
122
+ /**
123
+ * Discovers source files within `sourcePath` that match the given glob
124
+ * pattern. Each file is classified as binary or text and returned as a
125
+ * `FileInfo` object.
126
+ *
127
+ * Key behaviours:
128
+ * - `sourcePath` and `destinationPath` are resolved to absolute paths.
129
+ * - Files within `destinationPath` are excluded when the destination
130
+ * overlaps with the source (FR-015).
131
+ * - Binary files are included in the returned array with
132
+ * `isBinary: true` so callers can count/warn about them.
133
+ *
134
+ * @param sourcePath - Root directory to scan.
135
+ * @param globPattern - Glob pattern relative to `sourcePath`.
136
+ * @param destinationPath - Destination directory for generated docs
137
+ * (excluded from results when overlapping with source).
138
+ * @returns Array of `FileInfo` objects for every matched file.
139
+ */
140
+ export async function discoverFiles(sourcePath, globPattern, destinationPath) {
141
+ const absoluteSource = resolve(sourcePath);
142
+ const absoluteDestination = resolve(destinationPath);
143
+ // FR-015: When the destination directory overlaps with or is inside
144
+ // the source directory, add it as an ignore pattern so fast-glob
145
+ // skips those files entirely (better performance than post-hoc
146
+ // filtering for large trees). We also keep a post-hoc safety check
147
+ // below as a defence-in-depth measure.
148
+ const destIgnorePatterns = [];
149
+ if (isPathWithin(absoluteDestination, absoluteSource)) {
150
+ const relDest = relative(absoluteSource, absoluteDestination);
151
+ // fast-glob ignore patterns are relative to cwd.
152
+ destIgnorePatterns.push(relDest === "" ? "**/*" : `${relDest}/**`);
153
+ }
154
+ const gitignorePatterns = await loadGitignorePatterns(absoluteSource);
155
+ const allIgnorePatterns = [
156
+ ...DEFAULT_IGNORE_PATTERNS,
157
+ ...destIgnorePatterns,
158
+ ...gitignorePatterns,
159
+ ];
160
+ // Use fast-glob to discover files. The `cwd` option makes the
161
+ // pattern relative to the source directory. `dot: true` includes
162
+ // dotfiles; `onlyFiles: true` excludes directories.
163
+ const matches = await fg(globPattern, {
164
+ cwd: absoluteSource,
165
+ onlyFiles: true,
166
+ dot: true,
167
+ absolute: true,
168
+ ignore: allIgnorePatterns,
169
+ });
170
+ const results = [];
171
+ for (const absolutePath of matches) {
172
+ // Defence-in-depth: exclude files inside the destination directory
173
+ // even if fast-glob's ignore didn't catch them (e.g., symlinks,
174
+ // race conditions, or destination outside source that somehow
175
+ // appeared in results).
176
+ if (isPathWithin(absolutePath, absoluteDestination)) {
177
+ continue;
178
+ }
179
+ const relativePath = relative(absoluteSource, absolutePath);
180
+ const extension = extname(absolutePath);
181
+ let fileSize;
182
+ try {
183
+ const fileStat = await stat(absolutePath);
184
+ fileSize = fileStat.size;
185
+ }
186
+ catch {
187
+ // Skip files we cannot stat (e.g., broken symlinks).
188
+ continue;
189
+ }
190
+ const binary = await isBinaryFile(absolutePath, extension);
191
+ results.push({
192
+ path: relativePath,
193
+ absolutePath,
194
+ extension,
195
+ size: fileSize,
196
+ isBinary: binary,
197
+ });
198
+ }
199
+ // Sort by relative path for deterministic ordering.
200
+ results.sort((a, b) => a.path.localeCompare(b.path));
201
+ return results;
202
+ }
203
+ // ── Project name detection ──────────────────────────────────────────────
204
+ /**
205
+ * Maximum number of ancestor directories to walk when searching for
206
+ * manifest files. Prevents runaway traversal.
207
+ */
208
+ const MAX_WALK_DEPTH = 5;
209
+ /**
210
+ * Common directory basenames that are poor project names.
211
+ * If `basename(sourcePath)` matches one of these, we look harder.
212
+ */
213
+ const GENERIC_DIR_NAMES = new Set([
214
+ "src",
215
+ "lib",
216
+ "app",
217
+ "source",
218
+ "sources",
219
+ "code",
220
+ "packages",
221
+ "modules",
222
+ "internal",
223
+ "cmd",
224
+ "pkg",
225
+ ]);
226
+ /**
227
+ * Reads .gitignore at `dir` and converts its patterns to fast-glob
228
+ * ignore patterns. Returns an empty array if the file is absent.
229
+ * @internal
230
+ */
231
+ async function loadGitignorePatterns(dir) {
232
+ const content = await tryReadFile(join(dir, ".gitignore"));
233
+ if (!content)
234
+ return [];
235
+ return content
236
+ .split("\n")
237
+ .map((line) => line.trim())
238
+ .filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!"))
239
+ .flatMap((pattern) => {
240
+ // Strip leading slash — gitignore root-anchors with /, fast-glob uses cwd
241
+ const p = pattern.startsWith("/") ? pattern.slice(1) : pattern;
242
+ const bare = p.replace(/\/$/, "");
243
+ // Patterns with no interior slash match anywhere in the tree
244
+ const hasInteriorSlash = bare.includes("/");
245
+ if (!hasInteriorSlash) {
246
+ return [`**/${bare}`, `**/${bare}/**`];
247
+ }
248
+ return [bare, `${bare}/**`];
249
+ });
250
+ }
251
+ /**
252
+ * Try to read a file at `filePath` and return its text content,
253
+ * or `undefined` if the file does not exist or cannot be read.
254
+ * @internal
255
+ */
256
+ async function tryReadFile(filePath) {
257
+ try {
258
+ return await readFile(filePath, "utf-8");
259
+ }
260
+ catch {
261
+ return undefined;
262
+ }
263
+ }
264
+ /**
265
+ * Extract the project name from a `package.json` file.
266
+ * Returns `undefined` if the file is missing, malformed, or the
267
+ * `name` field is absent/empty.
268
+ * @internal
269
+ */
270
+ async function nameFromPackageJson(dir) {
271
+ const content = await tryReadFile(join(dir, "package.json"));
272
+ if (!content)
273
+ return undefined;
274
+ try {
275
+ const pkg = JSON.parse(content);
276
+ const raw = pkg.name;
277
+ if (typeof raw !== "string" || raw.trim().length === 0)
278
+ return undefined;
279
+ // Scoped packages: "@scope/foo" → "foo"
280
+ const name = raw.trim();
281
+ const slashIdx = name.indexOf("/");
282
+ return slashIdx >= 0 ? name.slice(slashIdx + 1) : name;
283
+ }
284
+ catch {
285
+ return undefined;
286
+ }
287
+ }
288
+ /**
289
+ * Extract the project name from a `Cargo.toml` file.
290
+ * Uses a simple regex — avoids pulling in a TOML parser.
291
+ * @internal
292
+ */
293
+ async function nameFromCargoToml(dir) {
294
+ const content = await tryReadFile(join(dir, "Cargo.toml"));
295
+ if (!content)
296
+ return undefined;
297
+ // Match `name = "..."` under `[package]` section
298
+ const packageMatch = content.match(/\[package\][^[]*?name\s*=\s*"([^"]+)"/s);
299
+ return packageMatch?.[1] || undefined;
300
+ }
301
+ /**
302
+ * Extract the project name from a `pyproject.toml` file.
303
+ * Checks `[project]` and `[tool.poetry]` sections.
304
+ * @internal
305
+ */
306
+ async function nameFromPyprojectToml(dir) {
307
+ const content = await tryReadFile(join(dir, "pyproject.toml"));
308
+ if (!content)
309
+ return undefined;
310
+ // Try [project] section first
311
+ const projectMatch = content.match(/\[project\][^[]*?name\s*=\s*"([^"]+)"/s);
312
+ if (projectMatch?.[1])
313
+ return projectMatch[1];
314
+ // Try [tool.poetry] section
315
+ const poetryMatch = content.match(/\[tool\.poetry\][^[]*?name\s*=\s*"([^"]+)"/s);
316
+ return poetryMatch?.[1] || undefined;
317
+ }
318
+ /**
319
+ * Extract the project name from a `go.mod` file.
320
+ * The module path's last segment is used as the name.
321
+ * @internal
322
+ */
323
+ async function nameFromGoMod(dir) {
324
+ const content = await tryReadFile(join(dir, "go.mod"));
325
+ if (!content)
326
+ return undefined;
327
+ const moduleMatch = content.match(/^module\s+(\S+)/m);
328
+ if (!moduleMatch?.[1])
329
+ return undefined;
330
+ const modulePath = moduleMatch[1];
331
+ const lastSegment = modulePath.split("/").pop();
332
+ return lastSegment || undefined;
333
+ }
334
+ /**
335
+ * Check whether a `.git` directory exists at the given path.
336
+ * @internal
337
+ */
338
+ async function hasGitDir(dir) {
339
+ try {
340
+ await access(join(dir, ".git"));
341
+ return true;
342
+ }
343
+ catch {
344
+ return false;
345
+ }
346
+ }
347
+ /**
348
+ * Detect the project name by walking up from the source directory
349
+ * and inspecting manifest files.
350
+ *
351
+ * Detection priority (first match wins):
352
+ * 1. `package.json` → `name` field (scoped names stripped)
353
+ * 2. `Cargo.toml` → `[package] name`
354
+ * 3. `pyproject.toml` → `[project] name` or `[tool.poetry] name`
355
+ * 4. `go.mod` → last segment of module path
356
+ *
357
+ * The walk starts at the parent of `sourcePath` (since `sourcePath`
358
+ * itself is typically `src/` or similar) and continues up to the
359
+ * git root or a maximum of {@link MAX_WALK_DEPTH} levels.
360
+ *
361
+ * If no manifest is found, falls back to the basename of the
362
+ * directory containing the first manifest-like file, the parent
363
+ * directory, or ultimately `basename(sourcePath)`.
364
+ *
365
+ * @param sourcePath - The absolute source directory path.
366
+ * @returns The detected project name.
367
+ */
368
+ export async function detectProjectName(sourcePath) {
369
+ const absoluteSource = resolve(sourcePath);
370
+ let dir = dirname(absoluteSource); // Start at parent of source
371
+ const detectors = [
372
+ nameFromPackageJson,
373
+ nameFromCargoToml,
374
+ nameFromPyprojectToml,
375
+ nameFromGoMod,
376
+ ];
377
+ for (let depth = 0; depth < MAX_WALK_DEPTH; depth++) {
378
+ // Try each manifest detector at this directory level
379
+ for (const detect of detectors) {
380
+ const name = await detect(dir);
381
+ if (name)
382
+ return name;
383
+ }
384
+ // If we hit a .git directory, this is the repo root — stop walking
385
+ if (await hasGitDir(dir)) {
386
+ // Use the repo root directory name as project name
387
+ const repoName = basename(dir);
388
+ if (repoName && !GENERIC_DIR_NAMES.has(repoName.toLowerCase())) {
389
+ return repoName;
390
+ }
391
+ break;
392
+ }
393
+ // Move up one level
394
+ const parent = dirname(dir);
395
+ if (parent === dir)
396
+ break; // Reached filesystem root
397
+ dir = parent;
398
+ }
399
+ // Fallback chain: parent basename → source basename
400
+ const parentName = basename(dirname(absoluteSource));
401
+ if (parentName && !GENERIC_DIR_NAMES.has(parentName.toLowerCase())) {
402
+ return parentName;
403
+ }
404
+ return basename(absoluteSource);
405
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * File grouping module for Hem.
3
+ *
4
+ * Analyses discovered source files and groups them by feature vertical
5
+ * (e.g., "user", "order") or architectural layer (e.g., controllers,
6
+ * services). Each file appears in at most one group; feature grouping
7
+ * takes priority over layer grouping.
8
+ *
9
+ * Reference: FR-003, data-model.md lines 93-108.
10
+ */
11
+ import type { FileInfo, FileGroup } from "./types.js";
12
+ /**
13
+ * Finds the common parent directory for a set of file paths.
14
+ *
15
+ * @param files - Array of FileInfo objects.
16
+ * @returns The common parent directory (relative path), or `"."` for root.
17
+ */
18
+ export declare function commonDirectory(files: FileInfo[]): string;
19
+ /**
20
+ * Groups discovered files by feature vertical or architectural layer.
21
+ *
22
+ * Grouping strategy:
23
+ * 1. Filter out binary files.
24
+ * 2. Attempt to assign each file to a **feature vertical** group
25
+ * based on its directory structure (e.g., files under `user/` →
26
+ * "User" feature group).
27
+ * 3. Files not assigned to a feature group are checked for
28
+ * **architectural layer** membership based on file name suffixes
29
+ * (e.g., `.controller.ts` → "Controllers" layer) or containing
30
+ * directory (e.g., `services/` → "Services").
31
+ * 4. Remaining files go into a catch-all "Other" group.
32
+ * 5. Each file appears in at most one group.
33
+ *
34
+ * @param files - Discovered files (may include binary files).
35
+ * @returns Array of `FileGroup` objects.
36
+ */
37
+ export declare function groupFiles(files: FileInfo[]): FileGroup[];