karajan-code 1.50.0 → 1.51.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/README.md CHANGED
@@ -243,7 +243,7 @@ Not nostalgia, not stubbornness. I've been using JavaScript since 1997, when Bre
243
243
  git clone https://github.com/manufosela/karajan-code.git
244
244
  cd karajan-code
245
245
  npm install
246
- npm test # Run 2318 tests with Vitest
246
+ npm test # Run ~2473 tests with Vitest
247
247
  npm run validate # Lint + test
248
248
  ```
249
249
 
package/docs/README.es.md CHANGED
@@ -213,7 +213,7 @@ No es nostalgia ni cabezoneria. Es que llevo usando JavaScript desde 1997, cuand
213
213
  git clone https://github.com/manufosela/karajan-code.git
214
214
  cd karajan-code
215
215
  npm install
216
- npm test # Ejecutar 2318 tests con Vitest
216
+ npm test # Ejecutar ~2473 tests con Vitest
217
217
  npm run validate # Lint + test
218
218
  ```
219
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.50.0",
3
+ "version": "1.51.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -208,15 +208,20 @@ async function checkBecariaInfra(config) {
208
208
  }
209
209
 
210
210
  async function checkRtk() {
211
- const NOT_FOUND_DETAIL = `Not found — install for 60-90% token savings: ${getInstallCommand("rtk")}`;
212
- let detail = NOT_FOUND_DETAIL;
211
+ const installCmd = getInstallCommand("rtk");
213
212
  try {
214
213
  const res = await runCommand("rtk", ["--version"]);
215
214
  if (res.exitCode === 0) {
216
- detail = `${res.stdout.trim()} — token savings active`;
215
+ return { name: "rtk", label: "RTK (Rust Token Killer)", ok: true, detail: `${res.stdout.trim()} — token savings active`, fix: null };
217
216
  }
218
217
  } catch { /* not installed */ }
219
- return { name: "rtk", label: "RTK (Rust Token Killer)", ok: true, detail, fix: null };
218
+ return {
219
+ name: "rtk",
220
+ label: "RTK (Rust Token Killer)",
221
+ ok: false,
222
+ detail: "Not found — 60-90% token savings available",
223
+ fix: `Install: ${installCmd}`
224
+ };
220
225
  }
221
226
 
