@kontextmind/kxm 0.7.0 → 0.7.5

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 (78) 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 +2257 -439
  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 +572 -17
  35. package/plugins/kxm/dist/server.js +129 -4
  36. package/plugins/kxm/dist/vnext-runtime-supervisor.js +136 -13
  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.ts +507 -9
  51. package/plugins/kxm/src/completion-install.ts +223 -0
  52. package/plugins/kxm/src/database.ts +1 -1
  53. package/plugins/kxm/src/external-effects.ts +1 -1
  54. package/plugins/kxm/src/hub-env.ts +193 -0
  55. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  56. package/plugins/kxm/src/local-snapshot.ts +1 -1
  57. package/plugins/kxm/src/mcp-server.ts +1 -1
  58. package/plugins/kxm/src/protocol.ts +111 -0
  59. package/plugins/kxm/src/role.ts +335 -0
  60. package/plugins/kxm/src/runtime.ts +1 -0
  61. package/plugins/kxm/src/safety-integrity.ts +76 -0
  62. package/plugins/kxm/src/sqlite.ts +76 -0
  63. package/plugins/kxm/src/store.ts +1 -1
  64. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  65. package/plugins/kxm/src/vnext-config.ts +38 -1
  66. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  67. package/plugins/kxm/src/vnext-engine.ts +16 -0
  68. package/plugins/kxm/src/vnext-harness.ts +4 -2
  69. package/plugins/kxm/src/vnext-oneshot-process.ts +21 -4
  70. package/plugins/kxm/src/vnext-oneshot-producer.ts +18 -0
  71. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  72. package/plugins/kxm/src/workflow-tui.ts +1 -1
  73. package/plugins/kxm/src/workflow.ts +144 -0
  74. package/scripts/kxm-bump-version.mjs +146 -0
  75. package/scripts/kxm-hub.mjs +145 -3
  76. package/scripts/kxm-publish-npm.mjs +3 -1
  77. package/scripts/kxm-release-github.mjs +3 -1
  78. package/scripts/kxm.mjs +0 -0
@@ -3,7 +3,7 @@ import { createHash, createHmac, randomUUID } from "node:crypto";
3
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
- import { DatabaseSync } from "node:sqlite";
6
+ import { DatabaseSync } from "./sqlite.ts";
7
7
  import { createInterface } from "node:readline";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Command, CommanderError } from "commander";
@@ -16,7 +16,7 @@ import {
16
16
  } from "./memory.ts";
17
17
  import { createBackup, restoreBackup } from "./database.ts";
18
18
  import { verifyArtifactExists } from "./artifacts-exist.ts";
19
- import { canonicalWorkflowEvidenceKey, parseWorkflowDefinitions } from "./workflow.ts";
19
+ import { canonicalWorkflowEvidenceKey, parseWorkflowDefinitions, resumeWorkflowFromRuling } from "./workflow.ts";
20
20
  import { postWorkflowSignal, watchGithubChecks } from "./github-watch.ts";
21
21
  import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
22
22
  import { redactSecrets } from "./redact.ts";
@@ -65,6 +65,13 @@ import {
65
65
  type InstallProbe,
66
66
  } from "./kxm-install-kind.ts";
67
67
  import { VnextConfigError, discoverVnextProjectRoot, type VnextInitializationPlan } from "./vnext-config.ts";
68
+ import {
69
+ GUIDE_WORKFLOWS,
70
+ parseGuideSelection,
71
+ planGuideSetup,
72
+ renderGuideSetupFiles,
73
+ writeGuideSetupFiles,
74
+ } from "./init-guide-setup.ts";
68
75
  import { vnextUserStateRoot } from "./vnext-bindings.ts";
69
76
  import { initializeVnextProject } from "./vnext-init.ts";
70
77
  import { applyVnextMigration, planVnextMigration, verifyVnextMigration } from "./vnext-migrate.ts";
@@ -104,6 +111,7 @@ import {
104
111
  formatKxmConfig,
105
112
  } from "./config.ts";
106
113
  import { generateShellCompletion, type SupportedShell } from "./autocomplete.ts";
114
+ import { completionRcTarget, completionScriptPath, detectShell, installPathEntry, installShellCompletion, kxmBinDir } from "./completion-install.ts";
107
115
  import { suggestWorkflowAndRoles } from "./suggest.ts";
