@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
@@ -56,6 +56,31 @@ export interface LocalMeshSnapshot {
56
56
  spend?: Array<{ recordedAt: string; routing: RoutingRecord | RoutingRecordV2 }> | undefined;
57
57
  }
58
58
 
59
+ /** Limits a snapshot to one project and one recipient.
60
+ *
61
+ * `hubProject` is the hub project key stored on legacy messages and runs.
62
+ * `runtimeProjectId` is the `.kxm/project.yaml` id that keys Runtime runs;
63
+ * without it the snapshot reads no Runtime runs. `recipientName` selects the
64
+ * open messages addressed to this agent; without it no messages are read. A
65
+ * scoped snapshot never reads journal plans. */
66
+ export interface LocalMeshSnapshotScope {
67
+ hubProject: string;
68
+ runtimeProjectId?: string | undefined;
69
+ recipientName?: string | undefined;
70
+ }
71
+
72
+ export interface LocalMeshSnapshotOptions {
73
+ env?: NodeJS.ProcessEnv;
74
+ projectRoot?: string;
75
+ kxmStateRoot?: string;
76
+ /** SQLite busy timeout for every database this snapshot opens. Default 5000. */
77
+ busyTimeoutMs?: number;
78
+ scope?: LocalMeshSnapshotScope;
79
+ }
80
+
81
+ const DEFAULT_BUSY_TIMEOUT_MS = 5000;
82
+ const RUNTIME_PROJECT_KEY = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
83
+
59
84
  function processExists(pid: number): boolean {
60
85
  try {
61
86
  process.kill(pid, 0);
@@ -65,8 +90,8 @@ function processExists(pid: number): boolean {
65
90
  }
66
91
  }
67
92
 
68
- function readJsonRows<T>(database: DatabaseSync, sql: string): T[] {
69
- const rows = database.prepare(sql).all() as Array<{ record: string }>;
93
+ function readJsonRows<T>(database: DatabaseSync, sql: string, params: string[] = []): T[] {
94
+ const rows = database.prepare(sql).all(...params) as Array<{ record: string }>;
70
95
  const out: T[] = [];
71
96
  for (const row of rows) {
72
97
  try {
@@ -155,12 +180,17 @@ function readPlanMetadata(database: DatabaseSync): MeshTuiPlan[] {
155
180
  }
156
181
  }
157
182
 
158
- function countRows(database: DatabaseSync, table: "messages" | "workflow_runs", where = ""): number {
159
- const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}${where}`).get() as { count?: number | bigint } | undefined;
183
+ function countRows(database: DatabaseSync, table: "messages" | "workflow_runs", where = "", params: string[] = []): number {
184
+ const row = database.prepare(`SELECT COUNT(*) AS count FROM ${table}${where}`).get(...params) as { count?: number | bigint } | undefined;
160
185
  return Number(row?.count ?? 0);
161
186
  }
162
187
 
163
- function readOpenMessageMetadata(database: DatabaseSync): MeshTuiOpenMessage[] {
188
+ const OPEN_MESSAGE_WHERE = " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')";
189
+ const SCOPED_MESSAGE_WHERE = `${OPEN_MESSAGE_WHERE}
190
+ AND json_extract(record, '$.project') = ?
191
+ AND COALESCE(json_extract(record, '$.toName'), json_extract(record, '$.to')) = ?`;
192
+
193
+ function readOpenMessageMetadata(database: DatabaseSync, where = OPEN_MESSAGE_WHERE, params: string[] = []): MeshTuiOpenMessage[] {
164
194
  const rows = database.prepare(`
165
195
  SELECT
166
196
  json_extract(record, '$.id') AS id,
@@ -170,11 +200,10 @@ function readOpenMessageMetadata(database: DatabaseSync): MeshTuiOpenMessage[] {
170
200
  json_extract(record, '$.delivery') AS delivery,
171
201
  json_extract(record, '$.createdAt') AS createdAt,
172
202
  json_extract(record, '$.correlationId') AS correlationId
173
- FROM messages
174
- WHERE json_extract(record, '$.status') IN ('queued', 'delivered')
203
+ FROM messages${where}
175
204
  ORDER BY json_extract(record, '$.createdAt') DESC
176
205
  LIMIT 16
177
- `).all() as Array<Record<string, unknown>>;
206
+ `).all(...params) as Array<Record<string, unknown>>;
178
207
  const messages: MeshTuiOpenMessage[] = [];
179
208
  for (const row of rows) {
180
209
  if (
@@ -236,11 +265,41 @@ function resolveKxmStateRoot(
236
265
  return undefined;
237
266
  }
238
267
 
268
+ function readRuntimeRuns(eventDb: DatabaseSync, projectId: string | undefined): { runs: MeshTuiRun[]; total: number } {
269
+ const where = projectId === undefined ? "" : " WHERE project_id = ?";
270
+ const params = projectId === undefined ? [] : [projectId];
271
+ const runRows = eventDb.prepare(`
272
+ SELECT run_id, project_id, workflow_id, status, created_at, updated_at
273
+ FROM runs${where} ORDER BY created_at DESC, run_id DESC LIMIT 8
274
+ `).all(...params) as Array<{
275
+ run_id: string;
276
+ project_id: string;
277
+ workflow_id: string;
278
+ status: string;
279
+ created_at: string;
280
+ updated_at: string;
281
+ }>;
282
+ const countRow = eventDb.prepare(`SELECT COUNT(*) AS total FROM runs${where}`).get(...params) as { total: number } | undefined;
283
+ return {
284
+ total: Number(countRow?.total ?? runRows.length),
285
+ runs: runRows.map((r) => ({
286
+ id: r.run_id,
287
+ status: r.status,
288
+ definitionId: r.workflow_id,
289
+ project: r.project_id,
290
+ updatedAt: r.updated_at || r.created_at,
291
+ })),
292
+ };
293
+ }
294
+
239
295
  export function loadLocalMeshSnapshot(
240
296
  dataPath: string,
241
297
  stateDir: string,
242
- options?: { env?: NodeJS.ProcessEnv; projectRoot?: string; kxmStateRoot?: string },
298
+ options?: LocalMeshSnapshotOptions,
243
299
  ): LocalMeshSnapshot {
300
+ const busyTimeoutMs = Math.max(0, Math.trunc(Number(options?.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS)) || 0);
301
+ const busyTimeout = `PRAGMA busy_timeout = ${busyTimeoutMs}`;
302
+ const scope = options?.scope;
244
303
  let hasLegacy = false;
245
304
  let agents: AgentRecord[] = [];
246
305
  let openMessages: MeshTuiOpenMessage[] = [];
@@ -252,7 +311,7 @@ export function loadLocalMeshSnapshot(
252
311
  hasLegacy = true;
253
312
  const database = openReadOnlyDatabase(dataPath);
254
313
  try {
255
- database.exec("PRAGMA busy_timeout = 5000");
314
+ database.exec(busyTimeout);
256
315
  // Stored rows carry identity only. Presence is derived here against the
257
316
  // default lease window, because the hub's configured `staleAfterMs` is
258
317
  // not in the file; a reachable hub's own records replace these.
@@ -262,11 +321,24 @@ export function loadLocalMeshSnapshot(
262
321
  // `online` boolean is the only durable truth; the hub's /v1/agents or the
263
322
  // ops snapshot is the authoritative presence source.
264
323
  agents = readJsonRows<AgentIdentity>(database, "SELECT record FROM agents");
265
- openMessages = readOpenMessageMetadata(database);
266
- openMessageTotal = countRows(database, "messages", " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')");
267
- legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
268
- legacyRunTotal = countRows(database, "workflow_runs");
269
- plans = readPlanMetadata(database);
324
+ if (!scope) {
325
+ openMessages = readOpenMessageMetadata(database);
326
+ openMessageTotal = countRows(database, "messages", OPEN_MESSAGE_WHERE);
327
+ legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
328
+ legacyRunTotal = countRows(database, "workflow_runs");
329
+ plans = readPlanMetadata(database);
330
+ } else {
331
+ // Scoped: this project's runs and this recipient's open messages only.
332
+ // Plans stay empty: journal text belongs to other agents' runs.
333
+ if (scope.recipientName) {
334
+ const messageParams = [scope.hubProject, scope.recipientName];
335
+ openMessages = readOpenMessageMetadata(database, SCOPED_MESSAGE_WHERE, messageParams);
336
+ openMessageTotal = countRows(database, "messages", SCOPED_MESSAGE_WHERE, messageParams);
337
+ }
338
+ const runWhere = " WHERE json_extract(record, '$.project') = ?";
339
+ legacyRuns = readJsonRows<WorkflowRun>(database, `SELECT record FROM workflow_runs${runWhere} ORDER BY rowid DESC LIMIT 8`, [scope.hubProject]);
340
+ legacyRunTotal = countRows(database, "workflow_runs", runWhere, [scope.hubProject]);
341
+ }
270
342
  } finally {
271
343
  database.close();
272
344
  }
@@ -276,7 +348,8 @@ export function loadLocalMeshSnapshot(
276
348
  let hasKxm = false;
277
349
  const kxmRuns: MeshTuiRun[] = [];
278
350
  let kxmRunTotal = 0;
279
- const kxmStateRoot = resolveKxmStateRoot(stateDir, options);
351
+ // A scoped snapshot without a Runtime project id reads no Runtime runs.
352
+ const kxmStateRoot = scope && !scope.runtimeProjectId ? undefined : resolveKxmStateRoot(stateDir, options);
280
353
  if (kxmStateRoot) {
281
354
  const runtimeDir = join(kxmStateRoot, "runtime");
282
355
  const registryDbPath = join(runtimeDir, "registry.db");
@@ -289,8 +362,10 @@ export function loadLocalMeshSnapshot(
289
362
  try {
290
363
  const regDb = openReadOnlyDatabase(registryDbPath);
291
364
  try {
292
- regDb.exec("PRAGMA busy_timeout = 5000");
293
- const pRows = regDb.prepare("SELECT project_key FROM projects").all() as Array<{ project_key: string }>;
365
+ regDb.exec(busyTimeout);
366
+ const pRows = (scope
367
+ ? regDb.prepare("SELECT project_key FROM projects WHERE project_id = ?").all(scope.runtimeProjectId)
368
+ : regDb.prepare("SELECT project_key FROM projects").all()) as Array<{ project_key: string }>;
294
369
  for (const row of pRows) {
295
370
  if (row.project_key) projectKeys.add(row.project_key);
296
371
  }
@@ -300,7 +375,9 @@ export function loadLocalMeshSnapshot(
300
375
  } catch { /* registry error */ }
301
376
  }
302
377
 
303
- if (existsSync(projectsDir)) {
378
+ // Scoped: only the registry's key for this project, never every project
379
+ // directory on the machine.
380
+ if (!scope && existsSync(projectsDir)) {
304
381
  try {
305
382
  for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
306
383
  if (entry.isDirectory()) {
@@ -311,35 +388,17 @@ export function loadLocalMeshSnapshot(
311
388
  }
312
389
 
313
390
  for (const key of projectKeys) {
391
+ if (scope && !RUNTIME_PROJECT_KEY.test(key)) continue;
314
392
  const eventDbPath = join(projectsDir, key, "run-events.db");
315
393
  if (existsSync(eventDbPath)) {
316
394
  hasKxm = true;
317
395
  try {
318
396
  const eventDb = openReadOnlyDatabase(eventDbPath);
319
397
  try {
320
- eventDb.exec("PRAGMA busy_timeout = 5000");
321
- const runRows = eventDb.prepare(`
322
- SELECT run_id, project_id, workflow_id, status, created_at, updated_at
323
- FROM runs ORDER BY created_at DESC, run_id DESC LIMIT 8
324
- `).all() as Array<{
325
- run_id: string;
326
- project_id: string;
327
- workflow_id: string;
328
- status: string;
329
- created_at: string;
330
- updated_at: string;
331
- }>;
332
- const countRow = eventDb.prepare("SELECT COUNT(*) AS total FROM runs").get() as { total: number } | undefined;
333
- kxmRunTotal += Number(countRow?.total ?? runRows.length);
334
- for (const r of runRows) {
335
- kxmRuns.push({
336
- id: r.run_id,
337
- status: r.status,
338
- definitionId: r.workflow_id,
339
- project: r.project_id,
340
- updatedAt: r.updated_at || r.created_at,
341
- });
342
- }
398
+ eventDb.exec(busyTimeout);
399
+ const { runs, total } = readRuntimeRuns(eventDb, scope?.runtimeProjectId);
400
+ kxmRunTotal += total;
401
+ kxmRuns.push(...runs);
343
402
  } finally {
344
403
  eventDb.close();
345
404
  }
@@ -1,14 +1,18 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
5
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
- import { HubClient } from "./client.ts";
5
- import { resolveClientHubAuthToken } from "./hub-env.ts";
6
+ import { HubClient, HubHttpError } from "./client.ts";
7
+ import { resolveAgentHubAuthToken } from "./hub-env.ts";
6
8
  import { defaultProjectName } from "./project-name.ts";
7
9
  import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
8
10
  import { deliverInboxNotification } from "./inbox.ts";
9
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
+ import { sessionTokenFixHint } from "./session-token-hint.ts";
10
13
 
11
- const VERSION = "0.7.92";
14
+ const VERSION = "0.7.93";
15
+ const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
12
16
  const inbox = new Map<string, MessageRecord>();
13
17
  const notifiedInbox = new Set<string>();
14
18
  let meshClient: HubClient | undefined;
@@ -22,11 +26,11 @@ const mcp = new Server(
22
26
  tools: {},
23
27
  },
24
28
  instructions: [
25
- 'KXM peer requests can arrive as <channel source="kxm" message_id="..."> events.',
26
- "Handle the request using normal safety rules, then call kxm_reply with message_id and the final response.",
27
- "Use kxm_inbox as a fallback when channel delivery is not enabled.",
28
- "For durable workflow requests, call kxm_workflow_get, record material plans/decisions/contradictions/errors/lessons, and pass every checkpoint before replying.",
29
- "If work is running in an external system, call kxm_workflow_wait and then kxm_reply so a signed callback can resume the workflow later.",
29
+ 'Peer requests arrive as <channel source="kxm" message_id="..."> events, or in kxm_inbox; handle each under normal safety rules, then call kxm_reply with message_id and the final response.',
30
+ "For workflow requests, call kxm_workflow_get, record material knowledge with kxm_workflow_record in its ten categories (plan, decision, contradiction, error, lesson, observation, hypothesis, experiment, state-change, skill-candidate), pass the stageId each entry belongs to, and pass every checkpoint before replying.",
31
+ "For external work, call kxm_workflow_wait, then kxm_reply; a signed callback resumes the run.",
32
+ "In a KXM project, call kxm_context with your role and task before planning.",
33
+ "If a KXM tool reports a problem with the hub or token, continue without KXM and tell the user the next step it names.",
30
34
  ].join(" "),
31
35
  },
32
36
  );
@@ -41,6 +45,16 @@ function asRecord(value: unknown): Record<string, unknown> {
41
45
  : {};
42
46
  }
43
47
 
48
+ /** Where this session works and which hub it talks to, read from the launch environment. */
49
+ function sessionIdentity(): { projectDir: string; project: string; serverUrl: string } {
50
+ const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
51
+ return {
52
+ projectDir,
53
+ project: defaultProjectName(projectDir, process.env),
54
+ serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
55
+ };
56
+ }
57
+
44
58
  async function onHubEvent(event: HubEvent): Promise<void> {
45
59
  if (event.type === "cancelled" || event.type === "expired") {
46
60
  inbox.delete(event.message.id);
@@ -75,28 +89,48 @@ async function onHubEvent(event: HubEvent): Promise<void> {
75
89
  });
76
90
  }
77
91
 
92
+ async function startClient(project: string, serverUrl: string, name: string, authToken: string): Promise<HubClient> {
93
+ const candidate = new HubClient({
94
+ serverUrl,
95
+ name,
96
+ purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
97
+ project,
98
+ model: "claude-code",
99
+ authToken,
100
+ });
101
+ try {
102
+ await candidate.start(onHubEvent);
103
+ meshClient = candidate;
104
+ return candidate;
105
+ } catch (error) {
106
+ await candidate.stop();
107
+ throw error;
108
+ }
109
+ }
110
+
78
111
  async function ensureClient(): Promise<HubClient> {
79
112
  if (meshClient?.agent) return meshClient;
80
113
  if (starting) return starting;
81
114
  starting = (async () => {
82
- const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
83
- const project = defaultProjectName(projectDir, process.env);
84
- const authToken = resolveClientHubAuthToken(process.env, project);
85
- const candidate = new HubClient({
86
- serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
87
- name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
88
- purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
89
- project,
90
- model: "claude-code",
91
- ...(authToken ? { authToken } : {}),
92
- });
115
+ const { project, serverUrl } = sessionIdentity();
116
+ // An agent session registers with a project token only. The hub admits its admin token
117
+ // to any project missing from its token map, so no project token fails here, before the
118
+ // hub is contacted.
119
+ const authToken = resolveAgentHubAuthToken(process.env, project);
120
+ if (!authToken) {
121
+ throw new Error(
122
+ `KXM has no project token for project ${project} on this machine. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) or to add ${project} to the hub KXM_PROJECT_TOKENS, listing every existing project too because that variable replaces the saved map.`,
123
+ );
124
+ }
125
+ const name = process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`;
93
126
  try {
94
- await candidate.start(onHubEvent);
95
- meshClient = candidate;
96
- return candidate;
127
+ return await startClient(project, serverUrl, name, authToken);
97
128
  } catch (error) {
98
- await candidate.stop();
99
- throw error;
129
+ if (!(error instanceof HubHttpError && error.code === "duplicate_agent_name")) throw error;
130
+ // Another live session in this project holds the name; register beside it once.
131
+ const substitute = `${name}-${process.pid}`;
132
+ process.stderr.write(`kxm: agent name ${name} is already active in project ${project}; this session registers as ${substitute}\n`);
133
+ return await startClient(project, serverUrl, substitute, authToken);
100
134
  }
101
135
  })();
102
136
  try {
@@ -106,6 +140,40 @@ async function ensureClient(): Promise<HubClient> {
106
140
  }
107
141
  }
108
142
 
143
+ /** The reason the hub could not be reached at all, or undefined for any other failure. */
144
+ function unreachableCause(error: unknown): string | undefined {
145
+ if (!(error instanceof Error)) return undefined;
146
+ if (error.message.startsWith("request timed out after")) return error.message;
147
+ const cause = (error as { cause?: unknown }).cause;
148
+ const causeCode = cause && typeof cause === "object" ? (cause as { code?: unknown }).code : undefined;
149
+ if (error.message === "fetch failed") {
150
+ if (typeof causeCode === "string") return causeCode;
151
+ return cause instanceof Error && cause.message ? cause.message : error.message;
152
+ }
153
+ if ((error as NodeJS.ErrnoException).code === "ECONNREFUSED" || error.message.includes("ECONNREFUSED")) return "ECONNREFUSED";
154
+ return undefined;
155
+ }
156
+
157
+ async function connectedClient(): Promise<HubClient> {
158
+ try {
159
+ return await ensureClient();
160
+ } catch (error) {
161
+ const cause = unreachableCause(error);
162
+ if (!cause) throw error;
163
+ throw new Error(
164
+ `KXM hub unreachable at ${sessionIdentity().serverUrl} (${cause}). Ask the user to start the hub (\`kxm hub start\`) or to correct the kxm plugin server_url with ${CONFIGURE_PLUGIN}.`,
165
+ );
166
+ }
167
+ }
168
+
169
+ /** Tool errors are read by the model, so each names the next step and who takes it. */
170
+ function toolErrorText(error: unknown): string {
171
+ if (error instanceof HubHttpError && error.code === "invalid_auth") {
172
+ return `KXM hub rejected the project token for project ${sessionIdentity().project}. Ask the user to set the kxm plugin auth_token (${CONFIGURE_PLUGIN}) to that project's token from the hub KXM_PROJECT_TOKENS.`;
173
+ }
174
+ return error instanceof Error ? error.message : String(error);
175
+ }
176
+
109
177
  const tools = getMcpTools();
110
178
 
111
179
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -114,9 +182,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
114
182
  try {
115
183
  const policy = enforceToolPolicy(request.params.name);
116
184
  if (!policy.allowed) {
117
- throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
185
+ // The denial stays fail closed; the hint only tells the user how to lift it.
186
+ const hint = sessionTokenFixHint(policy);
187
+ throw new Error(hint ? `tool_policy_denied: ${policy.detail}. ${hint}` : `tool_policy_denied: ${policy.detail ?? policy.error}`);
118
188
  }
119
- const client = await ensureClient();
189
+ const client = await connectedClient();
120
190
  const cmd = AGENT_COMMANDS_MAP.get(request.params.name);
121
191
  if (!cmd) throw new Error(`unknown tool: ${request.params.name}`);
122
192
  const args = asRecord(request.params.arguments);
@@ -124,18 +194,47 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
124
194
  return textResult(result);
125
195
  } catch (error) {
126
196
  return {
127
- content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }],
197
+ content: [{ type: "text" as const, text: toolErrorText(error) }],
128
198
  isError: true,
129
199
  };
130
200
  }
131
201
  });
132
202
 
203
+ /** Register before the first tool call, so peers see this session and its requests arrive,
204
+ * only where a tool call would register anyway: a KXM project with a project token whose tool
205
+ * policy lets the session receive and answer peer requests. */
206
+ function registersAtStartup(): boolean {
207
+ const { projectDir, project } = sessionIdentity();
208
+ try {
209
+ if (!statSync(join(projectDir, ".kxm")).isDirectory()) return false;
210
+ if (!resolveAgentHubAuthToken(process.env, project)) return false;
211
+ } catch {
212
+ return false;
213
+ }
214
+ return enforceToolPolicy("kxm_inbox").allowed && enforceToolPolicy("kxm_reply").allowed;
215
+ }
216
+
217
+ // Wait for the client's initialized notification: a channel event sent before the handshake
218
+ // completes would be marked delivered while the client could still drop it.
219
+ mcp.oninitialized = () => {
220
+ if (registersAtStartup()) void ensureClient().catch(() => undefined);
221
+ };
222
+
133
223
  await mcp.connect(new StdioServerTransport());
134
224
 
135
- async function shutdown(): Promise<void> {
136
- await meshClient?.stop();
137
- await mcp.close();
225
+ let shuttingDown: Promise<void> | undefined;
226
+
227
+ function shutdown(): Promise<void> {
228
+ shuttingDown ??= (async () => {
229
+ const client = meshClient ?? await starting?.catch(() => undefined);
230
+ await client?.stop();
231
+ await mcp.close();
232
+ })();
233
+ return shuttingDown;
138
234
  }
139
235
 
140
236
  process.once("SIGINT", () => void shutdown());
141
237
  process.once("SIGTERM", () => void shutdown());
238
+ // A client that exits without signalling closes stdin. Leave the hub then, so this session is
239
+ // not listed as online and does not hold its agent name against the next session.
240
+ process.stdin.once("end", () => void shutdown());
@@ -813,6 +813,29 @@ function validateAgentScope(
813
813
  }
814
814
  }
815
815
 
816
+ /** The outcomes a gate step settles on, by `expect` (computeGateEvidenceOutcome
817
+ * in runtime-store.ts): nothing else ever reaches a gate step's `on` map. */
818
+ const GATE_STEP_OUTCOMES = {
819
+ pass: ["passed", "implementation-failure"],
820
+ fail: ["passed", "repro-missing"],
821
+ } as const satisfies Record<"pass" | "fail", readonly string[]>;
822
+
823
+ /** A gate step that routes on an outcome it never produces (such as `failed`)
824
+ * instead of one it does can never settle: the Runtime records the produced
825
+ * outcome, finds no transition for it, and leaves the attempt unsettled. An
826
+ * extra dead key next to every produced outcome still settles, and the built-in
827
+ * template has always declared `failed` beside `implementation-failure`, so only
828
+ * the combination is refused. Misspellings are gate_outcome_renamed's. */
829
+ function gateOutcomeImpossible(step: JsonObject, stepId: string, file: string): KxmConfigIssue | undefined {
830
+ const expect = step.expect === "fail" ? "fail" : "pass";
831
+ const produced: readonly string[] = GATE_STEP_OUTCOMES[expect];
832
+ const declared = Object.keys(objectValue(step.on) ?? {});
833
+ const impossible = declared.filter((outcome) => !produced.includes(outcome) && outcome !== "implementation_failure" && outcome !== "repro_missing");
834
+ const missing = produced.filter((outcome) => !declared.includes(outcome));
835
+ if (impossible.length === 0 || missing.length === 0) return undefined;
836
+ return issue("semantic", "gate_outcome_impossible", file, `${stepId} declares ${impossible.join(", ")}, which a gate step with expect ${expect} never produces; it settles on ${produced.join(" or ")}, so declare ${missing.join(" and ")}`);
837
+ }
838
+
816
839
  function transition(value: JsonValue): { target?: string; maxTransitions?: number; terminalStatus?: string } {
817
840
  if (typeof value === "string") return { target: value };
818
841
  const object = objectValue(value);
@@ -887,6 +910,8 @@ function validateWorkflow(
887
910
  if (kind === "gate" && Object.keys(objectValue(step.on) ?? {}).some((outcome) => outcome === "implementation_failure" || outcome === "repro_missing")) {
888
911
  issues.push(issue("semantic", "gate_outcome_renamed", file, `${stepId} must use implementation-failure and repro-missing`));
889
912
  }
913
+ const impossibleOutcome = kind === "gate" ? gateOutcomeImpossible(step, stepId, file) : undefined;
914
+ if (impossibleOutcome) issues.push(impossibleOutcome);
890
915
  for (const repositoryId of Object.keys(objectValue(step.repositories) ?? {})) {
891
916
  if (!repositories.has(repositoryId)) issues.push(issue("reference", "repository_unknown", file, `${stepId} references unknown repository ${repositoryId}`));
892
917
  }
@@ -149,6 +149,17 @@ export type JournalCategory =
149
149
  | "state-change"
150
150
  | "skill-candidate";
151
151
  export type ImprovementArea = "harness" | "gates" | "implementation" | "workflow" | "documentation" | "security" | "other";
152
+ /** Every improvement area, in report order. The order also breaks ties when
153
+ * a signal's modal area is ambiguous. */
154
+ export const IMPROVEMENT_AREAS: readonly ImprovementArea[] = [
155
+ "harness",
156
+ "gates",
157
+ "implementation",
158
+ "workflow",
159
+ "documentation",
160
+ "security",
161
+ "other",
162
+ ];
152
163
 
153
164
  /** Evidence submitted for one checkpoint or external signal, keyed by a
154
165
  * requirement from WorkflowStageDefinition.requiredEvidence. */
@@ -0,0 +1,138 @@
1
+ import type { ContextItem } from "./context.ts";
2
+
3
+ /**
4
+ * Deterministic lexical relevance (BM25) for context ranking and recall.
5
+ *
6
+ * No model, no clock, no randomness: the same query and documents always
7
+ * produce bit-identical scores, so arbiter packets and recall results stay
8
+ * reproducible and testable.
9
+ */
10
+
11
+ /** Words that carry no task signal. Dropped before scoring. */
12
+ export const RELEVANCE_STOPWORDS: ReadonlySet<string> = Object.freeze(new Set([
13
+ "a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "could",
14
+ "did", "do", "does", "for", "from", "had", "has", "have", "how", "if", "in",
15
+ "into", "is", "it", "its", "of", "on", "or", "our", "should", "so", "than",
16
+ "that", "the", "their", "them", "then", "there", "these", "they", "this",
17
+ "those", "to", "was", "we", "were", "what", "when", "where", "which", "while",
18
+ "who", "why", "will", "with", "would", "you", "your",
19
+ ]));
20
+
21
+ /** BM25 term-frequency saturation. */
22
+ export const RELEVANCE_K1 = 1.2;
23
+ /** BM25 document-length normalization. */
24
+ export const RELEVANCE_B = 0.75;
25
+
26
+ const MIN_TOKEN_CHARS = 2;
27
+ const MAX_TOKEN_CHARS = 64;
28
+
29
+ function foldPlural(token: string): string {
30
+ if (/^\p{N}+$/u.test(token)) return token;
31
+ if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
32
+ if (token.length > 4 && token.endsWith("sses")) return token.slice(0, -2);
33
+ if (
34
+ token.length > 3
35
+ && token.endsWith("s")
36
+ && !token.endsWith("ss")
37
+ && !token.endsWith("us")
38
+ && !token.endsWith("is")
39
+ ) {
40
+ return token.slice(0, -1);
41
+ }
42
+ return token;
43
+ }
44
+
45
+ /** NFKC-normalized, lowercased, stopword-free, plural-folded tokens in text
46
+ * order, duplicates kept. */
47
+ export function relevanceTokens(text: string): string[] {
48
+ const tokens: string[] = [];
49
+ for (const raw of text.normalize("NFKC").toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
50
+ if (raw.length < MIN_TOKEN_CHARS || raw.length > MAX_TOKEN_CHARS) continue;
51
+ if (RELEVANCE_STOPWORDS.has(raw)) continue;
52
+ tokens.push(foldPlural(raw));
53
+ }
54
+ return tokens;
55
+ }
56
+
57
+ /** Index-aligned BM25 scores of each document against the query. Terms are
58
+ * summed in first-occurrence query order so the float result is bit-stable. */
59
+ export function scoreRelevance(query: string, documents: readonly string[]): number[] {
60
+ const scores = documents.map(() => 0);
61
+ const terms = [...new Set(relevanceTokens(query))];
62
+ if (terms.length === 0 || documents.length === 0) return scores;
63
+
64
+ const indexed = documents.map((document) => {
65
+ const tokens = relevanceTokens(document);
66
+ const frequencies = new Map<string, number>();
67
+ for (const token of tokens) frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
68
+ return { length: tokens.length, frequencies };
69
+ });
70
+ const count = indexed.length;
71
+ let totalLength = 0;
72
+ for (const document of indexed) totalLength += document.length;
73
+ const averageLength = totalLength > 0 ? totalLength / count : 1;
74
+
75
+ const inverseFrequency = new Map<string, number>();
76
+ for (const term of terms) {
77
+ let documentFrequency = 0;
78
+ for (const document of indexed) {
79
+ if (document.frequencies.has(term)) documentFrequency += 1;
80
+ }
81
+ inverseFrequency.set(term, Math.log(1 + (count - documentFrequency + 0.5) / (documentFrequency + 0.5)));
82
+ }
83
+
84
+ indexed.forEach((document, index) => {
85
+ let score = 0;
86
+ for (const term of terms) {
87
+ const frequency = document.frequencies.get(term) ?? 0;
88
+ if (frequency === 0) continue;
89
+ const lengthNorm = 1 - RELEVANCE_B + RELEVANCE_B * (document.length / averageLength);
90
+ score += inverseFrequency.get(term)! * ((frequency * (RELEVANCE_K1 + 1)) / (frequency + RELEVANCE_K1 * lengthNorm));
91
+ }
92
+ scores[index] = score;
93
+ });
94
+ return scores;
95
+ }
96
+
97
+ /** Relevance rounded to three decimals for audits and responses. */
98
+ export function roundRelevance(score: number): number {
99
+ return Math.round(score * 1000) / 1000;
100
+ }
101
+
102
+ /** The text a context item is ranked on: its summary plus its state key.
103
+ * Kind, id and sourceRef are excluded. */
104
+ export function contextItemRelevanceText(item: ContextItem): string {
105
+ return item.stateKey !== undefined ? `${item.summary} ${item.stateKey}` : item.summary;
106
+ }
107
+
108
+ /** Locale-independent code-unit order for identifiers. */
109
+ export function compareCodeUnitIds(left: string, right: string): number {
110
+ return left < right ? -1 : left > right ? 1 : 0;
111
+ }
112
+
113
+ export interface RankedRecallItem {
114
+ item: ContextItem;
115
+ relevance: number;
116
+ }
117
+
118
+ /** Rank recall candidates: exact-phrase hits first, then BM25 any-token hits,
119
+ * then id. An empty query is a phrase hit for every item (id order). Items
120
+ * that neither contain the phrase nor share a token are dropped. */
121
+ export function rankRecall(query: string, items: readonly ContextItem[], limit: number): RankedRecallItem[] {
122
+ const needle = query.toLowerCase();
123
+ const scores = scoreRelevance(query, items.map(contextItemRelevanceText));
124
+ const ranked: { item: ContextItem; score: number; phraseHit: boolean }[] = [];
125
+ items.forEach((item, index) => {
126
+ const score = scores[index] ?? 0;
127
+ const phraseHit = needle === ""
128
+ || item.summary.toLowerCase().includes(needle)
129
+ || (item.stateKey ?? "").toLowerCase().includes(needle);
130
+ if (phraseHit || score > 0) ranked.push({ item, score, phraseHit });
131
+ });
132
+ ranked.sort((left, right) =>
133
+ (right.phraseHit ? 1 : 0) - (left.phraseHit ? 1 : 0)
134
+ || right.score - left.score
135
+ || compareCodeUnitIds(left.item.id, right.item.id),
136
+ );
137
+ return ranked.slice(0, limit).map(({ item, score }) => ({ item, relevance: roundRelevance(score) }));
138
+ }