Compare commits

...

7 Commits

Author SHA1 Message Date
Jon Chery e8c6c5c917 feat(P05): ship infrastructure — Gitea API client, release notes, npm publishConfig, ciagent projects cmd, --project flag
---ci---
phase: 5
milestone: v1.0
plan: 05
task: SHIP-01-04 MULTI-01 MULTI-02
status: execute
---/ci---
2026-05-29 18:15:58 +00:00
Jon Chery 4de1f65c10 feat(P04): pipeline stage delegation — EXECUTE=3 agents, TEST=tester, VERIFY=verifier, COMPLETE=doc-writer+ship
---ci---
phase: 4
milestone: v1.0
plan: 04
task: PIPE-01-04
status: execute
---/ci---
2026-05-29 18:13:39 +00:00
Jon Chery 6902c37ced fix(P03): improve planner task descriptions — avoid redundant REQ-ID in task lines
---ci---
phase: 3
milestone: v0.6.0
plan: 03
task: 03-03
status: execute
---/ci---
2026-05-29 18:11:49 +00:00
Jon Chery bbabd2dc0a feat(P03): core agent flesh — VerifierAgent, ResearcherAgent, TesterAgent intrinsic logic 2026-05-29 18:08:38 +00:00
Jon Chery 99df4fe4e2 feat(P02): orchestrator enrichment — GitAgentContext, multi-phase, error recovery, timer cleanup, TEST stage
---ci---
phase: 2
milestone: v0.6
status: execute
decisions:
  - id: D-001
    decision: Pass GitAgentContext to agents instead of bare AgentContext
    rationale: Agents need git-native context (gitContext, gitBranch, ciFiles, milestone) to operate autonomously
    confidence: 0.95
  - id: D-002
    decision: Implement multi-phase iteration with totalPhases derived from ROADMAP.md
    rationale: Milestones can span multiple phases; orchestrator must advance through all of them
    confidence: 0.90
  - id: D-003
    decision: Add executeStageWithRecovery with retry + plan revision + escalation
    rationale: Robust error recovery requires multiple fallback levels before giving up
    confidence: 0.85
  - id: D-004
    decision: Add timer-to-escalation mapping in EscalationProtocol for proper cleanup
    rationale: resolveEscalation must clearTimeout for the corresponding timer to prevent resource leaks
    confidence: 0.90
  - id: D-005
    decision: Add dispose() to EscalationProtocol called in orchestrator finally block
    rationale: Ensures all timers are cleaned up on orchestrator exit regardless of outcome
    confidence: 0.95
  - id: D-006
    decision: Add mechanical TEST stage fallback running npm test via execSync
    rationale: When no backend is available, tests can still be run mechanically
    confidence: 0.85
---/ci---
2026-05-29 18:05:36 +00:00
Jon Chery 8527df24b3 fix(P01): rename ci-files.test.ts → ciagent-files.test.ts
---ci---
project: ci
phase: 1
milestone: v0.7
status: execute
requirements:
  covered: [RENAME-01, RENAME-02, RENAME-03, RENAME-04, RENAME-05, RENAME-06, RENAME-07, RENAME-08, RENAME-09, RENAME-10, RENAME-11, RENAME-12]
---/ci---

All 12 RENAME requirements covered. 31 test suites, 370 tests passing.
2026-05-29 18:03:31 +00:00
Jon Chery 4a58aa1657 refactor(rebrand): rename & rebrand CI → CIAgent across all source and test files
- Type renames: CIConfig → CIAgentConfig, DEFAULT_CI_CONFIG → DEFAULT_CIAGENT_CONFIG
- Type renames: CiMetadata → CIAgentMetadata, ParsedCiCommit → ParsedCIAgentCommit
- Function renames: initCI → initCIAgent, isCIInitialized → isCIAgentInitialized
- Function renames: extractCiBlock → extractCIAgentBlock, parseCiBlock → parseCIAgentBlock
- Class renames: CiFiles → CIAgentFiles
- Import paths: ci-files.js → ciagent-files.js
- Directory paths: .ci/ → .ciagent/ across all source and test files
- Check names: ".ci directory exists" → ".ciagent directory exists"
- Check names: "CI config valid" → "CIAgent config valid"
- Temp dir names: ci-*-test- → ciagent-*-test-
- CLI examples: "ci init" → "ciagent init"
- Fix deepMerge infinite recursion bug in config.ts
- ---ci---/---/ci--- block markers preserved unchanged
- All 31 test suites, 370 tests passing

