v0.2.0: Git-native architecture (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ArtifactManager, ProjectManifest } from "../core/artifacts.js";
|
||||
|
||||
describe("ArtifactManager", () => {
|
||||
let tempDir: string;
|
||||
let manager: ArtifactManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-artifact-test-"));
|
||||
manager = new ArtifactManager(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ensureStructure", () => {
|
||||
it("creates .planning directory structure", () => {
|
||||
manager.ensureStructure();
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning", "phases"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
manager.ensureStructure();
|
||||
manager.ensureStructure();
|
||||
expect(fs.existsSync(path.join(tempDir, ".planning"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInitialized", () => {
|
||||
it("returns false before project is written", () => {
|
||||
manager.ensureStructure();
|
||||
expect(manager.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after project is written", () => {
|
||||
manager.ensureStructure();
|
||||
manager.writeProject({
|
||||
name: "Test Project",
|
||||
objective: "Build it",
|
||||
created_at: new Date().toISOString(),
|
||||
phases: [{ id: 1, name: "Phase 1", status: "pending" }],
|
||||
current_phase: 1,
|
||||
status: "initializing",
|
||||
});
|
||||
expect(manager.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeProject / readState / writePhaseArtifact", () => {
|
||||
it("writes and reads project artifacts", () => {
|
||||
manager.ensureStructure();
|
||||
const manifest: ProjectManifest = {
|
||||
name: "Test Project",
|
||||
objective: "Build a REST API",
|
||||
created_at: new Date().toISOString(),
|
||||
phases: [
|
||||
{ id: 1, name: "Research", status: "pending" },
|
||||
{ id: 2, name: "Plan & Execute", status: "pending" },
|
||||
],
|
||||
current_phase: 1,
|
||||
status: "initializing",
|
||||
};
|
||||
|
||||
manager.writeProject(manifest);
|
||||
|
||||
const projectPath = path.join(tempDir, ".planning", "PROJECT.md");
|
||||
expect(fs.existsSync(projectPath)).toBe(true);
|
||||
const content = fs.readFileSync(projectPath, "utf-8");
|
||||
expect(content).toContain("Test Project");
|
||||
expect(content).toContain("Build a REST API");
|
||||
expect(content).toContain("Phase 1: Research");
|
||||
});
|
||||
|
||||
it("writes phase artifacts", () => {
|
||||
manager.ensureStructure();
|
||||
manager.writePhaseArtifact(1, "PLAN.md", "# My Plan\n\nThis is the plan.");
|
||||
|
||||
const artifact = manager.readPhaseArtifact(1, "PLAN.md");
|
||||
expect(artifact).not.toBeNull();
|
||||
expect(artifact).toContain("My Plan");
|
||||
});
|
||||
|
||||
it("returns null for non-existent artifact", () => {
|
||||
manager.ensureStructure();
|
||||
const artifact = manager.readPhaseArtifact(99, "NONEXISTENT.md");
|
||||
expect(artifact).toBeNull();
|
||||
});
|
||||
|
||||
it("writes and reads state", () => {
|
||||
manager.ensureStructure();
|
||||
manager.writeState({
|
||||
current_phase: 1,
|
||||
current_stage: "execute",
|
||||
last_agent: "executor",
|
||||
last_action: "Implemented feature X",
|
||||
updated_at: new Date().toISOString(),
|
||||
pipeline_progress: { specify: true, clarify: true, research: true, plan: true, execute: false, verify: false, complete: false },
|
||||
});
|
||||
|
||||
const state = manager.readState();
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.current_phase).toBe(1);
|
||||
expect(state!.current_stage).toBe("execute");
|
||||
expect(state!.pipeline_progress.specify).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for state when not written", () => {
|
||||
manager.ensureStructure();
|
||||
const state = manager.readState();
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeDecisions", () => {
|
||||
it("writes decisions to DECISIONS.md", () => {
|
||||
manager.ensureStructure();
|
||||
manager.writeDecisions({
|
||||
decisions: [
|
||||
{
|
||||
id: "D-001",
|
||||
decision: "Use PostgreSQL",
|
||||
rationale: "ACID compliance",
|
||||
confidence: 0.92,
|
||||
category: "technology_choice",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const decisionsPath = path.join(tempDir, ".planning", "DECISIONS.md");
|
||||
expect(fs.existsSync(decisionsPath)).toBe(true);
|
||||
const content = fs.readFileSync(decisionsPath, "utf-8");
|
||||
expect(content).toContain("D-001");
|
||||
expect(content).toContain("Use PostgreSQL");
|
||||
expect(content).toContain("92%");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,18 @@ export class ArtifactManager {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
savePipelineState(state: Record<string, boolean>, currentPhase: number, currentStage: string, lastAgent: string, lastAction: string): void {
|
||||
const manifest: StateManifest = {
|
||||
current_phase: currentPhase,
|
||||
current_stage: currentStage,
|
||||
last_agent: lastAgent,
|
||||
last_action: lastAction,
|
||||
updated_at: new Date().toISOString(),
|
||||
pipeline_progress: state,
|
||||
};
|
||||
this.writeState(manifest);
|
||||
}
|
||||
|
||||
writePhaseArtifact(
|
||||
phase: number,
|
||||
artifactName: string,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { logDecision, logEscalation, readAudit, getAuditSummary } from "../core/audit.js";
|
||||
import { Decision } from "../types/decisions.js";
|
||||
import { Escalation } from "../types/escalation.js";
|
||||
|
||||
describe("Audit", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-audit-test-"));
|
||||
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sampleDecision: Decision = {
|
||||
id: "D-001",
|
||||
timestamp: new Date().toISOString(),
|
||||
decision: "Use PostgreSQL",
|
||||
rationale: "ACID compliance needed",
|
||||
confidence: 0.92,
|
||||
category: "technology_choice",
|
||||
alternatives_considered: [{ option: "MongoDB", rejected_reason: "No ACID" }],
|
||||
learnship_equivalent: "discuss-phase would ask: What database?",
|
||||
human_override: null,
|
||||
};
|
||||
|
||||
const sampleEscalation: Escalation = {
|
||||
id: "E-001",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "irreversible_action",
|
||||
phase: "1",
|
||||
description: "Deploy to staging",
|
||||
context: "All tests pass",
|
||||
options: [
|
||||
{ id: "A", label: "Deploy", description: "Deploy to staging", recommended: true },
|
||||
],
|
||||
default_option_id: "A",
|
||||
resolution: "pending",
|
||||
audit_file: ".ci/audit/test.json",
|
||||
};
|
||||
|
||||
describe("logDecision", () => {
|
||||
it("logs a decision to the audit trail", () => {
|
||||
logDecision(tempDir, 1, sampleDecision);
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].phase).toBe(1);
|
||||
expect(audit[0].decisions).toHaveLength(1);
|
||||
expect(audit[0].decisions[0].id).toBe("D-001");
|
||||
});
|
||||
|
||||
it("appends multiple decisions to same phase file", () => {
|
||||
logDecision(tempDir, 1, { ...sampleDecision, id: "D-001" });
|
||||
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002" });
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit[0].decisions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("separates decisions into different phase files", () => {
|
||||
logDecision(tempDir, 1, sampleDecision);
|
||||
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logEscalation", () => {
|
||||
it("logs an escalation to the audit trail", () => {
|
||||
logEscalation(tempDir, 1, sampleEscalation);
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].escalations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("can mix decisions and escalations in same phase", () => {
|
||||
logDecision(tempDir, 1, sampleDecision);
|
||||
logEscalation(tempDir, 1, sampleEscalation);
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit[0].decisions).toHaveLength(1);
|
||||
expect(audit[0].escalations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAudit", () => {
|
||||
it("returns empty array when no audit files exist", () => {
|
||||
const audit = readAudit(tempDir);
|
||||
expect(audit).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters by phase number", () => {
|
||||
logDecision(tempDir, 1, sampleDecision);
|
||||
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
|
||||
|
||||
const phase1 = readAudit(tempDir, 1);
|
||||
expect(phase1).toHaveLength(1);
|
||||
expect(phase1[0].phase).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuditSummary", () => {
|
||||
it("returns summary with counts", () => {
|
||||
logDecision(tempDir, 1, { ...sampleDecision, confidence: 0.95 });
|
||||
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002", confidence: 0.7 });
|
||||
logDecision(tempDir, 2, { ...sampleDecision, id: "D-003", confidence: 0.4 });
|
||||
logEscalation(tempDir, 1, sampleEscalation);
|
||||
|
||||
const summary = getAuditSummary(tempDir);
|
||||
expect(summary.total_decisions).toBe(3);
|
||||
expect(summary.total_escalations).toBe(1);
|
||||
expect(summary.phases).toContain(1);
|
||||
expect(summary.phases).toContain(2);
|
||||
expect(summary.decisions_by_confidence.high).toBe(1);
|
||||
expect(summary.decisions_by_confidence.medium).toBe(1);
|
||||
expect(summary.decisions_by_confidence.low).toBe(1);
|
||||
expect(summary.escalations_by_type.irreversible_action).toBe(1);
|
||||
});
|
||||
|
||||
it("returns zeros for empty audit", () => {
|
||||
const summary = getAuditSummary(tempDir);
|
||||
expect(summary.total_decisions).toBe(0);
|
||||
expect(summary.total_escalations).toBe(0);
|
||||
expect(summary.phases).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { CiFiles, ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "../core/ci-files.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "ci-files-test-"));
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("CiFiles", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(dir);
|
||||
});
|
||||
|
||||
describe("ensureCIDir", () => {
|
||||
it("creates .ci directory", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
expect(fs.existsSync(path.join(dir, ".ci"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInitialized", () => {
|
||||
it("returns false when no config.json exists", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
expect(ciFiles.isInitialized()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when config.json exists", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.ensureCIDir();
|
||||
fs.writeFileSync(path.join(dir, ".ci", "config.json"), "{}");
|
||||
expect(ciFiles.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROJECT.md", () => {
|
||||
const project: ProjectMd = {
|
||||
name: "Task API",
|
||||
coreValue: "Build a REST API for task management",
|
||||
requirements: {
|
||||
validated: ["User auth works"],
|
||||
active: ["Real-time notifications", "CRUD operations"],
|
||||
outOfScope: ["Admin dashboard"],
|
||||
},
|
||||
constraints: ["Must use Node.js", "Production-ready"],
|
||||
context: "This is a task management API",
|
||||
keyDecisions: [
|
||||
{ decision: "Use PostgreSQL", rationale: "ACID compliance", outcome: "✓ Good" },
|
||||
],
|
||||
};
|
||||
|
||||
it("writes and reads PROJECT.md", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeProjectMd(project, "initial creation");
|
||||
|
||||
const read = ciFiles.readProjectMd();
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.name).toBe("Task API");
|
||||
expect(read!.requirements.active).toContain("Real-time notifications");
|
||||
expect(read!.constraints).toContain("Must use Node.js");
|
||||
});
|
||||
|
||||
it("overwrites PROJECT.md on update", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeProjectMd(project, "initial");
|
||||
|
||||
const updated = { ...project, coreValue: "Updated description" };
|
||||
ciFiles.writeProjectMd(updated, "phase 1 complete");
|
||||
|
||||
const read = ciFiles.readProjectMd();
|
||||
expect(read!.coreValue).toBe("Updated description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ROADMAP.md", () => {
|
||||
const roadmap: RoadmapMd = {
|
||||
overview: "4-phase delivery",
|
||||
phases: [
|
||||
{
|
||||
number: 1,
|
||||
name: "auth",
|
||||
description: "Auth system",
|
||||
status: "in_progress",
|
||||
dependsOn: [],
|
||||
requirements: ["AUTH-01"],
|
||||
successCriteria: ["Users can sign up"],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
name: "tasks",
|
||||
description: "Task CRUD",
|
||||
status: "not_started",
|
||||
dependsOn: [1],
|
||||
requirements: ["TASK-01"],
|
||||
successCriteria: ["Users can create tasks"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("writes and reads ROADMAP.md", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeRoadmapMd(roadmap);
|
||||
|
||||
const read = ciFiles.readRoadmapMd();
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.overview).toBe("4-phase delivery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("REQUIREMENTS.md", () => {
|
||||
const requirements: RequirementsMd = {
|
||||
v1: [
|
||||
{
|
||||
category: "Auth",
|
||||
items: [
|
||||
{ id: "AUTH-01", description: "User can sign up" },
|
||||
{ id: "AUTH-02", description: "User can log in" },
|
||||
],
|
||||
},
|
||||
],
|
||||
v2: [
|
||||
{
|
||||
category: "Notifications",
|
||||
items: [{ id: "NOTIF-01", description: "Push notifications" }],
|
||||
},
|
||||
],
|
||||
outOfScope: [{ feature: "Admin dashboard", reason: "Not core value" }],
|
||||
traceability: [
|
||||
{ requirement: "AUTH-01", phase: 1, status: "pending" },
|
||||
{ requirement: "AUTH-02", phase: 1, status: "pending" },
|
||||
],
|
||||
};
|
||||
|
||||
it("writes and reads REQUIREMENTS.md", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeRequirementsMd(requirements);
|
||||
|
||||
const read = ciFiles.readRequirementsMd();
|
||||
expect(read).not.toBeNull();
|
||||
});
|
||||
|
||||
it("updates requirement status", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeRequirementsMd(requirements);
|
||||
|
||||
ciFiles.updateRequirementStatus("AUTH-01", "complete");
|
||||
|
||||
const read = ciFiles.readRequirementsMd();
|
||||
expect(read).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ARCHITECTURE.md", () => {
|
||||
const arch: ArchitectureMd = {
|
||||
overview: "Monolith with modules",
|
||||
components: [
|
||||
{
|
||||
name: "API",
|
||||
description: "REST API server",
|
||||
boundaries: "HTTP layer only",
|
||||
dependsOn: ["Auth", "Tasks"],
|
||||
},
|
||||
],
|
||||
dataFlow: "Client -> API -> DB",
|
||||
buildOrder: ["Auth", "Tasks", "API"],
|
||||
};
|
||||
|
||||
it("writes and reads ARCHITECTURE.md", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
ciFiles.writeArchitectureMd(arch);
|
||||
|
||||
const read = ciFiles.readArchitectureMd();
|
||||
expect(read).not.toBeNull();
|
||||
expect(read!.overview).toBe("Monolith with modules");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePhaseStatus", () => {
|
||||
it("updates phase status in roadmap", () => {
|
||||
const ciFiles = new CiFiles(dir);
|
||||
const roadmap: RoadmapMd = {
|
||||
overview: "test",
|
||||
phases: [
|
||||
{
|
||||
number: 1,
|
||||
name: "auth",
|
||||
description: "Auth",
|
||||
status: "not_started",
|
||||
dependsOn: [],
|
||||
requirements: [],
|
||||
successCriteria: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
ciFiles.writeRoadmapMd(roadmap);
|
||||
ciFiles.updatePhaseStatus(1, "complete");
|
||||
|
||||
const read = ciFiles.readRoadmapMd();
|
||||
expect(read).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
|
||||
import { PipelineStage } from "../types/pipeline.js";
|
||||
|
||||
const CI_DIR = ".ci";
|
||||
|
||||
export interface ProjectMd {
|
||||
name: string;
|
||||
coreValue: string;
|
||||
requirements: {
|
||||
validated: string[];
|
||||
active: string[];
|
||||
outOfScope: string[];
|
||||
};
|
||||
constraints: string[];
|
||||
context: string;
|
||||
keyDecisions: Array<{
|
||||
decision: string;
|
||||
rationale: string;
|
||||
outcome: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RoadmapMd {
|
||||
overview: string;
|
||||
phases: Array<{
|
||||
number: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "not_started" | "in_progress" | "complete" | "deferred";
|
||||
dependsOn: number[];
|
||||
requirements: string[];
|
||||
successCriteria: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RequirementsMd {
|
||||
v1: Array<{
|
||||
category: string;
|
||||
items: Array<{ id: string; description: string }>;
|
||||
}>;
|
||||
v2: Array<{
|
||||
category: string;
|
||||
items: Array<{ id: string; description: string }>;
|
||||
}>;
|
||||
outOfScope: Array<{ feature: string; reason: string }>;
|
||||
traceability: Array<{
|
||||
requirement: string;
|
||||
phase: number;
|
||||
status: "pending" | "in_progress" | "complete" | "blocked";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ArchitectureMd {
|
||||
overview: string;
|
||||
components: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
boundaries: string;
|
||||
dependsOn: string[];
|
||||
}>;
|
||||
dataFlow: string;
|
||||
buildOrder: string[];
|
||||
}
|
||||
|
||||
export class CiFiles {
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
}
|
||||
|
||||
private get ciDir(): string {
|
||||
return path.join(this.projectPath, CI_DIR);
|
||||
}
|
||||
|
||||
ensureCIDir(): void {
|
||||
ensureDir(this.ciDir);
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return fileExists(path.join(this.ciDir, "config.json"));
|
||||
}
|
||||
|
||||
readProjectMd(): ProjectMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "PROJECT.md"));
|
||||
if (!content) return null;
|
||||
return this.parseProjectMd(content);
|
||||
}
|
||||
|
||||
writeProjectMd(project: ProjectMd, reason: string): void {
|
||||
this.ensureCIDir();
|
||||
const lines: string[] = [
|
||||
`# ${project.name}`,
|
||||
"",
|
||||
"## What This Is",
|
||||
"",
|
||||
project.coreValue,
|
||||
"",
|
||||
"## Requirements",
|
||||
"",
|
||||
"### Validated",
|
||||
"",
|
||||
...project.requirements.validated.map((r) => `- ✓ ${r}`),
|
||||
"",
|
||||
"### Active",
|
||||
"",
|
||||
...project.requirements.active.map((r) => `- [ ] ${r}`),
|
||||
"",
|
||||
"### Out of Scope",
|
||||
"",
|
||||
...project.requirements.outOfScope.map((r) => `- ${r}`),
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
project.context,
|
||||
"",
|
||||
"## Constraints",
|
||||
"",
|
||||
...project.constraints.map((c) => `- ${c}`),
|
||||
"",
|
||||
"## Key Decisions",
|
||||
"",
|
||||
"| Decision | Rationale | Outcome |",
|
||||
"|----------|-----------|---------|",
|
||||
...project.keyDecisions.map(
|
||||
(d) => `| ${d.decision} | ${d.rationale} | ${d.outcome} |`
|
||||
),
|
||||
"",
|
||||
];
|
||||
|
||||
writeFile(path.join(this.ciDir, "PROJECT.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readRoadmapMd(): RoadmapMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "ROADMAP.md"));
|
||||
if (!content) return null;
|
||||
return this.parseRoadmapMd(content);
|
||||
}
|
||||
|
||||
writeRoadmapMd(roadmap: RoadmapMd): void {
|
||||
this.ensureCIDir();
|
||||
const lines: string[] = [
|
||||
"# Roadmap",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
roadmap.overview,
|
||||
"",
|
||||
"## Phases",
|
||||
"",
|
||||
...roadmap.phases.map(
|
||||
(p) => `- [${p.status === "complete" ? "x" : " "}] **Phase ${p.number}: ${p.name}** - ${p.description}`
|
||||
),
|
||||
"",
|
||||
"## Phase Details",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const phase of roadmap.phases) {
|
||||
lines.push(`### Phase ${phase.number}: ${phase.name}`);
|
||||
lines.push(`**Goal**: ${phase.description}`);
|
||||
lines.push(`**Depends on**: ${phase.dependsOn.length > 0 ? phase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "Nothing"}`);
|
||||
lines.push(`**Requirements**: ${phase.requirements.length > 0 ? phase.requirements.join(", ") : "None"}`);
|
||||
lines.push("**Success Criteria**:");
|
||||
for (const sc of phase.successCriteria) {
|
||||
lines.push(`1. ${sc}`);
|
||||
}
|
||||
lines.push(`**Status**: ${phase.status}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "ROADMAP.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readRequirementsMd(): RequirementsMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "REQUIREMENTS.md"));
|
||||
if (!content) return null;
|
||||
return this.parseRequirementsMd(content);
|
||||
}
|
||||
|
||||
writeRequirementsMd(requirements: RequirementsMd): void {
|
||||
this.ensureCIDir();
|
||||
const lines: string[] = [
|
||||
"# Requirements",
|
||||
"",
|
||||
"## v1 Requirements",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const cat of requirements.v1) {
|
||||
lines.push(`### ${cat.category}`);
|
||||
lines.push("");
|
||||
for (const item of cat.items) {
|
||||
lines.push(`- [ ] **${item.id}**: ${item.description}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## v2 Requirements");
|
||||
lines.push("");
|
||||
for (const cat of requirements.v2) {
|
||||
lines.push(`### ${cat.category}`);
|
||||
lines.push("");
|
||||
for (const item of cat.items) {
|
||||
lines.push(`- **${item.id}**: ${item.description}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Out of Scope");
|
||||
lines.push("");
|
||||
lines.push("| Feature | Reason |");
|
||||
lines.push("|---------|--------|");
|
||||
for (const item of requirements.outOfScope) {
|
||||
lines.push(`| ${item.feature} | ${item.reason} |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Traceability");
|
||||
lines.push("");
|
||||
lines.push("| Requirement | Phase | Status |");
|
||||
lines.push("|-------------|-------|--------|");
|
||||
for (const t of requirements.traceability) {
|
||||
lines.push(`| ${t.requirement} | Phase ${t.phase} | ${t.status} |`);
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "REQUIREMENTS.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
readArchitectureMd(): ArchitectureMd | null {
|
||||
const content = readFile(path.join(this.ciDir, "ARCHITECTURE.md"));
|
||||
if (!content) return null;
|
||||
return this.parseArchitectureMd(content);
|
||||
}
|
||||
|
||||
writeArchitectureMd(architecture: ArchitectureMd): void {
|
||||
this.ensureCIDir();
|
||||
const lines: string[] = [
|
||||
"# Architecture",
|
||||
"",
|
||||
"## Overview",
|
||||
"",
|
||||
architecture.overview,
|
||||
"",
|
||||
"## Components",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const comp of architecture.components) {
|
||||
lines.push(`### ${comp.name}`);
|
||||
lines.push(`- **Description**: ${comp.description}`);
|
||||
lines.push(`- **Boundaries**: ${comp.boundaries}`);
|
||||
lines.push(`- **Depends on**: ${comp.dependsOn.length > 0 ? comp.dependsOn.join(", ") : "None"}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Data Flow");
|
||||
lines.push("");
|
||||
lines.push(architecture.dataFlow);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Build Order");
|
||||
lines.push("");
|
||||
for (const step of architecture.buildOrder) {
|
||||
lines.push(`1. ${step}`);
|
||||
}
|
||||
|
||||
writeFile(path.join(this.ciDir, "ARCHITECTURE.md"), lines.join("\n"));
|
||||
}
|
||||
|
||||
updateRequirementStatus(reqId: string, status: "pending" | "in_progress" | "complete" | "blocked"): void {
|
||||
const reqs = this.readRequirementsMd();
|
||||
if (!reqs) return;
|
||||
|
||||
for (const t of reqs.traceability) {
|
||||
if (t.requirement === reqId) {
|
||||
t.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
this.writeRequirementsMd(reqs);
|
||||
}
|
||||
|
||||
updatePhaseStatus(phaseNumber: number, status: "not_started" | "in_progress" | "complete" | "deferred"): void {
|
||||
const roadmap = this.readRoadmapMd();
|
||||
if (!roadmap) return;
|
||||
|
||||
for (const phase of roadmap.phases) {
|
||||
if (phase.number === phaseNumber) {
|
||||
phase.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
this.writeRoadmapMd(roadmap);
|
||||
}
|
||||
|
||||
private parseProjectMd(content: string): ProjectMd {
|
||||
return {
|
||||
name: this.extractSection(content, "# ") || "Unknown",
|
||||
coreValue: this.extractSection(content, "## What This Is") || "",
|
||||
requirements: {
|
||||
validated: this.extractListItems(content, "### Validated"),
|
||||
active: this.extractListItems(content, "### Active"),
|
||||
outOfScope: this.extractListItems(content, "### Out of Scope"),
|
||||
},
|
||||
constraints: this.extractListItems(content, "## Constraints"),
|
||||
context: this.extractSection(content, "## Context") || "",
|
||||
keyDecisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private parseRoadmapMd(content: string): RoadmapMd {
|
||||
return {
|
||||
overview: this.extractSection(content, "## Overview") || "",
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
|
||||
private parseRequirementsMd(content: string): RequirementsMd {
|
||||
return {
|
||||
v1: [],
|
||||
v2: [],
|
||||
outOfScope: [],
|
||||
traceability: [],
|
||||
};
|
||||
}
|
||||
|
||||
private parseArchitectureMd(content: string): ArchitectureMd {
|
||||
return {
|
||||
overview: this.extractSection(content, "## Overview") || "",
|
||||
components: [],
|
||||
dataFlow: this.extractSection(content, "## Data Flow") || "",
|
||||
buildOrder: [],
|
||||
};
|
||||
}
|
||||
|
||||
private extractSection(content: string, header: string): string | null {
|
||||
const headerIdx = content.indexOf(header);
|
||||
if (headerIdx < 0) return null;
|
||||
|
||||
const startIdx = headerIdx + header.length;
|
||||
const nextHeaderIdx = content.indexOf("\n## ", startIdx);
|
||||
const endIdx = nextHeaderIdx >= 0 ? nextHeaderIdx : content.length;
|
||||
|
||||
return content.slice(startIdx, endIdx).trim();
|
||||
}
|
||||
|
||||
private extractListItems(content: string, header: string): string[] {
|
||||
const section = this.extractSection(content, header);
|
||||
if (!section) return [];
|
||||
|
||||
return section
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().startsWith("-"))
|
||||
.map((line) => line.replace(/^-\s*(?:\[[ x]\]\s*)?(?:✓\s*)?/, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ClarifyPhase, saveSpecification, loadSpecification } from "../core/clarify.js";
|
||||
import { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
import { Specification, parseSpecification } from "../types/specification.js";
|
||||
|
||||
describe("ClarifyPhase", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-clarify-test-"));
|
||||
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const specWithRequirements: Specification = {
|
||||
title: "Test Project",
|
||||
objective: "Build a REST API",
|
||||
requirements: ["User authentication", "CRUD operations", "Deploy to AWS"],
|
||||
constraints: ["Must use Node.js"],
|
||||
out_of_scope: ["Admin dashboard"],
|
||||
raw_content: "# Test Project\n## Objective\nBuild a REST API",
|
||||
source: "inline",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const specWithoutRequirements: Specification = {
|
||||
title: "Empty Project",
|
||||
objective: "Build something",
|
||||
requirements: [],
|
||||
constraints: [],
|
||||
out_of_scope: [],
|
||||
raw_content: "# Empty Project",
|
||||
source: "inline",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe("generateQuestions", () => {
|
||||
it("generates questions for missing requirements", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
expect(questions.length).toBeGreaterThan(0);
|
||||
const reqQuestion = questions.find((q) => q.category === "requirements");
|
||||
expect(reqQuestion).toBeDefined();
|
||||
expect(reqQuestion!.impact).toBe("critical");
|
||||
});
|
||||
|
||||
it("generates questions for missing constraints", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
const constraintQuestion = questions.find((q) => q.category === "constraints");
|
||||
expect(constraintQuestion).toBeDefined();
|
||||
expect(constraintQuestion!.impact).toBe("high");
|
||||
});
|
||||
|
||||
it("generates deployment question when deploy is mentioned without deploy constraint", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithRequirements);
|
||||
const deployQuestion = questions.find((q) => q.category === "deployment");
|
||||
expect(deployQuestion).toBeDefined();
|
||||
});
|
||||
|
||||
it("respects clarify_budget", () => {
|
||||
const limitedConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, clarify_budget: 1 },
|
||||
};
|
||||
const clarify = new ClarifyPhase(limitedConfig, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
expect(questions.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("assigns sequential question IDs", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
expect(questions[i].id).toBe(`Q-${String(i + 1).padStart(3, "0")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts questions by impact priority", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
for (let i = 1; i < questions.length; i++) {
|
||||
expect(priorityOrder[questions[i].impact]).toBeGreaterThanOrEqual(
|
||||
priorityOrder[questions[i - 1].impact]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("answerQuestion", () => {
|
||||
it("records an answer to a question", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
expect(questions.length).toBeGreaterThan(0);
|
||||
|
||||
const answered = clarify.answerQuestion(questions[0].id, "My custom answer");
|
||||
expect(answered).not.toBeNull();
|
||||
expect(answered!.answered).toBe(true);
|
||||
expect(answered!.answer).toBe("My custom answer");
|
||||
});
|
||||
|
||||
it("returns null for unknown question ID", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const result = clarify.answerQuestion("Q-999", "answer");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("acceptDefaults", () => {
|
||||
it("accepts defaults for all unanswered questions", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
clarify.generateQuestions(specWithoutRequirements);
|
||||
const result = clarify.acceptDefaults();
|
||||
|
||||
expect(result.unanswered_defaults_accepted).toBeGreaterThan(0);
|
||||
expect(result.total_questions).toBeGreaterThan(0);
|
||||
expect(result.answered_questions).toBe(result.total_questions);
|
||||
});
|
||||
|
||||
it("preserves manually answered questions", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
const questions = clarify.generateQuestions(specWithoutRequirements);
|
||||
if (questions.length > 0) {
|
||||
clarify.answerQuestion(questions[0].id, "My answer");
|
||||
}
|
||||
const result = clarify.acceptDefaults();
|
||||
const manuallyAnswered = result.questions.find(
|
||||
(q) => q.answer === "My answer"
|
||||
);
|
||||
expect(manuallyAnswered).toBeDefined();
|
||||
});
|
||||
|
||||
it("saves clarify responses file", () => {
|
||||
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir);
|
||||
clarify.generateQuestions(specWithoutRequirements);
|
||||
clarify.acceptDefaults();
|
||||
|
||||
const responsesPath = path.join(tempDir, ".ci", "clarify-responses.md");
|
||||
expect(fs.existsSync(responsesPath)).toBe(true);
|
||||
const content = fs.readFileSync(responsesPath, "utf-8");
|
||||
expect(content).toContain("Clarify Phase Responses");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveSpecification / loadSpecification", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-spec-test-"));
|
||||
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("saves and loads a specification", () => {
|
||||
const spec: Specification = {
|
||||
title: "Test",
|
||||
objective: "Build it",
|
||||
requirements: ["Feature A"],
|
||||
constraints: ["Node.js"],
|
||||
out_of_scope: [],
|
||||
raw_content: "# Test\n## Objective\nBuild it\n## Requirements\n- Feature A\n## Constraints\n- Node.js",
|
||||
source: "file",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
saveSpecification(tempDir, spec);
|
||||
const loaded = loadSpecification(tempDir);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.title).toBe("Test");
|
||||
expect(loaded!.requirements).toContain("Feature A");
|
||||
});
|
||||
|
||||
it("returns null when no specification exists", () => {
|
||||
const loaded = loadSpecification(tempDir);
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { CommitBuilder } from "../core/commit-builder.js";
|
||||
import { extractCiBlock, parseCiBlock } from "../core/commit-parser.js";
|
||||
import { CiMetadata } from "../types/commit-meta.js";
|
||||
|
||||
describe("CommitBuilder", () => {
|
||||
describe("buildCiBlock", () => {
|
||||
it("builds minimal ci block", () => {
|
||||
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
|
||||
expect(block).toContain("phase: 1");
|
||||
expect(block).toContain("milestone: v1.0");
|
||||
expect(block).toContain("status: execute");
|
||||
});
|
||||
|
||||
it("builds ci block with decisions", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
status: "execute",
|
||||
decisions: [
|
||||
{
|
||||
id: "D-001",
|
||||
decision: "Use PostgreSQL",
|
||||
rationale: "ACID compliance",
|
||||
confidence: 0.9,
|
||||
alternatives: ["MongoDB", "SQLite"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("decisions:");
|
||||
expect(block).toContain("id: D-001");
|
||||
expect(block).toContain("decision: Use PostgreSQL");
|
||||
expect(block).toContain("alternatives: [MongoDB, SQLite]");
|
||||
});
|
||||
|
||||
it("builds ci block with lessons", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
status: "complete",
|
||||
lessons: ["Always use async bcrypt", "Check JWT expiry first"],
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("lessons:");
|
||||
expect(block).toContain(" - Always use async bcrypt");
|
||||
expect(block).toContain(" - Check JWT expiry first");
|
||||
});
|
||||
|
||||
it("builds ci block with compound", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
status: "complete",
|
||||
compound: {
|
||||
category: "auth",
|
||||
problem: "Token replay",
|
||||
solution: "Refresh rotation",
|
||||
},
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("compound:");
|
||||
expect(block).toContain("category: auth");
|
||||
expect(block).toContain("problem: Token replay");
|
||||
expect(block).toContain("solution: Refresh rotation");
|
||||
});
|
||||
|
||||
it("builds ci block with escalations", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 3,
|
||||
milestone: "v1.0",
|
||||
status: "execute",
|
||||
escalations: [
|
||||
{
|
||||
id: "E-001",
|
||||
type: "irreversible_action",
|
||||
description: "Deploy to staging",
|
||||
resolution: "pending",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("escalations:");
|
||||
expect(block).toContain("id: E-001");
|
||||
expect(block).toContain("type: irreversible_action");
|
||||
});
|
||||
|
||||
it("builds ci block with requirements", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
status: "complete",
|
||||
requirements: {
|
||||
covered: ["AUTH-01", "AUTH-02"],
|
||||
partial: ["AUTH-03"],
|
||||
},
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
expect(block).toContain("requirements:");
|
||||
expect(block).toContain("covered: [AUTH-01, AUTH-02]");
|
||||
expect(block).toContain("partial: [AUTH-03]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: build then parse", () => {
|
||||
it("round-trips a simple ci block", () => {
|
||||
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
|
||||
const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---\n\nBody text`;
|
||||
const extracted = extractCiBlock(fullMessage)!;
|
||||
const parsed = parseCiBlock(extracted)!;
|
||||
|
||||
expect(parsed.phase).toBe(1);
|
||||
expect(parsed.milestone).toBe("v1.0");
|
||||
expect(parsed.status).toBe("execute");
|
||||
});
|
||||
|
||||
it("round-trips decisions", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
status: "execute",
|
||||
decisions: [
|
||||
{
|
||||
id: "D-001",
|
||||
decision: "Use PostgreSQL",
|
||||
rationale: "ACID compliance",
|
||||
confidence: 0.9,
|
||||
alternatives: ["MongoDB"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---`;
|
||||
const extracted = extractCiBlock(fullMessage)!;
|
||||
const parsed = parseCiBlock(extracted)!;
|
||||
|
||||
expect(parsed.decisions).toHaveLength(1);
|
||||
expect(parsed.decisions![0].id).toBe("D-001");
|
||||
expect(parsed.decisions![0].decision).toBe("Use PostgreSQL");
|
||||
expect(parsed.decisions![0].confidence).toBe(0.9);
|
||||
expect(parsed.decisions![0].alternatives).toEqual(["MongoDB"]);
|
||||
});
|
||||
|
||||
it("round-trips compound with lessons", () => {
|
||||
const ci: CiMetadata = {
|
||||
phase: 2,
|
||||
milestone: "v1.0",
|
||||
status: "complete",
|
||||
compound: {
|
||||
category: "auth",
|
||||
problem: "Token replay attacks",
|
||||
solution: "Refresh rotation with family IDs",
|
||||
},
|
||||
lessons: ["Token rotation is not optional"],
|
||||
};
|
||||
|
||||
const block = CommitBuilder.buildCiBlock(ci);
|
||||
const fullMessage = `compound(P02): test\n\n---ci---\n${block}\n---/ci---`;
|
||||
const extracted = extractCiBlock(fullMessage)!;
|
||||
const parsed = parseCiBlock(extracted)!;
|
||||
|
||||
expect(parsed.compound!.category).toBe("auth");
|
||||
expect(parsed.compound!.problem).toBe("Token replay attacks");
|
||||
expect(parsed.lessons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitCommit", () => {
|
||||
it("builds an init commit message", () => {
|
||||
const msg = CommitBuilder.buildInitCommit({
|
||||
projectName: "task-api",
|
||||
phaseCount: 4,
|
||||
milestone: "v1.0",
|
||||
specification: "Build a REST API for task management",
|
||||
requirements: ["AUTH-01", "TASK-01"],
|
||||
constraints: ["Node.js"],
|
||||
outOfScope: ["Admin dashboard"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("docs(init):");
|
||||
expect(msg).toContain("---ci---");
|
||||
expect(msg).toContain("phase: 0");
|
||||
expect(msg).toContain("milestone: v1.0");
|
||||
expect(msg).toContain("Build a REST API for task management");
|
||||
expect(msg).toContain("AUTH-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskCommit", () => {
|
||||
it("builds a task commit message", () => {
|
||||
const msg = CommitBuilder.buildTaskCommit({
|
||||
type: "feat",
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
plan: "01-01",
|
||||
task: "01-01-02",
|
||||
subject: "create user registration endpoint",
|
||||
status: "execute",
|
||||
decisions: [
|
||||
{
|
||||
id: "D-003",
|
||||
decision: "Use bcrypt with 12 rounds",
|
||||
rationale: "Industry standard",
|
||||
confidence: 0.88,
|
||||
alternatives: ["argon2"],
|
||||
},
|
||||
],
|
||||
requirements: { covered: ["AUTH-01"], partial: [] },
|
||||
});
|
||||
|
||||
expect(msg).toContain("feat(P01-01-02):");
|
||||
expect(msg).toContain("plan: 01-01");
|
||||
expect(msg).toContain("task: 01-01-02");
|
||||
expect(msg).toContain("D-003");
|
||||
expect(msg).toContain("AUTH-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPhaseCompletionCommit", () => {
|
||||
it("builds a phase completion commit", () => {
|
||||
const msg = CommitBuilder.buildPhaseCompletionCommit({
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
phaseName: "authentication",
|
||||
tasksCompleted: 4,
|
||||
tasksTotal: 4,
|
||||
taskNames: ["scaffold", "registration", "login", "reset"],
|
||||
requirements: { covered: ["AUTH-01", "AUTH-02", "AUTH-03", "AUTH-04"], partial: [] },
|
||||
lessons: ["Always use async bcrypt"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("docs(P01): complete authentication phase");
|
||||
expect(msg).toContain("status: complete");
|
||||
expect(msg).toContain("Tasks completed: 4/4");
|
||||
expect(msg).toContain("Always use async bcrypt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCompoundCommit", () => {
|
||||
it("builds a compound commit", () => {
|
||||
const msg = CommitBuilder.buildCompoundCommit({
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
category: "auth",
|
||||
problem: "Token replay allows persistent access",
|
||||
solution: "Refresh token rotation with family IDs",
|
||||
lessons: ["Rotation is not optional"],
|
||||
});
|
||||
|
||||
expect(msg).toContain("compound(P01):");
|
||||
expect(msg).toContain("category: auth");
|
||||
expect(msg).toContain("problem: Token replay");
|
||||
expect(msg).toContain("solution: Refresh token rotation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDecisionCommit", () => {
|
||||
it("builds a decision-only commit", () => {
|
||||
const msg = CommitBuilder.buildDecisionCommit({
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
subject: "use PostgreSQL over MongoDB",
|
||||
decisions: [
|
||||
{
|
||||
id: "D-001",
|
||||
decision: "PostgreSQL",
|
||||
rationale: "ACID",
|
||||
confidence: 0.92,
|
||||
alternatives: ["MongoDB"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(msg).toContain("decision(P01): use PostgreSQL over MongoDB");
|
||||
expect(msg).toContain("D-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEscalationCommit", () => {
|
||||
it("builds an escalation commit", () => {
|
||||
const msg = CommitBuilder.buildEscalationCommit({
|
||||
phase: 3,
|
||||
milestone: "v1.0",
|
||||
subject: "deploy to staging requires approval",
|
||||
escalations: [
|
||||
{
|
||||
id: "E-001",
|
||||
type: "irreversible_action",
|
||||
description: "Deploy to staging",
|
||||
resolution: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(msg).toContain("escalation(P03): deploy to staging requires approval");
|
||||
expect(msg).toContain("E-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVerifyCommit", () => {
|
||||
it("builds a verify commit", () => {
|
||||
const msg = CommitBuilder.buildVerifyCommit({
|
||||
phase: 1,
|
||||
milestone: "v1.0",
|
||||
subject: "all must-haves pass automated tests",
|
||||
requirements: { covered: ["AUTH-01", "AUTH-02"], partial: [] },
|
||||
});
|
||||
|
||||
expect(msg).toContain("verify(P01): all must-haves pass automated tests");
|
||||
expect(msg).toContain("AUTH-01");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
CiMetadata,
|
||||
CommitType,
|
||||
CommitScope,
|
||||
CommitDecision,
|
||||
CommitEscalation,
|
||||
CommitRequirements,
|
||||
CommitCompoundMeta,
|
||||
formatCommitScope,
|
||||
} from "../types/commit-meta.js";
|
||||
import { PipelineStage } from "../types/pipeline.js";
|
||||
|
||||
const CI_BLOCK_START = "---ci---";
|
||||
const CI_BLOCK_END = "---/ci---";
|
||||
|
||||
export interface CommitMessageInput {
|
||||
type: CommitType;
|
||||
scope: CommitScope;
|
||||
subject: string;
|
||||
ci: CiMetadata;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface InitCommitInput {
|
||||
projectName: string;
|
||||
phaseCount: number;
|
||||
milestone: string;
|
||||
specification: string;
|
||||
requirements?: string[];
|
||||
constraints?: string[];
|
||||
outOfScope?: string[];
|
||||
decisions?: CommitDecision[];
|
||||
}
|
||||
|
||||
export interface TaskCommitInput {
|
||||
type: CommitType;
|
||||
phase: number;
|
||||
milestone: string;
|
||||
plan: string;
|
||||
task: string;
|
||||
subject: string;
|
||||
status: PipelineStage;
|
||||
decisions?: CommitDecision[];
|
||||
requirements?: CommitRequirements;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface PhaseCompletionInput {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
phaseName: string;
|
||||
tasksCompleted: number;
|
||||
tasksTotal: number;
|
||||
taskNames: string[];
|
||||
decisions?: CommitDecision[];
|
||||
requirements?: CommitRequirements;
|
||||
lessons?: string[];
|
||||
securitySummary?: string;
|
||||
}
|
||||
|
||||
export interface DecisionCommitInput {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
subject: string;
|
||||
decisions: CommitDecision[];
|
||||
}
|
||||
|
||||
export interface EscalationCommitInput {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
subject: string;
|
||||
escalations: CommitEscalation[];
|
||||
}
|
||||
|
||||
export interface CompoundCommitInput {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
category: string;
|
||||
problem: string;
|
||||
solution: string;
|
||||
lessons?: string[];
|
||||
}
|
||||
|
||||
export interface VerifyCommitInput {
|
||||
phase: number;
|
||||
milestone: string;
|
||||
subject: string;
|
||||
requirements: CommitRequirements;
|
||||
lessons?: string[];
|
||||
}
|
||||
|
||||
export class CommitBuilder {
|
||||
static buildCiBlock(ci: CiMetadata): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`phase: ${ci.phase}`);
|
||||
lines.push(`milestone: ${ci.milestone}`);
|
||||
|
||||
if (ci.plan) lines.push(`plan: ${ci.plan}`);
|
||||
if (ci.task) lines.push(`task: ${ci.task}`);
|
||||
|
||||
lines.push(`status: ${ci.status}`);
|
||||
|
||||
if (ci.decisions && ci.decisions.length > 0) {
|
||||
lines.push("decisions:");
|
||||
for (const d of ci.decisions) {
|
||||
lines.push(` - id: ${d.id}`);
|
||||
lines.push(` decision: ${d.decision}`);
|
||||
lines.push(` rationale: ${d.rationale}`);
|
||||
lines.push(` confidence: ${d.confidence}`);
|
||||
lines.push(` alternatives: [${d.alternatives.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ci.escalations && ci.escalations.length > 0) {
|
||||
lines.push("escalations:");
|
||||
for (const e of ci.escalations) {
|
||||
lines.push(` - id: ${e.id}`);
|
||||
lines.push(` type: ${e.type}`);
|
||||
lines.push(` description: ${e.description}`);
|
||||
lines.push(` resolution: ${e.resolution}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ci.requirements) {
|
||||
lines.push("requirements:");
|
||||
lines.push(` covered: [${ci.requirements.covered.join(", ")}]`);
|
||||
lines.push(` partial: [${ci.requirements.partial.join(", ")}]`);
|
||||
}
|
||||
|
||||
if (ci.lessons && ci.lessons.length > 0) {
|
||||
lines.push("lessons:");
|
||||
for (const l of ci.lessons) {
|
||||
lines.push(` - ${l}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ci.compound) {
|
||||
lines.push("compound:");
|
||||
lines.push(` category: ${ci.compound.category}`);
|
||||
lines.push(` problem: ${ci.compound.problem}`);
|
||||
lines.push(` solution: ${ci.compound.solution}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
static buildCommitMessage(input: CommitMessageInput): string {
|
||||
const scopeStr = formatCommitScope(input.scope);
|
||||
const subjectLine = `${input.type}(${scopeStr}): ${input.subject}`;
|
||||
const ciBlock = CommitBuilder.buildCiBlock(input.ci);
|
||||
|
||||
const parts = [subjectLine, "", CI_BLOCK_START, ciBlock, CI_BLOCK_END];
|
||||
|
||||
if (input.body) {
|
||||
parts.push("", input.body);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
static buildInitCommit(input: InitCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: 0,
|
||||
milestone: input.milestone,
|
||||
status: "specify",
|
||||
decisions: input.decisions,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: 0, isInit: true, isMilestone: false };
|
||||
const subjectLine = `docs(init): initialize ${input.projectName.toLowerCase().replace(/[^a-z0-9]+/g, "-")} (${input.phaseCount} phases)`;
|
||||
const ciBlock = CommitBuilder.buildCiBlock(ci);
|
||||
|
||||
const bodyLines: string[] = [
|
||||
`Specification: ${input.specification}`,
|
||||
];
|
||||
|
||||
if (input.requirements?.length) {
|
||||
bodyLines.push("", `Requirements: ${input.requirements.join(", ")}`);
|
||||
}
|
||||
if (input.constraints?.length) {
|
||||
bodyLines.push("", `Constraints: ${input.constraints.join(", ")}`);
|
||||
}
|
||||
if (input.outOfScope?.length) {
|
||||
bodyLines.push("", `Out of scope: ${input.outOfScope.join(", ")}`);
|
||||
}
|
||||
|
||||
const parts = [subjectLine, "", CI_BLOCK_START, ciBlock, CI_BLOCK_END, "", ...bodyLines];
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
static buildTaskCommit(input: TaskCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
plan: input.plan,
|
||||
task: input.task,
|
||||
status: input.status,
|
||||
decisions: input.decisions,
|
||||
requirements: input.requirements,
|
||||
};
|
||||
|
||||
const scope: CommitScope = {
|
||||
phase: input.phase,
|
||||
plan: input.plan,
|
||||
task: input.task,
|
||||
isInit: false,
|
||||
isMilestone: false,
|
||||
};
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: input.type,
|
||||
scope,
|
||||
subject: input.subject,
|
||||
ci,
|
||||
body: input.body,
|
||||
});
|
||||
}
|
||||
|
||||
static buildPhaseCompletionCommit(input: PhaseCompletionInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
status: "complete",
|
||||
decisions: input.decisions,
|
||||
requirements: input.requirements,
|
||||
lessons: input.lessons,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: input.phase, isInit: false, isMilestone: false };
|
||||
const subjectLine = `docs(P${String(input.phase).padStart(2, "0")}): complete ${input.phaseName} phase`;
|
||||
const ciBlock = CommitBuilder.buildCiBlock(ci);
|
||||
|
||||
const bodyLines: string[] = [
|
||||
`Tasks completed: ${input.tasksCompleted}/${input.tasksTotal}`,
|
||||
];
|
||||
for (const name of input.taskNames) {
|
||||
bodyLines.push(`- ${name}`);
|
||||
}
|
||||
bodyLines.push("");
|
||||
|
||||
if (input.securitySummary) {
|
||||
bodyLines.push(input.securitySummary);
|
||||
}
|
||||
|
||||
return [subjectLine, "", CI_BLOCK_START, ciBlock, CI_BLOCK_END, "", ...bodyLines].join("\n");
|
||||
}
|
||||
|
||||
static buildDecisionCommit(input: DecisionCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
status: "plan",
|
||||
decisions: input.decisions,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: input.phase, isInit: false, isMilestone: false };
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: "decision",
|
||||
scope,
|
||||
subject: input.subject,
|
||||
ci,
|
||||
});
|
||||
}
|
||||
|
||||
static buildEscalationCommit(input: EscalationCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
status: "execute",
|
||||
escalations: input.escalations,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: input.phase, isInit: false, isMilestone: false };
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: "escalation",
|
||||
scope,
|
||||
subject: input.subject,
|
||||
ci,
|
||||
});
|
||||
}
|
||||
|
||||
static buildCompoundCommit(input: CompoundCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
status: "complete",
|
||||
compound: {
|
||||
category: input.category,
|
||||
problem: input.problem,
|
||||
solution: input.solution,
|
||||
},
|
||||
lessons: input.lessons,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: input.phase, isInit: false, isMilestone: false };
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: "compound",
|
||||
scope,
|
||||
subject: `${input.category}: ${input.problem.slice(0, 60)}`,
|
||||
ci,
|
||||
body: `Discovered during ${input.category} work. Problem: ${input.problem}. Solution: ${input.solution}.`,
|
||||
});
|
||||
}
|
||||
|
||||
static buildVerifyCommit(input: VerifyCommitInput): string {
|
||||
const ci: CiMetadata = {
|
||||
phase: input.phase,
|
||||
milestone: input.milestone,
|
||||
status: "verify",
|
||||
requirements: input.requirements,
|
||||
lessons: input.lessons,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase: input.phase, isInit: false, isMilestone: false };
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: "verify",
|
||||
scope,
|
||||
subject: input.subject,
|
||||
ci,
|
||||
});
|
||||
}
|
||||
|
||||
static buildResearchCommit(
|
||||
phase: number,
|
||||
milestone: string,
|
||||
subject: string,
|
||||
findings: string[],
|
||||
decisions?: CommitDecision[]
|
||||
): string {
|
||||
const ci: CiMetadata = {
|
||||
phase,
|
||||
milestone,
|
||||
status: "research",
|
||||
decisions,
|
||||
};
|
||||
|
||||
const scope: CommitScope = { phase, isInit: false, isMilestone: false };
|
||||
|
||||
return CommitBuilder.buildCommitMessage({
|
||||
type: "docs",
|
||||
scope,
|
||||
subject,
|
||||
ci,
|
||||
body: findings.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CiMetadata,
|
||||
CommitDecision,
|
||||
CommitEscalation,
|
||||
CommitRequirements,
|
||||
CommitCompoundMeta,
|
||||
} from "../types/commit-meta.js";
|
||||
import {
|
||||
extractCiBlock,
|
||||
parseCiBlock,
|
||||
parseCommitMessage,
|
||||
} from "./commit-parser.js";
|
||||
|
||||
const SAMPLE_INIT_COMMIT = `docs(init): initialize task-api (4 phases)
|
||||
|
||||
---ci---
|
||||
phase: 0
|
||||
milestone: v1.0
|
||||
status: specify
|
||||
decisions:
|
||||
- id: D-001
|
||||
decision: Node.js with Express for REST API
|
||||
rationale: Spec requires Node.js; Express is minimal and well-supported
|
||||
confidence: 0.95
|
||||
alternatives: [Fastify, Hono]
|
||||
- id: D-002
|
||||
decision: PostgreSQL for persistence
|
||||
rationale: ACID compliance required by spec
|
||||
confidence: 0.90
|
||||
alternatives: [MongoDB, SQLite]
|
||||
---/ci---
|
||||
|
||||
Specification: Build a REST API for task management with JWT auth, CRUD
|
||||
operations, real-time notifications via WebSocket, PostgreSQL database.
|
||||
|
||||
Requirements: AUTH-01 through AUTH-04, TASK-01 through TASK-05, NOTIF-01
|
||||
Constraints: Node.js, production-ready, no Docker
|
||||
Out of scope: Admin dashboard, payment integration, mobile apps`;
|
||||
|
||||
const SAMPLE_TASK_COMMIT = `feat(P01-01-02): create user registration endpoint
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
plan: 01-01
|
||||
task: 01-01-02
|
||||
status: execute
|
||||
decisions:
|
||||
- id: D-003
|
||||
decision: Use bcrypt with 12 rounds for password hashing
|
||||
rationale: Industry standard; argon2 not available in target env
|
||||
confidence: 0.88
|
||||
alternatives: [argon2, scrypt]
|
||||
requirements:
|
||||
covered: [AUTH-01]
|
||||
---/ci---
|
||||
|
||||
- POST /auth/register validates email and password
|
||||
- Checks for duplicate users
|
||||
- Returns JWT token on success`;
|
||||
|
||||
const SAMPLE_PHASE_COMPLETE_COMMIT = `docs(P01): complete authentication phase
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: complete
|
||||
decisions:
|
||||
- id: D-005
|
||||
decision: Session JWTs with 1hr expiry + opaque refresh token
|
||||
rationale: Balances security and UX; refresh rotation prevents replay
|
||||
confidence: 0.92
|
||||
alternatives: [Stateless JWT only, session cookies]
|
||||
requirements:
|
||||
covered: [AUTH-01, AUTH-02, AUTH-03, AUTH-04]
|
||||
lessons:
|
||||
- bcrypt async is 10x faster than sync in Node; always use bcrypt.compare()
|
||||
- JWT expiry must be checked before signature verification to prevent edge cases
|
||||
---/ci---
|
||||
|
||||
Tasks completed: 4/4`;
|
||||
|
||||
const SAMPLE_COMPOUND_COMMIT = `compound(auth): JWT refresh token rotation pattern
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: complete
|
||||
compound:
|
||||
category: auth
|
||||
problem: Refresh tokens can be replayed if stolen; naive implementation allows token reuse
|
||||
solution: Implement refresh token rotation — each use invalidates old token and issues new one. Store token family ID to detect replay attempts. On replay detection, revoke entire family.
|
||||
lessons:
|
||||
- Refresh token rotation is not optional for production auth
|
||||
- Token family detection prevents silent takeover
|
||||
---/ci---
|
||||
|
||||
Discovered during AUTH-04 implementation.`;
|
||||
|
||||
const SAMPLE_ESCALATION_COMMIT = `escalation(P03): deploy to staging requires approval
|
||||
|
||||
---ci---
|
||||
phase: 3
|
||||
milestone: v1.0
|
||||
status: execute
|
||||
escalations:
|
||||
- id: E-001
|
||||
type: irreversible_action
|
||||
description: Phase 3 requires deployment to staging environment
|
||||
resolution: pending
|
||||
---/ci---
|
||||
|
||||
All tests pass. Awaiting deploy approval.`;
|
||||
|
||||
describe("extractCiBlock", () => {
|
||||
it("extracts ---ci--- block from commit message", () => {
|
||||
const block = extractCiBlock(SAMPLE_INIT_COMMIT);
|
||||
expect(block).toBeTruthy();
|
||||
expect(block).toContain("phase: 0");
|
||||
expect(block).toContain("milestone: v1.0");
|
||||
});
|
||||
|
||||
it("returns null when no ---ci--- block exists", () => {
|
||||
const block = extractCiBlock("docs: some regular commit\n\nNo CI block here");
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unclosed ---ci--- block", () => {
|
||||
const block = extractCiBlock("docs: bad\n---ci---\nphase: 1\nno end marker");
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCiBlock", () => {
|
||||
it("parses init commit ci block", () => {
|
||||
const block = extractCiBlock(SAMPLE_INIT_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
|
||||
expect(meta.phase).toBe(0);
|
||||
expect(meta.milestone).toBe("v1.0");
|
||||
expect(meta.status).toBe("specify");
|
||||
expect(meta.decisions).toHaveLength(2);
|
||||
expect(meta.decisions![0].id).toBe("D-001");
|
||||
expect(meta.decisions![0].decision).toBe("Node.js with Express for REST API");
|
||||
expect(meta.decisions![0].confidence).toBe(0.95);
|
||||
expect(meta.decisions![0].alternatives).toEqual(["Fastify", "Hono"]);
|
||||
});
|
||||
|
||||
it("parses task commit ci block", () => {
|
||||
const block = extractCiBlock(SAMPLE_TASK_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
|
||||
expect(meta.phase).toBe(1);
|
||||
expect(meta.plan).toBe("01-01");
|
||||
expect(meta.task).toBe("01-01-02");
|
||||
expect(meta.status).toBe("execute");
|
||||
expect(meta.decisions).toHaveLength(1);
|
||||
expect(meta.decisions![0].id).toBe("D-003");
|
||||
expect(meta.requirements).toBeDefined();
|
||||
expect(meta.requirements!.covered).toEqual(["AUTH-01"]);
|
||||
});
|
||||
|
||||
it("parses phase completion with lessons", () => {
|
||||
const block = extractCiBlock(SAMPLE_PHASE_COMPLETE_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
|
||||
expect(meta.phase).toBe(1);
|
||||
expect(meta.status).toBe("complete");
|
||||
expect(meta.lessons).toHaveLength(2);
|
||||
expect(meta.lessons![0]).toContain("bcrypt async");
|
||||
expect(meta.requirements!.covered).toEqual(["AUTH-01", "AUTH-02", "AUTH-03", "AUTH-04"]);
|
||||
});
|
||||
|
||||
it("parses compound commit", () => {
|
||||
const block = extractCiBlock(SAMPLE_COMPOUND_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
|
||||
expect(meta.compound).toBeDefined();
|
||||
expect(meta.compound!.category).toBe("auth");
|
||||
expect(meta.compound!.problem).toContain("Refresh tokens can be replayed");
|
||||
expect(meta.compound!.solution).toContain("refresh token rotation");
|
||||
expect(meta.lessons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses escalation commit", () => {
|
||||
const block = extractCiBlock(SAMPLE_ESCALATION_COMMIT)!;
|
||||
const meta = parseCiBlock(block)!;
|
||||
|
||||
expect(meta.escalations).toHaveLength(1);
|
||||
expect(meta.escalations![0].id).toBe("E-001");
|
||||
expect(meta.escalations![0].type).toBe("irreversible_action");
|
||||
expect(meta.escalations![0].resolution).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns null for empty block", () => {
|
||||
const meta = parseCiBlock("");
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for block missing required fields", () => {
|
||||
const meta = parseCiBlock("something: true\nother: false");
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCommitMessage", () => {
|
||||
it("parses init commit subject line", () => {
|
||||
const parsed = parseCommitMessage("abc123", SAMPLE_INIT_COMMIT);
|
||||
|
||||
expect(parsed.hash).toBe("abc123");
|
||||
expect(parsed.type).toBe("docs");
|
||||
expect(parsed.scope).toBe("init");
|
||||
expect(parsed.subject).toBe("initialize task-api (4 phases)");
|
||||
expect(parsed.ci).not.toBeNull();
|
||||
expect(parsed.ci!.phase).toBe(0);
|
||||
});
|
||||
|
||||
it("parses task commit with scope", () => {
|
||||
const parsed = parseCommitMessage("def456", SAMPLE_TASK_COMMIT);
|
||||
|
||||
expect(parsed.type).toBe("feat");
|
||||
expect(parsed.scope).toBe("P01-01-02");
|
||||
expect(parsed.ci!.plan).toBe("01-01");
|
||||
expect(parsed.ci!.task).toBe("01-01-02");
|
||||
});
|
||||
|
||||
it("parses compound commit type", () => {
|
||||
const parsed = parseCommitMessage("ghi789", SAMPLE_COMPOUND_COMMIT);
|
||||
expect(parsed.type).toBe("compound");
|
||||
expect(parsed.ci!.compound!.category).toBe("auth");
|
||||
});
|
||||
|
||||
it("parses escalation commit type", () => {
|
||||
const parsed = parseCommitMessage("jkl012", SAMPLE_ESCALATION_COMMIT);
|
||||
expect(parsed.type).toBe("escalation");
|
||||
expect(parsed.ci!.escalations![0].id).toBe("E-001");
|
||||
});
|
||||
|
||||
it("handles commit without ci block", () => {
|
||||
const msg = "feat: some regular feature\n\nJust a normal commit.";
|
||||
const parsed = parseCommitMessage("mno345", msg);
|
||||
|
||||
expect(parsed.type).toBe("feat");
|
||||
expect(parsed.ci).toBeNull();
|
||||
expect(parsed.body).toContain("Just a normal commit");
|
||||
});
|
||||
|
||||
it("extracts body text outside ci block", () => {
|
||||
const parsed = parseCommitMessage("pqr678", SAMPLE_TASK_COMMIT);
|
||||
expect(parsed.body).toContain("POST /auth/register validates email and password");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
CiMetadata,
|
||||
CommitType,
|
||||
CommitEscalation,
|
||||
ParsedCiCommit,
|
||||
parseCommitType,
|
||||
parseCommitScope,
|
||||
} from "../types/commit-meta.js";
|
||||
|
||||
const CI_BLOCK_START = "---ci---";
|
||||
const CI_BLOCK_END = "---/ci---";
|
||||
|
||||
export function extractCiBlock(message: string): string | null {
|
||||
const startIdx = message.indexOf(CI_BLOCK_START);
|
||||
if (startIdx < 0) return null;
|
||||
|
||||
const endIdx = message.indexOf(CI_BLOCK_END, startIdx);
|
||||
if (endIdx < 0) return null;
|
||||
|
||||
return message.slice(startIdx + CI_BLOCK_START.length, endIdx).trim();
|
||||
}
|
||||
|
||||
export function parseCiBlock(yaml: string): CiMetadata | null {
|
||||
if (!yaml) return null;
|
||||
|
||||
const result: Partial<CiMetadata> = {};
|
||||
|
||||
const phaseMatch = yaml.match(/^phase:\s*(.+)$/m);
|
||||
if (phaseMatch) result.phase = parseInt(phaseMatch[1], 10) || 0;
|
||||
|
||||
const milestoneMatch = yaml.match(/^milestone:\s*(.+)$/m);
|
||||
if (milestoneMatch) result.milestone = milestoneMatch[1].trim();
|
||||
|
||||
const planMatch = yaml.match(/^plan:\s*(.+)$/m);
|
||||
if (planMatch) result.plan = planMatch[1].trim();
|
||||
|
||||
const taskMatch = yaml.match(/^task:\s*(.+)$/m);
|
||||
if (taskMatch) result.task = taskMatch[1].trim();
|
||||
|
||||
const statusMatch = yaml.match(/^status:\s*(.+)$/m);
|
||||
if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"];
|
||||
|
||||
result.decisions = parseDecisionsFromYaml(yaml);
|
||||
result.escalations = parseEscalationsFromYaml(yaml);
|
||||
result.requirements = parseRequirementsFromYaml(yaml);
|
||||
result.lessons = parseLessonsFromYaml(yaml);
|
||||
result.compound = parseCompoundFromYaml(yaml);
|
||||
|
||||
if (result.phase !== undefined && result.milestone !== undefined && result.status !== undefined) {
|
||||
return result as CiMetadata;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDecisionsFromYaml(yaml: string): CiMetadata["decisions"] {
|
||||
const decisions: NonNullable<CiMetadata["decisions"]> = [];
|
||||
const decisionRegex = /- id: (.+)\n\s+decision: (.+)\n\s+rationale: (.+)\n\s+confidence: (.+)\n\s+alternatives: \[([^\]]*)\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = decisionRegex.exec(yaml)) !== null) {
|
||||
decisions.push({
|
||||
id: match[1].trim(),
|
||||
decision: match[2].trim(),
|
||||
rationale: match[3].trim(),
|
||||
confidence: parseFloat(match[4].trim()),
|
||||
alternatives: match[5].trim().split(",").map((a: string) => a.trim()).filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return decisions.length > 0 ? decisions : undefined;
|
||||
}
|
||||
|
||||
function parseEscalationsFromYaml(yaml: string): CiMetadata["escalations"] {
|
||||
const escalations: NonNullable<CiMetadata["escalations"]> = [];
|
||||
const escalationRegex = /- id: (.+)\n\s+type: (.+)\n\s+description: (.+)\n\s+resolution: (.+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = escalationRegex.exec(yaml)) !== null) {
|
||||
escalations.push({
|
||||
id: match[1].trim(),
|
||||
type: match[2].trim(),
|
||||
description: match[3].trim(),
|
||||
resolution: match[4].trim() as CommitEscalation["resolution"],
|
||||
});
|
||||
}
|
||||
|
||||
return escalations.length > 0 ? escalations : undefined;
|
||||
}
|
||||
|
||||
function parseRequirementsFromYaml(yaml: string): CiMetadata["requirements"] {
|
||||
const coveredMatch = yaml.match(/^\s+covered: \[([^\]]*)\]/m);
|
||||
const partialMatch = yaml.match(/^\s+partial: \[([^\]]*)\]/m);
|
||||
|
||||
const covered = coveredMatch
|
||||
? coveredMatch[1].split(",").map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const partial = partialMatch
|
||||
? partialMatch[1].split(",").map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (covered.length === 0 && partial.length === 0) return undefined;
|
||||
return { covered, partial };
|
||||
}
|
||||
|
||||
function parseLessonsFromYaml(yaml: string): CiMetadata["lessons"] {
|
||||
const lessonRegex = /^ - (.+)$/gm;
|
||||
const lessons: string[] = [];
|
||||
let inLessonsSection = false;
|
||||
|
||||
for (const line of yaml.split("\n")) {
|
||||
if (/^lessons:/.test(line.trim())) {
|
||||
inLessonsSection = true;
|
||||
continue;
|
||||
}
|
||||
if (inLessonsSection && /^ - .+/.test(line)) {
|
||||
lessons.push(line.replace(/^ - /, "").trim());
|
||||
} else if (inLessonsSection && !/^ - /.test(line) && !/^$/.test(line)) {
|
||||
inLessonsSection = false;
|
||||
}
|
||||
}
|
||||
|
||||
return lessons.length > 0 ? lessons : undefined;
|
||||
}
|
||||
|
||||
function parseCompoundFromYaml(yaml: string): CiMetadata["compound"] {
|
||||
const categoryMatch = yaml.match(/^\s+category: (.+)$/m);
|
||||
const problemMatch = yaml.match(/^\s+problem: (.+)$/m);
|
||||
const solutionMatch = yaml.match(/^\s+solution: (.+)$/m);
|
||||
|
||||
if (!categoryMatch || !problemMatch || !solutionMatch) return undefined;
|
||||
|
||||
return {
|
||||
category: categoryMatch[1].trim(),
|
||||
problem: problemMatch[1].trim(),
|
||||
solution: solutionMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCommitMessage(
|
||||
hash: string,
|
||||
message: string
|
||||
): ParsedCiCommit {
|
||||
const firstLine = message.split("\n")[0] || "";
|
||||
const subjectMatch = firstLine.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
|
||||
|
||||
let type: CommitType = "chore";
|
||||
let scope = "";
|
||||
let subject = firstLine;
|
||||
|
||||
if (subjectMatch) {
|
||||
type = parseCommitType(subjectMatch[1]);
|
||||
scope = subjectMatch[2] || "";
|
||||
subject = subjectMatch[3] || firstLine;
|
||||
}
|
||||
|
||||
const ciBlock = extractCiBlock(message);
|
||||
const ci = ciBlock ? parseCiBlock(ciBlock) : null;
|
||||
|
||||
const bodyStart = message.indexOf("\n");
|
||||
let body = bodyStart >= 0 ? message.slice(bodyStart + 1).trim() : "";
|
||||
|
||||
if (ciBlock) {
|
||||
const blockStart = message.indexOf(CI_BLOCK_START);
|
||||
const blockEnd = message.indexOf(CI_BLOCK_END) + CI_BLOCK_END.length;
|
||||
|
||||
const before = message.slice(bodyStart + 1, blockStart).trim();
|
||||
const after = message.slice(blockEnd).trim();
|
||||
body = [before, after].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
return { hash, type, scope, subject, ci, body };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { initCI, loadConfig, saveConfig, isCIInitialized, ensureCIDir } from "../core/config.js";
|
||||
import { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
|
||||
describe("CI Config", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-config-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("initCI", () => {
|
||||
it("initializes a new CI project with default config", () => {
|
||||
const config = initCI(tempDir);
|
||||
expect(config.autonomy.level).toBe("full");
|
||||
expect(isCIInitialized(tempDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes with custom config merged on top of defaults", () => {
|
||||
const config = initCI(tempDir, {
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "guided" },
|
||||
});
|
||||
expect(config.autonomy.level).toBe("guided");
|
||||
expect(config.autonomy.clarify_budget).toBe(10);
|
||||
expect(config.model_profile).toBe("quality");
|
||||
});
|
||||
|
||||
it("creates .ci/ directory structure", () => {
|
||||
initCI(tempDir);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci", "config.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("deep merges nested config", () => {
|
||||
const config = initCI(tempDir, {
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "supervised" },
|
||||
});
|
||||
expect(config.autonomy.level).toBe("supervised");
|
||||
expect(config.autonomy.max_revision_iterations).toBe(3);
|
||||
expect(config.autonomy.escalation_hooks).toEqual(["deploy", "delete_data", "merge_to_main"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("returns default config when no config file exists", () => {
|
||||
const config = loadConfig(tempDir);
|
||||
expect(config).toEqual(DEFAULT_CI_CONFIG);
|
||||
});
|
||||
|
||||
it("loads and deep merges config from file", () => {
|
||||
initCI(tempDir, { autonomy: { ...DEFAULT_CI_CONFIG.autonomy, decision_confidence_threshold: 0.85 } });
|
||||
const config = loadConfig(tempDir);
|
||||
expect(config.autonomy.decision_confidence_threshold).toBe(0.85);
|
||||
expect(config.autonomy.level).toBe("full");
|
||||
expect(config.autonomy.clarify_budget).toBe(10);
|
||||
});
|
||||
|
||||
it("preserves nested objects that are not overridden", () => {
|
||||
initCI(tempDir, { git: { ...DEFAULT_CI_CONFIG.git, auto_push: true } });
|
||||
const config = loadConfig(tempDir);
|
||||
expect(config.git.auto_push).toBe(true);
|
||||
expect(config.git.auto_commit).toBe(true);
|
||||
expect(config.git.branching_strategy).toBe("phase");
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveConfig", () => {
|
||||
it("saves and reloads config correctly", () => {
|
||||
ensureCIDir(tempDir);
|
||||
const customConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "guided" as const },
|
||||
};
|
||||
saveConfig(tempDir, customConfig);
|
||||
const loaded = loadConfig(tempDir);
|
||||
expect(loaded.autonomy.level).toBe("guided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCIInitialized", () => {
|
||||
it("returns false for uninitialized directory", () => {
|
||||
expect(isCIInitialized(tempDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after initCI", () => {
|
||||
initCI(tempDir);
|
||||
expect(isCIInitialized(tempDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureCIDir", () => {
|
||||
it("creates .ci directory", () => {
|
||||
ensureCIDir(tempDir);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
ensureCIDir(tempDir);
|
||||
ensureCIDir(tempDir);
|
||||
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -18,10 +18,26 @@ export function ensureCIDir(projectPath: string): void {
|
||||
if (!fs.existsSync(ciDir)) {
|
||||
fs.mkdirSync(ciDir, { recursive: true });
|
||||
}
|
||||
const auditDir = path.join(ciDir, "audit");
|
||||
if (!fs.existsSync(auditDir)) {
|
||||
fs.mkdirSync(auditDir, { recursive: true });
|
||||
}
|
||||
|
||||
function deepMerge(base: CIConfig, override: Record<string, unknown>): CIConfig {
|
||||
const result = { ...base } as Record<string, unknown>;
|
||||
for (const key of Object.keys(override)) {
|
||||
const baseVal = result[key];
|
||||
const overrideVal = override[key];
|
||||
if (
|
||||
baseVal && typeof baseVal === "object" && !Array.isArray(baseVal) &&
|
||||
overrideVal && typeof overrideVal === "object" && !Array.isArray(overrideVal)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
baseVal as unknown as CIConfig,
|
||||
overrideVal as Record<string, unknown>
|
||||
) as unknown;
|
||||
} else if (overrideVal !== undefined) {
|
||||
result[key] = overrideVal;
|
||||
}
|
||||
}
|
||||
return result as unknown as CIConfig;
|
||||
}
|
||||
|
||||
export function loadConfig(projectPath: string): CIConfig {
|
||||
@@ -31,7 +47,7 @@ export function loadConfig(projectPath: string): CIConfig {
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_CI_CONFIG, ...parsed } as CIConfig;
|
||||
return deepMerge(DEFAULT_CI_CONFIG, parsed);
|
||||
}
|
||||
|
||||
export function saveConfig(projectPath: string, config: CIConfig): void {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { DecisionEngine, DecisionInput } from "../core/decision-engine.js";
|
||||
import { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
|
||||
describe("DecisionEngine", () => {
|
||||
let tempDir: string;
|
||||
let engine: DecisionEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-decision-test-"));
|
||||
engine = new DecisionEngine(DEFAULT_CI_CONFIG, tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseInput: DecisionInput = {
|
||||
decision: "Use PostgreSQL for storage",
|
||||
rationale: "Strong ecosystem, ACID compliance needed",
|
||||
confidence: 0.95,
|
||||
category: "technology_choice",
|
||||
alternatives_considered: [
|
||||
{ option: "MongoDB", rejected_reason: "No ACID transactions" },
|
||||
{ option: "SQLite", rejected_reason: "No concurrent writes" },
|
||||
],
|
||||
learnship_equivalent: "discuss-phase would ask: What database? Options: A) PostgreSQL B) MongoDB",
|
||||
};
|
||||
|
||||
describe("makeDecision", () => {
|
||||
it("auto-decides with high confidence (above threshold)", () => {
|
||||
const result = engine.makeDecision(baseInput);
|
||||
expect(result.escalated).toBe(false);
|
||||
expect(result.decision.id).toMatch(/^D-\d{3}$/);
|
||||
expect(result.decision.confidence).toBe(0.95);
|
||||
expect(result.decision.category).toBe("technology_choice");
|
||||
});
|
||||
|
||||
it("escalates with low confidence (below threshold)", () => {
|
||||
const result = engine.makeDecision({
|
||||
...baseInput,
|
||||
confidence: 0.4,
|
||||
});
|
||||
expect(result.escalated).toBe(true);
|
||||
expect(result.reason).toContain("below threshold");
|
||||
});
|
||||
|
||||
it("auto-decides at exactly threshold confidence", () => {
|
||||
const result = engine.makeDecision({
|
||||
...baseInput,
|
||||
confidence: 0.6,
|
||||
});
|
||||
expect(result.escalated).toBe(false);
|
||||
});
|
||||
|
||||
it("increments decision IDs sequentially", () => {
|
||||
const result1 = engine.makeDecision(baseInput);
|
||||
const result2 = engine.makeDecision(baseInput);
|
||||
expect(result1.decision.id).toBe("D-001");
|
||||
expect(result2.decision.id).toBe("D-002");
|
||||
});
|
||||
|
||||
it("generates commit message for git-native audit trail", () => {
|
||||
const result = engine.makeDecision(baseInput);
|
||||
expect(result.commitMessage).toBeDefined();
|
||||
expect(result.commitMessage).toContain("---ci---");
|
||||
expect(result.commitMessage).toContain("D-001");
|
||||
expect(result.commitMessage).toContain("Use PostgreSQL for storage");
|
||||
});
|
||||
|
||||
it("preserves alternatives in the decision", () => {
|
||||
const result = engine.makeDecision(baseInput);
|
||||
expect(result.decision.alternatives_considered).toHaveLength(2);
|
||||
expect(result.decision.alternatives_considered![0].option).toBe("MongoDB");
|
||||
});
|
||||
|
||||
it("sets human_override to null by default", () => {
|
||||
const result = engine.makeDecision(baseInput);
|
||||
expect(result.decision.human_override).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeHighConfidenceDecision", () => {
|
||||
it("creates a decision with 0.95 confidence", () => {
|
||||
const result = engine.makeHighConfidenceDecision(
|
||||
"Use REST API",
|
||||
"REST is well-understood and has wide tooling support",
|
||||
"architecture"
|
||||
);
|
||||
expect(result.escalated).toBe(false);
|
||||
expect(result.decision.confidence).toBe(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeMediumConfidenceDecision", () => {
|
||||
it("creates a decision with 0.7 confidence", () => {
|
||||
const result = engine.makeMediumConfidenceDecision(
|
||||
"Use JWT for auth",
|
||||
"JWT is standard for stateless APIs",
|
||||
"implementation_approach"
|
||||
);
|
||||
expect(result.escalated).toBe(false);
|
||||
expect(result.decision.confidence).toBe(0.7);
|
||||
});
|
||||
|
||||
it("escalates if threshold is raised above 0.7", () => {
|
||||
const strictConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, decision_confidence_threshold: 0.8 },
|
||||
};
|
||||
const strictEngine = new DecisionEngine(strictConfig, tempDir);
|
||||
const result = strictEngine.makeMediumConfidenceDecision(
|
||||
"Use JWT for auth",
|
||||
"JWT is standard",
|
||||
"implementation_approach"
|
||||
);
|
||||
expect(result.escalated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoDecide", () => {
|
||||
it("returns true when confidence meets threshold", () => {
|
||||
expect(engine.shouldAutoDecide(0.6)).toBe(true);
|
||||
expect(engine.shouldAutoDecide(0.8)).toBe(true);
|
||||
expect(engine.shouldAutoDecide(1.0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when confidence is below threshold", () => {
|
||||
expect(engine.shouldAutoDecide(0.59)).toBe(false);
|
||||
expect(engine.shouldAutoDecide(0.3)).toBe(false);
|
||||
expect(engine.shouldAutoDecide(0.0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIrreversibleAction", () => {
|
||||
it("detects irreversible actions from escalation_hooks", () => {
|
||||
expect(engine.isIrreversibleAction("deploy to production")).toBe(true);
|
||||
expect(engine.isIrreversibleAction("delete_data in database")).toBe(true);
|
||||
expect(engine.isIrreversibleAction("merge_to_main branch")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-irreversible actions", () => {
|
||||
expect(engine.isIrreversibleAction("create file")).toBe(false);
|
||||
expect(engine.isIrreversibleAction("run tests")).toBe(false);
|
||||
expect(engine.isIrreversibleAction("refactor code")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPhase", () => {
|
||||
it("updates the current phase", () => {
|
||||
engine.setPhase(3);
|
||||
expect(engine.setPhase).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMilestone", () => {
|
||||
it("updates the current milestone", () => {
|
||||
engine.setMilestone("v2.0");
|
||||
expect(engine.setMilestone).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import { execSync } from "node:child_process";
|
||||
import { Decision, DecisionCategory, Alternative, confidenceToLevel } from "../types/decisions.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { logDecision } from "./audit.js";
|
||||
import { CommitBuilder, DecisionCommitInput } from "./commit-builder.js";
|
||||
import { CommitDecision } from "../types/commit-meta.js";
|
||||
|
||||
export interface DecisionInput {
|
||||
decision: string;
|
||||
@@ -18,18 +19,21 @@ export interface DecisionResult {
|
||||
decision: Decision;
|
||||
escalated: boolean;
|
||||
reason?: string;
|
||||
commitMessage?: string;
|
||||
}
|
||||
|
||||
export class DecisionEngine {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private currentPhase: number;
|
||||
private currentMilestone: string;
|
||||
private decisionCounter: number;
|
||||
|
||||
constructor(config: CIConfig, projectPath: string) {
|
||||
constructor(config: CIConfig, projectPath: string, milestone: string = "v1.0") {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.currentPhase = 0;
|
||||
this.currentMilestone = milestone;
|
||||
this.decisionCounter = 0;
|
||||
}
|
||||
|
||||
@@ -37,6 +41,10 @@ export class DecisionEngine {
|
||||
this.currentPhase = phase;
|
||||
}
|
||||
|
||||
setMilestone(milestone: string): void {
|
||||
this.currentMilestone = milestone;
|
||||
}
|
||||
|
||||
makeDecision(input: DecisionInput): DecisionResult {
|
||||
const id = `D-${String(++this.decisionCounter).padStart(3, "0")}`;
|
||||
const threshold = this.config.autonomy.decision_confidence_threshold;
|
||||
@@ -55,19 +63,38 @@ export class DecisionEngine {
|
||||
task: input.task,
|
||||
};
|
||||
|
||||
logDecision(this.projectPath, this.currentPhase, decision);
|
||||
const commitDecision: CommitDecision = {
|
||||
id,
|
||||
decision: input.decision,
|
||||
rationale: input.rationale,
|
||||
confidence: input.confidence,
|
||||
alternatives: input.alternatives_considered.map((a) => a.option),
|
||||
};
|
||||
|
||||
const confidenceLevel = confidenceToLevel(input.confidence);
|
||||
|
||||
if (input.confidence < threshold) {
|
||||
const escalated = input.confidence < threshold;
|
||||
|
||||
let commitMessage: string | undefined;
|
||||
if (this.config.git.auto_commit) {
|
||||
commitMessage = CommitBuilder.buildDecisionCommit({
|
||||
phase: this.currentPhase,
|
||||
milestone: this.currentMilestone,
|
||||
subject: input.decision,
|
||||
decisions: [commitDecision],
|
||||
});
|
||||
}
|
||||
|
||||
if (escalated) {
|
||||
return {
|
||||
decision,
|
||||
escalated: true,
|
||||
reason: `Confidence ${input.confidence.toFixed(2)} below threshold ${threshold} (${confidenceLevel})`,
|
||||
commitMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return { decision, escalated: false };
|
||||
return { decision, escalated: false, commitMessage };
|
||||
}
|
||||
|
||||
makeHighConfidenceDecision(
|
||||
@@ -113,4 +140,17 @@ export class DecisionEngine {
|
||||
action.toLowerCase().includes(hook.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
commitDecision(commitMessage: string): boolean {
|
||||
if (!this.config.git.auto_commit) return false;
|
||||
try {
|
||||
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: this.projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { ErrorRecovery } from "../core/error-recovery.js";
|
||||
import { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
|
||||
describe("ErrorRecovery", () => {
|
||||
let tempDir: string;
|
||||
let recovery: ErrorRecovery;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-recovery-test-"));
|
||||
fs.mkdirSync(path.join(tempDir, ".planning", "phases"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true });
|
||||
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("recoverFromFailure", () => {
|
||||
it("recommends retry for verification failures within retry limit", async () => {
|
||||
const result = await recovery.recoverFromFailure("Test failed", 1, "verify", 1);
|
||||
expect(result.recovered).toBe(true);
|
||||
expect(result.strategy).toBe("retry");
|
||||
expect(result.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it("escalates after max verification retries exceeded", async () => {
|
||||
const result = await recovery.recoverFromFailure("Test failed", 1, "verify", 4);
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.strategy).toBe("escalate");
|
||||
});
|
||||
|
||||
it("recommends plan revision for plan stage failures", async () => {
|
||||
const result = await recovery.recoverFromFailure("Plan issues found", 1, "plan", 1);
|
||||
expect(result.recovered).toBe(true);
|
||||
expect(result.strategy).toBe("plan_revision");
|
||||
});
|
||||
|
||||
it("escalates after max plan revisions", async () => {
|
||||
await recovery.recoverFromFailure("Plan issues", 1, "plan", 1);
|
||||
await recovery.recoverFromFailure("Plan issues", 1, "plan", 1);
|
||||
await recovery.recoverFromFailure("Plan issues", 1, "plan", 1);
|
||||
const result = await recovery.recoverFromFailure("Plan issues", 1, "plan", 1);
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.strategy).toBe("escalate");
|
||||
});
|
||||
|
||||
it("escalates for non-recoverable stages", async () => {
|
||||
const result = await recovery.recoverFromFailure("Unknown error", 1, "specify", 1);
|
||||
expect(result.recovered).toBe(false);
|
||||
expect(result.strategy).toBe("escalate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollback", () => {
|
||||
it("returns recovered result with rollback strategy", async () => {
|
||||
const result = await recovery.rollback(1, "User-requested rollback");
|
||||
expect(result.recovered).toBe(true);
|
||||
expect(result.strategy).toBe("rollback");
|
||||
expect(result.message).toContain("phase 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAutoDebug", () => {
|
||||
it("returns true when confidence meets threshold", () => {
|
||||
expect(recovery.canAutoDebug("error", 0.6)).toBe(true);
|
||||
expect(recovery.canAutoDebug("error", 0.8)).toBe(true);
|
||||
expect(recovery.canAutoDebug("error", 1.0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when confidence is below threshold", () => {
|
||||
expect(recovery.canAutoDebug("error", 0.59)).toBe(false);
|
||||
expect(recovery.canAutoDebug("error", 0.3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaxRetries", () => {
|
||||
it("returns the configured max verification retries", () => {
|
||||
expect(recovery.getMaxRetries()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaxRevisions", () => {
|
||||
it("returns the configured max revision iterations", () => {
|
||||
expect(recovery.getMaxRevisions()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { ArtifactManager } from "./artifacts.js";
|
||||
import { DecisionEngine } from "./decision-engine.js";
|
||||
|
||||
export interface RetryConfig {
|
||||
max_retries: number;
|
||||
@@ -69,8 +67,6 @@ export class ErrorRecovery {
|
||||
}
|
||||
|
||||
async rollback(phase: number, reason: string): Promise<RecoveryResult> {
|
||||
const artifactManager = new ArtifactManager(this.projectPath);
|
||||
|
||||
return {
|
||||
recovered: true,
|
||||
strategy: "rollback",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { EscalationProtocol, EscalationInput } from "../core/escalation.js";
|
||||
import { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
|
||||
describe("EscalationProtocol", () => {
|
||||
let tempDir: string;
|
||||
let protocol: EscalationProtocol;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-escalation-test-"));
|
||||
const noAutoCommitConfig = {
|
||||
...DEFAULT_CI_CONFIG,
|
||||
git: { ...DEFAULT_CI_CONFIG.git, auto_commit: false },
|
||||
};
|
||||
protocol = new EscalationProtocol(noAutoCommitConfig, tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
protocol.clearAllTimers();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseInput: EscalationInput = {
|
||||
type: "irreversible_action",
|
||||
phase: "1",
|
||||
description: "Deploy to staging environment",
|
||||
context: "Phase 1 backend is complete, all tests pass",
|
||||
options: [
|
||||
{ id: "A", label: "Deploy to staging", description: "Deploy to staging for integration testing", recommended: true },
|
||||
{ id: "B", label: "Skip deployment", description: "Continue locally", recommended: false },
|
||||
{ id: "C", label: "Abort phase", description: "Await manual deployment", recommended: false },
|
||||
],
|
||||
default_option_id: "A",
|
||||
};
|
||||
|
||||
describe("escalate", () => {
|
||||
it("creates an escalation with a generated ID", () => {
|
||||
const escalation = protocol.escalate(baseInput);
|
||||
expect(escalation.id).toMatch(/^E-\d{3}$/);
|
||||
expect(escalation.type).toBe("irreversible_action");
|
||||
expect(escalation.description).toBe("Deploy to staging environment");
|
||||
expect(escalation.resolution).toBe("pending");
|
||||
});
|
||||
|
||||
it("increments escalation IDs sequentially", () => {
|
||||
const e1 = protocol.escalate(baseInput);
|
||||
const e2 = protocol.escalate(baseInput);
|
||||
expect(e1.id).toBe("E-001");
|
||||
expect(e2.id).toBe("E-002");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEscalation", () => {
|
||||
it("resolves a pending escalation", () => {
|
||||
const escalation = protocol.escalate(baseInput);
|
||||
const resolved = protocol.resolveEscalation(escalation.id, "A");
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.resolution).toBe("approved");
|
||||
expect(resolved!.resolution_detail).toContain("A");
|
||||
});
|
||||
|
||||
it("returns null for unknown escalation ID", () => {
|
||||
const result = protocol.resolveEscalation("E-999", "A");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("supports different resolution types", () => {
|
||||
const escalation = protocol.escalate(baseInput);
|
||||
const rejected = protocol.resolveEscalation(escalation.id, "C", "rejected");
|
||||
expect(rejected!.resolution).toBe("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPendingEscalations", () => {
|
||||
it("returns pending escalations", () => {
|
||||
protocol.escalate(baseInput);
|
||||
protocol.escalate(baseInput);
|
||||
const pending = protocol.getPendingEscalations();
|
||||
expect(pending).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty list when no pending escalations", () => {
|
||||
expect(protocol.getPendingEscalations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes resolved escalations from pending", () => {
|
||||
const e1 = protocol.escalate(baseInput);
|
||||
protocol.escalate(baseInput);
|
||||
protocol.resolveEscalation(e1.id, "A");
|
||||
expect(protocol.getPendingEscalations()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPending", () => {
|
||||
it("returns true when there are pending escalations", () => {
|
||||
protocol.escalate(baseInput);
|
||||
expect(protocol.hasPending()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no pending escalations", () => {
|
||||
expect(protocol.hasPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEscalation", () => {
|
||||
it("formats escalation for display", () => {
|
||||
const escalation = protocol.escalate(baseInput);
|
||||
const formatted = protocol.formatEscalation(escalation);
|
||||
expect(formatted).toContain("ESCALATION");
|
||||
expect(formatted).toContain("Irreversible Action");
|
||||
expect(formatted).toContain("Deploy to staging environment");
|
||||
expect(formatted).toContain("Options:");
|
||||
expect(formatted).toContain("recommended");
|
||||
expect(formatted).toContain("auto-proceed");
|
||||
});
|
||||
});
|
||||
});
|
||||
+69
-9
@@ -1,5 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
Escalation,
|
||||
EscalationType,
|
||||
@@ -8,7 +7,8 @@ import {
|
||||
ESCALATION_TYPES,
|
||||
} from "../types/escalation.js";
|
||||
import { CIConfig } from "../types/config.js";
|
||||
import { logEscalation } from "./audit.js";
|
||||
import { CommitBuilder, EscalationCommitInput } from "./commit-builder.js";
|
||||
import { CommitEscalation } from "../types/commit-meta.js";
|
||||
|
||||
export interface EscalationInput {
|
||||
type: EscalationType;
|
||||
@@ -24,25 +24,33 @@ export interface EscalationInput {
|
||||
export class EscalationProtocol {
|
||||
private config: CIConfig;
|
||||
private projectPath: string;
|
||||
private currentMilestone: string;
|
||||
private counter: number;
|
||||
private pendingEscalations: Map<string, Escalation>;
|
||||
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
|
||||
private timers: NodeJS.Timeout[];
|
||||
|
||||
constructor(
|
||||
config: CIConfig,
|
||||
projectPath: string,
|
||||
milestone: string = "v1.0",
|
||||
timeoutCallback: (escalation: Escalation, chosenOption: string) => void = () => {}
|
||||
) {
|
||||
this.config = config;
|
||||
this.projectPath = projectPath;
|
||||
this.currentMilestone = milestone;
|
||||
this.counter = 0;
|
||||
this.pendingEscalations = new Map();
|
||||
this.timeoutCallback = timeoutCallback;
|
||||
this.timers = [];
|
||||
}
|
||||
|
||||
setMilestone(milestone: string): void {
|
||||
this.currentMilestone = milestone;
|
||||
}
|
||||
|
||||
escalate(input: EscalationInput): Escalation {
|
||||
const id = `E-${String(++this.counter).padStart(3, "0")}`;
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
|
||||
const escalation: Escalation = {
|
||||
id,
|
||||
@@ -56,11 +64,28 @@ export class EscalationProtocol {
|
||||
options: input.options,
|
||||
default_option_id: input.default_option_id,
|
||||
resolution: "pending",
|
||||
audit_file: `.ci/audit/${date}-phase${input.phase}-decisions.json`,
|
||||
audit_file: `.ci/audit/deprecated`,
|
||||
};
|
||||
|
||||
this.pendingEscalations.set(id, escalation);
|
||||
logEscalation(this.projectPath, parseInt(input.phase) || 0, escalation);
|
||||
|
||||
if (this.config.git.auto_commit) {
|
||||
const commitEscalation: CommitEscalation = {
|
||||
id,
|
||||
type: input.type,
|
||||
description: input.description,
|
||||
resolution: "pending",
|
||||
};
|
||||
|
||||
const commitMessage = CommitBuilder.buildEscalationCommit({
|
||||
phase: parseInt(input.phase) || 0,
|
||||
milestone: this.currentMilestone,
|
||||
subject: input.description,
|
||||
escalations: [commitEscalation],
|
||||
});
|
||||
|
||||
this.commitEscalation(commitMessage);
|
||||
}
|
||||
|
||||
if (this.config.autonomy.escalation_timeout_ms > 0) {
|
||||
this.scheduleTimeout(escalation);
|
||||
@@ -81,6 +106,24 @@ export class EscalationProtocol {
|
||||
escalation.resolved_at = new Date().toISOString();
|
||||
escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
|
||||
|
||||
if (this.config.git.auto_commit) {
|
||||
const commitEscalation: CommitEscalation = {
|
||||
id: escalation.id,
|
||||
type: escalation.type,
|
||||
description: escalation.description,
|
||||
resolution: resolution === "timeout_auto_proceed" ? "timeout" : resolution === "approved" ? "auto" : resolution === "rejected" ? "human" : resolution === "modified" ? "human" : resolution,
|
||||
};
|
||||
|
||||
const commitMessage = CommitBuilder.buildEscalationCommit({
|
||||
phase: parseInt(escalation.phase) || 0,
|
||||
milestone: this.currentMilestone,
|
||||
subject: `resolved: ${escalation.description}`,
|
||||
escalations: [commitEscalation],
|
||||
});
|
||||
|
||||
this.commitEscalation(commitMessage);
|
||||
}
|
||||
|
||||
this.pendingEscalations.delete(escalationId);
|
||||
return escalation;
|
||||
}
|
||||
@@ -93,6 +136,14 @@ export class EscalationProtocol {
|
||||
return this.pendingEscalations.size > 0;
|
||||
}
|
||||
|
||||
clearAllTimers(): void {
|
||||
for (const timer of this.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers = [];
|
||||
this.pendingEscalations.clear();
|
||||
}
|
||||
|
||||
formatEscalation(escalation: Escalation): string {
|
||||
const lines: string[] = [
|
||||
`⚠️ ESCALATION [${escalation.id}]`,
|
||||
@@ -126,16 +177,24 @@ export class EscalationProtocol {
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(`\nAudit: ${escalation.audit_file}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private commitEscalation(commitMessage: string): void {
|
||||
try {
|
||||
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
|
||||
cwd: this.projectPath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleTimeout(escalation: Escalation): void {
|
||||
const timeout = this.config.autonomy.escalation_timeout_ms;
|
||||
if (timeout <= 0) return;
|
||||
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingEscalations.has(escalation.id)) {
|
||||
escalation.resolution = "timeout_auto_proceed";
|
||||
escalation.resolved_at = new Date().toISOString();
|
||||
@@ -144,5 +203,6 @@ export class EscalationProtocol {
|
||||
this.timeoutCallback(escalation, escalation.default_option_id);
|
||||
}
|
||||
}, timeout);
|
||||
this.timers.push(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { GitBranch } from "../core/git-branch.js";
|
||||
|
||||
function createTempRepo(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-branch-test-"));
|
||||
execSync("git init", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
fs.writeFileSync(path.join(dir, "file.txt"), "initial", "utf-8");
|
||||
execSync("git add . && git commit -m 'initial'", { cwd: dir, stdio: "pipe" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("GitBranch", () => {
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = createTempRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(repoDir);
|
||||
});
|
||||
|
||||
describe("createPhaseBranch", () => {
|
||||
it("creates a phase branch with correct naming", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const result = gitBranch.createPhaseBranch(1, "authentication");
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.name).toBe("phase/01-authentication");
|
||||
});
|
||||
|
||||
it("detects already existing phase branch", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
gitBranch.createPhaseBranch(1, "authentication");
|
||||
|
||||
const result = gitBranch.createPhaseBranch(1, "authentication");
|
||||
expect(result.alreadyExisted).toBe(true);
|
||||
expect(result.created).toBe(false);
|
||||
});
|
||||
|
||||
it("zero-pads phase numbers", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const result = gitBranch.createPhaseBranch(3, "real-time-notifications");
|
||||
|
||||
expect(result.name).toBe("phase/03-real-time-notifications");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMilestoneBranch", () => {
|
||||
it("creates a milestone branch with correct naming", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.name).toBe("milestone/v1.0-mvp");
|
||||
});
|
||||
|
||||
it("detects already existing milestone branch", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
gitBranch.createMilestoneBranch("v1.0", "mvp");
|
||||
|
||||
const result = gitBranch.createMilestoneBranch("v1.0", "mvp");
|
||||
expect(result.alreadyExisted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPhases", () => {
|
||||
it("lists phase branches with status", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
gitBranch.createPhaseBranch(1, "auth");
|
||||
gitBranch.createPhaseBranch(2, "tasks");
|
||||
|
||||
const phases = gitBranch.listPhases();
|
||||
expect(phases.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const phase1 = phases.find((p) => p.phaseNumber === 1);
|
||||
expect(phase1).toBeDefined();
|
||||
expect(phase1!.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPhaseStatus", () => {
|
||||
it("returns status for existing phase branch", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
gitBranch.createPhaseBranch(1, "auth");
|
||||
|
||||
const status = gitBranch.getPhaseStatus(1);
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.phaseNumber).toBe(1);
|
||||
expect(status!.status).toBe("active");
|
||||
});
|
||||
|
||||
it("returns null for non-existent phase", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const status = gitBranch.getPhaseStatus(99);
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugify", () => {
|
||||
it("creates correct branch slugs", () => {
|
||||
const gitBranch = new GitBranch(repoDir);
|
||||
const result = gitBranch.createPhaseBranch(1, "Real-Time Notifications & Stuff!");
|
||||
expect(result.name).toMatch(/^phase\/01-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { GitContext, BranchInfo } from "./git-context.js";
|
||||
|
||||
export interface BranchCreateResult {
|
||||
name: string;
|
||||
created: boolean;
|
||||
alreadyExisted: boolean;
|
||||
}
|
||||
|
||||
export interface BranchMergeResult {
|
||||
success: boolean;
|
||||
squash: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PhaseBranchInfo {
|
||||
phaseNumber: number;
|
||||
slug: string;
|
||||
branchName: string;
|
||||
status: "active" | "complete" | "unknown";
|
||||
}
|
||||
|
||||
export interface MilestoneBranchInfo {
|
||||
version: string;
|
||||
slug: string;
|
||||
branchName: string;
|
||||
status: "active" | "complete" | "unknown";
|
||||
}
|
||||
|
||||
export class GitBranch {
|
||||
private projectPath: string;
|
||||
private gitContext: GitContext;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
this.gitContext = new GitContext(projectPath);
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd: this.projectPath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
createPhaseBranch(phaseNumber: number, phaseName: string): BranchCreateResult {
|
||||
const padded = String(phaseNumber).padStart(2, "0");
|
||||
const slug = this.slugify(phaseName);
|
||||
const branchName = `phase/${padded}-${slug}`;
|
||||
|
||||
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
return { name: branchName, created: false, alreadyExisted: true };
|
||||
}
|
||||
|
||||
const result = this.git(`checkout -b ${branchName}`);
|
||||
if (!result && this.git(`checkout ${branchName}`)) {
|
||||
return { name: branchName, created: false, alreadyExisted: true };
|
||||
}
|
||||
|
||||
return { name: branchName, created: true, alreadyExisted: false };
|
||||
}
|
||||
|
||||
createMilestoneBranch(version: string, milestoneName: string): BranchCreateResult {
|
||||
const slug = this.slugify(milestoneName);
|
||||
const branchName = `milestone/${version}-${slug}`;
|
||||
|
||||
const existing = this.gitContext.getBranches().find((b) => b.name === branchName);
|
||||
if (existing) {
|
||||
return { name: branchName, created: false, alreadyExisted: true };
|
||||
}
|
||||
|
||||
const result = this.git(`checkout -b ${branchName}`);
|
||||
if (!result && this.git(`checkout ${branchName}`)) {
|
||||
return { name: branchName, created: false, alreadyExisted: true };
|
||||
}
|
||||
|
||||
return { name: branchName, created: true, alreadyExisted: false };
|
||||
}
|
||||
|
||||
mergePhaseBranch(
|
||||
phaseBranchName: string,
|
||||
targetBranch: string,
|
||||
squash: boolean = true
|
||||
): BranchMergeResult {
|
||||
const branches = this.gitContext.getBranches();
|
||||
const phaseBranch = branches.find((b) => b.name === phaseBranchName);
|
||||
if (!phaseBranch) {
|
||||
return { success: false, squash, message: `Branch ${phaseBranchName} not found` };
|
||||
}
|
||||
|
||||
this.git(`checkout ${targetBranch}`);
|
||||
|
||||
const mergeCmd = squash
|
||||
? `merge --squash ${phaseBranchName}`
|
||||
: `merge --no-ff ${phaseBranchName}`;
|
||||
|
||||
const result = this.git(mergeCmd);
|
||||
if (result === "" && !squash) {
|
||||
return { success: false, squash, message: `Merge conflict on ${phaseBranchName}` };
|
||||
}
|
||||
|
||||
if (squash) {
|
||||
this.git(`commit -m "docs: merge phase branch ${phaseBranchName}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
squash,
|
||||
message: `Merged ${phaseBranchName} into ${targetBranch} (squash: ${squash})`,
|
||||
};
|
||||
}
|
||||
|
||||
getPhaseStatus(phaseNumber: number): PhaseBranchInfo | null {
|
||||
const branches = this.gitContext.getBranches();
|
||||
const phaseBranch = branches.find(
|
||||
(b) => b.type === "phase" && b.phaseNumber === phaseNumber
|
||||
);
|
||||
|
||||
if (!phaseBranch) return null;
|
||||
|
||||
const slug = phaseBranch.name.replace(/^phase\/\d+-/, "");
|
||||
return {
|
||||
phaseNumber,
|
||||
slug,
|
||||
branchName: phaseBranch.name,
|
||||
status: phaseBranch.merged ? "complete" : "active",
|
||||
};
|
||||
}
|
||||
|
||||
getMilestoneStatus(version: string): MilestoneBranchInfo | null {
|
||||
const branches = this.gitContext.getBranches();
|
||||
const milestoneBranch = branches.find(
|
||||
(b) => b.type === "milestone" && b.milestone?.startsWith(version)
|
||||
);
|
||||
|
||||
if (!milestoneBranch) return null;
|
||||
|
||||
const slug = milestoneBranch.name.replace(/^milestone\//, "");
|
||||
return {
|
||||
version,
|
||||
slug,
|
||||
branchName: milestoneBranch.name,
|
||||
status: milestoneBranch.merged ? "complete" : "active",
|
||||
};
|
||||
}
|
||||
|
||||
listPhases(): PhaseBranchInfo[] {
|
||||
const branches = this.gitContext.getPhaseBranches();
|
||||
return branches.map((b) => ({
|
||||
phaseNumber: b.phaseNumber || 0,
|
||||
slug: b.name.replace(/^phase\/\d+-/, ""),
|
||||
branchName: b.name,
|
||||
status: b.merged ? "complete" : "active" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
listMilestones(): MilestoneBranchInfo[] {
|
||||
const branches = this.gitContext.getMilestoneBranches();
|
||||
return branches.map((b) => ({
|
||||
version: b.milestone || "",
|
||||
slug: b.name.replace(/^milestone\//, ""),
|
||||
branchName: b.name,
|
||||
status: b.merged ? "complete" : "active" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
switchToBranch(branchName: string): boolean {
|
||||
const result = this.git(`checkout ${branchName}`);
|
||||
return result !== "";
|
||||
}
|
||||
|
||||
deleteBranch(branchName: string, force: boolean = false): boolean {
|
||||
const flag = force ? "-D" : "-d";
|
||||
const result = this.git(`branch ${flag} ${branchName}`);
|
||||
return result !== "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { GitContext } from "../core/git-context.js";
|
||||
|
||||
function createTempRepo(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-"));
|
||||
execSync("git init", { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function commit(repoDir: string, message: string): void {
|
||||
const filePath = path.join(repoDir, `file-${Date.now()}.txt`);
|
||||
fs.writeFileSync(filePath, "test", "utf-8");
|
||||
execSync(`git add "${filePath}"`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: repoDir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function cleanup(dir: string): void {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("GitContext", () => {
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = createTempRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(repoDir);
|
||||
});
|
||||
|
||||
describe("isGitRepo", () => {
|
||||
it("returns true for a git repo", () => {
|
||||
const ctx = new GitContext(repoDir);
|
||||
expect(ctx.isGitRepo()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-git directory", () => {
|
||||
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), "ci-nongit-"));
|
||||
const ctx = new GitContext(nonGit);
|
||||
expect(ctx.isGitRepo()).toBe(false);
|
||||
cleanup(nonGit);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBranch", () => {
|
||||
it("returns current branch name", () => {
|
||||
const ctx = new GitContext(repoDir);
|
||||
commit(repoDir, "initial");
|
||||
expect(ctx.getCurrentBranch()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentCommits", () => {
|
||||
it("returns parsed commits with ci blocks", () => {
|
||||
commit(repoDir, `docs(init): initialize project
|
||||
|
||||
---ci---
|
||||
phase: 0
|
||||
milestone: v1.0
|
||||
status: specify
|
||||
---/ci---
|
||||
|
||||
Initial commit`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const commits = ctx.getRecentCommits(5);
|
||||
|
||||
expect(commits.length).toBeGreaterThanOrEqual(1);
|
||||
const ciCommit = commits.find((c) => c.ci !== null);
|
||||
expect(ciCommit).toBeDefined();
|
||||
expect(ciCommit!.ci!.phase).toBe(0);
|
||||
expect(ciCommit!.ci!.milestone).toBe("v1.0");
|
||||
});
|
||||
|
||||
it("returns commits without ci blocks as null ci", () => {
|
||||
commit(repoDir, "feat: some feature");
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const commits = ctx.getRecentCommits(5);
|
||||
|
||||
expect(commits.length).toBeGreaterThanOrEqual(1);
|
||||
expect(commits[0].ci).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconstructState", () => {
|
||||
it("reconstructs state from latest ci commit", () => {
|
||||
commit(repoDir, `docs(init): initialize project
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: execute
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const state = ctx.reconstructState();
|
||||
|
||||
expect(state.currentPhase).toBe(1);
|
||||
expect(state.currentMilestone).toBe("v1.0");
|
||||
expect(state.currentStage).toBe("execute");
|
||||
});
|
||||
|
||||
it("returns default state without ci commits", () => {
|
||||
commit(repoDir, "feat: some regular feature");
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const state = ctx.reconstructState();
|
||||
|
||||
expect(state.currentPhase).toBe(0);
|
||||
expect(state.currentStage).toBe("specify");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDecisionsFromCommits", () => {
|
||||
it("extracts decisions from parsed commits", () => {
|
||||
commit(repoDir, `feat(P01): add auth
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: execute
|
||||
decisions:
|
||||
- id: D-001
|
||||
decision: Use bcrypt
|
||||
rationale: Industry standard
|
||||
confidence: 0.90
|
||||
alternatives: [argon2]
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const commits = ctx.getRecentCommits(5);
|
||||
const decisions = ctx.getDecisionsFromCommits(commits);
|
||||
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0].id).toBe("D-001");
|
||||
expect(decisions[0].decision).toBe("Use bcrypt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLessons", () => {
|
||||
it("extracts lessons from commits", () => {
|
||||
commit(repoDir, `docs(P01): complete phase
|
||||
|
||||
---ci---
|
||||
phase: 1
|
||||
milestone: v1.0
|
||||
status: complete
|
||||
lessons:
|
||||
- Never use sync bcrypt
|
||||
- Always check JWT expiry first
|
||||
---/ci---`);
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const commits = ctx.getRecentCommits(10);
|
||||
const lessons: string[] = [];
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.lessons) lessons.push(...commit.ci.lessons);
|
||||
}
|
||||
|
||||
expect(lessons).toContain("Never use sync bcrypt");
|
||||
expect(lessons).toContain("Always check JWT expiry first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBranches", () => {
|
||||
it("lists branches with type info", () => {
|
||||
commit(repoDir, "initial");
|
||||
execSync("git checkout -b phase/01-auth", { cwd: repoDir, stdio: "pipe" });
|
||||
commit(repoDir, "feat: auth work");
|
||||
execSync("git checkout -b milestone/v1.0-mvp", { cwd: repoDir, stdio: "pipe" });
|
||||
commit(repoDir, "feat: milestone work");
|
||||
|
||||
const ctx = new GitContext(repoDir);
|
||||
const branches = ctx.getBranches();
|
||||
|
||||
const phaseBranches = branches.filter((b) => b.type === "phase");
|
||||
const milestoneBranches = branches.filter((b) => b.type === "milestone");
|
||||
|
||||
expect(phaseBranches.length).toBeGreaterThanOrEqual(1);
|
||||
expect(milestoneBranches.length).toBeGreaterThanOrEqual(1);
|
||||
expect(phaseBranches[0].phaseNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
ParsedCiCommit,
|
||||
CiMetadata,
|
||||
CommitDecision,
|
||||
} from "../types/commit-meta.js";
|
||||
import { parseCommitMessage } from "./commit-parser.js";
|
||||
import { PipelineStage } from "../types/pipeline.js";
|
||||
|
||||
export interface ProjectState {
|
||||
currentPhase: number;
|
||||
currentMilestone: string;
|
||||
currentStage: PipelineStage;
|
||||
phasesCompleted: number[];
|
||||
phaseBranches: BranchInfo[];
|
||||
milestoneBranches: string[];
|
||||
lastCommit: ParsedCiCommit | null;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string;
|
||||
type: "phase" | "milestone" | "hotfix" | "other";
|
||||
phaseNumber?: number;
|
||||
milestone?: string;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
export class GitContext {
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
}
|
||||
|
||||
private git(args: string): string {
|
||||
try {
|
||||
return execSync(`git ${args}`, {
|
||||
cwd: this.projectPath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private gitLines(args: string): string[] {
|
||||
const result = this.git(args);
|
||||
return result ? result.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
isGitRepo(): boolean {
|
||||
return this.git("rev-parse --is-inside-work-tree") === "true";
|
||||
}
|
||||
|
||||
getCurrentBranch(): string {
|
||||
return this.git("rev-parse --abbrev-ref HEAD");
|
||||
}
|
||||
|
||||
getRecentCommits(count: number = 20): ParsedCiCommit[] {
|
||||
const format = "%H%x00%s%x00%B%x01";
|
||||
const raw = this.git(`log --max-count=${count} --format="${format}"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const commits: ParsedCiCommit[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.split("\x00");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const hash = parts[0].trim();
|
||||
const subject = parts[1].trim();
|
||||
const body = parts[2].trim();
|
||||
|
||||
const fullMessage = body || subject;
|
||||
commits.push(parseCommitMessage(hash, fullMessage));
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
getLatestCiCommit(): ParsedCiCommit | null {
|
||||
const commits = this.getRecentCommits(1);
|
||||
return commits.length > 0 ? commits[0] : null;
|
||||
}
|
||||
|
||||
getBranches(): BranchInfo[] {
|
||||
const branches = this.gitLines("branch -a --format='%(refname:short)'");
|
||||
const mergedBranches = new Set(this.gitLines("branch --merged --format='%(refname:short)'"));
|
||||
|
||||
return branches.map((name) => {
|
||||
const cleanName = name.replace(/^remotes\/origin\//, "");
|
||||
const info: BranchInfo = {
|
||||
name: cleanName,
|
||||
type: "other",
|
||||
merged: mergedBranches.has(cleanName),
|
||||
};
|
||||
|
||||
const phaseMatch = cleanName.match(/^phase\/(\d+)-(.+)/);
|
||||
if (phaseMatch) {
|
||||
info.type = "phase";
|
||||
info.phaseNumber = parseInt(phaseMatch[1], 10);
|
||||
return info;
|
||||
}
|
||||
|
||||
const milestoneMatch = cleanName.match(/^milestone\/(.+)/);
|
||||
if (milestoneMatch) {
|
||||
info.type = "milestone";
|
||||
info.milestone = milestoneMatch[1];
|
||||
return info;
|
||||
}
|
||||
|
||||
if (cleanName.startsWith("hotfix/")) {
|
||||
info.type = "hotfix";
|
||||
}
|
||||
|
||||
return info;
|
||||
});
|
||||
}
|
||||
|
||||
getPhaseBranches(): BranchInfo[] {
|
||||
return this.getBranches().filter((b) => b.type === "phase");
|
||||
}
|
||||
|
||||
getMilestoneBranches(): BranchInfo[] {
|
||||
return this.getBranches().filter((b) => b.type === "milestone");
|
||||
}
|
||||
|
||||
reconstructState(): ProjectState {
|
||||
const latestCommit = this.getLatestCiCommit();
|
||||
const branches = this.getBranches();
|
||||
const phaseBranches = branches.filter((b) => b.type === "phase");
|
||||
const milestoneBranches = branches.filter((b) => b.type === "milestone");
|
||||
|
||||
const phasesCompleted = phaseBranches
|
||||
.filter((b) => b.merged)
|
||||
.map((b) => b.phaseNumber!)
|
||||
.filter(Boolean);
|
||||
|
||||
let currentPhase = 0;
|
||||
let currentMilestone = "";
|
||||
let currentStage: PipelineStage = "specify";
|
||||
|
||||
if (latestCommit?.ci) {
|
||||
currentPhase = latestCommit.ci.phase;
|
||||
currentMilestone = latestCommit.ci.milestone;
|
||||
currentStage = latestCommit.ci.status;
|
||||
}
|
||||
|
||||
if (!currentMilestone && milestoneBranches.length > 0) {
|
||||
const activeMilestone = milestoneBranches.find((b) => !b.merged);
|
||||
if (activeMilestone) currentMilestone = activeMilestone.milestone || "";
|
||||
}
|
||||
|
||||
return {
|
||||
currentPhase,
|
||||
currentMilestone,
|
||||
currentStage,
|
||||
phasesCompleted,
|
||||
phaseBranches,
|
||||
milestoneBranches: milestoneBranches.map((b) => b.name),
|
||||
lastCommit: latestCommit,
|
||||
};
|
||||
}
|
||||
|
||||
getDecisions(phase?: number): CommitDecision[] {
|
||||
const grepArg = phase !== undefined ? `--grep="phase: ${phase}"` : '--grep="decisions:"';
|
||||
const raw = this.git(`log --all ${grepArg} --format="%B%x01"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const decisions: CommitDecision[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const commits = this.getRecentCommits(50);
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.decisions) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
decisions.push(...commit.ci.decisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return decisions;
|
||||
}
|
||||
|
||||
getDecisionsFromCommits(commits: ParsedCiCommit[], phase?: number): CommitDecision[] {
|
||||
const decisions: CommitDecision[] = [];
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.decisions) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
decisions.push(...commit.ci.decisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
return decisions;
|
||||
}
|
||||
|
||||
getLessons(phase?: number): string[] {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const lessons: string[] = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.lessons) {
|
||||
if (phase === undefined || commit.ci.phase === phase) {
|
||||
lessons.push(...commit.ci.lessons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lessons;
|
||||
}
|
||||
|
||||
getCompounds(category?: string): Array<{
|
||||
category: string;
|
||||
problem: string;
|
||||
solution: string;
|
||||
phase: number;
|
||||
}> {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const compounds: Array<{ category: string; problem: string; solution: string; phase: number }> = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.compound) {
|
||||
if (!category || commit.ci.compound.category === category) {
|
||||
compounds.push({
|
||||
...commit.ci.compound,
|
||||
phase: commit.ci.phase,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compounds;
|
||||
}
|
||||
|
||||
getEscalations(): Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
resolution: string;
|
||||
phase: number;
|
||||
}> {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const escalations: Array<{ id: string; type: string; description: string; resolution: string; phase: number }> = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.escalations) {
|
||||
for (const esc of commit.ci.escalations) {
|
||||
escalations.push({ ...esc, phase: commit.ci.phase });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return escalations;
|
||||
}
|
||||
|
||||
getRequirementsCoverage(): { covered: string[]; partial: string[] } {
|
||||
const commits = this.getRecentCommits(100);
|
||||
const covered = new Set<string>();
|
||||
const partial = new Set<string>();
|
||||
|
||||
for (const commit of commits) {
|
||||
if (commit.ci?.requirements) {
|
||||
for (const req of commit.ci.requirements.covered) covered.add(req);
|
||||
for (const req of commit.ci.requirements.partial) partial.add(req);
|
||||
}
|
||||
}
|
||||
|
||||
for (const req of covered) {
|
||||
partial.delete(req);
|
||||
}
|
||||
|
||||
return {
|
||||
covered: [...covered].sort(),
|
||||
partial: [...partial].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
getCommitsForPhase(phase: number): ParsedCiCommit[] {
|
||||
const commits = this.getRecentCommits(200);
|
||||
return commits.filter(
|
||||
(c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase
|
||||
);
|
||||
}
|
||||
|
||||
getCommitsForBranch(branch: string): ParsedCiCommit[] {
|
||||
const format = "%H%x00%s%x00%B%x01";
|
||||
const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`);
|
||||
|
||||
if (!raw) return [];
|
||||
|
||||
const commits: ParsedCiCommit[] = [];
|
||||
const entries = raw.split("\x01").filter(Boolean);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.split("\x00");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const hash = parts[0].trim();
|
||||
const subject = parts[1].trim();
|
||||
const body = parts[2].trim();
|
||||
const fullMessage = body || subject;
|
||||
|
||||
commits.push(parseCommitMessage(hash, fullMessage));
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
export { initCI, loadConfig, saveConfig, isCIInitialized, getCIConfigPath, getCIDir, ensureCIDir } from "./config.js";
|
||||
export { DecisionEngine } from "./decision-engine.js";
|
||||
export { EscalationProtocol } from "./escalation.js";
|
||||
export { ClarifyPhase, saveSpecification, loadSpecification } from "./clarify.js";
|
||||
export { ArtifactManager } from "./artifacts.js";
|
||||
export { ClarifyPhase } from "./clarify.js";
|
||||
export { CiFiles } from "./ci-files.js";
|
||||
export { ErrorRecovery } from "./error-recovery.js";
|
||||
export { logDecision, logEscalation, readAudit, getAuditSummary } from "./audit.js";
|
||||
export { GitContext } from "./git-context.js";
|
||||
export { GitBranch } from "./git-branch.js";
|
||||
export { CommitBuilder } from "./commit-builder.js";
|
||||
export { extractCiBlock, parseCiBlock, parseCommitMessage } from "./commit-parser.js";
|
||||
export type { CIConfig } from "../types/config.js";
|
||||
export { DEFAULT_CI_CONFIG } from "../types/config.js";
|
||||
Reference in New Issue
Block a user