@kontextmind/kxm 0.7.104 → 0.7.106

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.
@@ -9,7 +9,6 @@ import {
9
9
  listGoals,
10
10
  listTasks,
11
11
  getTask,
12
- updateTaskStatus,
13
12
  syncTaskWithTracker,
14
13
  goalFilePath,
15
14
  taskFilePath,
@@ -27,40 +26,71 @@ import { readSessionTokenFromDisk } from "../commands.ts";
27
26
  import { print, printPlan, type Runtime } from "./types.ts";
28
27
  import { cmdKxmRun, resolveKxmRunTarget } from "./project.ts";
29
28
 
30
- export async function cmdSuggest(runtime: Runtime, promptParts: string[]): Promise<number> {
29
+ export async function cmdSuggest(
30
+ runtime: Runtime,
31
+ promptParts: string[],
32
+ probeHarnesses = probeHarnessesAsync,
33
+ ): Promise<number> {
31
34
  try {
32
35
  const prompt = promptParts.join(" ").trim();
33
36
  if (!prompt) {
34
- runtime.io.stderr("prompt must be non-empty\n");
37
+ print(runtime.io, runtime.json, { ok: false, command: "suggest", error: "prompt_required" }, "prompt must be non-empty");
35
38
  return 2;
36
39
  }
37
- const inventory = await probeHarnessesAsync({ env: runtime.env });
38
- const availableHarnesses = inventory.harnesses.map((h) => ({
39
- harness: h.id,
40
- auth: h.authenticated === true ? "authenticated" : "unauthenticated",
41
- }));
42
- const suggestion = suggestWorkflowAndRoles(prompt, { availableHarnesses });
43
-
40
+ // Native authentication probes may initialize state. A dry run must not invoke them.
41
+ const inventory = runtime.dryRun ? undefined : await probeHarnesses({ env: runtime.env });
42
+ const suggestion = suggestWorkflowAndRoles(prompt, { availableHarnesses: inventory?.harnesses });
43
+ if (suggestion.execution.supported && [".yaml", ".yml"].some((extension) =>
44
+ existsSync(join(runtime.cwd, ".kxm", "workflows", `${suggestion.workflowId}${extension}`)))) {
45
+ suggestion.roles = [];
46
+ suggestion.execution = {
47
+ supported: false,
48
+ error: "workflow_already_exists",
49
+ reason: `Workflow ${suggestion.workflowId} already exists; its agents and permissions may differ from the recommended template.`,
50
+ nextSteps: ["Review the existing definition and every agent's harness/model before running it, or install the recommended template under a fresh flat ID. No existing workflow is overwritten or recommended for execution."],
51
+ };
52
+ }
53
+ const execution = suggestion.execution;
44
54
  const text = [
45
55
  `Suggested Workflow: ${suggestion.workflowId} (${suggestion.area})`,
56
+ `Template: ${suggestion.template}`,
46
57
  `Confidence: ${(suggestion.confidence * 100).toFixed(0)}%`,
47
58
  `Reasons: ${suggestion.reasons.join("; ")}`,
48
59
  `Suggested Skills: ${suggestion.suggestedSkills.join(", ") || "none"}`,
49
- `Roles:`,
50
- ` Planner: ${suggestion.roles.planner.harness} (${suggestion.roles.planner.model})`,
51
- ` Writer: ${suggestion.roles.writer.harness} (${suggestion.roles.writer.model})`,
52
- ` Critics: ${suggestion.roles.critics.map((c) => `${c.harness}:${c.model}`).join(", ")}`,
53
- ` Verifier: ${suggestion.roles.verifier.command}`,
54
- ``,
55
- `Execute with:`,
60
+ "",
61
+ "Install definition only (requires kxm init; does not execute):",
56
62
  ` ${suggestion.suggestedCommand}`,
63
+ "",
64
+ ...(execution.supported ? [
65
+ "Suggested agent routing (not applied):",
66
+ ...suggestion.roles.map((role) => ` ${role.agent}: ${role.harness} (${role.role}; use a configured compatible model)`),
67
+ "Prerequisites:",
68
+ ...execution.prerequisites.map((step) => ` ${step}`),
69
+ `Create a run only (${execution.shell}; does not execute steps):`,
70
+ ` ${execution.createCommand}`,
71
+ "Then drive the returned run ID with live calls and inspect its result:",
72
+ ` ${execution.driveCommand}`,
73
+ ` ${execution.statusCommand}`,
74
+ ` ${execution.receiptCommand}`,
75
+ ] : [
76
+ `Execution unavailable: ${execution.reason}`,
77
+ ...execution.nextSteps.map((step) => ` ${step}`),
78
+ ...(runtime.dryRun ? ["Harness authentication was not probed during --dry-run."] : []),
79
+ ]),
57
80
  ].join("\n");
58
81
 
59
- print(runtime.io, runtime.json, { ok: true, command: "suggest", prompt, ...suggestion }, text);
60
- return 0;
82
+ print(runtime.io, runtime.json, {
83
+ ok: execution.supported,
84
+ command: "suggest",
85
+ prompt,
86
+ ...suggestion,
87
+ ...(runtime.dryRun ? { dryRun: true } : {}),
88
+ ...(!execution.supported ? { error: execution.error } : {}),
89
+ }, text);
90
+ return execution.supported ? 0 : 1;
61
91
  } catch (error) {
62
92
  const message = error instanceof Error ? error.message : String(error);
63
- runtime.io.stderr(`suggest failed: ${message}\n`);
93
+ print(runtime.io, runtime.json, { ok: false, command: "suggest", error: "suggest_failed", detail: message }, `suggest failed: ${message}`);
64
94
  return 1;
65
95
  }
66
96
  }
@@ -196,27 +226,21 @@ export async function cmdTaskRun(runtime: Runtime, taskId: string): Promise<numb
196
226
  runtime.io.stderr(`Task ${taskId} not found\n`);
197
227
  return 1;
198
228
  }
199
- const workflow = task.assignedWorkflow ?? "default";
229
+ const workflow = task.assignedWorkflow;
200
230
  if (runtime.dryRun) {
201
- const target = resolveKxmRunTarget(runtime, workflow);
231
+ const target = resolveKxmRunTarget(runtime, workflow, true);
202
232
  if (typeof target === "number") return target;
203
- const started = updateTaskStatus(runtime.cwd, taskId, "in_progress", { dryRun: true });
204
233
  printPlan(
205
234
  runtime,
206
- { command: "task run", taskId, ...target, status: started.status },
235
+ { command: "task run", taskId, ...target, status: task.status, execution: { status: "not_started", mode: "live" } },
207
236
  [
208
237
  { action: "request", target: "POST kxm-runtime /v1/runs (starts the Runtime supervisor if it is not running)" },
209
- { action: "write", target: taskFilePath(runtime.cwd, taskId) },
210
238
  ],
211
- `run workflow ${workflow} for task ${taskId}, then mark it ${started.status}`,
239
+ `create workflow ${target.workflowId} for task ${taskId}; task status stays ${task.status} until work actually starts`,
212
240
  );
213
241
  return 0;
214
242
  }
215
- const exitCode = await cmdKxmRun(runtime, workflow, [task.objective]);
216
- if (exitCode === 0) {
217
- updateTaskStatus(runtime.cwd, taskId, "in_progress");
218
- }
219
- return exitCode;
243
+ return await cmdKxmRun(runtime, workflow, [task.objective], true);
220
244
  } catch (error) {
221
245
  const message = error instanceof Error ? error.message : String(error);
222
246
  runtime.io.stderr(`task run failed: ${message}\n`);
@@ -11,7 +11,6 @@ import {
11
11
  addWorkflowDefinition,
12
12
  removeWorkflowDefinition,
13
13
  modifyWorkflowDefinition,
14
- parseWorkflowFile,
15
14
  scaffoldWorkflowDefinition,
16
15
  WORKFLOW_TEMPLATES,
17
16
  } from "../workflow-manager.ts";
@@ -23,7 +22,7 @@ import {
23
22
  type WorkflowJournalEntry,
24
23
  type WorkflowRun,
25
24
  } from "../workflow.ts";
26
- import { discoverKxmProjectRoot, kxmWorkflowWriteIssues } from "../project-config.ts";
25
+ import { discoverKxmProjectRoot, kxmWorkflowWriteIssues, parseRestrictedYaml } from "../project-config.ts";
27
26
  import { ensureKxmSupervisor, kxmRuntimeRequest } from "../runtime-supervisor.ts";
28
27
  import { projectRuntimeOwnsRun } from "../runtime-store.ts";
29
28
  import type { WorkerOutcome } from "../envelope.ts";
@@ -215,63 +214,63 @@ export async function cmdWorkflowAdd(
215
214
  },
216
215
  ): Promise<number> {
217
216
  const scope = options.scope ?? "local";
218
- const refuse = (error: string, text: string): number => {
219
- print(runtime.io, runtime.json, { ok: false, command: "workflow add", error }, `workflow add failed: ${text}`);
220
- return 2;
217
+ const refuse = (error: string, message: string, code = 2): number => {
218
+ print(runtime.io, runtime.json, { ok: false, command: "workflow add", error, message }, `workflow add failed: ${message}`);
219
+ return code;
221
220
  };
222
- let content: Record<string, unknown> | string | undefined;
223
- if (options.template !== undefined) {
224
- if (options.file !== undefined || options.pick !== undefined) {
225
- return refuse("workflow_add_conflict", "--template cannot be combined with --file or --pick");
226
- }
227
- if (!workflowId) return refuse("workflow_id_required", "usage: kxm workflow add <workflowId> --template <name>");
228
- const template = Object.hasOwn(WORKFLOW_TEMPLATES, options.template) ? WORKFLOW_TEMPLATES[options.template] : undefined;
229
- if (!template) {
230
- return refuse("workflow_template_unknown", `unknown template ${options.template}; choose ${Object.keys(WORKFLOW_TEMPLATES).join(", ")}`);
231
- }
232
- content = { ...template, ...(options.description ? { description: options.description } : {}) };
233
- } else if (!workflowId || options.pick) {
234
- const candidates: PickCandidate[] = Object.entries(WORKFLOW_TEMPLATES).map(([id, tmpl]) => ({
235
- id,
236
- description: String(tmpl.description ?? id),
237
- label: "template",
238
- payload: tmpl,
239
- }));
240
- if (scope === "local") {
241
- const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
242
- for (const gd of globalDefs) {
243
- // Read the listed file itself: it may be `.yml`, which a lookup by `<id>.yaml` misses.
244
- const definition = parseWorkflowFile(gd.filePath);
245
- if (definition && !candidates.some((c) => c.id === gd.id)) {
246
- candidates.push({ id: gd.id, description: gd.description, label: "global", payload: definition });
221
+ try {
222
+ let content: Record<string, unknown> | string | undefined;
223
+ if (options.template !== undefined) {
224
+ if (options.file !== undefined || options.pick !== undefined) {
225
+ return refuse("workflow_add_conflict", "--template cannot be combined with --file or --pick");
226
+ }
227
+ if (!workflowId) return refuse("workflow_id_required", "usage: kxm workflow add <workflowId> --template <name>");
228
+ const template = Object.hasOwn(WORKFLOW_TEMPLATES, options.template) ? WORKFLOW_TEMPLATES[options.template] : undefined;
229
+ if (!template) {
230
+ return refuse("workflow_template_unknown", `unknown template ${options.template}; choose ${Object.keys(WORKFLOW_TEMPLATES).join(", ")}`);
231
+ }
232
+ content = { ...template, ...(options.description !== undefined ? { description: options.description } : {}) };
233
+ } else if (!workflowId || options.pick) {
234
+ const candidates: PickCandidate[] = Object.entries(WORKFLOW_TEMPLATES).map(([id, tmpl]) => ({
235
+ id,
236
+ description: String(tmpl.description ?? id),
237
+ label: "template",
238
+ payload: tmpl,
239
+ }));
240
+ if (scope === "local") {
241
+ const globalDefs = listWorkflowDefinitions({ scope: "global", userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
242
+ for (const gd of globalDefs) {
243
+ // Read the listed path when selected: globals may use `.yml`, not only `.yaml`.
244
+ if (!candidates.some((c) => c.id === gd.id)) {
245
+ candidates.push({ id: gd.id, description: gd.description, label: "global", payload: gd.filePath });
246
+ }
247
247
  }
248
248
  }
249
- }
250
- const picked = await resolvePickItem(runtime.io, `Select a workflow template to add (${scope})`, candidates, options.pick, runtime.env);
251
- if (!picked) {
252
- if (!workflowId) {
253
- runtime.io.stderr("workflow add failed: missing workflowId or pick selection\n");
254
- return 1;
249
+ const picked = await resolvePickItem(runtime.io, `Select a workflow template to add (${scope})`, candidates, options.pick, runtime.env);
250
+ if (!picked) {
251
+ return refuse("workflow_id_required", "provide a workflowId or select an available workflow with --pick <id>", 1);
255
252
  }
256
- } else {
257
- workflowId = picked.id;
258
- if (!options.file && picked.payload) {
259
- content = {
260
- ...picked.payload,
261
- ...(options.description ? { description: options.description } : {}),
262
- };
253
+ workflowId ??= picked.id;
254
+ if (options.file === undefined) {
255
+ const pickedContent = typeof picked.payload === "string"
256
+ ? readFileSync(picked.payload, "utf8")
257
+ : picked.payload;
258
+ content = options.description !== undefined
259
+ ? {
260
+ ...(typeof pickedContent === "string" ? parseRestrictedYaml(pickedContent, String(picked.payload)) : pickedContent),
261
+ description: options.description,
262
+ }
263
+ : pickedContent;
263
264
  }
264
265
  }
265
- }
266
266
 
267
- if (options.file) {
268
- const filePath = resolve(runtime.cwd, options.file);
269
- content = readFileSync(filePath, "utf8");
270
- } else if (!content) {
271
- content = scaffoldWorkflowDefinition(options.description || `Workflow ${workflowId}`);
272
- }
267
+ if (options.file !== undefined) {
268
+ const filePath = resolve(runtime.cwd, options.file);
269
+ content = readFileSync(filePath, "utf8");
270
+ } else if (!content) {
271
+ content = scaffoldWorkflowDefinition(options.description || `Workflow ${workflowId}`);
272
+ }
273
273
 
274
- try {
275
274
  const document = typeof content === "string" ? content : stringify(content);
276
275
  // A local workflow is read by the project loader, which refuses the whole
277
276
  // project over one bad file, so it is checked by that loader before it lands.
@@ -286,7 +285,7 @@ export async function cmdWorkflowAdd(
286
285
  print(
287
286
  runtime.io,
288
287
  runtime.json,
289
- { ok: false, command: "workflow add", error: "workflow_invalid", issues },
288
+ { ok: false, command: "workflow add", error: "workflow_invalid", message: "with this workflow the project would not load, so nothing was written", issues },
290
289
  `workflow add failed: with this workflow the project would not load, so nothing was written\n${issues.map((entry) => ` ${entry.file}: ${entry.code}: ${entry.message}`).join("\n")}`,
291
290
  );
292
291
  return 2;
@@ -312,8 +311,7 @@ export async function cmdWorkflowAdd(
312
311
  );
313
312
  return 0;
314
313
  } catch (err: unknown) {
315
- runtime.io.stderr(`workflow add failed: ${(err as Error).message}\n`);
316
- return 1;
314
+ return refuse("workflow_add_failed", err instanceof Error ? err.message : String(err), 1);
317
315
  }
318
316
  }
319
317
 
@@ -432,7 +432,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
432
432
  result.code = await cmdRestore(runtimeFrom(ctx, this), manifest, options);
433
433
  });
434
434
 
435
- addGlobalOptions(program.command("run").description("Create a KXM run (offline-first; kxm runs drive <runId> --simulated executes it model-free)")
435
+ addGlobalOptions(program.command("run").description("Create a KXM run without executing steps; follow its prerequisites, then kxm runs drive <runId> --wait")
436
436
  .argument("[workflow]", "Workflow id to run")
437
437
  .argument("[prompt...]", "Run prompt (events keep its hash; the full text is kept in a local 0600 sidecar file)")
438
438
  .action(async function runAction(this: Command, workflow: string | undefined, promptParts: string[]) {
@@ -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.106";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -1124,6 +1124,7 @@ function validateBundle(
1124
1124
  options: KxmConfigOptions,
1125
1125
  gateRegistry?: KxmResource,
1126
1126
  projectRoot?: string,
1127
+ writerRole?: string,
1127
1128
  ): KxmConfigIssue[] {
1128
1129
  const issues: KxmConfigIssue[] = [];
1129
1130
  const entries = valuesOf(project.value, "repositories").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
@@ -1200,9 +1201,9 @@ function validateBundle(
1200
1201
  if (projectRoot) {
1201
1202
  const writerRolePath = join(projectRoot, ".kxm", "roles", "writer.yaml");
1202
1203
  const implementerAgent = agents.get("implementer") ?? agents.get("writer");
1203
- if (existsSync(writerRolePath) && implementerAgent) {
1204
+ if ((writerRole !== undefined || existsSync(writerRolePath)) && implementerAgent) {
1204
1205
  try {
1205
- const rawRole = parseRestrictedYaml(readFileSync(writerRolePath, "utf8"));
1206
+ const rawRole = parseRestrictedYaml(writerRole ?? readFileSync(writerRolePath, "utf8"));
1206
1207
  const roleObj = objectValue(rawRole);
1207
1208
  const rosterEntries = valuesOf(roleObj ?? {}, "roster")
1208
1209
  .map((candidate) => objectValue(candidate))
@@ -1304,8 +1305,32 @@ export function kxmWorkflowWriteIssues(
1304
1305
  document: string,
1305
1306
  options: KxmConfigOptions = {},
1306
1307
  ): readonly KxmConfigIssue[] {
1308
+ return candidateIssues(projectRoot, options, { kind: "workflow", id: workflowId, document });
1309
+ }
1310
+
1311
+ /**
1312
+ * What `loadKxmProject` would refuse once `.kxm/roles/<roleId>.yaml` held `document`,
1313
+ * found without writing it. The loader reads only the writer role, so for any other
1314
+ * id this is the project's load as it stands.
1315
+ */
1316
+ export function kxmRoleWriteIssues(
1317
+ projectRoot: string,
1318
+ roleId: string,
1319
+ document: string,
1320
+ options: KxmConfigOptions = {},
1321
+ ): readonly KxmConfigIssue[] {
1322
+ return candidateIssues(projectRoot, options, { kind: "role", id: roleId, document });
1323
+ }
1324
+
1325
+ interface WriteCandidate {
1326
+ kind: "workflow" | "role";
1327
+ id: string;
1328
+ document: string;
1329
+ }
1330
+
1331
+ function candidateIssues(projectRoot: string, options: KxmConfigOptions, candidate: WriteCandidate): readonly KxmConfigIssue[] {
1307
1332
  try {
1308
- loadProjectBundle(projectRoot, options, { id: workflowId, document });
1333
+ loadProjectBundle(projectRoot, options, candidate);
1309
1334
  return [];
1310
1335
  } catch (error) {
1311
1336
  if (error instanceof KxmConfigError) return error.issues;
@@ -1316,8 +1341,11 @@ export function kxmWorkflowWriteIssues(
1316
1341
  function loadProjectBundle(
1317
1342
  projectRoot: string,
1318
1343
  options: KxmConfigOptions,
1319
- workflowCandidate?: { id: string; document: string },
1344
+ candidate?: WriteCandidate,
1320
1345
  ): KxmProjectBundle {
1346
+ const workflowCandidate = candidate?.kind === "workflow" ? candidate : undefined;
1347
+ // A case-insensitive filesystem serves `Writer.yaml` to the loader's `writer.yaml` lookup.
1348
+ const writerRole = candidate?.kind === "role" && candidate.id.toLocaleLowerCase("en-US") === "writer" ? candidate.document : undefined;
1321
1349
  assertNoRegisteredGates(options);
1322
1350
  const root = resolve(projectRoot);
1323
1351
  const legacyPresent = legacyConfigFilesAt(root);
@@ -1484,7 +1512,7 @@ function loadProjectBundle(
1484
1512
  }
1485
1513
  if (loadIssues.length > 0) throw new KxmConfigError(loadIssues);
1486
1514
 
1487
- const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root);
1515
+ const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root, writerRole);
1488
1516
  if (issues.length > 0) throw new KxmConfigError(issues);
1489
1517
  const resources = [project, ...repositories.values(), ...agents.values(), ...models.values(), ...workflows.values(), ...environments, ...(gateRegistry ? [gateRegistry] : [])]
1490
1518
  .sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath));