@kontextmind/kxm 0.6.0 → 0.7.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +5 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +2 -0
  14. package/docs/agent-skills.md +118 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/assignment-runner.md +21 -8
  17. package/docs/configuration.md +2 -2
  18. package/docs/kxm-handbook.md +3 -3
  19. package/docs/operator-pi-packages.md +63 -0
  20. package/docs/skills/repo-work-delivery.md +102 -0
  21. package/docs/test-matrix.md +4 -3
  22. package/docs/troubleshooting.md +19 -0
  23. package/docs/vnext/validation.md +9 -0
  24. package/docs/webhook-workflows.md +2 -2
  25. package/examples/README.md +1 -1
  26. package/package.json +16 -17
  27. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  28. package/plugins/kxm/README.md +1 -1
  29. package/plugins/kxm/dist/cli.js +6875 -3672
  30. package/plugins/kxm/dist/core.js +214 -34
  31. package/plugins/kxm/dist/extension.js +7721 -85
  32. package/plugins/kxm/dist/mcp-server.js +75 -21
  33. package/plugins/kxm/dist/runtime.js +5403 -1014
  34. package/plugins/kxm/dist/server.js +3008 -2268
  35. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5751 -631
  36. package/plugins/kxm/package.json +1 -1
  37. package/plugins/kxm/skills/SUITE.md +5 -0
  38. package/plugins/kxm/skills/hints.json +73 -0
  39. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  40. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  41. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  42. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  43. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  44. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +34 -0
  45. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  46. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  47. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  48. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +35 -0
  49. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  50. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  51. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  52. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  53. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  54. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  55. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  56. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  57. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  58. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  59. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  60. package/plugins/kxm/src/autocomplete.ts +9 -3
  61. package/plugins/kxm/src/cli.ts +1077 -11
  62. package/plugins/kxm/src/commands.ts +150 -8
  63. package/plugins/kxm/src/config.ts +7 -4
  64. package/plugins/kxm/src/context-packet.ts +172 -0
  65. package/plugins/kxm/src/extension.ts +36 -1
  66. package/plugins/kxm/src/external-effects.ts +356 -7
  67. package/plugins/kxm/src/hub.ts +2 -4
  68. package/plugins/kxm/src/improve.ts +72 -0
  69. package/plugins/kxm/src/mcp-server.ts +1 -1
  70. package/plugins/kxm/src/model-inventory.ts +127 -0
  71. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  72. package/plugins/kxm/src/policy-draft.mjs +565 -0
  73. package/plugins/kxm/src/price-calc.ts +17 -18
  74. package/plugins/kxm/src/prices.ts +32 -16
  75. package/plugins/kxm/src/producers.ts +71 -0
  76. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  77. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  78. package/plugins/kxm/src/role.ts +375 -0
  79. package/plugins/kxm/src/routing.ts +99 -1
  80. package/plugins/kxm/src/session-work.ts +9 -2
  81. package/plugins/kxm/src/studio-layout.ts +660 -17
  82. package/plugins/kxm/src/suggest.ts +7 -13
  83. package/plugins/kxm/src/telemetry.ts +82 -0
  84. package/plugins/kxm/src/tui.ts +140 -0
  85. package/plugins/kxm/src/vnext-config.ts +15 -110
  86. package/plugins/kxm/src/vnext-engine.ts +198 -62
  87. package/plugins/kxm/src/vnext-harness.ts +263 -81
  88. package/plugins/kxm/src/vnext-oneshot-evidence.ts +85 -0
  89. package/plugins/kxm/src/vnext-oneshot-process.ts +149 -0
  90. package/plugins/kxm/src/vnext-oneshot-producer.ts +170 -233
  91. package/plugins/kxm/src/vnext-runtime-store.ts +35 -1
  92. package/plugins/kxm/src/vnext-runtime-supervisor.ts +120 -5
  93. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  94. package/plugins/kxm/src/workflow-manager.ts +392 -0
  95. package/plugins/kxm/src/workflow-tui.ts +255 -0
  96. package/schemas/policy-draft/README.md +17 -0
  97. package/schemas/policy-draft/model.v2.schema.json +140 -0
  98. package/schemas/policy-draft/role.v2.schema.json +91 -0
  99. package/schemas/vnext/role.schema.json +76 -0
  100. package/schemas/vnext/run-event.schema.json +1 -0
  101. package/scripts/assignment-run.d.mts +1 -1
  102. package/scripts/assignment-run.mjs +44 -35
  103. package/scripts/check-generated.mjs +33 -9
  104. package/scripts/emit-codex-artifacts.mjs +255 -11
  105. package/scripts/harness-run.d.mts +12 -4
  106. package/scripts/harness-run.mjs +65 -17
  107. package/scripts/kxm-hub.mjs +6 -0
  108. package/scripts/native-critic.d.mts +5 -0
  109. package/scripts/native-critic.mjs +60 -0
  110. package/.kxm/config/README.md +0 -5
  111. package/.kxm/config/agents.json +0 -43
  112. package/.kxm/config/env.example +0 -56
  113. package/.kxm/config/update.example.yaml +0 -9
  114. package/.kxm/config/workflows/fix.json +0 -160
  115. package/.kxm/config/workflows/jira-development.json +0 -116
  116. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  117. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -0,0 +1,375 @@
