/** * KHAELOR * File: src/daemon/goals.ts * Description: Goal Engine — structured, event-sourced long-term goals with per-goal budgets and escalation (v2 design §7.4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { ulid } from "../shared/index.js"; export type GoalType = "maintain" | "achieve" | "watch"; export type GoalEscalation = "notify" | "draft-pr" | "auto-merge-if-verified"; export type GoalStatus = "active" | "paused" | "satisfied"; export interface Goal { id: string; description: string; type: GoalType; /** Command whose exit code evaluates the goal's state; empty → schedule-driven. */ check?: string; /** Cron expression or the literal "heartbeat". */ schedule: string; budget: { maxUsdPerDay: number; maxRunsPerDay: number }; escalation: GoalEscalation; status: GoalStatus; createdAt: number; } /** One line of a goal's event log (goals/.events.jsonl) — replayable, forkable, diffable. */ export interface GoalEvent { ts: number; type: | "goal.created" | "goal.paused" | "goal.resumed" | "goal.satisfied" | "goal.escalated" | "run.started" | "run.completed" | "run.skipped"; payload: Record; } export interface GoalRunRecord { runId: string; goalId: string; startedAt: number; finishedAt: number | null; outcome: "done" | "failed" | "skipped" | "running"; detail: string; /** Child session id — every nocturnal action replays from its JSONL (v2 §7.2). */ sessionId: string | null; } /** * Disk layout under `.khaelor/daemon/goals/`: * .json — current goal definition + status * .events.jsonl — append-only goal event log (the audit trail) */ export class GoalStore { readonly #dir: string; constructor(daemonDir: string) { this.#dir = join(daemonDir, "goals"); } get dir(): string { return this.#dir; } async create(input: { description: string; type: GoalType; check?: string; schedule: string; budget?: Partial; escalation?: GoalEscalation; }): Promise { const goal: Goal = { id: ulid().slice(0, 12).toLowerCase(), description: input.description, type: input.type, ...(input.check !== undefined ? { check: input.check } : {}), schedule: input.schedule, budget: { maxUsdPerDay: input.budget?.maxUsdPerDay ?? 5, maxRunsPerDay: input.budget?.maxRunsPerDay ?? 8, }, escalation: input.escalation ?? "notify", status: "active", createdAt: Date.now(), }; await mkdir(this.#dir, { recursive: true }); await this.#save(goal); await this.appendEvent(goal.id, { ts: Date.now(), type: "goal.created", payload: { goal } }); return goal; } async list(): Promise { let names: string[]; try { names = await readdir(this.#dir); } catch { return []; } const goals: Goal[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; try { goals.push(JSON.parse(await readFile(join(this.#dir, name), "utf8")) as Goal); } catch { // unreadable goal file — skipped, never fatal } } goals.sort((a, b) => a.createdAt - b.createdAt); return goals; } async get(goalId: string): Promise { try { return JSON.parse(await readFile(join(this.#dir, `${goalId}.json`), "utf8")) as Goal; } catch { return null; } } async setStatus(goalId: string, status: GoalStatus): Promise { const goal = await this.get(goalId); if (goal === null) return; goal.status = status; await this.#save(goal); const type = status === "satisfied" ? "goal.satisfied" : status === "paused" ? "goal.paused" : "goal.resumed"; await this.appendEvent(goalId, { ts: Date.now(), type, payload: {} }); } async remove(goalId: string): Promise { // Goals are never hard-deleted — paused is the terminal user-facing state; // the event log stays as the audit trail. await this.setStatus(goalId, "paused"); } async appendEvent(goalId: string, event: GoalEvent): Promise { await mkdir(this.#dir, { recursive: true }); await appendFile(join(this.#dir, `${goalId}.events.jsonl`), `${JSON.stringify(event)}\n`, "utf8"); } async events(goalId: string): Promise { try { const content = await readFile(join(this.#dir, `${goalId}.events.jsonl`), "utf8"); const out: GoalEvent[] = []; for (const line of content.split("\n")) { if (line.length === 0) continue; try { out.push(JSON.parse(line) as GoalEvent); } catch { // torn tail tolerated } } return out; } catch { return []; } } /** Runs today, derived from the event log (budget.maxRunsPerDay enforcement). */ async runsToday(goalId: string, now = new Date()): Promise { const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const events = await this.events(goalId); return events.filter((event) => event.type === "run.started" && event.ts >= dayStart).length; } async #save(goal: Goal): Promise { await writeFile(join(this.#dir, `${goal.id}.json`), `${JSON.stringify(goal, null, 2)}\n`, "utf8"); } }