@kontextmind/kxm 0.7.92 → 0.7.93

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 (91) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +1 -1
  3. package/CHANGELOG.md +204 -0
  4. package/README.md +3 -0
  5. package/docs/README.md +3 -0
  6. package/docs/agent-skills.md +123 -60
  7. package/docs/architecture.md +5 -2
  8. package/docs/cli-reference.md +3527 -0
  9. package/docs/config-reference.md +1943 -0
  10. package/docs/configuration.md +29 -3
  11. package/docs/continuous-improvement.md +122 -10
  12. package/docs/contracts/routing.md +95 -11
  13. package/docs/harness-routing.md +616 -0
  14. package/docs/kxm-handbook.md +106 -19
  15. package/docs/templates/README.md +1 -1
  16. package/docs/test-matrix.md +12 -6
  17. package/docs/troubleshooting.md +2 -2
  18. package/examples/project/.kxm/workflows/fix.yaml +1 -1
  19. package/examples/project/.kxm/workflows/improve.yaml +1 -1
  20. package/package.json +1 -1
  21. package/plugins/kxm/.claude-plugin/plugin.json +9 -10
  22. package/plugins/kxm/README.md +238 -56
  23. package/plugins/kxm/dist/claude-hook.js +10083 -0
  24. package/plugins/kxm/dist/cli.js +3068 -2446
  25. package/plugins/kxm/dist/client.js +64 -0
  26. package/plugins/kxm/dist/core.js +102 -9
  27. package/plugins/kxm/dist/extension.js +210 -68
  28. package/plugins/kxm/dist/mcp-server.js +217 -40
  29. package/plugins/kxm/dist/runtime-supervisor.js +1628 -157
  30. package/plugins/kxm/dist/runtime.js +1874 -298
  31. package/plugins/kxm/dist/server.js +416 -82
  32. package/plugins/kxm/package.json +1 -1
  33. package/plugins/kxm/skills/hints.json +1 -1
  34. package/plugins/kxm/skills/kxm/SKILL.md +48 -24
  35. package/plugins/kxm/skills/kxm/references/protocol.md +3 -3
  36. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +61 -21
  37. package/plugins/kxm/skills/kxm-definitions/SKILL.md +9 -0
  38. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +82 -16
  39. package/plugins/kxm/skills/kxm-harvest/SKILL.md +1 -1
  40. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +55 -27
  41. package/plugins/kxm/skills/kxm-insights/SKILL.md +1 -1
  42. package/plugins/kxm/skills/kxm-mind/SKILL.md +2 -2
  43. package/plugins/kxm/skills/{kxm-setup → kxm-mind-setup}/SKILL.md +4 -4
  44. package/plugins/kxm/skills/kxm-peer/SKILL.md +68 -93
  45. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +156 -23
  46. package/plugins/kxm/skills/kxm-projects/SKILL.md +1 -1
  47. package/plugins/kxm/skills/kxm-protocol/SKILL.md +1 -1
  48. package/plugins/kxm/skills/kxm-query/SKILL.md +1 -1
  49. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +74 -15
  50. package/plugins/kxm/skills/kxm-runs/SKILL.md +46 -17
  51. package/plugins/kxm/skills/kxm-session/SKILL.md +64 -36
  52. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +44 -15
  53. package/plugins/kxm/skills/kxm-tasks/SKILL.md +16 -4
  54. package/plugins/kxm/skills/kxm-triage/SKILL.md +1 -1
  55. package/plugins/kxm/skills/kxm-work/SKILL.md +1 -1
  56. package/plugins/kxm/skills/kxm-workflow/SKILL.md +60 -19
  57. package/plugins/kxm/src/arbiter.ts +67 -22
  58. package/plugins/kxm/src/autocomplete.ts +1 -1
  59. package/plugins/kxm/src/claude-hook.ts +192 -0
  60. package/plugins/kxm/src/cli/project.ts +11 -5
  61. package/plugins/kxm/src/cli/system.ts +85 -13
  62. package/plugins/kxm/src/cli/types.ts +4 -1
  63. package/plugins/kxm/src/cli/workflows.ts +18 -16
  64. package/plugins/kxm/src/cli.ts +23 -13
  65. package/plugins/kxm/src/client.ts +15 -4
  66. package/plugins/kxm/src/commands.ts +19 -9
  67. package/plugins/kxm/src/config.ts +42 -7
  68. package/plugins/kxm/src/context-packet.ts +14 -2
  69. package/plugins/kxm/src/context.ts +16 -5
  70. package/plugins/kxm/src/dispatch-context.ts +286 -0
  71. package/plugins/kxm/src/engine-plan.ts +40 -0
  72. package/plugins/kxm/src/engine.ts +138 -6
  73. package/plugins/kxm/src/hub-env.ts +17 -1
  74. package/plugins/kxm/src/hub.ts +92 -29
  75. package/plugins/kxm/src/improve-sources.ts +228 -0
  76. package/plugins/kxm/src/improve.ts +325 -140
  77. package/plugins/kxm/src/local-snapshot.ts +101 -42
  78. package/plugins/kxm/src/mcp-server.ts +129 -30
  79. package/plugins/kxm/src/project-config.ts +25 -0
  80. package/plugins/kxm/src/protocol.ts +11 -0
  81. package/plugins/kxm/src/relevance.ts +138 -0
  82. package/plugins/kxm/src/retrospective.ts +16 -10
  83. package/plugins/kxm/src/runtime-service.ts +8 -1
  84. package/plugins/kxm/src/runtime-supervisor.ts +16 -2
  85. package/plugins/kxm/src/session-token-hint.ts +17 -0
  86. package/plugins/kxm/src/suggest.ts +7 -7
  87. package/plugins/kxm/src/workflow-manager.ts +80 -78
  88. package/plugins/kxm/src/workflow.ts +202 -12
  89. package/scripts/build-runtime.mjs +7 -1
  90. package/scripts/check-generated.mjs +1 -0
  91. package/scripts/emit-codex-artifacts.mjs +1 -1