1
+ /**
2
+ * KXM Role Management Subsystem (kxm.role.v1).
3
+ * Supports modular per-role YAML files with descriptions, skills, tool permissions, and model rosters.
4
+ * Implements global (~/.config/kxm/roles/) and local (.kxm/roles/) inheritance.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { parse, stringify } from "yaml";
10
+ import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
+
12
+ export const KXM_ROLE_SCHEMA = "kxm.role.v1" as const;
13
+
14
+ export interface KxmRosterEntry {
15
+ harness: string;
16
+ model: string;
17
+ provider?: string | undefined;
18
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
19
+ mode?: "headless" | "interactive" | "either" | undefined;
20
+ }
21
+
22
+ export interface KxmRoleToolPolicy {
23
+ preset?: string | undefined;
24
+ allow?: string[] | undefined;
25
+ deny?: string[] | undefined;
26
+ }
27
+
28
+ export interface KxmTemplateContract {
29
+ template: string;
30
+ schema?: string | undefined;
31
+ }
32
+
33
+ export interface KxmRolePolicy {
34
+ vendorIndependenceRequired?: boolean | undefined;
35
+ maxTransitions?: number | undefined;
36
+ requiresGateVerification?: boolean | undefined;
37
+ }
38
+
39
+ export interface KxmRoleDefinition {
40
+ schema: typeof KXM_ROLE_SCHEMA;
41
+ id: string;
42
+ description: string;
43
+ skills?: string[] | undefined;
44
+ tools?: KxmRoleToolPolicy | undefined;
45
+ produces?: KxmTemplateContract[] | undefined;
46
+ consumes?: KxmTemplateContract[] | undefined;
47
+ roster: KxmRosterEntry[];
48
+ policy?: KxmRolePolicy | undefined;
49
+ }
50
+
51
+ export interface KxmRoleSummary {
52
+ id: string;
53
+ description: string;
54
+ scope: "global" | "local" | "overridden";
55
+ filePath: string;
56
+ skills: string[];
57
+ toolsCount: number;
58
+ roster: KxmRosterEntry[];
59
+ primaryModel?: string | undefined;
60
+ primaryHarness?: string | undefined;
61
+ }
62
+
63
+ export const DEFAULT_ROLES: Record<string, KxmRoleDefinition> = {
64
+ writer: {
65
+ schema: KXM_ROLE_SCHEMA,
66
+ id: "writer",
67
+ description: "Primary implementation agent. Writes code, refactors components, and authors unit tests.",
68
+ skills: ["kxm", "unit-testing"],
69
+ tools: {
70
+ preset: "author",
71
+ allow: ["view_file", "replace_file_content", "write_to_file", "run_command"],
72
+ deny: ["git push"],
73
+ },
74
+ produces: [{ template: "code-patch" }],
75
+ roster: [
76
+ { harness: "grok", model: "grok-4.6", effort: "low", mode: "headless" },
77
+ { harness: "pi", provider: "openrouter", model: "openrouter/qwen/qwen3-coder-plus", effort: "medium" },
78
+ { harness: "agy", model: "gemini-2.5-pro", effort: "low" },
79
+ ],
80
+ },
81
+ planner: {
82
+ schema: KXM_ROLE_SCHEMA,
83
+ id: "planner",
84
+ description: "Architecture breakdown, requirement decomposition, and safety boundary definition.",
85
+ skills: ["kxm-session", "architecture-planning"],
86
+ tools: {
87
+ preset: "read_only",
88
+ allow: ["view_file", "grep_search", "find_by_name", "list_dir"],
89
+ deny: ["write_to_file", "replace_file_content", "run_command"],
90
+ },
91
+ produces: [{ template: "implementation-plan" }],
92
+ roster: [
93
+ { harness: "claude", model: "fable", effort: "medium" },
94
+ ],
95
+ },
96
+ "critic-arch": {
97
+ schema: KXM_ROLE_SCHEMA,
98
+ id: "critic-arch",
99
+ description: "Independent critic reviewing implementation diffs for architectural integrity and permission boundaries.",
100
+ policy: { vendorIndependenceRequired: true },
101
+ tools: {
102
+ preset: "critic",
103
+ allow: ["view_file", "grep_search"],
104
+ },
105
+ produces: [{ template: "critic-signoff" }],
106
+ roster: [
107
+ { harness: "claude", model: "fable", effort: "medium" },
108
+ ],
109
+ },
110
+ "critic-cli": {
111
+ schema: KXM_ROLE_SCHEMA,
112
+ id: "critic-cli",
113
+ description: "CLI, documentation, and developer ergonomics critic.",
114
+ policy: { vendorIndependenceRequired: true },
115
+ tools: {
116
+ preset: "critic",
117
+ allow: ["view_file", "grep_search"],
118
+ },
119
+ produces: [{ template: "critic-signoff" }],
120
+ roster: [
121
+ { harness: "codex", model: "gpt-5.6-sol", effort: "low" },
122
+ ],
123
+ },
124
+ verifier: {
125
+ schema: KXM_ROLE_SCHEMA,
126
+ id: "verifier",
127
+ description: "Deterministic gate evaluation runner.",
128
+ tools: {
129
+ preset: "verifier",
130
+ allow: ["run_command"],
131
+ },
132
+ produces: [{ template: "verification-receipt" }],
133
+ roster: [
134
+ { harness: "pi", model: "evaluator", effort: "low" },
135
+ ],
136
+ },
137
+ };
138
+
139
+ export function rolesDirectory(
140
+ scope: "global" | "local",
141
+ repoRoot = process.cwd(),
142
+ userConfigDir?: string,
143
+ ): string {
144
+ if (scope === "global") {
145
+ return join(userConfigDirectory(userConfigDir), "roles");
146
+ }
147
+ return join(repoConfigDirectory(repoRoot), "roles");
148
+ }
149
+
150
+ export function ensureRolesDirectory(
151
+ scope: "global" | "local",
152
+ repoRoot = process.cwd(),
153
+ userConfigDir?: string,
154
+ ): string {
155
+ const dir = rolesDirectory(scope, repoRoot, userConfigDir);
156
+ if (!existsSync(dir)) {
157
+ mkdirSync(dir, { recursive: true });
158
+ }
159
+ return dir;
160
+ }
161
+
162
+ export function ensureDefaultRoles(userConfigDir?: string): void {
163
+ const dir = ensureRolesDirectory("global", undefined, userConfigDir);
164
+ for (const [id, def] of Object.entries(DEFAULT_ROLES)) {
165
+ const filePath = join(dir, `${id}.yaml`);
166
+ if (!existsSync(filePath)) {
167
+ writeFileSync(filePath, stringify(def), "utf8");
168
+ }
169
+ }
170
+ }
171
+
172
+ export function parseRoleFile(filePath: string): KxmRoleDefinition | undefined {
173
+ if (!existsSync(filePath)) return undefined;
174
+ try {
175
+ const raw = readFileSync(filePath, "utf8");
176
+ const parsed = parse(raw) as Partial<KxmRoleDefinition>;
177
+ if (!parsed || typeof parsed !== "object" || parsed.schema !== KXM_ROLE_SCHEMA) {
178
+ return undefined;
179
+ }
180
+ const id = parsed.id || filePath.replace(/^.*[\\/]/, "").replace(/\.yaml$/i, "");
181
+ return {
182
+ schema: KXM_ROLE_SCHEMA,
183
+ id,
184
+ description: parsed.description || "",
185
+ skills: Array.isArray(parsed.skills) ? parsed.skills : [],
186
+ tools: parsed.tools,
187
+ produces: parsed.produces,
188
+ consumes: parsed.consumes,
189
+ roster: Array.isArray(parsed.roster) ? parsed.roster : [],
190
+ policy: parsed.policy,
191
+ };
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ export function listRoles(options: {
198
+ scope?: "all" | "global" | "local" | undefined;
199
+ repoRoot?: string | undefined;
200
+ userConfigDir?: string | undefined;
201
+ } = {}): KxmRoleSummary[] {
202
+ const scopeFilter = options.scope ?? "all";
203
+ const repoRoot = options.repoRoot ?? process.cwd();
204
+ const globalDir = rolesDirectory("global", repoRoot, options.userConfigDir);
205
+ const localDir = rolesDirectory("local", repoRoot, options.userConfigDir);
206
+
207
+ const localRoles = new Map<string, { role: KxmRoleDefinition; filePath: string }>();
208
+ if (scopeFilter !== "global" && existsSync(localDir)) {
209
+ for (const entry of readdirSync(localDir)) {
210
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
211
+ const filePath = join(localDir, entry);
212
+ const parsed = parseRoleFile(filePath);
213
+ if (parsed) {
214
+ localRoles.set(parsed.id, { role: parsed, filePath });
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ const globalRoles = new Map<string, { role: KxmRoleDefinition; filePath: string }>();
221
+ if (scopeFilter !== "local" && existsSync(globalDir)) {
222
+ for (const entry of readdirSync(globalDir)) {
223
+ if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
224
+ const filePath = join(globalDir, entry);
225
+ const parsed = parseRoleFile(filePath);
226
+ if (parsed) {
227
+ globalRoles.set(parsed.id, { role: parsed, filePath });
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ const result: KxmRoleSummary[] = [];
234
+
235
+ // 1. Process local roles
236
+ for (const [id, { role, filePath }] of localRoles) {
237
+ const isOverridden = globalRoles.has(id);
238
+ const primary = role.roster[0];
239
+ result.push({
240
+ id,
241
+ description: role.description,
242
+ scope: isOverridden ? "overridden" : "local",
243
+ filePath,
244
+ skills: role.skills ?? [],
245
+ toolsCount: (role.tools?.allow?.length ?? 0),
246
+ roster: role.roster,
247
+ primaryModel: primary?.model,
248
+ primaryHarness: primary?.harness,
249
+ });
250
+ }
251
+
252
+ // 2. Process global roles not in local (or if scope is global only)
253
+ for (const [id, { role, filePath }] of globalRoles) {
254
+ if (scopeFilter === "global" || !localRoles.has(id)) {
255
+ const primary = role.roster[0];
256
+ result.push({
257
+ id,
258
+ description: role.description,
259
+ scope: "global",
260
+ filePath,
261
+ skills: role.skills ?? [],
262
+ toolsCount: (role.tools?.allow?.length ?? 0),
263
+ roster: role.roster,
264
+ primaryModel: primary?.model,
265
+ primaryHarness: primary?.harness,
266
+ });
267
+ }
268
+ }
269
+
270
+ result.sort((a, b) => a.id.localeCompare(b.id));
271
+ return result;
272
+ }
273
+
274
+ export function getRole(
275
+ roleId: string,
276
+ options: {
277
+ scope?: "all" | "global" | "local" | undefined;
278
+ repoRoot?: string | undefined;
279
+ userConfigDir?: string | undefined;
280
+ } = {},
281
+ ): { role: KxmRoleDefinition; scope: "global" | "local"; filePath: string } | undefined {
282
+ const scope = options.scope ?? "all";
283
+ const repoRoot = options.repoRoot ?? process.cwd();
284
+
285
+ // Check local first if allowed
286
+ if (scope !== "global") {
287
+ const localDir = rolesDirectory("local", repoRoot, options.userConfigDir);
288
+ const localFile = join(localDir, `${roleId}.yaml`);
289
+ const parsed = parseRoleFile(localFile);
290
+ if (parsed) return { role: parsed, scope: "local", filePath: localFile };
291
+ }
292
+
293
+ // Check global
294
+ if (scope !== "local") {
295
+ const globalDir = rolesDirectory("global", repoRoot, options.userConfigDir);
296
+ const globalFile = join(globalDir, `${roleId}.yaml`);
297
+ const parsed = parseRoleFile(globalFile);
298
+ if (parsed) return { role: parsed, scope: "global", filePath: globalFile };
299
+ }
300
+
301
+ return undefined;
302
+ }
303
+
304
+ export function addRole(
305
+ role: KxmRoleDefinition,
306
+ options: {
307
+ scope?: "global" | "local" | undefined;
308
+ repoRoot?: string | undefined;
309
+ userConfigDir?: string | undefined;
310
+ overwrite?: boolean | undefined;
311
+ } = {},
312
+ ): { id: string; filePath: string; scope: "global" | "local" } {
313
+ const scope = options.scope ?? "local";
314
+ const repoRoot = options.repoRoot ?? process.cwd();
315
+ const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
316
+ const filePath = join(dir, `${role.id}.yaml`);
317
+
318
+ if (existsSync(filePath) && !options.overwrite) {
319
+ throw new Error(`role_already_exists: role '${role.id}' already exists at ${filePath}`);
320
+ }
321
+
322
+ writeFileSync(filePath, stringify(role), "utf8");
323
+ return { id: role.id, filePath, scope };
324
+ }
325
+
326
+ export function removeRole(
327
+ roleId: string,
328
+ options: {
329
+ scope?: "global" | "local" | undefined;
330
+ repoRoot?: string | undefined;
331
+ userConfigDir?: string | undefined;
332
+ } = {},
333
+ ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
334
+ const scope = options.scope ?? "local";
335
+ const repoRoot = options.repoRoot ?? process.cwd();
336
+ const dir = rolesDirectory(scope, repoRoot, options.userConfigDir);
337
+ const filePath = join(dir, `${roleId}.yaml`);
338
+
339
+ if (!existsSync(filePath)) {
340
+ throw new Error(`role_not_found: role '${roleId}' not found in ${scope} directory (${filePath})`);
341
+ }
342
+
343
+ rmSync(filePath);
344
+ return { id: roleId, removed: true, filePath, scope };
345
+ }
346
+
347
+ export function modifyRole(
348
+ roleId: string,
349
+ updates: Partial<KxmRoleDefinition>,
350
+ options: {
351
+ scope?: "global" | "local" | undefined;
352
+ repoRoot?: string | undefined;
353
+ userConfigDir?: string | undefined;
354
+ } = {},
355
+ ): { id: string; role: KxmRoleDefinition; filePath: string; scope: "global" | "local" } {
356
+ const target = getRole(roleId, { scope: options.scope, repoRoot: options.repoRoot, userConfigDir: options.userConfigDir });
357
+ if (!target) {
358
+ throw new Error(`role_not_found: role '${roleId}' does not exist`);
359
+ }
360
+
361
+ const updated: KxmRoleDefinition = {
362
+ ...target.role,
363
+ ...updates,
364
+ schema: KXM_ROLE_SCHEMA,
365
+ id: roleId, // preserve identity
366
+ };
367
+
368
+ const scope = options.scope ?? target.scope;
369
+ const repoRoot = options.repoRoot ?? process.cwd();
370
+ const dir = ensureRolesDirectory(scope, repoRoot, options.userConfigDir);
371
+ const filePath = join(dir, `${roleId}.yaml`);
372
+
373
+ writeFileSync(filePath, stringify(updated), "utf8");
374
+ return { id: roleId, role: updated, filePath, scope };
375
+ }
@@ -532,9 +532,106 @@ export interface RoutingReport {
532
532
  export interface GenerateRoutingReportOptions {
533
533
  catalog?: PriceCatalog | undefined;
534
534
  includeEquivalentListCost?: boolean | undefined;
535
+ halfLifeDays?: number | undefined;
535
536
  now?: () => string;
536
537
  }
537
538
 
539
+ /**
540
+ * Calculates exponential decay weight for historical telemetry (Decision Q14).
541
+ * Weight w = 2^(-delta_t / halfLifeDays).
542
+ */
543
+ export function computeDecayedWeight(
544
+ recordedAt: string | number,
545
+ halfLifeDays: number = 14,
546
+ now: number = Date.now(),
547
+ ): number {
548
+ const ts = typeof recordedAt === "number" ? recordedAt : Date.parse(recordedAt);
549
+ if (!Number.isFinite(ts)) return 1.0;
550
+ const deltaMs = Math.max(0, now - ts);
551
+ const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
552
+ return Math.pow(2, -deltaMs / halfLifeMs);
553
+ }
554
+
555
+ export interface RouteCircuitStatus {
556
+ status: "healthy" | "demoted" | "quarantined";
557
+ consecutiveFailures: number;
558
+ penaltyMultiplier: number;
559
+ reason?: string | undefined;
560
+ }
561
+
562
+ /**
563
+ * Evaluates route health against the circuit breaker policy (Decision Q13).
564
+ * Detects failure streaks in the observation window and applies soft demotion or quarantine.
565
+ */
566
+ export function evaluateCircuitBreaker(
567
+ records: Array<RoutingRecord | RoutingRecordV2>,
568
+ route: { harness: string; model: string },
569
+ config?: {
570
+ mode?: "soft_demotion" | "quarantine" | undefined;
571
+ failureThreshold?: number | undefined;
572
+ windowSeconds?: number | undefined;
573
+ penaltyMultiplier?: number | undefined;
574
+ },
575
+ now: number = Date.now(),
576
+ ): RouteCircuitStatus {
577
+ const mode = config?.mode ?? "soft_demotion";
578
+ const failureThreshold = config?.failureThreshold ?? 3;
579
+ const windowSeconds = config?.windowSeconds ?? 3600;
580
+ const penaltyMultiplier = config?.penaltyMultiplier ?? 5.0;
581
+ const windowMs = windowSeconds * 1000;
582
+
583
+ const matching = records.filter((r) => {
584
+ const isV2 = r.schema === ROUTING_RECORD_V2_SCHEMA;
585
+ const harness = isV2 ? (r as RoutingRecordV2).harness : (r.providerMetadata?.harness as string | undefined);
586
+ const model = isV2
587
+ ? ((r as RoutingRecordV2).effectiveModel || (r as RoutingRecordV2).requestedModel)
588
+ : (r.effectiveModel || r.requestedModel);
589
+ if (harness !== route.harness || model !== route.model) return false;
590
+
591
+ const recAt = (r as any).recordedAt;
592
+ const ts = typeof recAt === "string" ? Date.parse(recAt) : 0;
593
+ return ts >= (now - windowMs);
594
+ });
595
+
596
+ matching.sort((a, b) => {
597
+ const ta = typeof (a as any).recordedAt === "string" ? Date.parse((a as any).recordedAt) : 0;
598
+ const tb = typeof (b as any).recordedAt === "string" ? Date.parse((b as any).recordedAt) : 0;
599
+ return tb - ta;
600
+ });
601
+
602
+ let consecutiveFailures = 0;
603
+ for (const r of matching) {
604
+ const passed = r.verifierOutcome === "passed" || r.finalOutcome === "accepted" || r.finalOutcome === "completed";
605
+ if (passed && (r.retries ?? 0) === 0) {
606
+ break;
607
+ }
608
+ consecutiveFailures++;
609
+ }
610
+
611
+ if (consecutiveFailures >= failureThreshold) {
612
+ if (mode === "quarantine") {
613
+ return {
614
+ status: "quarantined",
615
+ consecutiveFailures,
616
+ penaltyMultiplier,
617
+ reason: `Route quarantined after ${consecutiveFailures} consecutive failures within ${windowSeconds}s`,
618
+ };
619
+ }
620
+ return {
621
+ status: "demoted",
622
+ consecutiveFailures,
623
+ penaltyMultiplier,
624
+ reason: `Route demoted with ${penaltyMultiplier}x penalty after ${consecutiveFailures} consecutive failures`,
625
+ };
626
+ }
627
+
628
+ return {
629
+ status: "healthy",
630
+ consecutiveFailures,
631
+ penaltyMultiplier: 1.0,
632
+ };
633
+ }
634
+
538
635
  export function generateRoutingReport(
539
636
  records: Array<RoutingRecord | RoutingRecordV2>,
540
637
  options: GenerateRoutingReportOptions = {},
@@ -675,7 +772,8 @@ export function generateRoutingReport(
675
772
  tokensIn: r.tokensIn ?? null,
676
773
  tokensOut: r.tokensOut ?? null,
677
774
  cacheReadTokens: r.cacheReadTokens ?? null,
678
- contextTokens: (v2?.contextTokens ?? r.tokensIn) ?? null,
775
+ cacheWriteTokens: v2?.cacheWriteTokens ?? null,
776
+ contextTokens: v2?.contextTokens ?? null,
679
777
  });
