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%
4.5 KB · 121 lines typescript
Raw Blame History
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