@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.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/workflows/default.yaml +2 -0
  3. package/CHANGELOG.md +61 -2
  4. package/docs/concepts/architecture.md +1 -1
  5. package/docs/concepts/data-and-storage.md +1 -1
  6. package/docs/concepts/trust-model.md +3 -2
  7. package/docs/contracts/routing.md +1 -1
  8. package/docs/contributing/test-matrix.md +6 -5
  9. package/docs/glossary.md +1 -1
  10. package/docs/guides/peer-messaging.md +2 -2
  11. package/docs/guides/pi-workers.md +1 -1
  12. package/docs/guides/webhook-workflows.md +53 -18
  13. package/docs/operations/backup-and-restore.md +43 -24
  14. package/docs/operations/deploy.md +2 -2
  15. package/docs/operations/troubleshooting.md +3 -2
  16. package/docs/reference/cli-reference.md +65 -30
  17. package/docs/reference/config-reference.md +24 -13
  18. package/docs/reference/configuration.md +2 -2
  19. package/docs/reference/harness-routing.md +3 -3
  20. package/docs/reference/http-api.md +7 -7
  21. package/docs/reference/tools.md +1 -1
  22. package/docs/reference/workflow-definitions.md +2 -2
  23. package/docs/start/first-workflow.md +4 -4
  24. package/docs/start/quickstart-claude-code.md +2 -2
  25. package/docs/start/quickstart-pi.md +1 -1
  26. package/examples/workflow-signal.ts +4 -5
  27. package/package.json +1 -1
  28. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  29. package/plugins/kxm/dist/claude-hook.js +11 -1
  30. package/plugins/kxm/dist/cli.js +552 -228
  31. package/plugins/kxm/dist/client.js +3 -1
  32. package/plugins/kxm/dist/core.js +16 -3
  33. package/plugins/kxm/dist/extension.js +45 -13
  34. package/plugins/kxm/dist/mcp-server.js +20 -4
  35. package/plugins/kxm/dist/runtime-supervisor.js +232 -51
  36. package/plugins/kxm/dist/runtime.js +432 -95
  37. package/plugins/kxm/dist/server.js +122 -20
  38. package/plugins/kxm/package.json +1 -1
  39. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
  40. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
  41. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
  42. package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
  43. package/plugins/kxm/src/cli/project.ts +22 -13
  44. package/plugins/kxm/src/cli/system.ts +22 -1
  45. package/plugins/kxm/src/cli/workflows.ts +12 -7
  46. package/plugins/kxm/src/cli.ts +30 -8
  47. package/plugins/kxm/src/client.ts +4 -0
  48. package/plugins/kxm/src/commands.ts +23 -1
  49. package/plugins/kxm/src/database.ts +210 -36
  50. package/plugins/kxm/src/engine.ts +117 -2
  51. package/plugins/kxm/src/extension.ts +20 -14
  52. package/plugins/kxm/src/github-watch.ts +8 -5
  53. package/plugins/kxm/src/harness.ts +29 -0
  54. package/plugins/kxm/src/hub-env.ts +19 -1
  55. package/plugins/kxm/src/hub.ts +105 -21
  56. package/plugins/kxm/src/improve-sources.ts +2 -7
  57. package/plugins/kxm/src/init-guide-setup.ts +43 -28
  58. package/plugins/kxm/src/mcp-server.ts +9 -2
  59. package/plugins/kxm/src/oneshot-producer.ts +16 -8
  60. package/plugins/kxm/src/prices.ts +33 -2
  61. package/plugins/kxm/src/routing.ts +13 -7
  62. package/plugins/kxm/src/runtime-store.ts +23 -0
  63. package/plugins/kxm/src/studio-layout.ts +5 -4
  64. package/plugins/kxm/src/template.ts +31 -0
  65. package/plugins/kxm/src/workflow.ts +70 -1
  66. package/plugins/kxm/src/worktree-witness.ts +71 -0
  67. package/schemas/backup-manifest.schema.json +33 -0
  68. package/scripts/smoke-multi-pi.mjs +5 -1
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
6
+ import { oneShotWriterArgs } from "./harness.ts";
7
+ import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
6
8
  import {
7
9
  buildFormalContextPacket,
8
10
  buildHandoffManifest,
@@ -153,6 +155,8 @@ export interface KxmProducerRequest {
153
155
  readonly thinking?: string | undefined;
154
156
  readonly agentRole?: string | undefined;
155
157
  readonly harness?: string | undefined;
158
+ /** Live producers select an audited argv profile from this ceiling. */
159
+ readonly permission?: "read-only" | "edit" | undefined;
156
160
  readonly contextPacket?: FormalContextPacketV2 | undefined;
157
161
  readonly handoffManifest?: HandoffManifestV1 | undefined;
158
162
  }
@@ -1593,6 +1597,14 @@ function prepareDispatch(
1593
1597
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
1594
1598
  }
1595
1599
  resolvedRoute = routeResult;
1600
+ const writeRefusal = unsupportedLiveWrite(
1601
+ context.projectRoot,
1602
+ step,
1603
+ agentId,
1604
+ resolvedRoute.selector,
1605
+ loadKxmRunPlanEnvelope(context.eventStore, run).projectLimits.maxConcurrentRuns,
1606
+ );
1607
+ if (writeRefusal) return { kind: "return", state, handoff: { ...writeRefusal, stepId } };
1596
1608
  }
