@kontextmind/kxm 0.7.98 → 0.7.100

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.
@@ -16611,10 +16611,13 @@ function readResource(registry, root, file, logicalPath, kind, id, containmentRo
16611
16611
  parent = dirname2(parent);
16612
16612
  }
16613
16613
  if (!stat.isFile()) fail2("path", "resource_not_file", label, "configuration resource must be a regular file");
16614
- const value = parseRestrictedYaml2(readFileSync(file), label);
16614
+ return { kind, ...id === void 0 ? {} : { id }, file, logicalPath, value: resourceValue(registry, readFileSync(file), label, kind) };
16615
+ }
16616
+ function resourceValue(registry, input, label, kind) {
16617
+ const value = parseRestrictedYaml2(input, label);
16615
16618
  const issues = registry.validate(kind, value, label);
16616
16619
  if (issues.length > 0) throw new KxmConfigError(issues);
16617
- return { kind, ...id === void 0 ? {} : { id }, file, logicalPath, value };
16620
+ return value;
16618
16621
  }
16619
16622
  function readTemplateProvenance(registry, root) {
16620
16623
  const file = join2(root, ".kxm", "template-provenance.yaml");
@@ -16663,7 +16666,7 @@ function readTemplateProvenance(registry, root) {
16663
16666
  }
16664
16667
  return value;
16665
16668
  }
