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: tests/phases/phases.test.ts4 * Description: Phase-gate tests — state fold, per-phase capability policy, service transitions and approvals (v2 §1).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import { checkPhaseGate, foldPhaseState, PhaseService, PHASE_GATE_BLOCKED } from "../../src/phases/index.js";12import type { CapabilityRequest } from "../../src/permissions/index.js";13import type { DesignArtifact, DurableEvent, DurableEventInput } from "../../src/session/index.js";1415const ROOT = "/repo";1617function request(capability: CapabilityRequest["capability"], subject: string): CapabilityRequest {18 return { capability, subject, display: subject, alwaysPatterns: [], riskNotes: [] };19}2021function artifact(files: string[]): DesignArtifact {22 return {23 goal: "fix it",24 filesTouched: files,25 approach: "carefully",26 risks: [],27 verification: "run tests",28 outOfScope: [],29 };30}3132/** Tiny in-memory session satisfying PhaseSessionHandle. */33function fakeSession(): {34 events(): readonly DurableEvent[];35 publishDurable(input: DurableEventInput): DurableEvent;36} {37 const events: DurableEvent[] = [];38 let seq = 0;39 return {40 events: () => events,41 publishDurable(input: DurableEventInput): DurableEvent {42 seq += 1;43 const event = {44 v: 1,45 id: `e${seq}`,46 sessionId: "s1",47 seq,48 ts: seq,49 type: input.type,50 payload: input.payload,51 } as DurableEvent;52 events.push(event);53 return event;54 },55 };56}5758describe("foldPhaseState", () => {59 it("starts in understand with nothing pending", () => {60 const state = foldPhaseState([]);61 expect(state.phase).toBe("understand");62 expect(state.pendingArtifact).toBeNull();63 expect(state.designApproved).toBe(false);64 });6566 it("tracks entered/artifact/approved through the stream", () => {67 const session = fakeSession();68 session.publishDurable({ type: "phase.entered", payload: { phase: "design", via: "design-submitted" } });69 session.publishDurable({70 type: "phase.artifact",71 payload: { artifactId: "a1", artifact: artifact(["src/a.ts"]) },72 });73 let state = foldPhaseState(session.events());74 expect(state.phase).toBe("design");75 expect(state.pendingArtifact?.artifactId).toBe("a1");7677 session.publishDurable({78 type: "phase.approved",79 payload: { phase: "design", approvedBy: "auto-policy", artifactId: "a1" },80 });81 session.publishDurable({ type: "phase.entered", payload: { phase: "implement", via: "approval" } });82 state = foldPhaseState(session.events());83 expect(state.phase).toBe("implement");84 expect(state.designApproved).toBe(true);85 expect(state.pendingArtifact).toBeNull();86 expect(state.approvedArtifactId).toBe("a1");87 });88});8990describe("checkPhaseGate", () => {91 it("implement allows everything", () => {92 expect(checkPhaseGate("implement", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(true);93 });9495 it("understand allows reads and readonly commands only", () => {96 expect(checkPhaseGate("understand", [request("file.read", "/repo/src/a.ts")], ROOT).allowed).toBe(true);97 expect(checkPhaseGate("understand", [request("process.execute", "git log --oneline")], ROOT).allowed).toBe(true);98 const blocked = checkPhaseGate("understand", [request("process.execute", "npm install")], ROOT);99 expect(blocked.allowed).toBe(false);100 if (!blocked.allowed) expect(blocked.feedback).toContain(PHASE_GATE_BLOCKED);101 });102103 it("blocks writes in understand, allows design docs in design", () => {104 expect(checkPhaseGate("understand", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false);105 expect(checkPhaseGate("design", [request("file.write.project", "/repo/docs/design/plan.md")], ROOT).allowed).toBe(true);106 expect(checkPhaseGate("design", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false);107 });108109 it("project memory is writable in every phase (v2 §5)", () => {110 expect(111 checkPhaseGate("understand", [request("file.write.project", "/repo/.khaelor/MEMORY.md")], ROOT).allowed,112 ).toBe(true);113 });114115 it("git.modify and network stay locked before implement", () => {116 expect(checkPhaseGate("design", [request("git.modify", "git push")], ROOT).allowed).toBe(false);117 expect(checkPhaseGate("design", [request("network.access", "curl https://x")], ROOT).allowed).toBe(false);118 });119});120121describe("PhaseService", () => {122 it("auto mode self-approves small designs and unlocks implement", async () => {123 const session = fakeSession();124 const service = new PhaseService({125 session,126 config: { mode: "auto", autoApprove: { maxFiles: 3 } },127 projectRoot: ROOT,128 });129 service.ensureStarted();130 expect(service.current()).toBe("understand");131132 const decision = await service.submitDesign(artifact(["a.ts", "b.ts"]));133 expect(decision.status).toBe("approved");134 expect(service.current()).toBe("implement");135 const types = session.events().map((event) => event.type);136 expect(types).toContain("phase.artifact");137 expect(types).toContain("phase.approved");138 });139140 it("auto mode above the threshold stays pending without an asker", async () => {141 const session = fakeSession();142 const service = new PhaseService({143 session,144 config: { mode: "auto", autoApprove: { maxFiles: 1 } },145 projectRoot: ROOT,146 });147 const decision = await service.submitDesign(artifact(["a.ts", "b.ts"]));148 expect(decision.status).toBe("pending");149 expect(service.current()).toBe("design");150 });151152 it("strict mode asks; rejection records phase.rejected", async () => {153 const session = fakeSession();154 const service = new PhaseService({155 session,156 config: { mode: "strict", autoApprove: { maxFiles: 3 } },157 projectRoot: ROOT,158 asker: { askDesign: () => Promise.resolve({ approved: false, reason: "too vague" }) },159 });160 const decision = await service.submitDesign(artifact(["a.ts"]));161 expect(decision.status).toBe("rejected");162 expect(session.events().some((event) => event.type === "phase.rejected")).toBe(true);163 expect(service.current()).toBe("design");164 });165166 it("off mode reports implement and allows everything", () => {167 const service = new PhaseService({168 session: fakeSession(),169 config: { mode: "off", autoApprove: { maxFiles: 3 } },170 projectRoot: ROOT,171 });172 expect(service.current()).toBe("implement");173 expect(service.checkToolCall([request("file.write.project", "/repo/a.ts")]).allowed).toBe(true);174 });175176 it("forcePhase to implement records the user override", () => {177 const session = fakeSession();178 const service = new PhaseService({179 session,180 config: { mode: "strict", autoApprove: { maxFiles: 3 } },181 projectRoot: ROOT,182 });183 service.forcePhase("implement");184 expect(service.current()).toBe("implement");185 const approved = session.events().find((event) => event.type === "phase.approved");186 expect(approved !== undefined && approved.type === "phase.approved" ? approved.payload.approvedBy : "").toBe(187 "user-override",188 );189 });190});191