@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
@@ -3,6 +3,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSy
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { HubClient, HubHttpError } from "./client.ts";
6
+ import { IMPROVEMENT_AREAS } from "./protocol.ts";
7
+ import { JOURNAL_CATEGORIES } from "./workflow.ts";
6
8
  import type {
7
9
  DeliveryMode,
8
10
  ImprovementArea,
@@ -484,7 +486,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
484
486
  group: "workflow",
485
487
  verb: "run",
486
488
  label: "Get workflow run",
487
- description: "Get a workflow's stages and journal of plans, decisions, contradictions, errors, and lessons.",
489
+ description: "Get a workflow's stages and its learning journal (plans, decisions, contradictions, errors, lessons, and the other journal categories).",
488
490
  parameters: {
489
491
  type: "object",
490
492
  properties: {
@@ -563,20 +565,25 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
563
565
  group: "workflow",
564
566
  verb: "record",
565
567
  label: "Record workflow journal entry",
566
- description: "Record a plan, decision, contradiction, error, or lesson for continuous improvement.",
568
+ description:
569
+ "Record a plan, decision, contradiction, error, lesson, observation, hypothesis, experiment, state-change, or skill-candidate for continuous improvement. Pass stageId to bind the entry to that stage: the hub derives the attempt, and area defaults to the stage's declared area. Lessons and skill-candidates require evidence.",
567
570
  parameters: {
568
571
  type: "object",
569
572
  properties: {
570
573
  runId: { type: "string", description: "Active durable workflow run ID" },
571
574
  category: {
572
575
  type: "string",
573
- enum: ["plan", "decision", "contradiction", "error", "lesson"],
576
+ enum: [...JOURNAL_CATEGORIES],
574
577
  description: "Category of journal entry",
575
578
  },
576
579
  area: {
577
580
  type: "string",
578
- enum: ["harness", "gates", "implementation", "workflow", "documentation", "security", "other"],
579
- description: "System area",
581
+ enum: [...IMPROVEMENT_AREAS],
582
+ description: "System area; required unless stageId names a stage that declares an area",
583
+ },
584
+ stageId: {
585
+ type: "string",
586
+ description: "Stage the entry belongs to; the hub binds the attempt from the stage's state",
580
587
  },
581
588
  severity: {
582
589
  type: "string",
@@ -599,13 +606,14 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
599
606
  description: "Related previous journal entry IDs",
600
607
  },
601
608
  },
602
- required: ["runId", "category", "area", "summary"],
609
+ required: ["runId", "category", "summary"],
603
610
  additionalProperties: false,
604
611
  },
605
612
  async execute(client, args) {
606
613
  return await client.recordWorkflowEntry(requiredString(args.runId, "runId"), {
607
614
  category: requiredString(args.category, "category") as JournalCategory,
608
- area: requiredString(args.area, "area") as ImprovementArea,
615
+ ...(optionalString(args.area) ? { area: optionalString(args.area) as ImprovementArea } : {}),
616
+ ...(optionalString(args.stageId) ? { stageId: optionalString(args.stageId)! } : {}),
609
617
  ...(optionalString(args.severity)
610
618
  ? { severity: optionalString(args.severity) as "info" | "warning" | "error" }
611
619
  : {}),
@@ -689,7 +697,8 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
689
697
  group: "workflow",
690
698
  verb: "improve-report",
691
699
  label: "Summarize improvement report",
692
- description: "Summarize workflow errors, contradictions, and lessons by improvement area.",
700
+ description:
701
+ "Summarize workflow errors, contradictions, lessons, and skill candidates by improvement area, plus ranked cross-run signals: duplicates merged across runs and scored by frequency x severity x run-attempt cost x evidence confidence, security first, with redacted text.",
693
702
  parameters: {
694
703
  type: "object",
695
704
  properties: {},
@@ -744,7 +753,8 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
744
753
  group: "context",
745
754
  verb: "recall",
746
755
  label: "Recall context metadata",
747
- description: "Search durable context records for a project by query; returns bounded metadata only.",
756
+ description:
757
+ "Search durable context records for a project by query. Ranks exact-phrase matches first, then token relevance, then id; returns bounded metadata with a numeric relevance per item, never summaries.",
748
758
  parameters: {
749
759
  type: "object",
750
760
  properties: {
@@ -55,14 +55,17 @@ export interface KxmHubConfig {
55
55
 
56
56
  export type ImprovementPromotionPolicy = "manual_pr" | "critic_quorum" | "auto_threshold";
57
57
 
58
+ const IMPROVEMENT_PROMOTION_POLICIES: readonly ImprovementPromotionPolicy[] = ["manual_pr", "critic_quorum", "auto_threshold"];
59
+
60
+ /** Normalized on load: every field is present and in range. */
58
61
  export interface KxmImprovementConfig {
59
62
  promotionPolicy: ImprovementPromotionPolicy;
60
- telemetryHalfLifeDays?: number | undefined;
61
- autoThreshold?: {
62
- minRuns?: number | undefined;
63
- minPassRate?: number | undefined;
64
- minCostSavings?: number | undefined;
65
- } | undefined;
63
+ telemetryHalfLifeDays: number;
64
+ autoThreshold: {
65
+ minRuns: number;
66
+ minPassRate: number;
67
+ minCostSavings: number;
68
+ };
66
69
  }
67
70
 
68
71
  export interface KxmShadowExecutionConfig {
@@ -195,6 +198,38 @@ function normalizeHubConfig(raw: unknown): KxmHubConfig {
195
198
  return { autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart };
196
199
  }
197
200
 
201
+ function recordOf(raw: unknown): Record<string, unknown> {
202
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
203
+ }
204
+
205
+ function finiteNumber(value: unknown): number | undefined {
206
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
207
+ }
208
+
209
+ /** `improvement.*` values fail closed to the defaults field by field, so a typo
210
+ * never turns review readiness into something else. None of these values can
211
+ * authorize a promotion; they only shape the readiness `kxm improve` reports. */
212
+ function normalizeImprovementConfig(raw: unknown): KxmImprovementConfig {
213
+ const value = recordOf(raw);
214
+ const threshold = recordOf(value.autoThreshold);
215
+ const policy = value.promotionPolicy;
216
+ const halfLife = finiteNumber(value.telemetryHalfLifeDays);
217
+ const minRuns = finiteNumber(threshold.minRuns);
218
+ const minPassRate = finiteNumber(threshold.minPassRate);
219
+ const minCostSavings = finiteNumber(threshold.minCostSavings);
220
+ return {
221
+ promotionPolicy: IMPROVEMENT_PROMOTION_POLICIES.includes(policy as ImprovementPromotionPolicy)
222
+ ? policy as ImprovementPromotionPolicy
223
+ : "manual_pr",
224
+ telemetryHalfLifeDays: halfLife !== undefined && halfLife > 0 && halfLife <= 3650 ? halfLife : 14,
225
+ autoThreshold: {
226
+ minRuns: minRuns !== undefined && Number.isInteger(minRuns) && minRuns >= 1 && minRuns <= 1_000_000 ? minRuns : 10,
227
+ minPassRate: minPassRate !== undefined && minPassRate >= 0 && minPassRate <= 1 ? minPassRate : 0.95,
228
+ minCostSavings: minCostSavings !== undefined && minCostSavings >= 0 ? minCostSavings : 0.5,
229
+ },
230
+ };
231
+ }
232
+
198
233
  export function loadKxmConfig(
199
234
  repoRoot = process.cwd(),
200
235
  options: { userConfigDir?: string } = {},
@@ -241,7 +276,7 @@ export function loadKxmConfig(
241
276
  dash: (mergedAll.dash as KxmDashConfig) ?? {},
242
277
  sync: (mergedAll.sync as KxmSyncTrackerConfig) ?? {},
243
278
  hub: normalizeHubConfig(mergedAll.hub),
244
- improvement: (mergedAll.improvement as KxmImprovementConfig) ?? DEFAULT_KXM_CONFIG.improvement,
279
+ improvement: normalizeImprovementConfig(mergedAll.improvement),
245
280
  routing: (mergedAll.routing as KxmRoutingConfig) ?? DEFAULT_KXM_CONFIG.routing,
246
281
  telemetry: (mergedAll.telemetry as KxmTelemetryConfig) ?? DEFAULT_KXM_CONFIG.telemetry,
247
282
  loadedFrom: {
@@ -142,6 +142,7 @@ export function buildFormalContextPacket(input: {
142
142
  environment?: Partial<ContextPacketEnvironment> | undefined;
143
143
  tokenBudget?: number | undefined;
144
144
  arbitratedItems?: ContextItem[] | undefined;
145
+ unresolvedGaps?: string[] | undefined;
145
146
  }): FormalContextPacketV2 {
146
147
  const packetId = `ctxpkt_${randomUUID().replaceAll("-", "").slice(0, 16)}`;
147
148
  const now = new Date().toISOString();
@@ -208,7 +209,7 @@ export function buildFormalContextPacket(input: {
208
209
  budget: {
209
210
  allocatedTokens,
210
211
  estimatedTokens,
211
- unresolvedGaps: [],
212
+ unresolvedGaps: [...(input.unresolvedGaps ?? [])],
212
213
  provenanceSummary,
213
214
  },
214
215
  };
@@ -265,7 +266,12 @@ export function formatContextPacketForPrompt(packet: FormalContextPacketV2): str
265
266
  sections.push("");
266
267
  }
267
268
 
268
- if (packet.environment.projectKnowledge.length > 0 || packet.environment.sharedDefaults.length > 0) {
269
+ if (
270
+ packet.environment.projectKnowledge.length > 0
271
+ || packet.environment.sharedDefaults.length > 0
272
+ || packet.environment.activeSkills.length > 0
273
+ || packet.environment.contradictions.length > 0
274
+ ) {
269
275
  sections.push(`## 5. Environment & Memory`);
270
276
  if (packet.environment.sharedDefaults.length > 0) {
271
277
  sections.push(`### Shared Defaults`);
@@ -279,6 +285,12 @@ export function formatContextPacketForPrompt(packet: FormalContextPacketV2): str
279
285
  sections.push(`- [${k.authority}] ${k.summary}`);
280
286
  }
281
287
  }
288
+ if (packet.environment.activeSkills.length > 0) {
289
+ sections.push(`### Active Skills`);
290
+ for (const skill of packet.environment.activeSkills) {
291
+ sections.push(`- ${skill.summary} (${skill.provenance.sourceRef ?? skill.id})`);
292
+ }
293
+ }
282
294
  if (packet.environment.contradictions.length > 0) {
283
295
  sections.push(`### Active Contradictions`);
284
296
  for (const c of packet.environment.contradictions) {
@@ -150,6 +150,7 @@ export interface ContextPacket {
150
150
  workingState: Record<string, unknown>;
151
151
  currentState: ContextItem[];
152
152
  knowledge: ContextItem[];
153
+ evidence: ContextItem[];
153
154
  episodes: ContextItem[];
154
155
  skills: ContextItem[];
155
156
  contradictions: ContextItem[];
@@ -334,7 +335,14 @@ export function parseContextRequest(value: unknown): ContextRequest {
334
335
  /** Validate that a packet only contains items matching the request's project
335
336
  * and requested kinds. Cross-project content fails closed. */
336
337
  export function validateContextPacketContents(request: ContextRequest, packet: ContextPacket): void {
337
- const items = [...packet.currentState, ...packet.knowledge, ...packet.episodes, ...packet.skills, ...packet.contradictions];
338
+ const items = [
339
+ ...packet.currentState,
340
+ ...packet.knowledge,
341
+ ...packet.evidence,
342
+ ...packet.episodes,
343
+ ...packet.skills,
344
+ ...packet.contradictions,
345
+ ];
338
346
  if (items.length > MAX_CONTEXT_ITEMS) {
339
347
  throw new ProtocolError(400, `context packet exceeds ${MAX_CONTEXT_ITEMS} items`, "context_limits_exceeded");
340
348
  }
@@ -357,14 +365,17 @@ export function validateContextPacketContents(request: ContextRequest, packet: C
357
365
  }
358
366
  }
359
367
 
368
+ /** Serialized size of one item as the token estimate counts it: summary, id,
369
+ * kind, and provenance sourceRef characters. */
370
+ export function contextItemCharacters(item: ContextItem): number {
371
+ return item.summary.length + item.id.length + item.kind.length + (item.provenance.sourceRef?.length ?? 0);
372
+ }
373
+
360
374
  /** Deterministic, dependency-free token estimate. Roughly 4 characters per
361
375
  * token; used for budget enforcement, never for billing. */
362
376
  export function estimateContextTokens(items: ContextItem[]): number {
363
377
  let characters = 0;
364
- for (const item of items) {
365
- characters += item.summary.length + item.id.length + item.kind.length;
366
- if (item.provenance.sourceRef) characters += item.provenance.sourceRef.length;
367
- }
378
+ for (const item of items) characters += contextItemCharacters(item);
368
379
  return Math.ceil(characters / 4);
369
380
  }
370
381
 
@@ -0,0 +1,286 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync } from "node:fs";
3
+ import { extname, join } from "node:path";
4
+ import { arbitrate, memoryRecordToContextItem, rolePolicy, type ArbiterOutcome } from "./arbiter.ts";
5
+ import { CONTEXT_ROLES, MAX_CONTEXT_TASK_CHARS, type ContextItem } from "./context.ts";
6
+ import { loadAuthoredMemory, type MemoryRecord } from "./memory.ts";
7
+ import { computeKxmMemoryRevision } from "./runtime-service.ts";
8
+ import { SkillLifecycle, type SkillCandidateMetadata } from "./skills.ts";
9
+
10
+ /**
11
+ * Runtime dispatch context (Q-K).
12
+ *
13
+ * A Runtime-dispatched agent receives the project's authored memory (active
14
+ * records with project or operator scope; candidates are never read) and its
15
+ * hash-verified promoted skills, selected by the same deterministic arbiter the
16
+ * hub uses. Delivery happens only when those files are committed and clean at
17
+ * HEAD and still match the run's pinned memory revision; otherwise the context
18
+ * is withheld and a `dispatch_context_*` gap says why. Only local git and the
19
+ * project tree are read: no hub source is fetched, so dispatch stays offline.
20
+ *
21
+ * Loading (git, hashing, file reads) happens outside the engine's IMMEDIATE
22
+ * transaction; assembly is pure and runs at birth.
23
+ */
24
+
25
+ export const DISPATCH_CONTEXT_BUDGET_TOKENS = 4_000;
26
+ /** The prompt formatter renders at most this many project knowledge items. */
27
+ export const DISPATCH_CONTEXT_MAX_PROJECT_ITEMS = 5;
28
+
29
+ const GAP_NOT_LOADED = "dispatch_context_not_loaded";
30
+ const GAP_FAILED = "dispatch_context_failed";
31
+ const GAP_GIT_UNAVAILABLE = "dispatch_context_withheld:git_unavailable";
32
+ const GAP_UNCOMMITTED = "dispatch_context_withheld:uncommitted";
33
+ const GAP_DRIFT = "dispatch_context_withheld:memory_revision_drift";
34
+ const GAP_MEMORY_UNREADABLE = "dispatch_context_memory_unreadable";
35
+ const GAP_SKILLS_UNREADABLE = "dispatch_context_skills_unreadable";
36
+
37
+ /** Only the files the memory and skill loaders read. */
38
+ const DISPATCH_CONTEXT_PATHSPEC = [
39
+ ":(glob).kxm/memory/*.md",
40
+ ":(glob).kxm/skills/promoted/*/SKILL.md",
41
+ ":(glob).kxm/skills/promoted/*/metadata.json",
42
+ ] as const;
43
+ const GIT_STATUS_TIMEOUT_MS = 5_000;
44
+
45
+ export interface DispatchContextSources {
46
+ readonly present: boolean;
47
+ readonly pool: readonly ContextItem[];
48
+ readonly promoted: readonly SkillCandidateMetadata[];
49
+ readonly gaps: readonly string[];
50
+ /** Authored records with agent or run scope, which dispatch cannot bind. */
51
+ readonly skippedUnboundScopes: number;
52
+ }
53
+
54
+ export const EMPTY_DISPATCH_SOURCES: DispatchContextSources = Object.freeze({
55
+ present: false,
56
+ pool: Object.freeze([]),
57
+ promoted: Object.freeze([]),
58
+ gaps: Object.freeze([]),
59
+ skippedUnboundScopes: 0,
60
+ });
61
+
62
+ export const NOT_LOADED_DISPATCH_SOURCES: DispatchContextSources = Object.freeze({
63
+ present: true,
64
+ pool: Object.freeze([]),
65
+ promoted: Object.freeze([]),
66
+ gaps: Object.freeze([GAP_NOT_LOADED]),
67
+ skippedUnboundScopes: 0,
68
+ });
69
+
70
+ export interface DispatchContextAssembly {
71
+ /** Arbitrated items to deliver, in rank order. */
72
+ readonly items: ContextItem[];
73
+ readonly deliveredIds: string[];
74
+ /** Selected project items the prompt formatter would not render. */
75
+ readonly renderDeferred: number;
76
+ readonly role: string;
77
+ readonly unresolvedGaps: string[];
78
+ readonly audit?: ArbiterOutcome["audit"];
79
+ }
80
+
81
+ /** Map an agent id to a context role: an exact role, else the first role the
82
+ * id is prefixed by (`critic-arch` is a critic), else the id itself, which the
83
+ * arbiter's role policy falls back on. */
84
+ export function contextRoleForAgent(agentId: string): string {
85
+ if (CONTEXT_ROLES.includes(agentId)) return agentId;
86
+ for (const role of CONTEXT_ROLES) {
87
+ if (agentId.startsWith(`${role}-`)) return role;
88
+ }
89
+ return agentId;
90
+ }
91
+
92
+ /** Cheap presence probe: a top-level markdown file in `.kxm/memory` or any
93
+ * entry in `.kxm/skills/promoted`. No git and no hashing; a missing directory
94
+ * or a read error counts as absent. */
95
+ export function dispatchContextPresent(projectRoot: string): boolean {
96
+ return hasTopLevelMarkdown(join(projectRoot, ".kxm", "memory"))
97
+ || hasAnyEntry(join(projectRoot, ".kxm", "skills", "promoted"));
98
+ }
99
+
100
+ function hasTopLevelMarkdown(dir: string): boolean {
101
+ try {
102
+ return readdirSync(dir, { withFileTypes: true }).some((entry) => entry.isFile() && extname(entry.name) === ".md");
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function hasAnyEntry(dir: string): boolean {
109
+ try {
110
+ return readdirSync(dir).length > 0;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function gitEnvironment(): NodeJS.ProcessEnv {
117
+ return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
118
+ }
119
+
120
+ /** Invariant 12: only committed, clean content is delivered. `--no-optional-locks`
121
+ * keeps `.git/index.lock` free for command gates running in the same root. */
122
+ function committedGateGap(projectRoot: string): string | undefined {
123
+ const result = spawnSync("git", [
124
+ "--no-optional-locks",
125
+ "-C",
126
+ projectRoot,
127
+ "status",
128
+ "--porcelain=v1",
129
+ "--untracked-files=all",
130
+ "--ignored=matching",
131
+ "--",
132
+ ...DISPATCH_CONTEXT_PATHSPEC,
133
+ ], {
134
+ encoding: "utf8",
135
+ env: gitEnvironment(),
136
+ timeout: GIT_STATUS_TIMEOUT_MS,
137
+ windowsHide: true,
138
+ });
139
+ if (result.error || result.status !== 0) return GAP_GIT_UNAVAILABLE;
140
+ if (typeof result.stdout !== "string" || result.stdout.length > 0) return GAP_UNCOMMITTED;
141
+ return undefined;
142
+ }
143
+
144
+ /** A gap names ids only. An id that is not a plain identifier (skill metadata
145
+ * is file content) is not echoed. */
146
+ function gapId(value: unknown): string {
147
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) ? value : "invalid";
148
+ }
149
+
150
+ function withheld(gaps: readonly string[]): DispatchContextSources {
151
+ return { present: true, pool: [], promoted: [], gaps: [...gaps], skippedUnboundScopes: 0 };
152
+ }
153
+
154
+ /** Load the dispatch pool: every file read, git check and hash happens here, so
155
+ * callers run it outside any SQLite transaction. Total: it never throws. */
156
+ export function loadDispatchContextSources(input: {
157
+ projectRoot: string;
158
+ projectId: string;
159
+ pinnedMemoryRevision: string;
160
+ }): DispatchContextSources {
161
+ try {
162
+ const { projectRoot, projectId, pinnedMemoryRevision } = input;
163
+ if (!dispatchContextPresent(projectRoot)) return EMPTY_DISPATCH_SOURCES;
164
+
165
+ const gateGap = committedGateGap(projectRoot);
166
+ if (gateGap !== undefined) return withheld([gateGap]);
167
+ if (computeKxmMemoryRevision({ projectRoot }) !== pinnedMemoryRevision) return withheld([GAP_DRIFT]);
168
+
169
+ const gaps: string[] = [];
170
+ let records: MemoryRecord[];
171
+ try {
172
+ records = loadAuthoredMemory(projectRoot);
173
+ } catch {
174
+ gaps.push(GAP_MEMORY_UNREADABLE);
175
+ records = [];
176
+ }
177
+
178
+ const pool: ContextItem[] = [];
179
+ let skippedUnboundScopes = 0;
180
+ for (const record of records) {
181
+ if (record.scope !== "project" && record.scope !== "operator") {
182
+ skippedUnboundScopes += 1;
183
+ continue;
184
+ }
185
+ try {
186
+ pool.push(memoryRecordToContextItem(record, projectId));
187
+ } catch {
188
+ gaps.push(`dispatch_context_memory_rejected:${gapId(record.id)}`);
189
+ }
190
+ }
191
+
192
+ const promoted: SkillCandidateMetadata[] = [];
193
+ try {
194
+ const lifecycle = new SkillLifecycle(join(projectRoot, ".kxm", "skills"));
195
+ for (const metadata of lifecycle.list("promoted")) {
196
+ try {
197
+ promoted.push(lifecycle.verify("promoted", metadata.id));
198
+ } catch {
199
+ gaps.push(`dispatch_context_skill_unverified:${gapId(metadata.id)}`);
200
+ }
201
+ }
202
+ } catch {
203
+ gaps.push(GAP_SKILLS_UNREADABLE);
204
+ }
205
+
206
+ // The tree may have moved while it was read: what was read is only
207
+ // delivered when the revision still matches the pin.
208
+ if (computeKxmMemoryRevision({ projectRoot }) !== pinnedMemoryRevision) return withheld([...gaps, GAP_DRIFT]);
209
+
210
+ return { present: true, pool, promoted, gaps, skippedUnboundScopes };
211
+ } catch {
212
+ return withheld([GAP_FAILED]);
213
+ }
214
+ }
215
+
216
+ /** Arbitrate the loaded sources for one dispatched agent. Pure: no file I/O,
217
+ * never throws. Delivered project items are capped at what the prompt
218
+ * formatter renders; the overflow is reported as a gap. */
219
+ export function assembleDispatchContext(
220
+ sources: DispatchContextSources,
221
+ input: { projectId: string; runId: string; stepId: string; agentId: string; task: string },
222
+ ): DispatchContextAssembly {
223
+ const role = contextRoleForAgent(input.agentId);
224
+ try {
225
+ if (sources.pool.length === 0 && sources.promoted.length === 0) {
226
+ return { items: [], deliveredIds: [], renderDeferred: 0, role, unresolvedGaps: [...sources.gaps] };
227
+ }
228
+ const task = (input.task.trim() || input.stepId).slice(0, MAX_CONTEXT_TASK_CHARS);
229
+ const promoted = sources.promoted;
230
+ const snapshot: Pick<SkillLifecycle, "list" | "verify"> = {
231
+ list: (state) => (state === "promoted" ? [...promoted] : []),
232
+ verify: (state, id) => {
233
+ const found = state === "promoted" ? promoted.find((metadata) => metadata.id === id) : undefined;
234
+ if (!found) throw new Error("skill is not in the verified dispatch snapshot");
235
+ return found;
236
+ },
237
+ };
238
+ const { packet, audit } = arbitrate(
239
+ {
240
+ project: input.projectId,
241
+ role,
242
+ task,
243
+ workflowRunId: input.runId,
244
+ stageId: input.stepId,
245
+ budgetTokens: Math.min(rolePolicy(role).budgetTokens, DISPATCH_CONTEXT_BUDGET_TOKENS),
246
+ },
247
+ [...sources.pool],
248
+ promoted.length > 0 ? { skillLifecycle: snapshot } : {},
249
+ );
250
+
251
+ const byId = new Map<string, ContextItem>();
252
+ for (const section of [packet.currentState, packet.knowledge, packet.evidence, packet.episodes, packet.skills, packet.contradictions]) {
253
+ for (const item of section) byId.set(item.id, item);
254
+ }
255
+ const items: ContextItem[] = [];
256
+ let projectItems = 0;
257
+ let renderDeferred = 0;
258
+ for (const id of audit.selectedIds) {
259
+ const item = byId.get(id);
260
+ if (!item) continue;
261
+ const projectRouted = item.project !== "_shared" && item.kind !== "state" && item.kind !== "episode" && item.kind !== "skill";
262
+ if (projectRouted) {
263
+ if (projectItems >= DISPATCH_CONTEXT_MAX_PROJECT_ITEMS) {
264
+ renderDeferred += 1;
265
+ continue;
266
+ }
267
+ projectItems += 1;
268
+ }
269
+ items.push(item);
270
+ }
271
+ return {
272
+ items,
273
+ deliveredIds: items.map((item) => item.id),
274
+ renderDeferred,
275
+ role,
276
+ unresolvedGaps: [
277
+ ...sources.gaps,
278
+ ...packet.unresolvedGaps,
279
+ ...(renderDeferred > 0 ? [`dispatch_context_render_deferred:${renderDeferred}`] : []),
280
+ ],
281
+ audit,
282
+ };
283
+ } catch {
284
+ return { items: [], deliveredIds: [], renderDeferred: 0, role, unresolvedGaps: [...sources.gaps, GAP_FAILED] };
285
+ }
286
+ }
@@ -94,6 +94,46 @@ export function kxmSha256(input: string): string {
94
94
  return `sha256:${createHash("sha256").update(input, "utf8").digest("hex")}`;
95
95
  }
96
96
 
97
+ /**
98
+ * Stable identity of what one step asks of one agent. It is equal across runs
99
+ * of the same compiled step and agent, and it excludes the run, assignment and
100
+ * attempt ids, the run objective, repositories, model and the context packet,
101
+ * so a repeated ask is recognisable whatever the run was asked to do.
102
+ */
103
+ export function kxmStepAskSha256(plan: KxmCompiledPlan, stepId: string, agentId: string): string {
104
+ const step = Object.hasOwn(plan.steps, stepId) ? plan.steps[stepId] : undefined;
105
+ if (!step) throw runtimeError("run_plan_corrupt", plan.workflowId, `compiled plan is missing step ${stepId}`);
106
+ return kxmSha256(kxmCanonicalJson({
107
+ v: 1,
108
+ workflowId: plan.workflowId,
109
+ stepId,
110
+ kind: step.kind,
111
+ agentId,
112
+ instructions: step.instructions ?? null,
113
+ outcomes: [...step.outcomes],
114
+ requiredEvidence: step.requiredEvidence.map((entry) => entry.key),
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * The record-time verdict of one settled attempt. Only gate-negative results
120
+ * are known when the attempt settles: a back edge is 'blocked', and a producer
121
+ * error, an unknown outcome or a terminal failure is 'failed'. A forward edge
122
+ * or a completed terminal is undecided here; acceptance is resolved later from
123
+ * the event log, so this never returns 'accepted'.
124
+ */
125
+ export function kxmAttemptFinalOutcome(
126
+ step: Pick<KxmCompiledStep, "transitions">,
127
+ settled: { readonly resultClass: string; readonly outcome?: string | undefined },
128
+ ): "blocked" | "failed" | undefined {
129
+ if (settled.resultClass !== "outcome") return "failed";
130
+ const outcome = settled.outcome;
131
+ const transition = outcome !== undefined && Object.hasOwn(step.transitions, outcome) ? step.transitions[outcome] : undefined;
132
+ if (!transition) return "failed";
133
+ if (transition.to === "terminal") return transition.terminalStatus === "completed" ? undefined : "failed";
134
+ return transition.edge === "back" ? "blocked" : undefined;
135
+ }
136
+
97
137
  export function hashKxmRunPlanEnvelope(envelope: KxmRunPlanEnvelope): string {
98
138
  return kxmSha256(kxmCanonicalJson(envelope as unknown as JsonValue));
99
139
  }