@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
145 lines (134 loc) • 5.4 kB
text/typescript
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { discoverKxmProjectRoot } from "../project-config.ts";
import { CLI_RESULT_SCHEMA, print, type Runtime } from "./types.ts";
export interface AssignSpawnOptions {
cwd: string;
stdio: "inherit";
env: NodeJS.ProcessEnv;
}
export interface AssignSpawnResult {
status: number | null;
error?: Error;
}
/** Test seam. Production leaves `spawn` unset and uses `spawnSync`. */
export const kxmAssignCliSeams: {
spawn?: (command: string, args: readonly string[], options: AssignSpawnOptions) => AssignSpawnResult;
} = {};
export type AssignVerb =
| "run"
| "witness"
| "plan-current"
| "attribute"
| "observe-cost"
| "accept"
| "change-report";
export interface AssignArgs {
manifest?: string;
recordDir?: string;
taskDir?: string;
plan?: string;
sha256?: string;
baseCommit?: string;
expectedGeneration?: string;
classification?: string;
explanationFile?: string;
input?: string;
commit?: string;
critics?: readonly string[];
observedPr?: string;
observedCi?: string;
}
const RUNNER_REL = "scripts/assignment-run.mjs";
function refuse(runtime: Runtime, command: string, error: string, text: string): number {
print(runtime.io, runtime.json, { ok: false, command, error }, text);
return 1;
}
function flag(value: string | undefined): string {
if (value === undefined) throw new Error("assign invocation missing a parsed flag");
return value;
}
/** Argv after `node scripts/assignment-run.mjs`, in the recipe's flag order.
* The runner's usage text documents no `--json` flag. `accept` prints JSON
* and takes none, so the global `--json` flag stays on this command. */
function runnerArgv(subcommand: AssignVerb, args: AssignArgs): string[] {
switch (subcommand) {
case "run":
return ["run", "--manifest", flag(args.manifest)];
case "witness":
return ["witness", "--record-dir", flag(args.recordDir)];
case "plan-current":
return [
"plan-current",
"--task-dir", flag(args.taskDir),
"--plan", flag(args.plan),
"--sha256", flag(args.sha256),
"--base-commit", flag(args.baseCommit),
"--expected-generation", flag(args.expectedGeneration),
];
case "attribute":
return [
"attribute",
"--task-dir", flag(args.taskDir),
"--record-dir", flag(args.recordDir),
"--class", flag(args.classification),
"--explanation-file", flag(args.explanationFile),
];
case "observe-cost":
return ["observe-cost", "--task-dir", flag(args.taskDir), "--input", flag(args.input)];
case "accept": {
const argv = [
"accept",
"--task-dir", flag(args.taskDir),
"--commit", flag(args.commit),
"--record-dir", flag(args.recordDir),
];
for (const critic of args.critics ?? []) argv.push("--critic", critic);
if (args.observedPr !== undefined) argv.push("--observed-pr", args.observedPr);
if (args.observedCi !== undefined) argv.push("--observed-ci", args.observedCi);
return argv;
}
case "change-report":
return ["change-report", "--task-dir", flag(args.taskDir)];
default: {
const unreachable: never = subcommand;
throw new Error(`unknown assign verb ${String(unreachable)}`);
}
}
}
export async function cmdAssign(runtime: Runtime, subcommand: AssignVerb, args: AssignArgs): Promise<number> {
const command = `assign ${subcommand}`;
const root = discoverKxmProjectRoot(runtime.cwd);
if (!root) return refuse(runtime, command, "project_required", `${command} requires a KXM project (run kxm init first)`);
if (!existsSync(join(root, "scripts", "assignment-run.mjs"))) {
return refuse(runtime, command, "assign_runner_missing", `${command} requires scripts/assignment-run.mjs in the project root`);
}
const argv = ["node", RUNNER_REL, ...runnerArgv(subcommand, args)];
if (runtime.dryRun) {
// print() redacts every 64-hex string. plan-current passes a plan sha256
// through unchanged, so the dry-run argv is written directly.
const payload = { schema: CLI_RESULT_SCHEMA, ok: true, command, dryRun: true, argv };
runtime.io.stdout(runtime.json ? `${JSON.stringify(payload)}\n` : `${argv.join(" ")}\n`);
return 0;
}
// Never load a `.env` from the working directory. The justfile keeps
// `set dotenv-load` off for the same reason: a gitignored `.env` could set
// `NODE_OPTIONS`, and that value runs before the runner validates identity,
// tree, or roster. Pass the process environment through unchanged.
const spawn = kxmAssignCliSeams.spawn ?? ((commandName: string, commandArgs: readonly string[], options: AssignSpawnOptions): AssignSpawnResult => {
const result = spawnSync(commandName, commandArgs, {
cwd: options.cwd,
stdio: options.stdio,
env: options.env,
windowsHide: true,
});
return { status: result.status, ...(result.error ? { error: result.error } : {}) };
});
const result = spawn("node", argv.slice(1), { cwd: root, stdio: "inherit", env: runtime.env });
if (result.status === null) {
const detail = result.error?.message ?? "the assignment runner could not be started";
return refuse(runtime, command, "assign_spawn_failed", detail);
}
return result.status;
}