680
778
  if (calc) {
681
779
  equivTotal += calc.costUsd;
@@ -553,13 +553,20 @@ export function sessionBriefPickerEnabled(input: { env?: NodeJS.ProcessEnv; mode
553
553
  return reason === "startup" || reason === "new" || reason === "fork";
554
554
  }
555
555
 
556
- export const KXM_SLASH_SUBCOMMANDS = ["status", "hub", "memory", "help"] as const;
556
+ export const KXM_SLASH_SUBCOMMANDS = ["status", "hub", "memory", "progress", "workflow", "help"] as const;
557
557
  export type KxmSlashCommand = "brief" | typeof KXM_SLASH_SUBCOMMANDS[number];
558
558
 
559
559
  export function parseKxmSlashArgs(args: string | undefined): KxmSlashCommand {
560
560
  const raw = String(args ?? "").trim().toLowerCase();
561
561
  if (raw === "" || raw === "brief") return "brief";
562
- if (raw === "status" || raw === "hub" || raw === "memory" || raw === "help") return raw;
562
+ if (
563
+ raw === "status"
564
+ || raw === "hub"
565
+ || raw === "memory"
566
+ || raw === "progress"
567
+ || raw === "workflow"
568
+ || raw === "help"
569
+ ) return raw;
563
570
  return "help";
564
571
  }
565
572