jeo-code 0.7.33 → 0.7.37

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.
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
2
3
  import { createHash } from "node:crypto";
3
4
  import chalk from "chalk";
4
5
  import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
@@ -8,24 +9,21 @@ import {
8
9
  writeWorkflowState,
9
10
  acquireWorkflowRunLock,
10
11
  type WorkflowState,
12
+ type Config,
11
13
  } from "../agent/state";
12
- import { runAgentLoop } from "../agent/engine";
13
- import { maybeCompact } from "../agent/compaction";
14
- import { catalogMetadata } from "../ai";
15
- import { gitDirtyCount } from "./launch/tmux";
14
+ import { runSubagentOnce, type SubagentRunResult } from "../agent/task-tool";
15
+ import { SubagentRegistry } from "../agent/subagent-registry";
16
+ import { gitDirtyCount, resolveWorktree } from "./launch/tmux";
16
17
  import { readGlobalConfig } from "../agent/state";
17
18
  import {
18
19
  defaultSubagentRole,
19
20
  getSubagentRole,
20
21
  resolveSubagentModel,
21
22
  resolveSubagentMaxSteps,
22
- subagentSystemPrompt,
23
- subagentToolset,
24
23
  subagentRoleIds,
25
- validateSubagentDoneReason,
24
+ type SubagentRole,
26
25
  } from "../agent/subagents";
27
- import type { Message } from "../agent/loop";
28
- import { loadProjectContext, withProjectContext } from "../agent/context-files";
26
+ import { loadProjectContext } from "../agent/context-files";
29
27
  import { categoryBadge } from "../tui/components/category-index";
30
28
 
31
29
  export type RalphStreamKind = "step" | "complete" | "error" | "warn";
@@ -343,12 +341,65 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
343
341
  return { ok: false, reason: "aborted" };
344
342
  }
345
343
 
346
- const currentTask = teamState.pending_tasks[0];
347
- log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
348
344
  const activeIndex = activeStepIndex(tasks.length, teamState.pending_tasks);
349
345
  // Guide printed once above; per-task lines convey progress. No per-iteration
350
346
  // reprint — it was O(N²) lines, climbing the embedded-terminal renderer's CPU.
351
347
 
348
+ // Look ahead from the current position: if this step's `parallel_group`
349
+ // extends into one or more of the NEXT pending steps (the group's full
350
+ // contiguous run is, by construction, always still all-pending here —
351
+ // `activeIndex` is derived from how many tasks remain), dispatch the WHOLE
352
+ // group concurrently. A group of size 1 (no group, or a lone member) falls
353
+ // straight through to the existing serial path, unchanged.
354
+ const groupVal = parsed.data.steps[activeIndex]?.parallel_group?.trim() || undefined;
355
+ const groupIndices = [activeIndex];
356
+ if (groupVal) {
357
+ let i = activeIndex + 1;
358
+ while (i < tasks.length && (parsed.data.steps[i]?.parallel_group?.trim() || undefined) === groupVal) {
359
+ groupIndices.push(i);
360
+ i++;
361
+ }
362
+ }
363
+
364
+ if (groupIndices.length > 1) {
365
+ const groupSteps = groupIndices.map(i => ({ index: i, name: tasks[i]!, roleId: roleByIndex[i] }));
366
+ log(`\n${categoryBadge("progress")} Current parallel group "${groupVal}": ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
367
+ if (opts.onProgress) {
368
+ opts.onProgress({ skill: "team", phase: "executing", detail: `Current parallel group: ${groupSteps.map(s => s.name).join(", ")}` });
369
+ }
370
+
371
+ const groupResult = await runParallelGroup(groupSteps, {
372
+ cwd,
373
+ teamCfg,
374
+ tasks,
375
+ completed: teamState.completed_tasks ?? [],
376
+ strictMutations: opts.strictMutations ?? false,
377
+ log,
378
+ slug: planState.slug ?? teamState.slug ?? "team",
379
+ });
380
+
381
+ if (opts.signal?.aborted) {
382
+ return { ok: false, reason: "aborted" };
383
+ }
384
+
385
+ if (groupResult.ok) {
386
+ teamState.completed_tasks = [...(teamState.completed_tasks ?? []), ...groupSteps.map(s => s.name)];
387
+ teamState.pending_tasks = teamState.pending_tasks.slice(groupSteps.length);
388
+ await writeWorkflowState("team", teamState, cwd);
389
+ log(`${categoryBadge("done")} Completed parallel group: ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
390
+ } else {
391
+ teamState.current_phase = "failed";
392
+ teamState.failed_task = groupResult.failedTaskName;
393
+ await writeWorkflowState("team", teamState, cwd);
394
+ log(`${categoryBadge("error")} Failed on parallel group step: "${groupResult.failedTaskName}". Halting execution.`);
395
+ return { ok: false, reason: `Failed on task: "${groupResult.failedTaskName}"` };
396
+ }
397
+ continue;
398
+ }
399
+
400
+ const currentTask = teamState.pending_tasks[0];
401
+ log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
402
+
352
403
  if (opts.onProgress) {
353
404
  opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
354
405
  }
