/** * KHAELOR * File: tests/phases/phases.test.ts * Description: Phase-gate tests — state fold, per-phase capability policy, service transitions and approvals (v2 §1). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { checkPhaseGate, foldPhaseState, PhaseService, PHASE_GATE_BLOCKED } from "../../src/phases/index.js"; import type { CapabilityRequest } from "../../src/permissions/index.js"; import type { DesignArtifact, DurableEvent, DurableEventInput } from "../../src/session/index.js"; const ROOT = "/repo"; function request(capability: CapabilityRequest["capability"], subject: string): CapabilityRequest { return { capability, subject, display: subject, alwaysPatterns: [], riskNotes: [] }; } function artifact(files: string[]): DesignArtifact { return { goal: "fix it", filesTouched: files, approach: "carefully", risks: [], verification: "run tests", outOfScope: [], }; } /** Tiny in-memory session satisfying PhaseSessionHandle. */ function fakeSession(): { events(): readonly DurableEvent[]; publishDurable(input: DurableEventInput): DurableEvent; } { const events: DurableEvent[] = []; let seq = 0; return { events: () => events, publishDurable(input: DurableEventInput): DurableEvent { seq += 1; const event = { v: 1, id: `e${seq}`, sessionId: "s1", seq, ts: seq, type: input.type, payload: input.payload, } as DurableEvent; events.push(event); return event; }, }; } describe("foldPhaseState", () => { it("starts in understand with nothing pending", () => { const state = foldPhaseState([]); expect(state.phase).toBe("understand"); expect(state.pendingArtifact).toBeNull(); expect(state.designApproved).toBe(false); }); it("tracks entered/artifact/approved through the stream", () => { const session = fakeSession(); session.publishDurable({ type: "phase.entered", payload: { phase: "design", via: "design-submitted" } }); session.publishDurable({ type: "phase.artifact", payload: { artifactId: "a1", artifact: artifact(["src/a.ts"]) }, }); let state = foldPhaseState(session.events()); expect(state.phase).toBe("design"); expect(state.pendingArtifact?.artifactId).toBe("a1"); session.publishDurable({ type: "phase.approved", payload: { phase: "design", approvedBy: "auto-policy", artifactId: "a1" }, }); session.publishDurable({ type: "phase.entered", payload: { phase: "implement", via: "approval" } }); state = foldPhaseState(session.events()); expect(state.phase).toBe("implement"); expect(state.designApproved).toBe(true); expect(state.pendingArtifact).toBeNull(); expect(state.approvedArtifactId).toBe("a1"); }); }); describe("checkPhaseGate", () => { it("implement allows everything", () => { expect(checkPhaseGate("implement", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(true); }); it("understand allows reads and readonly commands only", () => { expect(checkPhaseGate("understand", [request("file.read", "/repo/src/a.ts")], ROOT).allowed).toBe(true); expect(checkPhaseGate("understand", [request("process.execute", "git log --oneline")], ROOT).allowed).toBe(true); const blocked = checkPhaseGate("understand", [request("process.execute", "npm install")], ROOT); expect(blocked.allowed).toBe(false); if (!blocked.allowed) expect(blocked.feedback).toContain(PHASE_GATE_BLOCKED); }); it("blocks writes in understand, allows design docs in design", () => { expect(checkPhaseGate("understand", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false); expect(checkPhaseGate("design", [request("file.write.project", "/repo/docs/design/plan.md")], ROOT).allowed).toBe(true); expect(checkPhaseGate("design", [request("file.write.project", "/repo/src/a.ts")], ROOT).allowed).toBe(false); }); it("project memory is writable in every phase (v2 §5)", () => { expect( checkPhaseGate("understand", [request("file.write.project", "/repo/.khaelor/MEMORY.md")], ROOT).allowed, ).toBe(true); }); it("git.modify and network stay locked before implement", () => { expect(checkPhaseGate("design", [request("git.modify", "git push")], ROOT).allowed).toBe(false); expect(checkPhaseGate("design", [request("network.access", "curl https://x")], ROOT).allowed).toBe(false); }); }); describe("PhaseService", () => { it("auto mode self-approves small designs and unlocks implement", async () => { const session = fakeSession(); const service = new PhaseService({ session, config: { mode: "auto", autoApprove: { maxFiles: 3 } }, projectRoot: ROOT, }); service.ensureStarted(); expect(service.current()).toBe("understand"); const decision = await service.submitDesign(artifact(["a.ts", "b.ts"])); expect(decision.status).toBe("approved"); expect(service.current()).toBe("implement"); const types = session.events().map((event) => event.type); expect(types).toContain("phase.artifact"); expect(types).toContain("phase.approved"); }); it("auto mode above the threshold stays pending without an asker", async () => { const session = fakeSession(); const service = new PhaseService({ session, config: { mode: "auto", autoApprove: { maxFiles: 1 } }, projectRoot: ROOT, }); const decision = await service.submitDesign(artifact(["a.ts", "b.ts"])); expect(decision.status).toBe("pending"); expect(service.current()).toBe("design"); }); it("strict mode asks; rejection records phase.rejected", async () => { const session = fakeSession(); const service = new PhaseService({ session, config: { mode: "strict", autoApprove: { maxFiles: 3 } }, projectRoot: ROOT, asker: { askDesign: () => Promise.resolve({ approved: false, reason: "too vague" }) }, }); const decision = await service.submitDesign(artifact(["a.ts"])); expect(decision.status).toBe("rejected"); expect(session.events().some((event) => event.type === "phase.rejected")).toBe(true); expect(service.current()).toBe("design"); }); it("off mode reports implement and allows everything", () => { const service = new PhaseService({ session: fakeSession(), config: { mode: "off", autoApprove: { maxFiles: 3 } }, projectRoot: ROOT, }); expect(service.current()).toBe("implement"); expect(service.checkToolCall([request("file.write.project", "/repo/a.ts")]).allowed).toBe(true); }); it("forcePhase to implement records the user override", () => { const session = fakeSession(); const service = new PhaseService({ session, config: { mode: "strict", autoApprove: { maxFiles: 3 } }, projectRoot: ROOT, }); service.forcePhase("implement"); expect(service.current()).toBe("implement"); const approved = session.events().find((event) => event.type === "phase.approved"); expect(approved !== undefined && approved.type === "phase.approved" ? approved.payload.approvedBy : "").toBe( "user-override", ); }); });