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/daemon/runner.ts4 * Description: Goal runner — one autonomous run: throwaway worktree, headless engine, phase gates + verify, escalation (v2 design §7.4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ResolvedConfig } from "../config/index.js";11import { runHeadlessTurn } from "../cli/headless.js";12import type { FileLogger } from "../cli/logger.js";13import {14 createWorktree,15 mergeSubtaskBranch,16 removeWorktree,17 worktreeDiffStats,18} from "../tasks/index.js";19import type { ExecFn } from "../tasks/index.js";20import type { DaemonPricing } from "./config.js";21import type { GoalRunResult, GoalRunner } from "./daemon.js";22import type { Goal } from "./goals.js";2324const RUN_PROMPT_SUFFIX =25 "\n\nYou are an autonomous KHAELOR daemon run inside a dedicated git worktree. " +26 "Design before implementing (the phase gate enforces it), verify before finishing, and never " +27 "push or merge — escalation is handled by the daemon. If nothing needs doing, say so and stop.";2829/** Real-usage → USD, only when pricing is configured (Absolute Rule #4). */30export function costFromUsage(31 usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number },32 pricing: DaemonPricing | undefined,33): number {34 if (pricing === undefined) return 0;35 return (36 (usage.inputTokens / 1_000_000) * pricing.inputPerMTok +37 (usage.outputTokens / 1_000_000) * pricing.outputPerMTok +38 (usage.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMTok +39 (usage.cacheWriteTokens / 1_000_000) * pricing.cacheWritePerMTok40 );41}4243export interface BuildGoalRunnerOptions {44 config: ResolvedConfig;45 projectRoot: string;46 exec: ExecFn;47 logger: FileLogger;48 pricing?: DaemonPricing;49 /** Model override for runs (daemon config `model.runs`). */50 runsModel?: string;51}5253/**54 * Each goal run (v2 §7.4):55 * 1. isolated session in a throwaway worktree — the daemon never touches the working copy,56 * 2. phase gates in the configured mode — the DesignArtifact lands in the log even at 3 AM,57 * 3. mandatory verification — no escalation when checks fail,58 * 4. escalation: notify → branch left for review; auto-merge-if-verified → --no-ff merge.59 */60export function buildGoalRunner(options: BuildGoalRunnerOptions): GoalRunner {61 return async (goal: Goal, runId: string): Promise<GoalRunResult> => {62 const worktree = await createWorktree(options.exec, options.projectRoot, `goal-${runId}`);63 try {64 const result = await runHeadlessTurn({65 config: options.config,66 cwd: worktree.path,67 prompt: `Goal (${goal.type}): ${goal.description}${RUN_PROMPT_SUFFIX}`,68 logger: options.logger,69 meta: { parent: null },70 });71 const costUsd = costFromUsage(result.usage, options.pricing);7273 // Commit the run's changes onto the goal branch so diff/merge see them.74 await options.exec("git add -A", worktree.path);75 await options.exec(76 `git commit -m ${JSON.stringify(`khaelor goal ${goal.id} run ${runId}`)} --no-verify`,77 worktree.path,78 );79 const diff = await worktreeDiffStats(options.exec, options.projectRoot, worktree.branch);80 const changed = diff.files.length > 0;8182 let detail =83 `${result.finalText.slice(0, 800)}\n` +84 `diff: +${diff.added} −${diff.removed} across ${diff.files.length} file(s)`;85 let keepBranch = changed;8687 if (changed && result.outcome === "done") {88 if (goal.escalation === "auto-merge-if-verified" && result.verifyOk === true) {89 const merge = await mergeSubtaskBranch(90 options.exec,91 options.projectRoot,92 worktree.branch,93 `khaelor goal ${goal.id}: ${goal.description.slice(0, 60)}`,94 );95 detail += merge.ok ? "\nauto-merged (verified)" : `\nmerge failed: ${merge.detail}`;96 keepBranch = !merge.ok;97 } else {98 detail += `\nbranch ${worktree.branch} left for review (escalation: ${goal.escalation})`;99 }100 }101102 await removeWorktree(options.exec, options.projectRoot, worktree, {103 deleteBranch: !keepBranch,104 });105 return {106 outcome: result.outcome === "done" ? "done" : "failed",107 detail,108 costUsd,109 sessionId: result.sessionId,110 verifyOk: result.verifyOk,111 branch: keepBranch ? worktree.branch : null,112 };113 } catch (error) {114 await removeWorktree(options.exec, options.projectRoot, worktree, { deleteBranch: true }).catch(115 () => undefined,116 );117 throw error;118 }119 };120}121