@@ -417,89 +468,68 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
417
468
  const maxSteps = resolveSubagentMaxSteps(role.id, config);
418
469
  log(` └─ Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps`);
419
470
 
420
- const contextTokens = catalogMetadata(model)?.contextTokens;
421
-
422
471
  const projectContext = await loadProjectContext(ctx.cwd);
423
- const history: Message[] = [
424
- { role: "system", content: withProjectContext(subagentSystemPrompt(role), projectContext) },
425
- { role: "user", content: buildRalphSubagentPrompt(ctx) },
426
- ];
427
472
 
428
- try {
429
- await maybeCompact(history, { model, contextTokens });
430
- } catch (err) {
431
- // LLM summary failure does not halt team
432
- }
433
-
434
- let fileMutations = 0; // round-8 parent audit: successful write/edit/mkdir/delete
435
- let bashRuns = 0; // bash counted apart so read-only bash isn't edit evidence
436
- const result = await runAgentLoop(history, {
437
- cwd: ctx.cwd,
438
- model,
439
- maxSteps,
440
- // Per-run prompt-cache key: each worker replays its own growing history every
441
- // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
442
- sessionKey: crypto.randomUUID(),
443
- // Bounded delegation: ralph/team subagents keep an exact step contract; the
444
- // orchestrator owns retries, so the gjc step-extension flow is disabled here.
445
- budget: { maxExtensions: 0 },
446
- tools: subagentToolset(role),
447
- events: {
448
- onAssistant: (_raw, invocation) => {
449
- if (!invocation) {
450
- log(formatRalphStreamEvent("error", "invalid tool-call json; retrying", renderOpts));
451
- } else if (invocation.tool !== "done") {
452
- log(formatRalphStreamEvent("step", `tool ${invocation.tool} requested`, renderOpts));
453
- }
454
- },
455
- onStep: async step => {
456
- log(formatRalphStreamEvent("step", `${role.title} thinking ${step}/${maxSteps}`, renderOpts));
457
- try {
458
- await maybeCompact(history, { model, contextTokens });
459
- } catch (err) {
460
- // LLM summary failure does not halt team
461
- }
462
- },
463
- onToolResult: (tool, ok) => {
464
- if (ok) {
465
- if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
466
- else if (tool === "bash") bashRuns++;
467
- }
468
- log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
469
- },
470
- onNotice: msg => log(formatRalphStreamEvent("step", msg, renderOpts)),
473
+ // `jeo team` now runs every step through the SAME execution core as the `task`/
474
+ // `subagent` tools (`runSubagentOnce`) — one way a jeo-code subagent runs,
475
+ // whether launched interactively, in a fan-out batch, detached, or as a team
476
+ // plan step. This gets team plan steps the same fenced-report protection
477
+ // (prompt-injection-safe) and done-reason contract validation the interactive
478
+ // tools already had, instead of a second hand-rolled copy of that logic.
479
+ const result = await runSubagentOnce(role, buildRalphSubagentPrompt(ctx), "", ctx.cwd, {
480
+ config,
481
+ projectContext,
482
+ onEvent: ev => {
483
+ if (ev.kind === "step") {
484
+ log(formatRalphStreamEvent("step", ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`, renderOpts));
485
+ } else if (ev.kind === "tool") {
486
+ const suffix = ev.summary ? ` — ${ev.summary}` : "";
487
+ log(formatRalphStreamEvent(ev.success ? "complete" : "error", `tool ${ev.detail}${suffix}`, renderOpts));
488
+ }
471
489
  },
472
490
  });
473
491
 
474
- const reason = result.doneReason?.trim() || `${role.title} did not converge within ${result.steps} steps`;
475
- if (!result.done) {
476
- log(formatRalphStreamEvent("error", reason, renderOpts));
477
- return false;
478
- }
492
+ return evaluateSubagentResult(result, role, ctx.strictMutations ?? false, log, renderOpts);
493
+ }
479
494
 
480
- const contract = validateSubagentDoneReason(role, reason);
481
- if (!contract.ok) {
482
- log(formatRalphStreamEvent("error", `${role.title} report incomplete: missing ${contract.missing?.join(", ")}`, renderOpts));
495
+ /**
496
+ * Shared pass/fail verdict for ONE subagent's `SubagentRunResult`: contract
497
+ * failure, role-gate rejection (architect BLOCK / critic REQUEST CHANGES), and
498
+ * `--strict-mutations` hard-fail. Used by BOTH the serial path
499
+ * (`executeTaskWithAgent`) and the parallel-group path (`runParallelGroup`) so
500
+ * the two never diverge on what counts as a passing step.
501
+ */
502
+ function evaluateSubagentResult(
503
+ result: SubagentRunResult,
504
+ role: SubagentRole,
505
+ strictMutations: boolean,
506
+ log: (line: string) => void,
507
+ renderOpts: RalphRenderOptions,
508
+ ): boolean {
509
+ if (!result.success) {
510
+ log(formatRalphStreamEvent("error", result.error || result.output, renderOpts));
483
511
  return false;
484
512
  }
485
513
 
486
- const gate = parseRoleGateVerdict(role.id, reason);
514
+ // Role gate verdict (architect BLOCK / critic REQUEST CHANGES) is parsed from the
515
+ // subagent's RAW done reason, not the fenced/wrapped report text around it.
516
+ const gate = parseRoleGateVerdict(role.id, result.doneReason);
487
517
  if (!gate.ok) {
488
518
  log(formatRalphStreamEvent("error", gate.message ?? `${role.title} blocked execution`, renderOpts));
489
519
  return false;
490
520
  }
491
521
 
492
- if (!role.readOnly && fileMutations === 0) {
493
- // Round-8: a mutating role finished without a successful file mutation — the
494
- // task may be legitimately read-only, but its "Changed Files:" claim is
495
- // unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
496
- const msg = bashRuns === 0
522
+ // Round-8: a mutating role finished without a successful file mutation — the
523
+ // task may be legitimately read-only, but its "Changed Files:" claim is
524
+ // unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
525
+ if (!role.readOnly && result.fileMutations === 0) {
526
+ const msg = result.bashRuns === 0
497
527
  ? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
498
528
  : `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
499
529
  // Round-11: under --strict-mutations, a mutating role that took NO action at
500
530
  // all (no write/edit/bash) is a hard failure — an empty run must not pass as
501
531
  // a completed task. bash-only stays advisory to avoid penalizing shell edits.
502
- const hardFail = ctx.strictMutations && bashRuns === 0;
532
+ const hardFail = strictMutations && result.bashRuns === 0;
503
533
  // Round-12: separate the tones so a passing advisory run doesn't masquerade
504
534
  // as a stream:error — only a real hard-fail is red; an advisory note is warn.
505
535
  log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
@@ -507,12 +537,169 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
507
537
  return false;
508
538
  }
509
539
  }
510
- // Surface the subagent's ACTUAL report to the user — not just a "finished"
511
- // status. Previously `reason` was consumed only for validation/gating and the
512
- // report content was discarded, so `team` runs produced no visible answer.
540
+
541
+ // Surface the subagent's full report (header + step trace + fenced reason +
542
+ // mutation audit) — not just a "finished" status.
513
543
  log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
514
544
  log(`\n${role.title} report:`);
515
- log(reason); // log() already splits multi-line strings across the io.output sink
545
+ log(result.output); // log() already splits multi-line strings across the io.output sink
516
546
 
517
547
  return true;
518
548
  }
549
+
550
+ /** One step of a dispatched parallel group, resolved to its plan position + role. */
551
+ interface ParallelGroupStep {
552
+ index: number;
553
+ name: string;
554
+ roleId?: string;
555
+ }
556
+
557
+ /** Per-step worktree + branch resolved for a dispatched parallel group. */
558
+ interface ParallelWorktree {
559
+ path: string;
560
+ branch: string;
561
+ }
562
+
563
+ function runGitSync(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
564
+ const res = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
565
+ return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
566
+ }
567
+
568
+ /**
569
+ * Execute a CONTIGUOUS run of `parallel_group`-marked plan steps concurrently,
570
+ * each in its own git worktree via `resolveWorktree` + the SAME
571
+ * `SubagentRegistry`/`runSubagentOnce` core the serial path and the `task`/
572
+ * `subagent` tools use. On full success, each worker's committed branch is
573
+ * merged back into `cwd`'s current branch IN ARRAY ORDER; any failure or merge
574
+ * conflict stops processing immediately and leaves the remaining
575
+ * worktrees/branches in place for manual inspection — nothing is ever silently
576
+ * resolved or discarded.
577
+ */
578
+ async function runParallelGroup(
579
+ group: ParallelGroupStep[],
580
+ ctx: {
581
+ cwd: string;
582
+ teamCfg: Config;
583
+ tasks: string[];
584
+ completed: readonly string[];
585
+ strictMutations: boolean;
586
+ log: (line: string) => void;
587
+ slug: string;
588
+ },
589
+ ): Promise<{ ok: true } | { ok: false; failedTaskName: string }> {
590
+ const { cwd, teamCfg, tasks, completed, strictMutations, log, slug } = ctx;
591
+ const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
592
+
593
+ const registry = new SubagentRegistry();
594
+ const resultsByIndex = new Map<number, SubagentRunResult>();
595
+ const idByIndex = new Map<number, string>();
596
+ const roleByStepIndex = new Map<number, SubagentRole>();
597
+ const worktreeByIndex = new Map<number, ParallelWorktree>();
598
+
599
+ log(`\n${categoryBadge("progress")} Dispatching parallel group of ${group.length} steps concurrently: ${group.map(s => `"${s.name}"`).join(", ")}`);
600
+
601
+ for (const step of group) {
602
+ const worktreePath = path.join(cwd, ".jeo", "team-worktrees", `${slug}-${step.index}`);
603
+ const resolvedPath = resolveWorktree(cwd, worktreePath);
604
+ const branchRes = runGitSync(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
605
+ const branch = branchRes.stdout.trim();
606
+ worktreeByIndex.set(step.index, { path: resolvedPath, branch });
607
+
608
+ const role = getSubagentRole(step.roleId, teamCfg) ?? defaultSubagentRole();
609
+ roleByStepIndex.set(step.index, role);
610
+ const model = resolveSubagentModel(role.id, teamCfg);
611
+ const maxSteps = resolveSubagentMaxSteps(role.id, teamCfg);
612
+ log(` └─ [${step.name}] Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps · worktree ${resolvedPath} (branch ${branch})`);
613
+
614
+ // Loaded PER WORKTREE (its own file tree) — never shared across concurrent workers.
615
+ const projectContext = await loadProjectContext(resolvedPath);
616
+ const promptCtx: RalphSubagentPromptContext = { task: step.name, tasks, activeIndex: step.index, completed };
617
+ const taskText = buildRalphSubagentPrompt(promptCtx);
618
+
619
+ const record = registry.launch(role.id, step.name, async (signal) => {
620
+ const result = await runSubagentOnce(role, taskText, "", resolvedPath, {
621
+ config: teamCfg,
622
+ signal,
623
+ projectContext,
624
+ onEvent: ev => {
625
+ if (ev.kind === "step") {
626
+ log(formatRalphStreamEvent("step", `[${step.name}] ${ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`}`, renderOpts));
627
+ } else if (ev.kind === "tool") {
628
+ const suffix = ev.summary ? ` — ${ev.summary}` : "";
629
+ log(formatRalphStreamEvent(ev.success ? "complete" : "error", `[${step.name}] tool ${ev.detail}${suffix}`, renderOpts));
630
+ }
631
+ },
632
+ });
633
+ // Capture the FULL SubagentRunResult (doneReason/fileMutations/etc.) before
634
+ // collapsing it to the plain ToolResult shape SubagentRegistry stores.
635
+ resultsByIndex.set(step.index, result);
636
+ return { success: result.success, output: result.output, error: result.error };
637
+ });
638
+ idByIndex.set(step.index, record.id);
639
+ }
640
+
641
+ // No timeout — team runs can take as long as needed.
642
+ await registry.awaitIds([...idByIndex.values()]);
643
+
644
+ // Evaluate + commit each successful worktree, IN ARRAY ORDER (not completion
645
+ // order). Any failure stops the whole group immediately: no merges happen,
646
+ // and every worktree/branch is left exactly as it is for inspection.
647
+ for (const step of group) {
648
+ const result = resultsByIndex.get(step.index);
649
+ const role = roleByStepIndex.get(step.index)!;
650
+ const wt = worktreeByIndex.get(step.index)!;
651
+ if (!result) {
652
+ log(formatRalphStreamEvent("error", `[${step.name}] subagent produced no result`, renderOpts));
653
+ return { ok: false, failedTaskName: step.name };
654
+ }
655
+ const success = evaluateSubagentResult(result, role, strictMutations, log, renderOpts);
656
+ if (!success) {
657
+ log(`${categoryBadge("error")} Parallel group step "${step.name}" failed — its worktree (${wt.path}) and the OTHER group worktrees are left in place for inspection; nothing was merged.`);
658
+ return { ok: false, failedTaskName: step.name };
659
+ }
660
+ // An empty diff (read-only role, or a no-op) needs no commit — skip it for
661
+ // the merge step below without treating it as an error.
662
+ if (gitDirtyCount(wt.path)) {
663
+ const add = runGitSync(wt.path, ["add", "-A"]);
664
+ if (!add.ok) {
665
+ log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
666
+ return { ok: false, failedTaskName: step.name };
667
+ }
668
+ const commit = runGitSync(wt.path, ["commit", "-m", step.name]);
669
+ if (!commit.ok) {
670
+ log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
671
+ return { ok: false, failedTaskName: step.name };
672
+ }
673
+ }
674
+ }
675
+
676
+ // All group steps succeeded — merge each worker branch back, IN ARRAY ORDER.
677
+ // A conflict aborts the merge immediately (never auto-resolved) and stops the
678
+ // rest of the group's merges.
679
+ for (const step of group) {
680
+ const wt = worktreeByIndex.get(step.index)!;
681
+ const merge = runGitSync(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
682
+ if (!merge.ok) {
683
+ runGitSync(cwd, ["merge", "--abort"]);
684
+ log(
685
+ `${categoryBadge("error")} Merge conflict merging step "${step.name}"'s branch "${wt.branch}" into the current branch — ` +
686
+ `the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
687
+ `cd ${wt.path} to inspect the change, then run 'git merge ${wt.branch}' yourself from ${cwd}.\n${(merge.stderr + merge.stdout).trim()}`,
688
+ );
689
+ return { ok: false, failedTaskName: step.name };
690
+ }
691
+ log(`${categoryBadge("done")} Merged step "${step.name}" (branch ${wt.branch}) into the current branch.`);
692
+ }
693
+
694
+ // Fully successful group — clean up worktrees best-effort (a removal failure,
695
+ // e.g. stray untracked files, is a warning, never a hard failure of the run).
696
+ for (const step of group) {
697
+ const wt = worktreeByIndex.get(step.index)!;
698
+ const removal = runGitSync(cwd, ["worktree", "remove", wt.path]);
699
+ if (!removal.ok) {
700
+ log(`[WARN] Could not remove worktree ${wt.path} for step "${step.name}": ${removal.stderr.trim()}`);
701
+ }
702
+ }
703
+
704
+ return { ok: true };
705
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Lone-surrogate sanitization for provider request bodies (gjc 0.8.1 parity).
3
+ *
4
+ * Tool-call arguments, tool-result output, and message content are embedded raw into
5
+ * provider request bodies before `JSON.stringify`. A truncated multi-byte emoji (or
6
+ * model-echoed text) can leave an unpaired UTF-16 surrogate in a string; `JSON.stringify`
7
+ * happily emits it as a lone `\ud8xx`/`\udcxx` escape, which strict provider JSON parsers
8
+ * reject (e.g. Anthropic 400 "no low surrogate in string"). `sanitizeJsonStrings` walks an
9
+ * arbitrary JS value and replaces every string (both object keys and values) with its
10
+ * `.toWellFormed()` form, which turns lone surrogates into U+FFFD without touching valid
11
+ * surrogate pairs (real emoji, etc.).
12
+ */
13
+
14
+ /** Recursively sanitize every string value and object key in `value` for well-formed UTF-16.
15
+ * Cycle-safe via a `WeakMap` of already-sanitized objects/arrays. */
16
+ export function sanitizeJsonStrings<T>(value: T): T {
17
+ return sanitizeJsonStringsInner(value, new WeakMap<object, unknown>()) as T;
18
+ }
19
+
20
+ function sanitizeJsonStringsInner(value: unknown, seen: WeakMap<object, unknown>): unknown {
21
+ if (typeof value === "string") return value.toWellFormed();
22
+ if (!value || typeof value !== "object") return value;
23
+
24
+ const cached = seen.get(value);
25
+ if (cached !== undefined) return cached;
26
+
27
+ if (Array.isArray(value)) {
28
+ const sanitized: unknown[] = [];
29
+ seen.set(value, sanitized);
30
+ for (const item of value) {
31
+ sanitized.push(sanitizeJsonStringsInner(item, seen));
32
+ }
33
+ return sanitized;
34
+ }
35
+
36
+ const sanitized: Record<string, unknown> = {};
37
+ seen.set(value, sanitized);
38
+ for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
39
+ sanitized[key.toWellFormed()] = sanitizeJsonStringsInner(nestedValue, seen);
40
+ }
41
+ return sanitized;
42
+ }