@@ -1,8 +1,9 @@
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 { RoutingRecord, RoutingRecordV2 } from "./routing.ts";
4
+ import { computeDecayedWeight, type RoutingRecord, type RoutingRecordV2 } from "./routing.ts";
5
5
  import { nowIso } from "./protocol.ts";
6
+ import { compareCodeUnitIds } from "./relevance.ts";
6
7
 
7
8
  export const CANDIDATE_SCHEMA = "kxm.candidate.v1" as const;
8
9
  export const IMPROVEMENT_REPORT_SCHEMA = "kxm.improvement-report.v2" as const;
@@ -32,22 +33,43 @@ export interface ImprovementCandidate {
32
33
  createdAt: string;
33
34
  }
34
35
 
36
+ /** Why a group that recurs is still not a coded-repeat candidate. */
37
+ export type ImprovementExcludedReason = "writes-repository" | "ask-not-repeated";
38
+
35
39
  export interface ImprovementGroupRow {
36
40
  workflowHash: string;
41
+ /** The engine's workflow id when the records carry one. */
42
+ workflowId?: string;
37
43
  stepId: string;
38
44
  agentRole: string;
39
45
  promptHash: string;
40
46
  recurrence: number;
47
+ /** Distinct runs among decided records. */
48
+ distinctRuns: number;
49
+ /** Most distinct decided runs that shared one objective (the same ask). */
50
+ askRecurrence: number;
51
+ undecidedRecords: number;
41
52
  meanCost: number;
42
53
  meanLatency: number;
54
+ /** Accepted share of decided records; a superseded retry never passes. */
43
55
  verifyPassRate: number;
44
56
  rework: number;
57
+ /** Recency-weighted recurrence. Orders rows; never decides candidacy. */
58
+ weightedRecurrence: number;
59
+ undatedRecords: number;
60
+ costSamples: number;
61
+ writesRepository: boolean;
45
62
  evidenceRefs: string[];
46
63
  isCandidate: boolean;
64
+ excludedReason?: ImprovementExcludedReason;
47
65
  candidateKind?: CandidateKind;
48
66
  candidateId?: string;
49
67
  }
50
68
 
