@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/roles/writer.yaml +2 -0
- package/docs/README.md +2 -0
- package/docs/agent-skills.md +5 -2
- package/docs/configuration.md +10 -1
- package/docs/getting-started.md +21 -0
- package/docs/operations.md +24 -0
- package/docs/skills/repo-work-delivery.md +5 -0
- package/docs/skills.md +2 -0
- package/docs/troubleshooting.md +22 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +2257 -439
- package/plugins/kxm/dist/core.js +57 -0
- package/plugins/kxm/dist/extension.js +40 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +140 -17
- package/plugins/kxm/dist/server.js +129 -4
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +136 -13
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
- package/plugins/kxm/src/autocomplete.ts +1 -1
- package/plugins/kxm/src/cli.ts +507 -9
- package/plugins/kxm/src/completion-install.ts +223 -0
- package/plugins/kxm/src/database.ts +1 -1
- package/plugins/kxm/src/external-effects.ts +1 -1
- package/plugins/kxm/src/hub-env.ts +193 -0
- package/plugins/kxm/src/init-guide-setup.ts +547 -0
- package/plugins/kxm/src/local-snapshot.ts +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +111 -0
- package/plugins/kxm/src/role.ts +335 -0
- package/plugins/kxm/src/safety-integrity.ts +76 -0
- package/plugins/kxm/src/sqlite.ts +76 -0
- package/plugins/kxm/src/store.ts +1 -1
- package/plugins/kxm/src/vnext-bindings.ts +1 -1
- package/plugins/kxm/src/vnext-config.ts +38 -1
- package/plugins/kxm/src/vnext-engine-command.ts +2 -0
- package/plugins/kxm/src/vnext-engine.ts +16 -0
- package/plugins/kxm/src/vnext-harness.ts +4 -2
- package/plugins/kxm/src/vnext-oneshot-process.ts +21 -4
- package/plugins/kxm/src/vnext-oneshot-producer.ts +18 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
- package/plugins/kxm/src/workflow-tui.ts +1 -1
- package/plugins/kxm/src/workflow.ts +144 -0
- package/scripts/kxm-bump-version.mjs +146 -0
- package/scripts/kxm-hub.mjs +145 -3
- package/scripts/kxm-publish-npm.mjs +3 -1
- package/scripts/kxm-release-github.mjs +3 -1
- package/scripts/kxm.mjs +0 -0
package/plugins/kxm/src/role.ts
CHANGED
|
@@ -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
|
+
}
|
package/plugins/kxm/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import type { DatabaseSync } from "
|
|
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 "
|
|
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
|
-
|
|
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;
|