@crewhaus/target-research-bundle 0.1.0

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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/target-research-bundle",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Codegen for the RES target — emits a resumable daemon that drives planner → branches → report (Section 23 RES)",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/errors": "0.0.0",
16
+ "@crewhaus/infra-utils": "0.0.0",
17
+ "@crewhaus/ir": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/target-research-bundle"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/target-research-bundle#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { IrResearchV0 } from "@crewhaus/ir";
3
+ import { TargetEmitError, emitResearchBundle } from "./index.js";
4
+
5
+ const baseIr: IrResearchV0 = {
6
+ version: 0,
7
+ name: "hello-research",
8
+ target: "research",
9
+ agent: { model: "claude-haiku-4-5-20251001", instructions: "Be brief." },
10
+ goal: "test goal",
11
+ branchingFactor: 3,
12
+ maxDurationMs: 60_000,
13
+ retrieve: {
14
+ allowedOrigins: ["https://docs.anthropic.com"],
15
+ allowedFileRoots: ["/tmp"],
16
+ },
17
+ tools: [],
18
+ toolConfigs: Object.freeze({}),
19
+ mcp_servers: Object.freeze({}),
20
+ permissions: { rules: [] },
21
+ compaction: {},
22
+ };
23
+
24
+ describe("emitResearchBundle", () => {
25
+ test("emits a single agent.ts file (T1 bundle structure)", () => {
26
+ const bundle = emitResearchBundle(baseIr);
27
+ expect(bundle.files).toHaveLength(1);
28
+ expect(bundle.files[0]?.path).toBe("agent.ts");
29
+ });
30
+
31
+ test("agent.ts wires planner + crawler + citation-tracker + report-writer", () => {
32
+ const bundle = emitResearchBundle(baseIr);
33
+ const code = bundle.files[0]?.content ?? "";
34
+ expect(code).toContain("@crewhaus/planner");
35
+ expect(code).toContain("@crewhaus/crawler");
36
+ expect(code).toContain("@crewhaus/citation-tracker");
37
+ expect(code).toContain("@crewhaus/report-writer");
38
+ expect(code).toContain("createSourceTool");
39
+ expect(code).toContain("createCiteFactTool");
40
+ });
41
+
42
+ test("CLI parser handles --goal / --resume / --branching", () => {
43
+ const code = emitResearchBundle(baseIr).files[0]?.content ?? "";
44
+ expect(code).toContain('"--goal"');
45
+ expect(code).toContain('"--resume"');
46
+ expect(code).toContain('"--branching"');
47
+ });
48
+
49
+ test("emits run_start / branch_start / branch_end / run_done events", () => {
50
+ const code = emitResearchBundle(baseIr).files[0]?.content ?? "";
51
+ expect(code).toContain('"run_start"');
52
+ expect(code).toContain('"branch_start"');
53
+ expect(code).toContain('"branch_end"');
54
+ expect(code).toContain('"run_done"');
55
+ });
56
+
57
+ test("wires alwaysAllow rules for Source + CiteFact at the flag layer", () => {
58
+ const code = emitResearchBundle(baseIr).files[0]?.content ?? "";
59
+ expect(code).toContain('pattern: "Source"');
60
+ expect(code).toContain('pattern: "CiteFact"');
61
+ });
62
+
63
+ test("rejects unknown spec-side tool names at compile time", () => {
64
+ const ir: IrResearchV0 = { ...baseIr, tools: ["nonexistent"] };
65
+ expect(() => emitResearchBundle(ir)).toThrow(TargetEmitError);
66
+ });
67
+
68
+ test("permissions block: passes spec yaml-source rules through, plus the flag-layer Source/CiteFact allowances", () => {
69
+ const ir: IrResearchV0 = {
70
+ ...baseIr,
71
+ permissions: {
72
+ mode: "default",
73
+ rules: [{ type: "alwaysAllow", pattern: "Read" }],
74
+ },
75
+ };
76
+ const code = emitResearchBundle(ir).files[0]?.content ?? "";
77
+ expect(code).toContain('permissionMode: "default"');
78
+ expect(code).toContain('pattern: "Read"');
79
+ expect(code).toContain('pattern: "Source"');
80
+ expect(code).toContain('pattern: "CiteFact"');
81
+ });
82
+
83
+ test("hard-codes ALLOWED_ORIGINS + ALLOWED_FILE_ROOTS so the daemon's crawler is locked to the spec", () => {
84
+ const code = emitResearchBundle(baseIr).files[0]?.content ?? "";
85
+ expect(code).toContain('"https://docs.anthropic.com"');
86
+ expect(code).toContain('"/tmp"');
87
+ });
88
+ });
package/src/index.ts ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Catalog F2 `target-research-bundle` — Section 23 RES.
3
+ *
4
+ * Codegen for the research target. Emits a single self-contained
5
+ * daemon (`agent.ts`) that:
6
+ *
7
+ * 1. Parses CLI args:
8
+ * --goal "<override>" override the spec goal
9
+ * --resume <runId> resume a partially-completed run
10
+ * --branching <n> override branchingFactor
11
+ * 2. Mints a runId (or reuses the resumed one) and opens a citation
12
+ * tracker under `.crewhaus/research/<runId>/`.
13
+ * 3. If the run-state checkpoint doesn't exist (or `--resume` was
14
+ * omitted), calls planner.decompose to produce N sub-questions.
15
+ * Persists the plan + initial state to
16
+ * `.crewhaus/research/<runId>/state.json`.
17
+ * 4. For each not-yet-completed sub-question:
18
+ * - emits `branch_start { branchId, question }` to stdout
19
+ * - runs `runChatLoop({ singleTurn: true, tools: [Source, CiteFact, ...spec.tools] })`
20
+ * - captures the assistant's terminal text as the branch answer
21
+ * - extends `state.completedBranches` and persists the checkpoint
22
+ * - emits `branch_end { branchId, citationCount }`
23
+ * The branch loop respects `maxDurationMs` — exceeding it stops at
24
+ * the next branch boundary and the daemon writes a partial report.
25
+ * 5. After all branches (or budget exit), assembles the final markdown
26
+ * + JSON report via `report-writer.writeReport` and writes them to
27
+ * `report.md` / `report.json` under the run dir. Emits `run_done`.
28
+ */
29
+ import { CrewhausError } from "@crewhaus/errors";
30
+ import { escapeJsonString } from "@crewhaus/infra-utils";
31
+ import type { Bundle, IrResearchV0 } from "@crewhaus/ir";
32
+
33
+ export class TargetEmitError extends CrewhausError {
34
+ override readonly name = "TargetEmitError";
35
+ constructor(message: string, cause?: unknown) {
36
+ super("compiler", message, cause);
37
+ }
38
+ }
39
+
40
+ type BuiltinToolEntry = {
41
+ readonly package: string;
42
+ readonly export: string;
43
+ readonly initSymbol?: string;
44
+ };
45
+
46
+ const BUILTIN_TOOL_MAP: Record<string, BuiltinToolEntry> = {
47
+ read: { package: "@crewhaus/tool-fs", export: "read" },
48
+ glob: { package: "@crewhaus/tool-fs", export: "glob" },
49
+ grep: { package: "@crewhaus/tool-fs", export: "grep" },
50
+ bash: { package: "@crewhaus/tool-bash", export: "bash" },
51
+ todoWrite: { package: "@crewhaus/tool-todo", export: "todoWrite" },
52
+ webFetch: {
53
+ package: "@crewhaus/tool-web",
54
+ export: "webFetch",
55
+ initSymbol: "registerWebFetchConfig",
56
+ },
57
+ webSearch: { package: "@crewhaus/tool-web", export: "webSearch" },
58
+ fetch: {
59
+ package: "@crewhaus/tool-fetch",
60
+ export: "fetch",
61
+ initSymbol: "registerFetchConfig",
62
+ },
63
+ };
64
+
65
+ function resolveTools(
66
+ toolNames: readonly string[],
67
+ toolConfigs: Readonly<Record<string, unknown>>,
68
+ ): {
69
+ imports: string[];
70
+ inits: string[];
71
+ registrations: string[];
72
+ } {
73
+ if (toolNames.length === 0) return { imports: [], inits: [], registrations: [] };
74
+ const byPackage = new Map<string, Set<string>>();
75
+ const inits: string[] = [];
76
+ const registrations: string[] = [];
77
+ for (const name of toolNames) {
78
+ const entry = BUILTIN_TOOL_MAP[name];
79
+ if (!entry) {
80
+ const known = Object.keys(BUILTIN_TOOL_MAP).sort().join(", ");
81
+ throw new TargetEmitError(`unknown tool "${name}" — known tools: ${known}`);
82
+ }
83
+ const set = byPackage.get(entry.package) ?? new Set<string>();
84
+ set.add(entry.export);
85
+ byPackage.set(entry.package, set);
86
+ if (entry.initSymbol !== undefined) {
87
+ const cfg = toolConfigs[name];
88
+ if (cfg !== undefined) {
89
+ set.add(entry.initSymbol);
90
+ inits.push(`${entry.initSymbol}(${JSON.stringify(cfg)});`);
91
+ }
92
+ }
93
+ registrations.push(`defaultCatalog.register(${entry.export});`);
94
+ }
95
+ const imports: string[] = [];
96
+ for (const pkg of [...byPackage.keys()].sort()) {
97
+ const symbols = [...(byPackage.get(pkg) ?? new Set<string>())].sort();
98
+ imports.push(`import { ${symbols.join(", ")} } from "${pkg}";`);
99
+ }
100
+ return { imports, inits, registrations };
101
+ }
102
+
103
+ function renderPermissionsField(ir: IrResearchV0): string {
104
+ // Source/CiteFact MUST be allowed for the daemon to function. We
105
+ // always emit them at the flag layer regardless of what the spec
106
+ // declared. Spec-supplied rules go under `yaml`; spec mode (if any)
107
+ // sets `permissionMode`.
108
+ const { mode, rules } = ir.permissions;
109
+ const lines: string[] = [];
110
+ if (mode !== undefined) {
111
+ lines.push(` permissionMode: ${escapeJsonString(mode)},`);
112
+ }
113
+ const yamlRuleLits =
114
+ rules.length > 0
115
+ ? rules
116
+ .map(
117
+ (r) =>
118
+ ` { type: ${escapeJsonString(r.type)}, pattern: ${escapeJsonString(r.pattern)}, source: "yaml" },`,
119
+ )
120
+ .join("\n")
121
+ : "";
122
+ lines.push(
123
+ [
124
+ " permissionRules: {",
125
+ " flag: [",
126
+ ' { type: "alwaysAllow", pattern: "Source", source: "flag" },',
127
+ ' { type: "alwaysAllow", pattern: "CiteFact", source: "flag" },',
128
+ " ],",
129
+ " settings: [],",
130
+ yamlRuleLits.length > 0
131
+ ? ` yaml: [\n${yamlRuleLits}\n ],`
132
+ : " yaml: [],",
133
+ " hooks: [],",
134
+ " builtin: BUILTIN_DEFAULT_RULES,",
135
+ " },",
136
+ ].join("\n"),
137
+ );
138
+ return `\n${lines.join("\n")}`;
139
+ }
140
+
141
+ export function emitResearchBundle(ir: IrResearchV0): Bundle {
142
+ return { files: [{ path: "agent.ts", content: renderAgent(ir) }] };
143
+ }
144
+
145
+ function renderAgent(ir: IrResearchV0): string {
146
+ const { imports: builtinImports, inits, registrations } = resolveTools(ir.tools, ir.toolConfigs);
147
+ const importBlock = builtinImports.length > 0 ? `${builtinImports.join("\n")}\n` : "";
148
+ const initLines = inits.length > 0 ? `${inits.join("\n")}\n` : "";
149
+ const registrationBlock =
150
+ registrations.length > 0
151
+ ? `\n// Spec-supplied tools registered at module load.\n${registrations.join("\n")}\n`
152
+ : "";
153
+ const permField = renderPermissionsField(ir);
154
+ return `#!/usr/bin/env bun
155
+ // Generated by crewhaus. DO NOT EDIT.
156
+ // Source spec: ${ir.name} (target: research, ir version: ${ir.version})
157
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
158
+ import { join, resolve as resolvePath } from "node:path";
159
+ import { createCitationTracker, newRunId } from "@crewhaus/citation-tracker";
160
+ import { createCrawler, createSourceTool, createCiteFactTool } from "@crewhaus/crawler";
161
+ import { decompose, type Plan } from "@crewhaus/planner";
162
+ import { writeReport, type BranchAnswer } from "@crewhaus/report-writer";
163
+ import { runChatLoop } from "@crewhaus/runtime-core";
164
+ import { createRunContext } from "@crewhaus/run-context";
165
+ import { BUILTIN_DEFAULT_RULES } from "@crewhaus/permission-engine";
166
+ import { defaultCatalog } from "@crewhaus/tool-catalog";
167
+ ${importBlock}
168
+ ${initLines}${registrationBlock}
169
+ type ResearchState = {
170
+ readonly version: 1;
171
+ readonly runId: string;
172
+ readonly goal: string;
173
+ readonly branchingFactor: number;
174
+ plan: Plan;
175
+ completedBranches: BranchAnswer[];
176
+ status: "in_progress" | "done";
177
+ };
178
+
179
+ const SPEC_GOAL = ${escapeJsonString(ir.goal)};
180
+ const SPEC_BRANCHING = ${ir.branchingFactor};
181
+ const SPEC_MAX_DURATION_MS = ${ir.maxDurationMs};
182
+ const SPEC_MODEL = ${escapeJsonString(ir.agent.model)};
183
+ const SPEC_INSTRUCTIONS = ${escapeJsonString(ir.agent.instructions)};
184
+ const ALLOWED_ORIGINS = ${JSON.stringify(ir.retrieve.allowedOrigins)};
185
+ const ALLOWED_FILE_ROOTS = ${JSON.stringify(ir.retrieve.allowedFileRoots)};
186
+ const RESEARCH_ROOT = ".crewhaus/research";
187
+
188
+ function parseArgs(): { goal: string; resumeRunId: string | undefined; branchingFactor: number } {
189
+ const args = process.argv.slice(2);
190
+ let goal = SPEC_GOAL;
191
+ let resumeRunId: string | undefined;
192
+ let branchingFactor = SPEC_BRANCHING;
193
+ for (let i = 0; i < args.length; i++) {
194
+ const a = args[i];
195
+ if (a === "--goal" && i + 1 < args.length) {
196
+ goal = String(args[++i]);
197
+ } else if (a === "--resume" && i + 1 < args.length) {
198
+ resumeRunId = String(args[++i]);
199
+ } else if (a === "--branching" && i + 1 < args.length) {
200
+ branchingFactor = Number(args[++i]);
201
+ }
202
+ }
203
+ return { goal, resumeRunId, branchingFactor };
204
+ }
205
+
206
+ function statePath(runId: string): string {
207
+ return join(RESEARCH_ROOT, runId, "state.json");
208
+ }
209
+
210
+ function loadState(runId: string): ResearchState | undefined {
211
+ const p = statePath(runId);
212
+ if (!existsSync(p)) return undefined;
213
+ return JSON.parse(readFileSync(p, "utf8")) as ResearchState;
214
+ }
215
+
216
+ function saveState(state: ResearchState): void {
217
+ const p = statePath(state.runId);
218
+ mkdirSync(join(RESEARCH_ROOT, state.runId), { recursive: true });
219
+ writeFileSync(p, JSON.stringify(state, null, 2), { mode: 0o600 });
220
+ }
221
+
222
+ function emit(event: Record<string, unknown>): void {
223
+ process.stdout.write(\`\${JSON.stringify(event)}\\n\`);
224
+ }
225
+
226
+ function listFileSources(roots: ReadonlyArray<string>, maxFiles = 50): string[] {
227
+ const out: string[] = [];
228
+ for (const root of roots) {
229
+ if (!existsSync(root)) continue;
230
+ const stack: string[] = [root];
231
+ while (stack.length > 0 && out.length < maxFiles) {
232
+ const dir = stack.pop();
233
+ if (dir === undefined) break;
234
+ let entries: string[];
235
+ try {
236
+ entries = readdirSync(dir);
237
+ } catch {
238
+ continue;
239
+ }
240
+ for (const name of entries.sort()) {
241
+ const abs = join(dir, name);
242
+ let st: ReturnType<typeof statSync>;
243
+ try {
244
+ st = statSync(abs);
245
+ } catch {
246
+ continue;
247
+ }
248
+ if (st.isDirectory()) stack.push(abs);
249
+ else if (st.isFile()) out.push("file://" + abs);
250
+ if (out.length >= maxFiles) break;
251
+ }
252
+ }
253
+ }
254
+ return out.sort();
255
+ }
256
+
257
+ async function runOneBranch(args: {
258
+ branchId: string;
259
+ question: string;
260
+ goal: string;
261
+ availableSources: ReadonlyArray<string>;
262
+ tracker: ReturnType<typeof createCitationTracker>;
263
+ crawler: ReturnType<typeof createCrawler>;
264
+ }): Promise<BranchAnswer> {
265
+ let currentBranch: string | undefined = args.branchId;
266
+ const sourceTool = createSourceTool({
267
+ crawler: args.crawler,
268
+ currentBranchId: () => currentBranch,
269
+ });
270
+ const citeFactTool = createCiteFactTool({
271
+ tracker: args.tracker,
272
+ currentBranchId: () => currentBranch,
273
+ });
274
+ const tools = [sourceTool, citeFactTool, ...defaultCatalog.list()];
275
+
276
+ const beforeUrls = new Set(args.tracker.listFetches().map((f) => f.url));
277
+ const sourcesBlock =
278
+ args.availableSources.length > 0
279
+ ? "\\n\\nAvailable sources (load each via Source(uri); subsequent calls cache):\\n" +
280
+ args.availableSources.map((u) => " - " + u).join("\\n")
281
+ : "";
282
+ const seedContent =
283
+ "Research goal: " +
284
+ args.goal +
285
+ "\\n\\nFocus on: " +
286
+ args.question +
287
+ sourcesBlock;
288
+
289
+ const runContext = createRunContext();
290
+ const finalText = await runChatLoop({
291
+ model: SPEC_MODEL,
292
+ instructions: SPEC_INSTRUCTIONS,
293
+ runContext,
294
+ sessionName: ${escapeJsonString(ir.name)},
295
+ sessionTarget: "research",
296
+ singleTurn: true,
297
+ seedMessages: [{ role: "user", content: seedContent }],
298
+ tools,
299
+ installSigintHandler: false,
300
+ maxTokens: 4096,${permField}
301
+ });
302
+
303
+ // Citations recorded for this branch are append-only, so:
304
+ // citationUrls = (urls present after - urls present before)
305
+ // is the set the model fetched on this branch. Same-URL cited
306
+ // multiple times within the branch is collapsed by the report-writer
307
+ // when numbering.
308
+ const afterUrls = args.tracker.listFetches().map((f) => f.url);
309
+ const newUrls = afterUrls.filter((u) => !beforeUrls.has(u));
310
+ currentBranch = undefined;
311
+ return {
312
+ question: args.question,
313
+ answer: finalText.trim(),
314
+ citationUrls: newUrls,
315
+ };
316
+ }
317
+
318
+ async function main(): Promise<void> {
319
+ const { goal, resumeRunId, branchingFactor } = parseArgs();
320
+
321
+ let state: ResearchState | undefined;
322
+ let runId: string;
323
+ if (resumeRunId !== undefined) {
324
+ state = loadState(resumeRunId);
325
+ if (state === undefined) {
326
+ process.stderr.write(\`[research] no state found for runId \${resumeRunId}\\n\`);
327
+ process.exit(2);
328
+ }
329
+ runId = resumeRunId;
330
+ emit({ kind: "resume", runId, completedBranches: state.completedBranches.length });
331
+ } else {
332
+ runId = newRunId();
333
+ emit({ kind: "run_start", runId, goal });
334
+ }
335
+
336
+ const tracker = createCitationTracker({ runId, rootDir: RESEARCH_ROOT });
337
+
338
+ const allowedFileRootsAbs = ALLOWED_FILE_ROOTS.map((p) => resolvePath(p));
339
+ const crawler = createCrawler({
340
+ tracker,
341
+ config: {
342
+ allowedOrigins: new Set(ALLOWED_ORIGINS),
343
+ allowedFileRoots: allowedFileRootsAbs,
344
+ },
345
+ });
346
+
347
+ // Plan ----------------------------------------------------------------
348
+ let plan: Plan;
349
+ if (state !== undefined) {
350
+ plan = state.plan;
351
+ emit({ kind: "plan_loaded", subQuestions: plan.subQuestions });
352
+ } else {
353
+ emit({ kind: "plan_start", branchingFactor });
354
+ plan = await decompose(goal, { model: SPEC_MODEL, branchingFactor });
355
+ state = {
356
+ version: 1,
357
+ runId,
358
+ goal,
359
+ branchingFactor,
360
+ plan,
361
+ completedBranches: [],
362
+ status: "in_progress",
363
+ };
364
+ saveState(state);
365
+ emit({ kind: "plan_done", subQuestions: plan.subQuestions });
366
+ }
367
+
368
+ // Resolve available file sources ONCE — passed into every branch.
369
+ const availableSources = listFileSources(allowedFileRootsAbs);
370
+ if (availableSources.length > 0) {
371
+ emit({ kind: "sources_resolved", count: availableSources.length });
372
+ }
373
+
374
+ // Branches ------------------------------------------------------------
375
+ const startMs = Date.now();
376
+ for (let i = state.completedBranches.length; i < plan.subQuestions.length; i++) {
377
+ if (Date.now() - startMs > SPEC_MAX_DURATION_MS) {
378
+ emit({ kind: "budget_exceeded", elapsedMs: Date.now() - startMs });
379
+ break;
380
+ }
381
+ const branchId = "b" + i;
382
+ const question = plan.subQuestions[i];
383
+ if (question === undefined) continue;
384
+ emit({ kind: "branch_start", branchId, question });
385
+ const answer = await runOneBranch({
386
+ branchId,
387
+ question,
388
+ goal,
389
+ availableSources,
390
+ tracker,
391
+ crawler,
392
+ });
393
+ state.completedBranches = [...state.completedBranches, answer];
394
+ saveState(state);
395
+ emit({
396
+ kind: "branch_end",
397
+ branchId,
398
+ citationCount: answer.citationUrls.length,
399
+ });
400
+ }
401
+
402
+ // Report --------------------------------------------------------------
403
+ state.status = state.completedBranches.length === plan.subQuestions.length ? "done" : "in_progress";
404
+ saveState(state);
405
+
406
+ const report = writeReport({
407
+ goal,
408
+ branches: state.completedBranches,
409
+ citations: tracker.listCitationsOrdered(),
410
+ });
411
+ const reportDir = join(RESEARCH_ROOT, runId);
412
+ writeFileSync(join(reportDir, "report.md"), report.markdown, { mode: 0o600 });
413
+ writeFileSync(join(reportDir, "report.json"), JSON.stringify(report.json, null, 2), { mode: 0o600 });
414
+ emit({ kind: "run_done", runId, reportPath: join(reportDir, "report.md"), citations: report.json.citations.length });
415
+ }
416
+
417
+ main().catch((err) => {
418
+ process.stderr.write(\`[research] fatal: \${(err as Error).message}\\n\`);
419
+ process.exit(1);
420
+ });
421
+ `;
422
+ }