@evo-dev/core 0.0.1-alpha → 0.0.1-alpha.2

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 (41) hide show
  1. package/assets/agents/review/code-reviewer/examples.md +1 -1
  2. package/assets/agents/review/code-reviewer/prompt.md +1 -1
  3. package/assets/agents/review/code-reviewer/verification.md +1 -1
  4. package/assets/skills/coding/knowledge-distillation/SKILL.md +249 -0
  5. package/assets/skills/coding/knowledge-distillation/manifest.json +10 -0
  6. package/assets/skills/coding/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
  7. package/assets/workflows/rd-bug-fix/WORKFLOW.json +1 -1
  8. package/assets/workflows/rd-code-review/WORKFLOW.json +1 -1
  9. package/assets/workflows/rd-docs-update/WORKFLOW.json +1 -1
  10. package/assets/workflows/rd-feature-implementation/WORKFLOW.json +1 -1
  11. package/assets/workflows/rd-refactor/WORKFLOW.json +1 -1
  12. package/assets/workflows/rd-release-readiness/WORKFLOW.json +1 -1
  13. package/assets/workflows/rd-security-boundary-review/WORKFLOW.json +2 -2
  14. package/assets/workflows/rd-test-generation/WORKFLOW.json +1 -1
  15. package/dist/config/index.js +968 -39
  16. package/dist/index.js +10914 -1476
  17. package/dist/plugins/index.js +32 -32
  18. package/package.json +5 -1
  19. package/src/agents/index.ts +84 -49
  20. package/src/code-agent-traces/index.ts +521 -0
  21. package/src/config/index.ts +5 -0
  22. package/src/config/paths.ts +30 -0
  23. package/src/config/settings.ts +130 -0
  24. package/src/config/store.ts +152 -0
  25. package/src/daemon/index.ts +465 -3
  26. package/src/evolution/index.ts +2827 -0
  27. package/src/hooks/index.ts +543 -247
  28. package/src/index.ts +6 -0
  29. package/src/knowledge/index.ts +4784 -0
  30. package/src/pack/index.ts +13 -13
  31. package/src/plugins/capabilities.ts +40 -42
  32. package/src/plugins/index.ts +0 -1
  33. package/src/plugins/types.ts +4 -0
  34. package/src/protected-zones/index.ts +29 -11
  35. package/src/runtime-logs/index.ts +798 -0
  36. package/src/sync/orchestrator.ts +6 -0
  37. package/src/task/index.ts +3 -3
  38. package/src/team/index.ts +3069 -0
  39. package/src/team/mcp.ts +405 -0
  40. package/src/team/prompts.ts +141 -0
  41. package/src/workflow/index.ts +6 -6
@@ -2,7 +2,24 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { type IncomingMessage, createServer } from "node:http";
4
4
  import { dirname, join } from "node:path";
5
+ import { listEvolutionTriggers, processEvolutionTriggers } from "../evolution/index.ts";
5
6
  import { listObservabilityEvents } from "../observability/index.ts";
7
+ import {
8
+ type TeamRuntimeAdapter,
9
+ isActiveTeamAgentStatus,
10
+ isIdleTeamAgentStatus,
11
+ isMidTurnTeamAgentStatus,
12
+ listTeamRuns,
13
+ markTeamMessagesDelivered,
14
+ readPendingTeamMessagesForRole,
15
+ reconcileTeamRun,
16
+ resumeTeamRun,
17
+ schedulePendingTeamMessageDelivery,
18
+ sendTeamMessage,
19
+ spawnTeamRole,
20
+ stopTeamRole,
21
+ updateTeamAgentHookState,
22
+ } from "../team/index.ts";
6
23
 
