SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
31.5 KB · 866 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/cli/commands.ts4 * Description: Slash-command implementations backed by real services — registered into the TUI command registry.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { join } from "node:path";11import type { ResolvedConfig } from "../config/index.js";12import { MEMORY_FILE, parseMemory, purgeCandidates } from "../memory/index.js";13import { GoalStore, daemonDirFor, readDaemonStatus } from "../daemon/index.js";14import {15  SessionLog,16  buildUsageTotals,17  forkSession,18  listForkCheckpoints,19  renderSessionDiff,20  summarizeSessionRun,21} from "../session/index.js";22import type { DurableEvent, Phase } from "../session/index.js";23import { mergeSubtaskBranch } from "../tasks/index.js";24import type { SubtaskManager } from "../tasks/index.js";25import type { CommandDef } from "../tui/index.js";26import type { PaletteItem } from "../tui/index.js";27import type { Engine } from "./engine.js";28import { replaySession } from "./replay.js";29import { listSessions } from "./sessions.js";30import { createSubtaskManager, workspaceExec } from "./subtasks.js";3132// ───────────────────────── dependencies ─────────────────────────3334/** The slice of the TUI the commands drive — structural, stubbable in tests. */35export interface CommandUi {36  printBlock(lines: string[]): void;37  openSelector(items: PaletteItem[], onSelect: (id: string) => void): void;38  setModelLabel(model: string): void;39}4041export interface CliCommandDeps {42  ui: CommandUi;43  /** Live engine accessor — the engine is rebuilt on /new and /resume. */44  engine(): Engine;45  config: ResolvedConfig;46  cwd: string;47  sessionsDir: string;48  projectHash: string;49  actions: {50    newSession(): void;51    resumeSession(sessionId: string): void;52    quit(): void;53    /** Consume the next composer submit as command input instead of a model turn. */54    captureNextSubmit?(consume: (text: string) => void): void;55  };56  /** Live model listing (SDK /v1/models); injectable for tests. */57  listModels?: () => Promise<{ id: string; displayName?: string }[]>;58}5960/** Every slash command the CLI layer provides (TUI built-ins add /diff-expand, /help, /quit). */61export const CLI_SLASH_COMMANDS: readonly string[] = [62  "/model",63  "/config",64  "/permissions",65  "/sessions",66  "/resume",67  "/new",68  "/rename",69  "/clear",70  "/compact",71  "/cost",72  "/context",73  "/diff",74  "/processes",75  "/status",76  "/phase",77  "/fork",78  "/sdiff",79  "/replay",80  "/memory",81  "/verify",82  "/spawn",83  "/tasks",84  "/merge",85  "/goals",86];8788// ───────────────────────── pure report builders ─────────────────────────8990/**91 * Session cost report from REAL usage events only (Absolute Rule #4):92 * every number is summed from ModelResponseCompleted.usage. No pricing is93 * configured in V1, so no dollar figure is invented.94 */95export function costReportLines(events: readonly DurableEvent[]): string[] {96  const usage = buildUsageTotals(events);97  const lines: string[] = ["", " cost · this session (real API usage)"];98  const fmt = (n: number): string => n.toLocaleString("en-US").padStart(12);99  const row = (label: string, u: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; requests: number }): string[] => [100    `   ${label}`,101    `     requests      ${String(u.requests).padStart(12)}`,102    `     input tokens  ${fmt(u.inputTokens)}`,103    `     output tokens ${fmt(u.outputTokens)}`,104    `     cache write   ${fmt(u.cacheWriteTokens)}`,105    `     cache read    ${fmt(u.cacheReadTokens)}`,106  ];107  for (const [model, perModel] of Object.entries(usage.perModel)) {108    lines.push(...row(model, perModel));109  }110  if (Object.keys(usage.perModel).length > 1) {111    lines.push(...row("total", usage.totals));112  }113  if (usage.totals.requests === 0) {114    lines.push("   no model requests yet");115  }116  lines.push("   cost estimate  n/a — no pricing configured (tokens above are exact)");117  return lines;118}119120function elapsed(sinceMs: number): string {121  const s = Math.max(0, Math.floor((Date.now() - sinceMs) / 1000));122  const mm = String(Math.floor(s / 60)).padStart(2, "0");123  const ss = String(s % 60).padStart(2, "0");124  return `${mm}:${ss}`;125}126127// ───────────────────────── command construction ─────────────────────────128129/** Build every CLI slash command. Pure with respect to the registry. */130export function buildCliCommands(deps: CliCommandDeps): CommandDef[] {131  const { ui } = deps;132  const print = (lines: string[]): void => {133    ui.printBlock(lines);134  };135  const printError = (what: string, error: unknown): void => {136    const message = error instanceof Error ? error.message : String(error);137    print(["", ` ${what} failed`, `   ${message}`]);138  };139  const runAsync = (what: string, fn: () => Promise<void>): void => {140    fn().catch((error: unknown) => {141      printError(what, error);142      deps.engine().logger.error(`${what} command failed`, {143        error: error instanceof Error ? error.message : String(error),144      });145    });146  };147148  const commands: CommandDef[] = [];149150  // /model — selector listing live models when reachable, config models otherwise.151  commands.push({152    id: "model.select",153    title: "Change model",154    slash: "/model",155    description: "switch the Anthropic model",156    run: () =>157      runAsync("/model", async () => {158        const engine = deps.engine();159        let models: { id: string; displayName?: string }[] = [];160        if (deps.listModels !== undefined) {161          try {162            models = await deps.listModels();163          } catch {164            models = [];165          }166        }167        if (models.length === 0) {168          const fallback = new Set([deps.config.model, deps.config.auxModel, engine.model]);169          models = [...fallback].map((id) => ({ id }));170        }171        const items: PaletteItem[] = models.map((m) => {172          const item: PaletteItem = { id: m.id, label: m.id };173          if (m.displayName !== undefined) item.detail = m.displayName;174          if (m.id === engine.model) item.detail = `${item.detail ?? ""} · current`.trim();175          return item;176        });177        ui.openSelector(items, (id) => {178          const current = deps.engine();179          if (current.turnActive) {180            print(["", " /model — cannot switch while a turn is running (Esc to interrupt first)"]);181            return;182          }183          current.switchModel(id, "user");184          ui.setModelLabel(id);185          print(["", ` model switched to ${id} (new prompt-cache lineage)`]);186        });187      }),188  });189190  // /config — redacted resolved configuration + sources.191  commands.push({192    id: "config.open",193    title: "Configuration",194    slash: "/config",195    description: "resolved configuration and sources",196    run: () => {197      const engine = deps.engine();198      const lines = [199        "",200        " configuration",201        `   model             ${engine.model}`,202        `   auxModel          ${deps.config.auxModel}`,203        `   thinking          ${deps.config.thinking}`,204        `   maxOutputTokens   ${deps.config.maxOutputTokens}`,205        `   apiKey            ${deps.config.hasApiKey ? "[redacted]" : "(not set)"}`,206        "   sources (highest precedence first)",207        ...(deps.config.sources.length > 0208          ? deps.config.sources.map((s) => `     ${s}`)209          : ["     defaults only"]),210        "   edit: .khaelor/config.json (project) · ~/.khaelor/config.json (user)",211      ];212      print(lines);213    },214  });215216  // /permissions — the effective rule stack.217  commands.push({218    id: "permissions.open",219    title: "Permissions",220    slash: "/permissions",221    description: "effective permission rules",222    run: () => {223      const rules = deps.engine().permissions.effectiveRules();224      const lines = ["", " permissions · effective rules (later rules win)"];225      for (const rule of rules) {226        const pattern = rule.pattern ?? "*";227        lines.push(228          `   ${rule.action.padEnd(5)} ${rule.capability.padEnd(26)} ${pattern.padEnd(24)} ${rule.source ?? "default"}`,229        );230      }231      lines.push(`   ${rules.length} rules · unmatched destructive capabilities ask`);232      print(lines);233    },234  });235236  // /sessions — list from the session directory.237  commands.push({238    id: "sessions.open",239    title: "Sessions",240    slash: "/sessions",241    description: "list this project's sessions",242    run: () => {243      const sessions = listSessions(deps.sessionsDir, deps.projectHash);244      const current = deps.engine().session.sessionId;245      const lines = ["", ` sessions · ${deps.cwd}`];246      if (sessions.length === 0) lines.push("   none yet");247      for (const s of sessions.slice(0, 20)) {248        const marker = s.sessionId === current ? "●" : " ";249        const when = new Date(s.updatedAt).toISOString().slice(0, 16).replace("T", " ");250        lines.push(` ${marker} ${s.sessionId}  ${when}  ${s.preview}`);251      }252      lines.push("   /resume opens a session picker");253      print(lines);254    },255  });256257  // /resume — picker over recorded sessions; resume = replay.258  commands.push({259    id: "sessions.resume",260    title: "Resume session",261    slash: "/resume",262    description: "resume a previous session (replay)",263    run: () => {264      const current = deps.engine().session.sessionId;265      const sessions = listSessions(deps.sessionsDir, deps.projectHash).filter(266        (s) => s.sessionId !== current,267      );268      if (sessions.length === 0) {269        print(["", " no other sessions to resume in this project"]);270        return;271      }272      const items: PaletteItem[] = sessions.slice(0, 30).map((s) => ({273        id: s.sessionId,274        label: s.preview,275        detail: s.sessionId,276      }));277      ui.openSelector(items, (id) => {278        deps.actions.resumeSession(id);279      });280    },281  });282283  // /new and /clear — a fresh session; logs are never truncated (§5.5).284  commands.push({285    id: "session.new",286    title: "New session",287    slash: "/new",288    description: "start a fresh session",289    run: () => deps.actions.newSession(),290  });291  // /rename — retitle the current session (§8). The next composer submit is the title.292  commands.push({293    id: "session.rename",294    title: "Rename session",295    slash: "/rename",296    description: "rename the current session",297    run: () => {298      const capture = deps.actions.captureNextSubmit;299      if (capture === undefined) {300        print(["", " /rename is unavailable in this mode"]);301        return;302      }303      print(["", " rename — type the new session title and press Enter (empty cancels)"]);304      capture((text) => {305        const title = text.trim();306        if (title === "") {307          print([" rename cancelled"]);308          return;309        }310        deps.engine().session.publishDurable({311          type: "session.renamed",312          payload: { title },313        });314        print([` session renamed · ${title}`]);315      });316    },317  });318  commands.push({319    id: "session.clear",320    title: "Clear",321    slash: "/clear",322    description: "start a fresh session (the old log is kept)",323    run: () => deps.actions.newSession(),324  });325326  // /compact — manual compaction: prune first, then summarize-compact.327  commands.push({328    id: "context.compact",329    title: "Compact context",330    slash: "/compact",331    description: "compact the conversation context now",332    run: () =>333      runAsync("/compact", async () => {334        const engine = deps.engine();335        if (engine.turnActive) {336          print(["", " /compact — wait for the current turn to finish (or Esc to interrupt)"]);337          return;338        }339        const events = engine.session.events();340        const prune = engine.contextEngine.pruneToolResults({ events });341        if (prune.toolUseIds.length > 0) {342          engine.session.publishDurable({ type: "context.pruned", payload: prune });343          print([344            "",345            ` context pruned · ${prune.toolUseIds.length} old tool results blanked`,346            `   ~${prune.tokensReclaimedEstimate.toLocaleString("en-US")} tokens reclaimed (estimate)`,347          ]);348          return;349        }350        const checkpoint = await engine.contextEngine.compress({ events });351        engine.session.publishDurable({ type: "context.compacted", payload: checkpoint });352        print([353          "",354          ` context compacted · events ${checkpoint.cut.fromSeq}–${checkpoint.cut.toSeq} → checkpoint`,355          `   summary model ${checkpoint.summaryModel} · trigger ${checkpoint.trigger}`,356        ]);357      }),358  });359360  // /cost — real usage projection over the durable log.361  commands.push({362    id: "cost.show",363    title: "Show cost",364    slash: "/cost",365    description: "session token usage from real API data",366    run: () => {367      print(costReportLines(deps.engine().session.events()));368    },369  });370371  // /context — budget breakdown from the Context Engine.372  commands.push({373    id: "context.open",374    title: "Context inspector",375    slash: "/context",376    description: "context budget breakdown",377    run: () =>378      runAsync("/context", async () => {379        const engine = deps.engine();380        const built = await engine.contextEngine.selectContext({381          events: engine.session.events(),382        });383        const budget = engine.budget;384        const lines = ["", " context · section estimates (~4 chars/token)"];385        for (const section of built.stats.sections) {386          lines.push(387            `   ${section.name.padEnd(28)} ~${section.estimatedTokens.toLocaleString("en-US").padStart(9)} tokens`,388          );389        }390        lines.push(391          `   ${"total (estimated)".padEnd(28)} ~${built.stats.estimatedInputTokens.toLocaleString("en-US").padStart(9)} tokens`,392        );393        lines.push(394          `   window ${budget.modelWindow.toLocaleString("en-US")} · usable ${budget.usableWindow.toLocaleString("en-US")} (output + compaction reserve held back)`,395        );396        if (budget.hasObservedUsage) {397          lines.push(398            `   last real total ${budget.lastTotalTokens.toLocaleString("en-US")} tokens · pressure ${(budget.pressure() * 100).toFixed(0)}%`,399          );400        } else {401          lines.push("   no real usage observed yet this session");402        }403        print(lines);404      }),405  });406407  // /diff — working-tree changes vs the recorded baseline, attributed.408  commands.push({409    id: "diff.show",410    title: "View diff",411    slash: "/diff",412    key: "d",413    description: "working-tree changes since the session baseline",414    run: () =>415      runAsync("/diff", async () => {416        const engine = deps.engine();417        const [diff, attribution] = await Promise.all([418          engine.git.diff(),419          engine.git.attributeChanges(),420        ]);421        if (diff.kind === "not-a-repo") {422          print(["", " /diff — not a git repository"]);423          return;424        }425        if (diff.kind === "error") {426          print(["", ` /diff — git failed: ${diff.message}`]);427          return;428        }429        const khaelor = new Set(attribution.kind === "ok" ? attribution.value.khaelor : []);430        const preExisting = new Set(431          attribution.kind === "ok" ? attribution.value.preExisting : [],432        );433        const lines = ["", ` diff · vs ${diff.value.base}`];434        if (diff.value.entries.length === 0) lines.push("   working tree clean");435        for (const entry of diff.value.entries) {436          const added = entry.added === null ? "bin" : `+${entry.added}`;437          const removed = entry.removed === null ? "" : `−${entry.removed}`;438          const who = khaelor.has(entry.path)439            ? "khaelor"440            : preExisting.has(entry.path)441              ? "pre-existing"442              : "";443          lines.push(444            `   ${entry.status.padEnd(9)} ${entry.path.padEnd(44)} ${`${added} ${removed}`.padEnd(12)} ${who}`,445          );446        }447        lines.push("   press d after an edit to expand its unified diff");448        print(lines);449      }),450  });451452  // /processes — the background process manager.453  commands.push({454    id: "processes.open",455    title: "Show processes",456    slash: "/processes",457    description: "background processes",458    run: () => {459      const list = deps.engine().processes.list();460      const lines = ["", " processes"];461      if (list.length === 0) lines.push("   none");462      for (const proc of list) {463        const glyph = proc.status === "running" ? "●" : "○";464        const status =465          proc.status === "running"466            ? `running   ${elapsed(proc.startedAt)}`467            : `${proc.status}    ${proc.exitCode === null ? "" : `code ${proc.exitCode}`}`;468        lines.push(` ${glyph} ${proc.id.padEnd(5)} ${proc.command.slice(0, 40).padEnd(42)} ${status}`);469      }470      print(lines);471    },472  });473474  // /status — where this session stands.475  commands.push({476    id: "status.show",477    title: "Status",478    slash: "/status",479    description: "session status",480    run: () =>481      runAsync("/status", async () => {482        const engine = deps.engine();483        const events = engine.session.events();484        const branch = await engine.git.currentBranch();485        const running = engine.processes.list().filter((p) => p.status === "running").length;486        print([487          "",488          " status",489          `   session   ${engine.session.sessionId}`,490          `   model     ${engine.model}`,491          `   cwd       ${deps.cwd}`,492          `   branch    ${branch.kind === "ok" ? branch.value : "(not a repo)"}`,493          `   events    ${events.length} durable`,494          `   turn      ${engine.turnActive ? "running" : "idle"}`,495          `   processes ${running} running`,496          `   log       ${engine.log.filePath}`,497        ]);498      }),499  });500501  // ───────────────────────── v2 commands ─────────────────────────502503  let subtasks: SubtaskManager | null = null;504  const getSubtasks = (): SubtaskManager => {505    if (subtasks === null) {506      const engine = deps.engine();507      subtasks = createSubtaskManager({508        config: deps.config,509        workspace: engine.workspace,510        projectRoot: deps.cwd,511        logger: engine.logger,512        publish: (event) => engine.session.publishDurable(event),513        parentSessionId: engine.session.sessionId,514      });515    }516    return subtasks;517  };518519  // /phase — the escape hatch: show the phase, force a transition (logged as override).520  commands.push({521    id: "phase.show",522    title: "Phase gate",523    slash: "/phase",524    description: "current phase; select to force a transition",525    run: () => {526      const engine = deps.engine();527      const state = engine.phases.state();528      const items: PaletteItem[] = (["understand", "design", "implement"] as Phase[]).map((phase) => ({529        id: phase,530        label: phase,531        detail: phase === state.phase ? "current" : "force transition (logged as user-override)",532      }));533      print([534        "",535        ` phase · ${state.phase} (gate mode: ${engine.phases.mode})`,536        state.pendingArtifact !== null537          ? `   pending design: ${state.pendingArtifact.artifact.goal.slice(0, 60)}`538          : `   design approved: ${state.designApproved ? "yes" : "no"}`,539      ]);540      ui.openSelector(items, (id) => {541        deps.engine().phases.forcePhase(id as Phase);542        print(["", ` phase forced to ${id} (recorded as user-override)`]);543      });544    },545  });546547  // /fork — pick a checkpoint, copy the JSONL prefix, resume the fork.548  commands.push({549    id: "session.fork",550    title: "Fork session",551    slash: "/fork",552    description: "fork this session at a checkpoint",553    run: () => {554      const engine = deps.engine();555      const checkpoints = listForkCheckpoints(engine.session.events());556      if (checkpoints.length === 0) {557        print(["", " /fork — no checkpoints yet (user turns, approved designs, compactions)"]);558        return;559      }560      const items: PaletteItem[] = checkpoints561        .slice(-30)562        .reverse()563        .map((cp) => ({ id: String(cp.seq), label: cp.label, detail: `seq ${cp.seq} · ${cp.kind}` }));564      ui.openSelector(items, (id) => {565        runAsync("/fork", async () => {566          const result = await forkSession({567            sessionsDir: deps.sessionsDir,568            projectHash: deps.projectHash,569            sourceSessionId: deps.engine().session.sessionId,570            uptoSeq: Number.parseInt(id, 10),571          });572          print([573            "",574            ` forked at seq ${result.forkPoint} → session ${result.sessionId}`,575            `   ${result.copiedEvents} events copied · opening the fork…`,576          ]);577          deps.actions.resumeSession(result.sessionId);578        });579      });580    },581  });582583  // /sdiff — structured diff between two runs: "<idA> <idB>" (A defaults to this session).584  commands.push({585    id: "session.sdiff",586    title: "Diff two sessions",587    slash: "/sdiff",588    description: "structured diff between two session runs",589    run: () => {590      const capture = deps.actions.captureNextSubmit;591      if (capture === undefined) {592        print(["", " /sdiff is unavailable in this mode"]);593        return;594      }595      print(["", ' sdiff — type "<sessionA> <sessionB>" (or just "<sessionB>" to compare with this one)']);596      capture((text) => {597        runAsync("/sdiff", async () => {598          const parts = text.trim().split(/\s+/).filter((part) => part.length > 0);599          if (parts.length === 0) {600            print([" sdiff cancelled"]);601            return;602          }603          const engine = deps.engine();604          const idA = parts.length >= 2 ? (parts[0] as string) : engine.session.sessionId;605          const idB = parts.length >= 2 ? (parts[1] as string) : (parts[0] as string);606          const load = async (id: string): Promise<ReturnType<typeof summarizeSessionRun>> => {607            if (id === engine.session.sessionId) {608              return summarizeSessionRun(id, engine.session.events());609            }610            const log = await SessionLog.open({611              projectHash: deps.projectHash,612              sessionId: id,613              sessionsDir: deps.sessionsDir,614            });615            return summarizeSessionRun(id, log.replayedEvents);616          };617          const [a, b] = await Promise.all([load(idA), load(idB)]);618          print(["", " sdiff", ...renderSessionDiff(a, b)]);619        });620      });621    },622  });623624  // /replay — re-run a session's user turns with the current model.625  commands.push({626    id: "session.replay",627    title: "Replay session",628    slash: "/replay",629    description: "re-run a session's user turns (current model)",630    run: () => {631      const capture = deps.actions.captureNextSubmit;632      if (capture === undefined) {633        print(["", " /replay is unavailable in this mode"]);634        return;635      }636      print(["", ' replay — type "<sessionId>" to re-run its user turns with the current model']);637      capture((text) => {638        const sourceId = text.trim();639        if (sourceId === "") {640          print([" replay cancelled"]);641          return;642        }643        runAsync("/replay", async () => {644          const engine = deps.engine();645          print(["", ` replaying ${sourceId} — tool calls re-execute for real (use a clean tree)`]);646          const result = await replaySession({647            config: deps.config,648            cwd: deps.cwd,649            sourceSessionId: sourceId,650            sessionsDir: deps.sessionsDir,651            logger: engine.logger,652            sandbox: { exec: workspaceExec(engine.workspace) },653            onProgress: (message) => print([`   ${message}`]),654          });655          print([656            "",657            ` replay done → session ${result.newSessionId} (${result.turnsReplayed} turns, sandboxed worktree)`,658            `   compare: /sdiff ${result.newSessionId}`,659          ]);660        });661      });662    },663  });664665  // /memory — the project memory with provenance; low-confidence purge candidates flagged.666  commands.push({667    id: "memory.open",668    title: "Project memory",669    slash: "/memory",670    description: "auto-maintained project memory with provenance",671    run: () =>672      runAsync("/memory", async () => {673        const engine = deps.engine();674        let content = "";675        try {676          content = await engine.workspace.readFile(join(deps.cwd, MEMORY_FILE));677        } catch {678          print(["", " memory — empty (the agent writes durable facts via the remember tool)"]);679          return;680        }681        const entries = parseMemory(content);682        const lines = ["", ` memory · ${MEMORY_FILE} (${entries.length} entries)`];683        let section = "";684        for (const entry of entries) {685          if (entry.section !== section) {686            section = entry.section;687            lines.push(`   ${section}`);688          }689          const provenance =690            entry.provenance !== null691              ? ` [${entry.provenance.confidence} · ${entry.provenance.date} · session ${entry.provenance.session.slice(0, 8)}]`692              : "";693          lines.push(`     ${entry.text.replace(/^-\s*/, "· ").slice(0, 100)}${provenance}`);694        }695        const purgeable = purgeCandidates(entries);696        if (purgeable.length > 0) {697          lines.push(`   ${purgeable.length} low-confidence entr${purgeable.length === 1 ? "y" : "ies"} — purge candidates at the next /compact`);698        }699        print(lines);700      }),701  });702703  // /verify — run the configured checks now, results recorded as verify.result events.704  commands.push({705    id: "verify.run",706    title: "Run verification",707    slash: "/verify",708    description: "run the project's verify checks now",709    run: () =>710      runAsync("/verify", async () => {711        const engine = deps.engine();712        if (!engine.verifyRunner.hasChecks) {713          print(["", " verify — no checks configured or detected (.khaelor/verify.json)"]);714          return;715        }716        print(["", " verify — running checks…"]);717        const outcome = await engine.verifyRunner.runAll();718        const lines = ["", ` verify · ${outcome.ok ? "✓ all passed" : "✗ failures"}`];719        for (const result of outcome.results) {720          const mark = result.ok ? "✓" : "✗";721          lines.push(722            `   ${mark} ${result.check.padEnd(12)} ${(result.durationMs / 1000).toFixed(1)}s${result.ok ? "" : ` (exit ${result.exitCode ?? "killed"})`}`,723          );724        }725        print(lines);726      }),727  });728729  // /spawn — a parallel subtask in its own worktree + child session.730  commands.push({731    id: "tasks.spawn",732    title: "Spawn subtask",733    slash: "/spawn",734    description: "run a task in an isolated worktree",735    run: () => {736      const capture = deps.actions.captureNextSubmit;737      if (capture === undefined) {738        print(["", " /spawn is unavailable in this mode"]);739        return;740      }741      print(["", " spawn — type the subtask description and press Enter (empty cancels)"]);742      capture((text) => {743        const description = text.trim();744        if (description === "") {745          print([" spawn cancelled"]);746          return;747        }748        runAsync("/spawn", async () => {749          const record = await getSubtasks().spawn(description);750          print([751            "",752            ` subtask ${record.taskId} spawned`,753            `   worktree ${record.worktree.path}`,754            `   branch   ${record.worktree.branch} · child session ${record.childSessionId}`,755            "   /tasks shows progress; completion lands in this session's log",756          ]);757        });758      });759    },760  });761762  // /tasks — the subtask board.763  commands.push({764    id: "tasks.open",765    title: "Subtasks",766    slash: "/tasks",767    description: "parallel subtasks and their state",768    run: () => {769      const records = subtasks?.list() ?? [];770      const lines = ["", " tasks"];771      if (records.length === 0) lines.push("   none — /spawn starts one");772      for (const record of records) {773        const glyph = record.status === "running" ? "●" : record.status === "done" ? "✓" : "✗";774        const verify = record.verifyOk === null ? "" : record.verifyOk ? " · verify ✓" : " · verify ✗";775        lines.push(776          ` ${glyph} ${record.taskId}  ${record.status.padEnd(11)} +${record.diff.added} −${record.diff.removed}${verify}  ${record.description.slice(0, 44)}`,777        );778      }779      if (records.some((record) => record.status === "done")) {780        lines.push("   merge a finished task with /merge");781      }782      print(lines);783    },784  });785786  // /merge — supervised --no-ff merge of a finished subtask branch.787  commands.push({788    id: "tasks.merge",789    title: "Merge subtask",790    slash: "/merge",791    description: "merge a finished subtask branch (--no-ff)",792    run: () => {793      const records = (subtasks?.list() ?? []).filter((record) => record.status === "done");794      if (records.length === 0) {795        print(["", " /merge — no finished subtasks"]);796        return;797      }798      const items: PaletteItem[] = records.map((record) => ({799        id: record.taskId,800        label: `${record.taskId} · ${record.description.slice(0, 40)}`,801        detail: `+${record.diff.added} −${record.diff.removed} · ${record.diff.files.length} file(s)`,802      }));803      ui.openSelector(items, (id) => {804        runAsync("/merge", async () => {805          const record = getSubtasks().get(id);806          if (record === undefined) return;807          const engine = deps.engine();808          const result = await mergeSubtaskBranch(809            workspaceExec(engine.workspace),810            deps.cwd,811            record.worktree.branch,812            `khaelor subtask ${record.taskId}: ${record.description.slice(0, 60)}`,813          );814          if (result.ok) {815            print(["", ` merged ${record.worktree.branch} (--no-ff)`]);816          } else if (result.conflict) {817            print([818              "",819              ` merge conflict on ${record.worktree.branch} — aborted cleanly`,820              "   ask KHAELOR to resolve it with both designs in context (v2 §6)",821            ]);822          } else {823            print(["", ` merge failed: ${result.detail}`]);824          }825        });826      });827    },828  });829830  // /goals — daemon goals + status, read from the project's daemon directory.831  commands.push({832    id: "goals.open",833    title: "Daemon goals",834    slash: "/goals",835    description: "long-term goals and daemon status",836    run: () =>837      runAsync("/goals", async () => {838        const daemonDir = daemonDirFor(deps.cwd);839        const status = await readDaemonStatus(daemonDir);840        const store = new GoalStore(daemonDir);841        const goals = await store.list();842        const lines = ["", ` goals · daemon ${status !== null ? `running (pid ${status.pid})` : "not running — khaelord start"}`];843        if (goals.length === 0) lines.push('   none — khaelord goal add "<description>"');844        for (const goal of goals) {845          const runs = await store.runsToday(goal.id);846          const glyph = goal.status === "active" ? "●" : "○";847          lines.push(848            ` ${glyph} ${goal.id}  ${goal.type.padEnd(8)} ${goal.schedule.padEnd(16)} $${goal.budget.maxUsdPerDay}/day · runs ${runs}/${goal.budget.maxRunsPerDay}`,849          );850          lines.push(`     ${goal.description.slice(0, 70)}`);851        }852        print(lines);853      }),854  });855856  return commands;857}858859/** Register every CLI command into the TUI registry (overrides Phase-2 placeholders by id). */860export function registerCliCommands(861  registry: { register(def: CommandDef): void },862  deps: CliCommandDeps,863): void {864  for (const def of buildCliCommands(deps)) registry.register(def);865}866