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%
7.1 KB · 191 lines typescript
Raw Blame History
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