7
24
  export interface DaemonPaths {
8
25
  rootDir: string;
@@ -43,6 +60,15 @@ export interface DaemonRequestInput {
43
60
  token?: string | null;
44
61
  homeDir: string;
45
62
  origin?: string | null;
63
+ body?: unknown;
64
+ runtimeAdapter?: TeamRuntimeAdapter;
65
+ }
66
+
67
+ interface DaemonTeamStatusSummary {
68
+ run: unknown;
69
+ agents: unknown[];
70
+ stoppedAgents: string[];
71
+ notifications: unknown[];
46
72
  }
47
73
 
48
74
  const DEFAULT_PORT = 37645;
@@ -163,6 +189,66 @@ export async function handleDaemonRequest(
163
189
  const removed = await cleanupDaemonState(input.homeDir, input.token ?? "");
164
190
  return ok({ stopped: true, removed }, warnings);
165
191
  }
192
+ if (
193
+ (input.path === "/team/message/enqueue" || input.path === "/teams/send") &&
194
+ input.method === "POST"
195
+ ) {
196
+ return dashboardMutation(
197
+ await sendDashboardTeamMessage(input.homeDir, input.body, input.runtimeAdapter),
198
+ warnings,
199
+ );
200
+ }
201
+ if (input.path === "/team/agent/state" && input.method === "POST") {
202
+ return dashboardMutation(
203
+ await updateDashboardTeamAgentState(input.homeDir, input.body),
204
+ warnings,
205
+ );
206
+ }
207
+ if (input.path === "/team/message/claim" && input.method === "POST") {
208
+ return ok(await claimDashboardTeamMessages(input.homeDir, input.body), warnings);
209
+ }
210
+ if (input.path === "/team/message/delivered" && input.method === "POST") {
211
+ return dashboardMutation(
212
+ await markDashboardTeamMessagesDelivered(input.homeDir, input.body),
213
+ warnings,
214
+ );
215
+ }
216
+ if (
217
+ (input.path === "/team/reconcile" || input.path === "/teams/reconcile") &&
218
+ input.method === "POST"
219
+ ) {
220
+ await schedulePendingTeamMessageDelivery({
221
+ homeDir: input.homeDir,
222
+ runtimeAdapter: input.runtimeAdapter,
223
+ }).catch((error) =>
224
+ warnings.push(`Team delivery scheduling unavailable: ${describeError(error)}`),
225
+ );
226
+ return ok(await collectTeamStatus(input.homeDir, warnings, input.runtimeAdapter), warnings);
227
+ }
228
+ if (input.path === "/teams/spawn" && input.method === "POST") {
229
+ return dashboardMutation(
230
+ await spawnDashboardTeamRole(input.homeDir, input.body, input.runtimeAdapter),
231
+ warnings,
232
+ );
233
+ }
234
+ if (input.path === "/teams/stop-role" && input.method === "POST") {
235
+ return dashboardMutation(
236
+ await stopDashboardTeamRole(input.homeDir, input.body, input.runtimeAdapter),
237
+ warnings,
238
+ );
239
+ }
240
+ if (input.path === "/teams/resume" && input.method === "POST") {
241
+ return dashboardMutation(
242
+ await resumeDashboardTeamRun(input.homeDir, input.body, input.runtimeAdapter),
243
+ warnings,
244
+ );
245
+ }
246
+ if (input.path === "/evolution/process" && input.method === "POST") {
247
+ return dashboardMutation(
248
+ await processDashboardEvolutionTriggers(input.homeDir, input.body),
249
+ warnings,
250
+ );
251
+ }
166
252
  if (input.method !== "GET") return notFound(warnings);
167
253
 
168
254
  if (input.path === "/tasks")
@@ -181,8 +267,16 @@ export async function handleDaemonRequest(
181
267
  await collectDirectorySummaries(join(input.homeDir, ".evodev", "PACKS"), warnings),
182
268
  warnings,
183
269
  );
184
- if (input.path === "/runs") return ok([], warnings);
185
- if (input.path === "/agents") return ok([], warnings);
270
+ if (input.path === "/runs") return ok(await collectTeamRuns(input.homeDir, warnings), warnings);
271
+ if (input.path === "/evolution/triggers")
272
+ return ok(await collectEvolutionTriggers(input.homeDir, warnings), warnings);
273
+ if (input.path === "/agents")
274
+ return ok(
275
+ (await collectTeamStatus(input.homeDir, warnings, input.runtimeAdapter)).agents,
276
+ warnings,
277
+ );
278
+ if (input.path === "/teams/status")
279
+ return ok(await collectTeamStatus(input.homeDir, warnings, input.runtimeAdapter), warnings);
186
280
 
187
281
  return notFound(warnings);
188
282
  }
