@kontextmind/kxm 0.7.95 → 0.7.97

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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/README.md +39 -9
  3. package/CHANGELOG.md +23 -1
  4. package/README.md +147 -257
  5. package/SECURITY.md +21 -12
  6. package/docs/README.md +133 -54
  7. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +24 -18
  8. package/docs/adr/ADR-0003-sqlite-only-store.md +100 -0
  9. package/docs/adr/ADR-0004-edge-identity-authentik.md +99 -0
  10. package/docs/adr/README.md +33 -0
  11. package/docs/concepts/architecture.md +262 -0
  12. package/docs/concepts/data-and-storage.md +194 -0
  13. package/docs/concepts/trust-model.md +153 -0
  14. package/docs/contracts/README.md +22 -14
  15. package/docs/contracts/effects-and-recovery.md +3 -0
  16. package/docs/contracts/migration.md +2 -2
  17. package/docs/contracts/routing.md +6 -5
  18. package/docs/contributing/assignment-runner.md +388 -0
  19. package/docs/contributing/ci-and-release.md +231 -0
  20. package/docs/contributing/development.md +362 -0
  21. package/docs/contributing/harness-routing-internals.md +192 -0
  22. package/docs/{packages.md → contributing/packages.md} +13 -15
  23. package/docs/{skills → contributing}/repo-work-delivery.md +20 -21
  24. package/docs/contributing/test-matrix.md +208 -0
  25. package/docs/{tui-components.md → contributing/tui-components.md} +30 -22
  26. package/docs/contributing/writing-docs.md +340 -0
  27. package/docs/glossary.md +471 -0
  28. package/docs/guides/agent-skills.md +137 -0
  29. package/docs/guides/browser-automation.md +160 -0
  30. package/docs/guides/context-and-memory.md +352 -0
  31. package/docs/guides/continuous-improvement.md +228 -0
  32. package/docs/guides/governed-skills.md +173 -0
  33. package/docs/guides/nous-providers.md +186 -0
  34. package/docs/guides/peer-messaging.md +304 -0
  35. package/docs/guides/pi-workers.md +219 -0
  36. package/docs/guides/provenance-gates.md +313 -0
  37. package/docs/guides/webhook-workflows.md +399 -0
  38. package/docs/kb/how-credentials-retrieved-safely.md +38 -12
  39. package/docs/kb/how-to-capture-and-annotate-section.md +15 -13
  40. package/docs/kb/how-to-connect-playwright-to-steel.md +16 -11
  41. package/docs/kb/how-to-recover-expired-session-or-orphan.md +26 -16
  42. package/docs/kb/how-to-resume-after-mfa.md +19 -11
  43. package/docs/kb/how-to-take-over-session.md +17 -13
  44. package/docs/kb/why-authentication-disappeared.md +22 -14
  45. package/docs/kb/why-automation-opened-different-browser.md +23 -14
  46. package/docs/kb/why-session-viewer-cannot-control.md +13 -12
  47. package/docs/operations/backup-and-restore.md +248 -0
  48. package/docs/operations/deploy.md +307 -0
  49. package/docs/operations/monitoring.md +209 -0
  50. package/docs/operations/runtime-sync.md +192 -0
  51. package/docs/operations/troubleshooting.md +266 -0
  52. package/docs/operations/upgrade.md +124 -0
  53. package/docs/prompts/browser-annotate-feedback.md +7 -7
  54. package/docs/prompts/browser-diagnose-recover.md +11 -10
  55. package/docs/prompts/browser-explore.md +7 -7
  56. package/docs/prompts/browser-repro-fix.md +7 -7
  57. package/docs/prompts/browser-start.md +12 -11
  58. package/docs/prompts/browser-takeover.md +8 -8
  59. package/docs/{cli-reference.md → reference/cli-reference.md} +88 -46
  60. package/docs/{config-reference.md → reference/config-reference.md} +159 -148
  61. package/docs/reference/configuration.md +299 -0
  62. package/docs/reference/harness-routing.md +508 -0
  63. package/docs/reference/http-api.md +203 -0
  64. package/docs/reference/tools.md +370 -0
  65. package/docs/{workflow-guide.md → reference/workflow-catalog.md} +92 -153
  66. package/docs/reference/workflow-definitions.md +286 -0
  67. package/docs/start/first-workflow.md +287 -0
  68. package/docs/start/install.md +146 -0
  69. package/docs/start/quickstart-claude-code.md +405 -0
  70. package/docs/start/quickstart-pi.md +213 -0
  71. package/docs/templates/README.md +78 -73
  72. package/docs/templates/adr.md +13 -13
  73. package/docs/templates/architecture.md +55 -71
  74. package/docs/templates/bug-fix.md +13 -16
  75. package/docs/templates/feature.md +14 -19
  76. package/docs/templates/handoff.md +44 -46
  77. package/docs/templates/postmortem.md +30 -43
  78. package/docs/templates/research.md +15 -20
  79. package/docs/templates/review.md +49 -50
  80. package/docs/templates/runbook.md +38 -30
  81. package/docs/templates/test-plan.md +16 -23
  82. package/docs/templates/test-report.md +14 -17
  83. package/examples/README.md +9 -5
  84. package/examples/provenance-workflow.json +1 -1
  85. package/examples/webhook-workflows/jira-development.json +59 -0
  86. package/examples/webhook-workflows/jira-issue-updated.json +12 -0
  87. package/examples/workflow-signal.ts +4 -5
  88. package/package.json +1 -1
  89. package/packages/core/tui/README.md +1 -1
  90. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  91. package/plugins/kxm/README.md +31 -32
  92. package/plugins/kxm/dist/claude-hook.js +11 -1
  93. package/plugins/kxm/dist/cli.js +164 -79
  94. package/plugins/kxm/dist/client.js +3 -1
  95. package/plugins/kxm/dist/core.js +11 -1
  96. package/plugins/kxm/dist/extension.js +45 -13
  97. package/plugins/kxm/dist/mcp-server.js +20 -4
  98. package/plugins/kxm/dist/runtime-supervisor.js +1 -3
  99. package/plugins/kxm/dist/runtime.js +18 -4
  100. package/plugins/kxm/dist/server.js +115 -20
  101. package/plugins/kxm/package.json +1 -1
  102. package/plugins/kxm/skills/kxm/references/protocol.md +3 -1
  103. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +1 -1
  104. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +5 -5
  105. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +2 -2
  106. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +10 -13
  107. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +1 -1
  108. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +1 -1
  109. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +13 -4
  110. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +3 -1
  111. package/plugins/kxm/skills/kxm-mind-setup/SKILL.md +2 -1
  112. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +31 -54
  113. package/plugins/kxm/skills/kxm-projects/SKILL.md +1 -1
  114. package/plugins/kxm/skills/kxm-protocol/SKILL.md +1 -1
  115. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +15 -7
  116. package/plugins/kxm/skills/kxm-runs/SKILL.md +11 -5
  117. package/plugins/kxm/skills/kxm-session/SKILL.md +1 -1
  118. package/plugins/kxm/skills/kxm-tasks/SKILL.md +9 -7
  119. package/plugins/kxm/skills/kxm-workflow/SKILL.md +10 -2
  120. package/plugins/kxm/src/cli/system.ts +1 -1
  121. package/plugins/kxm/src/cli/workflows.ts +12 -7
  122. package/plugins/kxm/src/cli.ts +22 -8
  123. package/plugins/kxm/src/client.ts +4 -0
  124. package/plugins/kxm/src/commands.ts +23 -1
  125. package/plugins/kxm/src/extension.ts +20 -14
  126. package/plugins/kxm/src/github-watch.ts +8 -5
  127. package/plugins/kxm/src/hub-env.ts +19 -1
  128. package/plugins/kxm/src/hub.ts +105 -21
  129. package/plugins/kxm/src/improve-sources.ts +2 -7
  130. package/plugins/kxm/src/init-guide-setup.ts +1 -1
  131. package/plugins/kxm/src/mcp-server.ts +9 -2
  132. package/plugins/kxm/src/modes.ts +1 -1
  133. package/plugins/kxm/src/runtime-store.ts +23 -0
  134. package/plugins/kxm/src/workflow.ts +70 -1
  135. package/schemas/README.md +1 -1
  136. package/scripts/smoke-multi-pi.mjs +5 -1
  137. package/docs/agent-communication-envelopes-and-gates.md +0 -553
  138. package/docs/agent-skills.md +0 -198
  139. package/docs/architecture.md +0 -245
  140. package/docs/assignment-runner.md +0 -264
  141. package/docs/browser-automation.md +0 -139
  142. package/docs/configuration.md +0 -437
  143. package/docs/continuous-improvement.md +0 -226
  144. package/docs/getting-started.md +0 -277
  145. package/docs/harness-routing.md +0 -616
  146. package/docs/kb/qa-authentik-authentication.md +0 -97
  147. package/docs/kb/qa-extension-install-and-hub-bootstrap.md +0 -85
  148. package/docs/kb/qa-hub-on-a-public-host.md +0 -48
  149. package/docs/kb/qa-sqlite-vs-duckdb.md +0 -35
  150. package/docs/kb/qa-what-the-hub-stores.md +0 -64
  151. package/docs/kxm-handbook.md +0 -1181
  152. package/docs/operations.md +0 -510
  153. package/docs/operator-pi-packages.md +0 -67
  154. package/docs/provenance-gates.md +0 -295
  155. package/docs/skills.md +0 -47
  156. package/docs/test-matrix.md +0 -132
  157. package/docs/troubleshooting.md +0 -322
  158. package/docs/webhook-workflows.md +0 -240
