/** * KHAELOR * File: src/phases/service.ts * Description: PhaseService — session-facing phase-gate coordinator: transitions, design approval, tool-call checks (v2 design §1). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { CapabilityRequest } from "../permissions/index.js"; import type { DesignArtifact, DurableEvent, DurableEventInput, Phase } from "../session/index.js"; import { ulid } from "../shared/index.js"; import { checkPhaseGate } from "./gate.js"; import type { GateCheck } from "./gate.js"; import { foldPhaseState } from "./state.js"; import type { PhaseState } from "./state.js"; import type { DesignDecision, GateConfig, PhaseApprovalAsker } from "./types.js"; /** The session seam the service needs — satisfied by EventLogSession. */ export interface PhaseSessionHandle { events(): readonly DurableEvent[]; publishDurable(input: DurableEventInput): unknown; } export interface PhaseServiceOptions { session: PhaseSessionHandle; config: GateConfig; projectRoot: string; /** Strict-mode design approval panel; absent → approval stays pending. */ asker?: PhaseApprovalAsker; newId?: () => string; } /** * The gate as a service AROUND the kernel (Absolute Rule #3): the executor * consults `checkToolCall` before dispatch; the `design` tool calls * `submitDesign`; the TUI's /phase escape hatch calls `forcePhase`. */ export class PhaseService { readonly #session: PhaseSessionHandle; readonly #config: GateConfig; readonly #projectRoot: string; readonly #asker: PhaseApprovalAsker | undefined; readonly #newId: () => string; constructor(options: PhaseServiceOptions) { this.#session = options.session; this.#config = options.config; this.#projectRoot = options.projectRoot; this.#asker = options.asker; this.#newId = options.newId ?? ulid; } get mode(): GateConfig["mode"] { return this.#config.mode; } state(): PhaseState { return foldPhaseState(this.#session.events()); } current(): Phase { return this.#config.mode === "off" ? "implement" : this.state().phase; } /** Record the initial understand phase for fresh gated sessions. */ ensureStarted(): void { if (this.#config.mode === "off") return; const hasPhaseEvent = this.#session.events().some((e) => e.type === "phase.entered"); if (hasPhaseEvent) return; this.#session.publishDurable({ type: "phase.entered", payload: { phase: "understand", via: "session-start" }, }); } /** Tool Runtime hook — evaluated between capability mapping and permissions. */ checkToolCall(requests: readonly CapabilityRequest[]): GateCheck { if (this.#config.mode === "off") return { allowed: true }; return checkPhaseGate(this.state().phase, requests, this.#projectRoot); } /** * Record a design artifact and decide its approval: * - auto mode: self-approved when it touches ≤ autoApprove.maxFiles files, * otherwise falls through to the asker (or stays pending). * - strict mode: always asks the user. */ async submitDesign(artifact: DesignArtifact): Promise { const artifactId = this.#newId(); if (this.state().phase === "understand") { this.#session.publishDurable({ type: "phase.entered", payload: { phase: "design", via: "design-submitted" }, }); } this.#session.publishDurable({ type: "phase.artifact", payload: { artifactId, artifact }, }); if (this.#config.mode === "off") { return { status: "approved", artifactId, reason: "phase gates are off" }; } if ( this.#config.mode === "auto" && artifact.filesTouched.length <= this.#config.autoApprove.maxFiles ) { this.#approve("auto-policy", artifactId); return { status: "approved", artifactId }; } if (this.#asker !== undefined) { const answer = await this.#asker.askDesign(artifactId, artifact); if (answer.approved) { this.#approve("user", artifactId); return { status: "approved", artifactId }; } const reason = answer.reason ?? "rejected by user"; this.#session.publishDurable({ type: "phase.rejected", payload: { phase: "design", reason, artifactId }, }); return { status: "rejected", artifactId, reason }; } // Non-interactive with a design above the auto threshold: stays pending. return { status: "pending", artifactId, reason: "The design exceeds the auto-approval threshold and no interactive approver is available. " + "Ask the user to approve with /phase, or narrow the design.", }; } /** /phase escape hatch — a user-forced transition, always logged as an override. */ forcePhase(phase: Phase): void { if (phase === "implement") { this.#session.publishDurable({ type: "phase.approved", payload: { phase: "design", approvedBy: "user-override" }, }); } this.#session.publishDurable({ type: "phase.entered", payload: { phase, via: "user-override" }, }); } #approve(approvedBy: "user" | "auto-policy", artifactId: string): void { this.#session.publishDurable({ type: "phase.approved", payload: { phase: "design", approvedBy, artifactId }, }); this.#session.publishDurable({ type: "phase.entered", payload: { phase: "implement", via: "approval" }, }); } }