@@ -211,6 +305,7 @@ export async function runDaemonForeground(input: {
211
305
  url.searchParams.get("token"),
212
306
  homeDir: input.homeDir,
213
307
  origin: readHeader(request, "origin"),
308
+ body: await readJsonBody(request),
214
309
  });
215
310
 
216
311
  response.writeHead(result.status, { "content-type": "application/json" });
@@ -248,8 +343,22 @@ export async function runDaemonForeground(input: {
248
343
  input.write?.(
249
344
  `Daemon listening on ${state.lock.host}:${state.lock.port}; token path: ${state.paths.tokenPath}`,
250
345
  );
346
+ const reconcileInterval = setInterval(() => {
347
+ void reconcileTeamRun({ homeDir: input.homeDir }).catch(() => undefined);
348
+ }, 2_000);
349
+ reconcileInterval.unref();
350
+ const evolutionInterval = setInterval(() => {
351
+ void processEvolutionTriggers({ homeDir: input.homeDir, limit: 20 })
352
+ .then(() => clearDaemonEvolutionProcessError(input.homeDir))
353
+ .catch((error) => recordDaemonEvolutionProcessError(input.homeDir, error));
354
+ }, 5_000);
355
+ evolutionInterval.unref();
251
356
  await new Promise<void>((resolve, reject) => {
252
- server.once("close", resolve);
357
+ server.once("close", () => {
358
+ clearInterval(reconcileInterval);
359
+ clearInterval(evolutionInterval);
360
+ resolve();
361
+ });
253
362
  server.once("error", reject);
254
363
  });
255
364
  }
@@ -264,6 +373,22 @@ function createDaemonToken(): string {
264
373
  return randomBytes(32).toString("hex");
265
374
  }
266
375
 
376
+ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
377
+ if (request.method === "GET" || request.method === "HEAD") return undefined;
378
+ const chunks: Buffer[] = [];
379
+ let totalBytes = 0;
380
+ for await (const chunk of request) {
381
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
382
+ totalBytes += buffer.length;
383
+ if (totalBytes > 64 * 1024) throw new Error("Request body too large.");
384
+ chunks.push(buffer);
385
+ }
386
+ if (chunks.length === 0) return undefined;
387
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
388
+ if (raw === "") return undefined;
389
+ return JSON.parse(raw);
390
+ }
391
+
267
392
  async function authorize(homeDir: string, token: string | null): Promise<{ ok: boolean }> {
268
393
  const expected = await readDaemonToken(homeDir);
269
394
  return { ok: expected !== null && token !== null && token === expected };
@@ -352,6 +477,258 @@ async function collectLearningCandidateSummaries(
352
477
  });
353
478
  }
354
479
 
