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