@pruddiman/hem 0.0.1-beta-72c22cf → 0.0.1-beta-697e946
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/dist/agents/documentation-agent.d.ts +15 -0
- package/dist/agents/documentation-agent.js +61 -5
- package/dist/discovery.js +13 -0
- package/dist/grouping.js +82 -17
- package/dist/providers/copilot.js +37 -1
- package/dist/providers/opencode.d.ts +41 -3
- package/dist/providers/opencode.js +27 -6
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -14,6 +14,21 @@
|
|
|
14
14
|
import type { Provider } from "../providers/types.js";
|
|
15
15
|
import type { FileGroup, GenerationContext, ExplorationFindings } from "../types.js";
|
|
16
16
|
import { BaseAgent } from "./base-agent.js";
|
|
17
|
+
/** Upper bound on the final prompt size before progressive section drops. */
|
|
18
|
+
export declare const MAX_PROMPT_CHARS = 500000;
|
|
19
|
+
/** Cap on per-group exploration findings text. */
|
|
20
|
+
export declare const MAX_FINDINGS_CHARS = 60000;
|
|
21
|
+
/** Cap on each individual existing-doc's embedded content. */
|
|
22
|
+
export declare const MAX_EXISTING_DOC_CHARS = 8000;
|
|
23
|
+
/** Cap on the aggregate existing-docs section (drops trailing docs beyond this). */
|
|
24
|
+
export declare const MAX_EXISTING_DOCS_SECTION_CHARS = 40000;
|
|
25
|
+
/** Cap on the cross-group findings summary. */
|
|
26
|
+
export declare const MAX_CROSS_GROUP_CHARS = 20000;
|
|
27
|
+
/**
|
|
28
|
+
* Truncate `text` to `max` chars, appending a short note explaining how to
|
|
29
|
+
* retrieve the rest. Returns the original text if already within budget.
|
|
30
|
+
*/
|
|
31
|
+
export declare function truncateForPrompt(text: string, max: number, tailNote: string): string;
|
|
17
32
|
/**
|
|
18
33
|
* An agent that uses an LLM to generate documentation for a single
|
|
19
34
|
* file group. The agent writes files directly via the edit tool.
|
|
@@ -12,6 +12,33 @@
|
|
|
12
12
|
* - If existing docs are provided, the agent merges inline.
|
|
13
13
|
*/
|
|
14
14
|
import { BaseAgent } from "./base-agent.js";
|
|
15
|
+
// ── Prompt-size budgets ─────────────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// Copilot Sonnet has a ~168k token context window. Claude 1M is larger but
|
|
18
|
+
// we pick the smaller envelope for a one-size-fits-all cap. At ~4 chars per
|
|
19
|
+
// token the hard ceiling is ~670k chars; we target ~500k to leave headroom
|
|
20
|
+
// for the model's output tokens. Per-section caps keep any single section
|
|
21
|
+
// from dominating the prompt.
|
|
22
|
+
/** Upper bound on the final prompt size before progressive section drops. */
|
|
23
|
+
export const MAX_PROMPT_CHARS = 500_000;
|
|
24
|
+
/** Cap on per-group exploration findings text. */
|
|
25
|
+
export const MAX_FINDINGS_CHARS = 60_000;
|
|
26
|
+
/** Cap on each individual existing-doc's embedded content. */
|
|
27
|
+
export const MAX_EXISTING_DOC_CHARS = 8_000;
|
|
28
|
+
/** Cap on the aggregate existing-docs section (drops trailing docs beyond this). */
|
|
29
|
+
export const MAX_EXISTING_DOCS_SECTION_CHARS = 40_000;
|
|
30
|
+
/** Cap on the cross-group findings summary. */
|
|
31
|
+
export const MAX_CROSS_GROUP_CHARS = 20_000;
|
|
32
|
+
/**
|
|
33
|
+
* Truncate `text` to `max` chars, appending a short note explaining how to
|
|
34
|
+
* retrieve the rest. Returns the original text if already within budget.
|
|
35
|
+
*/
|
|
36
|
+
export function truncateForPrompt(text, max, tailNote) {
|
|
37
|
+
if (text.length <= max)
|
|
38
|
+
return text;
|
|
39
|
+
return (text.slice(0, max).trimEnd() +
|
|
40
|
+
`\n\n[… truncated ${text.length - max} chars — ${tailNote}]`);
|
|
41
|
+
}
|
|
15
42
|
// ── Agent ───────────────────────────────────────────────────────────────
|
|
16
43
|
/**
|
|
17
44
|
* An agent that uses an LLM to generate documentation for a single
|
|
@@ -37,6 +64,14 @@ export class DocumentationAgent extends BaseAgent {
|
|
|
37
64
|
if (verbose) {
|
|
38
65
|
verbose(`[${tag}] Prompt: ${prompt.length.toLocaleString()} chars`);
|
|
39
66
|
}
|
|
67
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
68
|
+
// Per-section caps should have prevented this; log loudly so we can
|
|
69
|
+
// investigate what's overflowing rather than silently failing at the
|
|
70
|
+
// provider's 168k-token barrier.
|
|
71
|
+
process.stderr.write(`[${tag}] WARNING: prompt ${prompt.length.toLocaleString()} chars exceeds ` +
|
|
72
|
+
`MAX_PROMPT_CHARS=${MAX_PROMPT_CHARS.toLocaleString()}. ` +
|
|
73
|
+
`Provider may reject. Investigate per-section truncation.\n`);
|
|
74
|
+
}
|
|
40
75
|
// 2. Create a new session
|
|
41
76
|
const sessionId = await this.createSession(`Hem: doc — ${group.label}`);
|
|
42
77
|
if (verbose) {
|
|
@@ -84,7 +119,11 @@ export class DocumentationAgent extends BaseAgent {
|
|
|
84
119
|
// 1. System-level instructions
|
|
85
120
|
parts.push(`Generate documentation files that answer the questions the code asks — not`, `merely describe what the code does.`, "", `**You write files directly using the edit tool.** Do NOT return Markdown content`, `in your response text. Instead, use the edit tool to create and write files in`, `the destination directory. When you are done writing all files, stop.`, "");
|
|
86
121
|
// 2. Where to write files
|
|
87
|
-
|
|
122
|
+
// Sub-agents (IDs like `parent:sub-3`) share the parent group's folder —
|
|
123
|
+
// otherwise every chunk of a large group creates its own sibling dir.
|
|
124
|
+
const parentGroupId = group.id.split(":")[0] ?? group.id;
|
|
125
|
+
const groupSubfolder = `${context.destinationPath}/${parentGroupId}`;
|
|
126
|
+
parts.push("## Destination directory", "", `Write ALL documentation files for this group under this exact subfolder:`, "", ` \`${groupSubfolder}/\``, "", `**Do NOT** write outside this subfolder. **Do NOT** write to the root`, `\`${context.destinationPath}\` directory or any sibling group's subfolder.`, `This keeps the docs tree organized one-subfolder-per-group.`, "", `Within \`${groupSubfolder}/\` you have full autonomy over:`, `- **File naming**: Choose descriptive kebab-case filenames (e.g., \`overview.md\`, \`api-reference.md\`).`, `- **Nested subdirectories**: Create child folders inside the group subfolder if it helps (e.g., \`${groupSubfolder}/guides/getting-started.md\`).`, `- **Number of files**: Create as many files as needed to properly document the group.`, `- **File structure**: Design each document's heading hierarchy and sections.`, "", `Use \`.md\` extension for all files.`, "", `### How to create the subfolder`, "", `Run \`mkdir -p ${groupSubfolder}\` once at the start using the bash tool, then`, `use the edit tool to write each \`.md\` file into that folder. Do NOT write a`, `shell script, Python script, or any other helper file to create directories —`, `the bash tool is allowed to run \`mkdir\` directly, and the edit tool handles`, `everything else.`, "");
|
|
88
127
|
// 3. Quality standards
|
|
89
128
|
parts.push("## Quality Standard: Answer Every Question", "", "Your primary quality standard is: **every question from the exploration findings", "MUST be answered in the generated documentation.** Do NOT leave any question", "unanswered. If you cannot find a definitive answer, state what is known and what", "requires further investigation — but NEVER silently skip a question.", "", "For each integration discovered in the exploration findings:", "", "1. **Use `webfetch` to research answers**: When an integration has an", " `officialDocsUrl`, fetch that URL to find authoritative answers.", "2. **Address HOW, not just WHAT**: Explain HOW to access, query, monitor,", " and troubleshoot each integration.", "3. **Answer operational questions**: Every `operationalQuestions` entry for each", " integration MUST be answered in the documentation.", "");
|
|
90
129
|
parts.push("## Documentation quality standards", "");
|
|
@@ -103,7 +142,7 @@ export class DocumentationAgent extends BaseAgent {
|
|
|
103
142
|
parts.push("## Exploration findings for this group", "");
|
|
104
143
|
if (groupFindings) {
|
|
105
144
|
parts.push("The exploration phase discovered these findings for your group:", "");
|
|
106
|
-
parts.push(DocumentationAgent.formatFindings(groupFindings));
|
|
145
|
+
parts.push(truncateForPrompt(DocumentationAgent.formatFindings(groupFindings), MAX_FINDINGS_CHARS, "re-run hem exploration or inspect the source files directly"));
|
|
107
146
|
}
|
|
108
147
|
else {
|
|
109
148
|
parts.push("No exploration findings available for this group. Use tools to read and analyze the source files directly.", "");
|
|
@@ -111,16 +150,33 @@ export class DocumentationAgent extends BaseAgent {
|
|
|
111
150
|
// 6. Cross-group context
|
|
112
151
|
parts.push("## Cross-group context", "");
|
|
113
152
|
parts.push("Other groups discovered these integrations and dependencies that may relate to", "your group:", "");
|
|
114
|
-
parts.push(DocumentationAgent.summarizeCrossGroupFindings(allFindings, group.id));
|
|
153
|
+
parts.push(truncateForPrompt(DocumentationAgent.summarizeCrossGroupFindings(allFindings, group.id), MAX_CROSS_GROUP_CHARS, "inspect sibling group docs directly with `cat`"));
|
|
115
154
|
parts.push("");
|
|
116
155
|
// 7. Existing docs — search-before-write with skip/update/create decisions
|
|
117
156
|
if (context.existingDocs.length > 0 || (context.mentionedDocPaths && context.mentionedDocPaths.length > 0)) {
|
|
118
157
|
parts.push("## Existing documentation in destination", "");
|
|
119
158
|
parts.push("The destination directory already contains documentation files.", "**Before writing ANY file, you MUST search for related existing docs.**", "", "### Decision criteria for each topic you plan to document:", "", "1. **SKIP** — if an existing doc already covers the topic accurately and", " completely. Do NOT rewrite content that is already correct.", "2. **UPDATE** — if an existing doc covers the topic but is stale, incomplete,", " or missing sections. Update it **in place** using the edit tool. Preserve", " accurate content; fix or expand what is stale or missing.", "3. **CREATE** — if no existing doc covers the topic. Write a new file.", "", "**Content-only changes**: Do NOT rename, move, or delete existing files.", "Only modify file content.", "");
|
|
120
|
-
// Full content for the most relevant docs
|
|
159
|
+
// Full content for the most relevant docs — with per-file + total
|
|
160
|
+
// caps so a single group's docs can't blow out the prompt window.
|
|
161
|
+
// `context.existingDocs` is already ranked by the search index, so
|
|
162
|
+
// when we hit the total budget we drop the trailing (least relevant)
|
|
163
|
+
// entries rather than truncating across the board.
|
|
121
164
|
if (context.existingDocs.length > 0) {
|
|
122
|
-
|
|
165
|
+
const includedDocs = [];
|
|
166
|
+
let includedBytes = 0;
|
|
167
|
+
let droppedDocs = 0;
|
|
123
168
|
for (const doc of context.existingDocs) {
|
|
169
|
+
const truncated = truncateForPrompt(doc.content, MAX_EXISTING_DOC_CHARS, `run \`cat ${doc.path}\` to read the full content`);
|
|
170
|
+
if (includedBytes + truncated.length > MAX_EXISTING_DOCS_SECTION_CHARS &&
|
|
171
|
+
includedDocs.length > 0) {
|
|
172
|
+
droppedDocs = context.existingDocs.length - includedDocs.length;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
includedDocs.push({ path: doc.path, content: truncated });
|
|
176
|
+
includedBytes += truncated.length;
|
|
177
|
+
}
|
|
178
|
+
parts.push(`### Most relevant existing docs (${includedDocs.length} of ${context.existingDocs.length} file${context.existingDocs.length === 1 ? "" : "s"}, full content${droppedDocs > 0 ? `; ${droppedDocs} omitted — read with \`cat\` as needed` : ""})`, "");
|
|
179
|
+
for (const doc of includedDocs) {
|
|
124
180
|
parts.push(`#### \`${doc.path}\``);
|
|
125
181
|
parts.push("```markdown");
|
|
126
182
|
parts.push(doc.content);
|
package/dist/discovery.js
CHANGED
|
@@ -26,6 +26,19 @@ const DEFAULT_IGNORE_PATTERNS = [
|
|
|
26
26
|
"**/coverage/**",
|
|
27
27
|
"**/.cache/**",
|
|
28
28
|
"**/.tmp/**",
|
|
29
|
+
// Framework build output and on-disk caches. These produce noisy,
|
|
30
|
+
// non-source files that leaked into grouping when a project's own
|
|
31
|
+
// .gitignore wasn't picked up (e.g. monorepo inner packages).
|
|
32
|
+
"**/.next/**",
|
|
33
|
+
"**/.turbo/**",
|
|
34
|
+
"**/.vercel/**",
|
|
35
|
+
"**/.nuxt/**",
|
|
36
|
+
"**/.svelte-kit/**",
|
|
37
|
+
"**/.astro/**",
|
|
38
|
+
"**/.parcel-cache/**",
|
|
39
|
+
"**/.vite/**",
|
|
40
|
+
"**/out/**",
|
|
41
|
+
"**/storybook-static/**",
|
|
29
42
|
];
|
|
30
43
|
/**
|
|
31
44
|
* Known binary file extensions.
|
package/dist/grouping.js
CHANGED
|
@@ -137,15 +137,6 @@ export function commonDirectory(files) {
|
|
|
137
137
|
}
|
|
138
138
|
return common.length === 0 ? "." : common.join("/");
|
|
139
139
|
}
|
|
140
|
-
/**
|
|
141
|
-
* Extracts the file stem (name without the final extension).
|
|
142
|
-
* For multi-part suffixes like `user.controller.ts`, the stem is `user`.
|
|
143
|
-
*/
|
|
144
|
-
function fileStem(relativePath) {
|
|
145
|
-
const base = relativePath.split("/").pop() ?? "";
|
|
146
|
-
const dotIndex = base.indexOf(".");
|
|
147
|
-
return dotIndex === -1 ? base : base.substring(0, dotIndex);
|
|
148
|
-
}
|
|
149
140
|
/**
|
|
150
141
|
* Returns the "name" portion of the file without known layer suffixes
|
|
151
142
|
* and without the actual file extension.
|
|
@@ -225,12 +216,25 @@ function toDisplayLabel(name) {
|
|
|
225
216
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
|
226
217
|
}
|
|
227
218
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
228
|
-
/**
|
|
229
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Minimum files a top-level src directory needs before it's promoted.
|
|
221
|
+
*
|
|
222
|
+
* Raised from 3 to 6 after a real-world run produced 71 groups on a
|
|
223
|
+
* Next.js monorepo — every tiny infrastructure directory (heartbeat/,
|
|
224
|
+
* ngrok/, caddy/) was auto-promoted. A higher bar keeps those files
|
|
225
|
+
* flowing through to layer/component passes or "Other".
|
|
226
|
+
*/
|
|
227
|
+
const TOP_LEVEL_PROMOTION_THRESHOLD = 6;
|
|
230
228
|
/** Minimum size of an import-graph connected component to become a group. */
|
|
231
229
|
const MIN_COMPONENT_SIZE = 2;
|
|
232
230
|
/** Components larger than this split along directory boundaries. */
|
|
233
231
|
const MAX_COMPONENT_SIZE = 6;
|
|
232
|
+
/**
|
|
233
|
+
* Maximum number of vertical groups before consolidation kicks in. When
|
|
234
|
+
* exceeded, the smallest non-pinned groups are merged into "Other" until
|
|
235
|
+
* the count drops back under this cap.
|
|
236
|
+
*/
|
|
237
|
+
const MAX_VERTICAL_GROUPS = 20;
|
|
234
238
|
/**
|
|
235
239
|
* Groups discovered files. See module docstring for the priority order.
|
|
236
240
|
*
|
|
@@ -269,7 +273,7 @@ export function groupFiles(files, options = {}) {
|
|
|
269
273
|
addFeature(match.key, toDisplayLabel(match.name), file);
|
|
270
274
|
pinnedKeys.add(match.key);
|
|
271
275
|
}
|
|
272
|
-
// ── Pass 2: src top-level promotion (≥
|
|
276
|
+
// ── Pass 2: src top-level promotion (≥TOP_LEVEL_PROMOTION_THRESHOLD files) ──
|
|
273
277
|
const topLevelCounts = countTopLevelDirs(textFiles);
|
|
274
278
|
for (const file of textFiles) {
|
|
275
279
|
if (assigned.has(file.path))
|
|
@@ -279,6 +283,8 @@ export function groupFiles(files, options = {}) {
|
|
|
279
283
|
continue;
|
|
280
284
|
if (LAYER_DIRECTORIES.has(top.toLowerCase()))
|
|
281
285
|
continue;
|
|
286
|
+
if (!isValidLabelCandidate(top))
|
|
287
|
+
continue;
|
|
282
288
|
const count = topLevelCounts.get(top) ?? 0;
|
|
283
289
|
if (count < TOP_LEVEL_PROMOTION_THRESHOLD)
|
|
284
290
|
continue;
|
|
@@ -291,6 +297,8 @@ export function groupFiles(files, options = {}) {
|
|
|
291
297
|
const feature = extractFeatureName(file.path);
|
|
292
298
|
if (!feature)
|
|
293
299
|
continue;
|
|
300
|
+
if (!isValidLabelCandidate(feature))
|
|
301
|
+
continue;
|
|
294
302
|
addFeature(feature.toLowerCase(), toDisplayLabel(feature), file);
|
|
295
303
|
}
|
|
296
304
|
// Demote single-file feature buckets (unless pinned by a prior).
|
|
@@ -345,12 +353,32 @@ export function groupFiles(files, options = {}) {
|
|
|
345
353
|
}
|
|
346
354
|
// ── Pass 6: catch-all "Other" ──
|
|
347
355
|
const ungrouped = textFiles.filter((f) => !assigned.has(f.path));
|
|
356
|
+
// ── Pass 7: consolidate if too many vertical groups ──
|
|
357
|
+
// Monorepos with many top-level feature dirs produce too many verticals
|
|
358
|
+
// to be useful. Fold the smallest non-pinned ones into "Other" until
|
|
359
|
+
// we're back under the cap. Priors stay pinned regardless.
|
|
360
|
+
// If consolidation runs, "Other" will exist afterward, so we target
|
|
361
|
+
// one fewer bucket to keep the total (buckets + Other) at the cap.
|
|
362
|
+
if (featureBuckets.size > MAX_VERTICAL_GROUPS - 1) {
|
|
363
|
+
const candidates = [...featureBuckets.entries()]
|
|
364
|
+
.filter(([key]) => !pinnedKeys.has(key))
|
|
365
|
+
.sort(([, a], [, b]) => a.length - b.length);
|
|
366
|
+
let excess = featureBuckets.size - (MAX_VERTICAL_GROUPS - 1);
|
|
367
|
+
for (const [key, bucket] of candidates) {
|
|
368
|
+
if (excess <= 0)
|
|
369
|
+
break;
|
|
370
|
+
ungrouped.push(...bucket);
|
|
371
|
+
featureBuckets.delete(key);
|
|
372
|
+
featureLabels.delete(key);
|
|
373
|
+
excess--;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
348
376
|
// ── Build FileGroup objects ──
|
|
349
377
|
const groups = [];
|
|
350
378
|
for (const [key, bucket] of featureBuckets) {
|
|
351
379
|
const label = featureLabels.get(key) ?? toDisplayLabel(key);
|
|
352
380
|
groups.push({
|
|
353
|
-
id: toKebabCase(label)
|
|
381
|
+
id: toKebabCase(label),
|
|
354
382
|
label,
|
|
355
383
|
type: "vertical",
|
|
356
384
|
files: bucket.sort((a, b) => a.path.localeCompare(b.path)),
|
|
@@ -359,6 +387,10 @@ export function groupFiles(files, options = {}) {
|
|
|
359
387
|
}
|
|
360
388
|
for (const [label, bucket] of layerBuckets) {
|
|
361
389
|
groups.push({
|
|
390
|
+
// Layer groups keep the `-layer` suffix because they're a different
|
|
391
|
+
// architectural concept than feature verticals, and without it an
|
|
392
|
+
// "Auth" feature + "Controllers" layer + "Controllers" feature prior
|
|
393
|
+
// could collide on ID.
|
|
362
394
|
id: toKebabCase(label) + "-layer",
|
|
363
395
|
label,
|
|
364
396
|
type: "horizontal",
|
|
@@ -424,25 +456,58 @@ function buildComponentGroups(components, byPath) {
|
|
|
424
456
|
if (files.length < MIN_COMPONENT_SIZE)
|
|
425
457
|
continue;
|
|
426
458
|
if (files.length <= MAX_COMPONENT_SIZE) {
|
|
427
|
-
|
|
459
|
+
const group = componentToGroup(files);
|
|
460
|
+
if (group)
|
|
461
|
+
out.push(group);
|
|
428
462
|
continue;
|
|
429
463
|
}
|
|
430
464
|
for (const sub of bisectByDirectory(files)) {
|
|
431
465
|
if (sub.length < MIN_COMPONENT_SIZE)
|
|
432
466
|
continue;
|
|
433
|
-
|
|
467
|
+
const group = componentToGroup(sub);
|
|
468
|
+
if (group)
|
|
469
|
+
out.push(group);
|
|
434
470
|
}
|
|
435
471
|
}
|
|
436
472
|
return out;
|
|
437
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* Build a vertical group from an import-graph component.
|
|
476
|
+
*
|
|
477
|
+
* Returns `null` when the component has no meaningful shared directory
|
|
478
|
+
* (root-level files) or when the derived basename would produce a
|
|
479
|
+
* degenerate label (leading dot, hash-only, empty). Rejected components
|
|
480
|
+
* fall through to the "Other" bucket rather than inventing a group name
|
|
481
|
+
* from a filename.
|
|
482
|
+
*/
|
|
438
483
|
function componentToGroup(files) {
|
|
439
484
|
const commonDir = commonDirectory(files);
|
|
440
|
-
|
|
485
|
+
if (commonDir === "." || commonDir === "")
|
|
486
|
+
return null;
|
|
487
|
+
const basename = commonDir.split("/").filter((s) => s.length > 0).pop() ?? "";
|
|
488
|
+
if (!isValidLabelCandidate(basename))
|
|
489
|
+
return null;
|
|
441
490
|
const label = toDisplayLabel(basename);
|
|
442
|
-
// Append a stable short hash of paths to avoid collisions with other buckets.
|
|
443
491
|
const key = basename.toLowerCase() + "-cluster";
|
|
444
492
|
return { key, label, files };
|
|
445
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Reject directory/label candidates that would produce junk group IDs:
|
|
496
|
+
* - leading dot (`.next`, `.turbo`, `.vite`) — build-output leaks.
|
|
497
|
+
* - empty string.
|
|
498
|
+
* - hash-like strings (≥12 consecutive lowercase-alnum chars with no
|
|
499
|
+
* vowels or separators) — Next.js dev-cache hash dirs.
|
|
500
|
+
*/
|
|
501
|
+
function isValidLabelCandidate(name) {
|
|
502
|
+
if (!name)
|
|
503
|
+
return false;
|
|
504
|
+
if (name.startsWith("."))
|
|
505
|
+
return false;
|
|
506
|
+
const lower = name.toLowerCase();
|
|
507
|
+
if (/^[a-z0-9]{12,}$/.test(lower) && !/[aeiou]/.test(lower))
|
|
508
|
+
return false;
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
446
511
|
/**
|
|
447
512
|
* Split a large component into sub-groups by directory prefix. Files are
|
|
448
513
|
* bucketed by their first directory segment; singletons collapse back into
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
-
// ──
|
|
23
|
+
// ── Bash command allowlists ─────────────────────────────────────────────
|
|
24
24
|
/**
|
|
25
25
|
* Base commands allowed in read-only bash mode.
|
|
26
26
|
* Mirrors the READ_ONLY_BASH config in OpenCodeProvider.
|
|
@@ -29,6 +29,16 @@ const READ_ONLY_CMDS = new Set([
|
|
|
29
29
|
"cat", "head", "tail", "grep", "find", "ls",
|
|
30
30
|
"wc", "file", "tree", "du",
|
|
31
31
|
]);
|
|
32
|
+
/**
|
|
33
|
+
* Filesystem-setup commands writing agents are allowed to run. Mirrors
|
|
34
|
+
* WRITING_AGENT_BASH in OpenCodeProvider. These are needed so agents can
|
|
35
|
+
* create their per-group output subfolder without falling back to writing
|
|
36
|
+
* throwaway scripts.
|
|
37
|
+
*/
|
|
38
|
+
const WRITING_AGENT_CMDS = new Set([
|
|
39
|
+
...READ_ONLY_CMDS,
|
|
40
|
+
"mkdir", "rmdir", "touch", "echo", "mv", "cp",
|
|
41
|
+
]);
|
|
32
42
|
/**
|
|
33
43
|
* Check whether a shell command is read-only (safe to run without write access).
|
|
34
44
|
* Matches READ_ONLY_BASH from OpenCodeProvider:
|
|
@@ -48,6 +58,28 @@ function isReadOnlyCommand(cmd) {
|
|
|
48
58
|
}
|
|
49
59
|
return false;
|
|
50
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Check whether a shell command is allowed for writing agents. Includes
|
|
63
|
+
* the read-only set plus filesystem-setup commands (mkdir, touch, etc.).
|
|
64
|
+
*/
|
|
65
|
+
function isWritingAgentCommand(cmd) {
|
|
66
|
+
if (isReadOnlyCommand(cmd))
|
|
67
|
+
return true;
|
|
68
|
+
const base = cmd.trim().split(/\s+/)[0] ?? "";
|
|
69
|
+
return WRITING_AGENT_CMDS.has(base);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Writing agents that are allowed to mkdir / touch / mv / cp for
|
|
73
|
+
* scaffolding their per-group output subfolder. Matches the OpenCode
|
|
74
|
+
* `writingAgentPermission` / `orgAgentPermission` list.
|
|
75
|
+
*/
|
|
76
|
+
const WRITING_AGENTS = new Set([
|
|
77
|
+
"hem-doc",
|
|
78
|
+
"hem-arch",
|
|
79
|
+
"hem-index",
|
|
80
|
+
"hem-org",
|
|
81
|
+
"hem-xref",
|
|
82
|
+
]);
|
|
51
83
|
/** Environment variable names checked for GitHub token, in priority order. */
|
|
52
84
|
const TOKEN_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
|
53
85
|
/** Resolve the broadcast MCP server path relative to this compiled module. */
|
|
@@ -76,6 +108,10 @@ function agentAllowsShell(agentName, command) {
|
|
|
76
108
|
// hem-group uses READ_ONLY_BASH too (bash is explicitly read-only, not "deny")
|
|
77
109
|
if (isReadOnlyCommand(command))
|
|
78
110
|
return true;
|
|
111
|
+
// Writing agents (hem-doc / hem-arch / hem-index / hem-org / hem-xref)
|
|
112
|
+
// also need mkdir/touch/mv/cp/echo/rmdir to scaffold their output folder.
|
|
113
|
+
if (WRITING_AGENTS.has(agentName) && isWritingAgentCommand(command))
|
|
114
|
+
return true;
|
|
79
115
|
// hem-org additionally allows rm (mirrors ORG_AGENT_BASH)
|
|
80
116
|
if (agentName === "hem-org" && command.trim().split(/\s+/)[0] === "rm")
|
|
81
117
|
return true;
|
|
@@ -40,14 +40,52 @@ export declare const READ_ONLY_BASH: {
|
|
|
40
40
|
readonly "git diff *": "allow";
|
|
41
41
|
readonly "*": "deny";
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Bash permission set for writing agents (hem-doc, hem-arch, hem-index,
|
|
45
|
+
* hem-xref). Inherits read-only inspection commands and additionally
|
|
46
|
+
* allows the minimum set of filesystem-setup commands agents need to
|
|
47
|
+
* create per-group subfolders and scaffold empty files without resorting
|
|
48
|
+
* to writing throwaway scripts (e.g. a Python `make_dir.py`).
|
|
49
|
+
*
|
|
50
|
+
* Writing agents with only READ_ONLY_BASH got stuck in a loop trying to
|
|
51
|
+
* mkdir their output directory via the Copilot shell tool, being denied,
|
|
52
|
+
* and then fabricating Python to work around it.
|
|
53
|
+
*/
|
|
54
|
+
export declare const WRITING_AGENT_BASH: {
|
|
55
|
+
readonly "mkdir *": "allow";
|
|
56
|
+
readonly "rmdir *": "allow";
|
|
57
|
+
readonly "touch *": "allow";
|
|
58
|
+
readonly "echo *": "allow";
|
|
59
|
+
readonly "mv *": "allow";
|
|
60
|
+
readonly "cp *": "allow";
|
|
61
|
+
readonly "cat *": "allow";
|
|
62
|
+
readonly "head *": "allow";
|
|
63
|
+
readonly "tail *": "allow";
|
|
64
|
+
readonly "grep *": "allow";
|
|
65
|
+
readonly "find *": "allow";
|
|
66
|
+
readonly "ls *": "allow";
|
|
67
|
+
readonly "wc *": "allow";
|
|
68
|
+
readonly "file *": "allow";
|
|
69
|
+
readonly "tree *": "allow";
|
|
70
|
+
readonly "du *": "allow";
|
|
71
|
+
readonly "git status *": "allow";
|
|
72
|
+
readonly "git diff *": "allow";
|
|
73
|
+
readonly "*": "deny";
|
|
74
|
+
};
|
|
43
75
|
/**
|
|
44
76
|
* Extended bash permission set for organization workers. Inherits all
|
|
45
|
-
*
|
|
46
|
-
* `rm` so workers can delete files when executing arbiter DELETE
|
|
47
|
-
* instead of writing empty content as a deletion proxy.
|
|
77
|
+
* writing-agent commands from {@link WRITING_AGENT_BASH} and additionally
|
|
78
|
+
* allows `rm` so workers can delete files when executing arbiter DELETE
|
|
79
|
+
* decisions instead of writing empty content as a deletion proxy.
|
|
48
80
|
*/
|
|
49
81
|
export declare const ORG_AGENT_BASH: {
|
|
50
82
|
readonly "rm *": "allow";
|
|
83
|
+
readonly "mkdir *": "allow";
|
|
84
|
+
readonly "rmdir *": "allow";
|
|
85
|
+
readonly "touch *": "allow";
|
|
86
|
+
readonly "echo *": "allow";
|
|
87
|
+
readonly "mv *": "allow";
|
|
88
|
+
readonly "cp *": "allow";
|
|
51
89
|
readonly "cat *": "allow";
|
|
52
90
|
readonly "head *": "allow";
|
|
53
91
|
readonly "tail *": "allow";
|
|
@@ -43,14 +43,34 @@ export const READ_ONLY_BASH = {
|
|
|
43
43
|
"git diff *": "allow",
|
|
44
44
|
"*": "deny",
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Bash permission set for writing agents (hem-doc, hem-arch, hem-index,
|
|
48
|
+
* hem-xref). Inherits read-only inspection commands and additionally
|
|
49
|
+
* allows the minimum set of filesystem-setup commands agents need to
|
|
50
|
+
* create per-group subfolders and scaffold empty files without resorting
|
|
51
|
+
* to writing throwaway scripts (e.g. a Python `make_dir.py`).
|
|
52
|
+
*
|
|
53
|
+
* Writing agents with only READ_ONLY_BASH got stuck in a loop trying to
|
|
54
|
+
* mkdir their output directory via the Copilot shell tool, being denied,
|
|
55
|
+
* and then fabricating Python to work around it.
|
|
56
|
+
*/
|
|
57
|
+
export const WRITING_AGENT_BASH = {
|
|
58
|
+
...READ_ONLY_BASH,
|
|
59
|
+
"mkdir *": "allow",
|
|
60
|
+
"rmdir *": "allow",
|
|
61
|
+
"touch *": "allow",
|
|
62
|
+
"echo *": "allow",
|
|
63
|
+
"mv *": "allow",
|
|
64
|
+
"cp *": "allow",
|
|
65
|
+
};
|
|
46
66
|
/**
|
|
47
67
|
* Extended bash permission set for organization workers. Inherits all
|
|
48
|
-
*
|
|
49
|
-
* `rm` so workers can delete files when executing arbiter DELETE
|
|
50
|
-
* instead of writing empty content as a deletion proxy.
|
|
68
|
+
* writing-agent commands from {@link WRITING_AGENT_BASH} and additionally
|
|
69
|
+
* allows `rm` so workers can delete files when executing arbiter DELETE
|
|
70
|
+
* decisions instead of writing empty content as a deletion proxy.
|
|
51
71
|
*/
|
|
52
72
|
export const ORG_AGENT_BASH = {
|
|
53
|
-
...
|
|
73
|
+
...WRITING_AGENT_BASH,
|
|
54
74
|
"rm *": "allow",
|
|
55
75
|
};
|
|
56
76
|
// ── OpenCode Provider ───────────────────────────────────────────────────
|
|
@@ -183,10 +203,11 @@ export class OpenCodeProvider {
|
|
|
183
203
|
// modelID may already include a sub-provider prefix (e.g. "github-copilot/opus-4.6")
|
|
184
204
|
// when the user picked an opencode sub-provider model via `hem config`. Use it as-is.
|
|
185
205
|
: modelID.includes("/") ? modelID : `${providerID}/${modelID}`;
|
|
186
|
-
// Writing agents get scoped access to the destination directory
|
|
206
|
+
// Writing agents get scoped access to the destination directory plus
|
|
207
|
+
// the filesystem-setup commands they need to create group subfolders.
|
|
187
208
|
const writingAgentPermission = {
|
|
188
209
|
edit: "allow",
|
|
189
|
-
bash:
|
|
210
|
+
bash: WRITING_AGENT_BASH,
|
|
190
211
|
webfetch: "allow",
|
|
191
212
|
external_directory: "allow",
|
|
192
213
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface FileInfo {
|
|
|
71
71
|
}
|
|
72
72
|
/** A collection of related source files to be documented together. */
|
|
73
73
|
export interface FileGroup {
|
|
74
|
-
/** Unique group identifier (e.g., `user
|
|
74
|
+
/** Unique group identifier (e.g., `user`, `auth`, `controllers-layer`). */
|
|
75
75
|
id: string;
|
|
76
76
|
/** Human-readable group name (e.g., "User Feature", "Controllers"). */
|
|
77
77
|
label: string;
|