@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
|
@@ -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
|
-
|
|
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?:
|
|
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(
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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(
|
|
293
|
-
const pRows =
|
|
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
|
-
|
|
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(
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
'
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"If
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
95
|
-
meshClient = candidate;
|
|
96
|
-
return candidate;
|
|
127
|
+
return await startClient(project, serverUrl, name, authToken);
|
|
97
128
|
} catch (error) {
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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());
|
|
@@ -323,26 +323,30 @@ export function formatHarnessMemoryBlock(records: MemoryRecord[]): string {
|
|
|
323
323
|
* carry KXM's words instead of the project's. */
|
|
324
324
|
const HARNESS_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"] as const;
|
|
325
325
|
|
|
326
|
-
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
326
|
+
/** What is wrong with the memory markers in `text`, or undefined when it has exactly one
|
|
327
|
+
* start marker followed by exactly one end marker, or neither. Any other shape has no single
|
|
328
|
+
* block to replace: an orphan start would make the next sync delete the project's text up to
|
|
329
|
+
* a later end, an end before its start would duplicate text, and a second block goes stale. */
|
|
330
|
+
function memoryMarkerProblem(text: string): string | undefined {
|
|
331
|
+
const starts = text.split(MEMORY_MARKER_START).length - 1;
|
|
332
|
+
const ends = text.split(MEMORY_MARKER_END).length - 1;
|
|
333
|
+
if (starts > 1 || ends > 1) return `has ${starts} ${MEMORY_MARKER_START} and ${ends} ${MEMORY_MARKER_END} markers, not one block`;
|
|
334
|
+
if (starts > ends) return `has ${MEMORY_MARKER_START} with no ${MEMORY_MARKER_END}`;
|
|
335
|
+
if (ends > starts) return `has ${MEMORY_MARKER_END} with no ${MEMORY_MARKER_START}`;
|
|
336
|
+
if (text.indexOf(MEMORY_MARKER_END) < text.indexOf(MEMORY_MARKER_START)) return `has ${MEMORY_MARKER_END} before ${MEMORY_MARKER_START}`;
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
340
339
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
340
|
+
/** `original` with its memory block replaced, or with one appended when it has no markers.
|
|
341
|
+
* Nothing outside the markers is rewritten. Markers must already pass `memoryMarkerProblem`. */
|
|
342
|
+
function withMemoryBlock(original: string, block: string): string {
|
|
343
|
+
const startIdx = original.indexOf(MEMORY_MARKER_START);
|
|
344
|
+
if (startIdx === -1) {
|
|
345
|
+
const head = original.trimEnd();
|
|
346
|
+
return head ? `${head}\n\n${block}\n` : `${block}\n`;
|
|
344
347
|
}
|
|
345
|
-
|
|
348
|
+
const endIdx = original.indexOf(MEMORY_MARKER_END) + MEMORY_MARKER_END.length;
|
|
349
|
+
return original.slice(0, startIdx) + block + original.slice(endIdx);
|
|
346
350
|
}
|
|
347
351
|
|
|
348
352
|
export function syncHarnessMemory(
|
|
@@ -358,11 +362,30 @@ export function syncHarnessMemory(
|
|
|
358
362
|
);
|
|
359
363
|
}
|
|
360
364
|
|
|
365
|
+
// Check every file before writing any: one malformed file leaves all of them untouched.
|
|
366
|
+
const documents = present.map((name) => {
|
|
367
|
+
const original = readFileSync(join(root, name), "utf8");
|
|
368
|
+
return { name, original, problem: memoryMarkerProblem(original) };
|
|
369
|
+
});
|
|
370
|
+
const malformed = documents.filter((doc) => doc.problem !== undefined);
|
|
371
|
+
if (malformed.length > 0) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`${malformed.map((doc) => `${doc.name} ${doc.problem}`).join("; ")}; wrote no file. `
|
|
374
|
+
+ `Keep exactly one ${MEMORY_MARKER_START} followed by one ${MEMORY_MARKER_END} in each file, or delete both so sync appends a fresh block`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
361
378
|
const block = formatHarnessMemoryBlock(loadAuthoredMemory(root));
|
|
362
379
|
const updated: string[] = [];
|
|
363
380
|
const unchanged: string[] = [];
|
|
364
|
-
for (const name of
|
|
365
|
-
(
|
|
381
|
+
for (const { name, original } of documents) {
|
|
382
|
+
const next = withMemoryBlock(original, block);
|
|
383
|
+
if (next === original) {
|
|
384
|
+
unchanged.push(name);
|
|
385
|
+
} else {
|
|
386
|
+
if (!options.dryRun) writeFileSync(join(root, name), next, "utf8");
|
|
387
|
+
updated.push(name);
|
|
388
|
+
}
|
|
366
389
|
}
|
|
367
390
|
return { updated, unchanged, missing };
|
|
368
391
|
}
|
|
@@ -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. */
|