@kontextmind/kxm 0.7.99 → 0.7.101

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.99",
3
+ "version": "0.7.101",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -81,10 +81,10 @@ kxm workflow record run_12345 decision "Serialize the fixture setup" --stage-id
81
81
  does not read project `.kxm/workflows/*.yaml`; validate those with
82
82
  `kxm init --dry-run --json`.
83
83
 
84
- `kxm gate signal` on a KXM run ID (`run_` plus 32 hex digits) goes to the
85
- Runtime and passes `--recovery-action` through; on a hub webhook run it posts a
86
- signed callback that needs `KXM_WORKFLOW_ID` and the definition's signal
87
- secret. Reuse `--delivery-id` to retry one unchanged callback.
84
+ `kxm gate signal` on a run this project's Runtime store holds goes to the
85
+ Runtime and passes `--recovery-action` through; on a hub webhook run, including
86
+ one with the same `run_` + 32-hex shape, it posts a signed callback that needs
87
+ `KXM_WORKFLOW_ID` and the definition's signal secret. Reuse `--delivery-id` to retry one unchanged callback.
88
88
 
89
89
  ```bash
90
90
  kxm gate validate --file workflows.json --json
@@ -19,6 +19,7 @@ import {
19
19
  } from "../role.ts";
20
20
  import { discoverKxmProjectRoot } from "../project-config.ts";
21
21
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
22
+ import { projectRuntimeOwnsRun } from "../runtime-store.ts";
22
23
  import { resumeWorkflowFromRuling, type WorkflowRun } from "../workflow.ts";
23
24
  import { print, printPlan, type CliIo, type Runtime } from "./types.ts";
24
25
 
@@ -547,9 +548,9 @@ export async function cmdRoleResume(
547
548
 
548
549
  const effectiveRuling = ruling?.trim() || "operator_ruling: waived and resumed";
549
550
 
550
- // Check if it's a KXM run
551
+ // A run this project's Runtime owns; a hub workflow run of the same id shape falls through.
551
552
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
552
- if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
553
+ if (projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
553
554
  if (runtime.dryRun) {
554
555
  printPlan(
555
556
  runtime,
@@ -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,
@@ -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.99";
14
+ const VERSION = "0.7.101";
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[] = [];
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { DatabaseSync } from "./sqlite.ts";
4
+ import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
5
5
  import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
6
6
  import { kxmUserStateRoot } from "./bindings.ts";
7
7
  import { deriveKxmSyncEvent, kxmSyncEventBytes, KxmSyncRedactor } from "./sync-transform.ts";
@@ -59,13 +59,13 @@ export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.Process
59
59
  /**
60
60
  * Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
61
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.
62
+ * store instead of guessing from the id. Read-only and leaves no sidecars, so a dry run
63
+ * can ask; a project whose Runtime never ran owns no runs.
64
64
  */
65
65
  export function projectRuntimeOwnsRun(projectRoot: string, runId: string, env: NodeJS.ProcessEnv): boolean {
66
66
  const path = kxmProjectRunEventsPath(projectRoot, env);
67
67
  if (!existsSync(path)) return false;
68
- const database = new DatabaseSync(path, { readOnly: true });
68
+ const database = openReadOnlyDatabase(path);
69
69
  try {
70
70
  database.exec("PRAGMA busy_timeout = 5000");
71
71
  return database.prepare("SELECT 1 FROM runs WHERE run_id = ?").get(runId) !== undefined;