480
+ async function collectEvolutionTriggers(homeDir: string, warnings: string[]): Promise<unknown[]> {
481
+ const lastError = await readDaemonEvolutionProcessError(homeDir).catch(() => null);
482
+ if (lastError !== null) warnings.push(`Evolution processor last error: ${lastError}`);
483
+ try {
484
+ return (await listEvolutionTriggers({ homeDir })).map((trigger) =>
485
+ sanitizeMetadata({
486
+ id: trigger.id,
487
+ projectKey: trigger.projectKey,
488
+ runId: trigger.runId,
489
+ roleId: trigger.roleId,
490
+ taskId: trigger.taskId,
491
+ eventType: trigger.eventType,
492
+ triggerStrength: trigger.triggerStrength,
493
+ triggerReason: trigger.triggerReason,
494
+ status: trigger.status,
495
+ attempts: trigger.attempts,
496
+ createdAt: trigger.createdAt,
497
+ updatedAt: trigger.updatedAt,
498
+ }),
499
+ );
500
+ } catch (error) {
501
+ warnings.push(`Evolution triggers unavailable: ${describeError(error)}`);
502
+ return [];
503
+ }
504
+ }
505
+
506
+ async function processDashboardEvolutionTriggers(homeDir: string, body: unknown): Promise<unknown> {
507
+ if (body === undefined) {
508
+ return {
509
+ ok: false,
510
+ error: "evolution process requires JSON body with dryRun=true or confirm=true.",
511
+ };
512
+ }
513
+ const input = expectRequestBody(body);
514
+ const dryRun = optionalBodyBoolean(input.dryRun) === true;
515
+ const confirm = optionalBodyBoolean(input.confirm) === true;
516
+ if (!dryRun && !confirm) {
517
+ return {
518
+ ok: false,
519
+ error: "evolution process requires dryRun=true or confirm=true.",
520
+ };
521
+ }
522
+ return processEvolutionTriggers({
523
+ homeDir,
524
+ projectKey: optionalBodyString(input.projectKey),
525
+ runId: optionalBodyString(input.runId),
526
+ limit: optionalBodyNumber(input.limit),
527
+ dryRun,
528
+ });
529
+ }
530
+
531
+ async function collectTeamRuns(homeDir: string, warnings: string[]): Promise<unknown[]> {
532
+ try {
533
+ return (await listTeamRuns({ homeDir })).map((run) =>
534
+ sanitizeMetadata({
535
+ runId: run.runId,
536
+ status: run.status,
537
+ repoRoot: run.repoRoot,
538
+ mainAgentId: run.mainAgentId,
539
+ session: run.tmux.session,
540
+ roleCount: Object.keys(run.roles).length,
541
+ createdAt: run.createdAt,
542
+ updatedAt: run.updatedAt,
543
+ }),
544
+ );
545
+ } catch (error) {
546
+ warnings.push(`Team runs unavailable: ${describeError(error)}`);
547
+ return [];
548
+ }
549
+ }
550
+
551
+ async function collectTeamStatus(
552
+ homeDir: string,
553
+ warnings: string[],
554
+ runtimeAdapter?: TeamRuntimeAdapter,
555
+ ): Promise<DaemonTeamStatusSummary> {
556
+ try {
557
+ const status = await reconcileTeamRun({ homeDir, runtimeAdapter });
558
+ return sanitizeMetadata({
559
+ run:
560
+ status.run === null
561
+ ? null
562
+ : {
563
+ runId: status.run.runId,
564
+ status: status.run.status,
565
+ repoRoot: status.run.repoRoot,
566
+ session: status.run.tmux.session,
567
+ updatedAt: status.run.updatedAt,
568
+ },
569
+ agents: status.agents.map((agent) => ({
570
+ roleId: agent.roleId,
571
+ roleName: agent.roleName,
572
+ runId: agent.runId,
573
+ runtime: agent.runtime,
574
+ model: agent.model,
575
+ thinkingLevel: agent.thinkingLevel,
576
+ status: agent.status,
577
+ paneId: agent.tmux.paneId,
578
+ canReceiveMessages: isActiveTeamAgentStatus(agent.status),
579
+ isIdle: isIdleTeamAgentStatus(agent.status),
580
+ isMidTurn: isMidTurnTeamAgentStatus(agent.status),
581
+ nativeSessionRecorded: agent.nativeSession.sessionId !== null,
582
+ updatedAt: agent.updatedAt,
583
+ })),
584
+ stoppedAgents: status.stoppedAgents.map((agent) => agent.roleId),
585
+ notifications: status.notifications.map((notification) => ({
586
+ ok: notification.ok,
587
+ error: notification.error ?? null,
588
+ delivery: notification.delivery ?? null,
589
+ queuedFor: notification.queuedFor ?? null,
590
+ })),
591
+ }) as DaemonTeamStatusSummary;
592
+ } catch (error) {
593
+ warnings.push(`Team status unavailable: ${describeError(error)}`);
594
+ return { run: null, agents: [], stoppedAgents: [], notifications: [] };
595
+ }
596
+ }
597
+
598
+ async function sendDashboardTeamMessage(
599
+ homeDir: string,
600
+ body: unknown,
601
+ runtimeAdapter?: TeamRuntimeAdapter,
602
+ ): Promise<unknown> {
603
+ const input = expectRequestBody(body);
604
+ return sendTeamMessage({
605
+ homeDir,
606
+ runId: optionalBodyString(input.runId),
607
+ fromRoleId: "user",
608
+ toRoleId: expectBodyString(input, "toRoleId"),
609
+ message: expectBodyString(input, "message"),
610
+ runtimeAdapter,
611
+ });
612
+ }
613
+
614
+ async function updateDashboardTeamAgentState(homeDir: string, body: unknown): Promise<unknown> {
615
+ const input = expectRequestBody(body);
616
+ const result = await updateTeamAgentHookState({
617
+ homeDir,
618
+ runId: expectBodyString(input, "runId"),
619
+ roleId: expectBodyString(input, "roleId"),
620
+ hookEvent: expectBodyString(input, "hookEvent"),
621
+ });
622
+ return {
623
+ ok: true,
624
+ roleId: result.agent.roleId,
625
+ status: result.agent.status,
626
+ statusPath: result.statusPath,
627
+ };
628
+ }
629
+
630
+ async function claimDashboardTeamMessages(homeDir: string, body: unknown): Promise<unknown> {
631
+ const input = expectRequestBody(body);
632
+ const runId = expectBodyString(input, "runId");
633
+ const roleId = expectBodyString(input, "roleId");
634
+ const messages = await readPendingTeamMessagesForRole({
635
+ homeDir,
636
+ runId,
637
+ roleId,
638
+ limit: optionalBodyNumber(input.limit),
639
+ });
640
+ return {
641
+ runId,
642
+ roleId,
643
+ messages,
644
+ };
645
+ }
646
+
647
+ async function markDashboardTeamMessagesDelivered(
648
+ homeDir: string,
649
+ body: unknown,
650
+ ): Promise<unknown> {
651
+ const input = expectRequestBody(body);
652
+ const messageIdsValue = input.messageIds;
653
+ if (!Array.isArray(messageIdsValue)) throw new Error("Expected body.messageIds array.");
654
+ const messageIds = messageIdsValue.map((value) => {
655
+ if (typeof value !== "string" || value.length === 0) {
656
+ throw new Error("Expected body.messageIds to contain non-empty strings.");
657
+ }
658
+ return value;
659
+ });
660
+ await markTeamMessagesDelivered({
661
+ homeDir,
662
+ runId: expectBodyString(input, "runId"),
663
+ roleId: expectBodyString(input, "roleId"),
664
+ messageIds,
665
+ });
666
+ return { ok: true, delivered: messageIds };
667
+ }
668
+
669
+ async function spawnDashboardTeamRole(
670
+ homeDir: string,
671
+ body: unknown,
672
+ runtimeAdapter?: TeamRuntimeAdapter,
673
+ ): Promise<unknown> {
674
+ const input = expectRequestBody(body);
675
+ const roleId = expectBodyString(input, "roleId");
676
+ const status = await reconcileTeamRun({
677
+ homeDir,
678
+ runId: optionalBodyString(input.runId),
679
+ runtimeAdapter,
680
+ notifyMain: false,
681
+ });
682
+ if (status.run === null) return { ok: false, error: "team-run-not-found" };
683
+ if (status.run.status !== "running") return { ok: false, error: "team-run-not-running" };
684
+ const result = await spawnTeamRole({
685
+ homeDir,
686
+ repoRoot: status.run.repoRoot,
687
+ runId: status.run.runId,
688
+ roleId,
689
+ runtimeAdapter,
690
+ });
691
+ return { ok: true, created: result.created, agent: result.agent };
692
+ }
693
+
694
+ async function stopDashboardTeamRole(
695
+ homeDir: string,
696
+ body: unknown,
697
+ runtimeAdapter?: TeamRuntimeAdapter,
698
+ ): Promise<unknown> {
699
+ const input = expectRequestBody(body);
700
+ return stopTeamRole({
701
+ homeDir,
702
+ runId: optionalBodyString(input.runId),
703
+ roleId: expectBodyString(input, "roleId"),
704
+ runtimeAdapter,
705
+ });
706
+ }
707
+
708
+ async function resumeDashboardTeamRun(
709
+ homeDir: string,
710
+ body: unknown,
711
+ runtimeAdapter?: TeamRuntimeAdapter,
712
+ ): Promise<unknown> {
713
+ const input = expectRequestBody(body);
714
+ const decision = optionalBodyString(input.onSessionMissing);
715
+ if (decision !== undefined && !["ask", "fail", "recreate", "resume-latest"].includes(decision)) {
716
+ return { ok: false, error: "invalid-on-session-missing" };
717
+ }
718
+ const result = await resumeTeamRun({
719
+ homeDir,
720
+ runId: optionalBodyString(input.runId),
721
+ runtimeAdapter,
722
+ missingSessionDecision: decision as "ask" | "fail" | "recreate" | "resume-latest" | undefined,
723
+ });
724
+ return {
725
+ ok: result.decisionRequired.length === 0,
726
+ run: result.run,
727
+ outcomes: result.outcomes,
728
+ decisionRequired: result.decisionRequired,
729
+ };
730
+ }
731
+
355
732
  async function collectDirectorySummaries(root: string, warnings: string[]): Promise<unknown[]> {
356
733
  if (!(await pathExists(root))) {
357
734
  warnings.push(`Store not found: ${root}`);
@@ -379,6 +756,91 @@ function sanitizeMetadata(value: unknown): unknown {
379
756
  return output;
380
757
  }
381
758
 
759
+ function dashboardMutation(
760
+ data: unknown,
761
+ warnings: string[],
762
+ ): { status: number; body: DaemonResponseBody } {
763
+ const okResult = !(
764
+ typeof data === "object" &&
765
+ data !== null &&
766
+ "ok" in data &&
767
+ (data as { ok?: unknown }).ok === false
768
+ );
769
+ return {
770
+ status: okResult ? 200 : 400,
771
+ body: { ok: okResult, data: sanitizeMetadata(data), warnings },
772
+ };
773
+ }
774
+
775
+ function expectRequestBody(value: unknown): Record<string, unknown> {
776
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
777
+ throw new Error("Expected JSON object request body.");
778
+ }
779
+ return value as Record<string, unknown>;
780
+ }
781
+
782
+ function expectBodyString(input: Record<string, unknown>, key: string): string {
783
+ const value = input[key];
784
+ if (typeof value !== "string" || value.length === 0) {
785
+ throw new Error(`Expected non-empty string body.${key}.`);
786
+ }
787
+ return value;
788
+ }
789
+
790
+ function optionalBodyString(value: unknown): string | undefined {
791
+ return typeof value === "string" && value.length > 0 ? value : undefined;
792
+ }
793
+
794
+ function optionalBodyNumber(value: unknown): number | undefined {
795
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
796
+ }
797
+
798
+ function optionalBodyBoolean(value: unknown): boolean | undefined {
799
+ return typeof value === "boolean" ? value : undefined;
800
+ }
801
+
802
+ function resolveDaemonEvolutionProcessErrorPath(homeDir: string): string {
803
+ return join(resolveDaemonPaths(homeDir).rootDir, "evolution-process-error.json");
804
+ }
805
+
806
+ async function recordDaemonEvolutionProcessError(homeDir: string, error: unknown): Promise<void> {
807
+ try {
808
+ const path = resolveDaemonEvolutionProcessErrorPath(homeDir);
809
+ await mkdir(dirname(path), { recursive: true });
810
+ await writeFile(
811
+ path,
812
+ `${JSON.stringify(
813
+ {
814
+ schemaVersion: 1,
815
+ kind: "daemon-evolution-process-error",
816
+ updatedAt: new Date().toISOString(),
817
+ summary: sanitizeMetadata(describeError(error)),
818
+ },
819
+ null,
820
+ 2,
821
+ )}\n`,
822
+ { encoding: "utf8", flag: "w", mode: 0o600 },
823
+ );
824
+ } catch {
825
+ // Daemon background diagnostics must not affect foreground request handling.
826
+ }
827
+ }
828
+
829
+ async function clearDaemonEvolutionProcessError(homeDir: string): Promise<void> {
830
+ await rm(resolveDaemonEvolutionProcessErrorPath(homeDir), { force: true }).catch(() => undefined);
831
+ }
832
+
833
+ async function readDaemonEvolutionProcessError(homeDir: string): Promise<string | null> {
834
+ const path = resolveDaemonEvolutionProcessErrorPath(homeDir);
835
+ if (!(await pathExists(path))) return null;
836
+ const value = JSON.parse(await readFile(path, "utf8")) as { summary?: unknown };
837
+ return typeof value.summary === "string" && value.summary.length > 0 ? value.summary : null;
838
+ }
839
+
840
+ function describeError(error: unknown): string {
841
+ return error instanceof Error ? error.message : String(error);
842
+ }
843
+
382
844
  async function collectNamedFiles(root: string, name: string): Promise<string[]> {
383
845
  const entries = await readdir(root, { withFileTypes: true });
384
846
  const files: string[] = [];