@kontextmind/kxm 0.7.0 → 0.7.10

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 (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/writer.yaml +2 -0
  3. package/docs/README.md +3 -0
  4. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  5. package/docs/agent-skills.md +19 -2
  6. package/docs/browser-automation.md +116 -0
  7. package/docs/configuration.md +10 -1
  8. package/docs/getting-started.md +21 -0
  9. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  10. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  11. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  12. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  13. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  14. package/docs/kb/how-to-take-over-session.md +32 -0
  15. package/docs/kb/why-authentication-disappeared.md +32 -0
  16. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  17. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  18. package/docs/operations.md +24 -0
  19. package/docs/prompts/browser-annotate-feedback.md +41 -0
  20. package/docs/prompts/browser-diagnose-recover.md +38 -0
  21. package/docs/prompts/browser-explore.md +42 -0
  22. package/docs/prompts/browser-repro-fix.md +48 -0
  23. package/docs/prompts/browser-start.md +41 -0
  24. package/docs/prompts/browser-takeover.md +50 -0
  25. package/docs/skills/repo-work-delivery.md +5 -0
  26. package/docs/skills.md +2 -0
  27. package/docs/troubleshooting.md +22 -1
  28. package/package.json +1 -1
  29. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  30. package/plugins/kxm/dist/cli.js +41203 -38364
  31. package/plugins/kxm/dist/core.js +57 -0
  32. package/plugins/kxm/dist/extension.js +40 -3
  33. package/plugins/kxm/dist/mcp-server.js +1 -1
  34. package/plugins/kxm/dist/runtime.js +1582 -81
  35. package/plugins/kxm/dist/server.js +129 -4
  36. package/plugins/kxm/dist/vnext-runtime-supervisor.js +221 -41
  37. package/plugins/kxm/package.json +1 -1
  38. package/plugins/kxm/skills/hints.json +30 -0
  39. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  40. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  41. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  42. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  43. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  44. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  45. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  46. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
  47. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
  48. package/plugins/kxm/src/autocomplete.ts +1 -1
  49. package/plugins/kxm/src/browser.ts +603 -0
  50. package/plugins/kxm/src/cli/context-skills.ts +373 -0
  51. package/plugins/kxm/src/cli/hub.ts +614 -0
  52. package/plugins/kxm/src/cli/roles.ts +615 -0
  53. package/plugins/kxm/src/cli/system.ts +906 -0
  54. package/plugins/kxm/src/cli/tasks.ts +364 -0
  55. package/plugins/kxm/src/cli/types.ts +270 -0
  56. package/plugins/kxm/src/cli/vnext.ts +698 -0
  57. package/plugins/kxm/src/cli/workflows.ts +699 -0
  58. package/plugins/kxm/src/cli.ts +238 -3791
  59. package/plugins/kxm/src/completion-install.ts +223 -0
  60. package/plugins/kxm/src/database.ts +1 -1
  61. package/plugins/kxm/src/external-effects.ts +1 -1
  62. package/plugins/kxm/src/hub-env.ts +193 -0
  63. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  64. package/plugins/kxm/src/local-snapshot.ts +1 -1
  65. package/plugins/kxm/src/mcp-server.ts +1 -1
  66. package/plugins/kxm/src/model-inventory.ts +8 -8
  67. package/plugins/kxm/src/modes.ts +348 -0
  68. package/plugins/kxm/src/protocol.ts +111 -0
  69. package/plugins/kxm/src/role.ts +335 -0
  70. package/plugins/kxm/src/runtime.ts +4 -0
  71. package/plugins/kxm/src/safety-integrity.ts +76 -0
  72. package/plugins/kxm/src/sqlite.ts +76 -0
  73. package/plugins/kxm/src/ssh-remote.ts +560 -0
  74. package/plugins/kxm/src/store.ts +1 -1
  75. package/plugins/kxm/src/subagent-control.ts +312 -0
  76. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  77. package/plugins/kxm/src/vnext-config.ts +38 -1
  78. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  79. package/plugins/kxm/src/vnext-engine.ts +16 -0
  80. package/plugins/kxm/src/vnext-harness.ts +92 -22
  81. package/plugins/kxm/src/vnext-oneshot-evidence.ts +39 -7
  82. package/plugins/kxm/src/vnext-oneshot-process.ts +46 -8
  83. package/plugins/kxm/src/vnext-oneshot-producer.ts +22 -1
  84. package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
  85. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  86. package/plugins/kxm/src/vnext-runtime-supervisor.ts +5 -3
  87. package/plugins/kxm/src/workflow-tui.ts +1 -1
  88. package/plugins/kxm/src/workflow.ts +144 -0
  89. package/schemas/vnext/modes.schema.json +56 -0
  90. package/scripts/kxm-bump-version.mjs +146 -0
  91. package/scripts/kxm-hub.mjs +145 -3
  92. package/scripts/kxm-publish-npm.mjs +3 -1
  93. package/scripts/kxm-release-github.mjs +3 -1
  94. 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
+
@@ -7,3 +7,7 @@ export * from "./vnext-harness.ts";
7
7
  export * from "./database.ts";
8
8
  export * from "./logger.ts";
9
9
  export * from "./improve.ts";
10
+ export * from "./browser.ts";
11
+ export * from "./modes.ts";
12
+ export * from "./ssh-remote.ts";
13
+ export * from "./subagent-control.ts";
@@ -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
+ }