108
116
  import {
109
117
  createGoal,
@@ -126,6 +134,10 @@ import {
126
134
  removeRole,
127
135
  modifyRole,
128
136
  DEFAULT_ROLES,
137
+ DEFAULT_ROLE_SEATS,
138
+ loadRoleHostsConfig,
139
+ setRoleSeatHost,
140
+ resolveRoleSeat,
129
141
  type KxmRoleDefinition,
130
142
  } from "./role.ts";
131
143
  import {
@@ -625,9 +637,13 @@ async function cmdVnextInit(runtime: Runtime, options: { name?: string; projectI
625
637
  return code;
626
638
  };
627
639
  if (initialized.action === "created") {
640
+ await maybeOfferCompletionInstall(runtime);
641
+ await maybeOfferGuideSetup(runtime);
628
642
  return finishInit(0, `initialized vNext project at ${initialized.projectRoot ?? runtime.cwd}`);
629
643
  }
630
644
  if (initialized.action === "joined") {
645
+ await maybeOfferCompletionInstall(runtime);
646
+ await maybeOfferGuideSetup(runtime);
631
647
  return finishInit(0, `joined vNext project at ${initialized.projectRoot ?? runtime.cwd}`);
632
648
  }
633
649
  if (initialized.action === "repaired") {
@@ -1739,17 +1755,30 @@ async function cmdStop(runtime: Runtime, waitMsFlag?: string): Promise<number> {
1739
1755
  const requested: string[] = [];
1740
1756
  const ignored: string[] = [];
1741
1757
  const records = new Map<string, { pid: number; startedAt: string; generation?: string }>();
1758
+ const orphans: string[] = [];
1742
1759
  for (const file of pids) {
1743
1760
  try {
1744
- const record = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { version?: number; pid?: number; role?: string; startedAt?: string; generation?: string; controlFile?: string };
1761
+ const record = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { version?: number; pid?: number; serverPid?: number; role?: string; startedAt?: string; generation?: string; controlFile?: string };
1745
1762
  const expectedControl = file === "hub.pid" ? "hub.stop" : file.startsWith("worker-") ? `${file.slice(0, -4)}.stop` : undefined;
1746
1763
  const expectedRole = file === "hub.pid" ? "hub" : file.startsWith("worker-") ? "worker" : undefined;
1747
- if (record.version !== 1 || !Number.isInteger(record.pid) || record.pid! <= 0 || !record.startedAt || !expectedControl || record.controlFile !== expectedControl || record.role !== expectedRole || !processExists(record.pid!)) { ignored.push(file); continue; }
1748
- writeFileSync(join(runtime.dirs.state, record.controlFile), `${JSON.stringify({ startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}), requestedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
1764
+ if (record.version !== 1 || !Number.isInteger(record.pid) || record.pid! <= 0 || !record.startedAt || !expectedControl || record.controlFile !== expectedControl || record.role !== expectedRole) { ignored.push(file); continue; }
1765
+ if (!processExists(record.pid!)) {
1766
+ // Wrapper is dead. A recorded hub server child may still hold the
1767
+ // port; stop it directly so `kxm hub stop` recovers orphaned hubs.
1768
+ if (file === "hub.pid" && Number.isInteger(record.serverPid) && record.serverPid! > 0 && record.serverPid !== record.pid && processExists(record.serverPid!)) {
1769
+ try { process.kill(record.serverPid!, "SIGTERM"); } catch { /* racing exit */ }
1770
+ orphans.push(file);
1771
+ records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
1772
+ continue;
1773
+ }
1774
+ ignored.push(file);
1775
+ continue;
1776
+ }
1777
+ writeFileSync(join(runtime.dirs.state, record.controlFile!), `${JSON.stringify({ startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}), requestedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
1749
1778
  requested.push(file); records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
1750
1779
  } catch { ignored.push(file); }
1751
1780
  }
1752
- if (requested.length === 0) { print(runtime.io, runtime.json, { ok: false, command: "stop", requested, ignored }, "no current managed processes found"); return 1; }
1781
+ if (requested.length === 0 && orphans.length === 0) { print(runtime.io, runtime.json, { ok: false, command: "stop", requested, ignored }, "no current managed processes found"); return 1; }
1753
1782
  const waitMs = Math.min(30_000, Math.max(100, Number(waitMsFlag || 5_000)));
1754
1783
  const deadline = Date.now() + waitMs;
1755
1784
  const stopped = new Set<string>();
@@ -1763,8 +1792,28 @@ async function cmdStop(runtime: Runtime, waitMsFlag?: string): Promise<number> {
1763
1792
  if (stopped.size < requested.length) await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
1764
1793
  }
1765
1794
  const timedOut = requested.filter((file) => !stopped.has(file));
1795
+ // Orphan cleanup: wait for the directly-signaled server children to exit,
1796
+ // then remove their stale claims so the next start reclaims cleanly.
1797
+ const deadlineOrphans = Date.now() + Math.min(10_000, waitMs);
1798
+ for (const file of orphans) {
1799
+ while (Date.now() <= deadlineOrphans) {
1800
+ try {
1801
+ const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
1802
+ if (!Number.isInteger(current.serverPid) || !processExists(current.serverPid!)) break;
1803
+ } catch { break; }
1804
+ await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
1805
+ }
1806
+ try {
1807
+ const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
1808
+ if (Number.isInteger(current.serverPid) && processExists(current.serverPid!)) {
1809
+ try { process.kill(current.serverPid!, "SIGKILL"); } catch { /* racing exit */ }
1810
+ }
1811
+ rmSync(join(runtime.dirs.state, file), { force: true });
1812
+ } catch { /* claim already replaced or removed */ }
1813
+ stopped.add(file);
1814
+ }
1766
1815
  const ok = timedOut.length === 0;
1767
- print(runtime.io, runtime.json, { ok, command: "stop", requested, stopped: [...stopped], timedOut, ignored }, ok ? "managed processes stopped" : "stop request timed out");
1816
+ print(runtime.io, runtime.json, { ok, command: "stop", requested, stopped: [...stopped], timedOut, ...(orphans.length ? { orphans } : {}), ignored }, ok ? "managed processes stopped" : "stop request timed out");
1768
1817
  return ok ? 0 : 1;
1769
1818
  }
1770
1819
 
@@ -2445,6 +2494,201 @@ async function cmdCompletion(runtime: Runtime, shell: string): Promise<number> {
2445
2494
  }
2446
2495
  }
2447
2496
 
2497
+ async function cmdCompletionInstall(
2498
+ runtime: Runtime,
2499
+ options: { shell?: string; path?: boolean },
2500
+ ): Promise<number> {
2501
+ const installOptions = {
2502
+ env: runtime.env,
2503
+ configDir: runtime.env.KXM_USER_CONFIG_DIR,
2504
+ platform: process.platform as NodeJS.Platform,
2505
+ dryRun: runtime.dryRun,
2506
+ };
2507
+ const report = installShellCompletion(options.shell, installOptions);
2508
+ const pathReport = options.path === false ? undefined : installPathEntry(options.shell, installOptions);
2509
+ if (!report.ok) {
2510
+ print(runtime.io, runtime.json, {
2511
+ ok: false,
2512
+ command: "completion install",
2513
+ error: report.reason ?? "shell_not_detected",
2514
+ }, "could not detect your shell; pass --shell bash, zsh, or fish");
2515
+ return 1;
2516
+ }
2517
+ const shellNote = report.shell;
2518
+ const applyNote = runtime.dryRun
2519
+ ? "planned; rerun without --dry-run to apply"
2520
+ : report.alreadyInstalled
2521
+ ? "already installed"
2522
+ : "installed; restart your shell or open a new terminal to activate";
2523
+ const pathNote = pathReport
2524
+ ? pathReport.ok
2525
+ ? runtime.dryRun
2526
+ ? `; PATH entry for ${pathReport.binDir} planned`
2527
+ : pathReport.alreadyInstalled
2528
+ ? "; kxm already on PATH"
2529
+ : `; PATH entry for ${pathReport.binDir} added to ${pathReport.rcFile}`
2530
+ : undefined
2531
+ : undefined;
2532
+ print(runtime.io, runtime.json, {
2533
+ ok: true,
2534
+ command: "completion install",
2535
+ shell: shellNote,
2536
+ scriptPath: report.scriptPath,
2537
+ ...(report.rcFile ? { rcFile: report.rcFile } : {}),
2538
+ rcModified: report.rcModified,
2539
+ alreadyInstalled: report.alreadyInstalled,
2540
+ ...(pathReport ? { path: pathReport } : {}),
2541
+ dryRun: runtime.dryRun === true,
2542
+ }, `kxm ${shellNote} completion: ${applyNote}${report.rcFile ? ` (rc: ${report.rcFile})` : ""}${pathNote ?? ""}`);
2543
+ return 0;
2544
+ }
2545
+
2546
+ async function maybeOfferCompletionInstall(runtime: Runtime): Promise<void> {
2547
+ if (runtime.json || runtime.dryRun) return;
2548
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2549
+ if (runtime.env.KXM_SKIP_COMPLETION_PROMPT?.trim()) return;
2550
+ const shell = detectShell(runtime.env, process.platform);
2551
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") return;
2552
+ const binDir = kxmBinDir(runtime.env);
2553
+ const pathNeeded = shell !== "fish" && binDir && !(runtime.env.PATH ?? "").split(":").includes(binDir);
2554
+ if (!pathNeeded) {
2555
+ // completion-only offer; skip when already wired
2556
+ const scriptPath = completionScriptPath(shell, { env: runtime.env, configDir: runtime.env.KXM_USER_CONFIG_DIR });
2557
+ const { rcFile } = completionRcTarget(shell, { env: runtime.env });
2558
+ if (rcFile && existsSync(rcFile)) {
2559
+ try {
2560
+ if (readFileSync(rcFile, "utf8").includes(scriptPath)) return; // already wired
2561
+ } catch {
2562
+ // unreadable rc: still offer
2563
+ }
2564
+ }
2565
+ }
2566
+ const question = pathNeeded
2567
+ ? `\nInstall ${shell} tab completion for kxm and add ${binDir} to PATH? [Y/n] `
2568
+ : `\nInstall ${shell} tab completion for kxm? [y/N] `;
2569
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2570
+ await new Promise<void>((resolvePrompt) => {
2571
+ rl.question(question, (answer) => {
2572
+ rl.close();
2573
+ const trimmed = answer.trim().toLowerCase();
2574
+ const accept = pathNeeded ? trimmed !== "n" && trimmed !== "no" : trimmed === "y" || trimmed === "yes";
2575
+ if (accept) {
2576
+ try {
2577
+ const report = installShellCompletion(shell, {
2578
+ env: runtime.env,
2579
+ configDir: runtime.env.KXM_USER_CONFIG_DIR,
2580
+ platform: process.platform,
2581
+ });
2582
+ if (report.ok) {
2583
+ runtime.io.stdout(`completion installed for ${report.shell}`);
2584
+ } else {
2585
+ runtime.io.stdout(`completion install skipped: ${report.reason ?? "unknown"}\n`);
2586
+ resolvePrompt();
2587
+ return;
2588
+ }
2589
+ } catch {
2590
+ runtime.io.stdout("completion install skipped: local filesystem operation did not complete\n");
2591
+ resolvePrompt();
2592
+ return;
2593
+ }
2594
+ if (pathNeeded && binDir) {
2595
+ try {
2596
+ const pathReport = installPathEntry(shell, {
2597
+ env: runtime.env,
2598
+ configDir: runtime.env.KXM_USER_CONFIG_DIR,
2599
+ platform: process.platform,
2600
+ binDir,
2601
+ });
2602
+ runtime.io.stdout(pathReport.ok && pathReport.rcModified
2603
+ ? `; ${binDir} added to PATH in ${pathReport.rcFile}`
2604
+ : "; kxm already on PATH");
2605
+ } catch {
2606
+ runtime.io.stdout("; PATH entry skipped: local filesystem operation did not complete");
2607
+ }
2608
+ }
2609
+ runtime.io.stdout("; restart your shell or open a new terminal to activate\n");
2610
+ } else {
2611
+ runtime.io.stdout("skipped; run `kxm completion install` anytime\n");
2612
+ }
2613
+ resolvePrompt();
2614
+ });
2615
+ });
2616
+ }
2617
+
2618
+ const GUIDE_SETUP_OPT_OUT_ENV = "KXM_SKIP_GUIDE_SETUP_PROMPT";
2619
+
2620
+ async function askYesNo(question: string): Promise<boolean> {
2621
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2622
+ return new Promise<boolean>((resolvePrompt) => {
2623
+ rl.question(question, (answer) => {
2624
+ rl.close();
2625
+ const trimmed = answer.trim().toLowerCase();
2626
+ resolvePrompt(trimmed === "y" || trimmed === "yes");
2627
+ });
2628
+ });
2629
+ }
2630
+
2631
+ async function askLine(question: string): Promise<string> {
2632
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2633
+ return new Promise<string>((resolvePrompt) => {
2634
+ rl.question(question, (answer) => {
2635
+ rl.close();
2636
+ resolvePrompt(answer);
2637
+ });
2638
+ });
2639
+ }
2640
+
2641
+ /**
2642
+ * Post-init offer: install workflow-guide software-engineering workflows and
2643
+ * their agent resources, filtered to authenticated harnesses. Writes only
2644
+ * vNext project resources (.kxm/agents, .kxm/workflows); never legacy
2645
+ * authority (.kxm/config, .kxm/roster.json).
2646
+ */
2647
+ async function maybeOfferGuideSetup(runtime: Runtime): Promise<void> {
2648
+ if (runtime.json || runtime.dryRun) return;
2649
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2650
+ if (runtime.env[GUIDE_SETUP_OPT_OUT_ENV]?.trim()) return;
2651
+ let inventory;
2652
+ try {
2653
+ inventory = probeHarnesses({ env: runtime.env });
2654
+ } catch {
2655
+ return;
2656
+ }
2657
+ const authenticated = inventory.harnesses.filter((entry) => entry.detected && entry.authenticated === true);
2658
+ if (authenticated.length === 0) {
2659
+ runtime.io.stdout("no authenticated harnesses detected; skipping workflow-guide setup (see `kxm harness list`)\n");
2660
+ return;
2661
+ }
2662
+ const harnessList = authenticated.map((entry) => entry.id).join(", ");
2663
+ const accept = await askYesNo(`\nSet up workflow-guide agents and workflows for authenticated harnesses (${harnessList})? [y/N] `);
2664
+ if (!accept) {
2665
+ runtime.io.stdout(`skipped; set ${GUIDE_SETUP_OPT_OUT_ENV}=1 to suppress this offer, or re-run on a fresh project\n`);
2666
+ return;
2667
+ }
2668
+ const lines = ["", "Workflow-guide software-engineering workflows (docs/workflow-guide.md):"];
2669
+ GUIDE_WORKFLOWS.forEach((workflow, index) => {
2670
+ lines.push(` ${index + 1}) ${workflow.slug.padEnd(32)} ${workflow.summary}`);
2671
+ });
2672
+ runtime.io.stdout(`${lines.join("\n")}\n`);
2673
+ const answer = await askLine("Install which workflows? (numbers or slugs, comma-separated, 'all', or 'none'): ");
2674
+ const selected = parseGuideSelection(answer);
2675
+ if (selected.length === 0) {
2676
+ runtime.io.stdout("no workflows selected; nothing written\n");
2677
+ return;
2678
+ }
2679
+ const plan = planGuideSetup({ inventory, selected });
2680
+ const files = renderGuideSetupFiles(runtime.cwd, plan);
2681
+ const report = writeGuideSetupFiles(files);
2682
+ for (const file of report.written) runtime.io.stdout(`wrote ${file}\n`);
2683
+ for (const file of report.existed) runtime.io.stdout(`kept existing ${file} (not overwritten)\n`);
2684
+ for (const skip of plan.skipped) {
2685
+ runtime.io.stdout(`skipped ${skip.workflow}/${skip.role}: ${skip.reason}\n`);
2686
+ }
2687
+ if (report.written.length > 0) {
2688
+ runtime.io.stdout("inspect with `kxm workflow definitions`; guide candidates are dated research — verify before dispatch\n");
2689
+ }
2690
+ }
2691
+
2448
2692
  async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
2449
2693
  try {
2450
2694
  const prompt = promptParts.join(" ").trim();
@@ -3152,6 +3396,229 @@ async function cmdRoleModify(
3152
3396
  }
3153
3397
  }
3154
3398
 
3399
+ async function cmdRoleHosts(
3400
+ runtime: Runtime,
3401
+ options: { scope?: "all" | "global" | "local" } = {},
3402
+ ): Promise<number> {
3403
+ const hostsConfig = loadRoleHostsConfig({
3404
+ scope: options.scope,
3405
+ repoRoot: runtime.cwd,
3406
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
3407
+ });
3408
+
3409
+ const seatIds = Array.from(
3410
+ new Set([
3411
+ ...Object.keys(DEFAULT_ROLE_SEATS),
3412
+ ...Object.keys(hostsConfig.config.seats ?? {}),
3413
+ ]),
3414
+ ).sort();
3415
+
3416
+ const seats = seatIds.map((seatId) => {
3417
+ const resolved = resolveRoleSeat(seatId, {
3418
+ repoRoot: runtime.cwd,
3419
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
3420
+ });
3421
+ return {
3422
+ seatId,
3423
+ host: resolved.host,
3424
+ model: resolved.model,
3425
+ provider: resolved.provider,
3426
+ effort: resolved.effort,
3427
+ source: resolved.source,
3428
+ configuredHost: hostsConfig.config.seats?.[seatId]?.host,
3429
+ configuredModel: hostsConfig.config.seats?.[seatId]?.model,
3430
+ };
3431
+ });
3432
+
3433
+ if (runtime.json) {
3434
+ print(
3435
+ runtime.io,
3436
+ true,
3437
+ {
3438
+ ok: true,
3439
+ command: "role hosts",
3440
+ scope: hostsConfig.scope,
3441
+ filePath: hostsConfig.filePath,
3442
+ seats,
3443
+ hostProviders: hostsConfig.config.hostProviders ?? {},
3444
+ },
3445
+ "",
3446
+ );
3447
+ return 0;
3448
+ }
3449
+
3450
+ const lines: string[] = [
3451
+ `ROLE SEATS (${hostsConfig.scope}${hostsConfig.filePath ? ` at ${hostsConfig.filePath}` : ""}):`,
3452
+ ];
3453
+ for (const s of seats) {
3454
+ const modelStr = s.model ? ` [${s.model}]` : "";
3455
+ const sourceTag = `(via ${s.source})`;
3456
+ lines.push(` ${s.seatId.padEnd(16)} -> host: ${s.host.padEnd(12)} ${modelStr.padEnd(30)} ${sourceTag}`);
3457
+ }
3458
+ if (hostsConfig.config.hostProviders && Object.keys(hostsConfig.config.hostProviders).length > 0) {
3459
+ lines.push("\nHOST PROVIDERS:");
3460
+ for (const [h, p] of Object.entries(hostsConfig.config.hostProviders)) {
3461
+ lines.push(` ${h.padEnd(16)} -> provider: ${p}`);
3462
+ }
3463
+ }
3464
+ print(runtime.io, false, {}, `${lines.join("\n")}\n`);
3465
+ return 0;
3466
+ }
3467
+
3468
+ async function cmdRoleSetHost(
3469
+ runtime: Runtime,
3470
+ seatId: string,
3471
+ host: string,
3472
+ options: {
3473
+ model?: string;
3474
+ effort?: "low" | "medium" | "high" | "xhigh";
3475
+ scope?: "global" | "local";
3476
+ } = {},
3477
+ ): Promise<number> {
3478
+ if (!seatId || !host) {
3479
+ runtime.io.stderr("kxm role set-host requires <seatId> and <host>\n");
3480
+ return 1;
3481
+ }
3482
+
3483
+ try {
3484
+ const result = setRoleSeatHost(seatId, host, {
3485
+ model: options.model,
3486
+ effort: options.effort,
3487
+ scope: options.scope ?? "local",
3488
+ repoRoot: runtime.cwd,
3489
+ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR,
3490
+ });
3491
+
3492
+ print(
3493
+ runtime.io,
3494
+ runtime.json,
3495
+ {
3496
+ ok: true,
3497
+ command: "role set-host",
3498
+ seatId,
3499
+ host,
3500
+ binding: result.binding,
3501
+ filePath: result.filePath,
3502
+ scope: result.scope,
3503
+ },
3504
+ `Bound seat '${seatId}' to host '${host}' in ${result.filePath}\n`,
3505
+ );
3506
+ return 0;
3507
+ } catch (err: unknown) {
3508
+ runtime.io.stderr(`role set-host failed: ${(err as Error).message}\n`);
3509
+ return 1;
3510
+ }
3511
+ }
3512
+
3513
+ async function cmdRoleResume(
3514
+ runtime: Runtime,
3515
+ runId: string,
3516
+ ruling?: string,
3517
+ ): Promise<number> {
3518
+ if (!runId) {
3519
+ runtime.io.stderr("kxm role resume requires <runId>\n");
3520
+ return 1;
3521
+ }
3522
+
3523
+ const effectiveRuling = ruling?.trim() || "operator_ruling: waived and resumed";
3524
+
3525
+ // Check if it's a vNext run
3526
+ const projectRoot = discoverVnextProjectRoot(runtime.cwd);
3527
+ if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
3528
+ if (runtime.dryRun) {
3529
+ print(runtime.io, runtime.json, { ok: true, command: "role resume", runId, ruling: effectiveRuling }, `would resume vNext run ${runId}`);
3530
+ return 0;
3531
+ }
3532
+ try {
3533
+ const supervisor = await ensureVnextSupervisor({ env: runtime.env });
3534
+ const posted = await vnextRuntimeRequest(
3535
+ supervisor,
3536
+ "POST",
3537
+ `/v1/runs/${encodeURIComponent(runId)}/signal?projectRoot=${encodeURIComponent(projectRoot)}`,
3538
+ {
3539
+ signalKey: "audit_escalation",
3540
+ status: "passed",
3541
+ summary: effectiveRuling,
3542
+ action: "unblock",
3543
+ },
3544
+ );
3545
+ print(
3546
+ runtime.io,
3547
+ runtime.json,
3548
+ { ok: true, command: "role resume", runId, ruling: effectiveRuling, unblocked: posted.unblocked === true },
3549
+ `Resumed vNext run ${runId} with ruling: ${effectiveRuling}\n`,
3550
+ );
3551
+ return 0;
3552
+ } catch (error) {
3553
+ const msg = error instanceof Error ? error.message : "resume_failed";
3554
+ print(runtime.io, runtime.json, { ok: false, command: "role resume", error: "resume_failed", detail: msg }, `resume vNext run failed: ${msg}\n`);
3555
+ return 1;
3556
+ }
3557
+ }
3558
+
3559
+ // Workflow run: check local state database (.kxm/state/kxm.db)
3560
+ const dbPath = join(runtime.cwd, ".kxm", "state", "kxm.db");
3561
+ if (existsSync(dbPath)) {
3562
+ try {
3563
+ const database = new DatabaseSync(dbPath);
3564
+ try {
3565
+ const row = database.prepare("SELECT record FROM workflow_runs WHERE id = ?").get(runId) as { record: string } | undefined;
3566
+ if (!row) {
3567
+ runtime.io.stderr(`kxm: workflow run '${runId}' not found in ${dbPath}\n`);
3568
+ return 1;
3569
+ }
3570
+ const run = JSON.parse(row.record) as WorkflowRun;
3571
+ const now = new Date().toISOString();
3572
+ const resumeResult = resumeWorkflowFromRuling(run, effectiveRuling, now);
3573
+
3574
+ database.prepare("UPDATE workflow_runs SET record = ? WHERE id = ?").run(
3575
+ JSON.stringify(resumeResult.run),
3576
+ runId,
3577
+ );
3578
+
3579
+ const journalId = randomUUID();
3580
+ const journalPayload = {
3581
+ id: journalId,
3582
+ runId,
3583
+ stageId: resumeResult.stageId,
3584
+ category: "decision" as const,
3585
+ area: "workflow" as const,
3586
+ summary: `Role resume ruling: ${effectiveRuling}`,
3587
+ details: { ruling: effectiveRuling },
3588
+ evidence: {},
3589
+ createdAt: now,
3590
+ };
3591
+
3592
+ database.prepare(
3593
+ "INSERT INTO workflow_journal (id, run_id, category, area, record) VALUES (?, ?, ?, ?, ?)",
3594
+ ).run(
3595
+ journalId,
3596
+ runId,
3597
+ "decision",
3598
+ "workflow",
3599
+ JSON.stringify(journalPayload),
3600
+ );
3601
+
3602
+ print(
3603
+ runtime.io,
3604
+ runtime.json,
3605
+ { ok: true, command: "role resume", runId, stageId: resumeResult.stageId, ruling: effectiveRuling, status: resumeResult.run.status },
3606
+ `Resumed workflow run ${runId} (stage: ${resumeResult.stageId}) with ruling: ${effectiveRuling}\n`,
3607
+ );
3608
+ return 0;
3609
+ } finally {
3610
+ database.close();
3611
+ }
3612
+ } catch (err: unknown) {
3613
+ runtime.io.stderr(`role resume failed: ${(err as Error).message}\n`);
3614
+ return 1;
3615
+ }
3616
+ }
3617
+
3618
+ runtime.io.stderr(`kxm: no active run store or database found for run '${runId}'\n`);
3619
+ return 1;
3620
+ }
3621
+
3155
3622
  async function cmdWorkflowDefinitions(
3156
3623
  runtime: Runtime,
3157
3624
  options: { scope?: "all" | "global" | "local" },
@@ -4379,6 +4846,22 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
4379
4846
  .action(async function roleModifyAction(this: Command, roleId?: string, options?: { description?: string; addSkill?: string; removeSkill?: string; addModel?: string; removeModel?: string; scope?: "global" | "local"; pick?: string | boolean }) {
4380
4847
  result.code = await cmdRoleModify(runtimeFrom(ctx, this), roleId, options ?? {});
4381
4848
  });
4849
+ addGlobalOptions(role.command("hosts").description("List role seats and resolved execution hosts from .kxm/role-hosts.yaml"))
4850
+ .option("--scope <scope>", "Filter by scope: all, global, or local", "all")
4851
+ .action(async function roleHostsAction(this: Command, options: { scope?: "all" | "global" | "local" }) {
4852
+ result.code = await cmdRoleHosts(runtimeFrom(ctx, this), options);
4853
+ });
4854
+ addGlobalOptions(role.command("set-host <seatId> <host>").description("Bind a role seat to a host in .kxm/role-hosts.yaml"))
4855
+ .option("--model <model>", "Model identifier for this seat")
4856
+ .option("--effort <effort>", "Effort level: low, medium, high, xhigh")
4857
+ .option("--scope <scope>", "Configuration scope: global or local (default: local)", "local")
4858
+ .action(async function roleSetHostAction(this: Command, seatId: string, host: string, options: { model?: string; effort?: "low" | "medium" | "high" | "xhigh"; scope?: "global" | "local" }) {
4859
+ result.code = await cmdRoleSetHost(runtimeFrom(ctx, this), seatId, host, options);
4860
+ });
4861
+ addGlobalOptions(role.command("resume <runId> [ruling]").description("Resume an audit-escalated role run with an operator directive"))
4862
+ .action(async function roleResumeAction(this: Command, runId: string, ruling?: string) {
4863
+ result.code = await cmdRoleResume(runtimeFrom(ctx, this), runId, ruling);
4864
+ });
4382
4865
 
4383
4866
  const gate = addGlobalOptions(program.command("gate").description("Validate definitions and operate evidence gates"));
4384
4867
  gate.helpCommand("help", "Show gate help");
@@ -4633,9 +5116,24 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
4633
5116
  result.code = await cmdConfigList(runtimeFrom(ctx, this));
4634
5117
  });
4635
5118
 
4636
- addGlobalOptions(program.command("completion <shell>").description("Generate shell completion script (bash, zsh, fish)"))
4637
- .action(async function completionAction(this: Command, shell: string) {
5119
+ const completionCmd = addGlobalOptions(program.command("completion [shell]").description("Generate shell completion script, or install it into the current shell")).action(async function completionAction(this: Command, shell?: string) {
5120
+ if (shell && shell !== "install") {
4638
5121
  result.code = await cmdCompletion(runtimeFrom(ctx, this), shell);
5122
+ return;
5123
+ }
5124
+ if (shell === "install") {
5125
+ result.code = await cmdCompletionInstall(runtimeFrom(ctx, this), this.opts<{ shell?: string; path?: boolean }>());
5126
+ return;
5127
+ }
5128
+ ctx.io.stderr("usage: kxm completion <bash|zsh|fish> | kxm completion install [--shell <shell>] [--no-path]\n");
5129
+ result.code = 2;
5130
+ });
5131
+ completionCmd.helpCommand("help", "Show completion help");
5132
+ addGlobalOptions(completionCmd.command("install").description("Install tab completion for the detected or given shell and ensure kxm is on PATH"))
5133
+ .option("--shell <shell>", "Shell to install for (bash, zsh, fish; default: detect from $SHELL)")
5134
+ .option("--no-path", "Only install completion; do not add a PATH entry")
5135
+ .action(async function completionInstallAction(this: Command, options: { shell?: string; path?: boolean }) {
5136
+ result.code = await cmdCompletionInstall(runtimeFrom(ctx, this), options);
4639
5137
  });
4640
5138
 
4641
5139
  addGlobalOptions(program.command("suggest <prompt...>").description("Recommend workflow, area, roles, and skills from a prompt or issue description"))