---ci---
phase: 1
milestone: v0.5
plan: 07
task: 07-01-01
status: execute
---/ci---
2026-05-29 18:01:13 +00:00
58 changed files with 2373 additions and 555 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "@continuous-intelligence/ci", "name": "@continuous-intelligence/ciagent",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@continuous-intelligence/ci", "name": "@continuous-intelligence/ciagent",
"version": "0.4.0", "version": "0.5.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -14,7 +14,7 @@
"zod": "^3.23.0" "zod": "^3.23.0"
}, },
"bin": { "bin": {
"ci": "dist/cli/index.js" "ciagent": "dist/cli/index.js"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.0", "@types/jest": "^29.5.0",
+16 -4
View File
@@ -1,11 +1,11 @@
{ {
"name": "@continuous-intelligence/ci", "name": "@continuous-intelligence/ciagent",
"version": "0.5.0", "version": "0.5.0",
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence", "description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {
"ci": "./dist/cli/index.js" "ciagent": "./dist/cli/index.js"
}, },
"files": [ "files": [
"dist/", "dist/",
@@ -19,14 +19,26 @@
"dev": "ts-node src/cli.ts", "dev": "ts-node src/cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "jest", "test": "jest",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build && npm test",
"install-opencode": "node scripts/postinstall.js" "install-opencode": "node scripts/postinstall.js"
}, },
"keywords": ["ci", "autonomous", "ai", "software-engineering", "agent", "multi-project"], "keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent.git"
},
"homepage": "https://git.cloudinit.dev/continuous-intelligence/ciagent",
"bugs": {
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent/issues"
},
"dependencies": { "dependencies": {
"commander": "^12.1.0", "commander": "^12.1.0",
"zod": "^3.23.0" "zod": "^3.23.0"
+163 -7
View File
@@ -1,4 +1,21 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
export interface ExecutorResult {
success: boolean;
tasksExecuted: number;
tasksCommitted: number;
testsPassing: boolean;
mustHavesChecked: { name: string; passed: boolean }[];
error?: string;
}
interface MustHaveItem {
name: string;
passed: boolean;
}
export class ExecutorAgent extends BaseAgent { export class ExecutorAgent extends BaseAgent {
readonly name = "executor"; readonly name = "executor";
@@ -8,21 +25,160 @@ export class ExecutorAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Executing tasks..."); this.log("Executing tasks...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const taskPrompt = await this.buildBackendTaskPrompt(context);
context, const backendResult = await this.executeViaBackend(context, taskPrompt);
`Execute implementation for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
); const verification = await this.verifyExecution(context);
return { ...result, duration_ms: Date.now() - start };
return {
...backendResult,
output: `${backendResult.output}\nVerification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves checked=${verification.mustHavesChecked.length}`,
duration_ms: Date.now() - start,
};
} }
return { return {
success: false, success: false,
output: "Execution requires an intelligence backend. Configure one with: ci init --backend", output: "Executor requires intelligence backend for code implementation",
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available", error: "Executor requires intelligence backend for code implementation",
}; };
} }
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
"",
"## Specification",
context.specification || "No specification provided",
];
const planContent = this.readPlanFile(context);
if (planContent) {
parts.push("", "## Plan", planContent);
}
const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md");
const archPath = path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) {
try {
const roadmap = fs.readFileSync(roadmapPath, "utf-8");
parts.push("", "## Roadmap Context", roadmap.slice(0, 2000));
} catch {}
}
if (fs.existsSync(archPath)) {
try {
const arch = fs.readFileSync(archPath, "utf-8");
parts.push("", "## Architecture Boundaries", arch.slice(0, 2000));
} catch {}
}
parts.push("", "## Execution Rules");
parts.push("- Execute one task at a time");
parts.push("- Commit after each task with ---ci--- block");
parts.push("- Never pause for checkpoints");
parts.push("- Create automated verification for traditionally human tasks");
return parts.join("\n");
}
private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
try {
if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8");
}
} catch {}
return null;
}
private async verifyExecution(context: AgentContext): Promise<ExecutorResult> {
const mustHavesChecked: MustHaveItem[] = this.checkMustHaves(context);
let testsPassing = false;
let tasksExecuted = 0;
let tasksCommitted = 0;
try {
const logOutput = execSync("git log --max-count=20 --oneline", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const commitLines = logOutput.split("\n").filter(Boolean);
tasksCommitted = commitLines.filter((l) => /feat|fix|test/.test(l)).length;
tasksExecuted = tasksCommitted;
} catch {}
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
testsPassing = true;
} catch {
testsPassing = false;
}
return {
success: mustHavesChecked.every((m) => m.passed) && testsPassing,
tasksExecuted,
tasksCommitted,
testsPassing,
mustHavesChecked,
};
}
private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = [];
try {
if (!fs.existsSync(planPath)) return results;
const planContent = fs.readFileSync(planPath, "utf-8");
const mustHaveRegex = /-\s*\[x\]\s*(.+)/g;
let match;
while ((match = mustHaveRegex.exec(planContent)) !== null) {
const name = match[1].trim();
const passed = this.verifyMustHaveItem(name, context);
results.push({ name, passed });
}
} catch {}
return results;
}
private verifyMustHaveItem(item: string, context: AgentContext): boolean {
const fileMatch = item.match(/(?:exists|created?|present).*?[\s:]+([^\s]+\.(ts|js|json|md))/i);
if (fileMatch) {
const filePath = path.join(context.project_path, fileMatch[1]);
return fs.existsSync(filePath);
}
const testMatch = item.match(/(?:test|tests?)\s+(?:pass|passing)/i);
if (testMatch) {
try {
execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
return true;
} catch {
return false;
}
}
return true;
}
} }
+7 -4
View File
@@ -1,9 +1,9 @@
export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js"; export { BaseAgent, AgentContext, AgentResult, backendResultToAgentResult } from "./base.js";
export { OrchestratorAgent } from "./orchestrator.js"; export { OrchestratorAgent } from "./orchestrator.js";
export { PlannerAgent } from "./planner.js"; export { PlannerAgent, PlannerResult } from "./planner.js";
export { ExecutorAgent } from "./executor.js"; export { ExecutorAgent, ExecutorResult } from "./executor.js";
export { VerifierAgent } from "./verifier.js"; export { VerifierAgent, VerifierResult } from "./verifier.js";
export { ResearcherAgent } from "./researcher.js"; export { ResearcherAgent, ResearcherResult } from "./researcher.js";
export { ChallengerAgent } from "./challenger.js"; export { ChallengerAgent } from "./challenger.js";
export { SecurityAuditorAgent } from "./security-auditor.js"; export { SecurityAuditorAgent } from "./security-auditor.js";
export { DebuggerAgent } from "./debugger.js"; export { DebuggerAgent } from "./debugger.js";
@@ -17,6 +17,7 @@ export { ProjectResearcherAgent } from "./project-researcher.js";
export { ResearchSynthesizerAgent } from "./research-synthesizer.js"; export { ResearchSynthesizerAgent } from "./research-synthesizer.js";
export { SolutionWriterAgent } from "./solution-writer.js"; export { SolutionWriterAgent } from "./solution-writer.js";
export { PhaseResearcherAgent } from "./phase-researcher.js"; export { PhaseResearcherAgent } from "./phase-researcher.js";
export { TesterAgent, TesterResult } from "./tester.js";
import { AgentName } from "../types/config.js"; import { AgentName } from "../types/config.js";
import { BaseAgent as BaseAgentType } from "./base.js"; import { BaseAgent as BaseAgentType } from "./base.js";
@@ -38,6 +39,7 @@ import { ProjectResearcherAgent } from "./project-researcher.js";
import { ResearchSynthesizerAgent } from "./research-synthesizer.js"; import { ResearchSynthesizerAgent } from "./research-synthesizer.js";
import { SolutionWriterAgent } from "./solution-writer.js"; import { SolutionWriterAgent } from "./solution-writer.js";
import { PhaseResearcherAgent } from "./phase-researcher.js"; import { PhaseResearcherAgent } from "./phase-researcher.js";
import { TesterAgent } from "./tester.js";
const agentRegistry: Record<AgentName, () => BaseAgentType> = { const agentRegistry: Record<AgentName, () => BaseAgentType> = {
orchestrator: () => new OrchestratorAgent(), orchestrator: () => new OrchestratorAgent(),
@@ -58,6 +60,7 @@ const agentRegistry: Record<AgentName, () => BaseAgentType> = {
"project-researcher": () => new ProjectResearcherAgent(), "project-researcher": () => new ProjectResearcherAgent(),
"research-synthesizer": () => new ResearchSynthesizerAgent(), "research-synthesizer": () => new ResearchSynthesizerAgent(),
"solution-writer": () => new SolutionWriterAgent(), "solution-writer": () => new SolutionWriterAgent(),
tester: () => new TesterAgent(),
}; };
export function getAgent(name: AgentName): BaseAgentType { export function getAgent(name: AgentName): BaseAgentType {
+313 -34
View File
@@ -4,9 +4,9 @@ import { ClarifyPhase } from "../core/clarify.js";
import { EscalationProtocol, EscalationInput } from "../core/escalation.js"; import { EscalationProtocol, EscalationInput } from "../core/escalation.js";
import { GitContext, ProjectState } from "../core/git-context.js"; import { GitContext, ProjectState } from "../core/git-context.js";
import { GitBranch } from "../core/git-branch.js"; import { GitBranch } from "../core/git-branch.js";
import { CiFiles } from "../core/ci-files.js"; import { CIAgentFiles } from "../core/ciagent-files.js";
import { CommitBuilder } from "../core/commit-builder.js"; import { CommitBuilder } from "../core/commit-builder.js";
import { CIConfig, AgentName } from "../types/config.js"; import { CIAgentConfig, AgentName } from "../types/config.js";
import { import {
PipelineState, PipelineState,
PipelineStage, PipelineStage,
@@ -16,40 +16,44 @@ import {
STAGE_ORDER, STAGE_ORDER,
} from "../types/pipeline.js"; } from "../types/pipeline.js";
import { Specification, parseSpecification } from "../types/specification.js"; import { Specification, parseSpecification } from "../types/specification.js";
import { loadConfig, saveConfig, isCIInitialized, initCI } from "../core/config.js"; import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../core/config.js";
import { getAgent } from "./index.js"; import { getAgent } from "./index.js";
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js"; import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
import { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext { export interface GitAgentContext extends AgentContext {
gitContext: GitContext; gitContext: GitContext;
gitBranch: GitBranch; gitBranch: GitBranch;
ciFiles: CiFiles; ciFiles: CIAgentFiles;
milestone: string; milestone: string;
} }
export class OrchestratorAgent extends BaseAgent { export class OrchestratorAgent extends BaseAgent {
readonly name: AgentName = "orchestrator"; readonly name: AgentName = "orchestrator";
readonly description = "Top-level autonomous controller that coordinates the full CI pipeline"; readonly description = "Top-level autonomous controller that coordinates the full CIAgent pipeline";
readonly workflow = "run"; readonly workflow = "run";
private config: CIConfig; private config: CIAgentConfig;
private pipelineState: PipelineState | null = null; private pipelineState: PipelineState | null = null;
private decisionEngine: DecisionEngine | null = null; private decisionEngine: DecisionEngine | null = null;
private escalationProtocol: EscalationProtocol | null = null; private escalationProtocol: EscalationProtocol | null = null;
private gitContext: GitContext | null = null; private gitContext: GitContext | null = null;
private gitBranch: GitBranch | null = null; private gitBranch: GitBranch | null = null;
private ciFiles: CiFiles | null = null; private ciFiles: CIAgentFiles | null = null;
private currentMilestone: string; private currentMilestone: string;
private phaseResults: PhaseResult[] = []; private phaseResults: PhaseResult[] = [];
private totalPhases: number = 1;
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = { private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
research: "researcher", research: ["researcher"],
plan: "planner", plan: ["planner"],
execute: "executor", execute: ["executor", "code-reviewer", "security-auditor"],
verify: "verifier", test: ["tester"],
verify: ["verifier"],
complete: ["doc-writer"],
}; };
constructor(config?: CIConfig) { constructor(config?: CIAgentConfig) {
super(); super();
this.config = config || loadConfig(process.cwd()); this.config = config || loadConfig(process.cwd());
this.currentMilestone = "v1.0"; this.currentMilestone = "v1.0";
@@ -57,14 +61,14 @@ export class OrchestratorAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const startTime = Date.now(); const startTime = Date.now();
this.log("Starting CI Orchestrator pipeline (git-native)"); this.log("Starting CIAgent Orchestrator pipeline (git-native)");
try { try {
this.config = loadConfig(context.project_path); this.config = loadConfig(context.project_path);
this.gitContext = new GitContext(context.project_path); this.gitContext = new GitContext(context.project_path);
this.gitBranch = new GitBranch(context.project_path); this.gitBranch = new GitBranch(context.project_path);
this.ciFiles = new CiFiles(context.project_path); this.ciFiles = new CIAgentFiles(context.project_path);
this.ciFiles.ensureCIDir(); this.ciFiles.ensureCIDir();
const projectState = this.gitContext.reconstructState(); const projectState = this.gitContext.reconstructState();
@@ -78,15 +82,24 @@ export class OrchestratorAgent extends BaseAgent {
this.pipelineState.current_stage = projectState.currentStage; this.pipelineState.current_stage = projectState.currentStage;
} }
this.totalPhases = this.deriveTotalPhases();
this.log(`Total phases in milestone: ${this.totalPhases}`);
this.decisionEngine = new DecisionEngine(this.config, context.project_path, this.currentMilestone); this.decisionEngine = new DecisionEngine(this.config, context.project_path, this.currentMilestone);
this.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone); this.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone);
while (this.pipelineState.current_phase <= this.totalPhases) {
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
for (const stage of STAGE_ORDER) { for (const stage of STAGE_ORDER) {
this.log(`Entering stage: ${stage}`); this.log(`Entering stage: ${stage}`);
this.pipelineState.current_stage = stage; this.pipelineState.current_stage = stage;
this.pipelineState.last_updated = new Date().toISOString(); this.pipelineState.last_updated = new Date().toISOString();
const result = await this.executeStage(stage, context); const result = await this.executeStageWithRecovery(stage, context);
this.phaseResults.push(result);
this.recordPhaseResult(result);
if (!result.success && stage !== "complete") { if (!result.success && stage !== "complete") {
this.pipelineState.errors.push({ this.pipelineState.errors.push({
@@ -121,6 +134,16 @@ export class OrchestratorAgent extends BaseAgent {
} }
} }
if (this.pipelineState.current_phase < this.totalPhases) {
this.performPhaseBoundaryCheckpoint(context);
this.pipelineState.current_phase++;
this.pipelineState.current_stage = "specify";
this.log(`Advancing to phase ${this.pipelineState.current_phase}`);
} else {
break;
}
}
const totalDuration = Date.now() - startTime; const totalDuration = Date.now() - startTime;
const completionReport = this.generateCompletionReport(); const completionReport = this.generateCompletionReport();
@@ -151,36 +174,240 @@ export class OrchestratorAgent extends BaseAgent {
duration_ms: Date.now() - startTime, duration_ms: Date.now() - startTime,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}; };
} finally {
this.escalationProtocol?.dispose();
} }
} }
private buildGitAgentContext(context: AgentContext): GitAgentContext {
return {
...context,
gitContext: this.gitContext!,
gitBranch: this.gitBranch!,
ciFiles: this.ciFiles!,
milestone: this.currentMilestone,
};
}
private recordPhaseResult(result: PhaseResult): void {
for (const artifact of result.artifacts_created) {
this.log(`Artifact created: ${artifact}`);
}
if (result.decisions_made > 0 && this.decisionEngine) {
this.decisionEngine.makeHighConfidenceDecision(
`Agent reported ${result.decisions_made} decision(s) during ${result.stage}`,
`Decisions recorded from ${result.stage} stage execution`,
"general",
[]
);
}
if (result.escalations_raised > 0 && this.escalationProtocol) {
this.escalationProtocol.escalate({
type: "low_confidence_decision",
phase: String(this.pipelineState!.current_phase),
description: `Agent reported ${result.escalations_raised} escalation(s) during ${result.stage}`,
context: `Stage ${result.stage} raised escalations during execution`,
options: [
{ id: "proceed", label: "Proceed", description: "Continue pipeline execution", recommended: true },
{ id: "halt", label: "Halt", description: "Stop pipeline and await manual review", recommended: false },
],
default_option_id: "proceed",
});
}
}
private deriveTotalPhases(): number {
if (!this.ciFiles) return 1;
const roadmap = this.ciFiles.readRoadmapMd();
if (!roadmap || roadmap.phases.length === 0) return 1;
return roadmap.phases.length;
}
private performPhaseBoundaryCheckpoint(context: AgentContext): void {
this.log(`Phase boundary checkpoint for phase ${this.pipelineState!.current_phase}`);
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
try {
const message = `chore(P${String(this.pipelineState!.current_phase).padStart(2, "0")}): phase boundary checkpoint\n\n---ci---\nphase: ${this.pipelineState!.current_phase}\nmilestone: ${this.currentMilestone}\nstatus: complete\n---/ci---`;
execSync(`git add -A && git commit -m "${message.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
} catch (err) {
this.warn(`Phase boundary commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (this.ciFiles) {
this.ciFiles.updatePhaseStatus(this.pipelineState!.current_phase, "complete");
const reqs = this.ciFiles.readRequirementsMd();
if (reqs) {
for (const t of reqs.traceability) {
if (t.phase === this.pipelineState!.current_phase && t.status === "in_progress") {
this.ciFiles.updateRequirementStatus(t.requirement, "complete");
}
}
}
}
if (this.gitContext) {
const verifiedState = this.gitContext.reconstructState();
this.log(`Verified state: phase=${verifiedState.currentPhase}, milestone=${verifiedState.currentMilestone}, stage=${verifiedState.currentStage}`);
}
}
private async executeStageWithRecovery(
stage: PipelineStage,
context: AgentContext
): Promise<PhaseResult> {
try {
const result = await this.executeStage(stage, context);
if (result.success) return result;
} catch (err) {
this.warn(`First attempt failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
}
this.log(`Retrying stage ${stage}...`);
try {
const result = await this.executeStage(stage, context);
if (result.success) return result;
} catch (err) {
this.warn(`Retry failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
}
if (context.backend) {
this.log(`Attempting plan revision for failed stage ${stage}...`);
try {
const planner = getAgent("planner");
const gitContext = this.buildGitAgentContext(context);
const planResult = await planner.execute({
...gitContext,
specification: `Plan revision needed: stage ${stage} failed twice. Original error context: phase ${this.pipelineState!.current_phase}`,
});
if (planResult.success) {
this.log(`Plan revision succeeded, retrying ${stage} with revised plan...`);
try {
const result = await this.executeStage(stage, context);
if (result.success) return result;
} catch (err) {
this.warn(`Post-revision retry failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
}
}
} catch (err) {
this.warn(`Plan revision failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (this.escalationProtocol) {
this.escalationProtocol.escalate({
type: "verification_failure",
phase: String(this.pipelineState!.current_phase),
description: `Stage ${stage} failed after retry and plan revision attempts`,
context: `All recovery attempts exhausted for stage ${stage} in phase ${this.pipelineState!.current_phase}`,
options: [
{ id: "skip", label: "Skip stage", description: "Continue pipeline skipping this stage", recommended: true },
{ id: "abort", label: "Abort pipeline", description: "Stop the entire pipeline", recommended: false },
],
default_option_id: "skip",
});
}
return {
phase: this.pipelineState!.current_phase,
stage,
success: false,
artifacts_created: [],
decisions_made: 0,
escalations_raised: 1,
duration_ms: 0,
error: `Stage ${stage} failed after recovery attempts`,
};
}
private async executeStage( private async executeStage(
stage: PipelineStage, stage: PipelineStage,
context: AgentContext context: AgentContext
): Promise<PhaseResult> { ): Promise<PhaseResult> {
const stageStart = Date.now(); const stageStart = Date.now();
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage]; const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
if (agentName && context.backend) { if (agentNames && agentNames.length > 0 && context.backend) {
this.log(`Delegating ${stage} to ${agentName} agent via backend...`); this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
try { try {
let primaryResult: AgentResult | null = null;
const allArtifacts: string[] = [];
let totalDecisions = 0;
let totalEscalations = 0;
let lastError: string | undefined;
for (let i = 0; i < agentNames.length; i++) {
const agentName = agentNames[i];
const agent = getAgent(agentName); const agent = getAgent(agentName);
const result = await agent.execute(context); const gitContext = this.buildGitAgentContext(context);
if (i === 0) {
const result = await agent.execute(gitContext);
primaryResult = result;
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Primary agent ${agentName} failed for ${stage}`);
return { return {
phase: this.pipelineState!.current_phase, phase: this.pipelineState!.current_phase,
stage, stage,
success: result.success, success: false,
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [], artifacts_created: allArtifacts,
decisions_made: result.decisions, decisions_made: totalDecisions,
escalations_raised: result.escalations, escalations_raised: totalEscalations,
duration_ms: Date.now() - stageStart, duration_ms: Date.now() - stageStart,
error: result.error, error: result.error || `Primary agent ${agentName} failed`,
};
}
} else {
try {
const reviewContext: AgentContext = {
...gitContext,
specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`,
};
const result = await agent.execute(reviewContext);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Review agent ${agentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Review agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
return {
phase: this.pipelineState!.current_phase,
stage,
success: primaryResult?.success ?? false,
artifacts_created: allArtifacts,
decisions_made: totalDecisions,
escalations_raised: totalEscalations,
duration_ms: Date.now() - stageStart,
error: lastError,
}; };
} catch (err) { } catch (err) {
if (err instanceof BackendUnavailableError) { if (err instanceof BackendUnavailableError) {
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`); this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
} else { } else {
this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`); this.warn(`Agents failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
} }
} }
} }
@@ -207,11 +434,10 @@ export class OrchestratorAgent extends BaseAgent {
}); });
this.log("Init commit prepared with specification in ---ci--- block"); this.log("Init commit prepared with specification in ---ci--- block");
artifactsCreated.push(".ci/config.json"); artifactsCreated.push(".ciagent/config.json");
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) { if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
try { try {
const { execSync } = await import("node:child_process");
this.ciFiles!.writeProjectMd({ this.ciFiles!.writeProjectMd({
name: spec.objective.slice(0, 30), name: spec.objective.slice(0, 30),
coreValue: spec.objective, coreValue: spec.objective,
@@ -296,10 +522,9 @@ export class OrchestratorAgent extends BaseAgent {
1, 1,
this.currentMilestone, this.currentMilestone,
"initial domain research", "initial domain research",
["Research completed. Key findings in .ci/ARCHITECTURE.md and .ci/PROJECT.md updates."] ["Research completed. Key findings in .ciagent/ARCHITECTURE.md and .ciagent/PROJECT.md updates."]
); );
try { try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, { execSync(`git add -A && git commit -m "${researchCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path, cwd: context.project_path,
stdio: "pipe", stdio: "pipe",
@@ -310,7 +535,7 @@ export class OrchestratorAgent extends BaseAgent {
} }
this.pipelineState!.research_completed = true; this.pipelineState!.research_completed = true;
artifactsCreated.push(".ci/ARCHITECTURE.md"); artifactsCreated.push(".ciagent/ARCHITECTURE.md");
break; break;
} }
@@ -342,6 +567,38 @@ export class OrchestratorAgent extends BaseAgent {
this.pipelineState!.execute_completed = true; this.pipelineState!.execute_completed = true;
break; break;
case "test": {
this.log("Running tests...");
if (!context.backend) {
this.log("No backend available — running mechanical test fallback via npm test");
try {
const testOutput = execSync("npm test", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
this.log("npm test passed");
this.pipelineState!.test_completed = true;
artifactsCreated.push("test-results");
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
this.warn(`npm test failed: ${errMsg}`);
return {
phase: this.pipelineState!.current_phase,
stage: "test",
success: false,
artifacts_created: artifactsCreated,
decisions_made: decisionsMade,
escalations_raised: escalationsRaised,
duration_ms: Date.now() - stageStart,
error: `Test stage failed: ${errMsg}`,
};
}
}
break;
}
case "verify": { case "verify": {
this.log("Running verification..."); this.log("Running verification...");
@@ -372,7 +629,6 @@ export class OrchestratorAgent extends BaseAgent {
requirements: { covered: [], partial: [] }, requirements: { covered: [], partial: [] },
}); });
try { try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, { execSync(`git add -A && git commit -m "${verifyCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path, cwd: context.project_path,
stdio: "pipe", stdio: "pipe",
@@ -398,7 +654,6 @@ export class OrchestratorAgent extends BaseAgent {
taskNames: [], taskNames: [],
}); });
try { try {
const { execSync } = await import("node:child_process");
execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, { execSync(`git add -A && git commit -m "${completionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path, cwd: context.project_path,
stdio: "pipe", stdio: "pipe",
@@ -408,6 +663,30 @@ export class OrchestratorAgent extends BaseAgent {
} }
} }
const versionTag = `${this.currentMilestone}-P${String(this.pipelineState!.current_phase).padStart(2, "0")}`;
try {
execSync(`git tag "${versionTag}"`, {
cwd: context.project_path,
stdio: "pipe",
});
this.log(`Created version tag: ${versionTag}`);
artifactsCreated.push(`tag:${versionTag}`);
} catch (err) {
this.warn(`Version tag creation failed: ${err instanceof Error ? err.message : String(err)}`);
}
if (this.config.git.auto_push && this.gitContext!.isGitRepo()) {
try {
execSync(`git push origin ${versionTag}`, {
cwd: context.project_path,
stdio: "pipe",
});
this.log(`Pushed version tag: ${versionTag}`);
} catch (err) {
this.warn(`Version tag push failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
break; break;
} }
} }
@@ -425,7 +704,7 @@ export class OrchestratorAgent extends BaseAgent {
private generateCompletionReport(): string { private generateCompletionReport(): string {
const lines: string[] = [ const lines: string[] = [
"# CI Completion Report", "# CIAgent Completion Report",
"", "",
`✓ Pipeline completed successfully (git-native)`, `✓ Pipeline completed successfully (git-native)`,
"", "",
+320 -6
View File
@@ -1,4 +1,27 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { CIAgentFiles, RequirementsMd, RoadmapMd, ArchitectureMd } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir } from "../utils/file.js";
import { execSync } from "node:child_process";
import * as path from "node:path";
export interface PlannerResult {
success: boolean;
planCount: number;
waves: { wave: number; plans: string[] }[];
decisions: number;
error?: string;
}
interface PlanEntry {
name: string;
wave: number;
requirements: string[];
dependsOn: string[];
tasks: string[];
mustHaves: string[];
}
export class PlannerAgent extends BaseAgent { export class PlannerAgent extends BaseAgent {
readonly name = "planner"; readonly name = "planner";
@@ -8,21 +31,312 @@ export class PlannerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Creating phase plan..."); this.log("Creating phase plan...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const taskPrompt = await this.buildBackendTaskPrompt(context);
context, const result = await this.executeViaBackend(context, taskPrompt);
`Create a phase plan for stage ${context.stage}, phase ${context.phase}. Specification: ${context.specification}`
);
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
return this.executeMechanical(context, start);
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const ciFiles = new CIAgentFiles(context.project_path);
const parts: string[] = [
`Create a phase plan for stage ${context.stage}, phase ${context.phase}.`,
"",
"## Project Context",
];
const roadmap = ciFiles.readRoadmapMd();
if (roadmap) {
const currentPhase = roadmap.phases.find((p) => p.number === context.phase);
if (currentPhase) {
parts.push("", "### Phase Goal", currentPhase.description);
parts.push("", "### Phase Requirements", currentPhase.requirements.join(", ") || "None specified");
parts.push("", "### Phase Dependencies", currentPhase.dependsOn.length > 0 ? currentPhase.dependsOn.map((d) => `Phase ${d}`).join(", ") : "None");
parts.push("", "### Success Criteria", ...currentPhase.successCriteria.map((sc) => `- ${sc}`));
}
}
const requirements = ciFiles.readRequirementsMd();
if (requirements) {
const phaseReqs = requirements.traceability.filter((t) => t.phase === context.phase);
if (phaseReqs.length > 0) {
parts.push("", "### Requirements for Phase", ...phaseReqs.map((t) => `- ${t.requirement} (${t.status})`));
}
}
const architecture = ciFiles.readArchitectureMd();
if (architecture) {
parts.push("", "### Architecture Boundaries", ...architecture.components.map((c) => `- ${c.name}: ${c.boundaries}`));
parts.push("", "### Build Order", ...architecture.buildOrder.map((bo) => `${bo}`));
}
parts.push("", "## Specification", context.specification || "No specification provided");
return parts.join("\n");
}
private executeMechanical(context: AgentContext, start: number): AgentResult {
const ciFiles = new CIAgentFiles(context.project_path);
ciFiles.ensureCIDir();
const requirements = ciFiles.readRequirementsMd();
const roadmap = ciFiles.readRoadmapMd();
const architecture = ciFiles.readArchitectureMd();
if (!requirements && !roadmap) {
return { return {
success: false, success: false,
output: "Planning requires an intelligence backend. Configure one with: ci init --backend", output: "Planning requires either .ciagent/REQUIREMENTS.md or .ciagent/ROADMAP.md. Initialize the project first.",
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available", error: "No requirements or roadmap found for mechanical planning",
}; };
} }
let gitLogSummary = "";
try {
gitLogSummary = execSync("git log --max-count=20 --oneline", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
gitLogSummary = "(no git history available)";
}
const phaseGoal = this.extractPhaseGoal(roadmap, context.phase);
const phaseRequirements = this.extractPhaseRequirements(requirements, context.phase);
const componentBoundaries = architecture ? architecture.components.map((c) => c.name) : [];
const plans = this.buildPlans(phaseRequirements, componentBoundaries, context.phase);
const planFileContent = this.formatPlanFile(context.phase, phaseGoal, plans);
const planFilePath = path.join(context.project_path, ".ciagent", "PLAN.md");
ensureDir(path.dirname(planFilePath));
writeFile(planFilePath, planFileContent);
const decisionCount = plans.length > 0 ? 1 : 0;
if (this.shouldCommit(context)) {
try {
const commitMessage = CommitBuilder.buildTaskCommit({
type: "docs",
phase: context.phase,
milestone: "v1.0",
plan: "01",
task: "01-01",
subject: `create ${plans.length} phase plans`,
status: "plan",
decisions: decisionCount > 0 ? [{
id: "D-001",
decision: `Decomposed phase ${context.phase} into ${plans.length} vertical-slice plans`,
rationale: "Requirements grouped by dependency analysis — independent requirements in wave 1, dependent in wave 2+",
confidence: 0.75,
alternatives: ["single monolithic plan", "per-requirement plans"],
}] : undefined,
});
execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
} catch {
this.warn("Plan commit failed");
}
}
const waves = this.groupPlansByWave(plans);
const plannerResult: PlannerResult = {
success: true,
planCount: plans.length,
waves,
decisions: decisionCount,
};
return {
success: true,
output: `Created ${plans.length} plan(s) across ${waves.length} wave(s) for phase ${context.phase}`,
artifacts_created: [".ciagent/PLAN.md"],
decisions: decisionCount,
escalations: 0,
duration_ms: Date.now() - start,
};
}
private extractPhaseGoal(roadmap: RoadmapMd | null, phase: number): string {
if (!roadmap) return "No roadmap available";
const phaseEntry = roadmap.phases.find((p) => p.number === phase);
if (phaseEntry) return `${phaseEntry.name}: ${phaseEntry.description}`;
return `Phase ${phase} (no roadmap entry)`;
}
private extractPhaseRequirements(requirements: RequirementsMd | null, phase: number): Array<{ id: string; description: string; phase: number; status: string }> {
if (!requirements) return [];
return requirements.traceability
.filter((t) => t.phase === phase)
.map((t) => {
let description = t.requirement;
for (const cat of [...requirements.v1, ...requirements.v2]) {
const item = cat.items.find((i) => i.id === t.requirement);
if (item) {
description = `${t.requirement}: ${item.description}`;
break;
}
}
return { id: t.requirement, description, phase: t.phase, status: t.status };
});
}
private buildPlans(
phaseRequirements: Array<{ id: string; description: string; phase: number; status: string }>,
componentBoundaries: string[],
phase: number
): PlanEntry[] {
if (phaseRequirements.length === 0) {
return [{
name: `Phase ${phase} Core Implementation`,
wave: 1,
requirements: [],
dependsOn: [],
tasks: [`Implement phase ${phase} deliverables as specified in ROADMAP.md`],
mustHaves: [`Phase ${phase} deliverables exist and pass verification`],
}];
}
const independentReqs = phaseRequirements.filter((r) => r.status !== "blocked");
const blockedReqs = phaseRequirements.filter((r) => r.status === "blocked");
const plans: PlanEntry[] = [];
if (independentReqs.length > 0) {
const taskChunks = this.chunkByComponent(independentReqs, componentBoundaries);
for (const chunk of taskChunks) {
plans.push({
name: this.inferPlanName(chunk, phase),
wave: 1,
requirements: chunk.map((r) => r.id),
dependsOn: [],
tasks: chunk.map((r) => {
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
}),
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
});
}
}
if (blockedReqs.length > 0) {
const taskChunks = this.chunkByComponent(blockedReqs, componentBoundaries);
for (const chunk of taskChunks) {
plans.push({
name: this.inferPlanName(chunk, phase),
wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2,
requirements: chunk.map((r) => r.id),
dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name),
tasks: chunk.map((r) => {
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
}),
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
});
}
}
if (plans.length === 0) {
plans.push({
name: `Phase ${phase} Default`,
wave: 1,
requirements: [],
dependsOn: [],
tasks: [`Implement phase ${phase} deliverables`],
mustHaves: [`Phase ${phase} deliverables pass verification`],
});
}
return plans;
}
private chunkByComponent(
reqs: Array<{ id: string; description: string; phase: number; status: string }>,
_componentBoundaries: string[]
): Array<Array<{ id: string; description: string; phase: number; status: string }>> {
if (reqs.length <= 3) return [reqs];
const chunks: Array<Array<{ id: string; description: string; phase: number; status: string }>> = [];
const chunkSize = Math.ceil(reqs.length / Math.ceil(reqs.length / 3));
for (let i = 0; i < reqs.length; i += chunkSize) {
chunks.push(reqs.slice(i, i + chunkSize));
}
return chunks;
}
private inferPlanName(chunk: Array<{ id: string; description: string; phase: number; status: string }>, phase: number): string {
if (chunk.length === 1) return `Phase ${phase}: ${chunk[0].id}`;
return `Phase ${phase}: ${chunk[0].id}${chunk[chunk.length - 1].id}`;
}
private groupPlansByWave(plans: PlanEntry[]): { wave: number; plans: string[] }[] {
const waveMap = new Map<number, string[]>();
for (const plan of plans) {
const existing = waveMap.get(plan.wave) || [];
existing.push(plan.name);
waveMap.set(plan.wave, existing);
}
return Array.from(waveMap.entries())
.sort((a, b) => a[0] - b[0])
.map(([wave, names]) => ({ wave, plans: names }));
}
private formatPlanFile(phase: number, phaseGoal: string, plans: PlanEntry[]): string {
const lines: string[] = [
`# Phase ${phase} Plan`,
"",
"## Phase Goal",
phaseGoal,
"",
"## Plans",
"",
];
for (let i = 0; i < plans.length; i++) {
const plan = plans[i];
const planNum = i + 1;
lines.push(`### Plan ${planNum}: ${plan.name}`);
lines.push(`- Wave: ${plan.wave}`);
if (plan.requirements.length > 0) {
lines.push(`- Requirements: [${plan.requirements.join(", ")}]`);
}
if (plan.dependsOn.length > 0) {
lines.push(`- Depends on: ${plan.dependsOn.join(", ")}`);
}
lines.push("- Tasks:");
for (const task of plan.tasks) {
lines.push(` 1. ${task}`);
}
lines.push("- Must-haves:");
for (const mh of plan.mustHaves) {
lines.push(` - [x] ${mh}`);
}
lines.push("");
}
return lines.join("\n");
}
private shouldCommit(context: AgentContext): boolean {
try {
execSync("git rev-parse --is-inside-work-tree", {
cwd: context.project_path,
stdio: "pipe",
});
return true;
} catch {
return false;
}
}
} }
+240 -6
View File
@@ -1,4 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { GitContext } from "../core/git-context.js";
import { CIAgentFiles, ArchitectureMd, ProjectMd } from "../core/ciagent-files.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { CommitDecision } from "../types/commit-meta.js";
import { fileExists, readFile } from "../utils/file.js";
import { execSync } from "node:child_process";
export interface ResearcherResult {
success: boolean;
findingsCount: number;
decisionsLogged: number;
filesUpdated: string[];
error?: string;
}
export class ResearcherAgent extends BaseAgent { export class ResearcherAgent extends BaseAgent {
readonly name = "researcher"; readonly name = "researcher";
@@ -8,21 +24,239 @@ export class ResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Researching domain..."); this.log("Researching domain...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
`Research the domain for: ${context.specification}` `Research the domain for phase ${context.phase}. Specification: ${context.specification}. Read git history (last 50 commits), .ciagent/PROJECT.md, .ciagent/ARCHITECTURE.md, .ciagent/REQUIREMENTS.md. Scan src/ directory structure. Generate findings about module boundaries, risks, and approach. Update .ciagent/ARCHITECTURE.md with component boundary conclusions. Update .ciagent/PROJECT.md key decisions if warranted. Commit findings with CommitBuilder.buildResearchCommit().`
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const result = await this.runMechanicalResearch(context);
const output = JSON.stringify(result, null, 2);
return { return {
success: false, success: result.success,
output: "Research requires an intelligence backend. Configure one with: ci init --backend", output,
artifacts_created: [], artifacts_created: result.filesUpdated,
decisions: 0, decisions: result.decisionsLogged,
escalations: 0, escalations: 0,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available", error: result.error,
}; };
} }
private async runMechanicalResearch(context: AgentContext): Promise<ResearcherResult> {
try {
const gitContext = new GitContext(context.project_path);
const ciFiles = new CIAgentFiles(context.project_path);
const findings: string[] = [];
const decisions: CommitDecision[] = [];
const filesUpdated: string[] = [];
const commits = gitContext.getRecentCommits(50);
if (commits.length > 0) {
findings.push(`Analyzed ${commits.length} recent commits for project history`);
const researchCommits = commits.filter(c => c.ci?.status === "research");
if (researchCommits.length > 0) {
findings.push(`Found ${researchCommits.length} prior research commits`);
}
}
const projectMd = ciFiles.readProjectMd();
if (projectMd) {
findings.push(`Project: ${projectMd.name} — core value: ${projectMd.coreValue.slice(0, 80)}`);
findings.push(`Active requirements: ${projectMd.requirements.active.length}, validated: ${projectMd.requirements.validated.length}`);
} else {
findings.push("No PROJECT.md found — project context unavailable");
}
const archMd = ciFiles.readArchitectureMd();
if (archMd) {
findings.push(`Architecture: ${archMd.components.length} components, ${archMd.buildOrder.length} build steps`);
for (const comp of archMd.components) {
findings.push(` Component: ${comp.name} — boundaries: ${comp.boundaries.slice(0, 60)}, deps: ${comp.dependsOn.join(", ") || "none"}`);
}
} else {
findings.push("No ARCHITECTURE.md found — architecture analysis unavailable");
}
const reqsMd = ciFiles.readRequirementsMd();
if (reqsMd) {
const totalReqs = reqsMd.traceability.length;
const covered = reqsMd.traceability.filter(t => t.status === "complete").length;
const phaseReqs = reqsMd.traceability.filter(t => t.phase === context.phase);
findings.push(`Requirements: ${totalReqs} total, ${covered} complete, ${phaseReqs.length} for phase ${context.phase}`);
}
const srcDir = path.join(context.project_path, "src");
if (fs.existsSync(srcDir)) {
const moduleDirs = fs.readdirSync(srcDir, { withFileTypes: true })
.filter(d => d.isDirectory() && d.name !== "node_modules")
.map(d => d.name);
findings.push(`Source modules: ${moduleDirs.join(", ")}`);
const updatedArch = this.deriveArchitectureFromSource(srcDir, archMd, moduleDirs);
if (updatedArch) {
ciFiles.writeArchitectureMd(updatedArch);
filesUpdated.push(".ciagent/ARCHITECTURE.md");
findings.push("Updated ARCHITECTURE.md with source-derived component boundaries");
decisions.push({
id: `D-P${context.phase}-001`,
decision: "Updated component boundaries from source scan",
rationale: "Source directory structure reveals actual module boundaries",
confidence: 0.75,
alternatives: ["manual architecture review", "no update"],
});
}
}
if (projectMd && archMd) {
const updatedProject = this.maybeUpdateKeyDecisions(projectMd, findings);
if (updatedProject) {
ciFiles.writeProjectMd(updatedProject, "research findings update");
filesUpdated.push(".ciagent/PROJECT.md");
findings.push("Updated PROJECT.md key decisions from research");
decisions.push({
id: `D-P${context.phase}-002`,
decision: "Logged research-based decisions to PROJECT.md",
rationale: "Research findings warrant recording as key decisions",
confidence: 0.70,
alternatives: ["defer decision logging", "log after execution"],
});
}
}
this.commitFindings(context, findings, decisions);
return {
success: true,
findingsCount: findings.length,
decisionsLogged: decisions.length,
filesUpdated,
};
} catch (err) {
return {
success: false,
findingsCount: 0,
decisionsLogged: 0,
filesUpdated: [],
error: `Research failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private deriveArchitectureFromSource(srcDir: string, existing: ArchitectureMd | null, moduleDirs: string[]): ArchitectureMd | null {
const newComponents = moduleDirs.map(dir => {
const dirPath = path.join(srcDir, dir);
const fileCount = this.countTsFiles(dirPath);
const existingComp = existing?.components.find(c => c.name.toLowerCase() === dir.toLowerCase());
return {
name: existingComp?.name || this.capitalize(dir),
description: existingComp?.description || `${dir} module with ${fileCount} source files`,
boundaries: existingComp?.boundaries || `src/${dir}/ — ${fileCount} files, internal module`,
dependsOn: existingComp?.dependsOn || [],
};
});
if (existing) {
const existingNames = new Set(existing.components.map(c => c.name.toLowerCase()));
const hasNew = newComponents.some(c => !existingNames.has(c.name.toLowerCase()));
if (!hasNew) {
return {
...existing,
components: existing.components.map(comp => {
const updated = newComponents.find(n => n.name.toLowerCase() === comp.name.toLowerCase());
return updated || comp;
}),
};
}
const merged = [...existing.components];
for (const nc of newComponents) {
if (!existingNames.has(nc.name.toLowerCase())) {
merged.push(nc);
}
}
return { ...existing, components: merged };
}
return {
overview: "Architecture derived from source directory scan",
components: newComponents,
dataFlow: "Modules communicate via typed interfaces and shared utilities",
buildOrder: moduleDirs.map(d => `Build ${d} module`),
};
}
private maybeUpdateKeyDecisions(projectMd: ProjectMd, findings: string[]): ProjectMd | null {
const researchDecisions = findings
.filter(f => f.includes("Updated") || f.includes("Found") || f.includes("derived"))
.map(f => ({
decision: f.slice(0, 50),
rationale: "Derived from mechanical source analysis",
outcome: "logged by researcher",
}));
if (researchDecisions.length === 0) return null;
const existingDecisions = projectMd.keyDecisions || [];
const existingDecisionTexts = new Set(existingDecisions.map(d => d.decision));
const novelDecisions = researchDecisions.filter(d => !existingDecisionTexts.has(d.decision));
if (novelDecisions.length === 0) return null;
return {
...projectMd,
keyDecisions: [...existingDecisions, ...novelDecisions],
};
}
private commitFindings(context: AgentContext, findings: string[], decisions: CommitDecision[]): void {
try {
const gitContext = new GitContext(context.project_path);
const projectState = gitContext.reconstructState();
const milestone = projectState.currentMilestone || "v1.0";
const commitMsg = CommitBuilder.buildResearchCommit(
context.phase,
milestone,
`phase ${context.phase} domain research`,
findings,
decisions.length > 0 ? decisions : undefined,
);
if (fileExists(path.join(context.project_path, ".git"))) {
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
}
} catch (err) {
this.warn(`Research commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
private countTsFiles(dir: string): number {
if (!fs.existsSync(dir)) return 0;
let count = 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== "node_modules") {
count += this.countTsFiles(path.join(dir, entry.name));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
count++;
}
}
return count;
}
private capitalize(s: string): string {
return s.split("-").map(p => p.charAt(0).toUpperCase() + p.slice(1)).join("-");
}
} }
+181
View File
@@ -0,0 +1,181 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
export interface TesterResult {
success: boolean;
integrationTestsFound: number;
integrationTestsPassed: number;
e2eTestsFound: number;
e2eTestsPassed: number;
overallPassed: boolean;
error?: string;
}
export class TesterAgent extends BaseAgent {
readonly name = "tester";
readonly description = "Runs integration, e2e, functional tests. Validates non-unit test coverage.";
readonly workflow = "test";
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Running automated tests...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
`Run integration, e2e, and functional tests for phase ${context.phase}. Specification: ${context.specification}. Detect *.integration.test.ts, *.e2e.test.ts, *.functional.test.ts files. Run npm test. Parse output for pass/fail counts per category. Report structured TesterResult. Do NOT write any test files — only detect and run existing ones.`
);
return { ...result, duration_ms: Date.now() - start };
}
const result = await this.runMechanicalTests(context);
const output = JSON.stringify(result, null, 2);
return {
success: result.success,
output,
artifacts_created: [],
decisions: 0,
escalations: result.overallPassed ? 0 : 1,
duration_ms: Date.now() - start,
error: result.error,
};
}
private async runMechanicalTests(context: AgentContext): Promise<TesterResult> {
try {
const srcDir = path.join(context.project_path, "src");
const integrationFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.integration\.test\.ts$/) : [];
const e2eFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.e2e\.test\.ts$/) : [];
const functionalFiles = fs.existsSync(srcDir) ? this.findTestFiles(srcDir, /\.functional\.test\.ts$/) : [];
const integrationTestsFound = integrationFiles.length;
const e2eTestsFound = e2eFiles.length + functionalFiles.length;
let overallPassed = false;
let integrationTestsPassed = 0;
let e2eTestsPassed = 0;
try {
const testOutput = execSync("npm test 2>&1", {
cwd: context.project_path,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 120000,
});
overallPassed = true;
const passCounts = this.parseTestOutput(testOutput);
integrationTestsPassed = integrationTestsFound > 0 ? integrationTestsFound : 0;
e2eTestsPassed = e2eTestsFound > 0 ? e2eTestsFound : 0;
if (integrationTestsFound > 0) {
integrationTestsPassed = this.estimateCategoryPassed(testOutput, "integration");
}
if (e2eTestsFound > 0) {
e2eTestsPassed = this.estimateCategoryPassed(testOutput, "e2e");
}
} catch (err) {
const output = err instanceof Error && "stdout" in err
? (err as unknown as { stdout: string }).stdout || ""
: "";
const stderr = err instanceof Error && "stderr" in err
? (err as unknown as { stderr: string }).stderr || ""
: "";
const combined = `${output}\n${stderr}`;
overallPassed = false;
const passCounts = this.parseTestOutput(combined);
if (integrationTestsFound > 0) {
integrationTestsPassed = this.estimateCategoryPassed(combined, "integration");
}
if (e2eTestsFound > 0) {
e2eTestsPassed = this.estimateCategoryPassed(combined, "e2e");
}
return {
success: false,
integrationTestsFound,
integrationTestsPassed,
e2eTestsFound,
e2eTestsPassed,
overallPassed: false,
error: `npm test failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
return {
success: overallPassed,
integrationTestsFound,
integrationTestsPassed,
e2eTestsFound,
e2eTestsPassed,
overallPassed,
};
} catch (err) {
return {
success: false,
integrationTestsFound: 0,
integrationTestsPassed: 0,
e2eTestsFound: 0,
e2eTestsPassed: 0,
overallPassed: false,
error: `Test execution failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private findTestFiles(dir: string, pattern: RegExp): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files.push(...this.findTestFiles(fullPath, pattern));
} else if (pattern.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
private parseTestOutput(output: string): { total: number; passed: number; failed: number } {
const jestSummary = output.match(/Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?/);
if (jestSummary) {
const passed = parseInt(jestSummary[1], 10) || 0;
const failed = parseInt(jestSummary[2], 10) || 0;
return { total: passed + failed, passed, failed };
}
const jestAlt = output.match(/(\d+)\s+passing/);
const jestAltFail = output.match(/(\d+)\s+failing/);
if (jestAlt) {
const passed = parseInt(jestAlt[1], 10) || 0;
const failed = jestAltFail ? parseInt(jestAltFail[1], 10) || 0 : 0;
return { total: passed + failed, passed, failed };
}
return { total: 0, passed: 0, failed: 0 };
}
private estimateCategoryPassed(output: string, category: string): number {
const categoryPattern = category === "integration"
? /\.integration\.test\.ts/g
: /\.e2e\.test\.ts|\.functional\.test\.ts/g;
const mentions = (output.match(categoryPattern) || []).length;
if (mentions > 0) {
const failPattern = /FAIL|failed|error/i;
const lines = output.split("\n").filter(l => categoryPattern.test(l));
const failed = lines.filter(l => failPattern.test(l)).length;
return Math.max(mentions - failed, 0);
}
return 0;
}
}
+217 -5
View File
@@ -1,4 +1,22 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js"; import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { VerificationPipeline } from "../verification/index.js";
import { CommitBuilder, VerifyCommitInput } from "../core/commit-builder.js";
import { GitContext } from "../core/git-context.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { fileExists } from "../utils/file.js";
import { execSync } from "node:child_process";
export interface VerifierResult {
success: boolean;
mustHaveScore: number;
requirementsCovered: string[];
requirementsPartial: string[];
integrationChecks: { import: string; resolved: boolean }[];
layers: { name: string; passed: boolean }[];
error?: string;
}
export class VerifierAgent extends BaseAgent { export class VerifierAgent extends BaseAgent {
readonly name = "verifier"; readonly name = "verifier";
@@ -8,21 +26,215 @@ export class VerifierAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> { async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now(); const start = Date.now();
this.log("Verifying phase output..."); this.log("Verifying phase output...");
if (context.backend) { if (context.backend) {
const result = await this.executeViaBackend( const result = await this.executeViaBackend(
context, context,
`Verify phase ${context.phase} output. Specification: ${context.specification}` `Verify phase ${context.phase} output against must-haves, requirement coverage, and integration links. Specification: ${context.specification}. Check all .ciagent/ reference files. Run the 4-layer verification pipeline (structural, behavioral, security, quality). Verify imports resolve. Report structured VerifierResult.`
); );
return { ...result, duration_ms: Date.now() - start }; return { ...result, duration_ms: Date.now() - start };
} }
const result = await this.runMechanicalVerification(context);
const output = JSON.stringify(result, null, 2);
return { return {
success: false, success: result.success,
output: "Verification requires an intelligence backend. Configure one with: ci init --backend", output,
artifacts_created: [], artifacts_created: [],
decisions: 0, decisions: 0,
escalations: 0, escalations: result.success ? 0 : 1,
duration_ms: Date.now() - start, duration_ms: Date.now() - start,
error: "No intelligence backend available", error: result.error,
}; };
} }
private async runMechanicalVerification(context: AgentContext): Promise<VerifierResult> {
try {
const pipeline = new VerificationPipeline(context.project_path);
const pipelineResult = await pipeline.run(context.phase);
const layers: { name: string; passed: boolean }[] = [
{ name: pipelineResult.structural.name, passed: pipelineResult.structural.passed },
{ name: pipelineResult.behavioral.name, passed: pipelineResult.behavioral.passed },
{ name: pipelineResult.security.name, passed: pipelineResult.security.passed },
{ name: pipelineResult.quality.name, passed: pipelineResult.quality.passed },
];
const gitContext = new GitContext(context.project_path);
const ciFiles = new CIAgentFiles(context.project_path);
const mustHaveScore = this.checkMustHaves(context, gitContext, ciFiles);
const reqCoverage = this.checkRequirementCoverage(gitContext, ciFiles);
const integrationChecks = this.checkIntegrationLinks(context.project_path);
const allPassed = pipelineResult.all_passed &&
mustHaveScore >= 1.0 &&
reqCoverage.partial.length === 0;
const result: VerifierResult = {
success: allPassed,
mustHaveScore,
requirementsCovered: reqCoverage.covered,
requirementsPartial: reqCoverage.partial,
integrationChecks,
layers,
};
if (!allPassed) {
result.error = `Verification gaps: mustHaveScore=${mustHaveScore}, partialReqs=${reqCoverage.partial.join(",")}, layerFailures=${layers.filter(l => !l.passed).map(l => l.name).join(",")}`;
}
this.commitVerificationResult(context, result, ciFiles);
return result;
} catch (err) {
return {
success: false,
mustHaveScore: 0,
requirementsCovered: [],
requirementsPartial: [],
integrationChecks: [],
layers: [],
error: `Verification failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
private checkMustHaves(context: AgentContext, gitContext: GitContext, ciFiles: CIAgentFiles): number {
const roadmap = ciFiles.readRoadmapMd();
if (!roadmap) return 0;
const currentPhase = roadmap.phases.find(p => p.number === context.phase);
if (!currentPhase) return 0;
const successCriteria = currentPhase.successCriteria;
if (successCriteria.length === 0) return 1;
let passing = 0;
for (const criterion of successCriteria) {
const fileHint = criterion.match(/(?:file|exists|present|created|written)[:\s]+([^\s,;]+)/i);
if (fileHint) {
const candidate = path.join(context.project_path, fileHint[1]);
if (fileExists(candidate)) {
passing++;
continue;
}
}
if (fileExists(path.join(context.project_path, ".ciagent"))) {
passing++;
}
}
return Math.min(passing / successCriteria.length, 1);
}
private checkRequirementCoverage(gitContext: GitContext, ciFiles: CIAgentFiles): { covered: string[]; partial: string[] } {
const gitCoverage = gitContext.getRequirementsCoverage();
const reqsMd = ciFiles.readRequirementsMd();
if (!reqsMd || reqsMd.traceability.length === 0) {
return { covered: gitCoverage.covered, partial: gitCoverage.partial };
}
const covered = new Set(gitCoverage.covered);
const partial = new Set(gitCoverage.partial);
for (const t of reqsMd.traceability) {
if (t.status === "complete") {
covered.add(t.requirement);
partial.delete(t.requirement);
} else if (t.status === "in_progress" || t.status === "blocked") {
partial.add(t.requirement);
}
}
return {
covered: [...covered].sort(),
partial: [...partial].sort(),
};
}
private checkIntegrationLinks(projectPath: string): { import: string; resolved: boolean }[] {
const checks: { import: string; resolved: boolean }[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return checks;
const tsFiles = this.collectTsFiles(srcDir);
const importPattern = /import\s+.*from\s+['"](\.\/[^'"]+)['"]/g;
for (const file of tsFiles) {
const content = fs.readFileSync(file, "utf-8");
let match: RegExpExecArray | null;
while ((match = importPattern.exec(content)) !== null) {
const importPath = match[1];
const resolved = this.resolveImport(file, importPath);
if (importPath.startsWith(".")) {
checks.push({ import: `${path.relative(projectPath, file)}:${importPath}`, resolved: resolved !== null });
}
}
}
return checks;
}
private commitVerificationResult(context: AgentContext, result: VerifierResult, ciFiles: CIAgentFiles): void {
try {
const projectState = new GitContext(context.project_path).reconstructState();
const milestone = projectState.currentMilestone || "v1.0";
const verifyInput: VerifyCommitInput = {
phase: context.phase,
milestone,
subject: result.success ? "passed" : "gaps_found",
requirements: {
covered: result.requirementsCovered,
partial: result.requirementsPartial,
},
lessons: result.success ? ["All verification checks passed"] : [`Must-have score: ${result.mustHaveScore}`, `Layer failures: ${result.layers.filter(l => !l.passed).map(l => l.name).join(", ")}`],
};
const commitMsg = CommitBuilder.buildVerifyCommit(verifyInput);
if (fileExists(path.join(context.project_path, ".git"))) {
execSync(`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
}
} catch (err) {
this.warn(`Verification commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
private collectTsFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
files.push(...this.collectTsFiles(fullPath));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts")) {
files.push(fullPath);
}
}
return files;
}
private resolveImport(fromFile: string, importPath: string): string | null {
if (!importPath.startsWith(".")) return null;
const dir = path.dirname(fromFile);
const candidates = [
path.resolve(dir, importPath + ".ts"),
path.resolve(dir, importPath + ".js"),
path.resolve(dir, importPath, "index.ts"),
path.resolve(dir, importPath, "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
} }
+1 -1
View File
@@ -42,7 +42,7 @@ describe("OllamaBaseBackend", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ollama-base-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ollama-base-test-"));
}); });
afterEach(() => { afterEach(() => {
+75 -3
View File
@@ -15,16 +15,26 @@ import {
import { AgentName, ModelProfile } from "../types/config.js"; import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js"; import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js"; import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult } from "./tool-registry.js"; import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50; const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export abstract class OllamaBaseBackend implements IntelligenceBackend { export abstract class OllamaBaseBackend implements IntelligenceBackend {
abstract readonly name: string; abstract readonly name: string;
readonly type: BackendType = "llm"; readonly type: BackendType = "llm";
protected config: LLMBackendConfig; protected config: LLMBackendConfig;
protected projectPath: string; protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
constructor(config: LLMBackendConfig | undefined) { constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" }; this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
@@ -42,6 +52,9 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
const model = this.resolveModel(); const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path); const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: OllamaMessage[] = []; const messages: OllamaMessage[] = [];
messages.push({ messages.push({
@@ -62,7 +75,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
while (round < MAX_TOOL_ROUNDS) { while (round < MAX_TOOL_ROUNDS) {
round++; round++;
const response = await this.callModel(messages, model, toolRegistry); const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0; totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0; totalOutputTokens += response.usage?.completion_tokens || 0;
@@ -124,6 +137,65 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
} }
} }
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: OllamaMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<OllamaChatResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel( protected abstract callModel(
messages: OllamaMessage[], messages: OllamaMessage[],
model: string, model: string,
@@ -170,7 +242,7 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
return fs.readFileSync(candidate, "utf-8"); return fs.readFileSync(candidate, "utf-8");
} }
} }
return `You are the CI ${persona} agent. Execute the requested task thoroughly and autonomously.`; return `You are the CIAgent ${persona} agent. Execute the requested task thoroughly and autonomously.`;
} }
protected loadWorkflow(workflow: string): string { protected loadWorkflow(workflow: string): string {
+1 -1
View File
@@ -61,7 +61,7 @@ export class OllamaCloudBackend extends OllamaBaseBackend {
if (m.tool_calls) msg.tool_calls = m.tool_calls; if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg; return msg;
}), }),
tools: toolRegistry.getOpenAIToolSchema(), tools: this.getActiveToolSchema(toolRegistry),
stream: false, stream: false,
}; };
+1 -1
View File
@@ -48,7 +48,7 @@ export class OllamaLocalBackend extends OllamaBaseBackend {
if (m.tool_calls) msg.tool_calls = m.tool_calls; if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg; return msg;
}), }),
tools: toolRegistry.getOpenAIToolSchema(), tools: this.getActiveToolSchema(toolRegistry),
stream: false, stream: false,
}; };
+1 -1
View File
@@ -8,7 +8,7 @@ describe("ToolRegistry Extended", () => {
let registry: ToolRegistry; let registry: ToolRegistry;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-ext-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-tool-registry-ext-"));
registry = new ToolRegistry(tempDir); registry = new ToolRegistry(tempDir);
}); });
+1 -1
View File
@@ -8,7 +8,7 @@ describe("ToolRegistry", () => {
let registry: ToolRegistry; let registry: ToolRegistry;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-tool-registry-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-tool-registry-test-"));
registry = new ToolRegistry(tempDir); registry = new ToolRegistry(tempDir);
}); });
+173 -49
View File
@@ -1,6 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { CIConfig, AutonomyLevel } from "../types/config.js"; import { CIAgentConfig, AutonomyLevel } from "../types/config.js";
import { initCI, loadConfig, isCIInitialized, saveConfig } from "../core/config.js"; import { initCIAgent, loadConfig, isCIAgentInitialized, saveConfig } from "../core/config.js";
import { Specification, parseSpecification } from "../types/specification.js"; import { Specification, parseSpecification } from "../types/specification.js";
import { saveSpecification } from "../core/clarify.js"; import { saveSpecification } from "../core/clarify.js";
import { OrchestratorAgent } from "../agents/orchestrator.js"; import { OrchestratorAgent } from "../agents/orchestrator.js";
@@ -15,13 +15,15 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"
import { resolveBackend } from "../backends/index.js"; import { resolveBackend } from "../backends/index.js";
import { BackendUnavailableError } from "../backends/types.js"; import { BackendUnavailableError } from "../backends/types.js";
import { getAgent } from "../agents/index.js"; import { getAgent } from "../agents/index.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
export function createInitCommand(): Command { export function createInitCommand(): Command {
return new Command("init") return new Command("init")
.description("Initialize a new CI project from a specification") .description("Initialize a new CIAgent project from a specification")
.argument("[specification]", "Inline specification text") .argument("[specification]", "Inline specification text")
.option("-s, --spec <file>", "Specification file path") .option("-s, --spec <file>", "Specification file path")
.option("-c, --clarify", "Start interactive clarify phase", false) .option("-c, --clarify", "Start interactive clarify phase", false)
@@ -36,9 +38,9 @@ export function createInitCommand(): Command {
.action(async (specification, options) => { .action(async (specification, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (isCIInitialized(projectPath)) { if (isCIAgentInitialized(projectPath)) {
console.log("CI project already initialized in this directory."); console.log("CIAgent project already initialized in this directory.");
console.log("Use 'ci run' to execute the pipeline or 'ci status' to check progress."); console.log("Use 'ciagent run' to execute the pipeline or 'ciagent status' to check progress.");
return; return;
} }
@@ -60,7 +62,7 @@ export function createInitCommand(): Command {
} }
const autonomyLevel = options.autonomy as AutonomyLevel; const autonomyLevel = options.autonomy as AutonomyLevel;
const config: Partial<CIConfig> = { const config: Partial<CIAgentConfig> = {
autonomy: { autonomy: {
level: autonomyLevel, level: autonomyLevel,
escalation_hooks: ["deploy", "delete_data", "merge_to_main"], escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
@@ -86,8 +88,8 @@ export function createInitCommand(): Command {
}, },
}; };
const fullConfig = initCI(projectPath, config); const fullConfig = initCIAgent(projectPath, config);
console.log(`✓ CI project initialized (autonomy: ${autonomyLevel})`); console.log(`✓ CIAgent project initialized (autonomy: ${autonomyLevel})`);
console.log(` Backend: ${options.backend || "auto"}`); console.log(` Backend: ${options.backend || "auto"}`);
if (specText) { if (specText) {
@@ -115,15 +117,15 @@ export function createInitCommand(): Command {
} }
} }
console.log("\nConfiguration saved to .ci/config.json"); console.log("\nConfiguration saved to .ciagent/config.json");
console.log("\nNext steps:"); console.log("\nNext steps:");
console.log(" ci run --all # Run full pipeline"); console.log(" ciagent run --all # Run full pipeline");
console.log(" ci run research # Run specific phase"); console.log(" ciagent run research # Run specific phase");
console.log(" ci status # Check project status"); console.log(" ci status # Check project status");
}); });
} }
async function resolveBackendForCommand(config: CIConfig, overrideBackend?: string): Promise<{ backend: import("../backends/types.js").IntelligenceBackend | undefined; error?: string }> { async function resolveBackendForCommand(config: CIAgentConfig, overrideBackend?: string): Promise<{ backend: import("../backends/types.js").IntelligenceBackend | undefined; error?: string }> {
const backendConfig = { ...config.backend }; const backendConfig = { ...config.backend };
if (overrideBackend) { if (overrideBackend) {
backendConfig.provider = overrideBackend as typeof backendConfig.provider; backendConfig.provider = overrideBackend as typeof backendConfig.provider;
@@ -168,8 +170,8 @@ export function createRunCommand(): Command {
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -187,7 +189,7 @@ export function createRunCommand(): Command {
phase: parseInt(options.phase) || 1, phase: parseInt(options.phase) || 1,
stage: phase || "all", stage: phase || "all",
specification: "", specification: "",
config_path: path.join(projectPath, ".ci", "config.json"), config_path: path.join(projectPath, ".ciagent", "config.json"),
backend, backend,
}; };
@@ -196,7 +198,7 @@ export function createRunCommand(): Command {
context.specification = spec.raw_content; context.specification = spec.raw_content;
} }
console.log(`Running CI pipeline...`); console.log(`Running CIAgent pipeline...`);
if (options.all) { if (options.all) {
console.log(" Mode: Full pipeline (all phases)"); console.log(" Mode: Full pipeline (all phases)");
} else { } else {
@@ -226,16 +228,16 @@ export function createQuickCommand(): Command {
const projectPath = process.cwd(); const projectPath = process.cwd();
console.log(`Quick task: ${description}`); console.log(`Quick task: ${description}`);
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
const config = initCI(projectPath); const config = initCIAgent(projectPath);
console.log("Initialized temporary CI project"); console.log("Initialized temporary CIAgent project");
} }
const config = loadConfig(projectPath); const config = loadConfig(projectPath);
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend) { if (!backend) {
console.error(`\n✗ "ci quick" requires an intelligence backend.`); console.error(`\n✗ "ciagent quick" requires an intelligence backend.`);
if (backendError) console.error(` ${backendError}`); if (backendError) console.error(` ${backendError}`);
process.exit(1); process.exit(1);
} }
@@ -249,7 +251,7 @@ export function createQuickCommand(): Command {
phase: 0, phase: 0,
stage: "all", stage: "all",
specification: description, specification: description,
config_path: path.join(projectPath, ".ci", "config.json"), config_path: path.join(projectPath, ".ciagent", "config.json"),
backend, backend,
}; };
@@ -274,8 +276,8 @@ export function createDebugCommand(): Command {
.action(async (description, options) => { .action(async (description, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -283,7 +285,7 @@ export function createDebugCommand(): Command {
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend) { if (!backend) {
console.error(`\n✗ "ci debug" requires an intelligence backend.`); console.error(`\n✗ "ciagent debug" requires an intelligence backend.`);
if (backendError) console.error(` ${backendError}`); if (backendError) console.error(` ${backendError}`);
process.exit(1); process.exit(1);
} }
@@ -300,7 +302,7 @@ export function createDebugCommand(): Command {
phase: 0, phase: 0,
stage: "debug", stage: "debug",
specification: description || "", specification: description || "",
config_path: path.join(projectPath, ".ci", "config.json"), config_path: path.join(projectPath, ".ciagent", "config.json"),
backend, backend,
}; };
@@ -324,8 +326,8 @@ export function createVerifyCommand(): Command {
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -371,8 +373,8 @@ export function createReviewCommand(): Command {
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -380,7 +382,7 @@ export function createReviewCommand(): Command {
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend); const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend) { if (!backend) {
console.error(`\n✗ "ci review" requires an intelligence backend.`); console.error(`\n✗ "ciagent review" requires an intelligence backend.`);
if (backendError) console.error(` ${backendError}`); if (backendError) console.error(` ${backendError}`);
process.exit(1); process.exit(1);
} }
@@ -394,7 +396,7 @@ export function createReviewCommand(): Command {
phase: phaseNum, phase: phaseNum,
stage: "review", stage: "review",
specification: "", specification: "",
config_path: path.join(projectPath, ".ci", "config.json"), config_path: path.join(projectPath, ".ciagent", "config.json"),
backend, backend,
}; };
@@ -415,16 +417,16 @@ export function createStatusCommand(): Command {
.action(() => { .action(() => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.log("CI project not initialized in this directory."); console.log("CIAgent project not initialized in this directory.");
console.log("Run 'ci init' to get started."); console.log("Run 'ciagent init' to get started.");
return; return;
} }
const config = loadConfig(projectPath); const config = loadConfig(projectPath);
const artifacts = new ArtifactManager(projectPath); const artifacts = new ArtifactManager(projectPath);
console.log("─── CI Project Status ───"); console.log("─── CIAgent Project Status ───");
console.log(`\nAutonomy: ${config.autonomy.level}`); console.log(`\nAutonomy: ${config.autonomy.level}`);
console.log(`Model Profile: ${config.model_profile}`); console.log(`Model Profile: ${config.model_profile}`);
console.log(`Backend: ${config.backend?.provider || "auto"}`); console.log(`Backend: ${config.backend?.provider || "auto"}`);
@@ -444,7 +446,7 @@ export function createStatusCommand(): Command {
console.log(` ${icon} ${stage}`); console.log(` ${icon} ${stage}`);
} }
} else { } else {
console.log("\nNo pipeline state found. Run 'ci run --all' to start."); console.log("\nNo pipeline state found. Run 'ciagent run --all' to start.");
} }
const summary = getAuditSummary(projectPath); const summary = getAuditSummary(projectPath);
@@ -464,15 +466,15 @@ export function createAuditCommand(): Command {
.action((options) => { .action((options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
const phase = options.phase ? parseInt(options.phase) : undefined; const phase = options.phase ? parseInt(options.phase) : undefined;
const summary = getAuditSummary(projectPath); const summary = getAuditSummary(projectPath);
console.log("─── CI Audit Report ───"); console.log("─── CIAgent Audit Report ───");
console.log(`\nTotal Decisions: ${summary.total_decisions}`); console.log(`\nTotal Decisions: ${summary.total_decisions}`);
console.log(`Total Escalations: ${summary.total_escalations}`); console.log(`Total Escalations: ${summary.total_escalations}`);
console.log(`Phases Audited: ${summary.phases.join(", ") || "none"}`); console.log(`Phases Audited: ${summary.phases.join(", ") || "none"}`);
@@ -517,8 +519,8 @@ export function createClarifyCommand(): Command {
.action(async (options) => { .action(async (options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -526,7 +528,7 @@ export function createClarifyCommand(): Command {
const spec = loadSpec(projectPath); const spec = loadSpec(projectPath);
if (!spec) { if (!spec) {
console.error("No specification found. Run 'ci init' first."); console.error("No specification found. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -557,8 +559,8 @@ export function createRollbackCommand(): Command {
.action(async (target, options) => { .action(async (target, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -642,6 +644,83 @@ export function createRollbackCommand(): Command {
}); });
} }
export function createProjectsCommand(): Command {
const cmd = new Command("projects");
cmd.description("Manage CIAgent projects in multi-project mode");
cmd.command("list")
.description("List all registered projects")
.action(() => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const config = loadConfig(projectPath);
const ciFiles = new CIAgentFiles(projectPath);
const projects = ciFiles.listProjects();
const activeProject = config.active_project || ciFiles.getActiveProject();
if (projects.length === 0) {
console.log("No projects registered.");
console.log("Use 'ciagent projects add <slug> <name>' to add a project.");
return;
}
console.log("─── CIAgent Projects ───\n");
for (const project of projects) {
const isActive = project.slug === activeProject;
const marker = isActive ? " *" : "";
console.log(` ${project.slug}${project.name}${marker}`);
}
console.log("\n * = active project");
});
cmd.command("add <slug> <name>")
.description("Add a new project")
.action((slug: string, name: string) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const ciFiles = new CIAgentFiles(projectPath);
ciFiles.addProject(slug, name);
console.log(`✓ Project added: ${slug} (${name})`);
});
cmd.command("set <slug>")
.description("Set the active project")
.action((slug: string) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1);
}
const ciFiles = new CIAgentFiles(projectPath);
const projects = ciFiles.listProjects();
if (!projects.some((p) => p.slug === slug)) {
console.error(`Project "${slug}" not found. Registered projects: ${projects.map((p) => p.slug).join(", ")}`);
process.exit(1);
}
ciFiles.setActiveProject(slug);
const config = loadConfig(projectPath);
config.active_project = slug;
saveConfig(projectPath, config);
console.log(`✓ Active project set to: ${slug}`);
});
return cmd;
}
export function createShipCommand(): Command { export function createShipCommand(): Command {
return new Command("ship") return new Command("ship")
.description("Auto-complete phase: verify, security, commit, tag") .description("Auto-complete phase: verify, security, commit, tag")
@@ -650,8 +729,8 @@ export function createShipCommand(): Command {
.action(async (phase, options) => { .action(async (phase, options) => {
const projectPath = process.cwd(); const projectPath = process.cwd();
if (!isCIInitialized(projectPath)) { if (!isCIAgentInitialized(projectPath)) {
console.error("CI project not initialized. Run 'ci init' first."); console.error("CIAgent project not initialized. Run 'ciagent init' first.");
process.exit(1); process.exit(1);
} }
@@ -707,12 +786,41 @@ export function createShipCommand(): Command {
cwd: projectPath, cwd: projectPath,
stdio: "pipe", stdio: "pipe",
}); });
execSync(`git tag -a ${version.tag} -m "CI: Phase ${phaseNum} shipped"`, { execSync(`git tag -a ${version.tag} -m "CIAgent: Phase ${phaseNum} shipped"`, {
cwd: projectPath, cwd: projectPath,
stdio: "pipe", stdio: "pipe",
}); });
console.log(` ✓ Tagged: ${version.tag}`); console.log(` ✓ Tagged: ${version.tag}`);
if (config.gitea && config.gitea.owner && config.gitea.repo) {
const apiToken = process.env[config.gitea.api_token_env];
if (apiToken) {
try {
const previousTag = getPreviousTag(projectPath, version.tag);
const releaseNotes = generateReleaseNotes(projectPath, previousTag, version.tag);
const giteaClient = new GiteaClient({
baseUrl: config.gitea.base_url,
token: apiToken,
owner: config.gitea.owner,
repo: config.gitea.repo,
});
const release = await giteaClient.createRelease({
tag_name: version.tag,
name: version.tag,
body: releaseNotes,
draft: false,
prerelease: false,
});
console.log(` ✓ Release created: ${release.html_url}`);
} catch (giteaErr) {
console.warn(` ⚠ Gitea release failed: ${giteaErr instanceof Error ? giteaErr.message : String(giteaErr)}`);
}
}
}
if (config.git.auto_push) { if (config.git.auto_push) {
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" }); execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
console.log(` ✓ Pushed tag: ${version.tag}`); console.log(` ✓ Pushed tag: ${version.tag}`);
@@ -730,7 +838,7 @@ export function createShipCommand(): Command {
function computeShipVersion( function computeShipVersion(
projectPath: string, projectPath: string,
phaseNum: number, phaseNum: number,
config: CIConfig config: CIAgentConfig
): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } { ): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } {
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" }) const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
.split("\n") .split("\n")
@@ -820,3 +928,19 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
return "main"; return "main";
} }
function getPreviousTag(projectPath: string, currentTag: string): string | null {
try {
const tags = execSync("git tag -l --sort=-v:refname", { cwd: projectPath, encoding: "utf-8" })
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
const currentIdx = tags.indexOf(currentTag);
if (currentIdx >= 0 && currentIdx + 1 < tags.length) {
return tags[currentIdx + 1];
}
} catch {}
return null;
}
+15 -3
View File
@@ -2,6 +2,8 @@
import { Command } from "commander"; import { Command } from "commander";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { isCIAgentInitialized } from "../core/config.js";
import { import {
createInitCommand, createInitCommand,
createRunCommand, createRunCommand,
@@ -14,14 +16,23 @@ import {
createClarifyCommand, createClarifyCommand,
createRollbackCommand, createRollbackCommand,
createShipCommand, createShipCommand,
createProjectsCommand,
} from "./commands.js"; } from "./commands.js";
const program = new Command(); const program = new Command();
program program
.name("ci") .name("ciagent")
.description("CI — Continuous Intelligence: autonomous AI-driven software engineering harness") .description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
.version(VERSION) .version(VERSION)
.option("--project <slug>", "Specify which project to operate on")
.hook("preAction", () => {
const opts = program.opts();
if (opts.project && isCIAgentInitialized(process.cwd())) {
const ciFiles = new CIAgentFiles(process.cwd());
ciFiles.setProjectSlug(opts.project);
}
})
.addCommand(createInitCommand()) .addCommand(createInitCommand())
.addCommand(createRunCommand()) .addCommand(createRunCommand())
.addCommand(createQuickCommand()) .addCommand(createQuickCommand())
@@ -32,6 +43,7 @@ program
.addCommand(createAuditCommand()) .addCommand(createAuditCommand())
.addCommand(createClarifyCommand()) .addCommand(createClarifyCommand())
.addCommand(createRollbackCommand()) .addCommand(createRollbackCommand())
.addCommand(createShipCommand()); .addCommand(createShipCommand())
.addCommand(createProjectsCommand());
program.parse(); program.parse();
+7 -7
View File
@@ -8,7 +8,7 @@ describe("ArtifactManager", () => {
let manager: ArtifactManager; let manager: ArtifactManager;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-artifact-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-artifact-test-"));
manager = new ArtifactManager(tempDir); manager = new ArtifactManager(tempDir);
}); });
@@ -17,16 +17,16 @@ describe("ArtifactManager", () => {
}); });
describe("ensureStructure", () => { describe("ensureStructure", () => {
it("creates .ci directory structure", () => { it("creates .ciagent directory structure", () => {
manager.ensureStructure(); manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ci", "audit"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent", "audit"))).toBe(true);
}); });
it("is idempotent", () => { it("is idempotent", () => {
manager.ensureStructure(); manager.ensureStructure();
manager.ensureStructure(); manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
}); });
}); });
@@ -67,7 +67,7 @@ describe("ArtifactManager", () => {
manager.writeProject(manifest); manager.writeProject(manifest);
const projectPath = path.join(tempDir, ".ci", "PROJECT.md"); const projectPath = path.join(tempDir, ".ciagent", "PROJECT.md");
expect(fs.existsSync(projectPath)).toBe(true); expect(fs.existsSync(projectPath)).toBe(true);
const content = fs.readFileSync(projectPath, "utf-8"); const content = fs.readFileSync(projectPath, "utf-8");
expect(content).toContain("Test Project"); expect(content).toContain("Test Project");
@@ -131,7 +131,7 @@ describe("ArtifactManager", () => {
], ],
}); });
const decisionsPath = path.join(tempDir, ".ci", "DECISIONS.md"); const decisionsPath = path.join(tempDir, ".ciagent", "DECISIONS.md");
expect(fs.existsSync(decisionsPath)).toBe(true); expect(fs.existsSync(decisionsPath)).toBe(true);
const content = fs.readFileSync(decisionsPath, "utf-8"); const content = fs.readFileSync(decisionsPath, "utf-8");
expect(content).toContain("D-001"); expect(content).toContain("D-001");
+1 -1
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { writeFile, readFile, ensureDir } from "../utils/file.js"; import { writeFile, readFile, ensureDir } from "../utils/file.js";
const CI_DIR = ".ci"; const CI_DIR = ".ciagent";
export interface ProjectManifest { export interface ProjectManifest {
name: string; name: string;
+3 -3
View File
@@ -9,8 +9,8 @@ describe("Audit", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-audit-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-audit-test-"));
fs.mkdirSync(path.join(tempDir, ".ci", "audit"), { recursive: true }); fs.mkdirSync(path.join(tempDir, ".ciagent", "audit"), { recursive: true });
}); });
afterEach(() => { afterEach(() => {
@@ -40,7 +40,7 @@ describe("Audit", () => {
], ],
default_option_id: "A", default_option_id: "A",
resolution: "pending", resolution: "pending",
audit_file: ".ci/audit/test.json", audit_file: ".ciagent/audit/test.json",
}; };
describe("logDecision", () => { describe("logDecision", () => {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface AuditEntry {
const AUDIT_DIR = "audit"; const AUDIT_DIR = "audit";
function getAuditDir(projectPath: string): string { function getAuditDir(projectPath: string): string {
return path.join(projectPath, ".ci", AUDIT_DIR); return path.join(projectPath, ".ciagent", AUDIT_DIR);
} }
function getAuditFilePath(projectPath: string, phase: number): string { function getAuditFilePath(projectPath: string, phase: number): string {
@@ -1,17 +1,17 @@
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { CiFiles, ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "../core/ci-files.js"; import { CIAgentFiles, ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "../core/ciagent-files.js";
function createTempDir(): string { function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ci-files-test-")); return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-files-test-"));
} }
function cleanup(dir: string): void { function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} }
describe("CiFiles", () => { describe("CIAgentFiles", () => {
let dir: string; let dir: string;
beforeEach(() => { beforeEach(() => {
@@ -22,41 +22,41 @@ describe("CiFiles", () => {
cleanup(dir); cleanup(dir);
}); });
describe("ensureCIDir", () => { describe("ensureCIAgentDir", () => {
it("creates .ci directory", () => { it("creates .ciagent directory", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
expect(fs.existsSync(path.join(dir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent"))).toBe(true);
}); });
}); });
describe("isInitialized", () => { describe("isInitialized", () => {
it("returns false when no config.json exists", () => { it("returns false when no config.json exists", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.isInitialized()).toBe(false); expect(ciFiles.isInitialized()).toBe(false);
}); });
it("returns true when config.json exists", () => { it("returns true when config.json exists", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), "{}"); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), "{}");
expect(ciFiles.isInitialized()).toBe(true); expect(ciFiles.isInitialized()).toBe(true);
}); });
}); });
describe("projectSlug", () => { describe("projectSlug", () => {
it("defaults to empty string", () => { it("defaults to empty string", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.getProjectSlug()).toBe(""); expect(ciFiles.getProjectSlug()).toBe("");
}); });
it("uses provided project slug", () => { it("uses provided project slug", () => {
const ciFiles = new CiFiles(dir, "task-api"); const ciFiles = new CIAgentFiles(dir, "task-api");
expect(ciFiles.getProjectSlug()).toBe("task-api"); expect(ciFiles.getProjectSlug()).toBe("task-api");
}); });
it("setProjectSlug updates slug", () => { it("setProjectSlug updates slug", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.setProjectSlug("auth-svc"); ciFiles.setProjectSlug("auth-svc");
expect(ciFiles.getProjectSlug()).toBe("auth-svc"); expect(ciFiles.getProjectSlug()).toBe("auth-svc");
}); });
@@ -64,14 +64,14 @@ describe("CiFiles", () => {
describe("multi-project support", () => { describe("multi-project support", () => {
it("isMultiProject returns false when not initialized", () => { it("isMultiProject returns false when not initialized", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.isMultiProject()).toBe(false); expect(ciFiles.isMultiProject()).toBe(false);
}); });
it("isMultiProject returns false for single-project config", () => { it("isMultiProject returns false for single-project config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "default", name: "Default" }], projects: [{ slug: "default", name: "Default" }],
active_project: "default", active_project: "default",
})); }));
@@ -79,59 +79,59 @@ describe("CiFiles", () => {
}); });
it("isMultiProject returns false for config without projects array", () => { it("isMultiProject returns false for config without projects array", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({}));
expect(ciFiles.isMultiProject()).toBe(false); expect(ciFiles.isMultiProject()).toBe(false);
}); });
it("addProject adds a project to config", () => { it("addProject adds a project to config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [], projects: [],
active_project: "", active_project: "",
})); }));
ciFiles.addProject("task-api", "Task API", true); ciFiles.addProject("task-api", "Task API", true);
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("task-api"); expect(config.projects[0].slug).toBe("task-api");
expect(config.active_project).toBe("task-api"); expect(config.active_project).toBe("task-api");
}); });
it("addProject does not duplicate existing project", () => { it("addProject does not duplicate existing project", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "task-api", name: "Task API" }], projects: [{ slug: "task-api", name: "Task API" }],
active_project: "task-api", active_project: "task-api",
})); }));
ciFiles.addProject("task-api", "Task API V2"); ciFiles.addProject("task-api", "Task API V2");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
}); });
it("addProject creates project subdirectory", () => { it("addProject creates project subdirectory", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [], projects: [],
active_project: "", active_project: "",
})); }));
ciFiles.addProject("task-api", "Task API", true); ciFiles.addProject("task-api", "Task API", true);
expect(fs.existsSync(path.join(dir, ".ci", "task-api"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "task-api"))).toBe(true);
}); });
it("getActiveProject returns from config", () => { it("getActiveProject returns from config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "task-api", name: "Task API", default: true }], projects: [{ slug: "task-api", name: "Task API", default: true }],
active_project: "task-api", active_project: "task-api",
})); }));
@@ -140,9 +140,9 @@ describe("CiFiles", () => {
}); });
it("setActiveProject updates config", () => { it("setActiveProject updates config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [ projects: [
{ slug: "task-api", name: "Task API" }, { slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" }, { slug: "auth-svc", name: "Auth Service" },
@@ -152,14 +152,14 @@ describe("CiFiles", () => {
ciFiles.setActiveProject("auth-svc"); ciFiles.setActiveProject("auth-svc");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8"));
expect(config.active_project).toBe("auth-svc"); expect(config.active_project).toBe("auth-svc");
}); });
it("listProjects returns projects from config", () => { it("listProjects returns projects from config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [ projects: [
{ slug: "task-api", name: "Task API", default: true }, { slug: "task-api", name: "Task API", default: true },
{ slug: "auth-svc", name: "Auth Service" }, { slug: "auth-svc", name: "Auth Service" },
@@ -176,71 +176,71 @@ describe("CiFiles", () => {
describe("needsMigration", () => { describe("needsMigration", () => {
it("returns false when not initialized", () => { it("returns false when not initialized", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.needsMigration()).toBe(false); expect(ciFiles.needsMigration()).toBe(false);
}); });
it("returns false when already multi-project", () => { it("returns false when already multi-project", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "default", name: "Default" }], projects: [{ slug: "default", name: "Default" }],
})); }));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test");
expect(ciFiles.needsMigration()).toBe(false); expect(ciFiles.needsMigration()).toBe(false);
}); });
it("returns true when flat files exist without subdirs or multi-project config", () => { it("returns true when flat files exist without subdirs or multi-project config", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test");
expect(ciFiles.needsMigration()).toBe(true); expect(ciFiles.needsMigration()).toBe(true);
}); });
it("returns false when flat files exist but subdirs also exist", () => { it("returns false when flat files exist but subdirs also exist", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test"); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test");
fs.mkdirSync(path.join(dir, ".ci", "task-api")); fs.mkdirSync(path.join(dir, ".ciagent", "task-api"));
fs.writeFileSync(path.join(dir, ".ci", "task-api", "PROJECT.md"), "# Task API"); fs.writeFileSync(path.join(dir, ".ciagent", "task-api", "PROJECT.md"), "# Task API");
expect(ciFiles.needsMigration()).toBe(false); expect(ciFiles.needsMigration()).toBe(false);
}); });
}); });
describe("migrateFlatToProject", () => { describe("migrateFlatToProject", () => {
it("moves flat files to project subdirectory", () => { it("moves flat files to project subdirectory", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({}));
fs.writeFileSync(path.join(dir, ".ci", "PROJECT.md"), "# Test Project"); fs.writeFileSync(path.join(dir, ".ciagent", "PROJECT.md"), "# Test Project");
fs.writeFileSync(path.join(dir, ".ci", "ARCHITECTURE.md"), "# Architecture"); fs.writeFileSync(path.join(dir, ".ciagent", "ARCHITECTURE.md"), "# Architecture");
fs.writeFileSync(path.join(dir, ".ci", "ROADMAP.md"), "# Roadmap"); fs.writeFileSync(path.join(dir, ".ciagent", "ROADMAP.md"), "# Roadmap");
fs.writeFileSync(path.join(dir, ".ci", "REQUIREMENTS.md"), "# Requirements"); fs.writeFileSync(path.join(dir, ".ciagent", "REQUIREMENTS.md"), "# Requirements");
ciFiles.migrateFlatToProject("my-app"); ciFiles.migrateFlatToProject("my-app");
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "PROJECT.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ARCHITECTURE.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "ARCHITECTURE.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "ROADMAP.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "ROADMAP.md"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "REQUIREMENTS.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "REQUIREMENTS.md"))).toBe(true);
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("my-app"); expect(config.active_project).toBe("my-app");
}); });
it("does not migrate when not needed", () => { it("does not migrate when not needed", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "existing", name: "Existing" }], projects: [{ slug: "existing", name: "Existing" }],
})); }));
ciFiles.migrateFlatToProject("new-proj"); ciFiles.migrateFlatToProject("new-proj");
const config = JSON.parse(fs.readFileSync(path.join(dir, ".ci", "config.json"), "utf-8")); const config = JSON.parse(fs.readFileSync(path.join(dir, ".ciagent", "config.json"), "utf-8"));
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("existing"); expect(config.projects[0].slug).toBe("existing");
}); });
@@ -248,14 +248,14 @@ describe("CiFiles", () => {
describe("isNfrMilestone", () => { describe("isNfrMilestone", () => {
it("returns true when no roadmap exists", () => { it("returns true when no roadmap exists", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.isNfrMilestone()).toBe(true); expect(ciFiles.isNfrMilestone()).toBe(true);
}); });
it("returns true when phases are all NFR types", () => { it("returns true when phases are all NFR types", () => {
const ciFiles = new CiFiles(dir, "nfr-proj"); const ciFiles = new CIAgentFiles(dir, "nfr-proj");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }], projects: [{ slug: "nfr-proj", name: "NFR Project", default: true }],
active_project: "nfr-proj", active_project: "nfr-proj",
})); }));
@@ -271,9 +271,9 @@ describe("CiFiles", () => {
}); });
it("returns false when phases include feature work", () => { it("returns false when phases include feature work", () => {
const ciFiles = new CiFiles(dir, "feat-proj"); const ciFiles = new CIAgentFiles(dir, "feat-proj");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "feat-proj", name: "Feature Project", default: true }], projects: [{ slug: "feat-proj", name: "Feature Project", default: true }],
active_project: "feat-proj", active_project: "feat-proj",
})); }));
@@ -291,14 +291,14 @@ describe("CiFiles", () => {
describe("getMilestoneType", () => { describe("getMilestoneType", () => {
it("returns nfr when no roadmap exists", () => { it("returns nfr when no roadmap exists", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
expect(ciFiles.getMilestoneType()).toBe("nfr"); expect(ciFiles.getMilestoneType()).toBe("nfr");
}); });
it("returns nfr when phases are all NFR types", () => { it("returns nfr when phases are all NFR types", () => {
const ciFiles = new CiFiles(dir, "nfr-proj2"); const ciFiles = new CIAgentFiles(dir, "nfr-proj2");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }], projects: [{ slug: "nfr-proj2", name: "NFR Project 2", default: true }],
active_project: "nfr-proj2", active_project: "nfr-proj2",
})); }));
@@ -313,9 +313,9 @@ describe("CiFiles", () => {
}); });
it("returns feature when phases include feat work", () => { it("returns feature when phases include feat work", () => {
const ciFiles = new CiFiles(dir, "feat-proj2"); const ciFiles = new CIAgentFiles(dir, "feat-proj2");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }], projects: [{ slug: "feat-proj2", name: "Feature Project 2", default: true }],
active_project: "feat-proj2", active_project: "feat-proj2",
})); }));
@@ -330,9 +330,9 @@ describe("CiFiles", () => {
}); });
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => { it("returns schema-breaking when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CiFiles(dir, "schema-proj"); const ciFiles = new CIAgentFiles(dir, "schema-proj");
ciFiles.ensureProjectDir(); ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "schema-proj", name: "Schema Project", default: true }], projects: [{ slug: "schema-proj", name: "Schema Project", default: true }],
active_project: "schema-proj", active_project: "schema-proj",
})); }));
@@ -349,9 +349,9 @@ describe("CiFiles", () => {
describe("multi-project file paths", () => { describe("multi-project file paths", () => {
it("writes PROJECT.md to project subdirectory when slug is set", () => { it("writes PROJECT.md to project subdirectory when slug is set", () => {
const ciFiles = new CiFiles(dir, "my-app"); const ciFiles = new CIAgentFiles(dir, "my-app");
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({ fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
projects: [{ slug: "my-app", name: "My App", default: true }], projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app", active_project: "my-app",
})); }));
@@ -367,13 +367,13 @@ describe("CiFiles", () => {
ciFiles.writeProjectMd(project, "initial"); ciFiles.writeProjectMd(project, "initial");
expect(fs.existsSync(path.join(dir, ".ci", "my-app", "PROJECT.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "my-app", "PROJECT.md"))).toBe(true);
}); });
it("writes PROJECT.md to .ci root when no slug is set", () => { it("writes PROJECT.md to .ci root when no slug is set", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir(); ciFiles.ensureCIDir();
fs.writeFileSync(path.join(dir, ".ci", "config.json"), JSON.stringify({})); fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({}));
const project: ProjectMd = { const project: ProjectMd = {
name: "Default App", name: "Default App",
@@ -386,7 +386,7 @@ describe("CiFiles", () => {
ciFiles.writeProjectMd(project, "initial"); ciFiles.writeProjectMd(project, "initial");
expect(fs.existsSync(path.join(dir, ".ci", "PROJECT.md"))).toBe(true); expect(fs.existsSync(path.join(dir, ".ciagent", "PROJECT.md"))).toBe(true);
}); });
}); });
@@ -407,7 +407,7 @@ describe("CiFiles", () => {
}; };
it("writes and reads PROJECT.md", () => { it("writes and reads PROJECT.md", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeProjectMd(project, "initial creation"); ciFiles.writeProjectMd(project, "initial creation");
const read = ciFiles.readProjectMd(); const read = ciFiles.readProjectMd();
@@ -418,7 +418,7 @@ describe("CiFiles", () => {
}); });
it("overwrites PROJECT.md on update", () => { it("overwrites PROJECT.md on update", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeProjectMd(project, "initial"); ciFiles.writeProjectMd(project, "initial");
const updated = { ...project, coreValue: "Updated description" }; const updated = { ...project, coreValue: "Updated description" };
@@ -455,7 +455,7 @@ describe("CiFiles", () => {
}; };
it("writes and reads ROADMAP.md", () => { it("writes and reads ROADMAP.md", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeRoadmapMd(roadmap); ciFiles.writeRoadmapMd(roadmap);
const read = ciFiles.readRoadmapMd(); const read = ciFiles.readRoadmapMd();
@@ -489,7 +489,7 @@ describe("CiFiles", () => {
}; };
it("writes and reads REQUIREMENTS.md", () => { it("writes and reads REQUIREMENTS.md", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeRequirementsMd(requirements); ciFiles.writeRequirementsMd(requirements);
const read = ciFiles.readRequirementsMd(); const read = ciFiles.readRequirementsMd();
@@ -497,7 +497,7 @@ describe("CiFiles", () => {
}); });
it("updates requirement status", () => { it("updates requirement status", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeRequirementsMd(requirements); ciFiles.writeRequirementsMd(requirements);
ciFiles.updateRequirementStatus("AUTH-01", "complete"); ciFiles.updateRequirementStatus("AUTH-01", "complete");
@@ -523,7 +523,7 @@ describe("CiFiles", () => {
}; };
it("writes and reads ARCHITECTURE.md", () => { it("writes and reads ARCHITECTURE.md", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
ciFiles.writeArchitectureMd(arch); ciFiles.writeArchitectureMd(arch);
const read = ciFiles.readArchitectureMd(); const read = ciFiles.readArchitectureMd();
@@ -534,7 +534,7 @@ describe("CiFiles", () => {
describe("updatePhaseStatus", () => { describe("updatePhaseStatus", () => {
it("updates phase status in roadmap", () => { it("updates phase status in roadmap", () => {
const ciFiles = new CiFiles(dir); const ciFiles = new CIAgentFiles(dir);
const roadmap: RoadmapMd = { const roadmap: RoadmapMd = {
overview: "test", overview: "test",
phases: [ phases: [
@@ -4,7 +4,7 @@ import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineStage } from "../types/pipeline.js"; import { PipelineStage } from "../types/pipeline.js";
import { MilestoneType } from "../types/config.js"; import { MilestoneType } from "../types/config.js";
const CI_DIR = ".ci"; const CI_DIR = ".ciagent";
export interface ProjectMd { export interface ProjectMd {
name: string; name: string;
@@ -71,7 +71,7 @@ export interface ProjectEntry {
default?: boolean; default?: boolean;
} }
export class CiFiles { export class CIAgentFiles {
private projectPath: string; private projectPath: string;
private projectSlug: string; private projectSlug: string;
+18 -18
View File
@@ -2,15 +2,15 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { ClarifyPhase, saveSpecification, loadSpecification } from "../core/clarify.js"; import { ClarifyPhase, saveSpecification, loadSpecification } from "../core/clarify.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { Specification, parseSpecification } from "../types/specification.js"; import { Specification, parseSpecification } from "../types/specification.js";
describe("ClarifyPhase", () => { describe("ClarifyPhase", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-clarify-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-clarify-test-"));
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true }); fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
}); });
afterEach(() => { afterEach(() => {
@@ -41,7 +41,7 @@ describe("ClarifyPhase", () => {
describe("generateQuestions", () => { describe("generateQuestions", () => {
it("generates questions for missing requirements", () => { it("generates questions for missing requirements", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
expect(questions.length).toBeGreaterThan(0); expect(questions.length).toBeGreaterThan(0);
const reqQuestion = questions.find((q) => q.category === "requirements"); const reqQuestion = questions.find((q) => q.category === "requirements");
@@ -50,7 +50,7 @@ describe("ClarifyPhase", () => {
}); });
it("generates questions for missing constraints", () => { it("generates questions for missing constraints", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
const constraintQuestion = questions.find((q) => q.category === "constraints"); const constraintQuestion = questions.find((q) => q.category === "constraints");
expect(constraintQuestion).toBeDefined(); expect(constraintQuestion).toBeDefined();
@@ -58,7 +58,7 @@ describe("ClarifyPhase", () => {
}); });
it("generates deployment question when deploy is mentioned without deploy constraint", () => { it("generates deployment question when deploy is mentioned without deploy constraint", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithRequirements); const questions = clarify.generateQuestions(specWithRequirements);
const deployQuestion = questions.find((q) => q.category === "deployment"); const deployQuestion = questions.find((q) => q.category === "deployment");
expect(deployQuestion).toBeDefined(); expect(deployQuestion).toBeDefined();
@@ -66,8 +66,8 @@ describe("ClarifyPhase", () => {
it("respects clarify_budget", () => { it("respects clarify_budget", () => {
const limitedConfig = { const limitedConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, clarify_budget: 1 }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, clarify_budget: 1 },
}; };
const clarify = new ClarifyPhase(limitedConfig, tempDir); const clarify = new ClarifyPhase(limitedConfig, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
@@ -75,7 +75,7 @@ describe("ClarifyPhase", () => {
}); });
it("assigns sequential question IDs", () => { it("assigns sequential question IDs", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
expect(questions[i].id).toBe(`Q-${String(i + 1).padStart(3, "0")}`); expect(questions[i].id).toBe(`Q-${String(i + 1).padStart(3, "0")}`);
@@ -83,7 +83,7 @@ describe("ClarifyPhase", () => {
}); });
it("sorts questions by impact priority", () => { it("sorts questions by impact priority", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
for (let i = 1; i < questions.length; i++) { for (let i = 1; i < questions.length; i++) {
@@ -96,7 +96,7 @@ describe("ClarifyPhase", () => {
describe("answerQuestion", () => { describe("answerQuestion", () => {
it("records an answer to a question", () => { it("records an answer to a question", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
expect(questions.length).toBeGreaterThan(0); expect(questions.length).toBeGreaterThan(0);
@@ -107,7 +107,7 @@ describe("ClarifyPhase", () => {
}); });
it("returns null for unknown question ID", () => { it("returns null for unknown question ID", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const result = clarify.answerQuestion("Q-999", "answer"); const result = clarify.answerQuestion("Q-999", "answer");
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@@ -115,7 +115,7 @@ describe("ClarifyPhase", () => {
describe("acceptDefaults", () => { describe("acceptDefaults", () => {
it("accepts defaults for all unanswered questions", () => { it("accepts defaults for all unanswered questions", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
clarify.generateQuestions(specWithoutRequirements); clarify.generateQuestions(specWithoutRequirements);
const result = clarify.acceptDefaults(); const result = clarify.acceptDefaults();
@@ -125,7 +125,7 @@ describe("ClarifyPhase", () => {
}); });
it("preserves manually answered questions", () => { it("preserves manually answered questions", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
const questions = clarify.generateQuestions(specWithoutRequirements); const questions = clarify.generateQuestions(specWithoutRequirements);
if (questions.length > 0) { if (questions.length > 0) {
clarify.answerQuestion(questions[0].id, "My answer"); clarify.answerQuestion(questions[0].id, "My answer");
@@ -138,11 +138,11 @@ describe("ClarifyPhase", () => {
}); });
it("saves clarify responses file", () => { it("saves clarify responses file", () => {
const clarify = new ClarifyPhase(DEFAULT_CI_CONFIG, tempDir); const clarify = new ClarifyPhase(DEFAULT_CIAGENT_CONFIG, tempDir);
clarify.generateQuestions(specWithoutRequirements); clarify.generateQuestions(specWithoutRequirements);
clarify.acceptDefaults(); clarify.acceptDefaults();
const responsesPath = path.join(tempDir, ".ci", "clarify-responses.md"); const responsesPath = path.join(tempDir, ".ciagent", "clarify-responses.md");
expect(fs.existsSync(responsesPath)).toBe(true); expect(fs.existsSync(responsesPath)).toBe(true);
const content = fs.readFileSync(responsesPath, "utf-8"); const content = fs.readFileSync(responsesPath, "utf-8");
expect(content).toContain("Clarify Phase Responses"); expect(content).toContain("Clarify Phase Responses");
@@ -154,8 +154,8 @@ describe("saveSpecification / loadSpecification", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-spec-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-spec-test-"));
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true }); fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
}); });
afterEach(() => { afterEach(() => {
+4 -4
View File
@@ -2,22 +2,22 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { ClarifyQuestion, ClarifyResult } from "../types/clarify.js"; import { ClarifyQuestion, ClarifyResult } from "../types/clarify.js";
import { Specification, parseSpecification } from "../types/specification.js"; import { Specification, parseSpecification } from "../types/specification.js";
import { CIConfig } from "../types/config.js"; import { CIAgentConfig } from "../types/config.js";
const CLARIFY_RESPONSES_FILE = "clarify-responses.md"; const CLARIFY_RESPONSES_FILE = "clarify-responses.md";
const SPECIFICATION_FILE = "specification.md"; const SPECIFICATION_FILE = "specification.md";
function getCIDir(projectPath: string): string { function getCIDir(projectPath: string): string {
return path.join(projectPath, ".ci"); return path.join(projectPath, ".ciagent");
} }
export class ClarifyPhase { export class ClarifyPhase {
private config: CIConfig; private config: CIAgentConfig;
private projectPath: string; private projectPath: string;
private questions: ClarifyQuestion[]; private questions: ClarifyQuestion[];
private questionCounter: number; private questionCounter: number;
constructor(config: CIConfig, projectPath: string) { constructor(config: CIAgentConfig, projectPath: string) {
this.config = config; this.config = config;
this.projectPath = projectPath; this.projectPath = projectPath;
this.questions = []; this.questions = [];
+22 -22
View File
@@ -1,11 +1,11 @@
import { CommitBuilder } from "../core/commit-builder.js"; import { CommitBuilder } from "../core/commit-builder.js";
import { extractCiBlock, parseCiBlock } from "../core/commit-parser.js"; import { extractCIAgentBlock, parseCIAgentBlock } from "../core/commit-parser.js";
import { CiMetadata } from "../types/commit-meta.js"; import { CIAgentMetadata } from "../types/commit-meta.js";
describe("CommitBuilder", () => { describe("CommitBuilder", () => {
describe("buildCiBlock", () => { describe("buildCiBlock", () => {
it("builds minimal ci block", () => { it("builds minimal ci block", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" }; const ci: CIAgentMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
expect(block).toContain("phase: 1"); expect(block).toContain("phase: 1");
@@ -14,19 +14,19 @@ describe("CommitBuilder", () => {
}); });
it("builds ci block with project", () => { it("builds ci block with project", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" }; const ci: CIAgentMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
expect(block).toContain("project: task-api"); expect(block).toContain("project: task-api");
}); });
it("builds ci block without project when not set", () => { it("builds ci block without project when not set", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" }; const ci: CIAgentMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
expect(block).not.toContain("project:"); expect(block).not.toContain("project:");
}); });
it("builds ci block with decisions", () => { it("builds ci block with decisions", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 1, phase: 1,
milestone: "v1.0", milestone: "v1.0",
status: "execute", status: "execute",
@@ -49,7 +49,7 @@ describe("CommitBuilder", () => {
}); });
it("builds ci block with lessons", () => { it("builds ci block with lessons", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 1, phase: 1,
milestone: "v1.0", milestone: "v1.0",
status: "complete", status: "complete",
@@ -63,7 +63,7 @@ describe("CommitBuilder", () => {
}); });
it("builds ci block with compound", () => { it("builds ci block with compound", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 1, phase: 1,
milestone: "v1.0", milestone: "v1.0",
status: "complete", status: "complete",
@@ -82,7 +82,7 @@ describe("CommitBuilder", () => {
}); });
it("builds ci block with escalations", () => { it("builds ci block with escalations", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 3, phase: 3,
milestone: "v1.0", milestone: "v1.0",
status: "execute", status: "execute",
@@ -103,7 +103,7 @@ describe("CommitBuilder", () => {
}); });
it("builds ci block with requirements", () => { it("builds ci block with requirements", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 1, phase: 1,
milestone: "v1.0", milestone: "v1.0",
status: "complete", status: "complete",
@@ -122,12 +122,12 @@ describe("CommitBuilder", () => {
describe("round-trip: build then parse", () => { describe("round-trip: build then parse", () => {
it("round-trips a simple ci block", () => { it("round-trips a simple ci block", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute" }; const ci: CIAgentMetadata = { phase: 1, milestone: "v1.0", status: "execute" };
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---\n\nBody text`; const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---\n\nBody text`;
const extracted = extractCiBlock(fullMessage)!; const extracted = extractCIAgentBlock(fullMessage)!;
const parsed = parseCiBlock(extracted)!; const parsed = parseCIAgentBlock(extracted)!;
expect(parsed.phase).toBe(1); expect(parsed.phase).toBe(1);
expect(parsed.milestone).toBe("v1.0"); expect(parsed.milestone).toBe("v1.0");
@@ -135,7 +135,7 @@ describe("CommitBuilder", () => {
}); });
it("round-trips decisions", () => { it("round-trips decisions", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 1, phase: 1,
milestone: "v1.0", milestone: "v1.0",
status: "execute", status: "execute",
@@ -152,8 +152,8 @@ describe("CommitBuilder", () => {
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---`; const fullMessage = `feat(P01): test\n\n---ci---\n${block}\n---/ci---`;
const extracted = extractCiBlock(fullMessage)!; const extracted = extractCIAgentBlock(fullMessage)!;
const parsed = parseCiBlock(extracted)!; const parsed = parseCIAgentBlock(extracted)!;
expect(parsed.decisions).toHaveLength(1); expect(parsed.decisions).toHaveLength(1);
expect(parsed.decisions![0].id).toBe("D-001"); expect(parsed.decisions![0].id).toBe("D-001");
@@ -163,7 +163,7 @@ describe("CommitBuilder", () => {
}); });
it("round-trips compound with lessons", () => { it("round-trips compound with lessons", () => {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 2, phase: 2,
milestone: "v1.0", milestone: "v1.0",
status: "complete", status: "complete",
@@ -177,8 +177,8 @@ describe("CommitBuilder", () => {
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
const fullMessage = `compound(P02): test\n\n---ci---\n${block}\n---/ci---`; const fullMessage = `compound(P02): test\n\n---ci---\n${block}\n---/ci---`;
const extracted = extractCiBlock(fullMessage)!; const extracted = extractCIAgentBlock(fullMessage)!;
const parsed = parseCiBlock(extracted)!; const parsed = parseCIAgentBlock(extracted)!;
expect(parsed.compound!.category).toBe("auth"); expect(parsed.compound!.category).toBe("auth");
expect(parsed.compound!.problem).toBe("Token replay attacks"); expect(parsed.compound!.problem).toBe("Token replay attacks");
@@ -186,11 +186,11 @@ describe("CommitBuilder", () => {
}); });
it("round-trips project field", () => { it("round-trips project field", () => {
const ci: CiMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" }; const ci: CIAgentMetadata = { phase: 1, milestone: "v1.0", status: "execute", project: "task-api" };
const block = CommitBuilder.buildCiBlock(ci); const block = CommitBuilder.buildCiBlock(ci);
const fullMessage = `feat(task-api/P01): test\n\n---ci---\n${block}\n---/ci---`; const fullMessage = `feat(task-api/P01): test\n\n---ci---\n${block}\n---/ci---`;
const extracted = extractCiBlock(fullMessage)!; const extracted = extractCIAgentBlock(fullMessage)!;
const parsed = parseCiBlock(extracted)!; const parsed = parseCIAgentBlock(extracted)!;
expect(parsed.project).toBe("task-api"); expect(parsed.project).toBe("task-api");
}); });
+11 -11
View File
@@ -1,5 +1,5 @@
import { import {
CiMetadata, CIAgentMetadata,
CommitType, CommitType,
CommitScope, CommitScope,
CommitDecision, CommitDecision,
@@ -17,7 +17,7 @@ export interface CommitMessageInput {
type: CommitType; type: CommitType;
scope: CommitScope; scope: CommitScope;
subject: string; subject: string;
ci: CiMetadata; ci: CIAgentMetadata;
body?: string; body?: string;
} }
@@ -92,7 +92,7 @@ export interface VerifyCommitInput {
} }
export class CommitBuilder { export class CommitBuilder {
static buildCiBlock(ci: CiMetadata): string { static buildCiBlock(ci: CIAgentMetadata): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`phase: ${ci.phase}`); lines.push(`phase: ${ci.phase}`);
lines.push(`milestone: ${ci.milestone}`); lines.push(`milestone: ${ci.milestone}`);
@@ -162,7 +162,7 @@ export class CommitBuilder {
} }
static buildInitCommit(input: InitCommitInput): string { static buildInitCommit(input: InitCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: 0, phase: 0,
milestone: input.milestone, milestone: input.milestone,
project: input.project, project: input.project,
@@ -194,7 +194,7 @@ export class CommitBuilder {
} }
static buildTaskCommit(input: TaskCommitInput): string { static buildTaskCommit(input: TaskCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
project: input.project, project: input.project,
@@ -224,7 +224,7 @@ export class CommitBuilder {
} }
static buildPhaseCompletionCommit(input: PhaseCompletionInput): string { static buildPhaseCompletionCommit(input: PhaseCompletionInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
status: "complete", status: "complete",
@@ -253,7 +253,7 @@ export class CommitBuilder {
} }
static buildDecisionCommit(input: DecisionCommitInput): string { static buildDecisionCommit(input: DecisionCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
status: "plan", status: "plan",
@@ -271,7 +271,7 @@ export class CommitBuilder {
} }
static buildEscalationCommit(input: EscalationCommitInput): string { static buildEscalationCommit(input: EscalationCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
status: "execute", status: "execute",
@@ -289,7 +289,7 @@ export class CommitBuilder {
} }
static buildCompoundCommit(input: CompoundCommitInput): string { static buildCompoundCommit(input: CompoundCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
status: "complete", status: "complete",
@@ -313,7 +313,7 @@ export class CommitBuilder {
} }
static buildVerifyCommit(input: VerifyCommitInput): string { static buildVerifyCommit(input: VerifyCommitInput): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase: input.phase, phase: input.phase,
milestone: input.milestone, milestone: input.milestone,
status: "verify", status: "verify",
@@ -338,7 +338,7 @@ export class CommitBuilder {
findings: string[], findings: string[],
decisions?: CommitDecision[] decisions?: CommitDecision[]
): string { ): string {
const ci: CiMetadata = { const ci: CIAgentMetadata = {
phase, phase,
milestone, milestone,
status: "research", status: "research",
+22 -22
View File
@@ -1,5 +1,5 @@
import { import {
CiMetadata, CIAgentMetadata,
CommitDecision, CommitDecision,
CommitEscalation, CommitEscalation,
CommitRequirements, CommitRequirements,
@@ -9,8 +9,8 @@ import {
CommitScope, CommitScope,
} from "../types/commit-meta.js"; } from "../types/commit-meta.js";
import { import {
extractCiBlock, extractCIAgentBlock,
parseCiBlock, parseCIAgentBlock,
parseCommitMessage, parseCommitMessage,
} from "./commit-parser.js"; } from "./commit-parser.js";
@@ -128,29 +128,29 @@ status: execute
Registration endpoint for task-api project.`; Registration endpoint for task-api project.`;
describe("extractCiBlock", () => { describe("extractCIAgentBlock", () => {
it("extracts ---ci--- block from commit message", () => { it("extracts ---ci--- block from commit message", () => {
const block = extractCiBlock(SAMPLE_INIT_COMMIT); const block = extractCIAgentBlock(SAMPLE_INIT_COMMIT);
expect(block).toBeTruthy(); expect(block).toBeTruthy();
expect(block).toContain("phase: 0"); expect(block).toContain("phase: 0");
expect(block).toContain("milestone: v1.0"); expect(block).toContain("milestone: v1.0");
}); });
it("returns null when no ---ci--- block exists", () => { it("returns null when no ---ci--- block exists", () => {
const block = extractCiBlock("docs: some regular commit\n\nNo CI block here"); const block = extractCIAgentBlock("docs: some regular commit\n\nNo CI block here");
expect(block).toBeNull(); expect(block).toBeNull();
}); });
it("returns null for unclosed ---ci--- block", () => { it("returns null for unclosed ---ci--- block", () => {
const block = extractCiBlock("docs: bad\n---ci---\nphase: 1\nno end marker"); const block = extractCIAgentBlock("docs: bad\n---ci---\nphase: 1\nno end marker");
expect(block).toBeNull(); expect(block).toBeNull();
}); });
}); });
describe("parseCiBlock", () => { describe("parseCIAgentBlock", () => {
it("parses init commit ci block", () => { it("parses init commit ci block", () => {
const block = extractCiBlock(SAMPLE_INIT_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_INIT_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.phase).toBe(0); expect(meta.phase).toBe(0);
expect(meta.milestone).toBe("v1.0"); expect(meta.milestone).toBe("v1.0");
@@ -163,8 +163,8 @@ describe("parseCiBlock", () => {
}); });
it("parses task commit ci block", () => { it("parses task commit ci block", () => {
const block = extractCiBlock(SAMPLE_TASK_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_TASK_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.phase).toBe(1); expect(meta.phase).toBe(1);
expect(meta.plan).toBe("01-01"); expect(meta.plan).toBe("01-01");
@@ -177,8 +177,8 @@ describe("parseCiBlock", () => {
}); });
it("parses phase completion with lessons", () => { it("parses phase completion with lessons", () => {
const block = extractCiBlock(SAMPLE_PHASE_COMPLETE_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_PHASE_COMPLETE_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.phase).toBe(1); expect(meta.phase).toBe(1);
expect(meta.status).toBe("complete"); expect(meta.status).toBe("complete");
@@ -188,8 +188,8 @@ describe("parseCiBlock", () => {
}); });
it("parses compound commit", () => { it("parses compound commit", () => {
const block = extractCiBlock(SAMPLE_COMPOUND_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_COMPOUND_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.compound).toBeDefined(); expect(meta.compound).toBeDefined();
expect(meta.compound!.category).toBe("auth"); expect(meta.compound!.category).toBe("auth");
@@ -199,8 +199,8 @@ describe("parseCiBlock", () => {
}); });
it("parses escalation commit", () => { it("parses escalation commit", () => {
const block = extractCiBlock(SAMPLE_ESCALATION_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_ESCALATION_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.escalations).toHaveLength(1); expect(meta.escalations).toHaveLength(1);
expect(meta.escalations![0].id).toBe("E-001"); expect(meta.escalations![0].id).toBe("E-001");
@@ -209,20 +209,20 @@ describe("parseCiBlock", () => {
}); });
it("parses project field", () => { it("parses project field", () => {
const block = extractCiBlock(SAMPLE_PROJECT_COMMIT)!; const block = extractCIAgentBlock(SAMPLE_PROJECT_COMMIT)!;
const meta = parseCiBlock(block)!; const meta = parseCIAgentBlock(block)!;
expect(meta.project).toBe("task-api"); expect(meta.project).toBe("task-api");
expect(meta.phase).toBe(1); expect(meta.phase).toBe(1);
expect(meta.plan).toBe("01-01"); expect(meta.plan).toBe("01-01");
}); });
it("returns null for empty block", () => { it("returns null for empty block", () => {
const meta = parseCiBlock(""); const meta = parseCIAgentBlock("");
expect(meta).toBeNull(); expect(meta).toBeNull();
}); });
it("returns null for block missing required fields", () => { it("returns null for block missing required fields", () => {
const meta = parseCiBlock("something: true\nother: false"); const meta = parseCIAgentBlock("something: true\nother: false");
expect(meta).toBeNull(); expect(meta).toBeNull();
}); });
}); });
+17 -17
View File
@@ -1,8 +1,8 @@
import { import {
CiMetadata, CIAgentMetadata,
CommitType, CommitType,
CommitEscalation, CommitEscalation,
ParsedCiCommit, ParsedCIAgentCommit,
parseCommitType, parseCommitType,
parseCommitScope, parseCommitScope,
} from "../types/commit-meta.js"; } from "../types/commit-meta.js";
@@ -10,7 +10,7 @@ import {
const CI_BLOCK_START = "---ci---"; const CI_BLOCK_START = "---ci---";
const CI_BLOCK_END = "---/ci---"; const CI_BLOCK_END = "---/ci---";
export function extractCiBlock(message: string): string | null { export function extractCIAgentBlock(message: string): string | null {
const startIdx = message.indexOf(CI_BLOCK_START); const startIdx = message.indexOf(CI_BLOCK_START);
if (startIdx < 0) return null; if (startIdx < 0) return null;
@@ -20,10 +20,10 @@ export function extractCiBlock(message: string): string | null {
return message.slice(startIdx + CI_BLOCK_START.length, endIdx).trim(); return message.slice(startIdx + CI_BLOCK_START.length, endIdx).trim();
} }
export function parseCiBlock(yaml: string): CiMetadata | null { export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
if (!yaml) return null; if (!yaml) return null;
const result: Partial<CiMetadata> = {}; const result: Partial<CIAgentMetadata> = {};
const phaseMatch = yaml.match(/^phase:\s*(.+)$/m); const phaseMatch = yaml.match(/^phase:\s*(.+)$/m);
if (phaseMatch) result.phase = parseInt(phaseMatch[1], 10) || 0; if (phaseMatch) result.phase = parseInt(phaseMatch[1], 10) || 0;
@@ -38,7 +38,7 @@ export function parseCiBlock(yaml: string): CiMetadata | null {
if (taskMatch) result.task = taskMatch[1].trim(); if (taskMatch) result.task = taskMatch[1].trim();
const statusMatch = yaml.match(/^status:\s*(.+)$/m); const statusMatch = yaml.match(/^status:\s*(.+)$/m);
if (statusMatch) result.status = statusMatch[1].trim() as CiMetadata["status"]; if (statusMatch) result.status = statusMatch[1].trim() as CIAgentMetadata["status"];
const projectMatch = yaml.match(/^project:\s*(.+)$/m); const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim(); if (projectMatch) result.project = projectMatch[1].trim();
@@ -50,14 +50,14 @@ export function parseCiBlock(yaml: string): CiMetadata | null {
result.compound = parseCompoundFromYaml(yaml); result.compound = parseCompoundFromYaml(yaml);
if (result.phase !== undefined && result.milestone !== undefined && result.status !== undefined) { if (result.phase !== undefined && result.milestone !== undefined && result.status !== undefined) {
return result as CiMetadata; return result as CIAgentMetadata;
} }
return null; return null;
} }
function parseDecisionsFromYaml(yaml: string): CiMetadata["decisions"] { function parseDecisionsFromYaml(yaml: string): CIAgentMetadata["decisions"] {
const decisions: NonNullable<CiMetadata["decisions"]> = []; const decisions: NonNullable<CIAgentMetadata["decisions"]> = [];
const decisionRegex = /- id: (.+)\n\s+decision: (.+)\n\s+rationale: (.+)\n\s+confidence: (.+)\n\s+alternatives: \[([^\]]*)\]/g; const decisionRegex = /- id: (.+)\n\s+decision: (.+)\n\s+rationale: (.+)\n\s+confidence: (.+)\n\s+alternatives: \[([^\]]*)\]/g;
let match; let match;
@@ -74,8 +74,8 @@ function parseDecisionsFromYaml(yaml: string): CiMetadata["decisions"] {
return decisions.length > 0 ? decisions : undefined; return decisions.length > 0 ? decisions : undefined;
} }
function parseEscalationsFromYaml(yaml: string): CiMetadata["escalations"] { function parseEscalationsFromYaml(yaml: string): CIAgentMetadata["escalations"] {
const escalations: NonNullable<CiMetadata["escalations"]> = []; const escalations: NonNullable<CIAgentMetadata["escalations"]> = [];
const escalationRegex = /- id: (.+)\n\s+type: (.+)\n\s+description: (.+)\n\s+resolution: (.+)/g; const escalationRegex = /- id: (.+)\n\s+type: (.+)\n\s+description: (.+)\n\s+resolution: (.+)/g;
let match; let match;
@@ -91,7 +91,7 @@ function parseEscalationsFromYaml(yaml: string): CiMetadata["escalations"] {
return escalations.length > 0 ? escalations : undefined; return escalations.length > 0 ? escalations : undefined;
} }
function parseRequirementsFromYaml(yaml: string): CiMetadata["requirements"] { function parseRequirementsFromYaml(yaml: string): CIAgentMetadata["requirements"] {
const coveredMatch = yaml.match(/^\s+covered: \[([^\]]*)\]/m); const coveredMatch = yaml.match(/^\s+covered: \[([^\]]*)\]/m);
const partialMatch = yaml.match(/^\s+partial: \[([^\]]*)\]/m); const partialMatch = yaml.match(/^\s+partial: \[([^\]]*)\]/m);
@@ -106,7 +106,7 @@ function parseRequirementsFromYaml(yaml: string): CiMetadata["requirements"] {
return { covered, partial }; return { covered, partial };
} }
function parseLessonsFromYaml(yaml: string): CiMetadata["lessons"] { function parseLessonsFromYaml(yaml: string): CIAgentMetadata["lessons"] {
const lessonRegex = /^ - (.+)$/gm; const lessonRegex = /^ - (.+)$/gm;
const lessons: string[] = []; const lessons: string[] = [];
let inLessonsSection = false; let inLessonsSection = false;
@@ -126,7 +126,7 @@ function parseLessonsFromYaml(yaml: string): CiMetadata["lessons"] {
return lessons.length > 0 ? lessons : undefined; return lessons.length > 0 ? lessons : undefined;
} }
function parseCompoundFromYaml(yaml: string): CiMetadata["compound"] { function parseCompoundFromYaml(yaml: string): CIAgentMetadata["compound"] {
const categoryMatch = yaml.match(/^\s+category: (.+)$/m); const categoryMatch = yaml.match(/^\s+category: (.+)$/m);
const problemMatch = yaml.match(/^\s+problem: (.+)$/m); const problemMatch = yaml.match(/^\s+problem: (.+)$/m);
const solutionMatch = yaml.match(/^\s+solution: (.+)$/m); const solutionMatch = yaml.match(/^\s+solution: (.+)$/m);
@@ -143,7 +143,7 @@ function parseCompoundFromYaml(yaml: string): CiMetadata["compound"] {
export function parseCommitMessage( export function parseCommitMessage(
hash: string, hash: string,
message: string message: string
): ParsedCiCommit { ): ParsedCIAgentCommit {
const firstLine = message.split("\n")[0] || ""; const firstLine = message.split("\n")[0] || "";
const subjectMatch = firstLine.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/); const subjectMatch = firstLine.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
@@ -157,8 +157,8 @@ export function parseCommitMessage(
subject = subjectMatch[3] || firstLine; subject = subjectMatch[3] || firstLine;
} }
const ciBlock = extractCiBlock(message); const ciBlock = extractCIAgentBlock(message);
const ci = ciBlock ? parseCiBlock(ciBlock) : null; const ci = ciBlock ? parseCIAgentBlock(ciBlock) : null;
const bodyStart = message.indexOf("\n"); const bodyStart = message.indexOf("\n");
let body = bodyStart >= 0 ? message.slice(bodyStart + 1).trim() : ""; let body = bodyStart >= 0 ? message.slice(bodyStart + 1).trim() : "";
+37 -37
View File
@@ -1,45 +1,45 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { initCI, loadConfig, saveConfig, isCIInitialized, ensureCIDir } from "../core/config.js"; import { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized, ensureCIDir } from "../core/config.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("CI Config", () => { describe("CIAgent Config", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-config-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-config-test-"));
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
}); });
describe("initCI", () => { describe("initCIAgent", () => {
it("initializes a new CI project with default config", () => { it("initializes a new CIAgent project with default config", () => {
const config = initCI(tempDir); const config = initCIAgent(tempDir);
expect(config.autonomy.level).toBe("full"); expect(config.autonomy.level).toBe("full");
expect(isCIInitialized(tempDir)).toBe(true); expect(isCIAgentInitialized(tempDir)).toBe(true);
}); });
it("initializes with custom config merged on top of defaults", () => { it("initializes with custom config merged on top of defaults", () => {
const config = initCI(tempDir, { const config = initCIAgent(tempDir, {
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "guided" }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, level: "guided" },
}); });
expect(config.autonomy.level).toBe("guided"); expect(config.autonomy.level).toBe("guided");
expect(config.autonomy.clarify_budget).toBe(10); expect(config.autonomy.clarify_budget).toBe(10);
expect(config.model_profile).toBe("quality"); expect(config.model_profile).toBe("quality");
}); });
it("creates .ci/ directory structure", () => { it("creates .ciagent/ directory structure", () => {
initCI(tempDir); initCIAgent(tempDir);
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ci", "config.json"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent", "config.json"))).toBe(true);
}); });
it("deep merges nested config", () => { it("deep merges nested config", () => {
const config = initCI(tempDir, { const config = initCIAgent(tempDir, {
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "supervised" }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, level: "supervised" },
}); });
expect(config.autonomy.level).toBe("supervised"); expect(config.autonomy.level).toBe("supervised");
expect(config.autonomy.max_revision_iterations).toBe(3); expect(config.autonomy.max_revision_iterations).toBe(3);
@@ -47,7 +47,7 @@ describe("CI Config", () => {
}); });
it("initializes with project slug", () => { it("initializes with project slug", () => {
const config = initCI(tempDir, undefined, "task-api", "Task API"); const config = initCIAgent(tempDir, undefined, "task-api", "Task API");
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
expect(config.projects[0].slug).toBe("task-api"); expect(config.projects[0].slug).toBe("task-api");
expect(config.projects[0].name).toBe("Task API"); expect(config.projects[0].name).toBe("Task API");
@@ -56,20 +56,20 @@ describe("CI Config", () => {
}); });
it("does not re-add existing project slug", () => { it("does not re-add existing project slug", () => {
initCI(tempDir, undefined, "task-api", "Task API"); initCIAgent(tempDir, undefined, "task-api", "Task API");
const config = initCI(tempDir, undefined, "task-api", "Task API V2"); const config = initCIAgent(tempDir, undefined, "task-api", "Task API V2");
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
}); });
it("defaults projects and active_project when no slug provided", () => { it("defaults projects and active_project when no slug provided", () => {
const config = initCI(tempDir); const config = initCIAgent(tempDir);
expect(config.projects).toEqual([]); expect(config.projects).toEqual([]);
expect(config.active_project).toBe(""); expect(config.active_project).toBe("");
}); });
it("preserves existing projects when adding new one", () => { it("preserves existing projects when adding new one", () => {
const config1 = initCI(tempDir, undefined, "task-api", "Task API"); const config1 = initCIAgent(tempDir, undefined, "task-api", "Task API");
const config2 = initCI(tempDir, { const config2 = initCIAgent(tempDir, {
...config1, ...config1,
projects: [...config1.projects, { slug: "auth-svc", name: "Auth Service" }], projects: [...config1.projects, { slug: "auth-svc", name: "Auth Service" }],
}, "auth-svc", "Auth Service"); }, "auth-svc", "Auth Service");
@@ -81,11 +81,11 @@ describe("CI Config", () => {
describe("loadConfig", () => { describe("loadConfig", () => {
it("returns default config when no config file exists", () => { it("returns default config when no config file exists", () => {
const config = loadConfig(tempDir); const config = loadConfig(tempDir);
expect(config).toEqual(DEFAULT_CI_CONFIG); expect(config).toEqual(DEFAULT_CIAGENT_CONFIG);
}); });
it("loads and deep merges config from file", () => { it("loads and deep merges config from file", () => {
initCI(tempDir, { autonomy: { ...DEFAULT_CI_CONFIG.autonomy, decision_confidence_threshold: 0.85 } }); initCIAgent(tempDir, { autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, decision_confidence_threshold: 0.85 } });
const config = loadConfig(tempDir); const config = loadConfig(tempDir);
expect(config.autonomy.decision_confidence_threshold).toBe(0.85); expect(config.autonomy.decision_confidence_threshold).toBe(0.85);
expect(config.autonomy.level).toBe("full"); expect(config.autonomy.level).toBe("full");
@@ -93,7 +93,7 @@ describe("CI Config", () => {
}); });
it("preserves nested objects that are not overridden", () => { it("preserves nested objects that are not overridden", () => {
initCI(tempDir, { git: { ...DEFAULT_CI_CONFIG.git, auto_push: true } }); initCIAgent(tempDir, { git: { ...DEFAULT_CIAGENT_CONFIG.git, auto_push: true } });
const config = loadConfig(tempDir); const config = loadConfig(tempDir);
expect(config.git.auto_push).toBe(true); expect(config.git.auto_push).toBe(true);
expect(config.git.auto_commit).toBe(true); expect(config.git.auto_commit).toBe(true);
@@ -101,7 +101,7 @@ describe("CI Config", () => {
}); });
it("loads projects array from config", () => { it("loads projects array from config", () => {
initCI(tempDir, undefined, "task-api", "Task API"); initCIAgent(tempDir, undefined, "task-api", "Task API");
const config = loadConfig(tempDir); const config = loadConfig(tempDir);
expect(config.projects).toHaveLength(1); expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("task-api"); expect(config.active_project).toBe("task-api");
@@ -112,8 +112,8 @@ describe("CI Config", () => {
it("saves and reloads config correctly", () => { it("saves and reloads config correctly", () => {
ensureCIDir(tempDir); ensureCIDir(tempDir);
const customConfig = { const customConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level: "guided" as const }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, level: "guided" as const },
}; };
saveConfig(tempDir, customConfig); saveConfig(tempDir, customConfig);
const loaded = loadConfig(tempDir); const loaded = loadConfig(tempDir);
@@ -123,7 +123,7 @@ describe("CI Config", () => {
it("saves and reloads config with projects", () => { it("saves and reloads config with projects", () => {
ensureCIDir(tempDir); ensureCIDir(tempDir);
const config = { const config = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "my-app", name: "My App", default: true }], projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app", active_project: "my-app",
}; };
@@ -134,27 +134,27 @@ describe("CI Config", () => {
}); });
}); });
describe("isCIInitialized", () => { describe("isCIAgentInitialized", () => {
it("returns false for uninitialized directory", () => { it("returns false for uninitialized directory", () => {
expect(isCIInitialized(tempDir)).toBe(false); expect(isCIAgentInitialized(tempDir)).toBe(false);
}); });
it("returns true after initCI", () => { it("returns true after initCIAgent", () => {
initCI(tempDir); initCIAgent(tempDir);
expect(isCIInitialized(tempDir)).toBe(true); expect(isCIAgentInitialized(tempDir)).toBe(true);
}); });
}); });
describe("ensureCIDir", () => { describe("ensureCIDir", () => {
it("creates .ci directory", () => { it("creates .ciagent directory", () => {
ensureCIDir(tempDir); ensureCIDir(tempDir);
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
}); });
it("is idempotent", () => { it("is idempotent", () => {
ensureCIDir(tempDir); ensureCIDir(tempDir);
ensureCIDir(tempDir); ensureCIDir(tempDir);
expect(fs.existsSync(path.join(tempDir, ".ci"))).toBe(true); expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
}); });
}); });
}); });
+25 -25
View File
@@ -1,11 +1,11 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { CIConfig, DEFAULT_CI_CONFIG } from "../types/config.js"; import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
const CI_DIR = ".ci"; const CI_DIR = ".ciagent";
const CONFIG_FILE = "config.json"; const CONFIG_FILE = "config.json";
export function getCIConfigPath(projectPath: string): string { export function getCIAgentConfigPath(projectPath: string): string {
return path.join(projectPath, CI_DIR, CONFIG_FILE); return path.join(projectPath, CI_DIR, CONFIG_FILE);
} }
@@ -20,7 +20,7 @@ export function ensureCIDir(projectPath: string): void {
} }
} }
function deepMerge(base: CIConfig, override: Record<string, unknown>): CIConfig { function deepMerge(base: CIAgentConfig, override: Record<string, unknown>): CIAgentConfig {
const result = { ...base } as Record<string, unknown>; const result = { ...base } as Record<string, unknown>;
for (const key of Object.keys(override)) { for (const key of Object.keys(override)) {
const baseVal = result[key]; const baseVal = result[key];
@@ -30,43 +30,43 @@ function deepMerge(base: CIConfig, override: Record<string, unknown>): CIConfig
overrideVal && typeof overrideVal === "object" && !Array.isArray(overrideVal) overrideVal && typeof overrideVal === "object" && !Array.isArray(overrideVal)
) { ) {
result[key] = deepMerge( result[key] = deepMerge(
baseVal as unknown as CIConfig, baseVal as unknown as CIAgentConfig,
overrideVal as Record<string, unknown> overrideVal as Record<string, unknown>
) as unknown; ) as unknown;
} else if (overrideVal !== undefined) { } else if (overrideVal !== undefined) {
result[key] = overrideVal; result[key] = overrideVal;
} }
} }
return result as unknown as CIConfig; return result as unknown as CIAgentConfig;
} }
export function loadConfig(projectPath: string): CIConfig { export function loadConfig(projectPath: string): CIAgentConfig {
const configPath = getCIConfigPath(projectPath); const configPath = getCIAgentConfigPath(projectPath);
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
return { ...DEFAULT_CI_CONFIG }; return { ...DEFAULT_CIAGENT_CONFIG };
} }
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return deepMerge(DEFAULT_CI_CONFIG, parsed); return deepMerge(DEFAULT_CIAGENT_CONFIG, parsed);
} }
export function saveConfig(projectPath: string, config: CIConfig): void { export function saveConfig(projectPath: string, config: CIAgentConfig): void {
ensureCIDir(projectPath); ensureCIDir(projectPath);
const configPath = getCIConfigPath(projectPath); const configPath = getCIAgentConfigPath(projectPath);
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
} }
export function isCIInitialized(projectPath: string): boolean { export function isCIAgentInitialized(projectPath: string): boolean {
const ciDir = getCIDir(projectPath); const ciDir = getCIDir(projectPath);
const configPath = getCIConfigPath(projectPath); const configPath = getCIAgentConfigPath(projectPath);
return fs.existsSync(ciDir) && fs.existsSync(configPath); return fs.existsSync(ciDir) && fs.existsSync(configPath);
} }
export function initCI(projectPath: string, config?: Partial<CIConfig>, projectSlug?: string, projectName?: string): CIConfig { export function initCIAgent(projectPath: string, config?: Partial<CIAgentConfig>, projectSlug?: string, projectName?: string): CIAgentConfig {
ensureCIDir(projectPath); ensureCIDir(projectPath);
let projects = config?.projects || DEFAULT_CI_CONFIG.projects; let projects = config?.projects || DEFAULT_CIAGENT_CONFIG.projects;
let activeProject = config?.active_project || DEFAULT_CI_CONFIG.active_project; let activeProject = config?.active_project || DEFAULT_CIAGENT_CONFIG.active_project;
if (projectSlug) { if (projectSlug) {
if (!projects.some((p) => p.slug === projectSlug)) { if (!projects.some((p) => p.slug === projectSlug)) {
@@ -75,20 +75,20 @@ export function initCI(projectPath: string, config?: Partial<CIConfig>, projectS
activeProject = projectSlug; activeProject = projectSlug;
} }
const fullConfig: CIConfig = { const fullConfig: CIAgentConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
...config, ...config,
projects, projects,
active_project: activeProject, active_project: activeProject,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, ...config?.autonomy }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, ...config?.autonomy },
parallelization: { parallelization: {
...DEFAULT_CI_CONFIG.parallelization, ...DEFAULT_CIAGENT_CONFIG.parallelization,
...config?.parallelization, ...config?.parallelization,
}, },
verification: { ...DEFAULT_CI_CONFIG.verification, ...config?.verification }, verification: { ...DEFAULT_CIAGENT_CONFIG.verification, ...config?.verification },
security: { ...DEFAULT_CI_CONFIG.security, ...config?.security }, security: { ...DEFAULT_CIAGENT_CONFIG.security, ...config?.security },
git: { ...DEFAULT_CI_CONFIG.git, ...config?.git }, git: { ...DEFAULT_CIAGENT_CONFIG.git, ...config?.git },
backend: { ...DEFAULT_CI_CONFIG.backend, ...config?.backend }, backend: { ...DEFAULT_CIAGENT_CONFIG.backend, ...config?.backend },
}; };
saveConfig(projectPath, fullConfig); saveConfig(projectPath, fullConfig);
return fullConfig; return fullConfig;
+5 -5
View File
@@ -2,15 +2,15 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { DecisionEngine, DecisionInput } from "../core/decision-engine.js"; import { DecisionEngine, DecisionInput } from "../core/decision-engine.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("DecisionEngine", () => { describe("DecisionEngine", () => {
let tempDir: string; let tempDir: string;
let engine: DecisionEngine; let engine: DecisionEngine;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-decision-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-decision-test-"));
engine = new DecisionEngine(DEFAULT_CI_CONFIG, tempDir); engine = new DecisionEngine(DEFAULT_CIAGENT_CONFIG, tempDir);
}); });
afterEach(() => { afterEach(() => {
@@ -106,8 +106,8 @@ describe("DecisionEngine", () => {
it("escalates if threshold is raised above 0.7", () => { it("escalates if threshold is raised above 0.7", () => {
const strictConfig = { const strictConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, decision_confidence_threshold: 0.8 }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, decision_confidence_threshold: 0.8 },
}; };
const strictEngine = new DecisionEngine(strictConfig, tempDir); const strictEngine = new DecisionEngine(strictConfig, tempDir);
const result = strictEngine.makeMediumConfidenceDecision( const result = strictEngine.makeMediumConfidenceDecision(
+3 -3
View File
@@ -1,6 +1,6 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { Decision, DecisionCategory, Alternative, confidenceToLevel } from "../types/decisions.js"; import { Decision, DecisionCategory, Alternative, confidenceToLevel } from "../types/decisions.js";
import { CIConfig } from "../types/config.js"; import { CIAgentConfig } from "../types/config.js";
import { CommitBuilder, DecisionCommitInput } from "./commit-builder.js"; import { CommitBuilder, DecisionCommitInput } from "./commit-builder.js";
import { CommitDecision } from "../types/commit-meta.js"; import { CommitDecision } from "../types/commit-meta.js";
@@ -22,13 +22,13 @@ export interface DecisionResult {
} }
export class DecisionEngine { export class DecisionEngine {
private config: CIConfig; private config: CIAgentConfig;
private projectPath: string; private projectPath: string;
private currentPhase: number; private currentPhase: number;
private currentMilestone: string; private currentMilestone: string;
private decisionCounter: number; private decisionCounter: number;
constructor(config: CIConfig, projectPath: string, milestone: string = "v1.0") { constructor(config: CIAgentConfig, projectPath: string, milestone: string = "v1.0") {
this.config = config; this.config = config;
this.projectPath = projectPath; this.projectPath = projectPath;
this.currentPhase = 0; this.currentPhase = 0;
+4 -4
View File
@@ -2,16 +2,16 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { ErrorRecovery } from "../core/error-recovery.js"; import { ErrorRecovery } from "../core/error-recovery.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("ErrorRecovery", () => { describe("ErrorRecovery", () => {
let tempDir: string; let tempDir: string;
let recovery: ErrorRecovery; let recovery: ErrorRecovery;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-recovery-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-recovery-test-"));
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true }); fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
recovery = new ErrorRecovery(DEFAULT_CI_CONFIG, tempDir); recovery = new ErrorRecovery(DEFAULT_CIAGENT_CONFIG, tempDir);
}); });
afterEach(() => { afterEach(() => {
+3 -3
View File
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { CIConfig } from "../types/config.js"; import { CIAgentConfig } from "../types/config.js";
export interface RetryConfig { export interface RetryConfig {
max_retries: number; max_retries: number;
@@ -15,11 +15,11 @@ export interface RecoveryResult {
} }
export class ErrorRecovery { export class ErrorRecovery {
private config: CIConfig; private config: CIAgentConfig;
private projectPath: string; private projectPath: string;
private revisionCount: number; private revisionCount: number;
constructor(config: CIConfig, projectPath: string) { constructor(config: CIAgentConfig, projectPath: string) {
this.config = config; this.config = config;
this.projectPath = projectPath; this.projectPath = projectPath;
this.revisionCount = 0; this.revisionCount = 0;
+4 -4
View File
@@ -2,17 +2,17 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { EscalationProtocol, EscalationInput } from "../core/escalation.js"; import { EscalationProtocol, EscalationInput } from "../core/escalation.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("EscalationProtocol", () => { describe("EscalationProtocol", () => {
let tempDir: string; let tempDir: string;
let protocol: EscalationProtocol; let protocol: EscalationProtocol;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-escalation-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-escalation-test-"));
const noAutoCommitConfig = { const noAutoCommitConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
git: { ...DEFAULT_CI_CONFIG.git, auto_commit: false }, git: { ...DEFAULT_CIAGENT_CONFIG.git, auto_commit: false },
}; };
protocol = new EscalationProtocol(noAutoCommitConfig, tempDir); protocol = new EscalationProtocol(noAutoCommitConfig, tempDir);
}); });
+25 -4
View File
@@ -6,7 +6,7 @@ import {
EscalationResolution, EscalationResolution,
ESCALATION_TYPES, ESCALATION_TYPES,
} from "../types/escalation.js"; } from "../types/escalation.js";
import { CIConfig } from "../types/config.js"; import { CIAgentConfig } from "../types/config.js";
import { CommitBuilder, EscalationCommitInput } from "./commit-builder.js"; import { CommitBuilder, EscalationCommitInput } from "./commit-builder.js";
import { CommitEscalation } from "../types/commit-meta.js"; import { CommitEscalation } from "../types/commit-meta.js";
@@ -22,16 +22,17 @@ export interface EscalationInput {
} }
export class EscalationProtocol { export class EscalationProtocol {
private config: CIConfig; private config: CIAgentConfig;
private projectPath: string; private projectPath: string;
private currentMilestone: string; private currentMilestone: string;
private counter: number; private counter: number;
private pendingEscalations: Map<string, Escalation>; private pendingEscalations: Map<string, Escalation>;
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void; private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
private timers: NodeJS.Timeout[]; private timers: NodeJS.Timeout[];
private timerEscalationMap: Map<NodeJS.Timeout, string>;
constructor( constructor(
config: CIConfig, config: CIAgentConfig,
projectPath: string, projectPath: string,
milestone: string = "v1.0", milestone: string = "v1.0",
timeoutCallback: (escalation: Escalation, chosenOption: string) => void = () => {} timeoutCallback: (escalation: Escalation, chosenOption: string) => void = () => {}
@@ -43,6 +44,7 @@ export class EscalationProtocol {
this.pendingEscalations = new Map(); this.pendingEscalations = new Map();
this.timeoutCallback = timeoutCallback; this.timeoutCallback = timeoutCallback;
this.timers = []; this.timers = [];
this.timerEscalationMap = new Map();
} }
setMilestone(milestone: string): void { setMilestone(milestone: string): void {
@@ -64,7 +66,7 @@ export class EscalationProtocol {
options: input.options, options: input.options,
default_option_id: input.default_option_id, default_option_id: input.default_option_id,
resolution: "pending", resolution: "pending",
audit_file: `.ci/audit/deprecated`, audit_file: `.ciagent/audit/deprecated`,
}; };
this.pendingEscalations.set(id, escalation); this.pendingEscalations.set(id, escalation);
@@ -102,6 +104,16 @@ export class EscalationProtocol {
const escalation = this.pendingEscalations.get(escalationId); const escalation = this.pendingEscalations.get(escalationId);
if (!escalation) return null; if (!escalation) return null;
for (let i = this.timers.length - 1; i >= 0; i--) {
const timer = this.timers[i];
const mappedId = this.timerEscalationMap.get(timer);
if (mappedId === escalationId) {
clearTimeout(timer);
this.timerEscalationMap.delete(timer);
this.timers.splice(i, 1);
}
}
escalation.resolution = resolution; escalation.resolution = resolution;
escalation.resolved_at = new Date().toISOString(); escalation.resolved_at = new Date().toISOString();
escalation.resolution_detail = `Chose option: ${chosenOptionId}`; escalation.resolution_detail = `Chose option: ${chosenOptionId}`;
@@ -139,11 +151,16 @@ export class EscalationProtocol {
clearAllTimers(): void { clearAllTimers(): void {
for (const timer of this.timers) { for (const timer of this.timers) {
clearTimeout(timer); clearTimeout(timer);
this.timerEscalationMap.delete(timer);
} }
this.timers = []; this.timers = [];
this.pendingEscalations.clear(); this.pendingEscalations.clear();
} }
dispose(): void {
this.clearAllTimers();
}
formatEscalation(escalation: Escalation): string { formatEscalation(escalation: Escalation): string {
const lines: string[] = [ const lines: string[] = [
`⚠️ ESCALATION [${escalation.id}]`, `⚠️ ESCALATION [${escalation.id}]`,
@@ -200,9 +217,13 @@ export class EscalationProtocol {
escalation.resolved_at = new Date().toISOString(); escalation.resolved_at = new Date().toISOString();
escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`; escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`;
this.pendingEscalations.delete(escalation.id); this.pendingEscalations.delete(escalation.id);
this.timerEscalationMap.delete(timer);
const idx = this.timers.indexOf(timer);
if (idx >= 0) this.timers.splice(idx, 1);
this.timeoutCallback(escalation, escalation.default_option_id); this.timeoutCallback(escalation, escalation.default_option_id);
} }
}, timeout); }, timeout);
this.timers.push(timer); this.timers.push(timer);
this.timerEscalationMap.set(timer, escalation.id);
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
import { GitBranch } from "../core/git-branch.js"; import { GitBranch } from "../core/git-branch.js";
function createTempRepo(): string { function createTempRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-branch-test-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-branch-test-"));
execSync("git init", { cwd: dir, stdio: "pipe" }); execSync("git init", { cwd: dir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { 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" }); execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
+2 -2
View File
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
import { GitContext } from "../core/git-context.js"; import { GitContext } from "../core/git-context.js";
function createTempRepo(): string { function createTempRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-test-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-test-"));
execSync("git init", { cwd: dir, stdio: "pipe" }); execSync("git init", { cwd: dir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { 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" }); execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
@@ -41,7 +41,7 @@ describe("GitContext", () => {
}); });
it("returns false for non-git directory", () => { it("returns false for non-git directory", () => {
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), "ci-nongit-")); const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-nongit-"));
const ctx = new GitContext(nonGit); const ctx = new GitContext(nonGit);
expect(ctx.isGitRepo()).toBe(false); expect(ctx.isGitRepo()).toBe(false);
cleanup(nonGit); cleanup(nonGit);
+10 -10
View File
@@ -1,7 +1,7 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { import {
ParsedCiCommit, ParsedCIAgentCommit,
CiMetadata, CIAgentMetadata,
CommitDecision, CommitDecision,
} from "../types/commit-meta.js"; } from "../types/commit-meta.js";
import { parseCommitMessage } from "./commit-parser.js"; import { parseCommitMessage } from "./commit-parser.js";
@@ -16,7 +16,7 @@ export interface ProjectState {
phasesCompleted: number[]; phasesCompleted: number[];
phaseBranches: BranchInfo[]; phaseBranches: BranchInfo[];
milestoneBranches: string[]; milestoneBranches: string[];
lastCommit: ParsedCiCommit | null; lastCommit: ParsedCIAgentCommit | null;
} }
export interface BranchInfo { export interface BranchInfo {
@@ -69,13 +69,13 @@ export class GitContext {
return this.git("rev-parse --abbrev-ref HEAD"); return this.git("rev-parse --abbrev-ref HEAD");
} }
getRecentCommits(count: number = 20): ParsedCiCommit[] { getRecentCommits(count: number = 20): ParsedCIAgentCommit[] {
const format = "%H%x00%s%x00%B%x01"; const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log --max-count=${count} --format="${format}"`); const raw = this.git(`log --max-count=${count} --format="${format}"`);
if (!raw) return []; if (!raw) return [];
const commits: ParsedCiCommit[] = []; const commits: ParsedCIAgentCommit[] = [];
const entries = raw.split("\x01").filter(Boolean); const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) { for (const entry of entries) {
@@ -93,7 +93,7 @@ export class GitContext {
return commits; return commits;
} }
getLatestCiCommit(): ParsedCiCommit | null { getLatestCiCommit(): ParsedCIAgentCommit | null {
const commits = this.getRecentCommits(1); const commits = this.getRecentCommits(1);
return commits.length > 0 ? commits[0] : null; return commits.length > 0 ? commits[0] : null;
} }
@@ -207,7 +207,7 @@ export class GitContext {
return decisions; return decisions;
} }
getDecisionsFromCommits(commits: ParsedCiCommit[], phase?: number): CommitDecision[] { getDecisionsFromCommits(commits: ParsedCIAgentCommit[], phase?: number): CommitDecision[] {
const decisions: CommitDecision[] = []; const decisions: CommitDecision[] = [];
for (const commit of commits) { for (const commit of commits) {
if (commit.ci?.decisions) { if (commit.ci?.decisions) {
@@ -300,20 +300,20 @@ export class GitContext {
}; };
} }
getCommitsForPhase(phase: number): ParsedCiCommit[] { getCommitsForPhase(phase: number): ParsedCIAgentCommit[] {
const commits = this.getRecentCommits(200); const commits = this.getRecentCommits(200);
return commits.filter( return commits.filter(
(c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase (c) => c.scope === `P${String(phase).padStart(2, "0")}` || c.ci?.phase === phase
); );
} }
getCommitsForBranch(branch: string): ParsedCiCommit[] { getCommitsForBranch(branch: string): ParsedCIAgentCommit[] {
const format = "%H%x00%s%x00%B%x01"; const format = "%H%x00%s%x00%B%x01";
const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`); const raw = this.git(`log ${branch} --max-count=100 --format="${format}"`);
if (!raw) return []; if (!raw) return [];
const commits: ParsedCiCommit[] = []; const commits: ParsedCIAgentCommit[] = [];
const entries = raw.split("\x01").filter(Boolean); const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) { for (const entry of entries) {
+170
View File
@@ -0,0 +1,170 @@
import { execSync } from "node:child_process";
export interface GiteaReleaseConfig {
baseUrl: string;
token: string;
owner: string;
repo: string;
}
export interface GiteaRelease {
id: number;
tag_name: string;
name: string;
body: string;
url: string;
html_url: string;
draft: boolean;
prerelease: boolean;
}
export class GiteaClient {
private config: GiteaReleaseConfig;
constructor(config: GiteaReleaseConfig) {
this.config = config;
}
async createRelease(params: {
tag_name: string;
name: string;
body: string;
draft?: boolean;
prerelease?: boolean;
}): Promise<GiteaRelease> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `token ${this.config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
tag_name: params.tag_name,
name: params.name,
body: params.body,
draft: params.draft ?? false,
prerelease: params.prerelease ?? false,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gitea API error: ${response.status} ${text}`);
}
return response.json() as Promise<GiteaRelease>;
}
async listReleases(): Promise<GiteaRelease[]> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": `token ${this.config.token}`,
},
});
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`);
}
return response.json() as Promise<GiteaRelease[]>;
}
async getReleaseByTag(tag: string): Promise<GiteaRelease | null> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases/tags/${tag}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": `token ${this.config.token}`,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`);
}
return response.json() as Promise<GiteaRelease>;
}
}
export function generateReleaseNotes(projectPath: string, fromTag: string | null, toTag: string): string {
let gitLogCmd: string;
if (fromTag) {
gitLogCmd = `git log ${fromTag}..${toTag} --oneline`;
} else {
gitLogCmd = `git log ${toTag} --oneline`;
}
let logOutput: string;
try {
logOutput = execSync(gitLogCmd, { cwd: projectPath, encoding: "utf-8" }).trim();
} catch {
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
}
if (!logOutput) {
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
}
const lines = logOutput.split("\n").filter(Boolean);
const featCommits: string[] = [];
const fixCommits: string[] = [];
const testCommits: string[] = [];
const otherCommits: string[] = [];
for (const line of lines) {
const subject = line.replace(/^[a-f0-9]+\s+/, "");
if (/^feat/i.test(subject)) {
featCommits.push(subject);
} else if (/^fix/i.test(subject)) {
fixCommits.push(subject);
} else if (/^test/i.test(subject)) {
testCommits.push(subject);
} else {
otherCommits.push(subject);
}
}
const sections: string[] = [];
if (featCommits.length > 0) {
sections.push("### New Features\n");
for (const c of featCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (fixCommits.length > 0) {
sections.push("### Bug Fixes\n");
for (const c of fixCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (testCommits.length > 0) {
sections.push("### Tests\n");
for (const c of testCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (otherCommits.length > 0) {
sections.push("### Other Changes\n");
for (const c of otherCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
return `## What's Changed\n\n${sections.join("\n")}`;
}
+7 -5
View File
@@ -1,12 +1,14 @@
export { initCI, loadConfig, saveConfig, isCIInitialized, getCIConfigPath, getCIDir, ensureCIDir } from "./config.js"; export { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized, getCIAgentConfigPath, getCIDir, ensureCIDir } from "./config.js";
export { DecisionEngine } from "./decision-engine.js"; export { DecisionEngine } from "./decision-engine.js";
export { EscalationProtocol } from "./escalation.js"; export { EscalationProtocol } from "./escalation.js";
export { ClarifyPhase } from "./clarify.js"; export { ClarifyPhase } from "./clarify.js";
export { CiFiles } from "./ci-files.js"; export { CIAgentFiles } from "./ciagent-files.js";
export { ErrorRecovery } from "./error-recovery.js"; export { ErrorRecovery } from "./error-recovery.js";
export { GitContext } from "./git-context.js"; export { GitContext } from "./git-context.js";
export { GitBranch } from "./git-branch.js"; export { GitBranch } from "./git-branch.js";
export { CommitBuilder } from "./commit-builder.js"; export { CommitBuilder } from "./commit-builder.js";
export { extractCiBlock, parseCiBlock, parseCommitMessage } from "./commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
export type { CIConfig } from "../types/config.js"; export { GiteaClient, generateReleaseNotes } from "./gitea.js";
export { DEFAULT_CI_CONFIG } from "../types/config.js"; export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+11 -7
View File
@@ -2,20 +2,23 @@ export { OrchestratorAgent } from "./agents/orchestrator.js";
export { DecisionEngine } from "./core/decision-engine.js"; export { DecisionEngine } from "./core/decision-engine.js";
export { EscalationProtocol } from "./core/escalation.js"; export { EscalationProtocol } from "./core/escalation.js";
export { ClarifyPhase } from "./core/clarify.js"; export { ClarifyPhase } from "./core/clarify.js";
export { CiFiles } from "./core/ci-files.js"; export { CIAgentFiles } from "./core/ciagent-files.js";
export { ErrorRecovery } from "./core/error-recovery.js"; export { ErrorRecovery } from "./core/error-recovery.js";
export { GitContext } from "./core/git-context.js"; export { GitContext } from "./core/git-context.js";
export { GitBranch } from "./core/git-branch.js"; export { GitBranch } from "./core/git-branch.js";
export { CommitBuilder } from "./core/commit-builder.js"; export { CommitBuilder } from "./core/commit-builder.js";
export { extractCiBlock, parseCiBlock, parseCommitMessage } from "./core/commit-parser.js"; export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
export { VerificationPipeline } from "./verification/index.js"; export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js"; export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js"; export { BehavioralVerification } from "./verification/behavioral.js";
export { SecurityVerification } from "./verification/security.js"; export { SecurityVerification } from "./verification/security.js";
export { QualityVerification } from "./verification/quality.js"; export { QualityVerification } from "./verification/quality.js";
export { getAgent, getAvailableAgents } from "./agents/index.js"; export { getAgent, getAvailableAgents } from "./agents/index.js";
export { initCI, loadConfig, saveConfig, isCIInitialized } from "./core/config.js"; export type { PlannerResult } from "./agents/planner.js";
export { DEFAULT_CI_CONFIG } from "./types/config.js"; export type { ExecutorResult } from "./agents/executor.js";
export { initCIAgent, loadConfig, saveConfig, isCIAgentInitialized } from "./core/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "./types/config.js";
export { confidenceToLevel, shouldEscalate } from "./types/decisions.js"; export { confidenceToLevel, shouldEscalate } from "./types/decisions.js";
export { ESCALATION_TYPES } from "./types/escalation.js"; export { ESCALATION_TYPES } from "./types/escalation.js";
export { createClarifyQuestion } from "./types/clarify.js"; export { createClarifyQuestion } from "./types/clarify.js";
@@ -28,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js";
export { OllamaCloudBackend } from "./backends/ollama-cloud.js"; export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
export { ToolRegistry } from "./backends/tool-registry.js"; export { ToolRegistry } from "./backends/tool-registry.js";
export type { CIConfig, AutonomyLevel, ModelProfile } from "./types/config.js"; export type { CIAgentConfig, AutonomyLevel, ModelProfile, GiteaConfig } from "./types/config.js";
export type { Decision, DecisionCategory } from "./types/decisions.js"; export type { Decision, DecisionCategory } from "./types/decisions.js";
export type { Escalation, EscalationType } from "./types/escalation.js"; export type { Escalation, EscalationType } from "./types/escalation.js";
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js"; export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
@@ -38,9 +41,10 @@ export type { AgentContext, AgentResult } from "./agents/base.js";
export type { LayeredVerificationResult } from "./verification/index.js"; export type { LayeredVerificationResult } from "./verification/index.js";
export type { VerificationResult, VerificationCheck } from "./verification/types.js"; export type { VerificationResult, VerificationCheck } from "./verification/types.js";
export type { AgentName } from "./types/config.js"; export type { AgentName } from "./types/config.js";
export type { CiMetadata, ParsedCiCommit, CommitType, CommitScope, CommitDecision, CommitEscalation, CommitRequirements, CommitCompoundMeta } from "./types/commit-meta.js"; export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, CommitDecision, CommitEscalation, CommitRequirements, CommitCompoundMeta } from "./types/commit-meta.js";
export type { ProjectState, BranchInfo } from "./core/git-context.js"; export type { ProjectState, BranchInfo } from "./core/git-context.js";
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js"; export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ci-files.js"; export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js"; export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js"; export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
+3 -3
View File
@@ -51,7 +51,7 @@ export interface CommitCompoundMeta {
solution: string; solution: string;
} }
export interface CiMetadata { export interface CIAgentMetadata {
phase: number; phase: number;
milestone: string; milestone: string;
project?: string; project?: string;
@@ -65,12 +65,12 @@ export interface CiMetadata {
compound?: CommitCompoundMeta; compound?: CommitCompoundMeta;
} }
export interface ParsedCiCommit { export interface ParsedCIAgentCommit {
hash: string; hash: string;
type: CommitType; type: CommitType;
scope: string; scope: string;
subject: string; subject: string;
ci: CiMetadata | null; ci: CIAgentMetadata | null;
body: string; body: string;
} }
+32 -32
View File
@@ -1,33 +1,33 @@
import { CIConfig, DEFAULT_CI_CONFIG, AutonomyLevel, ModelProfile, ProjectEntry } from "../types/config.js"; import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG, AutonomyLevel, ModelProfile, ProjectEntry } from "../types/config.js";
describe("CIConfig", () => { describe("CIAgentConfig", () => {
it("DEFAULT_CI_CONFIG has all required fields", () => { it("DEFAULT_CIAGENT_CONFIG has all required fields", () => {
expect(DEFAULT_CI_CONFIG.autonomy.level).toBe("full"); expect(DEFAULT_CIAGENT_CONFIG.autonomy.level).toBe("full");
expect(DEFAULT_CI_CONFIG.autonomy.clarify_budget).toBe(10); expect(DEFAULT_CIAGENT_CONFIG.autonomy.clarify_budget).toBe(10);
expect(DEFAULT_CI_CONFIG.autonomy.decision_confidence_threshold).toBe(0.6); expect(DEFAULT_CIAGENT_CONFIG.autonomy.decision_confidence_threshold).toBe(0.6);
expect(DEFAULT_CI_CONFIG.autonomy.max_revision_iterations).toBe(3); expect(DEFAULT_CIAGENT_CONFIG.autonomy.max_revision_iterations).toBe(3);
expect(DEFAULT_CI_CONFIG.autonomy.max_verification_retries).toBe(2); expect(DEFAULT_CIAGENT_CONFIG.autonomy.max_verification_retries).toBe(2);
expect(DEFAULT_CI_CONFIG.autonomy.escalation_timeout_ms).toBe(300000); expect(DEFAULT_CIAGENT_CONFIG.autonomy.escalation_timeout_ms).toBe(300000);
expect(DEFAULT_CI_CONFIG.autonomy.escalation_hooks).toContain("deploy"); expect(DEFAULT_CIAGENT_CONFIG.autonomy.escalation_hooks).toContain("deploy");
expect(DEFAULT_CI_CONFIG.model_profile).toBe("quality"); expect(DEFAULT_CIAGENT_CONFIG.model_profile).toBe("quality");
expect(DEFAULT_CI_CONFIG.parallelization.enabled).toBe(true); expect(DEFAULT_CIAGENT_CONFIG.parallelization.enabled).toBe(true);
expect(DEFAULT_CI_CONFIG.verification.automated_only).toBe(true); expect(DEFAULT_CIAGENT_CONFIG.verification.automated_only).toBe(true);
expect(DEFAULT_CI_CONFIG.security.auto_accept_low_severity).toBe(true); expect(DEFAULT_CIAGENT_CONFIG.security.auto_accept_low_severity).toBe(true);
expect(DEFAULT_CI_CONFIG.git.auto_commit).toBe(true); expect(DEFAULT_CIAGENT_CONFIG.git.auto_commit).toBe(true);
expect(DEFAULT_CI_CONFIG.git.auto_push).toBe(false); expect(DEFAULT_CIAGENT_CONFIG.git.auto_push).toBe(false);
}); });
it("DEFAULT_CI_CONFIG has multi-project fields", () => { it("DEFAULT_CIAGENT_CONFIG has multi-project fields", () => {
expect(DEFAULT_CI_CONFIG.projects).toEqual([]); expect(DEFAULT_CIAGENT_CONFIG.projects).toEqual([]);
expect(DEFAULT_CI_CONFIG.active_project).toBe(""); expect(DEFAULT_CIAGENT_CONFIG.active_project).toBe("");
}); });
it("AutonomyLevel accepts all valid levels", () => { it("AutonomyLevel accepts all valid levels", () => {
const levels: AutonomyLevel[] = ["full", "supervised", "guided"]; const levels: AutonomyLevel[] = ["full", "supervised", "guided"];
for (const level of levels) { for (const level of levels) {
const config: CIConfig = { const config: CIAgentConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
autonomy: { ...DEFAULT_CI_CONFIG.autonomy, level }, autonomy: { ...DEFAULT_CIAGENT_CONFIG.autonomy, level },
}; };
expect(config.autonomy.level).toBe(level); expect(config.autonomy.level).toBe(level);
} }
@@ -36,8 +36,8 @@ describe("CIConfig", () => {
it("ModelProfile accepts all valid profiles", () => { it("ModelProfile accepts all valid profiles", () => {
const profiles: ModelProfile[] = ["quality", "speed", "balanced"]; const profiles: ModelProfile[] = ["quality", "speed", "balanced"];
for (const profile of profiles) { for (const profile of profiles) {
const config: CIConfig = { const config: CIAgentConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
model_profile: profile, model_profile: profile,
}; };
expect(config.model_profile).toBe(profile); expect(config.model_profile).toBe(profile);
@@ -45,7 +45,7 @@ describe("CIConfig", () => {
}); });
it("escalation_hooks defaults include expected items", () => { it("escalation_hooks defaults include expected items", () => {
expect(DEFAULT_CI_CONFIG.autonomy.escalation_hooks).toEqual([ expect(DEFAULT_CIAGENT_CONFIG.autonomy.escalation_hooks).toEqual([
"deploy", "deploy",
"delete_data", "delete_data",
"merge_to_main", "merge_to_main",
@@ -66,10 +66,10 @@ describe("CIConfig", () => {
}); });
}); });
describe("CIConfig with projects", () => { describe("CIAgentConfig with projects", () => {
it("supports multiple projects", () => { it("supports multiple projects", () => {
const config: CIConfig = { const config: CIAgentConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
projects: [ projects: [
{ slug: "task-api", name: "Task API", default: true }, { slug: "task-api", name: "Task API", default: true },
{ slug: "auth-svc", name: "Auth Service" }, { slug: "auth-svc", name: "Auth Service" },
@@ -82,8 +82,8 @@ describe("CIConfig", () => {
}); });
it("supports single project", () => { it("supports single project", () => {
const config: CIConfig = { const config: CIAgentConfig = {
...DEFAULT_CI_CONFIG, ...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "my-app", name: "My App", default: true }], projects: [{ slug: "my-app", name: "My App", default: true }],
active_project: "my-app", active_project: "my-app",
}; };
@@ -92,8 +92,8 @@ describe("CIConfig", () => {
}); });
it("defaults to empty projects array and empty active_project", () => { it("defaults to empty projects array and empty active_project", () => {
expect(DEFAULT_CI_CONFIG.projects).toEqual([]); expect(DEFAULT_CIAGENT_CONFIG.projects).toEqual([]);
expect(DEFAULT_CI_CONFIG.active_project).toBe(""); expect(DEFAULT_CIAGENT_CONFIG.active_project).toBe("");
}); });
}); });
}); });
+18 -3
View File
@@ -28,7 +28,8 @@ export type AgentName =
| "plan-checker" | "plan-checker"
| "project-researcher" | "project-researcher"
| "research-synthesizer" | "research-synthesizer"
| "solution-writer"; | "solution-writer"
| "tester";
export interface AutonomyConfig { export interface AutonomyConfig {
level: AutonomyLevel; level: AutonomyLevel;
@@ -65,13 +66,20 @@ export interface GitConfig {
auto_push: boolean; auto_push: boolean;
} }
export interface GiteaConfig {
base_url: string;
api_token_env: string;
owner: string;
repo: string;
}
export interface ProjectEntry { export interface ProjectEntry {
slug: string; slug: string;
name: string; name: string;
default?: boolean; default?: boolean;
} }
export interface CIConfig { export interface CIAgentConfig {
projects: ProjectEntry[]; projects: ProjectEntry[];
active_project: string; active_project: string;
autonomy: AutonomyConfig; autonomy: AutonomyConfig;
@@ -81,9 +89,10 @@ export interface CIConfig {
security: SecurityConfig; security: SecurityConfig;
git: GitConfig; git: GitConfig;
backend: BackendConfigSection; backend: BackendConfigSection;
gitea?: GiteaConfig;
} }
export const DEFAULT_CI_CONFIG: CIConfig = { export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [], projects: [],
active_project: "", active_project: "",
autonomy: { autonomy: {
@@ -135,4 +144,10 @@ export const DEFAULT_CI_CONFIG: CIConfig = {
}, },
}, },
}, },
gitea: {
base_url: "https://git.cloudinit.dev",
api_token_env: "GITEA_TOKEN",
owner: "",
repo: "",
},
}; };
+3 -3
View File
@@ -3,11 +3,11 @@ import { confidenceToLevel, shouldEscalate } from "../types/decisions.js";
import { ESCALATION_TYPES } from "../types/escalation.js"; import { ESCALATION_TYPES } from "../types/escalation.js";
import { parseSpecification } from "../types/specification.js"; import { parseSpecification } from "../types/specification.js";
import { createClarifyQuestion } from "../types/clarify.js"; import { createClarifyQuestion } from "../types/clarify.js";
import { DEFAULT_CI_CONFIG } from "../types/config.js"; import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
describe("Type exports", () => { describe("Type exports", () => {
it("pipeline types are importable and functional", () => { it("pipeline types are importable and functional", () => {
expect(STAGE_ORDER).toHaveLength(7); expect(STAGE_ORDER).toHaveLength(8);
expect(getNextStage("specify")).toBe("clarify"); expect(getNextStage("specify")).toBe("clarify");
const state = createInitialPipelineState("/tmp/test"); const state = createInitialPipelineState("/tmp/test");
expect(state.current_stage).toBe("specify"); expect(state.current_stage).toBe("specify");
@@ -40,6 +40,6 @@ describe("Type exports", () => {
}); });
it("config defaults are importable", () => { it("config defaults are importable", () => {
expect(DEFAULT_CI_CONFIG.autonomy.level).toBe("full"); expect(DEFAULT_CIAGENT_CONFIG.autonomy.level).toBe("full");
}); });
}); });
+5 -2
View File
@@ -8,13 +8,14 @@ import {
} from "../types/pipeline.js"; } from "../types/pipeline.js";
describe("STAGE_ORDER", () => { describe("STAGE_ORDER", () => {
it("has 7 stages in correct order", () => { it("has 8 stages in correct order", () => {
expect(STAGE_ORDER).toEqual([ expect(STAGE_ORDER).toEqual([
"specify", "specify",
"clarify", "clarify",
"research", "research",
"plan", "plan",
"execute", "execute",
"test",
"verify", "verify",
"complete", "complete",
]); ]);
@@ -27,7 +28,8 @@ describe("getNextStage", () => {
expect(getNextStage("clarify")).toBe("research"); expect(getNextStage("clarify")).toBe("research");
expect(getNextStage("research")).toBe("plan"); expect(getNextStage("research")).toBe("plan");
expect(getNextStage("plan")).toBe("execute"); expect(getNextStage("plan")).toBe("execute");
expect(getNextStage("execute")).toBe("verify"); expect(getNextStage("execute")).toBe("test");
expect(getNextStage("test")).toBe("verify");
expect(getNextStage("verify")).toBe("complete"); expect(getNextStage("verify")).toBe("complete");
}); });
@@ -51,6 +53,7 @@ describe("createInitialPipelineState", () => {
expect(state.research_completed).toBe(false); expect(state.research_completed).toBe(false);
expect(state.plan_completed).toBe(false); expect(state.plan_completed).toBe(false);
expect(state.execute_completed).toBe(false); expect(state.execute_completed).toBe(false);
expect(state.test_completed).toBe(false);
expect(state.verify_completed).toBe(false); expect(state.verify_completed).toBe(false);
expect(state.errors).toHaveLength(0); expect(state.errors).toHaveLength(0);
expect(state.started_at).toBeTruthy(); expect(state.started_at).toBeTruthy();
+4
View File
@@ -6,6 +6,7 @@ export type PipelineStage =
| "research" | "research"
| "plan" | "plan"
| "execute" | "execute"
| "test"
| "verify" | "verify"
| "complete"; | "complete";
@@ -19,6 +20,7 @@ export interface PipelineState {
research_completed: boolean; research_completed: boolean;
plan_completed: boolean; plan_completed: boolean;
execute_completed: boolean; execute_completed: boolean;
test_completed: boolean;
verify_completed: boolean; verify_completed: boolean;
errors: PipelineError[]; errors: PipelineError[];
started_at: string; started_at: string;
@@ -61,6 +63,7 @@ export const STAGE_ORDER: PipelineStage[] = [
"research", "research",
"plan", "plan",
"execute", "execute",
"test",
"verify", "verify",
"complete", "complete",
]; ];
@@ -84,6 +87,7 @@ export function createInitialPipelineState(
research_completed: false, research_completed: false,
plan_completed: false, plan_completed: false,
execute_completed: false, execute_completed: false,
test_completed: false,
verify_completed: false, verify_completed: false,
errors: [], errors: [],
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
+3 -3
View File
@@ -7,7 +7,7 @@ describe("file utilities", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-file-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-file-test-"));
}); });
afterEach(() => { afterEach(() => {
@@ -115,8 +115,8 @@ describe("file utilities", () => {
expect(getProjectRoot(path.join(tempDir, "subdir"))).toBe(tempDir); expect(getProjectRoot(path.join(tempDir, "subdir"))).toBe(tempDir);
}); });
it("finds project root with .ci directory", () => { it("finds project root with .ciagent directory", () => {
fs.mkdirSync(path.join(tempDir, ".ci")); fs.mkdirSync(path.join(tempDir, ".ciagent"));
expect(getProjectRoot(path.join(tempDir, "nested", "dir"))).toBe(tempDir); expect(getProjectRoot(path.join(tempDir, "nested", "dir"))).toBe(tempDir);
}); });
+1 -1
View File
@@ -47,7 +47,7 @@ export function copyFile(src: string, dest: string): void {
export function getProjectRoot(startPath?: string): string { export function getProjectRoot(startPath?: string): string {
let current = startPath || process.cwd(); let current = startPath || process.cwd();
while (current !== path.dirname(current)) { while (current !== path.dirname(current)) {
if (fs.existsSync(path.join(current, ".ci"))) return current; if (fs.existsSync(path.join(current, ".ciagent"))) return current;
if (fs.existsSync(path.join(current, ".git"))) return current; if (fs.existsSync(path.join(current, ".git"))) return current;
if (fs.existsSync(path.join(current, "package.json"))) return current; if (fs.existsSync(path.join(current, "package.json"))) return current;
current = path.dirname(current); current = path.dirname(current);
+4 -4
View File
@@ -7,7 +7,7 @@ describe("BehavioralVerification", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-behavioral-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-behavioral-test-"));
}); });
afterEach(() => { afterEach(() => {
@@ -50,7 +50,7 @@ describe("BehavioralVerification", () => {
}); });
it("passes with REQUIREMENTS.md", async () => { it("passes with REQUIREMENTS.md", async () => {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), "# Requirements\n\n| REQ-ID | Requirement | Priority | Phase | Status |\n|--------|-------------|----------|-------|--------|\n| REQ-01 | Must have auth | P0 | 1 | pending |\n"); fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), "# Requirements\n\n| REQ-ID | Requirement | Priority | Phase | Status |\n|--------|-------------|----------|-------|--------|\n| REQ-01 | Must have auth | P0 | 1 | pending |\n");
@@ -62,7 +62,7 @@ describe("BehavioralVerification", () => {
}); });
it("skips when no REQUIREMENTS.md or PROJECT.md", async () => { it("skips when no REQUIREMENTS.md or PROJECT.md", async () => {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
const verifier = new BehavioralVerification(); const verifier = new BehavioralVerification();
@@ -73,7 +73,7 @@ describe("BehavioralVerification", () => {
}); });
it("passes with PROJECT.md when no REQUIREMENTS.md", async () => { it("passes with PROJECT.md when no REQUIREMENTS.md", async () => {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), "# Test\n\n## What This Is\nBuild it\n\n## Requirements\n\n### Active\n\n- [ ] Must have auth\n- [ ] Shall support CRUD\n"); fs.writeFileSync(path.join(ciDir, "PROJECT.md"), "# Test\n\n## What This Is\nBuild it\n\n## Requirements\n\n### Active\n\n- [ ] Must have auth\n- [ ] Shall support CRUD\n");
+4 -4
View File
@@ -108,8 +108,8 @@ export class BehavioralVerification extends VerificationLayer {
} }
private checkSpecificationRequirements(projectPath: string): VerificationCheck { private checkSpecificationRequirements(projectPath: string): VerificationCheck {
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md"); const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
const projectPath_md = path.join(projectPath, ".ci", "PROJECT.md"); const projectPath_md = path.join(projectPath, ".ciagent", "PROJECT.md");
const specPath = reqPath; const specPath = reqPath;
if (!fs.existsSync(specPath)) { if (!fs.existsSync(specPath)) {
@@ -189,7 +189,7 @@ export class BehavioralVerification extends VerificationLayer {
private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck { private checkPlanMustHaves(projectPath: string, phase: number): VerificationCheck {
const roadmapPath = path.join( const roadmapPath = path.join(
projectPath, projectPath,
".ci", ".ciagent",
"ROADMAP.md" "ROADMAP.md"
); );
@@ -252,7 +252,7 @@ export class BehavioralVerification extends VerificationLayer {
ciBlockRegex.lastIndex = 0; ciBlockRegex.lastIndex = 0;
} }
const reqPath = path.join(projectPath, ".ci", "REQUIREMENTS.md"); const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) { if (!fs.existsSync(reqPath)) {
return this.check( return this.check(
"Requirement test coverage via git log", "Requirement test coverage via git log",
+2 -2
View File
@@ -7,8 +7,8 @@ describe("VerificationPipeline", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pipeline-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-pipeline-test-"));
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } })); fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }));
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Feature A\n"); fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it\n\n## Requirements\n- Feature A\n");
+1 -1
View File
@@ -7,7 +7,7 @@ describe("QualityVerification", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-quality-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-quality-test-"));
}); });
afterEach(() => { afterEach(() => {
+1 -1
View File
@@ -7,7 +7,7 @@ describe("SecurityVerification", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-security-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-security-test-"));
}); });
afterEach(() => { afterEach(() => {
+13 -13
View File
@@ -7,28 +7,28 @@ describe("StructuralVerification", () => {
let tempDir: string; let tempDir: string;
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-structural-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-structural-test-"));
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
}); });
function setupProjectStructure(hasCIDir = true, hasRoadmap = true, hasCIConfig = true, hasSpec = true) { function setupProjectStructure(hasCIDir = true, hasRoadmap = true, hasCIAgentConfig = true, hasSpec = true) {
if (hasCIDir) { if (hasCIDir) {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
if (hasRoadmap) { if (hasRoadmap) {
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n"); fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n## Phases\n\n### Phase 1: Init\n**Goal**: Set up project\n**Status**: not_started\n");
} }
} }
if (hasCIConfig) { if (hasCIAgentConfig) {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }, null, 2)); fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify({ autonomy: { level: "full" } }, null, 2));
} }
if (hasSpec) { if (hasSpec) {
const specDir = path.join(tempDir, ".ci"); const specDir = path.join(tempDir, ".ciagent");
if (!fs.existsSync(specDir)) fs.mkdirSync(specDir, { recursive: true }); if (!fs.existsSync(specDir)) fs.mkdirSync(specDir, { recursive: true });
fs.writeFileSync(path.join(specDir, "specification.md"), "# Project\n## Objective\nBuild a REST API for task management\n\n## Requirements\n- User auth\n- CRUD\n"); fs.writeFileSync(path.join(specDir, "specification.md"), "# Project\n## Objective\nBuild a REST API for task management\n\n## Requirements\n- User auth\n- CRUD\n");
} }
@@ -43,13 +43,13 @@ describe("StructuralVerification", () => {
expect(result.name).toBe("Structural"); expect(result.name).toBe("Structural");
expect(result.checks.length).toBeGreaterThan(0); expect(result.checks.length).toBeGreaterThan(0);
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists"); const phaseDirCheck = result.checks.find((c) => c.name === ".ciagent directory exists");
expect(phaseDirCheck?.status).toBe("pass"); expect(phaseDirCheck?.status).toBe("pass");
const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists"); const planCheck = result.checks.find((c) => c.name === "ROADMAP.md exists");
expect(planCheck?.status).toBe("pass"); expect(planCheck?.status).toBe("pass");
const configCheck = result.checks.find((c) => c.name === "CI config valid"); const configCheck = result.checks.find((c) => c.name === "CIAgent config valid");
expect(configCheck?.status).toBe("pass"); expect(configCheck?.status).toBe("pass");
const specCheck = result.checks.find((c) => c.name === "Specification exists"); const specCheck = result.checks.find((c) => c.name === "Specification exists");
@@ -61,7 +61,7 @@ describe("StructuralVerification", () => {
const verifier = new StructuralVerification(); const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1); const result = await verifier.verify(tempDir, 1);
const phaseDirCheck = result.checks.find((c) => c.name === ".ci directory exists"); const phaseDirCheck = result.checks.find((c) => c.name === ".ciagent directory exists");
expect(phaseDirCheck?.status).toBe("fail"); expect(phaseDirCheck?.status).toBe("fail");
}); });
@@ -75,15 +75,15 @@ describe("StructuralVerification", () => {
}); });
it("fails when CI config has invalid JSON", async () => { it("fails when CI config has invalid JSON", async () => {
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "not valid json{{{"); fs.writeFileSync(path.join(ciDir, "config.json"), "not valid json{{{");
fs.mkdirSync(path.join(tempDir, ".ci"), { recursive: true }); fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
const verifier = new StructuralVerification(); const verifier = new StructuralVerification();
const result = await verifier.verify(tempDir, 1); const result = await verifier.verify(tempDir, 1);
const configCheck = result.checks.find((c) => c.name === "CI config valid"); const configCheck = result.checks.find((c) => c.name === "CIAgent config valid");
expect(configCheck?.status).toBe("fail"); expect(configCheck?.status).toBe("fail");
}); });
@@ -91,7 +91,7 @@ describe("StructuralVerification", () => {
const srcDir = path.join(tempDir, "src"); const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true }); fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { /* TODO: implement */ }"); fs.writeFileSync(path.join(srcDir, "app.ts"), "export function main() { /* TODO: implement */ }");
const ciDir = path.join(tempDir, ".ci"); const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true }); fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}"); fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it"); fs.writeFileSync(path.join(ciDir, "specification.md"), "# Test\n## Objective\nBuild it");
+13 -13
View File
@@ -23,7 +23,7 @@ export class StructuralVerification extends VerificationLayer {
checks.push(this.checkPhaseDir(projectPath, phase)); checks.push(this.checkPhaseDir(projectPath, phase));
checks.push(this.checkPlanExists(projectPath, phase)); checks.push(this.checkPlanExists(projectPath, phase));
checks.push(this.checkCIConfig(projectPath)); checks.push(this.checkCIAgentConfig(projectPath));
checks.push(this.checkSpecification(projectPath)); checks.push(this.checkSpecification(projectPath));
checks.push(this.checkNoStubs(projectPath)); checks.push(this.checkNoStubs(projectPath));
checks.push(this.checkImportsWired(projectPath)); checks.push(this.checkImportsWired(projectPath));
@@ -41,47 +41,47 @@ export class StructuralVerification extends VerificationLayer {
} }
private checkPhaseDir(projectPath: string, phase: number) { private checkPhaseDir(projectPath: string, phase: number) {
const ciDir = path.join(projectPath, ".ci"); const ciDir = path.join(projectPath, ".ciagent");
const exists = fs.existsSync(ciDir); const exists = fs.existsSync(ciDir);
return this.check( return this.check(
".ci directory exists", ".ciagent directory exists",
exists ? "pass" : "fail", exists ? "pass" : "fail",
exists ? ".ci directory found" : ".ci directory not found", exists ? ".ciagent directory found" : ".ciagent directory not found",
ciDir ciDir
); );
} }
private checkPlanExists(projectPath: string, phase: number) { private checkPlanExists(projectPath: string, phase: number) {
const roadmapPath = path.join(projectPath, ".ci", "ROADMAP.md"); const roadmapPath = path.join(projectPath, ".ciagent", "ROADMAP.md");
const exists = fs.existsSync(roadmapPath); const exists = fs.existsSync(roadmapPath);
return this.check( return this.check(
"ROADMAP.md exists", "ROADMAP.md exists",
exists ? "pass" : "warning", exists ? "pass" : "warning",
exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ci init' first)", exists ? "ROADMAP.md found" : "ROADMAP.md not found (run 'ciagent init' first)",
roadmapPath roadmapPath
); );
} }
private checkCIConfig(projectPath: string) { private checkCIAgentConfig(projectPath: string) {
const configPath = path.join(projectPath, ".ci", "config.json"); const configPath = path.join(projectPath, ".ciagent", "config.json");
const exists = fs.existsSync(configPath); const exists = fs.existsSync(configPath);
if (!exists) { if (!exists) {
return this.check("CI config exists", "fail", ".ci/config.json not found", configPath); return this.check("CIAgent config exists", "fail", ".ciagent/config.json not found", configPath);
} }
try { try {
const content = fs.readFileSync(configPath, "utf-8"); const content = fs.readFileSync(configPath, "utf-8");
JSON.parse(content); JSON.parse(content);
return this.check("CI config valid", "pass", ".ci/config.json is valid JSON"); return this.check("CIAgent config valid", "pass", ".ciagent/config.json is valid JSON");
} catch (e) { } catch (e) {
return this.check("CI config valid", "fail", `.ci/config.json has invalid JSON: ${(e as Error).message}`); return this.check("CIAgent config valid", "fail", `.ciagent/config.json has invalid JSON: ${(e as Error).message}`);
} }
} }
private checkSpecification(projectPath: string) { private checkSpecification(projectPath: string) {
const specPath = path.join(projectPath, ".ci", "specification.md"); const specPath = path.join(projectPath, ".ciagent", "specification.md");
const exists = fs.existsSync(specPath); const exists = fs.existsSync(specPath);
if (!exists) { if (!exists) {
return this.check("Specification exists", "warning", ".ci/specification.md not found — specification may not be loaded yet"); return this.check("Specification exists", "warning", ".ciagent/specification.md not found — specification may not be loaded yet");
} }
const content = fs.readFileSync(specPath, "utf-8"); const content = fs.readFileSync(specPath, "utf-8");
if (content.trim().length < 10) { if (content.trim().length < 10) {