/** * KHAELOR * File: src/daemon/runner.ts * Description: Goal runner — one autonomous run: throwaway worktree, headless engine, phase gates + verify, escalation (v2 design §7.4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ResolvedConfig } from "../config/index.js"; import { runHeadlessTurn } from "../cli/headless.js"; import type { FileLogger } from "../cli/logger.js"; import { createWorktree, mergeSubtaskBranch, removeWorktree, worktreeDiffStats, } from "../tasks/index.js"; import type { ExecFn } from "../tasks/index.js"; import type { DaemonPricing } from "./config.js"; import type { GoalRunResult, GoalRunner } from "./daemon.js"; import type { Goal } from "./goals.js"; const RUN_PROMPT_SUFFIX = "\n\nYou are an autonomous KHAELOR daemon run inside a dedicated git worktree. " + "Design before implementing (the phase gate enforces it), verify before finishing, and never " + "push or merge — escalation is handled by the daemon. If nothing needs doing, say so and stop."; /** Real-usage → USD, only when pricing is configured (Absolute Rule #4). */ export function costFromUsage( usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }, pricing: DaemonPricing | undefined, ): number { if (pricing === undefined) return 0; return ( (usage.inputTokens / 1_000_000) * pricing.inputPerMTok + (usage.outputTokens / 1_000_000) * pricing.outputPerMTok + (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok + (usage.cacheWriteTokens / 1_000_000) * pricing.cacheWritePerMTok ); } export interface BuildGoalRunnerOptions { config: ResolvedConfig; projectRoot: string; exec: ExecFn; logger: FileLogger; pricing?: DaemonPricing; /** Model override for runs (daemon config `model.runs`). */ runsModel?: string; } /** * Each goal run (v2 §7.4): * 1. isolated session in a throwaway worktree — the daemon never touches the working copy, * 2. phase gates in the configured mode — the DesignArtifact lands in the log even at 3 AM, * 3. mandatory verification — no escalation when checks fail, * 4. escalation: notify → branch left for review; auto-merge-if-verified → --no-ff merge. */ export function buildGoalRunner(options: BuildGoalRunnerOptions): GoalRunner { return async (goal: Goal, runId: string): Promise => { const worktree = await createWorktree(options.exec, options.projectRoot, `goal-${runId}`); try { const result = await runHeadlessTurn({ config: options.config, cwd: worktree.path, prompt: `Goal (${goal.type}): ${goal.description}${RUN_PROMPT_SUFFIX}`, logger: options.logger, meta: { parent: null }, }); const costUsd = costFromUsage(result.usage, options.pricing); // Commit the run's changes onto the goal branch so diff/merge see them. await options.exec("git add -A", worktree.path); await options.exec( `git commit -m ${JSON.stringify(`khaelor goal ${goal.id} run ${runId}`)} --no-verify`, worktree.path, ); const diff = await worktreeDiffStats(options.exec, options.projectRoot, worktree.branch); const changed = diff.files.length > 0; let detail = `${result.finalText.slice(0, 800)}\n` + `diff: +${diff.added} −${diff.removed} across ${diff.files.length} file(s)`; let keepBranch = changed; if (changed && result.outcome === "done") { if (goal.escalation === "auto-merge-if-verified" && result.verifyOk === true) { const merge = await mergeSubtaskBranch( options.exec, options.projectRoot, worktree.branch, `khaelor goal ${goal.id}: ${goal.description.slice(0, 60)}`, ); detail += merge.ok ? "\nauto-merged (verified)" : `\nmerge failed: ${merge.detail}`; keepBranch = !merge.ok; } else { detail += `\nbranch ${worktree.branch} left for review (escalation: ${goal.escalation})`; } } await removeWorktree(options.exec, options.projectRoot, worktree, { deleteBranch: !keepBranch, }); return { outcome: result.outcome === "done" ? "done" : "failed", detail, costUsd, sessionId: result.sessionId, verifyOk: result.verifyOk, branch: keepBranch ? worktree.branch : null, }; } catch (error) { await removeWorktree(options.exec, options.projectRoot, worktree, { deleteBranch: true }).catch( () => undefined, ); throw error; } }; }