69
+ export interface ImprovementPromotionEntry extends PromotionReadiness {
70
+ candidateId: string;
71
+ }
72
+
51
73
  export interface ImprovementReport {
52
74
  schema: typeof IMPROVEMENT_REPORT_SCHEMA;
53
75
  createdAt: string;
@@ -55,6 +77,8 @@ export interface ImprovementReport {
55
77
  recordsCount: number;
56
78
  groups: ImprovementGroupRow[];
57
79
  candidates: ImprovementCandidate[];
80
+ promotionPolicy: string;
81
+ promotion: ImprovementPromotionEntry[];
58
82
  }
59
83
 
60
84
  export function classifyCandidateKind(stepId: string, agentRole: string): CandidateKind {
@@ -69,8 +93,17 @@ export function classifyCandidateKind(stepId: string, agentRole: string): Candid
69
93
  return "workflow-step";
70
94
  }
71
95
 
72
- function generateCandidateDiff(kind: CandidateKind, stepId: string, agentRole: string, workflowHash: string): { diff: string; declaredOutcome: string; measure: string; summary: string } {
73
- const safeSlug = stepId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "step";
96
+ function candidateSlug(value: string): string {
97
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "step";
98
+ }
99
+
100
+ function generateCandidateDiff(
101
+ kind: CandidateKind,
102
+ stepId: string,
103
+ agentRole: string,
104
+ workflowId?: string | undefined,
105
+ ): { diff: string; declaredOutcome: string; measure: string; summary: string } {
106
+ const safeSlug = candidateSlug(stepId);
74
107
  if (kind === "gate") {
75
108
  const diff = [
76
109
  "diff --git a/.kxm/gates.yaml b/.kxm/gates.yaml",
@@ -118,80 +151,159 @@ function generateCandidateDiff(kind: CandidateKind, stepId: string, agentRole: s
118
151
  diff,
119
152
  declaredOutcome: `Governed reusable skill for ${stepId}`,
120
153
  measure: `Reduced prompt drift and consistent model guidance for ${stepId}`,
121
- summary: `Promote repeated instructions for '${stepId}' (${agentRole}) to governed skill`,
154
+ summary: `Consolidate repeated instructions for '${stepId}' (${agentRole}) into a governed skill (consolidation, not a coded step)`,
122
155
  };
123
156
  }
124
- const rel = `.kxm/workflows/${safeSlug}.yaml`;
157
+ // A workflow-step candidate proposes the coded step itself: the agent turn
158
+ // becomes a command gate the operator writes and reviews.
159
+ const rel = `.kxm/workflows/${workflowId !== undefined ? candidateSlug(workflowId) : safeSlug}.yaml`;
125
160
  const diff = [
126
161
  `diff --git a/${rel} b/${rel}`,
127
162
  `--- a/${rel}`,
128
163
  `+++ b/${rel}`,
129
- "@@ -1,3 +1,6 @@",
130
- " steps:",
131
- `+ - id: ${stepId}`,
132
- `+ kind: agent`,
133
- `+ agent: ${agentRole}`,
164
+ "@@ -1,3 +1,3 @@",
165
+ ` - id: ${stepId}`,
166
+ "- kind: agent",
167
+ `- agent: ${agentRole}`,
168
+ "+ kind: gate",
169
+ `+ gate: ${safeSlug}`,
170
+ "diff --git a/.kxm/gates.yaml b/.kxm/gates.yaml",
171
+ "--- a/.kxm/gates.yaml",
172
+ "+++ b/.kxm/gates.yaml",
173
+ "@@ -1,2 +1,6 @@",
174
+ " schema: kxm.gate-registry.v1",
175
+ " gates:",
176
+ `+ ${safeSlug}:`,
177
+ "+ kind: command",
178
+ `+ argv: [node, scripts/${safeSlug}.mjs]`,
179
+ "+ timeoutMs: 600000",
134
180
  "",
135
181
  ].join("\n");
136
182
  return {
137
183
  diff,
138
- declaredOutcome: `Dedicated workflow step with typed inputs and transitions for ${stepId}`,
139
- measure: `Reduced manual coordination and faster stage transitions for ${stepId}`,
140
- summary: `Automate repetitive step '${stepId}' (${agentRole}) in workflow`,
184
+ declaredOutcome: `Coded gate replaces the model turn for ${stepId}; the operator writes scripts/${safeSlug}.mjs`,
185
+ measure: `Model cost and latency for ${stepId} drop to zero while its accepted rate holds`,
186
+ summary: `Replace the model turn for '${stepId}' (${agentRole}) with a coded gate step`,
141
187
  };
142
188
  }
143
189
 
190
+ /** A trimmed, non-empty string, or undefined. */
191
+ function nonEmptyText(value: unknown): string | undefined {
192
+ if (typeof value !== "string") return undefined;
193
+ const trimmed = value.trim();
194
+ return trimmed.length > 0 ? trimmed : undefined;
195
+ }
196
+
197
+ function roundThree(value: number): number {
198
+ return Number(value.toFixed(3));
199
+ }
200
+
201
+ interface GroupAccumulator {
202
+ key: string;
203
+ workflowHash: string;
204
+ workflowId?: string;
205
+ stepId: string;
206
+ agentRole: string;
207
+ promptHash: string;
208
+ costs: number[];
209
+ latencies: number[];
210
+ total: number;
211
+ decided: number;
212
+ passed: number;
213
+ undecided: number;
214
+ reworkCount: number;
215
+ weight: number;
216
+ undated: number;
217
+ writes: boolean;
218
+ decidedRuns: Set<string>;
219
+ /** Decided runs per objective digest; one '(unkeyed)' bucket for records without one. */
220
+ askRuns: Map<string, Set<string>>;
221
+ evidenceRefs: Set<string>;
222
+ }
223
+
224
+ export interface GroupRoutingOptions {
225
+ minRecurrence?: number | undefined;
226
+ minPassRate?: number | undefined;
227
+ /** improvement.telemetryHalfLifeDays; weights order rows and never decide candidacy. */
228
+ halfLifeDays?: number | undefined;
229
+ /** Epoch milliseconds the recency weights are measured against. */
230
+ now?: number | undefined;
231
+ }
232
+
233
+ /**
234
+ * Group routing records by (workflowHash, step, role, promptHash) across runs.
235
+ *
236
+ * Identity: workflowHash is the engine's workflowId when present, then a
237
+ * workflow definition digest, then the run; promptHash is the engine's
238
+ * askSha256 (stable across runs, independent of the objective), then a v1
239
+ * rolePromptSha256. A group is a coded-repeat candidate only when the same
240
+ * objective was decided in at least minRecurrence runs, the accepted rate over
241
+ * decided records reaches minPassRate, and the step writes no repository.
242
+ */
144
243
  export function groupRoutingRecords(
145
244
  records: Array<RoutingRecord | RoutingRecordV2>,
146
- options: { minRecurrence?: number; minPassRate?: number } = {},
245
+ options: GroupRoutingOptions = {},
147
246
  ): ImprovementGroupRow[] {
148
247
  const minRecurrence = options.minRecurrence ?? 2;
149
248
  const minPassRate = options.minPassRate ?? 0.75;
249
+ const halfLifeDays = options.halfLifeDays ?? 14;
250
+ const now = options.now ?? Date.now();
251
+
252
+ const raws = records.map((record) => record as unknown as Record<string, unknown>);
253
+ const runKeys = raws.map((raw, index) => nonEmptyText(raw.runId) ?? nonEmptyText(raw.workflowRunId) ?? `record:${index}`);
254
+ const stepIds = raws.map((raw) => nonEmptyText(raw.stepId) ?? nonEmptyText(raw.stageId) ?? "unknown");
255
+ const retriesOf = (raw: Record<string, unknown>): number =>
256
+ typeof raw.retries === "number" && Number.isFinite(raw.retries) ? raw.retries : 0;
257
+ // A retry of the same step in the same run supersedes every earlier attempt.
258
+ const maxRetries = new Map<string, number>();
259
+ raws.forEach((raw, index) => {
260
+ const attemptKey = `${runKeys[index]}\u0000${stepIds[index]}`;
261
+ maxRetries.set(attemptKey, Math.max(maxRetries.get(attemptKey) ?? 0, retriesOf(raw)));
262
+ });
150
263
 
151
- const map = new Map<string, {
152
- workflowHash: string;
153
- stepId: string;
154
- agentRole: string;
155
- promptHash: string;
156
- costs: number[];
157
- latencies: number[];
158
- passedCount: number;
159
- reworkCount: number;
160
- evidenceRefs: Set<string>;
161
- total: number;
162
- }>();
163
-
164
- for (const r of records) {
165
- const raw = r as unknown as Record<string, unknown>;
166
- const providerMeta = (raw.providerMetadata && typeof raw.providerMetadata === "object") ? raw.providerMetadata as Record<string, unknown> : {};
167
- const workflowHash = (raw.workflowDefinitionSha256 as string)
168
- || (providerMeta.workflowDefinitionSha256 as string)
169
- || (raw.workflowRunId as string)
170
- || (raw.runId as string)
171
- || "standalone";
172
- const stepId = (raw.stepId as string) || (raw.stageId as string) || "unknown";
173
- const agentRole = (raw.agentRole as string)?.trim() || "agent";
174
- const promptHash = (raw.rolePromptSha256 as string)?.trim()
175
- || (providerMeta.rolePromptSha256 as string)?.trim()
176
- || "none";
264
+ const map = new Map<string, GroupAccumulator>();
265
+ raws.forEach((raw, index) => {
266
+ const providerMeta = raw.providerMetadata && typeof raw.providerMetadata === "object" && !Array.isArray(raw.providerMetadata)
267
+ ? raw.providerMetadata as Record<string, unknown>
268
+ : {};
269
+ const workflowId = nonEmptyText(providerMeta.workflowId);
270
+ const workflowHash = workflowId
271
+ ?? nonEmptyText(raw.workflowDefinitionSha256)
272
+ ?? nonEmptyText(providerMeta.workflowDefinitionSha256)
273
+ ?? nonEmptyText(raw.workflowRunId)
274
+ ?? nonEmptyText(raw.runId)
275
+ ?? "standalone";
276
+ const stepId = stepIds[index]!;
277
+ const agentRole = nonEmptyText(raw.agentRole) ?? "agent";
278
+ const promptHash = nonEmptyText(providerMeta.askSha256) ?? nonEmptyText(raw.rolePromptSha256) ?? "none";
279
+ const runKey = runKeys[index]!;
177
280
 
178
281
  const key = `${workflowHash}:${stepId}:${agentRole}:${promptHash}`;
179
282
  let group = map.get(key);
180
283
  if (!group) {
181
284
  group = {
285
+ key,
182
286
  workflowHash,
183
287
  stepId,
184
288
  agentRole,
185
289
  promptHash,
186
290
  costs: [],
187
291
  latencies: [],
188
- passedCount: 0,
292
+ total: 0,
293
+ decided: 0,
294
+ passed: 0,
295
+ undecided: 0,
189
296
  reworkCount: 0,
297
+ weight: 0,
298
+ undated: 0,
299
+ writes: false,
300
+ decidedRuns: new Set<string>(),
301
+ askRuns: new Map<string, Set<string>>(),
190
302
  evidenceRefs: new Set<string>(),
191
- total: 0,
192
303
  };
193
304
  map.set(key, group);
194
305
  }
306
+ if (group.workflowId === undefined && workflowId !== undefined) group.workflowId = workflowId;
195
307
 
196
308
  group.total += 1;
197
309
  const cost = raw.costUsd;
@@ -205,8 +317,24 @@ export function groupRoutingRecords(
205
317
 
206
318
  const verifierOutcome = raw.verifierOutcome;
207
319
  const finalOutcome = raw.finalOutcome;
208
- if (verifierOutcome === "passed" || finalOutcome === "accepted" || finalOutcome === "completed") {
209
- group.passedCount += 1;
320
+ const decided = (verifierOutcome !== undefined && verifierOutcome !== null)
321
+ || (finalOutcome !== undefined && finalOutcome !== null && finalOutcome !== "pending");
322
+ if (decided) {
323
+ group.decided += 1;
324
+ group.decidedRuns.add(runKey);
325
+ const ask = nonEmptyText(providerMeta.objectiveSha256) ?? "(unkeyed)";
326
+ let askRuns = group.askRuns.get(ask);
327
+ if (!askRuns) {
328
+ askRuns = new Set<string>();
329
+ group.askRuns.set(ask, askRuns);
330
+ }
331
+ askRuns.add(runKey);
332
+ const superseded = (maxRetries.get(`${runKey}\u0000${stepId}`) ?? 0) > retriesOf(raw);
333
+ if (!superseded && (verifierOutcome === "passed" || finalOutcome === "accepted" || finalOutcome === "completed")) {
334
+ group.passed += 1;
335
+ }
336
+ } else {
337
+ group.undecided += 1;
210
338
  }
211
339
 
212
340
  const retries = raw.retries;
@@ -214,13 +342,18 @@ export function groupRoutingRecords(
214
342
  group.reworkCount += retries;
215
343
  }
216
344
 
345
+ const recordedAt = nonEmptyText(raw.recordedAt);
346
+ if (recordedAt === undefined || !Number.isFinite(Date.parse(recordedAt))) group.undated += 1;
347
+ group.weight += computeDecayedWeight(recordedAt ?? "", halfLifeDays, now);
348
+ if (providerMeta.stepWrites === true) group.writes = true;
349
+
217
350
  const ref = (raw.attemptId as string) || (raw.runId as string) || (raw.workflowRunId as string) || (raw.behavioralSha256 as string);
218
351
  if (ref && group.evidenceRefs.size < 16) {
219
352
  group.evidenceRefs.add(ref);
220
353
  }
221
- }
354
+ });
222
355
 
223
- const rows: ImprovementGroupRow[] = [];
356
+ const keyed: Array<{ key: string; row: ImprovementGroupRow }> = [];
224
357
  for (const group of map.values()) {
225
358
  const recurrence = group.total;
226
359
  const meanCost = group.costs.length > 0
@@ -229,56 +362,83 @@ export function groupRoutingRecords(
229
362
  const meanLatency = group.latencies.length > 0
230
363
  ? Math.round(group.latencies.reduce((a, b) => a + b, 0) / group.latencies.length)
231
364
  : 0;
232
- const verifyPassRate = recurrence > 0
233
- ? Number((group.passedCount / recurrence).toFixed(3))
234
- : 0;
235
- const rework = group.reworkCount;
236
-
237
- const isCandidate = recurrence >= minRecurrence && verifyPassRate >= minPassRate;
365
+ const verifyPassRate = group.decided > 0 ? roundThree(group.passed / group.decided) : 0;
366
+ const distinctRuns = group.decidedRuns.size;
367
+ const askRecurrence = Math.max(0, ...[...group.askRuns.values()].map((runs) => runs.size));
368
+ const passes = verifyPassRate >= minPassRate;
369
+
370
+ const isCandidate = askRecurrence >= minRecurrence && passes && !group.writes;
371
+ let excludedReason: ImprovementExcludedReason | undefined;
372
+ if (!isCandidate && passes) {
373
+ if (askRecurrence >= minRecurrence && group.writes) excludedReason = "writes-repository";
374
+ else if (distinctRuns >= minRecurrence && askRecurrence < minRecurrence) excludedReason = "ask-not-repeated";
375
+ }
238
376
  const candidateKind = isCandidate ? classifyCandidateKind(group.stepId, group.agentRole) : undefined;
239
377
  const safeSlug = group.stepId.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 16) || "step";
240
378
  const keyHash = createHash("sha256").update(`${group.workflowHash}:${group.stepId}:${group.agentRole}:${group.promptHash}`).digest("hex").slice(0, 10);
241
379
  const candidateId = isCandidate && candidateKind ? `cand_${candidateKind.replace(/-/g, "_")}_${safeSlug}_${keyHash}` : undefined;
242
380
 
243
- rows.push({
244
- workflowHash: group.workflowHash,
245
- stepId: group.stepId,
246
- agentRole: group.agentRole,
247
- promptHash: group.promptHash,
248
- recurrence,
249
- meanCost,
250
- meanLatency,
251
- verifyPassRate,
252
- rework,
253
- evidenceRefs: [...group.evidenceRefs],
254
- isCandidate,
255
- ...(candidateKind !== undefined ? { candidateKind } : {}),
256
- ...(candidateId !== undefined ? { candidateId } : {}),
381
+ keyed.push({
382
+ key: group.key,
383
+ row: {
384
+ workflowHash: group.workflowHash,
385
+ ...(group.workflowId !== undefined ? { workflowId: group.workflowId } : {}),
386
+ stepId: group.stepId,
387
+ agentRole: group.agentRole,
388
+ promptHash: group.promptHash,
389
+ recurrence,
390
+ distinctRuns,
391
+ askRecurrence,
392
+ undecidedRecords: group.undecided,
393
+ meanCost,
394
+ meanLatency,
395
+ verifyPassRate,
396
+ rework: group.reworkCount,
397
+ weightedRecurrence: roundThree(group.weight),
398
+ undatedRecords: group.undated,
399
+ costSamples: group.costs.length,
400
+ writesRepository: group.writes,
401
+ evidenceRefs: [...group.evidenceRefs],
402
+ isCandidate,
403
+ ...(excludedReason !== undefined ? { excludedReason } : {}),
404
+ ...(candidateKind !== undefined ? { candidateKind } : {}),
405
+ ...(candidateId !== undefined ? { candidateId } : {}),
406
+ },
257
407
  });
258
408
  }
259
409
 
260
- return rows.sort((a, b) => {
261
- if (a.isCandidate !== b.isCandidate) return a.isCandidate ? -1 : 1;
262
- if (b.recurrence !== a.recurrence) return b.recurrence - a.recurrence;
263
- return a.stepId.localeCompare(b.stepId);
264
- });
410
+ return keyed
411
+ .sort((left, right) => {
412
+ const a = left.row;
413
+ const b = right.row;
414
+ if (a.isCandidate !== b.isCandidate) return a.isCandidate ? -1 : 1;
415
+ if (b.weightedRecurrence !== a.weightedRecurrence) return b.weightedRecurrence - a.weightedRecurrence;
416
+ if (b.recurrence !== a.recurrence) return b.recurrence - a.recurrence;
417
+ return compareCodeUnitIds(a.stepId, b.stepId) || compareCodeUnitIds(left.key, right.key);
418
+ })
419
+ .map((entry) => entry.row);
420
+ }
421
+
422
+ export interface BuildImprovementReportOptions extends GroupRoutingOptions {
423
+ candidatesDir?: string | undefined;
424
+ projectRoot?: string | undefined;
425
+ dryRun?: boolean | undefined;
426
+ /** improvement.promotionPolicy; reported as readiness only. */
427
+ promotionPolicy?: string | undefined;
428
+ autoThreshold?: PromotionAutoThreshold | undefined;
265
429
  }
266
430
 
267
431
  export function buildImprovementReport(
268
432
  records: Array<RoutingRecord | RoutingRecordV2>,
269
- options: {
270
- minRecurrence?: number;
271
- minPassRate?: number;
272
- candidatesDir?: string;
273
- projectRoot?: string;
274
- dryRun?: boolean;
275
- } = {},
433
+ options: BuildImprovementReportOptions = {},
276
434
  ): ImprovementReport {
277
435
  const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
278
436
  const candidatesDir = options.candidatesDir ? resolve(options.candidatesDir) : join(projectRoot, ".kxm", "candidates");
437
+ const promotionPolicy = options.promotionPolicy ?? "manual_pr";
279
438
 
280
439
  const groups = groupRoutingRecords(records, options);
281
440
  const candidates: ImprovementCandidate[] = [];
441
+ const promotion: ImprovementPromotionEntry[] = [];
282
442
 
283
443
  for (const group of groups) {
284
444
  if (!group.isCandidate || !group.candidateKind || !group.candidateId) continue;
@@ -287,7 +447,7 @@ export function buildImprovementReport(
287
447
  group.candidateKind,
288
448
  group.stepId,
289
449
  group.agentRole,
290
- group.workflowHash,
450
+ group.workflowId,
291
451
  );
292
452
 
293
453
  const diffFileName = `${group.candidateId}.diff`;
@@ -326,6 +486,18 @@ export function buildImprovementReport(
326
486
  candidates.push(candidate);
327
487
  }
328
488
 
489
+ const rowsByCandidate = new Map(groups.flatMap((row) => row.candidateId !== undefined ? [[row.candidateId, row] as const] : []));
490
+ for (const candidate of candidates) {
491
+ const row = rowsByCandidate.get(candidate.id);
492
+ promotion.push({
493
+ candidateId: candidate.id,
494
+ ...evaluatePromotionPolicy(candidate, promotionPolicy, {
495
+ autoThreshold: options.autoThreshold,
496
+ ...(row ? { costSamples: row.costSamples, distinctRuns: row.distinctRuns } : {}),
497
+ }),
498
+ });
499
+ }
500
+
329
501
  return {
330
502
  schema: IMPROVEMENT_REPORT_SCHEMA,
331
503
  createdAt: nowIso(),
@@ -333,6 +505,8 @@ export function buildImprovementReport(
333
505
  recordsCount: records.length,
334
506
  groups,
335
507
  candidates,
508
+ promotionPolicy,
509
+ promotion,
336
510
  };
337
511
  }
338
512
 
@@ -348,27 +522,33 @@ export function writeImprovementReport(improvementsDir: string, report: Improvem
348
522
 
349
523
  export function formatImprovementReport(report: ImprovementReport): string {
350
524
  const lines: string[] = [
351
- `Improvement Report (${report.recordsCount} record(s), ${report.groups.length} group(s), ${report.candidates.length} candidate(s))`,
525
+ `Improvement Report (${report.recordsCount} record(s), ${report.groups.length} group(s), ${report.candidates.length} candidate(s); promotion policy ${report.promotionPolicy})`,
352
526
  "",
353
- "Workflow Step Role Prompt Recurrence Cost ($) Latency (ms) Pass Rate Rework Candidate",
354
- "-------------------------------------------------------------------------------------------------------------------------",
527
+ "Workflow Step Role Prompt Records Runs Asks Weighted Cost ($) Latency (ms) Accepted Rework Candidate",
528
+ "------------------------------------------------------------------------------------------------------------------------------------------",
355
529
  ];
356
530
 
357
531
  for (const g of report.groups) {
358
532
  const wf = g.workflowHash.slice(0, 12).padEnd(14);
359
533
  const step = g.stepId.slice(0, 11).padEnd(12);
360
534
  const role = g.agentRole.slice(0, 11).padEnd(12);
361
- const prompt = g.promptHash.slice(0, 10).padEnd(12);
362
- const rec = String(g.recurrence).padStart(10);
535
+ const prompt = g.promptHash.replace(/^sha256:/, "").slice(0, 10).padEnd(12);
536
+ const rec = String(g.recurrence).padStart(7);
537
+ const runs = String(g.distinctRuns).padStart(5);
538
+ const asks = String(g.askRecurrence).padStart(5);
539
+ const weighted = g.weightedRecurrence.toFixed(3).padStart(9);
363
540
  const cost = g.meanCost.toFixed(3).padStart(9);
364
541
  const lat = String(g.meanLatency).padStart(13);
365
- const pass = `${(g.verifyPassRate * 100).toFixed(0)}%`.padStart(10);
542
+ const accepted = `${(g.verifyPassRate * 100).toFixed(0)}%`.padStart(9);
366
543
  const rework = String(g.rework).padStart(7);
367
- const cand = g.isCandidate ? `yes (${g.candidateKind})` : "no";
368
- lines.push(`${wf} ${step} ${role} ${prompt} ${rec} ${cost} ${lat} ${pass} ${rework} ${cand}`);
544
+ const cand = g.isCandidate
545
+ ? `yes (${g.candidateKind})`
546
+ : g.excludedReason !== undefined ? `no (${g.excludedReason})` : "no";
547
+ lines.push(`${wf} ${step} ${role} ${prompt} ${rec} ${runs} ${asks} ${weighted} ${cost} ${lat} ${accepted} ${rework} ${cand}`);
369
548
  }
370
549
 
371
550
  if (report.candidates.length > 0) {
551
+ const readiness = new Map(report.promotion.map((entry) => [entry.candidateId, entry]));
372
552
  lines.push("");
373
553
  lines.push("Emitted Coded-Repeat Candidates:");
374
554
  for (const c of report.candidates) {
@@ -376,80 +556,85 @@ export function formatImprovementReport(report: ImprovementReport): string {
376
556
  lines.push(` Outcome: ${c.declaredOutcome}`);
377
557
  lines.push(` Measure: ${c.measure}`);
378
558
  lines.push(` Proposed diff: ${c.proposedDiffPath}`);
559
+ const entry = readiness.get(c.id);
560
+ if (entry) {
561
+ lines.push(` Promotion (${entry.policy}): ${entry.readyForReview ? "ready for review" : "not ready for review"}; ${entry.reason}`);
562
+ }
379
563
  }
564
+ lines.push("");
565
+ lines.push("Candidates are proposals: readiness never authorizes, and activation is a reviewed Git change.");
380
566
  }
381
567
 
382
568
  return lines.join("\n");
383
569
  }
384
570
 
385
- export interface PromotionDecision {
386
- eligible: boolean;
387
- policy: "manual_pr" | "critic_quorum" | "auto_threshold";
388
- authorized: boolean;
571
+ /** Review readiness of one candidate under improvement.promotionPolicy. */
572
+ export interface PromotionReadiness {
573
+ policy: string;
574
+ readyForReview: boolean;
389
575
  reason: string;
390
576
  }
391
577
 
578
+ export interface PromotionAutoThreshold {
579
+ minRuns?: number | undefined;
580
+ minPassRate?: number | undefined;
581
+ minCostSavings?: number | undefined;
582
+ }
583
+
392
584
  /**
393
- * Evaluates candidate promotion policy (Decision Q11).
394
- * Supports:
395
- * - manual_pr: strict operator signoff via Git PR / CLI (fail-closed anti-privilege-escalation)
396
- * - critic_quorum: requires dual critic approval before auto-promotion
397
- * - auto_threshold: requires candidate to exceed recurrence and verifyPassRate thresholds
585
+ * Report whether a candidate is ready for operator review (Decision Q11).
586
+ *
587
+ * No policy authorizes anything. AGENTS.md:141 forbids auto-promoting skills
588
+ * or gates from telemetry, and ADR-001 (docs/contracts/architecture.md:177-180)
589
+ * rejects automatic learned-policy activation: activation is a reviewed Git
590
+ * change. Every policy therefore ends at an operator PR:
591
+ * - manual_pr: always ready; the operator reviews the proposed diff.
592
+ * - critic_quorum: ready once two distinct critic receipts are cited.
593
+ * - auto_threshold: ready once the candidate's distinct runs and accepted rate
594
+ * reach the thresholds and, when minCostSavings is set, recorded cost samples
595
+ * show at least that mean cost; a group without cost samples is never ready.
596
+ * Any other policy is not ready.
398
597
  */
399
598
  export function evaluatePromotionPolicy(
400
599
  candidate: ImprovementCandidate,
401
- policy: "manual_pr" | "critic_quorum" | "auto_threshold" = "manual_pr",
402
- options?: {
403
- criticApprovals?: string[] | undefined;
404
- autoThreshold?: {
405
- minRuns: number;
406
- minPassRate: number;
407
- minCostSavings?: number | undefined;
408
- } | undefined;
409
- },
410
- ): PromotionDecision {
600
+ policy: string = "manual_pr",
601
+ options: {
602
+ criticReceipts?: string[] | undefined;
603
+ autoThreshold?: PromotionAutoThreshold | undefined;
604
+ costSamples?: number | undefined;
605
+ distinctRuns?: number | undefined;
606
+ } = {},
607
+ ): PromotionReadiness {
411
608
  if (policy === "manual_pr") {
412
- return {
413
- eligible: true,
414
- policy,
415
- authorized: false,
416
- reason: "Manual PR review and signoff required by policy (fail-closed anti-privilege-escalation)",
417
- };
609
+ return { policy, readyForReview: true, reason: `operator PR applying ${candidate.proposedDiffPath} required` };
418
610
  }
419
611
 
420
612
  if (policy === "critic_quorum") {
421
- const approvals = options?.criticApprovals ?? [];
422
- const hasQuorum = approvals.length >= 2;
423
- return {
424
- eligible: true,
425
- policy,
426
- authorized: hasQuorum,
427
- reason: hasQuorum
428
- ? `Authorized by critic quorum (${approvals.join(", ")})`
429
- : `Requires dual critic quorum; current approvals: ${approvals.length}/2`,
430
- };
613
+ const receipts = new Set((options.criticReceipts ?? []).map((receipt) => receipt.trim()).filter((receipt) => receipt.length > 0));
614
+ return receipts.size >= 2
615
+ ? { policy, readyForReview: true, reason: "two critic receipts cited; operator PR still required" }
616
+ : { policy, readyForReview: false, reason: `awaiting 2 distinct critic receipts (have ${receipts.size})` };
431
617
  }
432
618
 
433
619
  if (policy === "auto_threshold") {
434
- const threshold = options?.autoThreshold ?? { minRuns: 10, minPassRate: 0.95 };
435
- const recurrence = candidate.baselineMetrics.recurrence;
620
+ const minRuns = options.autoThreshold?.minRuns ?? 10;
621
+ const minPassRate = options.autoThreshold?.minPassRate ?? 0.95;
622
+ const minCostSavings = options.autoThreshold?.minCostSavings;
623
+ const runs = options.distinctRuns ?? candidate.baselineMetrics.recurrence;
436
624
  const passRate = candidate.baselineMetrics.verifyPassRate;
437
- const meetsThreshold = recurrence >= threshold.minRuns && passRate >= threshold.minPassRate;
438
-
439
- return {
440
- eligible: true,
441
- policy,
442
- authorized: meetsThreshold,
443
- reason: meetsThreshold
444
- ? `Authorized by auto-threshold (runs=${recurrence}>=${threshold.minRuns}, passRate=${passRate}>=${threshold.minPassRate})`
445
- : `Auto-threshold not met: runs=${recurrence}/${threshold.minRuns}, passRate=${passRate}/${threshold.minPassRate}`,
446
- };
625
+ const costSamples = options.costSamples ?? 0;
626
+ const meanCost = candidate.baselineMetrics.meanCost;
627
+ const costMet = minCostSavings === undefined || (costSamples > 0 && meanCost >= minCostSavings);
628
+ const ready = runs >= minRuns && passRate >= minPassRate && costMet;
629
+ const detail = [
630
+ `runs=${runs}/${minRuns}`,
631
+ `passRate=${passRate}/${minPassRate}`,
632
+ ...(minCostSavings === undefined ? [] : [`meanCost=${meanCost}/${minCostSavings} over ${costSamples} cost sample(s)`]),
633
+ ].join(", ");
634
+ return ready
635
+ ? { policy, readyForReview: true, reason: `auto-threshold met (${detail}); operator PR still required` }
636
+ : { policy, readyForReview: false, reason: `auto-threshold not met (${detail})` };
447
637
  }
448
638
 
449
- return {
450
- eligible: false,
451
- policy,
452
- authorized: false,
453
- reason: `Unknown promotion policy: ${String(policy)}`,
454
- };
639
+ return { policy, readyForReview: false, reason: "unknown promotion policy" };
455
640
  }