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%
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