@kontextmind/kxm 0.7.150 → 0.7.151

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.
@@ -14992,7 +14992,7 @@ var TOOL_KEYS = Object.freeze(["preset", "allow", "deny"]);
14992
14992
  var TEMPLATE_KEYS = Object.freeze(["template", "schema"]);
14993
14993
  var POLICY_KEYS = Object.freeze(["vendorIndependenceRequired", "maxTransitions", "requiresGateVerification", "fallback"]);
14994
14994
  var FALLBACK_KEYS = Object.freeze(["onError", "maxSwitches", "revert"]);
14995
- var FALLBACK_ERRORS = Object.freeze(["rate_limit", "transport", "provider_unavailable"]);
14995
+ var FALLBACK_ERRORS = Object.freeze(["rate_limit", "transport", "provider_unavailable", "context_overflow"]);
14996
14996
  var FALLBACK_REVERT = Object.freeze(["next_run", "never"]);
14997
14997
  var ROSTER_ENTRY_KEYS = Object.freeze(["route", "effort", "mode"]);
14998
14998
  var CRITIC_PURPOSES = Object.freeze(["reviewer-arch", "reviewer-cli"]);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.150",
3
+ "version": "0.7.151",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -597,6 +597,7 @@ export async function cmdImprove(runtime: Runtime, options: { file?: string | un
597
597
  promotionPolicy: config.improvement.promotionPolicy,
598
598
  autoThreshold: config.improvement.autoThreshold,
599
599
  halfLifeDays: config.improvement.telemetryHalfLifeDays,
600
+ routeSwitches: loaded.routeSwitches,
600
601
  });
601
602
 
602
603
  const reportDir = join(runtime.dirs.assets, "improvements");
