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%
5.4 KB · 180 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/daemon/goals.ts4 * Description: Goal Engine — structured, event-sourced long-term goals with per-goal budgets and escalation (v2 design §7.4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";11import { join } from "node:path";12import { ulid } from "../shared/index.js";1314export type GoalType = "maintain" | "achieve" | "watch";15export type GoalEscalation = "notify" | "draft-pr" | "auto-merge-if-verified";16export type GoalStatus = "active" | "paused" | "satisfied";1718export interface Goal {19  id: string;20  description: string;21  type: GoalType;22  /** Command whose exit code evaluates the goal's state; empty → schedule-driven. */23  check?: string;24  /** Cron expression or the literal "heartbeat". */25  schedule: string;26  budget: { maxUsdPerDay: number; maxRunsPerDay: number };27  escalation: GoalEscalation;28  status: GoalStatus;29  createdAt: number;30}3132/** One line of a goal's event log (goals/<id>.events.jsonl) — replayable, forkable, diffable. */33export interface GoalEvent {34  ts: number;35  type:36    | "goal.created"37    | "goal.paused"38    | "goal.resumed"39    | "goal.satisfied"40    | "goal.escalated"41    | "run.started"42    | "run.completed"43    | "run.skipped";44  payload: Record<string, unknown>;45}4647export interface GoalRunRecord {48  runId: string;49  goalId: string;50  startedAt: number;51  finishedAt: number | null;52  outcome: "done" | "failed" | "skipped" | "running";53  detail: string;54  /** Child session id — every nocturnal action replays from its JSONL (v2 §7.2). */55  sessionId: string | null;56}5758/**59 * Disk layout under `.khaelor/daemon/goals/`:60 *   <id>.json          — current goal definition + status61 *   <id>.events.jsonl  — append-only goal event log (the audit trail)62 */63export class GoalStore {64  readonly #dir: string;6566  constructor(daemonDir: string) {67    this.#dir = join(daemonDir, "goals");68  }6970  get dir(): string {71    return this.#dir;72  }7374  async create(input: {75    description: string;76    type: GoalType;77    check?: string;78    schedule: string;79    budget?: Partial<Goal["budget"]>;80    escalation?: GoalEscalation;81  }): Promise<Goal> {82    const goal: Goal = {83      id: ulid().slice(0, 12).toLowerCase(),84      description: input.description,85      type: input.type,86      ...(input.check !== undefined ? { check: input.check } : {}),87      schedule: input.schedule,88      budget: {89        maxUsdPerDay: input.budget?.maxUsdPerDay ?? 5,90        maxRunsPerDay: input.budget?.maxRunsPerDay ?? 8,91      },92      escalation: input.escalation ?? "notify",93      status: "active",94      createdAt: Date.now(),95    };96    await mkdir(this.#dir, { recursive: true });97    await this.#save(goal);98    await this.appendEvent(goal.id, { ts: Date.now(), type: "goal.created", payload: { goal } });99    return goal;100  }101102  async list(): Promise<Goal[]> {103    let names: string[];104    try {105      names = await readdir(this.#dir);106    } catch {107      return [];108    }109    const goals: Goal[] = [];110    for (const name of names) {111      if (!name.endsWith(".json")) continue;112      try {113        goals.push(JSON.parse(await readFile(join(this.#dir, name), "utf8")) as Goal);114      } catch {115        // unreadable goal file — skipped, never fatal116      }117    }118    goals.sort((a, b) => a.createdAt - b.createdAt);119    return goals;120  }121122  async get(goalId: string): Promise<Goal | null> {123    try {124      return JSON.parse(await readFile(join(this.#dir, `${goalId}.json`), "utf8")) as Goal;125    } catch {126      return null;127    }128  }129130  async setStatus(goalId: string, status: GoalStatus): Promise<void> {131    const goal = await this.get(goalId);132    if (goal === null) return;133    goal.status = status;134    await this.#save(goal);135    const type =136      status === "satisfied" ? "goal.satisfied" : status === "paused" ? "goal.paused" : "goal.resumed";137    await this.appendEvent(goalId, { ts: Date.now(), type, payload: {} });138  }139140  async remove(goalId: string): Promise<void> {141    // Goals are never hard-deleted — paused is the terminal user-facing state;142    // the event log stays as the audit trail.143    await this.setStatus(goalId, "paused");144  }145146  async appendEvent(goalId: string, event: GoalEvent): Promise<void> {147    await mkdir(this.#dir, { recursive: true });148    await appendFile(join(this.#dir, `${goalId}.events.jsonl`), `${JSON.stringify(event)}\n`, "utf8");149  }150151  async events(goalId: string): Promise<GoalEvent[]> {152    try {153      const content = await readFile(join(this.#dir, `${goalId}.events.jsonl`), "utf8");154      const out: GoalEvent[] = [];155      for (const line of content.split("\n")) {156        if (line.length === 0) continue;157        try {158          out.push(JSON.parse(line) as GoalEvent);159        } catch {160          // torn tail tolerated161        }162      }163      return out;164    } catch {165      return [];166    }167  }168169  /** Runs today, derived from the event log (budget.maxRunsPerDay enforcement). */170  async runsToday(goalId: string, now = new Date()): Promise<number> {171    const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();172    const events = await this.events(goalId);173    return events.filter((event) => event.type === "run.started" && event.ts >= dayStart).length;174  }175176  async #save(goal: Goal): Promise<void> {177    await writeFile(join(this.#dir, `${goal.id}.json`), `${JSON.stringify(goal, null, 2)}\n`, "utf8");178  }179}180