@kontextmind/kxm 0.7.91 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/workflows/default.yaml +1 -1
- package/CHANGELOG.md +212 -0
- package/README.md +3 -0
- package/docs/README.md +3 -0
- package/docs/agent-skills.md +123 -60
- package/docs/architecture.md +5 -2
- package/docs/cli-reference.md +3527 -0
- package/docs/config-reference.md +1943 -0
- package/docs/configuration.md +30 -4
- package/docs/continuous-improvement.md +122 -10
- package/docs/contracts/routing.md +95 -11
- package/docs/harness-routing.md +616 -0
- package/docs/kxm-handbook.md +106 -19
- package/docs/templates/README.md +1 -1
- package/docs/test-matrix.md +12 -6
- package/docs/troubleshooting.md +2 -2
- package/examples/project/.kxm/workflows/fix.yaml +1 -1
- package/examples/project/.kxm/workflows/improve.yaml +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +9 -10
- package/plugins/kxm/README.md +238 -56
- package/plugins/kxm/dist/claude-hook.js +10083 -0
- package/plugins/kxm/dist/cli.js +2487 -1848
- package/plugins/kxm/dist/client.js +64 -0
- package/plugins/kxm/dist/core.js +102 -9
- package/plugins/kxm/dist/extension.js +210 -68
- package/plugins/kxm/dist/mcp-server.js +217 -40
- package/plugins/kxm/dist/runtime-supervisor.js +1628 -157
- package/plugins/kxm/dist/runtime.js +1874 -298
- package/plugins/kxm/dist/server.js +416 -82
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/hints.json +1 -1
- package/plugins/kxm/skills/kxm/SKILL.md +48 -24
- package/plugins/kxm/skills/kxm/references/protocol.md +3 -3
- package/plugins/kxm/skills/kxm-context-memory/SKILL.md +67 -21
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +9 -0
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +82 -16
- package/plugins/kxm/skills/kxm-harvest/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +55 -27
- package/plugins/kxm/skills/kxm-insights/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-mind/SKILL.md +2 -2
- package/plugins/kxm/skills/{kxm-setup → kxm-mind-setup}/SKILL.md +4 -4
- package/plugins/kxm/skills/kxm-peer/SKILL.md +68 -93
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +156 -23
- package/plugins/kxm/skills/kxm-projects/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-protocol/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-query/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +74 -15
- package/plugins/kxm/skills/kxm-runs/SKILL.md +46 -17
- package/plugins/kxm/skills/kxm-session/SKILL.md +64 -36
- package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +44 -15
- package/plugins/kxm/skills/kxm-tasks/SKILL.md +16 -4
- package/plugins/kxm/skills/kxm-triage/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-work/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +60 -19
- package/plugins/kxm/src/arbiter.ts +67 -22
- package/plugins/kxm/src/autocomplete.ts +1 -1
- package/plugins/kxm/src/claude-hook.ts +192 -0
- package/plugins/kxm/src/cli/project.ts +11 -5
- package/plugins/kxm/src/cli/system.ts +85 -13
- package/plugins/kxm/src/cli/types.ts +4 -1
- package/plugins/kxm/src/cli/workflows.ts +18 -16
- package/plugins/kxm/src/cli.ts +23 -13
- package/plugins/kxm/src/client.ts +15 -4
- package/plugins/kxm/src/commands.ts +19 -9
- package/plugins/kxm/src/config.ts +42 -7
- package/plugins/kxm/src/context-packet.ts +14 -2
- package/plugins/kxm/src/context.ts +16 -5
- package/plugins/kxm/src/dispatch-context.ts +286 -0
- package/plugins/kxm/src/engine-plan.ts +40 -0
- package/plugins/kxm/src/engine.ts +138 -6
- package/plugins/kxm/src/hub-env.ts +17 -1
- package/plugins/kxm/src/hub.ts +92 -29
- package/plugins/kxm/src/improve-sources.ts +228 -0
- package/plugins/kxm/src/improve.ts +325 -140
- package/plugins/kxm/src/local-snapshot.ts +101 -42
- package/plugins/kxm/src/mcp-server.ts +129 -30
- package/plugins/kxm/src/memory.ts +43 -20
- package/plugins/kxm/src/project-config.ts +25 -0
- package/plugins/kxm/src/protocol.ts +11 -0
- package/plugins/kxm/src/relevance.ts +138 -0
- package/plugins/kxm/src/retrospective.ts +16 -10
- package/plugins/kxm/src/runtime-service.ts +8 -1
- package/plugins/kxm/src/runtime-supervisor.ts +16 -2
- package/plugins/kxm/src/session-token-hint.ts +17 -0
- package/plugins/kxm/src/suggest.ts +7 -7
- package/plugins/kxm/src/workflow-manager.ts +80 -78
- package/plugins/kxm/src/workflow.ts +202 -12
- package/scripts/build-runtime.mjs +7 -1
- package/scripts/check-generated.mjs +1 -0
- 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
|
|
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:
|
|
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: [
|
|
576
|
+
enum: [...JOURNAL_CATEGORIES],
|
|
574
577
|
description: "Category of journal entry",
|
|
575
578
|
},
|
|
576
579
|
area: {
|
|
577
580
|
type: "string",
|
|
578
|
-
enum: [
|
|
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", "
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
61
|
-
autoThreshold
|
|
62
|
-
minRuns
|
|
63
|
-
minPassRate
|
|
64
|
-
minCostSavings
|
|
65
|
-
}
|
|
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
|
|
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 (
|
|
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 = [
|
|
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
|
}
|