16666
- function listNamedResources(registry, root, directory, logicalDirectory, kind) {
16669
+ function listNamedResources(registry, root, directory, logicalDirectory, kind, replacedId) {
16667
16670
  const resources = /* @__PURE__ */ new Map();
16668
16671
  if (!existsSync3(directory)) return resources;
16669
16672
  const stat = lstatSync(directory);
@@ -16684,6 +16687,7 @@ function listNamedResources(registry, root, directory, logicalDirectory, kind) {
16684
16687
  }
16685
16688
  const id = basename(entry.name, ".yaml");
16686
16689
  if (kind === "model" && id === "inventory") continue;
16690
+ if (id === replacedId) continue;
16687
16691
  if (!resourceIdentifier(id)) {
16688
16692
  issues.push(issue2("path", "resource_id_invalid", displayPath(root, join2(directory, entry.name)), `filename-derived identity ${id} is invalid or platform-reserved`));
16689
16693
  continue;
@@ -17284,6 +17288,9 @@ function assertNoRegisteredGates(options) {
17284
17288
  }
17285
17289
  }
17286
17290
  function loadKxmProject(projectRoot, options = {}) {
17291
+ return loadProjectBundle(projectRoot, options);
17292
+ }
17293
+ function loadProjectBundle(projectRoot, options, workflowCandidate) {
17287
17294
  assertNoRegisteredGates(options);
17288
17295
  const root = resolve(projectRoot);
17289
17296
  const legacyPresent = legacyConfigFilesAt(root);
@@ -17317,7 +17324,15 @@ function loadKxmProject(projectRoot, options = {}) {
17317
17324
  if (earlyIssues.length > 0) throw new KxmConfigError(earlyIssues);
17318
17325
  const agents = listNamedResources(registry, root, join2(root, ".kxm", "agents"), ".kxm/agents", "agent");
17319
17326
  const models = listNamedResources(registry, root, join2(root, ".kxm", "models"), ".kxm/models", "model");
17320
- const workflows = listNamedResources(registry, root, join2(root, ".kxm", "workflows"), ".kxm/workflows", "workflow");
17327
+ const workflows = listNamedResources(registry, root, join2(root, ".kxm", "workflows"), ".kxm/workflows", "workflow", workflowCandidate?.id);
17328
+ if (workflowCandidate) {
17329
+ const { id, document } = workflowCandidate;
17330
+ const logicalPath = `.kxm/workflows/${id}.yaml`;
17331
+ if (!resourceIdentifier(id)) fail2("path", "resource_id_invalid", logicalPath, `filename-derived identity ${id} is invalid or platform-reserved`);
17332
+ const collision = [...workflows.keys()].find((candidate) => candidate.toLocaleLowerCase("en-US") === id.toLocaleLowerCase("en-US"));
17333
+ if (collision) fail2("path", "resource_id_collision", logicalPath, `${id} case-folds to existing ${collision}`);
17334
+ workflows.set(id, { kind: "workflow", id, file: join2(root, logicalPath), logicalPath, value: resourceValue(registry, document, logicalPath, "workflow") });
17335
+ }
17321
17336
  const gatePath = join2(root, ".kxm", "gates.yaml");
17322
17337
  const gateRegistry = existsSync3(gatePath) ? readResource(registry, root, gatePath, ".kxm/gates.yaml", "gate-registry") : void 0;
17323
17338
  const environments = [];
@@ -16781,10 +16781,13 @@ function readResource(registry, root, file, logicalPath, kind, id, containmentRo
16781
16781
  parent = dirname2(parent);
16782
16782
  }
16783
16783
  if (!stat.isFile()) fail2("path", "resource_not_file", label, "configuration resource must be a regular file");
16784
- const value = parseRestrictedYaml2(readFileSync(file), label);
16784
+ return { kind, ...id === void 0 ? {} : { id }, file, logicalPath, value: resourceValue(registry, readFileSync(file), label, kind) };
16785
+ }
16786
+ function resourceValue(registry, input, label, kind) {
16787
+ const value = parseRestrictedYaml2(input, label);
16785
16788
  const issues = registry.validate(kind, value, label);
16786
16789
  if (issues.length > 0) throw new KxmConfigError(issues);
16787
- return { kind, ...id === void 0 ? {} : { id }, file, logicalPath, value };
16790
+ return value;
16788
16791
  }
16789
16792
  function readTemplateProvenance(registry, root) {
16790
16793
  const file = join2(root, ".kxm", "template-provenance.yaml");
@@ -16833,7 +16836,7 @@ function readTemplateProvenance(registry, root) {
16833
16836
  }
16834
16837
  return value;
16835
16838
  }
16836
- function listNamedResources(registry, root, directory, logicalDirectory, kind) {
16839
+ function listNamedResources(registry, root, directory, logicalDirectory, kind, replacedId) {
16837
16840
  const resources = /* @__PURE__ */ new Map();
16838
16841
  if (!existsSync3(directory)) return resources;
16839
16842
  const stat = lstatSync(directory);
@@ -16854,6 +16857,7 @@ function listNamedResources(registry, root, directory, logicalDirectory, kind) {
16854
16857
  }
16855
16858
  const id = basename(entry.name, ".yaml");
16856
16859
  if (kind === "model" && id === "inventory") continue;
16860
+ if (id === replacedId) continue;
16857
16861
  if (!resourceIdentifier(id)) {
16858
16862
  issues.push(issue2("path", "resource_id_invalid", displayPath(root, join2(directory, entry.name)), `filename-derived identity ${id} is invalid or platform-reserved`));
16859
16863
  continue;
@@ -17454,6 +17458,9 @@ function assertNoRegisteredGates(options) {
17454
17458
  }
17455
17459
  }
17456
17460
  function loadKxmProject(projectRoot, options = {}) {
17461
+ return loadProjectBundle(projectRoot, options);
17462
+ }
17463
+ function loadProjectBundle(projectRoot, options, workflowCandidate) {
17457
17464
  assertNoRegisteredGates(options);
17458
17465
  const root = resolve(projectRoot);
17459
17466
  const legacyPresent = legacyConfigFilesAt(root);
@@ -17487,7 +17494,15 @@ function loadKxmProject(projectRoot, options = {}) {
17487
17494
  if (earlyIssues.length > 0) throw new KxmConfigError(earlyIssues);
17488
17495
  const agents = listNamedResources(registry, root, join2(root, ".kxm", "agents"), ".kxm/agents", "agent");
17489
17496
  const models = listNamedResources(registry, root, join2(root, ".kxm", "models"), ".kxm/models", "model");
17490
- const workflows = listNamedResources(registry, root, join2(root, ".kxm", "workflows"), ".kxm/workflows", "workflow");
17497
+ const workflows = listNamedResources(registry, root, join2(root, ".kxm", "workflows"), ".kxm/workflows", "workflow", workflowCandidate?.id);
17498
+ if (workflowCandidate) {
17499
+ const { id, document } = workflowCandidate;
17500
+ const logicalPath = `.kxm/workflows/${id}.yaml`;
17501
+ if (!resourceIdentifier(id)) fail2("path", "resource_id_invalid", logicalPath, `filename-derived identity ${id} is invalid or platform-reserved`);
17502
+ const collision = [...workflows.keys()].find((candidate) => candidate.toLocaleLowerCase("en-US") === id.toLocaleLowerCase("en-US"));
17503
+ if (collision) fail2("path", "resource_id_collision", logicalPath, `${id} case-folds to existing ${collision}`);
17504
+ workflows.set(id, { kind: "workflow", id, file: join2(root, logicalPath), logicalPath, value: resourceValue(registry, document, logicalPath, "workflow") });
17505
+ }
17491
17506
  const gatePath = join2(root, ".kxm", "gates.yaml");
17492
17507
  const gateRegistry = existsSync3(gatePath) ? readResource(registry, root, gatePath, ".kxm/gates.yaml", "gate-registry") : void 0;
17493
17508
  const environments = [];
@@ -17223,7 +17223,10 @@ var AGENT_COMMANDS = [
17223
17223
  await reconcileInbox(client, context.inbox, context.notifiedInbox);
17224
17224
  return { messages: [...context.inbox.values()] };
17225
17225
  }
17226
- return { messages: [] };
17226
+ if (context?.hubInbox) return { messages: await client.listInbox() };
17227
+ throw new Error(
17228
+ "kxm_inbox is not available in this session: it activates each inbound request as a turn, and that turn's final response is the reply"
17229
+ );
17227
17230
  }
17228
17231
  },
17229
17232
  {
@@ -22526,6 +22529,14 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
22526
22529
  json(response, 200, { agent: publicAgent(current, staleAfterMs) });
22527
22530
  return;
22528
22531
  }
22532
+ const inboxMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)\/inbox$/);
22533
+ if (method === "GET" && inboxMatch) {
22534
+ const current = requireAgent(request, decodeURIComponent(inboxMatch[1]));
22535
+ requireProjectAuth(request, current.project);
22536
+ expireMessages();
22537
+ json(response, 200, { messages: store.getPendingMessages(current.id) });
22538
+ return;
22539
+ }
22529
22540
  const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
22530
22541
  if (method === "DELETE" && agentMatch) {
22531
22542
  const current = requireAgent(request, decodeURIComponent(agentMatch[1]));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.98",
3
+ "version": "0.7.100",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -42,9 +42,11 @@ reading of its heartbeat lease: `online` holds the lease, `stale` has passed
42
42
  peer the hub has retired. Offline peers are listed only with
43
43
  `--include-offline`. The host label is a reading aid, never a permission.
44
44
 
45
- `kxm peer inbox` from a one-shot CLI call always returns an empty list,
46
- because the inbox lives in a long-running harness session. Use `kxm_inbox` in
47
- that session or `kxm dash --screen inbox`.
45
+ `kxm peer inbox` lists the requests addressed to the CLI agent's own name, so
46
+ run it with a stable `KXM_AGENT_NAME` and answer each with `kxm peer reply`
47
+ under that name. The default `cli-<pid>` is a new agent on every call, and its
48
+ inbox is always empty. In Claude Code, `kxm_inbox` lists the session's inbox; a
49
+ Pi session receives each request as a turn instead, and its `kxm_inbox` refuses.
48
50
 
49
51
  ## Examples
50
52
 
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { basename, join, resolve } from "node:path";
4
+ import { stringify } from "yaml";
4
5
  import { openReadOnlyDatabase } from "../sqlite.ts";
5
6
  import { buildRetrospective, writeRetrospective } from "../retrospective.ts";
6
7
  import { redactSecrets } from "../redact.ts";
@@ -10,6 +11,7 @@ import {
10
11
  addWorkflowDefinition,
11
12
  removeWorkflowDefinition,
12
13
  modifyWorkflowDefinition,
14
+ parseWorkflowFile,
13
15
  scaffoldWorkflowDefinition,
14
16
  WORKFLOW_TEMPLATES,
15
17
  } from "../workflow-manager.ts";
@@ -21,7 +23,7 @@ import {
21
23
  type WorkflowJournalEntry,
22
24
  type WorkflowRun,
23
25
  } from "../workflow.ts";
24
- import { discoverKxmProjectRoot } from "../project-config.ts";
26
+ import { discoverKxmProjectRoot, kxmWorkflowWriteIssues } from "../project-config.ts";
25
27
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
26
28
  import { projectRuntimeOwnsRun } from "../runtime-store.ts";
27
29
  import type { WorkerOutcome } from "../envelope.ts";
@@ -213,12 +215,12 @@ export async function cmdWorkflowAdd(
213
215
  },
214
216
  ): Promise<number> {
215
217
  const scope = options.scope ?? "local";
218
+ const refuse = (error: string, text: string): number => {
219
+ print(runtime.io, runtime.json, { ok: false, command: "workflow add", error }, `workflow add failed: ${text}`);
220
+ return 2;
221
+ };
216
222
  let content: Record<string, unknown> | string | undefined;
217
223
  if (options.template !== undefined) {
218
- const refuse = (error: string, text: string): number => {
219
- print(runtime.io, runtime.json, { ok: false, command: "workflow add", error }, `workflow add failed: ${text}`);
220
- return 2;
221
- };
222
224
  if (options.file !== undefined || options.pick !== undefined) {
223
225
  return refuse("workflow_add_conflict", "--template cannot be combined with --file or --pick");
224
226
  }
@@ -238,8 +240,10 @@ export async function cmdWorkflowAdd(
238
240
  if (scope === "local") {
239
241
  const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
240
242
  for (const gd of globalDefs) {
241
- if (!candidates.some((c) => c.id === gd.id)) {
242
- candidates.push({ id: gd.id, description: gd.description, label: "global" });
243
+ // Read the listed file itself: it may be `.yml`, which a lookup by `<id>.yaml` misses.
244
+ const definition = parseWorkflowFile(gd.filePath);
245
+ if (definition && !candidates.some((c) => c.id === gd.id)) {
246
+ candidates.push({ id: gd.id, description: gd.description, label: "global", payload: definition });
243
247
  }
244
248
  }
245
249
  }
@@ -268,9 +272,30 @@ export async function cmdWorkflowAdd(
268
272
  }
269
273
 
270
274
  try {
271
- const res = addWorkflowDefinition(workflowId!, content, {
275
+ const document = typeof content === "string" ? content : stringify(content);
276
+ // A local workflow is read by the project loader, which refuses the whole
277
+ // project over one bad file, so it is checked by that loader before it lands.
278
+ let repoRoot = runtime.cwd;
279
+ if (scope === "local") {
280
+ const projectRoot = discoverKxmProjectRoot(runtime.cwd);
281
+ if (!projectRoot) {
282
+ return refuse("project_not_found", "local workflows belong to a KXM project; run kxm init at the repository root, or pass --scope global");
283
+ }
284
+ const issues = kxmWorkflowWriteIssues(projectRoot, workflowId!, document);
285
+ if (issues.length > 0) {
286
+ print(
287
+ runtime.io,
288
+ runtime.json,
289
+ { ok: false, command: "workflow add", error: "workflow_invalid", issues },
290
+ `workflow add failed: with this workflow the project would not load, so nothing was written\n${issues.map((entry) => ` ${entry.file}: ${entry.code}: ${entry.message}`).join("\n")}`,
291
+ );
292
+ return 2;
293
+ }
294
+ repoRoot = projectRoot;
295
+ }
296
+ const res = addWorkflowDefinition(workflowId!, document, {
272
297
  scope,
273
- repoRoot: runtime.cwd,
298
+ repoRoot,
274
299
  userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
275
300
  overwrite: options.overwrite,
276
301
  dryRun: runtime.dryRun,
@@ -349,7 +349,9 @@ async function dispatchAgentCliCommand(
349
349
  let client: HubClient | undefined;
350
350
  try {
351
351
  client = await ensureCliClient(runtime);
352
- const output = await cmd.execute(client, args);
352
+ // A one-shot call keeps no inbox, so `peer inbox` reads this agent's open requests from
353
+ // the hub; with a stable KXM_AGENT_NAME those include ones queued while it was offline.
354
+ const output = await cmd.execute(client, args, { hubInbox: true });
353
355
  const payload = (output && typeof output === "object" ? output : { result: output }) as object;
354
356
  print(runtime.io, runtime.json, payload, JSON.stringify(output, null, 2));
355
357
  return 0;
@@ -325,6 +325,16 @@ export class HubClient {
325
325
  }));
326
326
  }
327
327
 
328
+ /** This agent's open inbound requests (queued or delivered), oldest first. A read: nothing
329
+ * is acknowledged. */
330
+ async listInbox(): Promise<MessageRecord[]> {
331
+ if (!this.agent) throw new Error("hub client is not registered");
332
+ const result = await this.request<{ messages: MessageRecord[] }>(
333
+ `/v1/agents/${encodeURIComponent(this.agent.id)}/inbox`,
334
+ );
335
+ return result.messages;
336
+ }
337
+
328
338
  async getMessage(messageId: string): Promise<MessageRecord> {
329
339
  const result = await this.request<{ message: MessageRecord }>(`/v1/messages/${encodeURIComponent(messageId)}`);
330
340
  return result.message;
@@ -72,8 +72,12 @@ export interface SessionTokenPayload {
72
72
 
73
73
  export interface CommandExecutionContext {
74
74
  signal?: AbortSignal | undefined;
75
+ /** An event-fed inbox the caller keeps (the MCP server); `kxm_inbox` reconciles it. */
75
76
  inbox?: Map<string, MessageRecord> | undefined;
76
77
  notifiedInbox?: Set<string> | undefined;
78
+ /** The caller keeps no inbox and reads its open requests from the hub (a one-shot CLI
79
+ * call). A caller with neither this nor `inbox` activates inbound requests itself (Pi). */
80
+ hubInbox?: boolean | undefined;
77
81
  /** Inbound requests this session is handling: the Pi extension's active request, the MCP
78
82
  * server's open inbox. A request sent meanwhile is one more hop along their chain. */
79
83
  handling?: readonly Pick<MessageRecord, "hops" | "maxHops">[] | undefined;
@@ -452,7 +456,12 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
452
456
  await reconcileInbox(client, context.inbox, context.notifiedInbox);
453
457
  return { messages: [...context.inbox.values()] };
454
458
  }
455
- return { messages: [] };
459
+ if (context?.hubInbox) return { messages: await client.listInbox() };
460
+ // Listing would offer requests this session's own activation queue is about to hand
461
+ // it as turns, and an empty list would hide them, so it refuses.
462
+ throw new Error(
463
+ "kxm_inbox is not available in this session: it activates each inbound request as a turn, and that turn's final response is the reply",
464
+ );
456
465
  },
457
466
  },
458
467
  {
@@ -898,6 +898,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
898
898
  throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
899
899
  }
900
900
  // A request sent while handling the active inbound request continues its hop chain.
901
+ // No inbox and no hubInbox: `pending` owns activation, so kxm_inbox refuses here.
901
902
  const handling = activeInbound ? [activeInbound] : [];
902
903
  return result(
903
904
  await workflowCall(() =>
@@ -2520,6 +2520,18 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2520
2520
  return;
2521
2521
  }
2522
2522
 
2523
+ // An agent's open inbound requests, queued or delivered, in delivery order. A read
2524
+ // for a caller that holds no event-fed inbox of its own (a one-shot CLI call): it
2525
+ // acknowledges nothing and moves no cursor, so push delivery is unchanged.
2526
+ const inboxMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)\/inbox$/);
2527
+ if (method === "GET" && inboxMatch) {
2528
+ const current = requireAgent(request, decodeURIComponent(inboxMatch[1]!));
2529
+ requireProjectAuth(request, current.project);
2530
+ expireMessages();
2531
+ json(response, 200, { messages: store.getPendingMessages(current.id) });
2532
+ return;
2533
+ }
2534
+
2523
2535
  const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
2524
2536
  if (method === "DELETE" && agentMatch) {
2525
2537
  const current = requireAgent(request, decodeURIComponent(agentMatch[1]!));
@@ -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.98";
14
+ const VERSION = "0.7.100";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -453,10 +453,14 @@ function readResource(
453
453
  parent = dirname(parent);
454
454
  }
455
455
  if (!stat.isFile()) fail("path", "resource_not_file", label, "configuration resource must be a regular file");
456
- const value = parseRestrictedYaml(readFileSync(file), label);
456
+ return { kind, ...(id === undefined ? {} : { id }), file, logicalPath, value: resourceValue(registry, readFileSync(file), label, kind) };
457
+ }
458
+
459
+ function resourceValue(registry: KxmSchemaRegistry, input: string | Uint8Array, label: string, kind: KxmResourceKind): JsonObject {
460
+ const value = parseRestrictedYaml(input, label);
457
461
  const issues = registry.validate(kind, value, label);
458
462
  if (issues.length > 0) throw new KxmConfigError(issues);
459
- return { kind, ...(id === undefined ? {} : { id }), file, logicalPath, value };
463
+ return value;
460
464
  }
461
465
 
462
466
  function readTemplateProvenance(registry: KxmSchemaRegistry, root: string): JsonObject | undefined {
@@ -513,6 +517,7 @@ function listNamedResources(
513
517
  directory: string,
514
518
  logicalDirectory: string,
515
519
  kind: "agent" | "model" | "workflow",
520
+ replacedId?: string,
516
521
  ): Map<string, KxmResource> {
517
522
  const resources = new Map<string, KxmResource>();
518
523
  if (!existsSync(directory)) return resources;
@@ -534,6 +539,7 @@ function listNamedResources(
534
539
  }
535
540
  const id = basename(entry.name, ".yaml");
536
541
  if (kind === "model" && id === "inventory") continue;
542
+ if (id === replacedId) continue;
537
543
  if (!resourceIdentifier(id)) {
538
544
  issues.push(issue("path", "resource_id_invalid", displayPath(root, join(directory, entry.name)), `filename-derived identity ${id} is invalid or platform-reserved`));
539
545
  continue;
@@ -1284,6 +1290,34 @@ export function assertNoRegisteredGates(options: object): void {
1284
1290
 
1285
1291
  /** Load and semantically validate one complete, path-derived KXM configuration bundle. */
1286
1292
  export function loadKxmProject(projectRoot: string, options: KxmConfigOptions = {}): KxmProjectBundle {
1293
+ return loadProjectBundle(projectRoot, options);
1294
+ }
1295
+
1296
+ /**
1297
+ * What `loadKxmProject` would refuse once `.kxm/workflows/<workflowId>.yaml` held
1298
+ * `document`, found without writing it: the same parser, schema and bundle rules,
1299
+ * with `document` standing in for any file of that name.
1300
+ */
1301
+ export function kxmWorkflowWriteIssues(
1302
+ projectRoot: string,
1303
+ workflowId: string,
1304
+ document: string,
1305
+ options: KxmConfigOptions = {},
1306
+ ): readonly KxmConfigIssue[] {
1307
+ try {
1308
+ loadProjectBundle(projectRoot, options, { id: workflowId, document });
1309
+ return [];
1310
+ } catch (error) {
1311
+ if (error instanceof KxmConfigError) return error.issues;
1312
+ throw error;
1313
+ }
1314
+ }
1315
+
1316
+ function loadProjectBundle(
1317
+ projectRoot: string,
1318
+ options: KxmConfigOptions,
1319
+ workflowCandidate?: { id: string; document: string },
1320
+ ): KxmProjectBundle {
1287
1321
  assertNoRegisteredGates(options);
1288
1322
  const root = resolve(projectRoot);
1289
1323
  const legacyPresent = legacyConfigFilesAt(root);
@@ -1323,7 +1357,15 @@ export function loadKxmProject(projectRoot: string, options: KxmConfigOptions =
1323
1357
 
1324
1358
  const agents = listNamedResources(registry, root, join(root, ".kxm", "agents"), ".kxm/agents", "agent");
1325
1359
  const models = listNamedResources(registry, root, join(root, ".kxm", "models"), ".kxm/models", "model");
1326
- const workflows = listNamedResources(registry, root, join(root, ".kxm", "workflows"), ".kxm/workflows", "workflow");
1360
+ const workflows = listNamedResources(registry, root, join(root, ".kxm", "workflows"), ".kxm/workflows", "workflow", workflowCandidate?.id);
1361
+ if (workflowCandidate) {
1362
+ const { id, document } = workflowCandidate;
1363
+ const logicalPath = `.kxm/workflows/${id}.yaml`;
1364
+ if (!resourceIdentifier(id)) fail("path", "resource_id_invalid", logicalPath, `filename-derived identity ${id} is invalid or platform-reserved`);
1365
+ const collision = [...workflows.keys()].find((candidate) => candidate.toLocaleLowerCase("en-US") === id.toLocaleLowerCase("en-US"));
1366
+ if (collision) fail("path", "resource_id_collision", logicalPath, `${id} case-folds to existing ${collision}`);
1367
+ workflows.set(id, { kind: "workflow", id, file: join(root, logicalPath), logicalPath, value: resourceValue(registry, document, logicalPath, "workflow") });
1368
+ }
1327
1369
  const gatePath = join(root, ".kxm", "gates.yaml");
1328
1370
  const gateRegistry = existsSync(gatePath) ? readResource(registry, root, gatePath, ".kxm/gates.yaml", "gate-registry") : undefined;
1329
1371
  const environments: KxmResource[] = [];