1597
1609
 
1598
1610
  const used = state.stepAttempts[stepId] ?? 0;
@@ -1825,6 +1837,7 @@ function birthMember(
1825
1837
  signal: controller.signal,
1826
1838
  prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
1827
1839
  thinking: input.stepAttempt <= 1 ? "low" : "medium",
1840
+ permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
1828
1841
  contextPacket,
1829
1842
  ...(resolvedRoute ? { provider: resolvedRoute.provider, model: resolvedRoute.model } : {}),
1830
1843
  },
@@ -1998,7 +2011,18 @@ async function drivePanel(
1998
2011
  if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
1999
2012
  kxmPanelDispatchSeams.beforeInvoke?.(member);
2000
2013
  if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
2014
+ const live = member.producerId !== "driver-simulated";
2015
+ const writes = Object.values(member.step.repositories).some((access) => access === "write");
2016
+ const before = live ? captureWorktreeWitness(context.projectRoot) : undefined;
2001
2017
  const produced = await invokeProducer(producer, member.request);
2018
+ if (live && produced.result && before) {
2019
+ const after = captureWorktreeWitness(context.projectRoot);
2020
+ return {
2021
+ attemptId: member.attemptId,
2022
+ invoked: true,
2023
+ produced: { ...produced, result: applyAuthoringWitness(produced.result, { writes, before, after }) },
2024
+ };
2025
+ }
2002
2026
  return { attemptId: member.attemptId, invoked: true, produced };
2003
2027
  } catch (error) {
2004
2028
  member.controller.abort();
@@ -2722,11 +2746,102 @@ function unsupportedStep(
2722
2746
  return { reason: "step_unsupported", field: "repositories", detail: `invalid repository access '${access}' on ${repoId}` };
2723
2747
  }
2724
2748
  }
