@kontextmind/kxm 0.7.0 → 0.7.4

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/writer.yaml +2 -0
  3. package/docs/README.md +2 -0
  4. package/docs/agent-skills.md +5 -2
  5. package/docs/configuration.md +10 -1
  6. package/docs/getting-started.md +21 -0
  7. package/docs/operations.md +24 -0
  8. package/docs/skills/repo-work-delivery.md +5 -0
  9. package/docs/skills.md +2 -0
  10. package/docs/troubleshooting.md +22 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  13. package/plugins/kxm/dist/cli.js +2257 -439
  14. package/plugins/kxm/dist/core.js +57 -0
  15. package/plugins/kxm/dist/extension.js +40 -3
  16. package/plugins/kxm/dist/mcp-server.js +1 -1
  17. package/plugins/kxm/dist/runtime.js +140 -17
  18. package/plugins/kxm/dist/server.js +129 -4
  19. package/plugins/kxm/dist/vnext-runtime-supervisor.js +136 -13
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
  22. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
  23. package/plugins/kxm/src/autocomplete.ts +1 -1
  24. package/plugins/kxm/src/cli.ts +507 -9
  25. package/plugins/kxm/src/completion-install.ts +223 -0
  26. package/plugins/kxm/src/database.ts +1 -1
  27. package/plugins/kxm/src/external-effects.ts +1 -1
  28. package/plugins/kxm/src/hub-env.ts +193 -0
  29. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  30. package/plugins/kxm/src/local-snapshot.ts +1 -1
  31. package/plugins/kxm/src/mcp-server.ts +1 -1
  32. package/plugins/kxm/src/protocol.ts +111 -0
  33. package/plugins/kxm/src/role.ts +335 -0
  34. package/plugins/kxm/src/safety-integrity.ts +76 -0
  35. package/plugins/kxm/src/sqlite.ts +76 -0
  36. package/plugins/kxm/src/store.ts +1 -1
  37. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  38. package/plugins/kxm/src/vnext-config.ts +38 -1
  39. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  40. package/plugins/kxm/src/vnext-engine.ts +16 -0
  41. package/plugins/kxm/src/vnext-harness.ts +4 -2
  42. package/plugins/kxm/src/vnext-oneshot-process.ts +21 -4
  43. package/plugins/kxm/src/vnext-oneshot-producer.ts +18 -0
  44. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  45. package/plugins/kxm/src/workflow-tui.ts +1 -1
  46. package/plugins/kxm/src/workflow.ts +144 -0
  47. package/scripts/kxm-bump-version.mjs +146 -0
  48. package/scripts/kxm-hub.mjs +145 -3
  49. package/scripts/kxm-publish-npm.mjs +3 -1
  50. package/scripts/kxm-release-github.mjs +3 -1
  51. package/scripts/kxm.mjs +0 -0
