karajan-code 2.4.1 → 2.6.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.
Files changed (115) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
package/src/hu/store.js CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * HU (Historia de Usuario / User Story) storage. Persists HU batches and
3
+ * their stories to the session directory.
4
+ *
5
+ * @typedef {import("../types/hu.js").HUStory} HUStory
6
+ * @typedef {import("../types/hu.js").HUBatch} HUBatch
7
+ * @typedef {import("../types/hu.js").HUStatus} HUStatus
8
+ */
9
+
1
10
  import fs from "node:fs/promises";
2
11
  import path from "node:path";
3
12
  import { execFile } from "node:child_process";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * CommandRunner — DI-friendly adapter over execa / runCommand.
3
+ *
4
+ * Why: agent adapters (claude, codex, gemini, ...) all end up invoking
5
+ * src/utils/process.js -> runCommand. That makes unit-testing the agent layer
6
+ * slow and flaky — every test either spins up a real child process or stubs
7
+ * execa deep inside Vitest's module graph.
8
+ *
9
+ * This adapter preserves the runCommand API exactly, so current consumers
10
+ * can opt-in to DI without behavioural changes. A matching MockCommandRunner
11
+ * lives in src/infrastructure/mocks.js and lets tests script outputs per
12
+ * invocation.
13
+ *
14
+ * @typedef {Object} CommandRunResult
15
+ * @property {number} exitCode
16
+ * @property {string} stdout
17
+ * @property {string} stderr
18
+ * @property {boolean} [timedOut]
19
+ * @property {string|null} [signal]
20
+ *
21
+ * @typedef {Object} CommandRunOptions
22
+ * @property {number} [timeout]
23
+ * @property {(evt: {stream: string, line: string}) => void} [onOutput]
24
+ * @property {number} [silenceTimeoutMs]
25
+ * @property {number} [partialOutputFlushMs]
26
+ * @property {string|Buffer} [input]
27
+ * @property {string} [cwd]
28
+ * @property {Record<string,string>} [env]
29
+ *
30
+ * @typedef {Object} CommandRunner
31
+ * @property {(command: string, args?: string[], options?: CommandRunOptions) => Promise<CommandRunResult>} run
32
+ */
33
+
34
+ import { runCommand } from "../utils/process.js";
35
+
36
+ /**
37
+ * Default implementation — delegates to runCommand.
38
+ * @type {CommandRunner}
39
+ */
40
+ export const defaultCommandRunner = {
41
+ async run(command, args = [], options = {}) {
42
+ return runCommand(command, args, options);
43
+ }
44
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Environment — the DI bundle passed to services that need filesystem + command
3
+ * execution. Grouping them keeps call sites small: `new BaseAgent(..., env)`
4
+ * instead of `new BaseAgent(..., fs, runner)`.
5
+ *
6
+ * @typedef {import("./file-system-service.js").FileSystemService} FileSystemService
7
+ * @typedef {import("./command-runner.js").CommandRunner} CommandRunner
8
+ *
9
+ * @typedef {Object} Environment
10
+ * @property {FileSystemService} fs
11
+ * @property {CommandRunner} runner
12
+ */
13
+
14
+ import { defaultFileSystem } from "./file-system-service.js";
15
+ import { defaultCommandRunner } from "./command-runner.js";
16
+
17
+ /**
18
+ * Default environment uses native fs + real execa-based runner.
19
+ * Tests should inject a custom environment via buildEnvironment() below.
20
+ * @type {Environment}
21
+ */
22
+ export const defaultEnvironment = Object.freeze({
23
+ fs: defaultFileSystem,
24
+ runner: defaultCommandRunner
25
+ });
26
+
27
+ /**
28
+ * Merge overrides on top of the default environment. Missing properties fall
29
+ * back to defaults, so callers can replace only the pieces they care about.
30
+ * @param {Partial<Environment>} [overrides]
31
+ * @returns {Environment}
32
+ */
33
+ export function buildEnvironment(overrides = {}) {
34
+ return Object.freeze({
35
+ fs: overrides.fs ?? defaultFileSystem,
36
+ runner: overrides.runner ?? defaultCommandRunner
37
+ });
38
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * FileSystemService — thin DI-friendly adapter over node:fs/promises.
3
+ *
4
+ * Why: existing code uses node:fs directly (see src/utils/fs.js and consumers),
5
+ * which makes unit-testing orchestrator/agent paths slow and flaky — every test
6
+ * that exercises a code path touching fs has to stage real files on disk.
7
+ *
8
+ * This adapter preserves the exact native API shapes for the handful of
9
+ * operations actually used in the codebase, so consumers can opt-in to DI
10
+ * without rewriting call sites. A matching MockFileSystem lives in
11
+ * src/infrastructure/mocks.js.
12
+ *
13
+ * Scope for TSK-0316: intentionally minimal. Only the operations already used
14
+ * by orchestrator + agents. Additional methods added on demand.
15
+ *
16
+ * @typedef {Object} FileSystemService
17
+ * @property {(path: string, encoding?: string) => Promise<string|Buffer>} readFile
18
+ * @property {(path: string, data: string|Buffer, encoding?: string) => Promise<void>} writeFile
19
+ * @property {(path: string, data: string|Buffer, encoding?: string) => Promise<void>} appendFile
20
+ * @property {(path: string) => Promise<boolean>} exists
21
+ * @property {(path: string) => Promise<void>} ensureDir
22
+ * @property {(path: string) => Promise<string[]>} readdir
23
+ * @property {(src: string, dest: string) => Promise<void>} copyFile
24
+ * @property {(path: string) => Promise<{size: number, isDirectory: () => boolean, isFile: () => boolean}>} stat
25
+ */
26
+
27
+ import fs from "node:fs/promises";
28
+
29
+ /**
30
+ * Default implementation backed by node:fs/promises.
31
+ * @type {FileSystemService}
32
+ */
33
+ export const defaultFileSystem = {
34
+ async readFile(path, encoding = "utf8") {
35
+ return fs.readFile(path, encoding);
36
+ },
37
+ async writeFile(path, data, encoding = "utf8") {
38
+ await fs.writeFile(path, data, encoding);
39
+ },
40
+ async appendFile(path, data, encoding = "utf8") {
41
+ await fs.appendFile(path, data, encoding);
42
+ },
43
+ async exists(path) {
44
+ try {
45
+ await fs.access(path);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ },
51
+ async ensureDir(path) {
52
+ await fs.mkdir(path, { recursive: true });
53
+ },
54
+ async readdir(path) {
55
+ return fs.readdir(path);
56
+ },
57
+ async copyFile(src, dest) {
58
+ await fs.copyFile(src, dest);
59
+ },
60
+ async stat(path) {
61
+ return fs.stat(path);
62
+ }
63
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Test doubles for the DI surfaces. Intentionally dumb — tests stage inputs
3
+ * through simple setters and assert on recorded calls.
4
+ *
5
+ * @typedef {import("./file-system-service.js").FileSystemService} FileSystemService
6
+ * @typedef {import("./command-runner.js").CommandRunner} CommandRunner
7
+ * @typedef {import("./command-runner.js").CommandRunResult} CommandRunResult
8
+ * @typedef {import("./command-runner.js").CommandRunOptions} CommandRunOptions
9
+ */
10
+
11
+ /**
12
+ * In-memory file system. Paths are stored as-is (no normalization) to keep
13
+ * behaviour predictable in tests.
14
+ */
15
+ export class MockFileSystem {
16
+ constructor(initial = {}) {
17
+ /** @type {Map<string, string|Buffer>} */
18
+ this.files = new Map(Object.entries(initial));
19
+ /** @type {Set<string>} */
20
+ this.directories = new Set();
21
+ /** @type {Array<{op: string, path: string, args?: unknown}>} */
22
+ this.calls = [];
23
+ }
24
+
25
+ _record(op, path, args) {
26
+ this.calls.push({ op, path, args });
27
+ }
28
+
29
+ async readFile(path, encoding = "utf8") {
30
+ this._record("readFile", path, { encoding });
31
+ if (!this.files.has(path)) {
32
+ const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
33
+ err.code = "ENOENT";
34
+ throw err;
35
+ }
36
+ const data = this.files.get(path);
37
+ if (Buffer.isBuffer(data) && encoding) return data.toString(encoding);
38
+ return data;
39
+ }
40
+
41
+ async writeFile(path, data, encoding = "utf8") {
42
+ this._record("writeFile", path, { encoding });
43
+ this.files.set(path, data);
44
+ }
45
+
46
+ async appendFile(path, data, encoding = "utf8") {
47
+ this._record("appendFile", path, { encoding });
48
+ const prev = this.files.get(path) ?? "";
49
+ this.files.set(path, String(prev) + String(data));
50
+ }
51
+
52
+ async exists(path) {
53
+ this._record("exists", path);
54
+ return this.files.has(path) || this.directories.has(path);
55
+ }
56
+
57
+ async ensureDir(path) {
58
+ this._record("ensureDir", path);
59
+ this.directories.add(path);
60
+ }
61
+
62
+ async readdir(path) {
63
+ this._record("readdir", path);
64
+ const prefix = path.endsWith("/") ? path : path + "/";
65
+ const entries = new Set();
66
+ for (const p of this.files.keys()) {
67
+ if (p.startsWith(prefix)) {
68
+ const tail = p.slice(prefix.length).split("/")[0];
69
+ if (tail) entries.add(tail);
70
+ }
71
+ }
72
+ if (!entries.size && !this.directories.has(path)) {
73
+ const err = new Error(`ENOENT: no such file or directory, scandir '${path}'`);
74
+ err.code = "ENOENT";
75
+ throw err;
76
+ }
77
+ return [...entries];
78
+ }
79
+
80
+ async copyFile(src, dest) {
81
+ this._record("copyFile", src, { dest });
82
+ if (!this.files.has(src)) {
83
+ const err = new Error(`ENOENT: no such file or directory, copyfile '${src}' -> '${dest}'`);
84
+ err.code = "ENOENT";
85
+ throw err;
86
+ }
87
+ this.files.set(dest, this.files.get(src));
88
+ }
89
+
90
+ async stat(path) {
91
+ this._record("stat", path);
92
+ if (this.files.has(path)) {
93
+ const data = this.files.get(path);
94
+ const size = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data));
95
+ return {
96
+ size,
97
+ isDirectory: () => false,
98
+ isFile: () => true
99
+ };
100
+ }
101
+ if (this.directories.has(path)) {
102
+ return {
103
+ size: 0,
104
+ isDirectory: () => true,
105
+ isFile: () => false
106
+ };
107
+ }
108
+ const err = new Error(`ENOENT: no such file or directory, stat '${path}'`);
109
+ err.code = "ENOENT";
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Scripted command runner. Tests call enqueue() to stage responses in order,
116
+ * or setResponder() for a per-invocation decision. Every run() is recorded.
117
+ */
118
+ export class MockCommandRunner {
119
+ constructor() {
120
+ /** @type {Array<CommandRunResult>} */
121
+ this.queue = [];
122
+ /** @type {Array<{command: string, args: string[], options: CommandRunOptions}>} */
123
+ this.calls = [];
124
+ /** @type {null | ((args: {command: string, args: string[], options: CommandRunOptions}) => CommandRunResult|Promise<CommandRunResult>)} */
125
+ this.responder = null;
126
+ /** @type {CommandRunResult} */
127
+ this.defaultResponse = { exitCode: 0, stdout: "", stderr: "" };
128
+ }
129
+
130
+ /**
131
+ * Queue a response. FIFO — first enqueued is returned on the first run().
132
+ * @param {Partial<CommandRunResult>} response
133
+ */
134
+ enqueue(response) {
135
+ this.queue.push({ exitCode: 0, stdout: "", stderr: "", ...response });
136
+ return this;
137
+ }
138
+
139
+ /**
140
+ * Replace FIFO queue with a function that decides per-call.
141
+ * @param {(args: {command: string, args: string[], options: CommandRunOptions}) => CommandRunResult|Promise<CommandRunResult>} fn
142
+ */
143
+ setResponder(fn) {
144
+ this.responder = fn;
145
+ return this;
146
+ }
147
+
148
+ /**
149
+ * @param {string} command
150
+ * @param {string[]} args
151
+ * @param {CommandRunOptions} options
152
+ * @returns {Promise<CommandRunResult>}
153
+ */
154
+ async run(command, args = [], options = {}) {
155
+ this.calls.push({ command, args: [...args], options: { ...options } });
156
+ if (this.responder) return this.responder({ command, args, options });
157
+ if (this.queue.length > 0) return this.queue.shift();
158
+ return { ...this.defaultResponse };
159
+ }
160
+
161
+ /**
162
+ * Convenience: last recorded invocation, or null.
163
+ */
164
+ get lastCall() {
165
+ return this.calls.at(-1) ?? null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Build an Environment backed by the mocks above. Useful shorthand in tests:
171
+ *
172
+ * const { env, fs, runner } = buildMockEnvironment();
173
+ * const agent = new BaseAgent(..., env);
174
+ *
175
+ * @param {{files?: Record<string, string|Buffer>}} [opts]
176
+ */
177
+ export function buildMockEnvironment({ files } = {}) {
178
+ const fs = new MockFileSystem(files);
179
+ const runner = new MockCommandRunner();
180
+ const env = Object.freeze({ fs, runner });
181
+ return { env, fs, runner };
182
+ }
@@ -157,8 +157,13 @@ export function handleInit(a) {
157
157
  return runKjCommand({ command: "init", options: a });
158
158
  }
159
159
 
160
- export function handleDoctor(a) {
161
- return runKjCommand({ command: "doctor", options: a });
160
+ export function handleDoctor(a = {}) {
161
+ const commandArgs = [];
162
+ if (a.checkOnly) commandArgs.push("--check-only");
163
+ if (a.yes) commandArgs.push("--yes");
164
+ if (a.json) commandArgs.push("--json");
165
+ if (a.verbose) commandArgs.push("--verbose");
166
+ return runKjCommand({ command: "doctor", commandArgs, options: a });
162
167
  }
163
168
 
164
169
  export function handleConfig(a) {
package/src/mcp/tools.js CHANGED
@@ -12,12 +12,16 @@ export const tools = [
12
12
  },
13
13
  {
14
14
  name: "kj_doctor",
15
- description: "Check system dependencies and agent CLIs",
15
+ description: "Check system dependencies, agent CLIs, ports, tokens and MCP health — auto-remediates when possible",
16
16
  inputSchema: {
17
17
  type: "object",
18
18
  properties: {
19
19
  kjHome: { type: "string" },
20
- timeoutMs: { type: "number" }
20
+ timeoutMs: { type: "number" },
21
+ checkOnly: { type: "boolean", description: "Detect only, do not apply fixes" },
22
+ yes: { type: "boolean", description: "Auto-confirm prompts for invasive remediations" },
23
+ json: { type: "boolean", description: "Emit machine-readable JSON output" },
24
+ verbose: { type: "boolean", description: "Include fix hints and timing per check" }
21
25
  }
22
26
  }
23
27
  },
@@ -105,7 +109,10 @@ export const tools = [
105
109
  kjHome: { type: "string" },
106
110
  sonarToken: { type: "string" },
107
111
  timeoutMs: { type: "number" },
108
- domain: { type: "string", description: "Domain knowledge: inline text describing the project domain, or absolute path to a .md file. Auto-saved to .karajan/domains/ for the curator." }
112
+ domain: { type: "string", description: "Domain knowledge: inline text describing the project domain, or absolute path to a .md file. Auto-saved to .karajan/domains/ for the curator." },
113
+ brain: { type: "string", enum: ["on", "off"], description: "Brain decisor: on|off. Controls whether intent-driven routing overrides pipeline flags." },
114
+ forceRole: { type: "array", items: { type: "string" }, description: "Force roles ON regardless of triage (e.g. [\"security\"])" },
115
+ skipRole: { type: "array", items: { type: "string" }, description: "Force roles OFF regardless of triage (e.g. [\"tester\"])" }
109
116
  }
110
117
  }
111
118
  },
@@ -10,6 +10,7 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
10
10
  import { resolveRole } from "../config.js";
11
11
  import { emitProgress, makeEvent } from "../utils/events.js";
12
12
  import { BudgetTracker, extractUsageMetrics } from "../utils/budget.js";
13
+ import { computeKjComparison } from "../budget/comparison.js";
13
14
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
14
15
  import { applyPolicies } from "../guards/policy-resolver.js";
15
16
  import { resolveReviewProfile } from "../review/profiles.js";
@@ -280,7 +281,7 @@ export async function handleDryRun({ task, config, flags, emitter, pipelineFlags
280
281
  return summary;
281
282
  }
282
283
 
283
- export function createBudgetManager({ config, emitter, eventBase }) {
284
+ export function createBudgetManager({ config, emitter, eventBase, getCompressionStats = null }) {
284
285
  const budgetTracker = new BudgetTracker({ pricing: config?.budget?.pricing });
285
286
  const budgetLimit = Number(config?.max_budget_usd);
286
287
  const hasBudgetLimit = Number.isFinite(budgetLimit) && budgetLimit >= 0;
@@ -290,6 +291,20 @@ export function createBudgetManager({ config, emitter, eventBase }) {
290
291
  function budgetSummary() {
291
292
  const s = budgetTracker.summary();
292
293
  s.trace = budgetTracker.trace();
294
+ // Attach the "With KJ vs Without KJ" comparison when compression data
295
+ // is available (TSK-0274). hasCompression: false means no savings to
296
+ // report; the display falls back to the plain cost line.
297
+ if (typeof getCompressionStats === "function") {
298
+ try {
299
+ const stats = getCompressionStats() || {};
300
+ s.kj_comparison = computeKjComparison({
301
+ realTokens: s.total_tokens,
302
+ realCost: s.total_cost_usd,
303
+ rtkSavings: stats.rtkSavings,
304
+ brainCtx: stats.brainCtx,
305
+ });
306
+ } catch { /* best-effort — omit comparison on failure */ }
307
+ }
293
308
  return s;
294
309
  }
295
310