@@ -923,6 +924,7 @@ export async function cmdRoutingReport(
923
924
  let file: string;
924
925
  let records: Array<RoutingRecord | RoutingRecordV2>;
925
926
  let sources: RoutingSourceSummary[] | undefined;
927
+ let routeSwitches: LoadedRoutingSources["routeSwitches"] = [];
926
928
  if (options.file !== undefined) {
927
929
  file = options.file;
928
930
  records = readRoutingRecords(file).map((entry) => entry.routing);
@@ -933,6 +935,7 @@ export async function cmdRoutingReport(
933
935
  file = telemetryPath(runtime.dirs.logs);
934
936
  records = loaded.records;
935
937
  sources = loaded.sources;
938
+ routeSwitches = loaded.routeSwitches;
936
939
  }
937
940
  const includeEquivalentListCost = Boolean(options.equivalentListCost || options.listPrices);
938
941
 
@@ -946,7 +949,7 @@ export async function cmdRoutingReport(
946
949
  }
947
950
  }
948
951
 
949
- const report = generateRoutingReport(records, { catalog, includeEquivalentListCost });
952
+ const report = generateRoutingReport(records, { catalog, includeEquivalentListCost, routeSwitches });
950
953
 
951
954
  if (records.length === 0) {
952
955
  print(runtime.io, runtime.json, { ok: true, command: "routing report", file, ...(sources ? { sources } : {}), configurations: [], report }, "no routing records in telemetry");
@@ -555,6 +555,7 @@ export function foldKxmRunState(
555
555
  foldEffectUncertain(state, plan!, event);
556
556
  break;
557
557
  case "routing.attempt.recorded":
558
+ case "routing.route_switched":
558
559
  break;
559
560
  default:
560
561
  throw runtimeError("run_events_illegal", run.runId, `event type ${event.eventType} is not legal in this engine slice`);
@@ -3,6 +3,17 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { listRoleBindings, loadRoutePolicy } from "./routes.ts";
6
+ import {
7
+ carryForwardPrompt,
8
+ classifyRouteFailure,
9
+ decideRouteSwitch,
10
+ loadAgentFallback,
11
+ mergeSessionCarry,
12
+ parseSessionCarry,
13
+ type FallbackCandidate,
14
+ type FallbackPolicy,
15
+ type RouteSessionCarry,
16
+ } from "./route-fallback.ts";
6
17
  import { findYamlBasename } from "./workforce-names.mjs";
7
18
  import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
8
19
  import {
@@ -878,10 +889,58 @@ export function verifyKxmAttemptCapability(
878
889
  };
879
890
  }
880
891
 
892
+ interface ProducerFailureObservation {
893
+ classified: ReturnType<typeof classifyRouteFailure>;
894
+ sessionCarry?: RouteSessionCarry;
895
+ }
896
+
897
+ interface AttemptFallbackState {
898
+ policy: FallbackPolicy;
899
+ chain: FallbackCandidate[];
900
+ index: number;
901
+ switches: number;
902
+ basePrompt: string;
903
+ stickyRouteId?: string;
904
+ }
905
+
906
+ const attemptFallback = new Map<string, AttemptFallbackState>();
907
+ const runStickyRoute = new Map<string, string>();
908
+
909
+ function observeProducerFailure(error: unknown, capability: string | undefined): { code: string; failure: ProducerFailureObservation } {
910
+ // Never throw while classifying a throw: a producer can reject with anything
911
+ // (Object.create(null) defeats String(), an Error whose message is not a string
912
+ // defeats split()), and a throwing classifier would reroute a producer_rejected
913
+ // settle into the execution-error path.
914
+ try {
915
+ const raw = error instanceof Error ? error.message : error;
916
+ const message = typeof raw === "string" ? raw : "";
917
+ let text = message;
918
+ if (capability) text = text.split(capability).join("[redacted]");
919
+ text = text.replace(/kxmcap_[A-Za-z0-9_-]+/g, "[redacted]");
920
+ const match = /^[a-z][a-z0-9]*(?:_[a-z0-9]+){1,3}/.exec(text);
921
+ const extracted = match?.[0] ?? "";
922
+ const code = extracted.length > 0 && extracted.length <= 64 ? extracted : "producer_error";
923
+ const record = error && typeof error === "object" ? error as Record<string, unknown> : undefined;
924
+ const statusValue = record?.statusCode ?? record?.status ?? record?.httpStatus;
925
+ const httpStatus = typeof statusValue === "number" && Number.isInteger(statusValue) ? statusValue : undefined;
926
+ const name = error instanceof Error ? error.name : undefined;
927
+ const sessionCarry = parseSessionCarry(record?.sessionCarry);
928
+ return {
929
+ code,
930
+ failure: {
931
+ classified: classifyRouteFailure({ code, text: text.slice(0, 500), httpStatus, name }),
932
+ ...(sessionCarry ? { sessionCarry } : {}),
933
+ },
934
+ };
935
+ } catch {
936
+ return { code: "producer_error", failure: { classified: classifyRouteFailure({ code: "producer_error" }) } };
937
+ }
938
+ }
939
+
881
940
  function invokeProducer(
882
941
  producer: KxmProducer,
883
942
  request: KxmProducerRequest,
884
- ): Promise<{ result?: KxmProducerResult; error: boolean; errorCode?: string }> {
943
+ ): Promise<{ result?: KxmProducerResult; error: boolean; errorCode?: string; failure?: ProducerFailureObservation }> {
885
944
  // The reason a producer refused is the single most useful fact about a failed attempt,
886
945
  // and it used to be discarded here: a live drive that could not authenticate read in the
887
946
  // event log exactly like a simulation that ran fine. But the message is producer-
@@ -890,36 +949,184 @@ function invokeProducer(
890
949
  // So: redact the attempt capability and anything capability-shaped, then keep only a
891
950
  // leading machine-code identifier (lowercase segments joined by underscores). Anything
892
951
  // else — prose, free text, a redacted-to-empty message — records as producer_error.
893
- const reason = (error: unknown): string => {
894
- // Never throw while classifying a throw: a producer can reject with anything
895
- // (Object.create(null) defeats String(), an Error whose message is not a string
896
- // defeats split()), and a throwing classifier would reroute a producer_rejected
897
- // settle into the execution-error path — the exact invisibility this exists to fix.
898
- try {
899
- const raw = error instanceof Error ? error.message : error;
900
- const message = typeof raw === "string" ? raw : "";
901
- let text = message;
902
- if (request.capability) text = text.split(request.capability).join("[redacted]");
903
- text = text.replace(/kxmcap_[A-Za-z0-9_-]+/g, "[redacted]");
904
- const match = /^[a-z][a-z0-9]*(?:_[a-z0-9]+){1,3}/.exec(text);
905
- const code = match?.[0] ?? "";
906
- return code.length > 0 && code.length <= 64 ? code : "producer_error";
907
- } catch {
908
- return "producer_error";
909
- }
910
- };
911
952
  let pending: Promise<KxmProducerResult>;
912
953
  try {
913
954
  pending = Promise.resolve(producer.produce(request));
914
955
  } catch (error) {
915
- return Promise.resolve({ error: true, errorCode: reason(error) });
956
+ const observed = observeProducerFailure(error, request.capability);
957
+ return Promise.resolve({ error: true, errorCode: observed.code, failure: observed.failure });
916
958
  }
917
959
  return pending.then(
918
960
  (result) => ({ result, error: false }),
919
- (error) => ({ error: true, errorCode: reason(error) }),
961
+ (error) => {
962
+ const observed = observeProducerFailure(error, request.capability);
963
+ return { error: true, errorCode: observed.code, failure: observed.failure };
964
+ },
920
965
  );
921
966
  }
922
967
 
968
+ function stickyRouteFromEvents(context: KxmRuntimeContext, runId: string): string | undefined {
969
+ const remembered = runStickyRoute.get(runId);
970
+ if (remembered) return remembered;
971
+ let found: string | undefined;
972
+ for (const event of context.eventStore.events(runId, 0, 100000)) {
973
+ if (event.eventType !== "routing.attempt.recorded") continue;
974
+ const routing = (event.payload as { routing?: { providerMetadata?: { fallbackRoute?: unknown } } }).routing;
975
+ const route = routing?.providerMetadata?.fallbackRoute;
976
+ if (typeof route === "string" && route.length > 0) found = route;
977
+ }
978
+ return found;
979
+ }
980
+
981
+ function stickyWalkRoute(
982
+ context: KxmRuntimeContext,
983
+ step: KxmCompiledStep,
984
+ agentId: string,
985
+ runId: string,
986
+ liveWrite: boolean,
987
+ ): ResolvedProducerRoute | undefined {
988
+ const loaded = loadAgentFallback(context.projectRoot, agentId, { liveWrite });
989
+ if (!loaded || loaded.policy.revert !== "never") return undefined;
990
+ const sticky = stickyRouteFromEvents(context, runId);
991
+ if (!sticky) return undefined;
992
+ const candidate = loaded.chain.find((item) => item.routeId === sticky);
993
+ if (!candidate) return undefined;
994
+ return candidateToRoute(candidate, loaded.roleId);
995
+ }
996
+
997
+ function candidateToRoute(candidate: FallbackCandidate, role: string): ResolvedProducerRoute {
998
+ return {
999
+ provider: candidate.provider,
1000
+ model: candidate.model,
1001
+ selector: candidate.selector,
1002
+ harness: candidate.harness,
1003
+ routeId: candidate.routeId,
1004
+ role,
1005
+ permissions: [...candidate.permissions],
1006
+ ...(candidate.effort ? { effort: candidate.effort } : {}),
1007
+ };
1008
+ }
1009
+
1010
+ function rememberAttemptFallback(
1011
+ attemptId: string,
1012
+ loaded: { policy: FallbackPolicy; chain: FallbackCandidate[] },
1013
+ index: number,
1014
+ basePrompt: string,
1015
+ ): void {
1016
+ if (loaded.chain.length < 2 || loaded.policy.onError.length === 0 || loaded.policy.maxSwitches <= 0) return;
1017
+ if (index < 0 || index >= loaded.chain.length) return;
1018
+ attemptFallback.set(attemptId, {
1019
+ policy: loaded.policy,
1020
+ chain: loaded.chain,
1021
+ index,
1022
+ switches: 0,
1023
+ basePrompt,
1024
+ });
1025
+ }
1026
+
1027
+ function appendRouteSwitch(
1028
+ context: KxmRuntimeContext,
1029
+ member: PreparedDispatch,
1030
+ routeSwitch: { from: string; to: string; reason: string; effort?: string },
1031
+ ): void {
1032
+ const run = requireRun(context, member.run.runId);
1033
+ const now = new Date().toISOString();
1034
+ const sequence = context.eventStore.nextSequence(run.runId);
1035
+ const event: KxmRunEvent = {
1036
+ ...kxmEventBase(context, run, now, kxmMonotonicNs()),
1037
+ eventId: newKxmEventId(),
1038
+ eventType: "routing.route_switched",
1039
+ sequence,
1040
+ payload: {
1041
+ attemptId: member.attemptId,
1042
+ stepId: member.stepId,
1043
+ routeSwitch: {
1044
+ from: routeSwitch.from,
1045
+ to: routeSwitch.to,
1046
+ reason: routeSwitch.reason,
1047
+ ...(routeSwitch.effort ? { effort: routeSwitch.effort } : {}),
1048
+ },
1049
+ },
1050
+ };
1051
+ context.eventStore.appendEvent(event);
1052
+ const next = foldStoredKxmRun(context, run);
1053
+ persistKxmRunState(context, run.runId, next, sequence);
1054
+ try {
1055
+ context.logger?.({
1056
+ event: "route_switch",
1057
+ runId: run.runId,
1058
+ attemptId: member.attemptId,
1059
+ stepId: member.stepId,
1060
+ from: routeSwitch.from,
1061
+ to: routeSwitch.to,
1062
+ reason: routeSwitch.reason,
1063
+ ...(routeSwitch.effort ? { effort: routeSwitch.effort } : {}),
1064
+ });
1065
+ } catch {
1066
+ // Logging is best effort and never fails a switch.
1067
+ }
1068
+ }
1069
+
1070
+ async function produceWithRouteFallback(
1071
+ context: KxmRuntimeContext,
1072
+ member: PreparedDispatch,
1073
+ producer: KxmProducer,
1074
+ ): Promise<{ result?: KxmProducerResult; error: boolean; errorCode?: string }> {
1075
+ const session = attemptFallback.get(member.attemptId);
1076
+ let request = member.request;
1077
+ let produced = await invokeProducer(producer, request);
1078
+ if (!session) return producerOutcome(produced);
1079
+ let carry: RouteSessionCarry | undefined;
1080
+ while (produced.error) {
1081
+ const current = session.chain[session.index];
1082
+ if (!current) break;
1083
+ const failure = produced.failure?.classified ?? classifyRouteFailure({ code: produced.errorCode, text: produced.errorCode });
1084
+ const decision = decideRouteSwitch({
1085
+ policy: session.policy,
1086
+ chain: session.chain,
1087
+ currentRouteId: current.routeId,
1088
+ switchesUsed: session.switches,
1089
+ failure,
1090
+ ...(current.effort || member.request.thinking ? { currentEffort: current.effort ?? member.request.thinking } : {}),
1091
+ });
1092
+ if (decision.action !== "switch") break;
1093
+ appendRouteSwitch(context, member, {
1094
+ from: current.routeId,
1095
+ to: decision.to.routeId,
1096
+ reason: decision.reason,
1097
+ ...(decision.effort ? { effort: decision.effort } : {}),
1098
+ });
1099
+ session.switches += 1;
1100
+ session.index = session.chain.findIndex((candidate) => candidate.routeId === decision.to.routeId);
1101
+ carry = mergeSessionCarry(carry, produced.failure?.sessionCarry);
1102
+ request = {
1103
+ ...member.request,
1104
+ prompt: carryForwardPrompt(session.basePrompt, carry),
1105
+ model: decision.to.model,
1106
+ provider: decision.to.provider,
1107
+ harness: decision.to.harness,
1108
+ ...(decision.effort ? { thinking: decision.effort } : {}),
1109
+ };
1110
+ produced = await invokeProducer(producer, request);
1111
+ }
1112
+ if (!produced.error && session.switches > 0 && session.policy.revert === "never") {
1113
+ const route = session.chain[session.index];
1114
+ if (route) {
1115
+ session.stickyRouteId = route.routeId;
1116
+ runStickyRoute.set(member.run.runId, route.routeId);
1117
+ }
1118
+ }
1119
+ return producerOutcome(produced);
1120
+ }
1121
+
1122
+ function producerOutcome(produced: { result?: KxmProducerResult; error: boolean; errorCode?: string }): { result?: KxmProducerResult; error: boolean; errorCode?: string } {
1123
+ return {
1124
+ error: produced.error,
1125
+ ...(produced.result !== undefined ? { result: produced.result } : {}),
1126
+ ...(produced.errorCode !== undefined ? { errorCode: produced.errorCode } : {}),
1127
+ };
1128
+ }
1129
+
923
1130
  export interface KxmPreparedGateDispatch {
924
1131
  readonly run: KxmRunRecord;
925
1132
  readonly envelope: KxmRunPlanEnvelope;
@@ -1748,6 +1955,14 @@ function prepareDispatch(
1748
1955
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
1749
1956
  }
1750
1957
  resolvedRoute = routeResult;
1958
+ const stickyRoute = stickyWalkRoute(
1959
+ context,
1960
+ step,
1961
+ agentId,
1962
+ run.runId,
1963
+ Object.values(step.repositories).some((access) => access === "write"),
1964
+ );
1965
+ if (stickyRoute && stickyRoute.routeId !== resolvedRoute.routeId) resolvedRoute = stickyRoute;
1751
1966
  const writeRefusal = unsupportedLiveWrite(
1752
1967
  context.projectRoot,
1753
1968
  step,
@@ -1885,6 +2100,27 @@ function birthMember(
1885
2100
  }
1886
2101
  resolvedRoute = routeResult;
1887
2102
  }
2103
+ const liveProducer = Boolean(input.producerId && input.producerId !== "driver-simulated");
2104
+ const liveWrite = liveProducer && Object.values(input.step.repositories).some((access) => access === "write");
2105
+ const loadedFallback = loadAgentFallback(context.projectRoot, agentId, { liveWrite });
2106
+ let fallbackIndex = -1;
2107
+ if (loadedFallback && loadedFallback.chain.length > 1 && loadedFallback.policy.maxSwitches > 0) {
2108
+ const sticky = loadedFallback.policy.revert === "never" ? stickyRouteFromEvents(context, run.runId) : undefined;
2109
+ const stickyIndex = sticky ? loadedFallback.chain.findIndex((candidate) => candidate.routeId === sticky) : -1;
2110
+ if (!resolvedRoute) {
2111
+ fallbackIndex = stickyIndex > 0 ? stickyIndex : 0;
2112
+ const start = loadedFallback.chain[fallbackIndex];
2113
+ if (start) resolvedRoute = candidateToRoute(start, loadedFallback.roleId);
2114
+ } else if (stickyIndex > 0 && resolvedRoute.routeId !== loadedFallback.chain[stickyIndex]?.routeId) {
2115
+ const start = loadedFallback.chain[stickyIndex];
2116
+ if (start) {
2117
+ resolvedRoute = candidateToRoute(start, loadedFallback.roleId);
2118
+ fallbackIndex = stickyIndex;
2119
+ }
2120
+ } else {
2121
+ fallbackIndex = loadedFallback.chain.findIndex((candidate) => candidate.routeId === resolvedRoute?.routeId);
2122
+ }
2123
+ }
1888
2124
  const dispatchContext = assembleDispatchContext(input.dispatchSources, {
1889
2125
  projectId: run.projectId,
1890
2126
  runId: run.runId,
@@ -2024,6 +2260,9 @@ function birthMember(
2024
2260
  // Logging is best effort and never fails a birth.
2025
2261
  }
2026
2262
  }
2263
+ if (loadedFallback && fallbackIndex >= 0) {
2264
+ rememberAttemptFallback(attemptId, loadedFallback, fallbackIndex, member.request.prompt ?? "");
2265
+ }
2027
2266
  kxmPanelDispatchSeams.afterBirth?.(member);
2028
2267
  return member;
2029
2268
  }
@@ -2172,7 +2411,7 @@ async function drivePanel(
2172
2411
  const live = member.producerId !== "driver-simulated";
2173
2412
  const writes = Object.values(member.step.repositories).some((access) => access === "write");
2174
2413
  const before = live ? captureWorktreeWitness(context.projectRoot) : undefined;
2175
- const produced = await invokeProducer(producer, member.request);
2414
+ const produced = await produceWithRouteFallback(context, member, producer);
2176
2415
  if (live && produced.result && before) {
2177
2416
  const after = captureWorktreeWitness(context.projectRoot);
2178
2417
  return {
@@ -2384,6 +2623,8 @@ function engineRoutingMetadata(
2384
2623
  // Already `sha256:<hex>` from acceptance; the prompt text is never read here.
2385
2624
  metadata.objectiveSha256 = dispatch.run.promptSha256;
2386
2625
  metadata.stepWrites = Object.values(dispatch.step.repositories).some((access) => access === "write");
2626
+ const stickyRouteId = attemptFallback.get(dispatch.attemptId)?.stickyRouteId;
2627
+ if (stickyRouteId && Object.keys(metadata).length < MAX_PROVIDER_METADATA_FIELDS) metadata.fallbackRoute = stickyRouteId;
2387
2628
  return metadata;
2388
2629
  }
2389
2630
 
@@ -2555,6 +2796,7 @@ function settleMember(
2555
2796
  }
2556
2797
  }
2557
2798
  context.eventStore.settleCapability(dispatch.attemptId, "settled");
2799
+ attemptFallback.delete(dispatch.attemptId);
2558
2800
  for (const event of events) context.eventStore.appendEvent(event);
2559
2801
  const next = foldStoredKxmRun(context, run);
2560
2802
  persistKxmRunState(context, run.runId, next, events[events.length - 1]!.sequence);
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { DatabaseSync } from "./sqlite.ts";
4
4
  import { discoverKxmProjectRoot } from "./project-config.ts";
5
5
  import { kxmProjectRunEventsPath } from "./runtime-store.ts";
6
+ import { parseRouteSwitchRecord, type RouteSwitchRecord } from "./route-switch.ts";
6
7
  import { parseRoutingRecordV2, ROUTING_RECORD_V2_SCHEMA, type RoutingRecord, type RoutingRecordV2 } from "./routing.ts";
7
8
  import { readRoutingRecords, telemetryPath } from "./telemetry.ts";
8
9
 
@@ -30,16 +31,18 @@ export interface RoutingSourceSummary {
30
31
  export interface EngineRoutingRead {
31
32
  records: RoutingRecordV2[];
32
33
  source: RoutingSourceSummary;
34
+ routeSwitches: RouteSwitchRecord[];
33
35
  }
34
36
 
35
37
  export interface LoadedRoutingSources {
36
38
  projectRoot?: string;
37
39
  records: Array<RoutingRecord | RoutingRecordV2>;
38
40
  sources: RoutingSourceSummary[];
41
+ routeSwitches: RouteSwitchRecord[];
39
42
  }
40
43
 
41
44
  const ENGINE_EVENTS_SQL = "SELECT run_id, sequence, event_type, payload FROM events"
42
- + " WHERE event_type IN ('routing.attempt.recorded','step.entered','run.status_changed')"
45
+ + " WHERE event_type IN ('routing.attempt.recorded','routing.route_switched','step.entered','run.status_changed')"
43
46
  + " ORDER BY run_id, sequence";
44
47
 
45
48
  /** The simulated producer's harness label; its attempts measure nothing. */
@@ -81,7 +84,7 @@ export function readEngineRoutingRecords(path: string): EngineRoutingRead {
81
84
  undecided: 0,
82
85
  duplicatesDropped: 0,
83
86
  };
84
- if (!existsSync(path)) return { records: [], source };
87
+ if (!existsSync(path)) return { records: [], source, routeSwitches: [] };
85
88
  source.exists = true;
86
89
 
87
90
  let rows: Array<Record<string, unknown>>;
@@ -102,6 +105,7 @@ export function readEngineRoutingRecords(path: string): EngineRoutingRead {
102
105
  }
103
106
 
104
107
  const runs = new Map<string, RunLog>();
108
+ const routeSwitches: RouteSwitchRecord[] = [];
105
109
  for (const row of rows) {
106
110
  const runId = String(row.run_id);
107
111
  const sequence = Number(row.sequence);
@@ -114,6 +118,14 @@ export function readEngineRoutingRecords(path: string): EngineRoutingRead {
114
118
  }
115
119
  if (eventType === "routing.attempt.recorded") {
116
120
  run.routing.push({ sequence, payload });
121
+ } else if (eventType === "routing.route_switched") {
122
+ const parsed = parsePayload(payload);
123
+ const record = parseRouteSwitchRecord(parsed, {
124
+ attemptId: typeof parsed?.attemptId === "string" ? parsed.attemptId : undefined,
125
+ stepId: typeof parsed?.stepId === "string" ? parsed.stepId : undefined,
126
+ runId,
127
+ });
128
+ if (record) routeSwitches.push(record);
117
129
  } else if (eventType === "step.entered") {
118
130
  const stepId = parsePayload(payload)?.stepId;
119
131
  if (typeof stepId === "string") run.lastEntered.set(stepId, sequence);
@@ -158,6 +170,7 @@ export function readEngineRoutingRecords(path: string): EngineRoutingRead {
158
170
  }
159
171
  return {
160
172
  records,
173
+ routeSwitches,
161
174
  source: { ...source, records: records.length, skippedInvalid, excludedSimulated, undecided },
162
175
  };
163
176
  }
@@ -189,7 +202,7 @@ export function loadRoutingSources(options: {
189
202
  file?: string | undefined;
190
203
  }): LoadedRoutingSources {
191
204
  const projectRoot = discoverKxmProjectRoot(options.cwd);
192
- const reads: Array<{ records: Array<RoutingRecord | RoutingRecordV2>; source: RoutingSourceSummary }> = [];
205
+ const reads: Array<{ records: Array<RoutingRecord | RoutingRecordV2>; source: RoutingSourceSummary; routeSwitches?: RouteSwitchRecord[] }> = [];
193
206
  if (options.file !== undefined) {
194
207
  reads.push(readJsonlSource("file", resolve(options.cwd, options.file)));
195
208
  } else {
@@ -219,5 +232,6 @@ export function loadRoutingSources(options: {
219
232
  ...(projectRoot !== undefined ? { projectRoot } : {}),
220
233
  records,
221
234
  sources: reads.map((read) => read.source),
235
+ routeSwitches: reads.flatMap((read) => read.routeSwitches ?? []),
222
236
  };
223
237
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import type { RouteSwitchRecord } from "./route-switch.ts";
4
5
  import { computeDecayedWeight, type RoutingRecord, type RoutingRecordV2 } from "./routing.ts";
5
6
  import { nowIso } from "./protocol.ts";
6
7
  import { compareCodeUnitIds } from "./relevance.ts";
@@ -79,6 +80,7 @@ export interface ImprovementReport {
79
80
  candidates: ImprovementCandidate[];
80
81
  promotionPolicy: string;
81
82
  promotion: ImprovementPromotionEntry[];
83
+ routeSwitches?: RouteSwitchRecord[] | undefined;
82
84
  }
83
85
 
84
86
  export function classifyCandidateKind(stepId: string, agentRole: string): CandidateKind {
@@ -426,6 +428,7 @@ export interface BuildImprovementReportOptions extends GroupRoutingOptions {
426
428
  /** improvement.promotionPolicy; reported as readiness only. */
427
429
  promotionPolicy?: string | undefined;
428
430
  autoThreshold?: PromotionAutoThreshold | undefined;
431
+ routeSwitches?: readonly RouteSwitchRecord[] | undefined;
429
432
  }
430
433
 
431
434
  export function buildImprovementReport(
@@ -498,6 +501,7 @@ export function buildImprovementReport(
498
501
  });
499
502
  }
500
503
 
504
+ const routeSwitches = options.routeSwitches?.filter((item) => item.from && item.to) ?? [];
501
505
  return {
502
506
  schema: IMPROVEMENT_REPORT_SCHEMA,
503
507
  createdAt: nowIso(),
@@ -507,6 +511,7 @@ export function buildImprovementReport(
507
511
  candidates,
508
512
  promotionPolicy,
509
513
  promotion,
514
+ ...(routeSwitches.length > 0 ? { routeSwitches: [...routeSwitches] } : {}),
510
515
  };
511
516
  }
512
517
 
@@ -547,6 +552,15 @@ export function formatImprovementReport(report: ImprovementReport): string {
547
552
  lines.push(`${wf} ${step} ${role} ${prompt} ${rec} ${runs} ${asks} ${weighted} ${cost} ${lat} ${accepted} ${rework} ${cand}`);
548
553
  }
549
554
 
555
+ if (report.routeSwitches && report.routeSwitches.length > 0) {
556
+ lines.push("");
557
+ lines.push(`Route switches (${report.routeSwitches.length}):`);
558
+ for (const item of report.routeSwitches) {
559
+ const effort = item.effort ? ` effort ${item.effort}` : "";
560
+ lines.push(` - ${item.from} -> ${item.to} (${item.reason}${effort}) on ${item.stepId}`);
561
+ }
562
+ }
563
+
550
564
  if (report.candidates.length > 0) {
551
565
  const readiness = new Map(report.promotion.map((entry) => [entry.candidateId, entry]));
552
566
  lines.push("");
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.150";
14
+ const VERSION = "0.7.151";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -33,7 +33,7 @@ const TOOL_KEYS = Object.freeze(["preset", "allow", "deny"]);
33
33
  const TEMPLATE_KEYS = Object.freeze(["template", "schema"]);
34
34
  const POLICY_KEYS = Object.freeze(["vendorIndependenceRequired", "maxTransitions", "requiresGateVerification", "fallback"]);
35
35
  const FALLBACK_KEYS = Object.freeze(["onError", "maxSwitches", "revert"]);
36
- const FALLBACK_ERRORS = Object.freeze(["rate_limit", "transport", "provider_unavailable"]);
36
+ const FALLBACK_ERRORS = Object.freeze(["rate_limit", "transport", "provider_unavailable", "context_overflow"]);
37
37
  const FALLBACK_REVERT = Object.freeze(["next_run", "never"]);
38
38
  const ROSTER_ENTRY_KEYS = Object.freeze(["route", "effort", "mode"]);
39
39
  const CRITIC_PURPOSES = Object.freeze(["reviewer-arch", "reviewer-cli"]);
@@ -398,7 +398,7 @@ function validateRoleShape(document, id, label, issues) {
398
398
  if (fallback.onError !== undefined) {
399
399
  if (!Array.isArray(fallback.onError) || new Set(fallback.onError).size !== fallback.onError.length
400
400
  || fallback.onError.some((item) => !FALLBACK_ERRORS.includes(item))) {
401
- issues.push(issue("schema", "schema_enum", path, "onError must be unique rate_limit, transport, or provider_unavailable values"));
401
+ issues.push(issue("schema", "schema_enum", path, "onError must be unique rate_limit, transport, provider_unavailable, or context_overflow values"));
402
402
  }
403
403
  }
404
404
  if (fallback.maxSwitches !== undefined && (!Number.isInteger(fallback.maxSwitches) || fallback.maxSwitches < 0)) {
@@ -32,7 +32,7 @@ export interface KxmTemplateContract {
32
32
  }
33
33
 
34
34
  export interface KxmRoleFallback {
35
- onError?: Array<"rate_limit" | "transport" | "provider_unavailable"> | undefined;
35
+ onError?: Array<"rate_limit" | "transport" | "provider_unavailable" | "context_overflow"> | undefined;
36
36
  maxSwitches?: number | undefined;
37
37
  revert?: "next_run" | "never" | undefined;
38
38
  }