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/phases/service.ts4 * Description: PhaseService — session-facing phase-gate coordinator: transitions, design approval, tool-call checks (v2 design §1).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { CapabilityRequest } from "../permissions/index.js";11import type { DesignArtifact, DurableEvent, DurableEventInput, Phase } from "../session/index.js";12import { ulid } from "../shared/index.js";13import { checkPhaseGate } from "./gate.js";14import type { GateCheck } from "./gate.js";15import { foldPhaseState } from "./state.js";16import type { PhaseState } from "./state.js";17import type { DesignDecision, GateConfig, PhaseApprovalAsker } from "./types.js";1819/** The session seam the service needs — satisfied by EventLogSession. */20export interface PhaseSessionHandle {21 events(): readonly DurableEvent[];22 publishDurable(input: DurableEventInput): unknown;23}2425export interface PhaseServiceOptions {26 session: PhaseSessionHandle;27 config: GateConfig;28 projectRoot: string;29 /** Strict-mode design approval panel; absent → approval stays pending. */30 asker?: PhaseApprovalAsker;31 newId?: () => string;32}3334/**35 * The gate as a service AROUND the kernel (Absolute Rule #3): the executor36 * consults `checkToolCall` before dispatch; the `design` tool calls37 * `submitDesign`; the TUI's /phase escape hatch calls `forcePhase`.38 */39export class PhaseService {40 readonly #session: PhaseSessionHandle;41 readonly #config: GateConfig;42 readonly #projectRoot: string;43 readonly #asker: PhaseApprovalAsker | undefined;44 readonly #newId: () => string;4546 constructor(options: PhaseServiceOptions) {47 this.#session = options.session;48 this.#config = options.config;49 this.#projectRoot = options.projectRoot;50 this.#asker = options.asker;51 this.#newId = options.newId ?? ulid;52 }5354 get mode(): GateConfig["mode"] {55 return this.#config.mode;56 }5758 state(): PhaseState {59 return foldPhaseState(this.#session.events());60 }6162 current(): Phase {63 return this.#config.mode === "off" ? "implement" : this.state().phase;64 }6566 /** Record the initial understand phase for fresh gated sessions. */67 ensureStarted(): void {68 if (this.#config.mode === "off") return;69 const hasPhaseEvent = this.#session.events().some((e) => e.type === "phase.entered");70 if (hasPhaseEvent) return;71 this.#session.publishDurable({72 type: "phase.entered",73 payload: { phase: "understand", via: "session-start" },74 });75 }7677 /** Tool Runtime hook — evaluated between capability mapping and permissions. */78 checkToolCall(requests: readonly CapabilityRequest[]): GateCheck {79 if (this.#config.mode === "off") return { allowed: true };80 return checkPhaseGate(this.state().phase, requests, this.#projectRoot);81 }8283 /**84 * Record a design artifact and decide its approval:85 * - auto mode: self-approved when it touches ≤ autoApprove.maxFiles files,86 * otherwise falls through to the asker (or stays pending).87 * - strict mode: always asks the user.88 */89 async submitDesign(artifact: DesignArtifact): Promise<DesignDecision> {90 const artifactId = this.#newId();91 if (this.state().phase === "understand") {92 this.#session.publishDurable({93 type: "phase.entered",94 payload: { phase: "design", via: "design-submitted" },95 });96 }97 this.#session.publishDurable({98 type: "phase.artifact",99 payload: { artifactId, artifact },100 });101102 if (this.#config.mode === "off") {103 return { status: "approved", artifactId, reason: "phase gates are off" };104 }105106 if (107 this.#config.mode === "auto" &&108 artifact.filesTouched.length <= this.#config.autoApprove.maxFiles109 ) {110 this.#approve("auto-policy", artifactId);111 return { status: "approved", artifactId };112 }113114 if (this.#asker !== undefined) {115 const answer = await this.#asker.askDesign(artifactId, artifact);116 if (answer.approved) {117 this.#approve("user", artifactId);118 return { status: "approved", artifactId };119 }120 const reason = answer.reason ?? "rejected by user";121 this.#session.publishDurable({122 type: "phase.rejected",123 payload: { phase: "design", reason, artifactId },124 });125 return { status: "rejected", artifactId, reason };126 }127128 // Non-interactive with a design above the auto threshold: stays pending.129 return {130 status: "pending",131 artifactId,132 reason:133 "The design exceeds the auto-approval threshold and no interactive approver is available. " +134 "Ask the user to approve with /phase, or narrow the design.",135 };136 }137138 /** /phase escape hatch — a user-forced transition, always logged as an override. */139 forcePhase(phase: Phase): void {140 if (phase === "implement") {141 this.#session.publishDurable({142 type: "phase.approved",143 payload: { phase: "design", approvedBy: "user-override" },144 });145 }146 this.#session.publishDurable({147 type: "phase.entered",148 payload: { phase, via: "user-override" },149 });150 }151152 #approve(approvedBy: "user" | "auto-policy", artifactId: string): void {153 this.#session.publishDurable({154 type: "phase.approved",155 payload: { phase: "design", approvedBy, artifactId },156 });157 this.#session.publishDurable({158 type: "phase.entered",159 payload: { phase: "implement", via: "approval" },160 });161 }162}163