2725
- if (producerId !== "driver-simulated" && Object.values(step.repositories).some((access) => access === "write")) {
2749
+ return undefined;
2750
+ }
2751
+
2752
+ function readYamlRecord(path: string): Record<string, unknown> | undefined {
2753
+ if (!existsSync(path)) return undefined;
2754
+ try {
2755
+ const parsed = parse(readFileSync(path, "utf8")) as unknown;
2756
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
2757
+ } catch {
2758
+ return undefined;
2759
+ }
2760
+ return undefined;
2761
+ }
2762
+
2763
+ function agentHarness(projectRoot: string, agentId: string): string | undefined {
2764
+ const harness = readYamlRecord(join(projectRoot, ".kxm", "agents", `${agentId}.yaml`))?.harness;
2765
+ return typeof harness === "string" && harness.length > 0 ? harness : undefined;
2766
+ }
2767
+
2768
+ function projectDefaultHarness(projectRoot: string): string {
2769
+ const harness = readYamlRecord(join(projectRoot, ".kxm", "project.yaml"))?.defaultHarness;
2770
+ return typeof harness === "string" && harness.length > 0 ? harness : "pi";
2771
+ }
2772
+
2773
+ /**
2774
+ * Live write steps run only on an audited writer profile, and only when the
2775
+ * developer roster (when present) lists that harness and model as an edit writer.
2776
+ * A missing roster is a fresh project: route admission is the other gate.
2777
+ *
2778
+ * The authoring witness fingerprints the one project checkout around each
2779
+ * spawn, so it can only attribute a change to a lone writer. A write step with
2780
+ * more than one assignment would run several writers there (and member n runs
2781
+ * as allowedAgents[n], which the writer check below never sees), and a
2782
+ * project that admits concurrent runs lets another run's writer edit the tree
2783
+ * mid-attempt. Both hand off.
2784
+ */
2785
+ function unsupportedLiveWrite(
2786
+ projectRoot: string,
2787
+ step: KxmCompiledStep,
2788
+ agentId: string,
2789
+ selector: string,
2790
+ maxConcurrentRuns: number,
2791
+ ): Omit<KxmRunHandoff, "stepId"> | undefined {
2792
+ if (!Object.values(step.repositories).some((access) => access === "write")) return undefined;
2793
+ if (step.assignments.maximum !== 1) {
2794
+ return {
2795
+ reason: "step_unsupported",
2796
+ field: "assignments.maximum",
2797
+ detail: "live write steps run a single assignment; the checkout witness cannot attribute edits between writers",
2798
+ };
2799
+ }
2800
+ if (maxConcurrentRuns !== 1) {
2801
+ return {
2802
+ reason: "step_unsupported",
2803
+ field: "limits.maxConcurrentRuns",
2804
+ detail: "live write steps require limits.maxConcurrentRuns of 1; concurrent runs share one checkout",
2805
+ };
2806
+ }
2807
+ const harness = agentHarness(projectRoot, agentId) ?? projectDefaultHarness(projectRoot);
2808
+ if (!oneShotWriterArgs(harness)) {
2726
2809
  return {
2727
2810
  reason: "step_unsupported",
2728
2811
  field: "repositories",
2729
- detail: "live write steps are unsupported until writer sandboxing witness passes",
2812
+ detail: `live write steps require an audited writer profile; ${harness} has none`,
2813
+ };
2814
+ }
2815
+ const rosterPath = join(projectRoot, ".kxm", "roster.yaml");
2816
+ if (!existsSync(rosterPath)) return undefined;
2817
+ const roster = readYamlRecord(rosterPath);
2818
+ if (!roster || roster.schema !== "kxm.developer-roster.v1") {
2819
+ return { reason: "step_unsupported", field: "model", detail: "live write steps require a readable kxm.developer-roster.v1" };
2820
+ }
2821
+ const routes = roster.routes;
2822
+ const lineup = roster.lineup;
2823
+ const writerIds = lineup && typeof lineup === "object" && !Array.isArray(lineup)
2824
+ ? (lineup as Record<string, unknown>).writer
2825
+ : undefined;
2826
+ if (!routes || typeof routes !== "object" || Array.isArray(routes) || !Array.isArray(writerIds)) {
2827
+ return { reason: "step_unsupported", field: "model", detail: "developer roster has no writer lineup" };
2828
+ }
2829
+ const allowed = writerIds.some((id) => {
2830
+ if (typeof id !== "string") return false;
2831
+ const route = (routes as Record<string, unknown>)[id];
2832
+ if (!route || typeof route !== "object" || Array.isArray(route)) return false;
2833
+ const record = route as Record<string, unknown>;
2834
+ if (record.harness !== harness || record.status !== "admitted") return false;
2835
+ if (!Array.isArray(record.permissions) || !record.permissions.includes("edit")) return false;
2836
+ const model = typeof record.model === "string" ? record.model : "";
2837
+ const vendor = typeof record.vendor === "string" ? record.vendor : "";
2838
+ return model === selector || (vendor.length > 0 && `${vendor}/${model}` === selector);
2839
+ });
2840
+ if (!allowed) {
2841
+ return {
2842
+ reason: "step_unsupported",
2843
+ field: "model",
2844
+ detail: `live write route ${selector} on ${harness} is not on the developer roster writer lineup`,
2730
2845
  };
2731
2846
  }
2732
2847
  return undefined;
@@ -6,7 +6,7 @@ import { AGENT_COMMANDS, enforceToolPolicy } from "./commands.ts";
6
6
  import { HubClient, HubHttpError } from "./client.ts";
7
7
  import { loadKxmConfig } from "./config.ts";
8
8
  import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
9
- import { readHubEnvRecord } from "./hub-env.ts";
9
+ import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
10
10
  import { defaultProjectName } from "./project-name.ts";
11
11
  import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
12
12
  import {
@@ -685,13 +685,11 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
685
685
  removeMatchingLegacyRecoveryContext();
686
686
  const purpose = process.env.KXM_AGENT_PURPOSE ?? "General-purpose coding agent";
687
687
  const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
688
- let autoStartToken: string | undefined;
689
688
  try {
690
689
  const config = loadKxmConfig(ctx.cwd);
691
690
  if (hubAutoStartMode(config) === "background") {
692
691
  const ensured = await ensureHubRunning({ config, cwd: ctx.cwd });
693
692
  if (ensured.status === "started") {
694
- autoStartToken = ensured.authToken;
695
693
  ctx.ui.notify(
696
694
  `kxm hub started in the background (pid ${ensured.pid}); logs: ${ensured.logPath}`
697
695
  + (ensured.authTokenSource === "generated" ? "; new admin token generated and persisted to user state" : ""),
@@ -704,23 +702,29 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
704
702
  } catch (error) {
705
703
  ctx.ui.notify(`kxm hub auto-start failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
706
704
  }
707
- // Authenticate with the explicit env token first, then the token resolved
708
- // by auto-start, then the credential persisted for this machine's hubs.
709
- const envAuthToken = process.env.KXM_AUTH_TOKEN?.trim();
710
- let hubAuthToken = envAuthToken || autoStartToken;
705
+ // An agent session registers with KXM_AUTH_TOKEN or this project's saved project
706
+ // token only. The hub admits its admin token to any project missing from its token
707
+ // map, so neither the persisted admin token nor the one auto-start resolved may
708
+ // stand in for a project token.
709
+ let hubAuthToken: string | undefined;
710
+ try {
711
+ hubAuthToken = resolveAgentHubAuthToken(process.env, project);
712
+ } catch (error) {
713
+ ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "error");
714
+ await applySessionChrome(ctx, event, true);
715
+ return;
716
+ }
711
717
  if (!hubAuthToken) {
712
- try {
713
- hubAuthToken = readHubEnvRecord()?.authToken?.trim() || undefined;
714
- } catch (error) {
715
- ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "warning");
716
- }
718
+ ctx.ui.notify(new AgentProjectTokenMissingError(project).message, "error");
719
+ await applySessionChrome(ctx, event, true);
720
+ return;
717
721
  }
718
722
  client = new HubClient({
719
723
  serverUrl,
720
724
  name,
721
725
  purpose,
722
726
  project,
723
- ...(hubAuthToken ? { authToken: hubAuthToken } : {}),
727
+ authToken: hubAuthToken,
724
728
  ...(model ? { model } : {}),
725
729
  });
726
730
  notify = (message, type) => ctx.ui.notify(message, type);
@@ -893,9 +897,11 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
893
897
  if (!policy.allowed) {
894
898
  throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
895
899
  }
900
+ // A request sent while handling the active inbound request continues its hop chain.
901
+ const handling = activeInbound ? [activeInbound] : [];
896
902
  return result(
897
903
  await workflowCall(() =>
898
- cmd.execute(requireClient(), (params ?? {}) as Record<string, unknown>, { signal }),
904
+ cmd.execute(requireClient(), (params ?? {}) as Record<string, unknown>, { signal, handling }),
899
905
  ),
900
906
  );
901
907
  },
@@ -1,6 +1,6 @@
1
- import { createHmac, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { redactSecrets } from "./redact.ts";
3
- import type { WorkflowEvidenceInput } from "./workflow.ts";
3
+ import { workflowWebhookHeaders, type WorkflowEvidenceInput } from "./workflow.ts";
4
4
 
5
5
  export type WatchStatus = "passed" | "failed" | "warning";
6
6
 
@@ -109,7 +109,6 @@ export async function postWorkflowSignal(input: {
109
109
  fetchImpl?: typeof fetch;
110
110
  }): Promise<{ httpStatus: number; duplicate: boolean }> {
111
111
  const body = JSON.stringify({ status: input.status, summary: input.summary, evidence: input.evidence });
112
- const signature = `sha256=${createHmac("sha256", input.signalSecret).update(body).digest("hex")}`;
113
112
  const endpoint = [
114
113
  input.serverUrl.replace(/\/$/, ""),
115
114
  "v1/webhooks",
@@ -123,8 +122,12 @@ export async function postWorkflowSignal(input: {
123
122
  method: "POST",
124
123
  headers: {
125
124
  "content-type": "application/json",
126
- "x-hub-signature-256": signature,
127
- "x-kxm-delivery-id": input.deliveryId,
125
+ ...workflowWebhookHeaders({
126
+ secret: input.signalSecret,
127
+ scope: { definitionId: input.definitionId, runId: input.runId, signalKey: input.signalKey },
128
+ deliveryId: input.deliveryId,
129
+ body,
130
+ }),
128
131
  },
129
132
  body,
130
133
  }, input.timeoutMs ?? 15_000);
@@ -518,6 +518,35 @@ export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefi
518
518
  return Object.hasOwn(READ_ONLY_ONESHOT_ARGS, harness) ? READ_ONLY_ONESHOT_ARGS[harness as keyof typeof READ_ONLY_ONESHOT_ARGS] : undefined;
519
519
  }
520
520
 
521
+ /**
522
+ * Audited edit profiles. These are a writer containment, not read-only flags
523
+ * with the sandbox removed. They match the assignment helper
524
+ * (`scripts/harness-run.mjs` `buildArgv` for `permission: "edit"` with hooks
525
+ * and skills refused):
526
+ *
527
+ * - Pi: `-a` auto-approves tools so the process can edit the checkout, while
528
+ * extensions, skills, prompt templates, and session persistence stay off.
529
+ * `--no-tools` is the read-only profile and is not used here.
530
+ * - Grok: `--always-approve` so edits are not an interactive prompt, with
531
+ * subagents and web search disabled. Grok has no audited read-only edit
532
+ * mix; the read-only one-shot flags stay on the read-only profile.
533
+ *
534
+ * Claude, Codex, agy, and Kimi stay read-only. A live write step on a harness
535
+ * without an entry here hands off instead of spawning unconstrained.
536
+ */
537
+ const WRITER_ONESHOT_ARGS = Object.freeze({
538
+ pi: Object.freeze(["-a", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-session"]),
539
+ grok: Object.freeze(["--always-approve", "--no-subagents", "--disable-web-search"]),
540
+ });
541
+
542
+ export function oneShotWriterArgs(harness: string): readonly string[] | undefined {
543
+ return Object.hasOwn(WRITER_ONESHOT_ARGS, harness) ? WRITER_ONESHOT_ARGS[harness as keyof typeof WRITER_ONESHOT_ARGS] : undefined;
544
+ }
545
+
546
+ export function oneShotPermissionArgs(harness: string, permission: "read-only" | "edit"): readonly string[] | undefined {
547
+ return permission === "edit" ? oneShotWriterArgs(harness) : oneShotReadOnlyArgs(harness);
548
+ }
549
+
521
550
  export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
522
551
  {
523
552
  id: "pi",
@@ -236,7 +236,8 @@ export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: strin
236
236
  }
237
237
 
238
238
  /**
239
- * Token an agent session (the Claude MCP server) registers with: explicit `KXM_AUTH_TOKEN`,
239
+ * Token an agent session (the Claude MCP server, the Pi extension, the `kxm peer` and
240
+ * `kxm workflow` agent commands) registers with: explicit `KXM_AUTH_TOKEN`,
240
241
  * else the persisted project token for `project`, else nothing. It never returns the
241
242
  * persisted admin token. The hub accepts the admin token for any project missing from its
242
243
  * project-token map, so an agent falling back to it would join a project nobody issued it a
@@ -251,6 +252,23 @@ export function resolveAgentHubAuthToken(env: NodeJS.ProcessEnv, project: string
251
252
  return tokens[project]?.trim() || undefined;
252
253
  }
253
254
 
255
+ /**
256
+ * Refusal for an agent session that has neither `KXM_AUTH_TOKEN` nor a saved project
257
+ * token for its project. The fix is the operator's, so the message names it.
258
+ */
259
+ export class AgentProjectTokenMissingError extends Error {
260
+ readonly code = "project_token_missing";
261
+ readonly project: string;
262
+
263
+ constructor(project: string) {
264
+ super(
265
+ `kxm has no project token for project ${project} on this machine. Set KXM_AUTH_TOKEN to that project's token, or add ${project} to the hub KXM_PROJECT_TOKENS (list every existing project too, because that variable replaces the saved map). An agent never uses the hub admin token.`,
266
+ );
267
+ this.name = "AgentProjectTokenMissingError";
268
+ this.project = project;
269
+ }
270
+ }
271
+
254
272
  /**
255
273
  * Admin-scoped hub reads (`/v1/ops/snapshot`, …) are rejected with 401 by a project token,
256
274
  * so this variant resolves the admin credential only: explicit `KXM_AUTH_TOKEN`, else the
@@ -70,6 +70,8 @@ import {
70
70
  verifyWorkflowEvidenceReferences,
71
71
  workflowDefinitionHash,
72
72
  workflowEvidenceStrings,
73
+ workflowWebhookSignature,
74
+ WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS,
73
75
  type ImprovementArea,
74
76
  type JournalCategory,
75
77
  type WebhookWorkflowDefinition,
@@ -81,6 +83,7 @@ import {
81
83
  type WorkflowSignalReceipt,
82
84
  type WorkflowStageState,
83
85
  type WorkflowVerifiedEvidence,
86
+ type WorkflowWebhookScope,
84
87
  } from "./workflow.ts";
85
88
 
86
89
  export interface RateLimitOptions {
@@ -971,20 +974,77 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
971
974
  return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
972
975
  }
973
976
 
974
- function verifyWebhookSignature(request: IncomingMessage, body: Buffer, secret: string): void {
975
- const signature = request.headers["x-hub-signature-256"] ?? request.headers["x-hub-signature"];
976
- if (typeof signature !== "string") {
977
- throw new ProtocolError(401, "webhook signature is required", "webhook_signature_missing");
978
- }
977
+ function requireSha256Signature(signature: string): void {
979
978
  const separator = signature.indexOf("=");
980
979
  const algorithm = separator > 0 ? signature.slice(0, separator).toLowerCase() : "";
981
980
  if (algorithm !== "sha256") {
982
981
  throw new ProtocolError(401, "webhook signature must use sha256", "webhook_signature_unsupported");
983
982
  }
984
- const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
983
+ }
984
+
985
+ /** Jira and GitHub sign only the body, so a provider delivery ID is unsigned
986
+ * routing data. Returns that provider's own delivery header. */
987
+ function verifyProviderWebhookSignature(
988
+ request: IncomingMessage,
989
+ body: Buffer,
990
+ definition: WebhookWorkflowDefinition,
991
+ ): string {
992
+ if (definition.source === "generic") {
993
+ throw new ProtocolError(
994
+ 401,
995
+ "generic workflow webhooks require x-kxm-signature over the timestamp, delivery ID, definition, and body",
996
+ "webhook_signature_missing",
997
+ );
998
+ }
999
+ const signature = request.headers["x-hub-signature-256"] ?? request.headers["x-hub-signature"];
1000
+ if (typeof signature !== "string") {
1001
+ throw new ProtocolError(401, "webhook signature is required", "webhook_signature_missing");
1002
+ }
1003
+ requireSha256Signature(signature);
1004
+ const expected = `sha256=${createHmac("sha256", definition.secret).update(body).digest("hex")}`;
985
1005
  if (!safeTokenEqual(signature, expected)) {
986
1006
  throw new ProtocolError(401, "webhook signature is invalid", "webhook_signature_invalid");
987
1007
  }
1008
+ const deliveryHeader = definition.source === "jira"
1009
+ ? request.headers["x-atlassian-webhook-identifier"]
1010
+ : request.headers["x-github-delivery"];
1011
+ return requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
1012
+ }
1013
+
1014
+ /** The KXM sender contract (see workflowWebhookSignedMaterial): the
1015
+ * signature binds the delivery ID, timestamp, and route scope. A valid
1016
+ * signature outside the skew window is refused as expired. */
1017
+ function verifyKxmWebhookSignature(
1018
+ request: IncomingMessage,
1019
+ body: Buffer,
1020
+ secret: string,
1021
+ scope: WorkflowWebhookScope,
1022
+ ): string {
1023
+ const signature = request.headers["x-kxm-signature"];
1024
+ if (typeof signature !== "string") {
1025
+ throw new ProtocolError(
1026
+ 401,
1027
+ "KXM webhooks require x-kxm-signature over the timestamp, delivery ID, route, and body",
1028
+ "webhook_signature_missing",
1029
+ );
1030
+ }
1031
+ requireSha256Signature(signature);
1032
+ const timestamp = request.headers["x-kxm-timestamp"];
1033
+ if (typeof timestamp !== "string" || !/^[0-9]{1,12}$/.test(timestamp)) {
1034
+ throw new ProtocolError(401, "x-kxm-timestamp must be Unix seconds", "webhook_timestamp_invalid");
1035
+ }
1036
+ const deliveryId = requireString(request.headers["x-kxm-delivery-id"], "webhook delivery identifier", { max: 128 });
1037
+ if (!safeTokenEqual(signature, workflowWebhookSignature(secret, scope, timestamp, deliveryId, body))) {
1038
+ throw new ProtocolError(401, "webhook signature is invalid", "webhook_signature_invalid");
1039
+ }
1040
+ if (Math.abs(Date.now() / 1_000 - Number(timestamp)) > WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS) {
1041
+ throw new ProtocolError(
1042
+ 401,
1043
+ `webhook timestamp is outside the ${WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS}-second window; re-sign at send time`,
1044
+ "webhook_timestamp_expired",
1045
+ );
1046
+ }
1047
+ return deliveryId;
988
1048
  }
989
1049
 
990
1050
  function workflowPrompt(
@@ -1013,7 +1073,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1013
1073
  stageList,
1014
1074
  "",
1015
1075
  `At every stage, record material knowledge with kxm_workflow_record in one of these categories: ${JOURNAL_CATEGORIES.join(", ")}. Pass the stageId the entry belongs to; the hub binds the attempt and, when you omit area, uses the stage's declared area.`,
1016
- "Keep repository-local configuration in .kxm/config, logs in .kxm/logs, and durable workflow artifacts in .kxm/assets; never commit runtime logs, state, or secrets.",
1076
+ "Keep reviewed configuration as YAML directly under .kxm/ (such as .kxm/project.yaml and .kxm/workflows/), logs in .kxm/logs, and durable workflow artifacts in .kxm/assets. Never create .kxm/config, which KXM refuses, and never commit runtime logs, .kxm/state, or secrets.",
1017
1077
  "Complete each stage with kxm_workflow_checkpoint. Supply evidence as an object whose keys exactly match the stage's required evidence keys. Unrelated keys never satisfy a requirement. A warning or failure must be corrected and checkpointed again until it passes or the attempt limit is reached.",
1018
1078
  "For a peer-evidence requirement, send or fan out with workflowContext containing this run ID, the exact stage ID, requirement key, and current 1-based attempt. At checkpoint, cite only the returned message IDs under evidenceRefs; the hub derives producer and reply provenance.",
1019
1079
  "When an external system must finish asynchronously, call kxm_workflow_wait with a stable signal key and any already-verified keyed evidence. That evidence is accumulated with the signed callback before the stage can pass; then settle the turn.",
@@ -1335,16 +1395,17 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1335
1395
  const definition = webhookWorkflows.get(definitionId);
1336
1396
  if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
1337
1397
  const rawBody = await readBody(request);
1338
- verifyWebhookSignature(request, rawBody, definition.signalSecret ?? definition.secret);
1398
+ const deliveryId = verifyKxmWebhookSignature(
1399
+ request,
1400
+ rawBody,
1401
+ definition.signalSecret ?? definition.secret,
1402
+ { definitionId: definition.id, runId, signalKey },
1403
+ );
1339
1404
  const body = parseJsonBody(rawBody);
1340
1405
  const run = workflowRuns.get(runId);
1341
1406
  if (!run || run.definitionId !== definition.id) {
1342
1407
  throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
1343
1408
  }
1344
- const deliveryHeader = request.headers["x-atlassian-webhook-identifier"]
1345
- ?? request.headers["x-github-delivery"]
1346
- ?? request.headers["x-kxm-delivery-id"];
1347
- const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
1348
1409
  const payloadHash = createHash("sha256").update(rawBody).digest("hex");
1349
1410
  const existingReceipt = run.signalReceipts?.find((receipt) => receipt.deliveryId === deliveryId);
1350
1411
  if (existingReceipt) {
@@ -1491,7 +1552,13 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1491
1552
  const definition = webhookWorkflows.get(definitionId);
1492
1553
  if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
1493
1554
  const rawBody = await readBody(request);
1494
- verifyWebhookSignature(request, rawBody, definition.secret);
1555
+ // A KXM sender signs the delivery ID and timestamp. A Jira or GitHub
1556
+ // delivery signs only its body, so for those the body is the replay
1557
+ // identity: it may start one run, under one delivery ID.
1558
+ const bodyOnlySignature = request.headers["x-kxm-signature"] === undefined;
1559
+ const deliveryId = bodyOnlySignature
1560
+ ? verifyProviderWebhookSignature(request, rawBody, definition)
1561
+ : verifyKxmWebhookSignature(request, rawBody, definition.secret, { definitionId: definition.id });
1495
1562
  const payload = parseJsonBody(rawBody);
1496
1563
  const event = webhookEvent(request, payload);
1497
1564
  if (definition.event && event !== definition.event) {
@@ -1502,17 +1569,30 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1502
1569
  response.writeHead(204, { "cache-control": "no-store" }).end();
1503
1570
  return;
1504
1571
  }
1505
- const deliveryHeader = request.headers["x-atlassian-webhook-identifier"]
1506
- ?? request.headers["x-github-delivery"]
1507
- ?? request.headers["x-kxm-delivery-id"];
1508
- const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
1572
+ const payloadHash = createHash("sha256").update(rawBody).digest("hex");
1509
1573
  const existing = [...workflowRuns.values()].find(
1510
1574
  (run) => run.definitionId === definition.id && run.deliveryId === deliveryId,
1511
1575
  );
1512
1576
  if (existing) {
1513
- json(response, 200, { run: existing, duplicate: true });
1577
+ if (existing.payloadHash !== payloadHash) {
1578
+ throw new ProtocolError(
1579
+ 409,
1580
+ "webhook delivery identifier was already used for a different body",
1581
+ "webhook_delivery_conflict",
1582
+ );
1583
+ }
1584
+ json(response, 200, { duplicate: true, runId: existing.id, status: existing.status });
1514
1585
  return;
1515
1586
  }
1587
+ if (bodyOnlySignature && [...workflowRuns.values()].some(
1588
+ (run) => run.definitionId === definition.id && run.payloadHash === payloadHash,
1589
+ )) {
1590
+ throw new ProtocolError(
1591
+ 409,
1592
+ "this signed body already started a run under another delivery identifier",
1593
+ "webhook_payload_replayed",
1594
+ );
1595
+ }
1516
1596
  const target = findKnownTarget(definition.project, definition.target);
1517
1597
  const createdAt = nowIso();
1518
1598
  const runId = newId("run");
@@ -1562,7 +1642,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1562
1642
  definitionId: definition.id,
1563
1643
  source: definition.source,
1564
1644
  deliveryId,
1565
- payloadHash: createHash("sha256").update(rawBody).digest("hex"),
1645
+ payloadHash,
1566
1646
  definitionHash: workflowDefinitionHash(definition),
1567
1647
  ...(event ? { event } : {}),
1568
1648
  project: definition.project,
@@ -1593,7 +1673,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1593
1673
  project: definition.project,
1594
1674
  target: target.id,
1595
1675
  });
1596
- json(response, 202, { run, duplicate: false });
1676
+ json(response, 202, { run, runId: run.id, duplicate: false });
1597
1677
  return;
1598
1678
  }
1599
1679
 
@@ -2693,7 +2773,11 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2693
2773
  const hops = parseBoundedInteger(body.hops, "hops", 0, 0, 100);
2694
2774
  const maxHops = parseBoundedInteger(body.maxHops, "maxHops", DEFAULT_MAX_HOPS, 1, 20);
2695
2775
  if (hops >= maxHops) {
2696
- throw new ProtocolError(400, `hop limit reached (${hops}/${maxHops})`, "hop_limit_reached");
2776
+ throw new ProtocolError(
2777
+ 400,
2778
+ `hop limit reached (${hops}/${maxHops}): this request would extend a chain of forwarded requests past its limit; answer the inbound request directly`,
2779
+ "hop_limit_reached",
2780
+ );
2697
2781
  }
2698
2782
  const ttlMs = parseBoundedInteger(
2699
2783
  body.ttlMs,
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { resolve } from "node:path";
3
3
  import { DatabaseSync } from "./sqlite.ts";
4
4
  import { discoverKxmProjectRoot } from "./project-config.ts";
5
- import { kxmRuntimePaths, projectRuntimeKey } from "./runtime-store.ts";
5
+ import { kxmProjectRunEventsPath } from "./runtime-store.ts";
6
6
  import { parseRoutingRecordV2, ROUTING_RECORD_V2_SCHEMA, type RoutingRecord, type RoutingRecordV2 } from "./routing.ts";
7
7
  import { readRoutingRecords, telemetryPath } from "./telemetry.ts";
8
8
 
@@ -45,11 +45,6 @@ const ENGINE_EVENTS_SQL = "SELECT run_id, sequence, event_type, payload FROM eve
45
45
  /** The simulated producer's harness label; its attempts measure nothing. */
46
46
  const SIMULATED_HARNESS = "driver-simulated";
47
47
 
48
- /** The project's Runtime event store, derived exactly as the Runtime derives it. */
49
- export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
50
- return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
51
- }
52
-
53
48
  interface RunLog {
54
49
  lastStatus?: string;
55
50
  /** Latest step.entered sequence per step. */