@kontextmind/kxm 0.7.96 → 0.7.98
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 +2 -0
- package/CHANGELOG.md +61 -2
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/concepts/trust-model.md +3 -2
- package/docs/contracts/routing.md +1 -1
- package/docs/contributing/test-matrix.md +6 -5
- package/docs/glossary.md +1 -1
- package/docs/guides/peer-messaging.md +2 -2
- package/docs/guides/pi-workers.md +1 -1
- package/docs/guides/webhook-workflows.md +53 -18
- package/docs/operations/backup-and-restore.md +43 -24
- package/docs/operations/deploy.md +2 -2
- package/docs/operations/troubleshooting.md +3 -2
- package/docs/reference/cli-reference.md +65 -30
- package/docs/reference/config-reference.md +24 -13
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/harness-routing.md +3 -3
- package/docs/reference/http-api.md +7 -7
- package/docs/reference/tools.md +1 -1
- package/docs/reference/workflow-definitions.md +2 -2
- package/docs/start/first-workflow.md +4 -4
- package/docs/start/quickstart-claude-code.md +2 -2
- package/docs/start/quickstart-pi.md +1 -1
- package/examples/workflow-signal.ts +4 -5
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +11 -1
- package/plugins/kxm/dist/cli.js +552 -228
- package/plugins/kxm/dist/client.js +3 -1
- package/plugins/kxm/dist/core.js +16 -3
- package/plugins/kxm/dist/extension.js +45 -13
- package/plugins/kxm/dist/mcp-server.js +20 -4
- package/plugins/kxm/dist/runtime-supervisor.js +232 -51
- package/plugins/kxm/dist/runtime.js +432 -95
- package/plugins/kxm/dist/server.js +122 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
- package/plugins/kxm/src/cli/project.ts +22 -13
- package/plugins/kxm/src/cli/system.ts +22 -1
- package/plugins/kxm/src/cli/workflows.ts +12 -7
- package/plugins/kxm/src/cli.ts +30 -8
- package/plugins/kxm/src/client.ts +4 -0
- package/plugins/kxm/src/commands.ts +23 -1
- package/plugins/kxm/src/database.ts +210 -36
- package/plugins/kxm/src/engine.ts +117 -2
- package/plugins/kxm/src/extension.ts +20 -14
- package/plugins/kxm/src/github-watch.ts +8 -5
- package/plugins/kxm/src/harness.ts +29 -0
- package/plugins/kxm/src/hub-env.ts +19 -1
- package/plugins/kxm/src/hub.ts +105 -21
- package/plugins/kxm/src/improve-sources.ts +2 -7
- package/plugins/kxm/src/init-guide-setup.ts +43 -28
- package/plugins/kxm/src/mcp-server.ts +9 -2
- package/plugins/kxm/src/oneshot-producer.ts +16 -8
- package/plugins/kxm/src/prices.ts +33 -2
- package/plugins/kxm/src/routing.ts +13 -7
- package/plugins/kxm/src/runtime-store.ts +23 -0
- package/plugins/kxm/src/studio-layout.ts +5 -4
- package/plugins/kxm/src/template.ts +31 -0
- package/plugins/kxm/src/workflow.ts +70 -1
- package/plugins/kxm/src/worktree-witness.ts +71 -0
- package/schemas/backup-manifest.schema.json +33 -0
- package/scripts/smoke-multi-pi.mjs +5 -1
|
@@ -10,15 +10,21 @@
|
|
|
10
10
|
* This module writes only current KXM project resources:
|
|
11
11
|
* - `.kxm/agents/<role-slug>.yaml` (kxm.agent.v1)
|
|
12
12
|
* - `.kxm/workflows/<slug>.yaml` (kxm.workflow.v1)
|
|
13
|
+
* - admitted selectors appended to `.kxm/routes.yaml`
|
|
13
14
|
* It never writes retired legacy authority (`.kxm/config`, retired
|
|
14
15
|
* `.kxm/roster.json`) or the trusted `.kxm/roster.yaml` policy
|
|
15
16
|
* and does not use the kxm.role.v1 subsystem.
|
|
17
|
+
*
|
|
18
|
+
* Guide research ids are not dispatch ids. Only the admitted map below is
|
|
19
|
+
* written, and only when that harness is authenticated. Google goes through
|
|
20
|
+
* the Pi `antigravity` provider. Unmapped ids are skipped.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
19
24
|
import { dirname, join } from "node:path";
|
|
20
25
|
import { stringify } from "yaml";
|
|
21
|
-
import {
|
|
26
|
+
import { type HarnessInventory } from "./harness.ts";
|
|
27
|
+
import { loadRoutePolicy } from "./routes.ts";
|
|
22
28
|
|
|
23
29
|
export interface GuideCandidate {
|
|
24
30
|
readonly vendor: string;
|
|
@@ -41,19 +47,17 @@ export interface GuideWorkflow {
|
|
|
41
47
|
readonly stages: readonly GuideStage[];
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
google: "
|
|
54
|
-
|
|
55
|
-
moonshot: "kimi",
|
|
56
|
-
deepseek: "deepseek",
|
|
50
|
+
/** Research id → the admitted harness/provider/model drive will actually accept.
|
|
51
|
+
* Anything absent is skipped, even when its harness is logged in. */
|
|
52
|
+
const ADMITTED_GUIDE_BINDINGS: Readonly<Record<string, AgentBinding>> = Object.freeze({
|
|
53
|
+
"anthropic/claude-fable-5.1": { harness: "claude", provider: "anthropic", model: "fable" },
|
|
54
|
+
"anthropic/fable": { harness: "claude", provider: "anthropic", model: "fable" },
|
|
55
|
+
"openai/gpt-5.6-sol": { harness: "codex", provider: "openai", model: "gpt-5.6-sol" },
|
|
56
|
+
"x-ai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
|
|
57
|
+
"xai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
|
|
58
|
+
"google/gemini-3.8-flash": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
|
|
59
|
+
"google/gemini-3.8-flash-high": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
|
|
60
|
+
"qwen/qwen3-coder-plus": { harness: "pi", provider: "openrouter", model: "qwen/qwen3-coder-plus" },
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
/** Guide candidates are ordered by preference; the first eligible wins. */
|
|
@@ -380,25 +384,17 @@ function authenticatedHarnesses(inventory: HarnessInventory): ReadonlySet<string
|
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
/**
|
|
383
|
-
* Resolve a guide candidate to
|
|
384
|
-
* candidate
|
|
385
|
-
* Pi OpenRouter provider (guide ids are already OpenRouter-style slugs).
|
|
387
|
+
* Resolve a guide candidate to an admitted dispatch binding, or undefined when
|
|
388
|
+
* no candidate is both on the admitted map and authenticated on its harness.
|
|
386
389
|
*/
|
|
387
390
|
export function resolveCandidate(
|
|
388
391
|
candidates: readonly GuideCandidate[],
|
|
389
392
|
eligible: ReadonlySet<string>,
|
|
390
393
|
): AgentBinding | undefined {
|
|
391
394
|
for (const candidate of candidates) {
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
return { harness: nativeHarness, provider: NATIVE_HARNESS_PROVIDERS[nativeHarness]!, model: candidate.model };
|
|
396
|
-
}
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
if (eligible.has("pi")) {
|
|
400
|
-
return { harness: "pi", provider: "openrouter", model: `${candidate.vendor}/${candidate.model}` };
|
|
401
|
-
}
|
|
395
|
+
const mapped = ADMITTED_GUIDE_BINDINGS[`${candidate.vendor}/${candidate.model}`];
|
|
396
|
+
if (!mapped || !eligible.has(mapped.harness)) continue;
|
|
397
|
+
return mapped;
|
|
402
398
|
}
|
|
403
399
|
return undefined;
|
|
404
400
|
}
|
|
@@ -424,7 +420,7 @@ export function planGuideSetup(options: {
|
|
|
424
420
|
workflow: workflow.slug,
|
|
425
421
|
stage: stage.slug,
|
|
426
422
|
role: stage.role,
|
|
427
|
-
reason: "no guide candidate has an authenticated harness (see `kxm harness list`)",
|
|
423
|
+
reason: "no admitted guide candidate has an authenticated harness (see `kxm harness list`)",
|
|
428
424
|
});
|
|
429
425
|
covered = false;
|
|
430
426
|
continue;
|
|
@@ -465,6 +461,7 @@ function workflowDocument(workflow: GuideWorkflow): Record<string, unknown> {
|
|
|
465
461
|
id: stage.slug,
|
|
466
462
|
kind: "agent",
|
|
467
463
|
agent: stage.role,
|
|
464
|
+
repositories: { control: isWriterRole(stage.role) ? "write" : "read" },
|
|
468
465
|
maxAttempts: 2,
|
|
469
466
|
on: {
|
|
470
467
|
passed: last ? { target: "$terminal", terminalStatus: "completed" } : workflow.stages[index + 1]!.slug,
|
|
@@ -508,6 +505,24 @@ export function renderGuideSetupFiles(projectRoot: string, plan: GuideSetupPlan)
|
|
|
508
505
|
return files;
|
|
509
506
|
}
|
|
510
507
|
|
|
508
|
+
/** Append the plan's admitted selectors to `.kxm/routes.yaml`. Does not write role files. */
|
|
509
|
+
export function mergeGuideRouteAdmission(projectRoot: string, plan: GuideSetupPlan): string[] {
|
|
510
|
+
const policy = loadRoutePolicy(projectRoot);
|
|
511
|
+
const added: string[] = [];
|
|
512
|
+
for (const binding of plan.agents.values()) {
|
|
513
|
+
const selector = `${binding.provider}/${binding.model}`;
|
|
514
|
+
if (policy.disabled.includes(selector) || policy.admitted.includes(selector)) continue;
|
|
515
|
+
policy.admitted.push(selector);
|
|
516
|
+
added.push(selector);
|
|
517
|
+
}
|
|
518
|
+
if (added.length === 0) return added;
|
|
519
|
+
policy.updatedAt = new Date().toISOString();
|
|
520
|
+
const path = join(projectRoot, ".kxm", "routes.yaml");
|
|
521
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
522
|
+
writeFileSync(path, stringify(policy), "utf8");
|
|
523
|
+
return added;
|
|
524
|
+
}
|
|
525
|
+
|
|
511
526
|
export interface WriteReport {
|
|
512
527
|
readonly written: string[];
|
|
513
528
|
readonly existed: string[];
|
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.98";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -190,7 +190,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
190
190
|
const cmd = AGENT_COMMANDS_MAP.get(request.params.name);
|
|
191
191
|
if (!cmd) throw new Error(`unknown tool: ${request.params.name}`);
|
|
192
192
|
const args = asRecord(request.params.arguments);
|
|
193
|
-
|
|
193
|
+
// Every open inbound request is work this session is handling; a request it sends
|
|
194
|
+
// meanwhile continues their hop chain.
|
|
195
|
+
const result = await cmd.execute(client, args, {
|
|
196
|
+
signal: extra.signal,
|
|
197
|
+
inbox,
|
|
198
|
+
notifiedInbox,
|
|
199
|
+
handling: [...inbox.values()],
|
|
200
|
+
});
|
|
194
201
|
return textResult(result);
|
|
195
202
|
} catch (error) {
|
|
196
203
|
return {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
BUILTIN_HARNESSES,
|
|
8
8
|
NATIVE_HARNESS_PROVIDERS,
|
|
9
9
|
probeHarnessAssignmentAsync,
|
|
10
|
-
|
|
10
|
+
oneShotPermissionArgs,
|
|
11
11
|
type HarnessAssignmentProbeOptions,
|
|
12
12
|
type HarnessStatus,
|
|
13
13
|
type HarnessCatalogEntry,
|
|
@@ -128,11 +128,11 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
131
|
+
if (options.defaultModel) {
|
|
132
|
+
const parsed = parseModelString(options.defaultModel, harness);
|
|
133
|
+
return { provider: parsed.provider, model: parsed.model, thinking: request.thinking };
|
|
134
|
+
}
|
|
135
|
+
throw new Error("producer_route_not_admitted");
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
async function checkAuth(harness: string, provider: string, model: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<HarnessStatus> {
|
|
@@ -167,8 +167,13 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
|
|
|
167
167
|
if (request.signal.aborted) return cancelled();
|
|
168
168
|
const catalogEntry = (options.catalog ?? BUILTIN_HARNESSES).find((h) => h.id === harness);
|
|
169
169
|
if (!catalogEntry?.oneShot) throw new Error(`oneshot_harness_unsupported: ${harness}`);
|
|
170
|
-
const
|
|
171
|
-
|
|
170
|
+
const permission = request.permission === "edit" || request.contextPacket?.task.permissionCeiling === "edit"
|
|
171
|
+
? "edit"
|
|
172
|
+
: "read-only";
|
|
173
|
+
const permissionArgs = oneShotPermissionArgs(harness, permission);
|
|
174
|
+
if (!permissionArgs) {
|
|
175
|
+
throw new Error(`oneshot_harness_unsupported: ${harness} ${permission === "edit" ? "writer_profile_unaudited" : "permission_profile_unaudited"}`);
|
|
176
|
+
}
|
|
172
177
|
// Pin the environment for auth and execution; don't observe subscription
|
|
173
178
|
// auth under one environment and then spawn under changed API-key settings.
|
|
174
179
|
const env = { ...(options.env ?? process.env) };
|
|
@@ -287,6 +292,9 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
|
|
|
287
292
|
const providerMetadata: Record<string, string | number | boolean> = {
|
|
288
293
|
processStatus: aborted ? "aborted" : transportFailed ? "failed" : "completed",
|
|
289
294
|
executionEvidenceId: evidence.id,
|
|
295
|
+
permission,
|
|
296
|
+
permissionProfile: permission === "edit" ? "writer" : "read-only",
|
|
297
|
+
authored: false,
|
|
290
298
|
};
|
|
291
299
|
if (procResult.code !== null && Number.isFinite(procResult.code)) providerMetadata.processExitCode = procResult.code;
|
|
292
300
|
if (procResult.signal) providerMetadata.processSignal = procResult.signal;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { parse } from "yaml";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
5
|
|
|
6
6
|
export * from "./price-calc.ts";
|
|
7
7
|
import type { PriceCatalog, ModelPriceRow, PriceTier } from "./price-calc.ts";
|
|
@@ -167,3 +167,34 @@ export function loadPriceCatalogForEstimate(options?: {
|
|
|
167
167
|
return { catalog, unavailable: false, stale: false };
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Stamp the existing project list-price file as today's estimate.
|
|
172
|
+
* This does not fetch vendor rates. Until a catalog's date is today,
|
|
173
|
+
* `loadPriceCatalogForEstimate` keeps the estimate unknown.
|
|
174
|
+
*/
|
|
175
|
+
export function acknowledgePriceCatalog(projectRoot: string, now = new Date()): PriceCatalog {
|
|
176
|
+
const path = join(projectRoot, ".kxm", "prices.yaml");
|
|
177
|
+
const loaded = loadPriceCatalog(projectRoot);
|
|
178
|
+
if (!loaded) throw new Error("price catalog missing");
|
|
179
|
+
const date = now.toISOString().slice(0, 10);
|
|
180
|
+
const body = {
|
|
181
|
+
schema: loaded.schema,
|
|
182
|
+
date,
|
|
183
|
+
currency: loaded.currency ?? "USD",
|
|
184
|
+
models: loaded.models,
|
|
185
|
+
};
|
|
186
|
+
const stamped: PriceCatalog = { ...body, sha256: hashPriceCatalog(body) };
|
|
187
|
+
writeFileSync(path, stringify({
|
|
188
|
+
schema: stamped.schema,
|
|
189
|
+
date: stamped.date,
|
|
190
|
+
sha256: stamped.sha256,
|
|
191
|
+
currency: stamped.currency ?? "USD",
|
|
192
|
+
models: stamped.models,
|
|
193
|
+
}), "utf8");
|
|
194
|
+
const again = loadPriceCatalog(projectRoot);
|
|
195
|
+
if (!again || again.date !== date || again.sha256 !== stamped.sha256) {
|
|
196
|
+
throw new Error("price catalog acknowledge failed verification");
|
|
197
|
+
}
|
|
198
|
+
return again;
|
|
199
|
+
}
|
|
200
|
+
|
|
@@ -422,7 +422,8 @@ export interface RoutingComparison {
|
|
|
422
422
|
blocked: number;
|
|
423
423
|
failed: number;
|
|
424
424
|
reworkRate: number;
|
|
425
|
-
totalCostUsd: number;
|
|
425
|
+
totalCostUsd: number | null;
|
|
426
|
+
missingCostRuns: number;
|
|
426
427
|
totalTokensIn: number;
|
|
427
428
|
totalTokensOut: number;
|
|
428
429
|
totalHumanInterventions: number;
|
|
@@ -445,6 +446,8 @@ export function compareRoutingRecords(records: RoutingRecord[]): RoutingComparis
|
|
|
445
446
|
const blocked = settled.filter((record) => record.finalOutcome === "blocked").length;
|
|
446
447
|
const failed = settled.filter((record) => record.finalOutcome === "failed").length;
|
|
447
448
|
const reworked = records.filter((record) => record.retries > 0 || record.transitions > 0).length;
|
|
449
|
+
const missingCostRuns = records.filter((record) => typeof record.costUsd !== "number" || !Number.isFinite(record.costUsd)).length;
|
|
450
|
+
const summedCost = records.reduce((sum, record) => sum + (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) ? record.costUsd : 0), 0);
|
|
448
451
|
return {
|
|
449
452
|
behavioralSha256,
|
|
450
453
|
runs: records.length,
|
|
@@ -452,7 +455,8 @@ export function compareRoutingRecords(records: RoutingRecord[]): RoutingComparis
|
|
|
452
455
|
blocked,
|
|
453
456
|
failed,
|
|
454
457
|
reworkRate: records.length === 0 ? 0 : Math.round((reworked / records.length) * 100) / 100,
|
|
455
|
-
totalCostUsd:
|
|
458
|
+
totalCostUsd: missingCostRuns > 0 ? null : Math.round(summedCost * 10_000) / 10_000,
|
|
459
|
+
missingCostRuns,
|
|
456
460
|
totalTokensIn: records.reduce((sum, record) => sum + (record.tokensIn ?? 0), 0),
|
|
457
461
|
totalTokensOut: records.reduce((sum, record) => sum + (record.tokensOut ?? 0), 0),
|
|
458
462
|
totalHumanInterventions: records.reduce((sum, record) => sum + record.humanInterventions, 0),
|
|
@@ -748,9 +752,11 @@ export function generateRoutingReport(
|
|
|
748
752
|
|
|
749
753
|
const meteredCostUsd = Math.round(meteredCostTotal * 10_000) / 10_000;
|
|
750
754
|
const costPerAcceptedUsd = acceptedCount > 0
|
|
751
|
-
? (
|
|
752
|
-
?
|
|
753
|
-
: (
|
|
755
|
+
? (unknownCostAttempts > 0
|
|
756
|
+
? null
|
|
757
|
+
: (meteredCostUsd > 0 || unmeteredAttempts > 0
|
|
758
|
+
? Math.round((meteredCostUsd / acceptedCount) * 10_000) / 10_000
|
|
759
|
+
: 0))
|
|
754
760
|
: null;
|
|
755
761
|
|
|
756
762
|
const flagged = unknownCostAttempts > 0;
|
|
@@ -817,8 +823,8 @@ export function generateRoutingReport(
|
|
|
817
823
|
return a.reworkRate - b.reworkRate;
|
|
818
824
|
}
|
|
819
825
|
|
|
820
|
-
// 2. Cost: unknown is never ranked cheapest. Any unknown-cost attempt
|
|
821
|
-
// route's cost
|
|
826
|
+
// 2. Cost: unknown is never ranked cheapest. Any unknown-cost attempt leaves the
|
|
827
|
+
// route's cost unknown (unmetered or metered attempts beside it do not price it).
|
|
822
828
|
const aCostUnknown = a.unknownCostAttempts > 0;
|
|
823
829
|
const bCostUnknown = b.unknownCostAttempts > 0;
|
|
824
830
|
if (aCostUnknown && !bCostUnknown) return 1;
|
|
@@ -51,6 +51,29 @@ export function projectRuntimeKey(projectRoot: string): string {
|
|
|
51
51
|
return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
55
|
+
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
56
|
+
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
|
|
61
|
+
* runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
|
|
62
|
+
* store instead of guessing from the id. Read-only; a project whose Runtime never ran
|
|
63
|
+
* owns no runs.
|
|
64
|
+
*/
|
|
65
|
+
export function projectRuntimeOwnsRun(projectRoot: string, runId: string, env: NodeJS.ProcessEnv): boolean {
|
|
66
|
+
const path = kxmProjectRunEventsPath(projectRoot, env);
|
|
67
|
+
if (!existsSync(path)) return false;
|
|
68
|
+
const database = new DatabaseSync(path, { readOnly: true });
|
|
69
|
+
try {
|
|
70
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
71
|
+
return database.prepare("SELECT 1 FROM runs WHERE run_id = ?").get(runId) !== undefined;
|
|
72
|
+
} finally {
|
|
73
|
+
database.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
function checkedParent(path: string, description: string): void {
|
|
55
78
|
const parent = dirname(path);
|
|
56
79
|
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
@@ -420,13 +420,14 @@ export function createStudioServer(options: StudioServerOptions = {}): StudioSer
|
|
|
420
420
|
return;
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
res.writeHead(
|
|
423
|
+
res.writeHead(501, { "Content-Type": "application/json" });
|
|
424
424
|
res.end(JSON.stringify({
|
|
425
|
-
ok:
|
|
425
|
+
ok: false,
|
|
426
|
+
executed: false,
|
|
426
427
|
mutationId,
|
|
427
428
|
command,
|
|
428
|
-
mappedToCli:
|
|
429
|
-
|
|
429
|
+
mappedToCli: false,
|
|
430
|
+
error: "mutation_handler_missing",
|
|
430
431
|
}));
|
|
431
432
|
return;
|
|
432
433
|
}
|
|
@@ -174,6 +174,37 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
|
|
|
174
174
|
}],
|
|
175
175
|
]);
|
|
176
176
|
if (variant === "v4-registry") {
|
|
177
|
+
const coordinator = files.get(".kxm/agents/coordinator.yaml");
|
|
178
|
+
if (coordinator) {
|
|
179
|
+
files.set(".kxm/agents/coordinator.yaml", {
|
|
180
|
+
...coordinator,
|
|
181
|
+
harness: "claude",
|
|
182
|
+
model: { provider: "anthropic", model: "fable" },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const implementer = files.get(".kxm/agents/implementer.yaml");
|
|
186
|
+
if (implementer) {
|
|
187
|
+
files.set(".kxm/agents/implementer.yaml", {
|
|
188
|
+
...implementer,
|
|
189
|
+
harness: "grok",
|
|
190
|
+
model: { provider: "xai", model: "grok-4.6" },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const workflow = files.get(".kxm/workflows/default.yaml");
|
|
194
|
+
if (workflow) {
|
|
195
|
+
const limits = { ...(workflow.limits as JsonObject) };
|
|
196
|
+
delete limits.maxAgentTimeMs;
|
|
197
|
+
files.set(".kxm/workflows/default.yaml", { ...workflow, limits });
|
|
198
|
+
}
|
|
199
|
+
// The two models the template names, and nothing else. A fresh project can
|
|
200
|
+
// be driven without falling through to an unadmitted default model.
|
|
201
|
+
files.set(".kxm/routes.yaml", {
|
|
202
|
+
schema: "kxm.routes.v2",
|
|
203
|
+
updatedAt: "2026-09-23T00:00:00.000Z",
|
|
204
|
+
admitted: ["anthropic/fable", "xai/grok-4.6"],
|
|
205
|
+
disabled: [],
|
|
206
|
+
roles: { implementer: ["xai/grok-4.6"] },
|
|
207
|
+
});
|
|
177
208
|
files.set(".kxm/gates.yaml", {
|
|
178
209
|
schema: "kxm.gate-registry.v1",
|
|
179
210
|
gates: { test: { kind: "command", argv: ["npm", "test"], timeoutMs: 3_600_000 } },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
IMPROVEMENT_AREAS,
|
|
4
4
|
MAX_MESSAGE_TTL_MS,
|
|
@@ -1386,6 +1386,75 @@ export function workflowDefinitionHash(definition: WebhookWorkflowDefinition): s
|
|
|
1386
1386
|
return createHash("sha256").update(canonicalWorkflowDefinitionJson(definition), "utf8").digest("hex");
|
|
1387
1387
|
}
|
|
1388
1388
|
|
|
1389
|
+
/** The KXM-owned webhook sender contract: every signal callback, and any
|
|
1390
|
+
* workflow start that is not a Jira or GitHub provider delivery. The HMAC
|
|
1391
|
+
* covers a fixed-arity header block and the exact body bytes, so a captured
|
|
1392
|
+
* request cannot be replayed under another delivery ID, against another run
|
|
1393
|
+
* or signal key, or outside the timestamp window. */
|
|
1394
|
+
export const WORKFLOW_WEBHOOK_SIGNATURE_VERSION = "kxm-webhook-v1";
|
|
1395
|
+
export const WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS = 300;
|
|
1396
|
+
|
|
1397
|
+
/** What a signature is bound to. A start names only the definition; a signal
|
|
1398
|
+
* callback also names the run and signal key from its route. */
|
|
1399
|
+
export type WorkflowWebhookScope =
|
|
1400
|
+
| { definitionId: string; runId?: undefined; signalKey?: undefined }
|
|
1401
|
+
| { definitionId: string; runId: string; signalKey: string };
|
|
1402
|
+
|
|
1403
|
+
/** The exact bytes a KXM webhook signature covers: seven newline-terminated
|
|
1404
|
+
* fields (version, kind, timestamp, delivery ID, definition ID, run ID, signal
|
|
1405
|
+
* key; the last two empty for a start) followed by the raw body. No field may
|
|
1406
|
+
* contain a line break, so the encoding is unambiguous. */
|
|
1407
|
+
export function workflowWebhookSignedMaterial(
|
|
1408
|
+
scope: WorkflowWebhookScope,
|
|
1409
|
+
timestamp: string,
|
|
1410
|
+
deliveryId: string,
|
|
1411
|
+
body: string | Uint8Array,
|
|
1412
|
+
): Buffer {
|
|
1413
|
+
const fields = [
|
|
1414
|
+
WORKFLOW_WEBHOOK_SIGNATURE_VERSION,
|
|
1415
|
+
scope.runId === undefined ? "start" : "signal",
|
|
1416
|
+
timestamp,
|
|
1417
|
+
deliveryId,
|
|
1418
|
+
scope.definitionId,
|
|
1419
|
+
scope.runId ?? "",
|
|
1420
|
+
scope.signalKey ?? "",
|
|
1421
|
+
];
|
|
1422
|
+
if (fields.some((field) => /[\r\n]/.test(field))) {
|
|
1423
|
+
throw new ProtocolError(400, "webhook signature fields must not contain line breaks", "webhook_signature_field_invalid");
|
|
1424
|
+
}
|
|
1425
|
+
return Buffer.concat([
|
|
1426
|
+
Buffer.from(`${fields.join("\n")}\n`, "utf8"),
|
|
1427
|
+
typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body),
|
|
1428
|
+
]);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
export function workflowWebhookSignature(
|
|
1432
|
+
secret: string,
|
|
1433
|
+
scope: WorkflowWebhookScope,
|
|
1434
|
+
timestamp: string,
|
|
1435
|
+
deliveryId: string,
|
|
1436
|
+
body: string | Uint8Array,
|
|
1437
|
+
): string {
|
|
1438
|
+
return `sha256=${createHmac("sha256", secret).update(workflowWebhookSignedMaterial(scope, timestamp, deliveryId, body)).digest("hex")}`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** Headers for one KXM webhook send. Sign at send time: a transport retry
|
|
1442
|
+
* re-signs with a fresh timestamp and keeps the same delivery ID and body. */
|
|
1443
|
+
export function workflowWebhookHeaders(input: {
|
|
1444
|
+
secret: string;
|
|
1445
|
+
scope: WorkflowWebhookScope;
|
|
1446
|
+
deliveryId: string;
|
|
1447
|
+
body: string;
|
|
1448
|
+
nowMs?: number;
|
|
1449
|
+
}): Record<string, string> {
|
|
1450
|
+
const timestamp = String(Math.floor((input.nowMs ?? Date.now()) / 1_000));
|
|
1451
|
+
return {
|
|
1452
|
+
"x-kxm-delivery-id": input.deliveryId,
|
|
1453
|
+
"x-kxm-timestamp": timestamp,
|
|
1454
|
+
"x-kxm-signature": workflowWebhookSignature(input.secret, input.scope, timestamp, input.deliveryId, input.body),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1389
1458
|
/** Resolve a declared transition rule for a stage outcome, or undefined when
|
|
1390
1459
|
* the outcome keeps the v0.4 default edges. */
|
|
1391
1460
|
function resolveOutcomeRule(stage: WorkflowStageState, outcomeKey: string): WorkflowTransitionRule | undefined {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import type { KxmProducerResult } from "./engine.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fingerprint of a checkout taken around a live producer spawn.
|
|
6
|
+
* Porcelain alone misses a content edit that keeps the same status line, so the
|
|
7
|
+
* witness also includes unstaged and staged diffs.
|
|
8
|
+
*/
|
|
9
|
+
export interface WorktreeWitness {
|
|
10
|
+
readonly unwitnessed: boolean;
|
|
11
|
+
readonly fingerprint: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function gitText(cwd: string, args: readonly string[]): string | undefined {
|
|
15
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
timeout: 15_000,
|
|
19
|
+
});
|
|
20
|
+
if (result.error || result.status !== 0) return undefined;
|
|
21
|
+
return result.stdout ?? "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function captureWorktreeWitness(cwd: string): WorktreeWitness {
|
|
25
|
+
const porcelain = gitText(cwd, ["status", "--porcelain=v1", "-uall"]);
|
|
26
|
+
const diff = gitText(cwd, ["diff", "--no-ext-diff"]);
|
|
27
|
+
const staged = gitText(cwd, ["diff", "--cached", "--no-ext-diff"]);
|
|
28
|
+
if (porcelain === undefined || diff === undefined || staged === undefined) {
|
|
29
|
+
return { unwitnessed: true, fingerprint: "" };
|
|
30
|
+
}
|
|
31
|
+
return { unwitnessed: false, fingerprint: `${porcelain}\0${diff}\0${staged}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function worktreeChanged(before: WorktreeWitness, after: WorktreeWitness): boolean {
|
|
35
|
+
if (before.unwitnessed || after.unwitnessed) return false;
|
|
36
|
+
return before.fingerprint !== after.fingerprint;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A live `passed` is an authoring success only when the step declared write
|
|
41
|
+
* and the checkout fingerprint changed. A read-only step that mutates the tree
|
|
42
|
+
* cannot stay `passed`. Metadata `authored` is set here, not trusted from the
|
|
43
|
+
* producer.
|
|
44
|
+
*/
|
|
45
|
+
export function applyAuthoringWitness(
|
|
46
|
+
result: KxmProducerResult,
|
|
47
|
+
input: { writes: boolean; before: WorktreeWitness; after: WorktreeWitness },
|
|
48
|
+
): KxmProducerResult {
|
|
49
|
+
const changed = worktreeChanged(input.before, input.after);
|
|
50
|
+
const unwitnessed = input.before.unwitnessed || input.after.unwitnessed;
|
|
51
|
+
const providerMetadata: Record<string, string | number | boolean> = { ...(result.providerMetadata ?? {}) };
|
|
52
|
+
if (input.writes) {
|
|
53
|
+
if (unwitnessed || !changed) {
|
|
54
|
+
providerMetadata.authored = false;
|
|
55
|
+
providerMetadata.authoringWitness = unwitnessed ? "unwitnessed" : "unchanged";
|
|
56
|
+
if (result.outcome === "passed") return { ...result, outcome: "failed", providerMetadata };
|
|
57
|
+
} else {
|
|
58
|
+
providerMetadata.authored = true;
|
|
59
|
+
providerMetadata.authoringWitness = "changed";
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
providerMetadata.authored = false;
|
|
63
|
+
if (!unwitnessed && changed) {
|
|
64
|
+
providerMetadata.authoringWitness = "readonly_mutated";
|
|
65
|
+
if (result.outcome === "passed") return { ...result, outcome: "failed", providerMetadata };
|
|
66
|
+
} else {
|
|
67
|
+
providerMetadata.authoringWitness = unwitnessed ? "unwitnessed" : "read-only";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ...result, providerMetadata };
|
|
71
|
+
}
|
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
"minItems": 1,
|
|
32
32
|
"maxItems": 64
|
|
33
33
|
},
|
|
34
|
+
"files": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": { "$ref": "#/$defs/backupFileRecord" },
|
|
37
|
+
"maxItems": 64
|
|
38
|
+
},
|
|
39
|
+
"complete": { "type": "boolean" },
|
|
40
|
+
"omitted": {
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
43
|
+
"maxItems": 64
|
|
44
|
+
},
|
|
34
45
|
"manifestSha256": {
|
|
35
46
|
"$ref": "common.schema.json#/$defs/sha256"
|
|
36
47
|
}
|
|
@@ -84,6 +95,28 @@
|
|
|
84
95
|
}
|
|
85
96
|
},
|
|
86
97
|
"additionalProperties": false
|
|
98
|
+
},
|
|
99
|
+
"backupFileRecord": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"required": ["id", "sourcePath", "backupFile", "sha256", "bytes"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"id": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"minLength": 1,
|
|
106
|
+
"maxLength": 128,
|
|
107
|
+
"pattern": "^[a-zA-Z0-9_.:-]+$"
|
|
108
|
+
},
|
|
109
|
+
"sourcePath": { "type": "string", "minLength": 1, "maxLength": 1024 },
|
|
110
|
+
"backupFile": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"minLength": 1,
|
|
113
|
+
"maxLength": 256,
|
|
114
|
+
"pattern": "^[a-zA-Z0-9_.-]+$"
|
|
115
|
+
},
|
|
116
|
+
"sha256": { "$ref": "common.schema.json#/$defs/sha256" },
|
|
117
|
+
"bytes": { "type": "integer", "minimum": 0, "maximum": 107374182400 }
|
|
118
|
+
},
|
|
119
|
+
"additionalProperties": false
|
|
87
120
|
}
|
|
88
121
|
}
|
|
89
122
|
}
|
|
@@ -423,10 +423,14 @@ export async function runRealSmoke(options = {}) {
|
|
|
423
423
|
stage = "workflow-journal-checkpoint";
|
|
424
424
|
const resumedOperator = values["durable-restart-resume"];
|
|
425
425
|
const payload = JSON.stringify({ event: "smoke.requested" });
|
|
426
|
+
// KXM webhook contract (workflowWebhookSignedMaterial in plugins/kxm/src/workflow.ts).
|
|
427
|
+
const deliveryId = `smoke-${randomUUID()}`;
|
|
428
|
+
const timestamp = String(Math.floor(Date.now() / 1_000));
|
|
429
|
+
const signedMaterial = ["kxm-webhook-v1", "start", timestamp, deliveryId, "real-pi-smoke", "", ""].join("\n") + "\n" + payload;
|
|
426
430
|
const started = await api(baseUrl, "/v1/webhooks/real-pi-smoke", {
|
|
427
431
|
method: "POST",
|
|
428
432
|
authToken,
|
|
429
|
-
headers: { "x-
|
|
433
|
+
headers: { "x-kxm-delivery-id": deliveryId, "x-kxm-timestamp": timestamp, "x-kxm-signature": `sha256=${createHmac("sha256", webhookSecret).update(signedMaterial).digest("hex")}` },
|
|
430
434
|
body: payload,
|
|
431
435
|
});
|
|
432
436
|
await api(baseUrl, `/v1/workflows/${encodeURIComponent(started.run.id)}/journal`, {
|