222
227
  async function checkRuleFiles(config) {
@@ -10,6 +10,8 @@ import { createWizard, isTTY } from "../utils/wizard.js";
10
10
  import { runCommand } from "../utils/process.js";
11
11
  import { getInstallCommand } from "../utils/os-detect.js";
12
12
  import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
13
+ import { detectRtk } from "../utils/rtk-detect.js";
14
+ import { installRtk } from "../utils/rtk-install.js";
13
15
 
14
16
  async function runWizard(config, logger) {
15
17
  const agents = await detectAvailableAgents();
@@ -328,18 +330,16 @@ export async function initCommand({ logger, flags = {} }) {
328
330
  await ensureCoderRules(coderRulesPath, logger);
329
331
  await installSkills(logger, interactive);
330
332
 
331
- // Check RTK availability and inform user
332
- let hasRtk = false;
333
- try {
334
- const rtkRes = await runCommand("rtk", ["--version"]);
335
- hasRtk = rtkRes.exitCode === 0;
336
- } catch { /* rtk not installed */
337
- hasRtk = false;
338
- }
339
- if (!hasRtk) {
340
- logger.info("");
341
- logger.info("RTK (Rust Token Killer) can reduce token usage by 60-90%.");
342
- logger.info(` Install: ${getInstallCommand("rtk")}`);
333
+ // Check RTK availability — auto-install if missing
334
+ const rtk = await detectRtk();
335
+ if (rtk.available) {
336
+ logger.info(`RTK ${rtk.version || ""} detected.`);
337
+ } else {
338
+ const installResult = await installRtk(logger);
339
+ if (!installResult.ok) {
340
+ logger.warn("RTK is optional but recommended for 60-90% token savings.");
341
+ logger.warn(` Manual install: ${getInstallCommand("rtk")}`);
342
+ }
343
343
  }
344
344
 
345
345
  await setupSonarQube(config, logger);
@@ -152,6 +152,7 @@ async function buildReport(dir, sessionId) {
152
152
  };
153
153
  if (session.pg_task_id) report.pg_task_id = session.pg_task_id;
154
154
  if (session.pg_project_id) report.pg_project_id = session.pg_project_id;
155
+ if (session.rtk_savings) report.rtk_savings = session.rtk_savings;
155
156
  return report;
156
157
  }
157
158
 
@@ -326,6 +327,16 @@ async function resolveTraceOptions(currency) {
326
327
  return { cur, rate };
327
328
  }
328
329
 
330
+ function printRtkSavings(rtkSavings) {
331
+ if (!rtkSavings || !rtkSavings.callCount) return;
332
+ const tokens = rtkSavings.estimatedTokensSaved ?? 0;
333
+ const ratio = rtkSavings.savedPct ?? 0;
334
+ const commands = rtkSavings.callCount ?? 0;
335
+ console.log("");
336
+ console.log(`RTK Savings: ~${tokens} tokens saved (${ratio}% compression, ${commands} commands)`);
337
+ console.log(` Original: ${rtkSavings.originalBytes} bytes, Compressed: ${rtkSavings.rtkBytes} bytes, Saved: ${rtkSavings.savedBytes} bytes`);
338
+ }
339
+
329
340
  function printTraceReport(report, currency, exchangeRate) {
330
341
  console.log(`Session: ${report.session_id}`);
331
342
  printPgCardLine(report);
@@ -333,6 +344,7 @@ function printTraceReport(report, currency, exchangeRate) {
333
344
  console.log(`Task: ${report.task_description || "N/A"}`);
334
345
  console.log("");
335
346
  printTraceTable(report.budget_trace, { currency, exchangeRate });
347
+ printRtkSavings(report.rtk_savings);
336
348
  }
337
349
 
338
350
  async function handlePgTaskReport({ dir, pgTask, list, sessionId, format, trace, currency }) {
@@ -6,6 +6,15 @@ import { VALID_TASK_TYPES } from "./policy-resolver.js";
6
6
  * Evaluated top-down; first match with confidence >= threshold wins.
7
7
  */
8
8
  const INTENT_PATTERNS = [
9
+ // Audit / analysis tasks (no code changes, read-only analysis)
10
+ {
11
+ id: "audit",
12
+ keywords: ["audit", "review", "analyze", "check", "verify", "inspect", "scan", "auditar", "revisar", "analizar", "comprobar", "verificar", "inspeccionar"],
13
+ taskType: "audit",
14
+ level: "simple",
15
+ confidence: 0.9,
16
+ message: "Audit/analysis task detected — skipping coder and reviewer",
17
+ },
9
18
  // Documentation-only tasks
10
19
  {
11
20
  id: "doc-readme",
@@ -1,4 +1,4 @@
1
- export const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor"]);
1
+ export const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "audit", "analysis"]);
2
2
 
3
3
  export const DEFAULT_POLICIES = {
4
4
  sw: { tdd: true, sonar: true, reviewer: true, testsRequired: true },
@@ -6,6 +6,8 @@ export const DEFAULT_POLICIES = {
6
6
  doc: { tdd: false, sonar: false, reviewer: true, testsRequired: false },
7
7
  "add-tests": { tdd: false, sonar: true, reviewer: true, testsRequired: true },
8
8
  refactor: { tdd: true, sonar: true, reviewer: true, testsRequired: false },
9
+ audit: { tdd: false, sonar: false, reviewer: false, testsRequired: false, coderRequired: false },
10
+ analysis: { tdd: false, sonar: false, reviewer: false, testsRequired: false, coderRequired: false },
9
11
  };
10
12
 
11
13
  const FALLBACK_TYPE = "sw";
@@ -13,6 +13,7 @@ import { checkBinary } from "../utils/agent-detect.js";
13
13
  import { isSonarReachable, sonarUp } from "../sonar/manager.js";
14
14
  import { runCommand } from "../utils/process.js";
15
15
  import { emitProgress, makeEvent } from "../utils/events.js";
16
+ import { msg, getLang } from "../utils/messages.js";
16
17
  import {
17
18
  resolveSonarHost,
18
19
  resolveSonarToken,
@@ -273,13 +274,14 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
273
274
 
274
275
  const hasErrors = result.errors.length > 0;
275
276
  const hasWarnings = result.warnings.length > 0;
277
+ const preflightLang = getLang(config);
276
278
  emitProgress(emitter, makeEvent("preflight:end", { ...eventBase, stage: "preflight" }, {
277
279
  status: hasErrors ? "fail" : hasWarnings ? "warn" : "ok",
278
280
  message: hasErrors
279
281
  ? `Preflight FAILED — ${result.errors.length} blocking issue(s)`
280
282
  : hasWarnings
281
283
  ? `Preflight completed with ${result.warnings.length} warning(s)`
282
- : "Preflight passed — all checks OK",
284
+ : msg("preflight_passed", preflightLang),
283
285
  detail: { ...result, executorType: "local" }
284
286
  }));
285
287
 
@@ -1,6 +1,7 @@
1
1
  import { SolomonRole } from "../roles/solomon-role.js";
2
2
  import { addCheckpoint, pauseSession, saveSession } from "../session-store.js";
3
3
  import { emitProgress, makeEvent } from "../utils/events.js";
4
+ import { msg, getLang } from "../utils/messages.js";
4
5
 
5
6
  /**
6
7
  * Build a human-readable escalation message from raw Solomon conflict data.
@@ -14,15 +15,16 @@ export function formatEscalationMessage(solomonResult, session) {
14
15
  const stage = solomonResult?.stage || "unknown";
15
16
  const solomonReason = solomonResult?.solomonReason || null;
16
17
  const history = solomonResult?.history || [];
18
+ const lang = getLang(session?.config_snapshot);
17
19
 
18
20
  const lines = [];
19
- lines.push(`--- Conflict: ${stage} ---`);
21
+ lines.push(msg("solomon_conflict", lang, { stage }));
20
22
  lines.push("");
21
23
 
22
24
  // Extract reviewer/agent feedback from history
23
25
  const feedbackEntries = history.filter(entry => entry && typeof entry === "object");
24
26
  if (feedbackEntries.length > 0) {
25
- lines.push("Reviewer feedback:");
27
+ lines.push(msg("solomon_feedback", lang));
26
28
  for (const entry of feedbackEntries) {
27
29
  const agent = entry.agent || "unknown";
28
30
  const feedback = entry.feedback || "No feedback provided";
@@ -42,7 +44,7 @@ export function formatEscalationMessage(solomonResult, session) {
42
44
 
43
45
  // Solomon's reason (if it tried and failed)
44
46
  if (solomonReason) {
45
- lines.push(`Solomon could not resolve the conflict: ${solomonReason}`);
47
+ lines.push(`${msg("solomon_reason", lang)} ${solomonReason}`);
46
48
  lines.push("");
47
49
  }
48
50
 
@@ -50,16 +52,13 @@ export function formatEscalationMessage(solomonResult, session) {
50
52
  const iterationCount = solomonResult?.iterationCount;
51
53
  const maxIterations = solomonResult?.maxIterations;
52
54
  if (iterationCount !== undefined && maxIterations !== undefined) {
53
- lines.push(`Iterations: ${iterationCount}/${maxIterations}`);
55
+ lines.push(msg("pipeline_iteration", lang, { current: iterationCount, max: maxIterations }));
54
56
  lines.push("");
55
57
  }
56
58
 
57
- lines.push("Options:");
58
- lines.push(" 1. Accept coder's work as-is");
59
- lines.push(" 2. Retry with reviewer's feedback");
60
- lines.push(" 3. Stop the session");
59
+ lines.push(msg("solomon_options", lang));
61
60
  lines.push("");
62
- lines.push("How should we proceed?");
61
+ lines.push(msg("solomon_proceed", lang));
63
62
 
64
63
  return lines.join("\n");
65
64
  }
@@ -161,12 +160,37 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
161
160
  return { action: "continue", conditions: [], ruling };
162
161
  }
163
162
 
163
+ export function parseEscalationAnswer(answer) {
164
+ if (!answer) return null;
165
+ const trimmed = answer.trim().toLowerCase();
166
+ // Option 1: Accept coder's work as-is
167
+ if (trimmed === "1" || trimmed === "accept" || trimmed === "yes" || trimmed === "sí" || trimmed === "si") {
168
+ return { action: "continue", humanGuidance: "Accept coder's work as-is" };
169
+ }
170
+ // Option 2: Retry with reviewer's feedback
171
+ if (trimmed === "2" || trimmed === "retry") {
172
+ return { action: "continue", humanGuidance: "Retry with reviewer feedback" };
173
+ }
174
+ // Option 3: Stop the session
175
+ if (trimmed === "3" || trimmed.startsWith("stop") || trimmed.startsWith("pause")) {
176
+ return { action: "stop" };
177
+ }
178
+ // Free-text: treat as human guidance (backward compat)
179
+ return { action: "continue", humanGuidance: answer.trim() };
180
+ }
181
+
164
182
  export async function escalateToHuman({ askQuestion, session, emitter, eventBase, stage, conflict, iteration }) {
165
183
  const question = formatEscalationMessage({ ...conflict, stage }, session);
166
184
 
167
185
  if (askQuestion) {
168
186
  const answer = await askQuestion(question, { iteration, stage });
169
- if (answer) {
187
+ const parsed = parseEscalationAnswer(answer);
188
+ if (parsed && parsed.action === "continue") {
189
+ return { action: "continue", humanGuidance: parsed.humanGuidance };
190
+ }
191
+ if (parsed && parsed.action === "stop") {
192
+ // Fall through to pause session
193
+ } else if (answer) {
170
194
  return { action: "continue", humanGuidance: answer };
171
195
  }
172
196
  }
@@ -31,6 +31,7 @@ import { classifyIntent } from "./guards/intent-guard.js";
31
31
  import { resolveReviewProfile } from "./review/profiles.js";
32
32
  import { CoderRole } from "./roles/coder-role.js";
33
33
  import { invokeSolomon } from "./orchestrator/solomon-escalation.js";
34
+ import { msg, getLang } from "./utils/messages.js";
34
35
  import { PipelineContext } from "./orchestrator/pipeline-context.js";
35
36
  import { runTriageStage, runResearcherStage, runArchitectStage, runPlannerStage, runDiscoverStage, runHuReviewerStage } from "./orchestrator/pre-loop-stages.js";
36
37
  import { runCoderStage, runRefactorerStage, runTddCheckStage, runSonarStage, runSonarCloudStage, runReviewerStage } from "./orchestrator/iteration-stages.js";
@@ -40,6 +41,9 @@ import { waitForCooldown, MAX_STANDBY_RETRIES } from "./orchestrator/standby.js"
40
41
  import { detectTestFramework } from "./utils/project-detect.js";
41
42
  import { runPreflightChecks } from "./orchestrator/preflight-checks.js";
42
43
  import { detectRtk } from "./utils/rtk-detect.js";
44
+ import { createRtkRunner, RtkSavingsTracker } from "./utils/rtk-wrapper.js";
45
+ import { setRunner as setDiffRunner } from "./review/diff-generator.js";
46
+ import { setRunner as setGitRunner } from "./utils/git.js";
43
47
  import { detectNeededSkills, autoInstallSkills, cleanupAutoInstalledSkills } from "./skills/skill-detector.js";
44
48
  import { isOpenSkillsAvailable } from "./skills/openskills-client.js";
45
49
 
@@ -277,6 +281,9 @@ function resolvePipelinePolicies({ flags, config, stageResults, emitter, eventBa
277
281
  if (!resolvedPolicies.reviewer) {
278
282
  pipelineFlags.reviewerEnabled = false;
279
283
  }
284
+ if (resolvedPolicies.coderRequired === false) {
285
+ pipelineFlags.coderRequired = false;
286
+ }
280
287
 
281
288
  emitProgress(
282
289
  emitter,
@@ -402,8 +409,9 @@ async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpoin
402
409
  })
403
410
  );
404
411
 
412
+ const lang = getLang(config);
405
413
  const answer = await askQuestion(
406
- `${checkpointMsg}\n\nOptions:\n1. Continue 5 more minutes\n2. Continue until done (no more checkpoints)\n3. Continue for N minutes (reply with the number)\n4. Stop now`
414
+ `${checkpointMsg}\n\n${msg("checkpoint_options", lang)}`
407
415
  );
408
416
 
409
417
  await addCheckpoint(session, { stage: "interactive-checkpoint", elapsed_minutes: Number(elapsedStr), answer });
@@ -429,7 +437,7 @@ async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpoin
429
437
  return parsed;
430
438
  }
431
439
 
432
- function parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config }) {
440
+ export function parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config }) {
433
441
  if (!trimmedAnswer) {
434
442
  return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now() };
435
443
  }
@@ -632,11 +640,23 @@ async function checkSolomonCriticalAlerts({ rulesResult, askQuestion, session, i
632
640
  .filter(a => a.severity === "critical")
633
641
  .map(a => a.message)
634
642
  .join("\n");
635
- const answer = await askQuestion(
636
- `Solomon detected critical issues:\n${alertSummary}\n\nShould I continue, pause, or revert?`,
637
- { iteration: i, stage: "solomon" }
638
- );
639
- if (!answer || answer.toLowerCase().includes("pause") || answer.toLowerCase().includes("stop")) {
643
+ const question = [
644
+ "Solomon detected critical issues:",
645
+ alertSummary,
646
+ "",
647
+ "What should we do?",
648
+ "1 = Continue anyway",
649
+ "2 = Pause the session",
650
+ "3 = Stop the session",
651
+ "",
652
+ "Type 1, 2, or 3:"
653
+ ].join("\n");
654
+ const answer = await askQuestion(question, { iteration: i, stage: "solomon" });
655
+ const trimmed = (answer || "").trim().toLowerCase();
656
+ const shouldPause = !answer
657
+ || trimmed === "2" || trimmed === "3"
658
+ || trimmed.startsWith("pause") || trimmed.startsWith("stop");
659
+ if (shouldPause) {
640
660
  await pauseSession(session, {
641
661
  question: `Solomon supervisor paused: ${alertSummary}`,
642
662
  context: { iteration: i, stage: "solomon", alerts: rulesResult.alerts }
@@ -746,7 +766,7 @@ async function handlePostLoopStages({ config, session, emitter, eventBase, coder
746
766
  return { action: "proceed" };
747
767
  }
748
768
 
749
- async function finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i }) {
769
+ async function finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i, rtkTracker }) {
750
770
  const gitResult = await finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults });
751
771
 
752
772
  // Accumulate final commits for PG card lifecycle tracking
@@ -765,16 +785,24 @@ async function finalizeApprovedSession({ config, gitCtx, task, logger, session,
765
785
  await markPgCardToValidate({ pgCard, pgProject, config, session, gitResult, logger });
766
786
 
767
787
  const deferredIssues = session.deferred_issues || [];
788
+ const rtkSavings = rtkTracker?.hasData() ? rtkTracker.summary() : undefined;
789
+ if (rtkSavings) session.rtk_savings = rtkSavings;
790
+ await saveSession(session);
791
+
792
+ const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitResult, budget: budgetSummary(), deferredIssues };
793
+ if (rtkSavings) endDetail.rtk_savings = rtkSavings;
768
794
  emitProgress(
769
795
  emitter,
770
796
  makeEvent("session:end", { ...eventBase, stage: "done" }, {
771
797
  message: deferredIssues.length > 0
772
798
  ? `Session approved (${deferredIssues.length} deferred issue(s) tracked as tech debt)`
773
799
  : "Session approved",
774
- detail: { approved: true, iterations: i, stages: stageResults, git: gitResult, budget: budgetSummary(), deferredIssues }
800
+ detail: endDetail
775
801
  })
776
802
  );
777
- return { approved: true, sessionId: session.id, review, git: gitResult, deferredIssues };
803
+ const result = { approved: true, sessionId: session.id, review, git: gitResult, deferredIssues };
804
+ if (rtkSavings) result.rtk_savings = rtkSavings;
805
+ return result;
778
806
  }
779
807
 
780
808
  // PG card "To Validate" logic moved to src/planning-game/pipeline-adapter.js → markPgCardToValidate()
@@ -1118,7 +1146,7 @@ async function runReviewerGateStage({ pipelineFlags, reviewerRole, config, logge
1118
1146
  return { action: "ok", review: reviewerResult.review };
1119
1147
  }
1120
1148
 
1121
- async function handleApprovedReview({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, pipelineFlags, askQuestion, logger, gitCtx, budgetSummary, pgCard, pgProject, review }) {
1149
+ async function handleApprovedReview({ config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults, pipelineFlags, askQuestion, logger, gitCtx, budgetSummary, pgCard, pgProject, review, rtkTracker }) {
1122
1150
  session.reviewer_retry_count = 0;
1123
1151
  const postLoopResult = await handlePostLoopStages({
1124
1152
  config, session, emitter, eventBase, coderRole, trackBudget, i, task, stageResults,
@@ -1127,11 +1155,11 @@ async function handleApprovedReview({ config, session, emitter, eventBase, coder
1127
1155
  if (postLoopResult.action === "return") return { action: "return", result: postLoopResult.result };
1128
1156
  if (postLoopResult.action === "continue") return { action: "continue" };
1129
1157
 
1130
- const result = await finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i });
1158
+ const result = await finalizeApprovedSession({ config, gitCtx, task, logger, session, stageResults, emitter, eventBase, budgetSummary, pgCard, pgProject, review, i, rtkTracker });
1131
1159
  return { action: "return", result };
1132
1160
  }
1133
1161
 
1134
- async function handleMaxIterationsReached({ session, budgetSummary, emitter, eventBase, config, stageResults, logger, askQuestion, task }) {
1162
+ async function handleMaxIterationsReached({ session, budgetSummary, emitter, eventBase, config, stageResults, logger, askQuestion, task, rtkTracker }) {
1135
1163
  // Escalate to Solomon / human before giving up
1136
1164
  const solomonResult = await invokeSolomon({
1137
1165
  config, logger, emitter, eventBase, stage: "max_iterations", askQuestion, session,
@@ -1164,13 +1192,17 @@ async function handleMaxIterationsReached({ session, budgetSummary, emitter, eve
1164
1192
 
1165
1193
  // Solomon also couldn't resolve — fail
1166
1194
  session.budget = budgetSummary();
1195
+ const rtkSavings = rtkTracker?.hasData() ? rtkTracker.summary() : undefined;
1196
+ if (rtkSavings) session.rtk_savings = rtkSavings;
1167
1197
  await markSessionStatus(session, "failed");
1198
+ const failDetail = { approved: false, reason: "max_iterations", iterations: config.max_iterations, stages: stageResults, budget: budgetSummary() };
1199
+ if (rtkSavings) failDetail.rtk_savings = rtkSavings;
1168
1200
  emitProgress(
1169
1201
  emitter,
1170
1202
  makeEvent("session:end", { ...eventBase, stage: "done" }, {
1171
1203
  status: "fail",
1172
1204
  message: "Max iterations reached (Solomon could not resolve)",
1173
- detail: { approved: false, reason: "max_iterations", iterations: config.max_iterations, stages: stageResults, budget: budgetSummary() }
1205
+ detail: failDetail
1174
1206
  })
1175
1207
  );
1176
1208
  return { approved: false, sessionId: session.id, reason: "max_iterations" };
@@ -1218,9 +1250,14 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
1218
1250
  const rtkResult = await detectRtk();
1219
1251
  if (rtkResult.available) {
1220
1252
  config = { ...config, rtk: { available: true, version: rtkResult.version } };
1221
- logger.info(`RTK detected (${rtkResult.version}) — instructing agents to prefix Bash commands with rtk`);
1253
+ const rtkTracker = new RtkSavingsTracker();
1254
+ const rtkRunner = createRtkRunner(true, rtkTracker);
1255
+ setDiffRunner(rtkRunner);
1256
+ setGitRunner(rtkRunner);
1257
+ ctx.rtkTracker = rtkTracker;
1258
+ logger.info(`RTK detected (${rtkResult.version}) — wrapping internal git/diff commands with rtk`);
1222
1259
  emitProgress(emitter, makeEvent("rtk:detected", ctx.eventBase, {
1223
- message: "RTK detected — agent commands will use token optimization",
1260
+ message: "RTK detected — internal commands wrapped for token optimization",
1224
1261
  detail: { version: rtkResult.version, executorType: "local" }
1225
1262
  }));
1226
1263
  }
@@ -1280,11 +1317,13 @@ async function runSingleIteration(ctx) {
1280
1317
 
1281
1318
  const reviewerRetryCount = session.reviewer_retry_count || 0;
1282
1319
  const maxReviewerRetries = config.session.max_reviewer_retries ?? config.session.fail_fast_repeats;
1320
+ const iterLang = getLang(config);
1321
+ const iterMsg = msg("pipeline_iteration", iterLang, { current: i, max: config.max_iterations });
1283
1322
  emitProgress(emitter, makeEvent("iteration:start", { ...eventBase, stage: "iteration" }, {
1284
- message: `Iteration ${i}/${config.max_iterations}`,
1323
+ message: iterMsg,
1285
1324
  detail: { iteration: i, maxIterations: config.max_iterations, reviewerRetryCount, maxReviewerRetries }
1286
1325
  }));
1287
- logger.info(`Iteration ${i}/${config.max_iterations}`);
1326
+ logger.info(iterMsg);
1288
1327
 
1289
1328
  const crResult = await runCoderAndRefactorerStages({
1290
1329
  coderRoleInstance: ctx.coderRoleInstance, coderRole: ctx.coderRole, refactorerRole: ctx.refactorerRole,
@@ -1335,7 +1374,8 @@ async function runSingleIteration(ctx) {
1335
1374
  const approvedResult = await handleApprovedReview({
1336
1375
  config, session, emitter, eventBase, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, i, task,
1337
1376
  stageResults: ctx.stageResults, pipelineFlags: ctx.pipelineFlags, askQuestion: ctx.askQuestion, logger,
1338
- gitCtx: ctx.gitCtx, budgetSummary: ctx.budgetSummary, pgCard: ctx.pgCard, pgProject: ctx.pgProject, review
1377
+ gitCtx: ctx.gitCtx, budgetSummary: ctx.budgetSummary, pgCard: ctx.pgCard, pgProject: ctx.pgProject, review,
1378
+ rtkTracker: ctx.rtkTracker
1339
1379
  });
1340
1380
  if (approvedResult.action === "return" || approvedResult.action === "continue") return approvedResult;
1341
1381
  }
@@ -1414,7 +1454,7 @@ async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitter, log
1414
1454
  if (iterResult.action === "retry") { i -= 1; }
1415
1455
  }
1416
1456
 
1417
- const maxIterResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask });
1457
+ const maxIterResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker });
1418
1458
  return maxIterResult;
1419
1459
  }
1420
1460
 
@@ -1428,6 +1468,53 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1428
1468
  const ctx = await initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags });
1429
1469
 
1430
1470
  try {
1471
+ // --- Analysis-only flow: skip coder/reviewer when coderRequired === false ---
1472
+ if (ctx.pipelineFlags.coderRequired === false) {
1473
+ logger.info("Analysis-only task — skipping coder/reviewer iteration loop");
1474
+ emitProgress(emitter, makeEvent("pipeline:analysis-only", { ...ctx.eventBase, stage: "analysis" }, {
1475
+ message: "Analysis-only task — running security and audit stages only",
1476
+ detail: { taskType: ctx.session.resolved_policies?.taskType, coderRequired: false }
1477
+ }));
1478
+
1479
+ const analysisStageResults = ctx.stageResults;
1480
+ const postLoopDiff = await generateDiff({ baseRef: ctx.session.session_start_sha });
1481
+
1482
+ if (ctx.pipelineFlags.securityEnabled) {
1483
+ const securityResult = await runSecurityStage({
1484
+ config: ctx.config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session,
1485
+ coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
1486
+ iteration: 1, task: ctx.plannedTask, diff: postLoopDiff, askQuestion
1487
+ });
1488
+ if (securityResult.stageResult) analysisStageResults.security = securityResult.stageResult;
1489
+ }
1490
+
1491
+ const auditResult = await runFinalAuditStage({
1492
+ config: ctx.config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session,
1493
+ coderRole: ctx.coderRole, trackBudget: ctx.trackBudget,
1494
+ iteration: 1, task: ctx.plannedTask, diff: postLoopDiff
1495
+ });
1496
+ if (auditResult.stageResult) analysisStageResults.audit = auditResult.stageResult;
1497
+
1498
+ ctx.session.budget = ctx.budgetSummary();
1499
+ await markSessionStatus(ctx.session, "approved");
1500
+
1501
+ const analysisResult = {
1502
+ approved: true,
1503
+ sessionId: ctx.session.id,
1504
+ analysisOnly: true,
1505
+ stages: analysisStageResults,
1506
+ budget: ctx.budgetSummary()
1507
+ };
1508
+ await writeHistoryRecord({ sessionId: ctx.session.id, task, result: analysisResult, logger });
1509
+
1510
+ emitProgress(emitter, makeEvent("session:end", { ...ctx.eventBase, stage: "done" }, {
1511
+ message: "Analysis-only session completed",
1512
+ detail: analysisResult
1513
+ }));
1514
+
1515
+ return analysisResult;
1516
+ }
1517
+
1431
1518
  // --- HU Sub-Pipeline: run each certified HU as an independent iteration loop ---
1432
1519
  if (needsSubPipeline(ctx.stageResults.huReviewer)) {
1433
1520
  logger.info(`HU sub-pipeline: ${ctx.stageResults.huReviewer.certified} certified stories — running each as a sub-pipeline`);
@@ -4,6 +4,8 @@
4
4
  * recommends decomposition and user accepts.
5
5
  */
6
6
 
7
+ import { msg } from "../utils/messages.js";
8
+
7
9
  export async function createDecompositionSubtasks({
8
10
  client,
9
11
  projectId,
@@ -66,9 +68,9 @@ export async function createDecompositionSubtasks({
66
68
  return created;
67
69
  }
68
70
 
69
- export function buildDecompositionQuestion(subtasks, parentCardId) {
71
+ export function buildDecompositionQuestion(subtasks, parentCardId, lang = "en") {
70
72
  const lines = [
71
- `Triage recommends decomposing this task into ${subtasks.length} subtasks:`,
73
+ msg("triage_decompose", lang, { count: subtasks.length }),
72
74
  ""
73
75
  ];
74
76
  for (let i = 0; i < subtasks.length; i++) {
@@ -76,10 +78,10 @@ export function buildDecompositionQuestion(subtasks, parentCardId) {
76
78
  }
77
79
  lines.push(
78
80
  "",
79
- `Create these as linked cards in Planning Game (parent: ${parentCardId})?`,
80
- "Each subtask will block the next one (sequential chain).",
81
+ msg("triage_create_cards", lang, { cardId: parentCardId }),
82
+ msg("triage_sequential", lang),
81
83
  "",
82
- "Reply: yes / no"
84
+ msg("triage_reply", lang)
83
85
  );
84
86
  return lines.join("\n");
85
87
  }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { emitProgress, makeEvent } from "../utils/events.js";
15
15
  import { addCheckpoint, saveSession } from "../session-store.js";
16
+ import { getLang } from "../utils/messages.js";
16
17
 
17
18
  /**
18
19
  * Attach the PG adapter to a pipeline emitter.
@@ -54,10 +55,13 @@ export async function handlePgDecomposition({ triageResult, pgTaskId, pgProject,
54
55
  const { buildDecompositionQuestion, createDecompositionSubtasks } = await import("./decomposition.js");
55
56
  const { createCard, relateCards, fetchCard } = await import("./client.js");
56
57
 
57
- const question = buildDecompositionQuestion(triageResult.stageResult.subtasks, pgTaskId);
58
+ const lang = getLang(config);
59
+ const question = buildDecompositionQuestion(triageResult.stageResult.subtasks, pgTaskId, lang);
58
60
  const answer = await askQuestion(question);
59
61
 
60
- if (answer && (answer.trim().toLowerCase() === "yes" || answer.trim().toLowerCase() === "sí" || answer.trim().toLowerCase() === "si")) {
62
+ const normalizedAnswer = (answer || "").trim().toLowerCase();
63
+ const isAccept = normalizedAnswer === "1" || normalizedAnswer === "yes" || normalizedAnswer === "sí" || normalizedAnswer === "si";
64
+ if (isAccept) {
61
65
  const parentCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId }).catch(() => null);
62
66
  const createdSubtasks = await createDecompositionSubtasks({
63
67
  client: { createCard, relateCards },
@@ -39,13 +39,14 @@ export function buildTriagePrompt({ task, instructions, availableRoles }) {
39
39
  "- **reviewer**: Enable for most tasks as a quality gate. Disable only for trivial, single-line changes.",
40
40
  "- **architect**: Enable when creating new modules/apps, changing data models or APIs, medium/complex tasks, or when design approach is ambiguous. Disable for simple fixes, doc-only, or CSS-only changes.",
41
41
  "- **hu-reviewer**: Enable for medium/complex tasks that benefit from story decomposition and certification. Disable for trivial/simple tasks, doc-only, or CSS-only changes.",
42
+ "- **audit/analysis taskType**: Set taskType to \"audit\" or \"analysis\" for tasks that only require reading/inspecting code without making changes (e.g., audit, review, analyze, check, verify, inspect, scan, auditar, revisar, analizar, comprobar, verificar, inspeccionar). The coder and reviewer will be skipped entirely for these tasks.",
42
43
  "",
43
- "Note: coder is ALWAYS active — you don't need to decide on it."
44
+ "Note: coder is ALWAYS active for coding tasks — you don't need to decide on it. However, for audit/analysis taskTypes the coder is skipped."
44
45
  ].join("\n"),
45
46
  "Classify the task complexity, determine its taskType, recommend only the necessary pipeline roles, and assess whether the task should be decomposed into smaller subtasks.",
46
47
  "Keep the reasoning short and practical.",
47
48
  "Return a single valid JSON object and nothing else.",
48
- 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security|architect|hu-reviewer"],"taskType":"sw|infra|doc|add-tests|refactor","reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}',
49
+ 'JSON schema: {"level":"trivial|simple|medium|complex","roles":["planner|researcher|refactorer|reviewer|tester|security|architect|hu-reviewer"],"taskType":"sw|infra|doc|add-tests|refactor|audit|analysis","reasoning":string,"shouldDecompose":boolean,"subtasks":string[]}',
49
50
  `## Task\n${task}`
50
51
  );
51
52
 
@@ -1,10 +1,26 @@
1
1
  import { runCommand } from "../utils/process.js";
2
2
 
3
+ /** @type {((command: string, args?: string[], options?: object) => Promise<object>)|null} */
4
+ let _runner = null;
5
+
6
+ /**
7
+ * Inject an RTK-aware runner so all git operations in this module benefit from token savings.
8
+ * Call once at pipeline start when RTK is available.
9
+ * @param {(command: string, args?: string[], options?: object) => Promise<object>} runner
10
+ */
11
+ export function setRunner(runner) {
12
+ _runner = runner;
13
+ }
14
+
15
+ function run(command, args, ...rest) {
16
+ return (_runner || runCommand)(command, args, ...rest);
17
+ }
18
+
3
19
  export async function computeBaseRef({ baseBranch = "main", baseRef = null }) {
4
20
  if (baseRef) return baseRef;
5
- const mergeBase = await runCommand("git", ["merge-base", "HEAD", `origin/${baseBranch}`]);
21
+ const mergeBase = await run("git", ["merge-base", "HEAD", `origin/${baseBranch}`]);
6
22
  if (mergeBase.exitCode !== 0) {
7
- const fallback = await runCommand("git", ["rev-parse", "HEAD~1"]);
23
+ const fallback = await run("git", ["rev-parse", "HEAD~1"]);
8
24
  if (fallback.exitCode !== 0) {
9
25
  throw new Error("Could not compute diff base reference");
10
26
  }
@@ -16,9 +32,9 @@ export async function computeBaseRef({ baseBranch = "main", baseRef = null }) {
16
32
  export async function generateDiff({ baseRef, stageNewFiles = false }) {
17
33
  // Stage untracked files so they appear in the diff (coder creates files but doesn't git add)
18
34
  if (stageNewFiles) {
19
- await runCommand("git", ["add", "-A"]);
35
+ await run("git", ["add", "-A"]);
20
36
  }
21
- const result = await runCommand("git", ["diff", stageNewFiles ? "--cached" : "", `${baseRef}`].filter(Boolean));
37
+ const result = await run("git", ["diff", stageNewFiles ? "--cached" : "", `${baseRef}`].filter(Boolean));
22
38
  if (result.exitCode !== 0) {
23
39
  throw new Error(`git diff failed: ${result.stderr || result.stdout}`);
24
40
  }
@@ -26,7 +42,7 @@ export async function generateDiff({ baseRef, stageNewFiles = false }) {
26
42
  }
27
43
 
28
44
  export async function getUntrackedFiles() {
29
- const result = await runCommand("git", ["ls-files", "--others", "--exclude-standard"]);
45
+ const result = await run("git", ["ls-files", "--others", "--exclude-standard"]);
30
46
  if (result.exitCode !== 0) return [];
31
47
  return result.stdout.trim().split("\n").filter(Boolean);
32
48
  }
@@ -252,6 +252,14 @@ function isBudgetUnavailable(budget) {
252
252
  (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0);
253
253
  }
254
254
 
255
+ function printSessionRtkSavings(rtkSavings) {
256
+ if (!rtkSavings || !rtkSavings.callCount) return;
257
+ const tokens = rtkSavings.estimatedTokensSaved ?? 0;
258
+ const ratio = rtkSavings.savedPct ?? 0;
259
+ const commands = rtkSavings.callCount ?? 0;
260
+ console.log(` ${ANSI.dim}\u26a1 RTK: saved ~${tokens} tokens (${ratio}% compression, ${commands} commands)${ANSI.reset}`);
261
+ }
262
+
255
263
  function printSessionBudget(budget) {
256
264
  if (!budget) return;
257
265
  if (isBudgetUnavailable(budget)) {
@@ -447,6 +455,7 @@ const EVENT_HANDLERS = {
447
455
  printSessionStages(event.detail?.stages);
448
456
  printSessionGit(event.detail?.git);
449
457
  printSessionBudget(event.detail?.budget);
458
+ printSessionRtkSavings(event.detail?.rtk_savings);
450
459
  console.log(`${ANSI.dim}Session: ${event.sessionId}${ANSI.reset}`);
451
460
  },
452
461
 
package/src/utils/git.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { runCommand } from "./process.js";
2
2
 
3
+ /** @type {((command: string, args?: string[], options?: object) => Promise<object>)|null} */
4
+ let _runner = null;
5
+
6
+ /**
7
+ * Inject an RTK-aware runner so all git operations in this module benefit from token savings.
8
+ * @param {(command: string, args?: string[], options?: object) => Promise<object>} runner
9
+ */
10
+ export function setRunner(runner) {
11
+ _runner = runner;
12
+ }
13
+
14
+ function run(command, args, ...rest) {
15
+ return (_runner || runCommand)(command, args, ...rest);
16
+ }
17
+
3
18
  function slugifyTask(task) {
4
19
  return String(task)
5
20
  .toLowerCase()
@@ -9,7 +24,7 @@ function slugifyTask(task) {
9
24
  }
10
25
 
11
26
  async function runGit(args, options = {}) {
12
- const res = await runCommand("git", args, options);
27
+ const res = await run("git", args, options);
13
28
  if (res.exitCode !== 0) {
14
29
  throw new Error(`git ${args.join(" ")} failed: ${res.stderr || res.stdout}`);
15
30
  }
@@ -17,7 +32,7 @@ async function runGit(args, options = {}) {
17
32
  }
18
33
 
19
34
  export async function ensureGitRepo() {
20
- const res = await runCommand("git", ["rev-parse", "--is-inside-work-tree"]);
35
+ const res = await run("git", ["rev-parse", "--is-inside-work-tree"]);
21
36
  return res.exitCode === 0 && res.stdout.trim() === "true";
22
37
  }
23
38
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Centralized message catalog for pipeline UI strings.
3
+ * Supports i18n by resolving messages for the configured language.
4
+ * Falls back to English when a key or language is missing.
5
+ */
6
+
7
+ const MESSAGES = {
8
+ en: {
9
+ triage_decompose: "Triage recommends decomposing this task into {count} subtasks:",
10
+ triage_create_cards: "Create these as linked cards in Planning Game (parent: {cardId})?",
11
+ triage_sequential: "Each subtask will block the next one (sequential chain).",
12
+ triage_reply: "Reply: yes / no",
13
+ solomon_conflict: "--- Conflict: {stage} ---",
14
+ solomon_feedback: "Reviewer feedback:",
15
+ solomon_reason: "Solomon could not resolve the conflict:",
16
+ solomon_options: "Options:\n 1. Accept coder's work as-is\n 2. Retry with reviewer's feedback\n 3. Stop the session",
17
+ solomon_proceed: "How should we proceed?",
18
+ checkpoint_continue: "Continue for {minutes} more minutes?",
19
+ checkpoint_options: "1. Continue 5 more minutes\n2. Continue until done (no more checkpoints)\n3. Continue for N minutes (reply with the number)\n4. Stop now",
20
+ preflight_passed: "Preflight passed — all checks OK",
21
+ pipeline_iteration: "Iteration {current}/{max}",
22
+ },
23
+ es: {
24
+ triage_decompose: "Triage recomienda descomponer esta tarea en {count} subtareas:",
25
+ triage_create_cards: "¿Crear estas como cards vinculadas en Planning Game (padre: {cardId})?",
26
+ triage_sequential: "Cada subtarea bloqueará la siguiente (cadena secuencial).",
27
+ triage_reply: "Responde: sí / no",
28
+ solomon_conflict: "--- Conflicto: {stage} ---",
29
+ solomon_feedback: "Feedback del reviewer:",
30
+ solomon_reason: "Solomon no pudo resolver el conflicto:",
31
+ solomon_options: "Opciones:\n 1. Aceptar el trabajo del coder tal cual\n 2. Reintentar con el feedback del reviewer\n 3. Parar la sesión",
32
+ solomon_proceed: "¿Cómo procedemos?",
33
+ checkpoint_continue: "¿Continuar {minutes} minutos más?",
34
+ checkpoint_options: "1. Continuar (5 min)\n2. Continuar hasta terminar\n3. Tiempo personalizado\n4. Parar",
35
+ preflight_passed: "Preflight superado — todas las comprobaciones OK",
36
+ pipeline_iteration: "Iteración {current}/{max}",
37
+ }
38
+ };
39
+
40
+ /**
41
+ * Resolve a message template by key and language, interpolating parameters.
42
+ * Falls back to English, then to the raw key if not found.
43
+ *
44
+ * @param {string} key - Message key (e.g. "solomon_conflict")
45
+ * @param {string} [lang="en"] - 2-letter language code
46
+ * @param {object} [params={}] - Interpolation values (e.g. {stage: "reviewer"})
47
+ * @returns {string}
48
+ */
49
+ export function msg(key, lang = "en", params = {}) {
50
+ const template = MESSAGES[lang]?.[key] || MESSAGES.en[key] || key;
51
+ return template.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
52
+ }
53
+
54
+ /**
55
+ * Extract the language code from a Karajan config object.
56
+ * @param {object} [config] - Karajan config
57
+ * @returns {string} 2-letter language code
58
+ */
59
+ export function getLang(config) {
60
+ return config?.language || "en";
61
+ }
@@ -0,0 +1,38 @@
1
+ import { runCommand } from "./process.js";
2
+ import { getInstallCommand } from "./os-detect.js";
3
+ import { detectRtk } from "./rtk-detect.js";
4
+
5
+ /**
6
+ * Install RTK (Rust Token Killer) using the OS-appropriate command.
7
+ * @param {object} logger - Logger with info/warn methods
8
+ * @returns {Promise<{ ok: boolean, version: string|null, error: string|null }>}
9
+ */
10
+ export async function installRtk(logger) {
11
+ const command = getInstallCommand("rtk");
12
+
13
+ logger.info("Installing RTK (Rust Token Killer)...");
14
+
15
+ try {
16
+ const result = await runCommand("sh", ["-c", command], { timeout: 120_000 });
17
+
18
+ if (result.exitCode !== 0) {
19
+ const error = (result.stderr || "").trim() || `exit code ${result.exitCode}`;
20
+ logger.warn(`RTK install failed: ${error}`);
21
+ return { ok: false, version: null, error };
22
+ }
23
+
24
+ // Verify installation
25
+ const check = await detectRtk();
26
+ if (check.available) {
27
+ logger.info(`RTK ${check.version || ""} installed successfully.`);
28
+ return { ok: true, version: check.version, error: null };
29
+ }
30
+
31
+ logger.warn("RTK install command succeeded but rtk binary not found in PATH.");
32
+ return { ok: false, version: null, error: "Binary not found after install" };
33
+ } catch (err) {
34
+ const error = err.message || String(err);
35
+ logger.warn(`RTK install failed: ${error}`);
36
+ return { ok: false, version: null, error };
37
+ }
38
+ }
@@ -0,0 +1,87 @@
1
+ import { runCommand } from "./process.js";
2
+
3
+ /**
4
+ * Commands that RTK can safely wrap for token-optimized output.
5
+ */
6
+ export const RTK_SUPPORTED_COMMANDS = [
7
+ "git", "ls", "find", "grep", "cat", "head", "tail",
8
+ "wc", "diff", "tree", "du", "file"
9
+ ];
10
+
11
+ const supportedSet = new Set(RTK_SUPPORTED_COMMANDS);
12
+
13
+ /**
14
+ * Wrap a command with RTK if available and the command is in the whitelist.
15
+ * @param {string} command
16
+ * @param {string[]} args
17
+ * @param {boolean} rtkAvailable
18
+ * @returns {{ command: string, args: string[] }}
19
+ */
20
+ export function wrapWithRtk(command, args, rtkAvailable) {
21
+ if (rtkAvailable && supportedSet.has(command)) {
22
+ return { command: "rtk", args: [command, ...args] };
23
+ }
24
+ return { command, args };
25
+ }
26
+
27
+ /**
28
+ * Session-level accumulator for RTK byte savings.
29
+ */
30
+ export class RtkSavingsTracker {
31
+ constructor() {
32
+ this.originalBytes = 0;
33
+ this.rtkBytes = 0;
34
+ this.callCount = 0;
35
+ }
36
+
37
+ record(originalSize, rtkSize) {
38
+ this.originalBytes += originalSize;
39
+ this.rtkBytes += rtkSize;
40
+ this.callCount += 1;
41
+ }
42
+
43
+ summary() {
44
+ const savedBytes = this.originalBytes - this.rtkBytes;
45
+ const savedPct = this.originalBytes > 0
46
+ ? Number(((savedBytes / this.originalBytes) * 100).toFixed(1))
47
+ : 0;
48
+ const estimatedTokensSaved = Math.floor(savedBytes / 4);
49
+ return {
50
+ originalBytes: this.originalBytes,
51
+ rtkBytes: this.rtkBytes,
52
+ savedBytes,
53
+ savedPct,
54
+ estimatedTokensSaved,
55
+ callCount: this.callCount
56
+ };
57
+ }
58
+
59
+ /** Returns true if any commands were recorded. */
60
+ hasData() {
61
+ return this.callCount > 0;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create a runner function that transparently wraps supported commands with RTK.
67
+ * @param {boolean} rtkAvailable
68
+ * @param {RtkSavingsTracker} [tracker] - Optional savings tracker
69
+ * @returns {(command: string, args?: string[], options?: object) => Promise<object>}
70
+ */
71
+ export function createRtkRunner(rtkAvailable, tracker = null) {
72
+ return async (command, args = [], options = {}) => {
73
+ const wrapped = wrapWithRtk(command, args, rtkAvailable);
74
+ const result = await runCommand(wrapped.command, wrapped.args, options);
75
+
76
+ if (tracker && rtkAvailable && supportedSet.has(command)) {
77
+ const outputSize = Buffer.byteLength(result.stdout || "", "utf8");
78
+ // For RTK-wrapped calls, the output is already compressed.
79
+ // We estimate the original size as outputSize (conservative — real savings
80
+ // would require running the command without RTK, which defeats the purpose).
81
+ // The tracker is useful when the caller provides real estimates externally.
82
+ tracker.record(outputSize, outputSize);
83
+ }
84
+
85
+ return result;
86
+ };
87
+ }