@kontextmind/kxm 0.7.104 → 0.7.105

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.
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
6
- import { oneShotWriterArgs } from "./harness.ts";
7
6
  import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
8
7
  import {
9
8
  buildFormalContextPacket,
@@ -14,6 +13,7 @@ import {
14
13
  type HandoffManifestV1,
15
14
  } from "./context-packet.ts";
16
15
  import { compileKxmWorkflow, type KxmCompiledPlan, type KxmCompiledStep } from "./engine-compile.ts";
16
+ import { BUILTIN_HARNESSES, oneShotPermissionArgs, oneShotWriterArgs, validateHarnessModelPair } from "./harness.ts";
17
17
  import {
18
18
  effectiveRunDurationBudget,
19
19
  isTerminalRunStatus,
@@ -2693,7 +2693,83 @@ function armRunDurationBudgetTimer(context: KxmRuntimeContext, runId: string): (
2693
2693
  };
2694
2694
  }
2695
2695
 
2696
- function unsupportedLimit(envelope: KxmRunPlanEnvelope): KxmRunHandoff | undefined {
2696
+ /** Read-only admission guidance for a live run, using the same policies as dispatch. */
2697
+ export function kxmLiveRunPrerequisites(
2698
+ bundle: KxmProjectBundle,
2699
+ workflowId: string,
2700
+ projectRoot: string,
2701
+ ): KxmRunHandoff[] {
2702
+ const workflow = bundle.workflows.get(workflowId);
2703
+ if (!workflow) throw runtimeError("run_workflow_unknown", workflowId, `workflow ${workflowId} does not exist in this project`);
2704
+ const plan = compileKxmWorkflow({ id: workflowId, value: workflow.value, logicalPath: workflow.logicalPath });
2705
+ const envelope = { plan, projectLimits: kxmProjectAdmissionLimits(bundle), gates: pinnedGatesForCompiledPlan(plan, bundle, projectRoot) };
2706
+ const prerequisites: KxmRunHandoff[] = [];
2707
+ const limit = unsupportedLimit(envelope);
2708
+ if (limit) prerequisites.push(limit);
2709
+ const visited = new Set<string>();
2710
+ const pending = [plan.entryStepId];
2711
+ while (pending.length > 0) {
2712
+ const stepId = pending.pop()!;
2713
+ if (visited.has(stepId)) continue;
2714
+ visited.add(stepId);
2715
+ const step = plan.steps[stepId]!;
2716
+ for (const transition of Object.values(step.transitions)) {
2717
+ if (transition.to === "step") pending.push(transition.target);
2718
+ }
2719
+ const unsupported = step.kind === "gate"
2720
+ ? unsupportedGateStep(plan, step, envelope, { projectRoot }) ?? starterGatePrerequisite(step, envelope.gates, projectRoot)
2721
+ : unsupportedStep(plan, step, "oneshot");
2722
+ if (unsupported) {
2723
+ prerequisites.push({ ...unsupported, stepId });
2724
+ continue;
2725
+ }
2726
+ if (step.kind !== "agent" && step.kind !== "moa") continue;
2727
+ const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
2728
+ for (const agentId of agentIds) {
2729
+ const agent = bundle.agents.get(agentId);
2730
+ const harness = String(agent?.value.harness ?? bundle.project.value.defaultHarness ?? "pi");
2731
+ const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
2732
+ if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
2733
+ prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
2734
+ continue;
2735
+ }
2736
+ const route = resolveProducerRoute(projectRoot, step, agentId);
2737
+ if ("error" in route) {
2738
+ prerequisites.push({ ...route.error, stepId, detail: `${agentId}: ${route.error.detail}; configure its model in .kxm/agents/${agentId}.yaml and admit the installed model with kxm routes admit --model <provider/model>` });
2739
+ continue;
2740
+ }
2741
+ const writeRefusal = unsupportedLiveWrite(projectRoot, step, agentId, route.selector, envelope.projectLimits.maxConcurrentRuns);
2742
+ if (writeRefusal) {
2743
+ prerequisites.push({ ...writeRefusal, stepId });
2744
+ continue;
2745
+ }
2746
+ const compatible = validateHarnessModelPair(harness, route);
2747
+ if (!compatible.valid) {
2748
+ prerequisites.push({ reason: "step_unsupported", stepId, field: "model", detail: `${agentId}: ${compatible.message ?? `${harness} cannot run ${route.selector}`}; configure a model supported by ${harness} in .kxm/agents/${agentId}.yaml` });
2749
+ }
2750
+ }
2751
+ }
2752
+ return prerequisites;
2753
+ }
2754
+
2755
+ function starterGatePrerequisite(step: KxmCompiledStep & { kind: "gate" }, gates: KxmPinnedGates, projectRoot: string): Omit<KxmRunHandoff, "stepId"> | undefined {
2756
+ const definition = gates.definitions[step.gate];
2757
+ if (!definition || definition.kind !== "command" || definition.argv.length !== 2 || definition.argv[0] !== "npm" || definition.argv[1] !== "test") return undefined;
2758
+ let testScript: unknown;
2759
+ try {
2760
+ const manifest: unknown = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
2761
+ if (manifest && typeof manifest === "object" && "scripts" in manifest) {
2762
+ const scripts = manifest.scripts;
2763
+ if (scripts && typeof scripts === "object" && "test" in scripts) testScript = scripts.test;
2764
+ }
2765
+ } catch { /* A missing or unreadable manifest cannot satisfy the starter gate. */ }
2766
+ if (typeof testScript !== "string" || testScript.trim().length === 0) {
2767
+ return { reason: "gate_unsupported", field: `gates.${step.gate}.argv`, detail: `gate ${step.gate} runs npm test but this repository has no readable package.json with scripts.test; configure .kxm/gates.yaml gates.${step.gate}.argv for the repository's actual test runner before driving this workflow` };
2768
+ }
2769
+ return undefined;
2770
+ }
2771
+
2772
+ function unsupportedLimit(envelope: Pick<KxmRunPlanEnvelope, "plan" | "projectLimits">): KxmRunHandoff | undefined {
2697
2773
  if (envelope.plan.limits.maxAgentTimeMs !== undefined) {
2698
2774
  return { reason: "limit_unsupported", field: "limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2699
2775
  }
@@ -2847,7 +2923,7 @@ function unsupportedLiveWrite(
2847
2923
  return undefined;
2848
2924
  }
2849
2925
 
2850
- function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: KxmRunPlanEnvelope, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
2926
+ function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: Pick<KxmRunPlanEnvelope, "gates">, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
2851
2927
  if (!step.gate) return { reason: "step_unsupported", field: "gate", detail: "gate step is missing gate id" };
2852
2928
  if (envelope.gates.registry === null) {
2853
2929
  return { reason: "step_unsupported", field: "gate", detail: `step ${step.id} refers to a gate but registry is null` };
@@ -62,6 +62,7 @@ export type HarnessRunCommand = (command: string, args: readonly string[], timeo
62
62
  export type HarnessRunCommandAsync = (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult | Promise<HarnessCommandResult>;
63
63
 
64
64
  export interface HarnessProbeOptions {
65
+ defaultHarness?: string | undefined;
65
66
  env?: NodeJS.ProcessEnv | undefined;
66
67
  runCommand?: HarnessRunCommand | undefined;
67
68
  timeoutMs?: number | undefined;
@@ -96,7 +97,7 @@ export interface HarnessStatus {
96
97
  }
97
98
 
98
99
  export interface HarnessInventory {
99
- defaultHarness: typeof DEFAULT_HARNESS;
100
+ defaultHarness: string;
100
101
  harnesses: readonly HarnessStatus[];
101
102
  }
102
103
 
@@ -958,7 +959,7 @@ function probeEntry(
958
959
  runCommand: (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult,
959
960
  timeoutMs: number,
960
961
  platform: NodeJS.Platform,
961
- probe: Pick<HarnessProbeOptions, "env" | "existsSync"> = {},
962
+ probe: Pick<HarnessProbeOptions, "env" | "existsSync" | "defaultHarness"> = {},
962
963
  ): HarnessStatus {
963
964
  const issues: string[] = [];
964
965
  const found = detectHarnessCommand(entry, runCommand, timeoutMs, platform, probe);
@@ -987,7 +988,7 @@ function probeEntry(
987
988
  return {
988
989
  id: entry.id,
989
990
  label: entry.label,
990
- default: entry.default,
991
+ default: entry.id === (probe.defaultHarness ?? DEFAULT_HARNESS),
991
992
  mode: entry.mode,
992
993
  detected,
993
994
  authenticated,
@@ -1009,7 +1010,7 @@ export function probeHarnesses(options: HarnessProbeOptions = {}): HarnessInvent
1009
1010
  const runCommand = options.runCommand ?? defaultRunner(options.env ?? process.env);
1010
1011
  const platform = options.platform ?? process.platform;
1011
1012
  return {
1012
- defaultHarness: DEFAULT_HARNESS,
1013
+ defaultHarness: options.defaultHarness ?? DEFAULT_HARNESS,
1013
1014
  harnesses: BUILTIN_HARNESSES.map((entry) => probeEntry(entry, runCommand, timeoutMs, platform, options)),
1014
1015
  };
1015
1016
  }
@@ -1508,7 +1509,7 @@ export function formatHarnessInventory(inventory: HarnessInventory): string {
1508
1509
  ].join(" ");
1509
1510
  });
1510
1511
  return [
1511
- `default harness: ${inventory.defaultHarness} (omit agent harness: to use headless Pi)`,
1512
+ `default harness: ${inventory.defaultHarness} (used when an agent omits harness:)`,
1512
1513
  "enable/disable = Git YAML (.kxm/agents, .kxm/models) or the harness's own plugin CLI",
1513
1514
  "governed kxm skills are not auto-updated",
1514
1515
  header,
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.104";
14
+ const VERSION = "0.7.105";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -1,156 +1,209 @@
1
+ import { BUILTIN_HARNESS_IDS, oneShotWriterArgs, type HarnessStatus } from "./harness.ts";
2
+ import { WORKFLOW_TEMPLATES } from "./workflow-manager.ts";
3
+
1
4
  export interface RoleSpec {
5
+ agent: string;
2
6
  harness: string;
3
- model: string;
4
7
  role: string;
5
8
  }
6
9
 
10
+ export type SuggestionExecution = {
11
+ supported: true;
12
+ prerequisites: string[];
13
+ shell: "powershell" | "posix";
14
+ createCommand: string;
15
+ driveCommand: string;
16
+ statusCommand: string;
17
+ receiptCommand: string;
18
+ } | {
19
+ supported: false;
20
+ error: "harness_unavailable" | "live_write_unsupported" | "workflow_already_exists";
21
+ reason: string;
22
+ nextSteps: string[];
23
+ };
24
+
7
25
  export interface SuggestionResult {
8
26
  workflowId: string;
27
+ template: string;
9
28
  area: string;
10
29
  confidence: number;
11
30
  reasons: string[];
12
31
  suggestedSkills: string[];
13
- roles: {
14
- planner: RoleSpec;
15
- writer: RoleSpec;
16
- critics: RoleSpec[];
17
- verifier: { kind: "witness"; command: string };
18
- };
32
+ roles: RoleSpec[];
33
+ /** Installs a definition; it does not create or execute a run. */
19
34
  suggestedCommand: string;
35
+ execution: SuggestionExecution;
20
36
  }
21
37
 
22
38
  interface WorkflowPattern {
23
39
  id: string;
40
+ template: string;
24
41
  area: string;
25
42
  keywords: string[];
26
43
  skills: string[];
27
- defaultCommand: (prompt: string) => string;
28
44
  }
29
45
 
30
46
  const WORKFLOW_PATTERNS: WorkflowPattern[] = [
31
47
  {
32
- id: "software-engineering/bug-fix",
48
+ id: "bug-fix",
49
+ template: "implement-and-verify",
33
50
  area: "software-engineering",
34
51
  keywords: ["fix", "bug", "flaky", "failure", "timeout", "error", "repro", "crash", "broken", "hang"],
35
52
  skills: ["kxm-workflow", "kxm-runs", "kxm-context-memory"],
36
- defaultCommand: (p) => `kxm run software-engineering/bug-fix "${p}"`,
37
53
  },
38
54
  {
39
- id: "software-engineering/feature-implementation",
55
+ id: "feature-implementation",
56
+ template: "implement-and-verify",
40
57
  area: "software-engineering",
41
58
  keywords: ["feature", "implement", "add", "build", "create", "develop", "support", "endpoint", "ui", "tui"],
42
59
  skills: ["kxm-workflow", "kxm-peer", "kxm-context-memory"],
43
- defaultCommand: (p) => `kxm run software-engineering/feature-implementation "${p}"`,
44
60
  },
45
61
  {
46
- id: "software-engineering/refactoring",
62
+ id: "refactoring",
63
+ template: "dual-critic-review",
47
64
  area: "software-engineering",
48
65
  keywords: ["refactor", "cleanup", "reorganize", "modularize", "deduplicate", "split", "simplify", "deprecate"],
49
66
  skills: ["kxm-workflow", "kxm-runs"],
50
- defaultCommand: (p) => `kxm run software-engineering/refactoring "${p}"`,
51
67
  },
52
68
  {
53
- id: "security-reliability/vulnerability-remediation",
69
+ id: "vulnerability-remediation",
70
+ template: "dual-critic-review",
54
71
  area: "security-reliability",
55
72
  keywords: ["cve", "vulnerability", "security", "exploit", "sanitize", "leak", "secret", "injection", "redact", "auth"],
56
73
  skills: ["kxm-workflow", "kxm-definitions"],
57
- defaultCommand: (p) => `kxm run security-reliability/vulnerability-remediation "${p}"`,
58
74
  },
59
75
  {
60
- id: "security-reliability/reliability-hardening",
76
+ id: "reliability-hardening",
77
+ template: "dual-critic-review",
61
78
  area: "security-reliability",
62
79
  keywords: ["idempotency", "retry", "circuit-breaker", "cas", "lock", "concurrency", "deadlock", "race", "crash-recovery"],
63
80
  skills: ["kxm-workflow", "kxm-peer"],
64
- defaultCommand: (p) => `kxm run security-reliability/reliability-hardening "${p}"`,
65
81
  },
66
82
  {
67
- id: "data-analytics/pipeline-migration",
83
+ id: "pipeline-migration",
84
+ template: "implement-and-verify",
68
85
  area: "data-analytics",
69
86
  keywords: ["database", "sqlite", "migration", "pipeline", "schema", "transform", "table", "wal", "foreign", "cascading"],
70
87
  skills: ["kxm-runs", "kxm-context-memory"],
71
- defaultCommand: (p) => `kxm run data-analytics/pipeline-migration "${p}"`,
72
88
  },
73
89
  {
74
- id: "research-strategy/architecture-spike",
90
+ id: "architecture-spike",
91
+ template: "spec-and-plan",
75
92
  area: "research-strategy",
76
93
  keywords: ["spike", "investigate", "prototype", "research", "feasibility", "benchmark", "explore", "evaluate"],
77
94
  skills: ["kxm-session", "kxm-context-memory", "kxm-routing-improve"],
78
- defaultCommand: (p) => `kxm run research-strategy/architecture-spike "${p}"`,
79
95
  },
80
96
  ];
81
97
 
98
+ type TemplateStep = { id: string; kind: string; agent?: string; repositories?: Record<string, string> };
99
+ type SuggestionHarness = Pick<HarnessStatus, "id" | "detected" | "authenticated" | "dispatch">;
100
+
82
101
  export function suggestWorkflowAndRoles(
83
102
  prompt: string,
84
- options: {
85
- availableHarnesses?: Array<{ harness: string; auth?: string | undefined }> | undefined;
86
- } = {},
103
+ options: { availableHarnesses?: readonly SuggestionHarness[] | undefined } = {},
87
104
  ): SuggestionResult {
88
105
  const normalized = prompt.toLowerCase();
89
106
  const words = normalized.split(/\W+/).filter(Boolean);
90
-
91
- let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[0]!;
92
- let bestScore = -1;
93
- const matchedReasons: string[] = [];
107
+ const claudeOnly = /\bclaude(?:\s+code)?(?:[\s-]+only|\s+exclusively)\b|\b(?:only|exclusively)(?:\s+(?:use|using|with|via))?\s+claude(?:\s+code)?\b/i.test(prompt);
108
+ let bestPattern: WorkflowPattern = WORKFLOW_PATTERNS[1]!;
109
+ let bestScore = 0;
110
+ const reasons: string[] = [];
94
111
 
95
112
  for (const pattern of WORKFLOW_PATTERNS) {
96
- let score = 0;
97
- const matchedWords: string[] = [];
98
- for (const keyword of pattern.keywords) {
99
- if (words.includes(keyword) || normalized.includes(keyword)) {
100
- score += 1;
101
- matchedWords.push(keyword);
102
- }
103
- }
104
- if (score > bestScore) {
105
- bestScore = score;
113
+ const matchedWords = pattern.keywords.filter((keyword) => words.includes(keyword) || normalized.includes(keyword));
114
+ if (matchedWords.length > bestScore) {
115
+ bestScore = matchedWords.length;
106
116
  bestPattern = pattern;
107
- matchedReasons.length = 0;
108
- matchedReasons.push(`Matched keywords: ${matchedWords.join(", ")}`);
117
+ reasons.length = 0;
118
+ reasons.push(`Matched keywords: ${matchedWords.join(", ")}`);
109
119
  }
110
120
  }
121
+ if (bestScore === 0) reasons.push("Default fallback: general feature implementation workflow");
122
+ if (claudeOnly) reasons.push("Explicit Claude-only constraint; other harnesses are excluded");
111
123
 
112
- if (bestScore <= 0) {
113
- bestPattern = WORKFLOW_PATTERNS[1]!;
114
- matchedReasons.push("Default fallback: general feature implementation workflow");
115
- }
116
-
117
- const activeHarnesses = new Set(
118
- (options.availableHarnesses ?? [])
119
- .filter((h) => h.auth === "active" || h.auth === "ready" || h.auth === "configured")
120
- .map((h) => h.harness.toLowerCase()),
121
- );
122
-
123
- const hasClaude = activeHarnesses.size === 0 || activeHarnesses.has("claude");
124
- const hasGrok = activeHarnesses.size === 0 || activeHarnesses.has("grok");
125
- const hasCodex = activeHarnesses.size === 0 || activeHarnesses.has("codex");
126
-
127
- const planner: RoleSpec = hasClaude
128
- ? { harness: "claude", model: "fable", role: "planner" }
129
- : { harness: "codex", model: "gpt-5.6-sol", role: "planner" };
130
-
131
- const writer: RoleSpec = hasGrok
132
- ? { harness: "grok", model: "grok-4.6", role: "writer" }
133
- : (activeHarnesses.has("agy")
134
- ? { harness: "agy", model: "gemini-2.5-pro", role: "writer" }
135
- : { harness: "pi", model: "qwen/qwen3-coder-plus", role: "writer" });
136
-
137
- const critics: RoleSpec[] = [
138
- { harness: "claude", model: "fable", role: "reviewer-arch" },
139
- { harness: "codex", model: "gpt-5.6-sol", role: "reviewer-cli" },
140
- ];
141
-
142
- return {
124
+ const template = WORKFLOW_TEMPLATES[bestPattern.template]!;
125
+ const steps = template.steps as TemplateStep[];
126
+ const base = {
143
127
  workflowId: bestPattern.id,
128
+ template: bestPattern.template,
144
129
  area: bestPattern.area,
145
130
  confidence: bestScore > 0 ? Math.min(1, 0.5 + bestScore * 0.15) : 0.5,
146
- reasons: matchedReasons,
131
+ reasons,
147
132
  suggestedSkills: bestPattern.skills,
148
- roles: {
149
- planner,
150
- writer,
151
- critics,
152
- verifier: { kind: "witness", command: "npm run verify" },
133
+ suggestedCommand: `kxm workflow add ${bestPattern.id} --template ${bestPattern.template}`,
134
+ };
135
+ const writeStep = steps.find((step) => step.kind === "agent" && Object.values(step.repositories ?? {}).includes("write"));
136
+
137
+ const available = (options.availableHarnesses ?? []).filter((entry) =>
138
+ BUILTIN_HARNESS_IDS.includes(entry.id) && entry.detected && entry.authenticated === true
139
+ && entry.dispatch?.supported === true && entry.dispatch.status === "yes"
140
+ && (!claudeOnly || entry.id === "claude"));
141
+ const selected = available.find((entry) => entry.id === "claude") ?? available[0];
142
+ if (!selected) {
143
+ const relevant = (options.availableHarnesses ?? []).filter((entry) => !claudeOnly || entry.id === "claude");
144
+ const detail = relevant.map((entry) => `${entry.id}: ${entry.dispatch?.reason ?? (!entry.detected ? "not_detected" : entry.authenticated !== true ? "authentication_unverified" : "dispatch_unavailable")}`).join("; ");
145
+ return {
146
+ ...base,
147
+ roles: [],
148
+ execution: {
149
+ supported: false,
150
+ error: "harness_unavailable",
151
+ reason: `${claudeOnly ? "Claude-only execution requires Claude Code" : "Execution requires a supported harness"} detected, authenticated, and ready for dispatch. ${detail || "No verified harness inventory is available."}`,
152
+ nextSteps: claudeOnly
153
+ ? ["Install Claude Code if missing, authenticate with claude auth login, then confirm claude auth status and kxm harness list. Other harnesses will not be substituted."]
154
+ : ["Install and authenticate a supported harness, then run kxm harness list to check detection, authentication, and dispatch readiness. Pi requires a configured model/provider auth context."],
155
+ },
156
+ };
157
+ }
158
+
159
+ if (writeStep && !oneShotWriterArgs(selected.id)) {
160
+ return {
161
+ ...base,
162
+ roles: [],
163
+ execution: {
164
+ supported: false,
165
+ error: "live_write_unsupported",
166
+ reason: `${bestPattern.template} requires repository writes at step ${writeStep.id}, but the selected ${selected.id} harness has no audited live writer profile. No other harness will be substituted.`,
167
+ nextSteps: [
168
+ `Run the implementation directly in ${claudeOnly ? "Claude Code" : selected.id} in the target worktree, and run the repository's actual verification command.`,
169
+ `For read-only KXM planning, try kxm suggest "Investigate an architecture spike${claudeOnly ? " using Claude only" : ""}"; the shipped spec-and-plan template does not implement changes.`,
170
+ "Installing a definition or creating a run does not execute it. Simulation is not evidence of a bug fix or workflow completion.",
171
+ ],
172
+ },
173
+ };
174
+ }
175
+
176
+ const roles: RoleSpec[] = [];
177
+ for (const step of steps) {
178
+ if (!step.agent) continue;
179
+ const role = roles.find((entry) => entry.agent === step.agent);
180
+ if (role) role.role += `, ${step.id}`;
181
+ else roles.push({ agent: step.agent, harness: selected.id, role: step.id });
182
+ }
183
+ const shell = process.platform === "win32" ? "powershell" : "posix";
184
+ const quotedPrompt = shell === "powershell"
185
+ ? `'${prompt.replace(/['\u2018-\u201b]/g, "$&$&")}'`
186
+ : `'${prompt.replace(/'/g, "'\\''")}'`;
187
+ return {
188
+ ...base,
189
+ roles,
190
+ execution: {
191
+ supported: true,
192
+ prerequisites: [
193
+ "Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
194
+ ...roles.map((role) => `Set harness: ${role.harness} and model: <your authenticated compatible model selector> in .kxm/agents/${role.agent}.yaml. Admit that exact selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
195
+ ...(writeStep ? [
196
+ `Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
197
+ `If .kxm/roster.yaml exists, it must be readable kxm.developer-roster.v1 with a lineup.writer route matching harness: ${selected.id} and the selected writer model (model or vendor/model), status: admitted, and permissions containing edit.`,
198
+ "Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
199
+ ] : []),
200
+ `Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
201
+ ],
202
+ shell,
203
+ createCommand: `kxm run ${bestPattern.id} -- ${quotedPrompt}`,
204
+ driveCommand: "kxm runs drive <runId> --wait",
205
+ statusCommand: "kxm runs status <runId>",
206
+ receiptCommand: "kxm runs receipt <runId>",
153
207
  },
154
- suggestedCommand: bestPattern.defaultCommand(prompt.replace(/"/g, '\\"')),
155
208
  };
156
209
  }
@@ -5,9 +5,28 @@
5
5
  */
6
6
 
7
7
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
9
- import { parse, stringify } from "yaml";
8
+ import { join } from "node:path";
9
+ import { stringify } from "yaml";
10
10
  import { repoConfigDirectory, userConfigDirectory } from "./config.ts";
11
+ import { compileKxmWorkflow } from "./engine-compile.ts";
12
+ import { KxmConfigError, KxmSchemaRegistry, kxmResourceIdentifier, parseRestrictedYaml } from "./project-config.ts";
13
+
14
+ function assertWorkflowId(workflowId: string): void {
15
+ if (!kxmResourceIdentifier(workflowId)) {
16
+ throw new Error(`workflow_id_invalid: '${workflowId}' must be a flat lowercase slug of at most 64 characters, starting with a letter and using letters, digits, single hyphens or underscores; paths and platform-reserved names are not allowed (use 'bug-fix', not 'software-engineering/bug-fix')`);
17
+ }
18
+ }
19
+
20
+ let workflowSchemaRegistry: KxmSchemaRegistry | undefined;
21
+
22
+ function workflowPayload(workflowId: string, content: Record<string, unknown> | string, filePath: string): string {
23
+ const payload = typeof content === "string" ? content : stringify(content);
24
+ const value = parseRestrictedYaml(payload, filePath);
25
+ const issues = (workflowSchemaRegistry ??= new KxmSchemaRegistry()).validate("workflow", value, filePath);
26
+ if (issues.length > 0) throw new KxmConfigError(issues);
27
+ compileKxmWorkflow({ id: workflowId, value, logicalPath: filePath });
28
+ return payload;
29
+ }
11
30
 
12
31
  export interface WorkflowDefSummary {
13
32
  id: string;
@@ -197,9 +216,7 @@ export function parseWorkflowFile(filePath: string): Record<string, unknown> | u
197
216
  if (!existsSync(filePath)) return undefined;
198
217
  try {
199
218
  const raw = readFileSync(filePath, "utf8");
200
- const parsed = parse(raw) as Record<string, unknown>;
201
- if (!parsed || typeof parsed !== "object") return undefined;
202
- return parsed;
219
+ return parseRestrictedYaml(raw, filePath);
203
220
  } catch {
204
221
  return undefined;
205
222
  }
@@ -218,11 +235,11 @@ export function listWorkflowDefinitions(options: {
218
235
  const localDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
219
236
  if (scopeFilter !== "global" && existsSync(localDir)) {
220
237
  for (const entry of readdirSync(localDir)) {
221
- if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
238
+ const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
239
+ if (id && kxmResourceIdentifier(id)) {
222
240
  const filePath = join(localDir, entry);
223
241
  const def = parseWorkflowFile(filePath);
224
242
  if (def) {
225
- const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
226
243
  localDefs.set(id, { def, filePath });
227
244
  }
228
245
  }
@@ -232,11 +249,11 @@ export function listWorkflowDefinitions(options: {
232
249
  const globalDefs = new Map<string, { def: Record<string, unknown>; filePath: string }>();
233
250
  if (scopeFilter !== "local" && existsSync(globalDir)) {
234
251
  for (const entry of readdirSync(globalDir)) {
235
- if (entry.endsWith(".yaml") || entry.endsWith(".yml")) {
252
+ const id = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.endsWith(".yml") ? entry.slice(0, -4) : undefined;
253
+ if (id && kxmResourceIdentifier(id)) {
236
254
  const filePath = join(globalDir, entry);
237
255
  const def = parseWorkflowFile(filePath);
238
256
  if (def) {
239
- const id = (def.id as string) || entry.replace(/\.ya?ml$/i, "");
240
257
  globalDefs.set(id, { def, filePath });
241
258
  }
242
259
  }
@@ -248,7 +265,7 @@ export function listWorkflowDefinitions(options: {
248
265
  const steps = Array.isArray(def.steps) ? def.steps : [];
249
266
  for (const step of steps) {
250
267
  if (step && typeof step === "object") {
251
- const role = (step as Record<string, unknown>).role || (step as Record<string, unknown>).agent;
268
+ const role = (step as Record<string, unknown>).agent;
252
269
  if (typeof role === "string" && !roles.includes(role)) {
253
270
  roles.push(role);
254
271
  }
@@ -298,6 +315,7 @@ export function getWorkflowDefinition(
298
315
  userConfigDir?: string | undefined;
299
316
  } = {},
300
317
  ): { workflow: Record<string, unknown>; scope: "global" | "local"; filePath: string } | undefined {
318
+ assertWorkflowId(workflowId);
301
319
  const scope = options.scope ?? "all";
302
320
  const repoRoot = options.repoRoot ?? process.cwd();
303
321
 
@@ -331,19 +349,21 @@ export function addWorkflowDefinition(
331
349
  dryRun?: boolean | undefined;
332
350
  } = {},
333
351
  ): { id: string; filePath: string; scope: "global" | "local" } {
352
+ assertWorkflowId(workflowId);
334
353
  const scope = options.scope ?? "local";
335
354
  const repoRoot = options.repoRoot ?? process.cwd();
336
- const dir = options.dryRun
337
- ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
338
- : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
355
+ const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
339
356
  const filePath = join(dir, `${workflowId}.yaml`);
340
357
 
341
358
  if (existsSync(filePath) && !options.overwrite) {
342
359
  throw new Error(`workflow_already_exists: workflow '${workflowId}' already exists at ${filePath}`);
343
360
  }
344
361
 
345
- const payload = typeof content === "string" ? content : stringify(content);
346
- if (!options.dryRun) writeFileSync(filePath, payload, "utf8");
362
+ const payload = workflowPayload(workflowId, content, filePath);
363
+ if (!options.dryRun) {
364
+ ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
365
+ writeFileSync(filePath, payload, "utf8");
366
+ }
347
367
  return { id: workflowId, filePath, scope };
348
368
  }
349
369
 
@@ -356,6 +376,7 @@ export function removeWorkflowDefinition(
356
376
  dryRun?: boolean | undefined;
357
377
  } = {},
358
378
  ): { id: string; removed: boolean; filePath: string; scope: "global" | "local" } {
379
+ assertWorkflowId(workflowId);
359
380
  const scope = options.scope ?? "local";
360
381
  const repoRoot = options.repoRoot ?? process.cwd();
361
382
  const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
@@ -392,11 +413,13 @@ export function modifyWorkflowDefinition(
392
413
 
393
414
  const scope = options.scope ?? target.scope;
394
415
  const repoRoot = options.repoRoot ?? process.cwd();
395
- const dir = options.dryRun
396
- ? workflowsDirectory(scope, repoRoot, options.userConfigDir)
397
- : ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
416
+ const dir = workflowsDirectory(scope, repoRoot, options.userConfigDir);
398
417
  const filePath = join(dir, `${workflowId}.yaml`);
399
418
 
400
- if (!options.dryRun) writeFileSync(filePath, stringify(updated), "utf8");
419
+ const payload = workflowPayload(workflowId, updated, filePath);
420
+ if (!options.dryRun) {
421
+ ensureWorkflowsDirectory(scope, repoRoot, options.userConfigDir);
422
+ writeFileSync(filePath, payload, "utf8");
423
+ }
401
424
  return { id: workflowId, workflow: updated, filePath, scope };
402
425
  }