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.3 KB · 163 lines typescript
Raw Blame History
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