@@ -18,7 +18,7 @@ import { Command, CommanderError } from "commander";
18
18
  import { readInstalledKxmVersion } from "./kxm-update.ts";
19
19
  import { findKxmRepoRoot } from "./repo-root.ts";
20
20
  import { HubClient } from "./client.ts";
21
- import { resolveClientHubAuthToken } from "./hub-env.ts";
21
+ import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
22
22
  import { defaultProjectName } from "./project-name.ts";
23
23
  import {
24
24
  AGENT_COMMANDS_MAP,
@@ -27,6 +27,7 @@ import {
27
27
  import { discoverKxmProjectRoot } from "./project-config.ts";
28
28
  import { JOURNAL_CATEGORIES } from "./workflow.ts";
29
29
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts";
30
+ import { projectRuntimeOwnsRun } from "./runtime-store.ts";
30
31
 
31
32
  // Submodule imports
32
33
  import {
@@ -234,14 +235,17 @@ async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
234
235
  const project = defaultProjectName(runtime.cwd, runtime.env);
235
236
  const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
236
237
  const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
237
- const authToken = resolveClientHubAuthToken(runtime.env, project);
238
+ // These commands act as a peer agent, so they take the agent credential: never the
239
+ // persisted admin token, which the hub accepts for any project without its own token.
240
+ const authToken = resolveAgentHubAuthToken(runtime.env, project);
241
+ if (!authToken) throw new AgentProjectTokenMissingError(project);
238
242
  const client = new HubClient({
239
243
  serverUrl,
240
244
  name,
241
245
  project,
242
246
  purpose,
243
247
  fetchImpl: runtime.fetchImpl,
244
- ...(authToken ? { authToken } : {}),
248
+ authToken,
245
249
  });
246
250
  await client.start(() => {});
247
251
  return client;
@@ -308,11 +312,12 @@ async function dispatchAgentCliCommand(
308
312
  }
309
313
  }
310
314
 
311
- // Handle KXM run binding for workflow wait
315
+ // A workflow wait on a run this project's Runtime owns goes to the Runtime; any other
316
+ // run id, including a hub workflow run of the same shape, goes to the hub.
312
317
  if (toolName === "kxm_workflow_wait") {
313
318
  const runId = typeof args.runId === "string" ? args.runId : undefined;
314
319
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
315
- if (runId && projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
320
+ if (runId && projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
316
321
  if (runtime.dryRun) {
317
322
  print(runtime.io, runtime.json, { ok: true, command: "workflow wait", runId, dryRun: true }, `would wait for signal on KXM run ${runId}`);
318
323
  return 0;
@@ -349,6 +354,15 @@ async function dispatchAgentCliCommand(
349
354
  return 0;
350
355
  } catch (error) {
351
356
  const message = error instanceof Error ? error.message : String(error);
357
+ if (error instanceof AgentProjectTokenMissingError) {
358
+ print(
359
+ runtime.io,
360
+ runtime.json,
361
+ { ok: false, error: error.code, project: error.project, nextAction: "export_kxm_auth_token", detail: message },
362
+ message,
363
+ );
364
+ return 2;
365
+ }
352
366
  print(runtime.io, runtime.json, { ok: false, error: "command_failed", detail: message }, `command failed: ${message}`);
353
367
  return 1;
354
368
  } finally {
@@ -402,7 +416,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
402
416
  });
403
417
  });
404
418
 
405
- addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of all stores with a hashed manifest"))
419
+ addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store with a hashed manifest (Runtime stores under the user state root are not included)"))
406
420
  .option("--out <dir>", "Directory to write backup and manifest")
407
421
  .action(async function backupAction(this: Command, options: { out?: string }) {
408
422
  result.code = await cmdBackup(runtimeFrom(ctx, this), options);
@@ -415,7 +429,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
415
429
 
416
430
  addGlobalOptions(program.command("run").description("Create a KXM run (offline-first; kxm runs drive <runId> --simulated executes it model-free)")
417
431
  .argument("[workflow]", "Workflow id to run")
418
- .argument("[prompt...]", "Run prompt (hashed, never stored raw)")
432
+ .argument("[prompt...]", "Run prompt (events keep its hash; the full text is kept in a local 0600 sidecar file)")
419
433
  .action(async function runAction(this: Command, workflow: string | undefined, promptParts: string[]) {
420
434
  result.code = await cmdKxmRun(runtimeFrom(ctx, this), workflow, promptParts);
421
435
  }));
@@ -426,7 +440,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
426
440
  .action(async function runStatusAction(this: Command, runId: string) {
427
441
  result.code = await cmdKxmRunStatus(runtimeFrom(ctx, this), runId);
428
442
  });
429
- addGlobalOptions(runCmd.command("drive").description("Drive a run with an explicit model-free simulation"))
443
+ addGlobalOptions(runCmd.command("drive").description("Drive a run with live harness calls, or with the model-free simulation when --simulated is passed"))
430
444
  .argument("<runId>", "Run id")
431
445
  .option("--simulated", "Use the model-free simulation producer")
432
446
  .option("--wait", "Wait until a drive receipt is recorded; exits 0 only for a VERIFIED COMPLETED settlement")
@@ -259,6 +259,8 @@ export class HubClient {
259
259
  ttlMs?: number;
260
260
  timeoutMs?: number;
261
261
  signal?: AbortSignal;
262
+ hops?: number;
263
+ maxHops?: number;
262
264
  }): Promise<FanoutResult[]> {
263
265
  const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))];
264
266
  if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets");
@@ -280,6 +282,8 @@ export class HubClient {
280
282
  ),
281
283
  } : {}),