@@ -373,3 +373,338 @@ export function modifyRole(
373
373
  writeFileSync(filePath, stringify(updated), "utf8");
374
374
  return { id: roleId, role: updated, filePath, scope };
375
375
  }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Role Seat & Host Mapping Governance (kxm.role-hosts.v1)
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export const KXM_ROLE_HOSTS_SCHEMA = "kxm.role-hosts.v1" as const;
382
+
383
+ export interface RoleSeatDefinition {
384
+ seatId: string;
385
+ description?: string | undefined;
386
+ defaultModel?: string | undefined;
387
+ defaultHost?: string | undefined;
388
+ allowedTools?: readonly string[] | undefined;
389
+ requiredEvidenceKind?: string | undefined;
390
+ }
391
+
392
+ export interface RoleSeatBinding {
393
+ model?: string | undefined;
394
+ host?: string | undefined;
395
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
396
+ }
397
+
398
+ export interface RoleHostsConfig {
399
+ schema: typeof KXM_ROLE_HOSTS_SCHEMA;
400
+ seats?: Record<string, RoleSeatBinding> | undefined;
401
+ hostProviders?: Record<string, string> | undefined;
402
+ }
403
+
404
+ export const DEFAULT_ROLE_SEATS: Record<string, RoleSeatDefinition> = {
405
+ planner: {
406
+ seatId: "planner",
407
+ description: "Architecture breakdown, requirement decomposition, and safety boundary definition.",
408
+ defaultModel: "anthropic/claude-fable-5.1",
409
+ defaultHost: "pi",
410
+ allowedTools: ["view_file", "grep_search", "find_by_name", "list_dir"],
411
+ requiredEvidenceKind: "architecture_review",
412
+ },
413
+ writer: {
414
+ seatId: "writer",
415
+ description: "Primary implementation agent. Writes code, refactors components, and authors unit tests.",
416
+ defaultModel: "x-ai/grok-4.6",
417
+ defaultHost: "grok",
418
+ allowedTools: ["view_file", "replace_file_content", "write_to_file", "run_command"],
419
+ requiredEvidenceKind: "git_diff",
420
+ },
421
+ "critic-arch": {
422
+ seatId: "critic-arch",
423
+ description: "Independent critic reviewing implementation diffs for architectural integrity.",
424
+ defaultModel: "anthropic/claude-fable-5.1",
425
+ defaultHost: "pi",
426
+ allowedTools: ["view_file", "grep_search"],
427
+ requiredEvidenceKind: "architecture_review",
428
+ },
429
+ "critic-cli": {
430
+ seatId: "critic-cli",
431
+ description: "CLI, documentation, and developer ergonomics critic.",
432
+ defaultModel: "openai/gpt-5.6-sol",
433
+ defaultHost: "pi",
434
+ allowedTools: ["view_file", "grep_search"],
435
+ requiredEvidenceKind: "cli_review",
436
+ },
437
+ verifier: {
438
+ seatId: "verifier",
439
+ description: "Deterministic gate evaluation runner.",
440
+ defaultModel: "evaluator",
441
+ defaultHost: "pi",
442
+ allowedTools: ["run_command"],
443
+ requiredEvidenceKind: "test_run",
444
+ },
445
+ };
446
+
447
+ export function roleHostsFilePath(
448
+ scope: "global" | "local",
449
+ repoRoot = process.cwd(),
450
+ userConfigDir?: string,
451
+ preferJson = false,
452
+ ): string {
453
+ const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
454
+ return join(dir, preferJson ? "role-hosts.json" : "role-hosts.yaml");
455
+ }
456
+
457
+ export function findExistingRoleHostsFile(
458
+ scope: "global" | "local",
459
+ repoRoot = process.cwd(),
460
+ userConfigDir?: string,
461
+ ): { filePath: string; exists: boolean; format: "yaml" | "json" } {
462
+ const dir = scope === "global" ? userConfigDirectory(userConfigDir) : repoConfigDirectory(repoRoot);
463
+ const yamlPath = join(dir, "role-hosts.yaml");
464
+ const ymlPath = join(dir, "role-hosts.yml");
465
+ const jsonPath = join(dir, "role-hosts.json");
466
+
467
+ if (existsSync(yamlPath)) return { filePath: yamlPath, exists: true, format: "yaml" };
468
+ if (existsSync(ymlPath)) return { filePath: ymlPath, exists: true, format: "yaml" };
469
+ if (existsSync(jsonPath)) return { filePath: jsonPath, exists: true, format: "json" };
470
+ return { filePath: yamlPath, exists: false, format: "yaml" };
471
+ }
472
+
473
+ export function parseRoleHostsFile(filePath: string): RoleHostsConfig | undefined {
474
+ if (!existsSync(filePath)) return undefined;
475
+ try {
476
+ const raw = readFileSync(filePath, "utf8");
477
+ const parsed = parse(raw) as Partial<RoleHostsConfig>;
478
+ if (!parsed || typeof parsed !== "object") return undefined;
479
+ if (parsed.schema && parsed.schema !== KXM_ROLE_HOSTS_SCHEMA) return undefined;
480
+ return {
481
+ schema: KXM_ROLE_HOSTS_SCHEMA,
482
+ seats: parsed.seats && typeof parsed.seats === "object" ? parsed.seats : {},
483
+ hostProviders: parsed.hostProviders && typeof parsed.hostProviders === "object" ? parsed.hostProviders : {},
484
+ };
485
+ } catch {
486
+ return undefined;
487
+ }
488
+ }
489
+
490
+ export function loadRoleHostsConfig(options: {
491
+ scope?: "all" | "global" | "local" | undefined;
492
+ repoRoot?: string | undefined;
493
+ userConfigDir?: string | undefined;
494
+ } = {}): { config: RoleHostsConfig; filePath?: string | undefined; scope: "local" | "global" | "default" } {
495
+ const scopeFilter = options.scope ?? "all";
496
+ const repoRoot = options.repoRoot ?? process.cwd();
497
+
498
+ let localConfig: RoleHostsConfig | undefined;
499
+ let localPath: string | undefined;
500
+ if (scopeFilter !== "global") {
501
+ const localFound = findExistingRoleHostsFile("local", repoRoot, options.userConfigDir);
502
+ if (localFound.exists) {
503
+ localConfig = parseRoleHostsFile(localFound.filePath);
504
+ localPath = localFound.filePath;
505
+ }
506
+ }
507
+
508
+ let globalConfig: RoleHostsConfig | undefined;
509
+ let globalPath: string | undefined;
510
+ if (scopeFilter !== "local") {
511
+ const globalFound = findExistingRoleHostsFile("global", repoRoot, options.userConfigDir);
512
+ if (globalFound.exists) {
513
+ globalConfig = parseRoleHostsFile(globalFound.filePath);
514
+ globalPath = globalFound.filePath;
515
+ }
516
+ }
517
+
518
+ if (scopeFilter === "local") {
519
+ return {
520
+ config: localConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
521
+ filePath: localPath,
522
+ scope: localPath ? "local" : "default",
523
+ };
524
+ }
525
+
526
+ if (scopeFilter === "global") {
527
+ return {
528
+ config: globalConfig ?? { schema: KXM_ROLE_HOSTS_SCHEMA, seats: {}, hostProviders: {} },
529
+ filePath: globalPath,
530
+ scope: globalPath ? "global" : "default",
531
+ };
532
+ }
533
+
534
+ // Merge: local seats override global seats; local hostProviders merge over global hostProviders
535
+ const mergedSeats: Record<string, RoleSeatBinding> = {
536
+ ...(globalConfig?.seats ?? {}),
537
+ ...(localConfig?.seats ?? {}),
538
+ };
539
+ const mergedHostProviders: Record<string, string> = {
540
+ ...(globalConfig?.hostProviders ?? {}),
541
+ ...(localConfig?.hostProviders ?? {}),
542
+ };
543
+
544
+ const primaryPath = localPath ?? globalPath;
545
+ const primaryScope = localPath ? "local" : (globalPath ? "global" : "default");
546
+
547
+ return {
548
+ config: {
549
+ schema: KXM_ROLE_HOSTS_SCHEMA,
550
+ seats: mergedSeats,
551
+ hostProviders: mergedHostProviders,
552
+ },
553
+ filePath: primaryPath,
554
+ scope: primaryScope,
555
+ };
556
+ }
557
+
558
+ export function saveRoleHostsConfig(
559
+ config: RoleHostsConfig,
560
+ options: {
561
+ scope?: "global" | "local" | undefined;
562
+ repoRoot?: string | undefined;
563
+ userConfigDir?: string | undefined;
564
+ format?: "yaml" | "json" | undefined;
565
+ } = {},
566
+ ): { filePath: string; scope: "global" | "local" } {
567
+ const scope = options.scope ?? "local";
568
+ const repoRoot = options.repoRoot ?? process.cwd();
569
+ const dir = scope === "global" ? userConfigDirectory(options.userConfigDir) : repoConfigDirectory(repoRoot);
570
+ if (!existsSync(dir)) {
571
+ mkdirSync(dir, { recursive: true });
572
+ }
573
+
574
+ const format = options.format ?? "yaml";
575
+ const filePath = join(dir, format === "json" ? "role-hosts.json" : "role-hosts.yaml");
576
+ const payload: RoleHostsConfig = {
577
+ schema: KXM_ROLE_HOSTS_SCHEMA,
578
+ seats: config.seats ?? {},
579
+ ...(config.hostProviders && Object.keys(config.hostProviders).length > 0 ? { hostProviders: config.hostProviders } : {}),
580
+ };
581
+
582
+ const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : stringify(payload);
583
+ writeFileSync(filePath, content, "utf8");
584
+ return { filePath, scope };
585
+ }
586
+
587
+ export function setRoleSeatHost(
588
+ seatId: string,
589
+ host: string,
590
+ options: {
591
+ model?: string | undefined;
592
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
593
+ scope?: "global" | "local" | undefined;
594
+ repoRoot?: string | undefined;
595
+ userConfigDir?: string | undefined;
596
+ format?: "yaml" | "json" | undefined;
597
+ } = {},
598
+ ): { filePath: string; seatId: string; binding: RoleSeatBinding; scope: "global" | "local" } {
599
+ const scope = options.scope ?? "local";
600
+ const current = loadRoleHostsConfig({
601
+ scope,
602
+ repoRoot: options.repoRoot,
603
+ userConfigDir: options.userConfigDir,
604
+ });
605
+
606
+ const seats = { ...(current.config.seats ?? {}) };
607
+ const existing = seats[seatId] ?? {};
608
+ const binding: RoleSeatBinding = {
609
+ ...existing,
610
+ host,
611
+ ...(options.model !== undefined ? { model: options.model } : {}),
612
+ ...(options.effort !== undefined ? { effort: options.effort } : {}),
613
+ };
614
+ seats[seatId] = binding;
615
+
616
+ const updatedConfig: RoleHostsConfig = {
617
+ ...current.config,
618
+ schema: KXM_ROLE_HOSTS_SCHEMA,
619
+ seats,
620
+ };
621
+
622
+ const saved = saveRoleHostsConfig(updatedConfig, {
623
+ scope,
624
+ repoRoot: options.repoRoot,
625
+ userConfigDir: options.userConfigDir,
626
+ format: options.format,
627
+ });
628
+
629
+ return { filePath: saved.filePath, seatId, binding, scope };
630
+ }
631
+
632
+ export function resolveRoleSeat(
633
+ seatId: string,
634
+ options: {
635
+ hostOverride?: string | undefined;
636
+ modelOverride?: string | undefined;
637
+ repoRoot?: string | undefined;
638
+ userConfigDir?: string | undefined;
639
+ } = {},
640
+ ): {
641
+ seatId: string;
642
+ host: string;
643
+ model?: string | undefined;
644
+ provider?: string | undefined;
645
+ effort?: "low" | "medium" | "high" | "xhigh" | undefined;
646
+ source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback";
647
+ } {
648
+ const hostsConfig = loadRoleHostsConfig({
649
+ scope: "all",
650
+ repoRoot: options.repoRoot,
651
+ userConfigDir: options.userConfigDir,
652
+ }).config;
653
+
654
+ const configuredSeat = hostsConfig.seats?.[seatId];
655
+ const defaultSeat = DEFAULT_ROLE_SEATS[seatId];
656
+ const roleDef = getRole(seatId, {
657
+ scope: "all",
658
+ repoRoot: options.repoRoot,
659
+ userConfigDir: options.userConfigDir,
660
+ })?.role ?? DEFAULT_ROLES[seatId];
661
+ const primaryRoster = roleDef?.roster?.[0];
662
+
663
+ // Resolve host
664
+ let host = "pi";
665
+ let source: "override" | "role-hosts" | "seat-default" | "role-roster" | "fallback" = "fallback";
666
+
667
+ if (options.hostOverride) {
668
+ host = options.hostOverride;
669
+ source = "override";
670
+ } else if (configuredSeat?.host) {
671
+ host = configuredSeat.host;
672
+ source = "role-hosts";
673
+ } else if (defaultSeat?.defaultHost) {
674
+ host = defaultSeat.defaultHost;
675
+ source = "seat-default";
676
+ } else if (primaryRoster?.harness) {
677
+ host = primaryRoster.harness;
678
+ source = "role-roster";
679
+ }
680
+
681
+ // Resolve model
682
+ let model: string | undefined;
683
+ if (options.modelOverride) {
684
+ model = options.modelOverride;
685
+ } else if (configuredSeat?.model) {
686
+ model = configuredSeat.model;
687
+ } else if (defaultSeat?.defaultModel) {
688
+ model = defaultSeat.defaultModel;
689
+ } else if (primaryRoster?.model) {
690
+ model = primaryRoster.model;
691
+ }
692
+
693
+ // Resolve provider
694
+ const provider = hostsConfig.hostProviders?.[host]
695
+ ?? roleDef?.roster?.find((r) => r.harness === host)?.provider;
696
+
697
+ // Resolve effort
698
+ const effort = configuredSeat?.effort
699
+ ?? roleDef?.roster?.find((r) => r.harness === host)?.effort;
700
+
701
+ return {
702
+ seatId,
703
+ host,
704
+ model,
705
+ provider,
706
+ effort,
707
+ source,
708
+ };
709
+ }
710
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Safety, security, process integrity, and command seatbelts.
3
+ *
4
+ * Implements deterministic safety invariants:
5
+ * - Literal blocklist intercepting destructive shell mutations (rm -rf, git reset --hard, etc.)
6
+ * - Unbypassable raw byte fidelity brake for verification gates and critic reviews (bypassing lossy compression)
7
+ * - Pinned SSH host key verification policy for headless remote execution
8
+ */
9
+
10
+ export const DESTRUCTIVE_COMMAND_PATTERNS: readonly RegExp[] = Object.freeze([
11
+ /^\s*rm\s+.*-[a-zA-Z]*r[a-zA-Z]*f/i,
12
+ /^\s*rm\s+.*-[a-zA-Z]*f[a-zA-Z]*r/i,
13
+ /^\s*rm\s+.*(-[a-zA-Z]*r[a-zA-Z]*\s+.*-[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*\s+.*-[a-zA-Z]*r[a-zA-Z]*)/i,
14
+ /^\s*git\s+reset\s+--hard/i,
15
+ /^\s*git\s+clean\s+-[a-zA-Z]*f/i,
16
+ /^\s*git\s+checkout\s+--\s+/i,
17
+ /^\s*git\s+restore\s+(\.|\*|--staged\s+(\.|\*))/i,
18
+ ]);
19
+
20
+ /**
21
+ * Literal command seatbelt: blocks destructive mutations from autonomous agents.
22
+ * Throws a descriptive error if command matches any destructive pattern.
23
+ */
24
+ export function assertCommandSeatbelt(command: string): void {
25
+ for (const pattern of DESTRUCTIVE_COMMAND_PATTERNS) {
26
+ if (pattern.test(command)) {
27
+ throw new Error(
28
+ `Command blocked by KXM safety seatbelt: "${command}". Destructive workspace mutations require explicit operator override.`,
29
+ );
30
+ }
31
+ }
32
+ }
33
+
34
+ export interface RtkBypassContext {
35
+ stepKind?: string | undefined;
36
+ agentRole?: string | undefined;
37
+ preset?: string | undefined;
38
+ }
39
+
40
+ const CRITIC_PRESETS = new Set(["critic-arch", "critic-cli", "auditor"]);
41
+ const CRITIC_ROLES = new Set(["critic-arch", "critic-cli", "auditor", "reviewer-arch", "reviewer-cli"]);
42
+
43
+ /**
44
+ * Gate and Critic RTK Brake:
45
+ * Hardcode an unbypassable check ensuring lossy command output compression (RTK)
46
+ * is strictly prohibited for:
47
+ * 1. Verification gates (stepKind === "gate")
48
+ * 2. Critic and auditor agents (critic-arch, critic-cli, auditor)
49
+ */
50
+ export function isRtkBypassRequired(context: RtkBypassContext): boolean {
51
+ if (context.stepKind === "gate") return true;
52
+ if (context.agentRole && CRITIC_ROLES.has(context.agentRole)) return true;
53
+ if (context.preset && CRITIC_PRESETS.has(context.preset)) return true;
54
+ return false;
55
+ }
56
+
57
+ const INSECURE_SSH_HOST_KEY_PATTERNS: readonly RegExp[] = Object.freeze([
58
+ /StrictHostKeyChecking=(accept-new|no|off)/i,
59
+ /UserKnownHostsFile=\/dev\/null/i,
60
+ ]);
61
+
62
+ /**
63
+ * Pinned host key verification policy for headless SSH remote execution.
64
+ * Rejects TOFU (accept-new) and disabled host key checking.
65
+ */
66
+ export function assertPinnedSshHostKeyPolicy(sshArgs: readonly string[]): void {
67
+ for (const arg of sshArgs) {
68
+ for (const pattern of INSECURE_SSH_HOST_KEY_PATTERNS) {
69
+ if (pattern.test(arg)) {
70
+ throw new Error(
71
+ `Insecure SSH host key policy rejected: "${arg}". KXM headless SSH requires pinned host keys with StrictHostKeyChecking=yes and BatchMode=yes.`,
72
+ );
73
+ }
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,76 @@
1
+ /** Runtime-portable SQLite constructor.
2
+ *
3
+ * Prefers Node's built-in `node:sqlite` (the supported runtime for the kxm
4
+ * CLI). Pi loads extensions inside its embedded Bun runtime, which does not
5
+ * provide `node:sqlite`, so fall back to Bun's `bun:sqlite` there.
6
+ *
7
+ * The two runtimes differ in one option this codebase uses: Node spells the
8
+ * flag `readOnly`, Bun spells it `readonly`. Options are normalized here so
9
+ * callers keep using the Node spelling everywhere.
10
+ */
11
+ import { createRequire } from "node:module";
12
+
13
+ export interface DatabaseSyncOptions {
14
+ readOnly?: boolean;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface StatementSync {
19
+ run(...params: unknown[]): { changes: number | bigint; lastInsertRowid: number | bigint };
20
+ get(...params: unknown[]): Record<string, unknown> | undefined;
21
+ all(...params: unknown[]): Array<Record<string, unknown>>;
22
+ }
23
+
24
+ type NativeDatabase = {
25
+ prepare(sql: string): StatementSync;
26
+ exec(sql: string): unknown;
27
+ close(): void;
28
+ };
29
+
30
+ const requireFromHere = createRequire(import.meta.url);
31
+
32
+ function loadNative(): { Ctor: new (path: string, options?: object) => NativeDatabase; bun: boolean } {
33
+ try {
34
+ const mod = requireFromHere("node:sqlite") as { DatabaseSync?: new (path: string, options?: object) => NativeDatabase };
35
+ if (mod.DatabaseSync) return { Ctor: mod.DatabaseSync, bun: false };
36
+ } catch {
37
+ // Not a Node runtime exposing node:sqlite (e.g. pi's embedded Bun).
38
+ }
39
+ try {
40
+ const mod = requireFromHere("bun:sqlite") as
41
+ | { DatabaseSync?: new (path: string, options?: object) => NativeDatabase; Database?: new (path: string, options?: object) => NativeDatabase }
42
+ | undefined;
43
+ const Ctor = mod?.DatabaseSync ?? mod?.Database;
44
+ if (Ctor) return { Ctor, bun: true };
45
+ } catch {
46
+ // No bun:sqlite either.
47
+ }
48
+ throw new Error("kxm: no supported sqlite module found (need node:sqlite or bun:sqlite)");
49
+ }
50
+
51
+ const native = loadNative();
52
+
53
+ export class DatabaseSync {
54
+ private readonly inner: NativeDatabase;
55
+
56
+ constructor(path: string, options?: DatabaseSyncOptions) {
57
+ let normalized: object | undefined = options;
58
+ if (native.bun && options) {
59
+ const { readOnly, ...rest } = options;
60
+ normalized = readOnly === undefined ? rest : { ...rest, readonly: readOnly };
61
+ }
62
+ this.inner = normalized === undefined ? new native.Ctor(path) : new native.Ctor(path, normalized);
63
+ }
64
+
65
+ prepare(sql: string): StatementSync {
66
+ return this.inner.prepare(sql);
67
+ }
68
+
69
+ exec(sql: string): unknown {
70
+ return this.inner.exec(sql);
71
+ }
72
+
73
+ close(): void {
74
+ this.inner.close();
75
+ }
76
+ }
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import type { DatabaseSync } from "node:sqlite";
2
+ import type { DatabaseSync } from "./sqlite.ts";
3
3
  import type { AgentRecord, MessageRecord } from "./protocol.ts";
4
4
  import type { ContextItem } from "./context.ts";
5
5
  import type { WorkflowJournalEntry, WorkflowRun } from "./workflow.ts";
@@ -15,7 +15,7 @@ import {
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
17
  import { homedir } from "node:os";
18
- import { DatabaseSync } from "node:sqlite";
18
+ import { DatabaseSync } from "./sqlite.ts";
19
19
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
20
20
  import {
21
21
  VnextConfigError,
@@ -1033,6 +1033,7 @@ function validateBundle(
1033
1033
  environments: readonly VnextResource[],
1034
1034
  options: VnextConfigOptions,
1035
1035
  gateRegistry?: VnextResource,
1036
+ projectRoot?: string,
1036
1037
  ): VnextConfigIssue[] {
1037
1038
  const issues: VnextConfigIssue[] = [];
1038
1039
  const entries = valuesOf(project.value, "repositories").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
@@ -1105,6 +1106,42 @@ function validateBundle(
1105
1106
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
1106
1107
  if (!workflows.has(defaultWorkflow)) issues.push(issue("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
1107
1108
  for (const workflow of workflows.values()) validateWorkflow(workflow, agents, models, repositoryIds, gates, issues);
1109
+
1110
+ if (projectRoot) {
1111
+ const writerRolePath = join(projectRoot, ".kxm", "roles", "writer.yaml");
1112
+ const implementerAgent = agents.get("implementer") ?? agents.get("writer");
1113
+ if (existsSync(writerRolePath) && implementerAgent) {
1114
+ try {
1115
+ const rawRole = parseRestrictedYaml(readFileSync(writerRolePath, "utf8"));
1116
+ const roleObj = objectValue(rawRole);
1117
+ const rosterEntries = valuesOf(roleObj ?? {}, "roster")
1118
+ .map((candidate) => objectValue(candidate))
1119
+ .filter((entry): entry is JsonObject => Boolean(entry));
1120
+ const enabledRosterModels = rosterEntries
1121
+ .filter((entry) => entry.enabled !== false)
1122
+ .map((entry) => stringValue(entry.model))
1123
+ .filter((m): m is string => Boolean(m));
1124
+
1125
+ const agentModelObj = objectValue(implementerAgent.value.model);
1126
+ const agentModelStr = stringValue(implementerAgent.value.model);
1127
+ const agentProvider = agentModelObj ? stringValue(agentModelObj.provider) : undefined;
1128
+ const agentModel = agentModelObj ? stringValue(agentModelObj.model) : agentModelStr;
1129
+ const canonicalAgentModel = agentProvider && agentModel ? `${agentProvider}/${agentModel}` : agentModel;
1130
+
1131
+ if (enabledRosterModels.length > 0 && canonicalAgentModel) {
1132
+ const matches = enabledRosterModels.some((rm) => rm === canonicalAgentModel || rm === agentModel || rm.endsWith(`/${agentModel}`));
1133
+ if (!matches) {
1134
+ issues.push(issue("semantic", "role_roster_conflicts_with_agent", ".kxm/roles/writer.yaml", `role roster in .kxm/roles/writer.yaml does not include agent model ${canonicalAgentModel} from ${implementerAgent.logicalPath}`));
1135
+ }
1136
+ }
1137
+ } catch (error) {
1138
+ if (error instanceof VnextConfigError) {
1139
+ issues.push(...error.issues);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+
1108
1145
  return sortIssues(issues);
1109
1146
  }
1110
1147
 
@@ -1306,7 +1343,7 @@ export function loadVnextProject(projectRoot: string, options: VnextConfigOption
1306
1343
  }
1307
1344
  if (loadIssues.length > 0) throw new VnextConfigError(loadIssues);
1308
1345
 
1309
- const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry);
1346
+ const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root);
1310
1347
  if (issues.length > 0) throw new VnextConfigError(issues);
1311
1348
  const resources = [project, ...repositories.values(), ...agents.values(), ...models.values(), ...workflows.values(), ...environments, ...(gateRegistry ? [gateRegistry] : [])]
1312
1349
  .sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath));
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import type { VnextGateObservationInput } from "./vnext-engine-gate-records.ts";
4
4
  import type { VnextGateErrorClass, VnextGateStopCause } from "./vnext-runtime-store.ts";
5
5
  import { runtimeError } from "./vnext-runtime-store.ts";
6
+ import { assertCommandSeatbelt } from "./safety-integrity.ts";
6
7
 
7
8
  const MAX_DIRECT_TIMER_MS = 2_147_483_647;
8
9
  export const COMMAND_TERM_GRACE_MS = 2000;
@@ -176,6 +177,7 @@ class CommandObserver implements VnextCommandObserver {
176
177
  ) {
177
178
  throw runtimeError("run_events_illegal", "command", "command timeoutMs exceeds the direct timer bound");
178
179
  }
180
+ assertCommandSeatbelt(this.definition.argv.join(" "));
179
181
  this.startedAt = new Date().toISOString();
180
182
  let child: ChildProcess;
181
183
  try {
@@ -94,6 +94,21 @@ import {
94
94
  parseRoutingRecordV2,
95
95
  behavioralConfigHash,
96
96
  } from "./routing.ts";
97
+ import {
98
+ assertCommandSeatbelt,
99
+ DESTRUCTIVE_COMMAND_PATTERNS,
100
+ isRtkBypassRequired,
101
+ assertPinnedSshHostKeyPolicy,
102
+ type RtkBypassContext,
103
+ } from "./safety-integrity.ts";
104
+
105
+ export {
106
+ assertCommandSeatbelt,
107
+ DESTRUCTIVE_COMMAND_PATTERNS,
108
+ isRtkBypassRequired,
109
+ assertPinnedSshHostKeyPolicy,
110
+ type RtkBypassContext,
111
+ };
97
112
 
98
113
  const trustedProducers = new WeakSet<object>();
99
114
  const CAPABILITY_PREFIX = "kxm-attempt-capability\0";
@@ -557,6 +572,7 @@ async function runPreparedCommandGate(
557
572
  definition: { readonly kind: "command"; readonly argv: readonly string[]; readonly timeoutMs: number; readonly cwd?: "control" },
558
573
  token: string,
559
574
  ): Promise<VnextRunDriveResult> {
575
+ assertCommandSeatbelt(definition.argv.join(" "));
560
576
  const storePath = context.eventStore.path;
561
577
  const observer = createCommandObserver({
562
578
  definition,
@@ -414,6 +414,8 @@ const READ_ONLY_ONESHOT_ARGS = Object.freeze({
414
414
  claude: Object.freeze(["--tools", "Read,Glob,Grep", "--restricted", "--safe-mode", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", "--no-session-persistence"]),
415
415
  codex: Object.freeze(["--sandbox", "read-only", "--ignore-user-config", "-c", 'approval_policy="never"']),
416
416
  grok: Object.freeze(["--sandbox", "read-only", "--permission-mode", "plan", "--tools", "Read,Glob,Grep", "--no-subagents", "--disable-web-search"]),
417
+ agy: Object.freeze(["--mode", "plan", "--sandbox", "--disable-slash-commands"]),
418
+ kimi: Object.freeze(["--plan"]),
417
419
  });
418
420
 
419
421
  export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefined {
@@ -469,7 +471,7 @@ export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
469
471
  authArgs: ["provider", "list"],
470
472
  update: { self: ["upgrade"] },
471
473
  oneShot: {
472
- argv: ["--output-format", "stream-json", "-p"],
474
+ argv: [...oneShotReadOnlyArgs("kimi")!, "--output-format", "stream-json", "-p"],
473
475
  promptVia: "arg",
474
476
  outputFormat: "stream-json",
475
477
  usageParser: parseKimiOneShotUsage,
@@ -532,7 +534,7 @@ export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
532
534
  authArgs: ["models"],
533
535
  update: { self: ["update"] },
534
536
  oneShot: {
535
- argv: ["--output-format", "json", "-p"],
537
+ argv: [...oneShotReadOnlyArgs("agy")!, "--output-format", "json", "-p"],
536
538
  promptVia: "arg",
537
539
  outputFormat: "json",
538
540
  usageParser: parseAgyOneShotUsage,
@@ -26,6 +26,26 @@ const KILL_GRACE_MS = 250;
26
26
  const DRAIN_GRACE_MS = 500;
27
27
  const REAP_GRACE_MS = 1000;
28
28
 
29
+ /**
30
+ * POSIX process-group tree kill with fallback to direct child kill.
31
+ * Ensures orphaned subshells, test workers, and background daemons are pruned on cancellation/timeout.
32
+ */
33
+ export function killProcessTree(child: ReturnType<typeof spawn>, signal: NodeJS.Signals = "SIGKILL"): void {
34
+ if (process.platform !== "win32" && child.pid) {
35
+ try {
36
+ process.kill(-child.pid, signal);
37
+ return;
38
+ } catch {
39
+ // Fall through to direct child kill if process group is unavailable
40
+ }
41
+ }
42
+ try {
43
+ child.kill(signal);
44
+ } catch {
45
+ // Direct kill error if child already exited
46
+ }
47
+ }
48
+
29
49
  /** Bound both execution and pipe drain. Observing exit is not proof of descendant death. */
30
50
  export function defaultSpawn(command: string, args: readonly string[], options: VnextOneShotSpawnOptions): Promise<VnextOneShotProcessResult> {
31
51
  if (options.signal?.aborted) {
@@ -63,10 +83,7 @@ export function defaultSpawn(command: string, args: readonly string[], options:
63
83
  resolve({ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), code, signal, started, observedChildExit, terminationRequested: stopping, ...(error ? { error } : {}) });
64
84
  };
65
85
  const kill = (requested: NodeJS.Signals): void => {
66
- try {
67
- if (process.platform !== "win32" && child.pid) process.kill(-child.pid, requested);
68
- else child.kill(requested);
69
- } catch { /* Reaping, not kill() success, establishes direct-child exit. */ }
86
+ killProcessTree(child, requested);
70
87
  };
71
88
  const stop = (reason: string): void => {
72
89
  if (finished) return;