282
284
  ...(options.ttlMs ? { ttlMs: options.ttlMs } : {}),
285
+ ...(options.hops !== undefined ? { hops: options.hops } : {}),
286
+ ...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}),
283
287
  });
284
288
  const completed = await this.awaitResponse(
285
289
  message.id,
@@ -74,6 +74,26 @@ export interface CommandExecutionContext {
74
74
  signal?: AbortSignal | undefined;
75
75
  inbox?: Map<string, MessageRecord> | undefined;
76
76
  notifiedInbox?: Set<string> | undefined;
77
+ /** Inbound requests this session is handling: the Pi extension's active request, the MCP
78
+ * server's open inbox. A request sent meanwhile is one more hop along their chain. */
79
+ handling?: readonly Pick<MessageRecord, "hops" | "maxHops">[] | undefined;
80
+ }
81
+
82
+ /**
83
+ * Hop fields for a request sent while handling inbound work: one hop past the furthest
84
+ * handled request, under the tightest limit among them, so the hub's `hop_limit_reached`
85
+ * refusal bounds a chain of agents forwarding to each other. Handling nothing starts a new
86
+ * chain with the hub defaults. When several requests are open the furthest one counts, so a
87
+ * forwarding loop cannot reset its count because an unrelated request arrived beside it.
88
+ */
89
+ export function forwardedHops(
90
+ handling: CommandExecutionContext["handling"],
91
+ ): { hops: number; maxHops: number } | undefined {
92
+ if (!handling?.length) return undefined;
93
+ return {
94
+ hops: Math.max(...handling.map((message) => message.hops)) + 1,
95
+ maxHops: Math.min(...handling.map((message) => message.maxHops)),
96
+ };
77
97
  }
78
98
 
79
99
  export interface AgentCommand {
@@ -260,12 +280,13 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
260
280
  required: ["target", "content"],
261
281
  additionalProperties: false,
262
282
  },
263
- async execute(client, args) {
283
+ async execute(client, args, context) {
264
284
  const delivery = optionalString(args.delivery) as DeliveryMode | undefined;
265
285
  const correlationId = optionalString(args.correlationId);
266
286
  const idempotencyKey = optionalString(args.idempotencyKey);
267
287
  const workflowContext = optionalWorkflowContext(args.workflowContext);
268
288
  const message = await client.send({
289
+ ...forwardedHops(context?.handling),
269
290
  target: requiredString(args.target, "target"),
270
291
  content: requiredString(args.content, "content"),
271
292
  ...(delivery ? { delivery } : {}),
@@ -346,6 +367,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
346
367
  : [];
347
368
  return {
348
369
  responses: await client.fanout({
370
+ ...forwardedHops(context?.handling),
349
371
  targets,
350
372
  content: requiredString(args.content, "content"),
351
373
  ...(optionalString(args.correlationId) ? { correlationId: optionalString(args.correlationId)! } : {}),
@@ -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);
@@ -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. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Interactive post-init setup for workflow-guide agents and workflows.
3
3
  *
4
- * Source of truth: `docs/workflow-guide.md`. The catalog below transcribes the
4
+ * Source of truth: `docs/reference/workflow-catalog.md`. The catalog below transcribes the
5
5
  * software-engineering area (workflows, stages, role slugs, and the guide's
6
6
  * ordered candidate lists). Guide candidates are dated research; each role's
7
7
  * first candidate whose harness is installed AND authenticated wins. No
@@ -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.95";
14
+ const VERSION = "0.7.97";
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
- const result = await cmd.execute(client, args, { signal: extra.signal, inbox, notifiedInbox });
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 {
@@ -99,7 +99,7 @@ export const DEFAULT_MODES_CONFIG: ModesConfig = Object.freeze({
99
99
  browser: {
100
100
  description: "Web application exploration, screenshotting, and UI testing",
101
101
  baseTools: ["read", "bash"],
102
- contextFiles: ["docs/browser-automation.md"],
102
+ contextFiles: ["docs/guides/browser-automation.md"],
103
103
  thinkingLevel: "medium" as const,
104
104
  model: "grok/grok-4.6",
105
105
  },
@@ -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 });