Compare commits

...

4 Commits

Author SHA1 Message Date
Jon Chery a416413c7d feat(P06): docs & hardening — AGENTS.md/README fixes, agent tests, Gitea tests, multi-project tests, version 0.7.0
---ci---
phase: 6
milestone: v0.7.0
plan: 06
task: P06-all
status: execute
---/ci---
2026-05-29 18:20:46 +00:00
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
22 changed files with 1474 additions and 44 deletions
+4 -4
View File
@@ -53,7 +53,7 @@ src/
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
index.ts # Public API exports
version.ts # VERSION = "0.6.0"
version.ts # VERSION = "0.7.0"
templates/ # Template files (config.json, DECISIONS.md, specification.md)
```
@@ -82,10 +82,10 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
## Pipeline Flow
```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
```
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly.
Each stage is executed by `OrchestratorAgent.executeStage()`. The orchestrator delegates intelligent stages (research, plan, execute, test, verify) to specialized agents via `context.backend` when available, falling back to mechanical execution when no backend is configured. Mechanical stages (specify, clarify, complete) are always handled by the orchestrator directly.
## Intelligence Backend Architecture
@@ -191,7 +191,7 @@ IntelligenceBackend (unified interface)
## Current State
- **v0.6.0**: Backends module (OllamaLocal, OllamaCloud, Opencode), learnship references removed, verification layers migrated from .planning/ to .ciagent/
- **v0.7.0**: Backends module (OllamaLocal, OllamaCloud, Opencode), learnship references removed, verification layers migrated from .planning/ to .ciagent/
- **New modules**: commit-parser (`---ci---` YAML block extraction/parsing), commit-builder (structured commit message generation), git-context (project state reconstruction from git log + branches), git-branch (phase/milestone branch lifecycle), ciagent-files (`.ciagent/` long-lived reference file management)
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, and compound metadata
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
+11 -10
View File
@@ -211,9 +211,9 @@ CIAgent uses `.ciagent/config.json` for project configuration:
### Pipeline
```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
↕ ↕ ↕ ↕
(questions) (auto-decide) (auto-run) (auto-verify)
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
↕ ↕ ↕
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify)
```
### Git-Native Core Modules
@@ -235,7 +235,7 @@ Every autonomous decision is classified by confidence:
Decisions are committed to git as `decision` type commits. The audit trail is `git log --grep="decisions:"`.
### 18 Agents
### 19 Agents
| Agent | Role | CIAgent Modification |
|-------|------|----------------|
@@ -244,17 +244,18 @@ Decisions are committed to git as `decision` type commits. The audit trail is `g
| executor | Task execution | Never pauses for checkpoints |
| verifier | Output verification | Generates automated tests, not human UAT |
| researcher | Domain research | Logs assumptions, never flags for human |
| tester | Integration/e2e tests | Detects and runs existing test files, never writes tests |
| challenger | Plan stress-testing | Binding verdicts, only escalates <0.60 |
| security-auditor | Security audit | Auto-dispositions threats |
| debugger | Bug fixing | Auto-fixes when confidence > threshold |
| Others | Various | Retained from Learnship |
| Others | Various | Delegates to active intelligence backend |
### Verification Layers
1. **Structural**: File existence, import/export wiring, no stubs
2. **Behavioral**: Generated automated tests for must-haves
3. **Security**: STRIDE analysis with auto-disposition
4. **Code Quality**: Multi-persona review with P0 auto-fix
2. **Behavioral**: Test infrastructure and requirement traceability (partially implemented — static analysis, no test generation yet)
3. **Security**: Regex-based threat pattern scanning with auto-disposition (partially implemented — no STRIDE analysis yet)
4. **Code Quality**: Regex-based code quality checks (partially implemented — no multi-persona review yet)
## Specification Format
@@ -292,9 +293,9 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation
## Current Limitations
- **Agent implementations are stubs**: All 18 agents return success immediately. Real LLM-based agent implementations are needed for research, planning, execution, and verification.
- **Agent implementations**: 5 core agents have intrinsic logic (planner, executor, verifier, researcher, tester); 13 agents delegate to backends. Full LLM-powered agent behavior requires an intelligence backend.
- **Package not published to npm**: Install from source only until a publishing pipeline is configured.
- **Behavioral/Security/Quality verification layers**: Structural verification is fully implemented; behavioral, security, and quality layers are partially stubbed.
- **Behavioral/Security/Quality verification layers**: Partially implemented — structural verification is complete; behavioral does static analysis; security does regex-based threat scanning; quality does regex-based code quality checks.
## Differences from Learnship
+1 -1
View File
@@ -1 +1 @@
0.5.0
0.7.0
+14 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@continuous-intelligence/ciagent",
"version": "0.5.0",
"version": "0.7.0",
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -19,7 +19,7 @@
"dev": "ts-node src/cli.ts",
"typecheck": "tsc --noEmit",
"test": "jest",
"prepublishOnly": "npm run build",
"prepublishOnly": "npm run build && npm test",
"install-opencode": "node scripts/postinstall.js"
},
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
@@ -27,6 +27,18 @@
"engines": {
"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": {
"commander": "^12.1.0",
"zod": "^3.23.0"
+1 -1
View File
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
export class DocWriterAgent extends BaseAgent {
readonly name = "doc-writer";
readonly description = "Autonomous documentation writer. No behavioral changes from Learnship.";
readonly description = "Autonomous documentation writer.";
readonly workflow = "execute";
async execute(context: AgentContext): Promise<AgentResult> {
+79
View File
@@ -0,0 +1,79 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ExecutorAgent } from "../agents/executor.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-executor-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "execute",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
describe("ExecutorAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("returns honest failure without backend", async () => {
const executor = new ExecutorAgent();
const result = await executor.execute(makeContext(dir));
expect(result.success).toBe(false);
expect(result.error).toContain("intelligence backend");
});
it("delegates to backend when available", async () => {
const mockBackend = new MockBackend();
const executor = new ExecutorAgent();
const result = await executor.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const executor = new ExecutorAgent();
expect(executor.name).toBe("executor");
});
it("has correct workflow", () => {
const executor = new ExecutorAgent();
expect(executor.workflow).toBe("execute");
});
});
+96 -18
View File
@@ -44,12 +44,13 @@ export class OrchestratorAgent extends BaseAgent {
private phaseResults: PhaseResult[] = [];
private totalPhases: number = 1;
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
research: "researcher",
plan: "planner",
execute: "executor",
test: "tester",
verify: "verifier",
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
research: ["researcher"],
plan: ["planner"],
execute: ["executor", "code-reviewer", "security-auditor"],
test: ["tester"],
verify: ["verifier"],
complete: ["doc-writer"],
};
constructor(config?: CIAgentConfig) {
@@ -331,29 +332,82 @@ export class OrchestratorAgent extends BaseAgent {
context: AgentContext
): Promise<PhaseResult> {
const stageStart = Date.now();
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
if (agentName && context.backend) {
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
if (agentNames && agentNames.length > 0 && context.backend) {
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
try {
const agent = getAgent(agentName);
const gitContext = this.buildGitAgentContext(context);
const result = await agent.execute(gitContext);
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 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 {
phase: this.pipelineState!.current_phase,
stage,
success: false,
artifacts_created: allArtifacts,
decisions_made: totalDecisions,
escalations_raised: totalEscalations,
duration_ms: Date.now() - stageStart,
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: result.success,
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
decisions_made: result.decisions,
escalations_raised: result.escalations,
success: primaryResult?.success ?? false,
artifacts_created: allArtifacts,
decisions_made: totalDecisions,
escalations_raised: totalEscalations,
duration_ms: Date.now() - stageStart,
error: result.error,
error: lastError,
};
} catch (err) {
if (err instanceof BackendUnavailableError) {
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
} 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)}`);
}
}
}
@@ -609,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;
}
}
+167
View File
@@ -0,0 +1,167 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PlannerAgent } from "../agents/planner.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-planner-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "plan",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupCIAgentDir(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
}
function writeRequirementsMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Core",
"",
"- [ ] **REQ-01**: User authentication",
"- [ ] **REQ-02**: Task CRUD operations",
"- [ ] **REQ-03**: Real-time notifications",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| REQ-01 | Phase 1 | in_progress |",
"| REQ-02 | Phase 1 | pending |",
"| REQ-03 | Phase 1 | blocked |",
].join("\n");
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), content);
}
function writeRoadmapMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Roadmap",
"",
"## Overview",
"",
"Task management API roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Authentication** - Implement auth",
"",
"## Phase Details",
"",
"### Phase 1: Authentication",
"**Goal**: Implement user authentication",
"**Depends on**: Nothing",
"**Requirements**: REQ-01, REQ-02",
"**Success Criteria**:",
"1. .ciagent/REQUIREMENTS.md exists",
"**Status**: in_progress",
].join("\n");
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), content);
}
describe("PlannerAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("returns honest failure without backend when no requirements or roadmap", async () => {
setupCIAgentDir(dir);
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir));
expect(result.success).toBe(false);
expect(result.error).toContain("No requirements or roadmap");
});
it("creates PLAN.md from REQUIREMENTS.md without backend", async () => {
setupCIAgentDir(dir);
writeRequirementsMd(dir);
writeRoadmapMd(dir);
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir));
expect(result.success).toBe(true);
expect(result.output).toContain("plan");
expect(fs.existsSync(path.join(dir, ".ciagent", "PLAN.md"))).toBe(true);
});
it("PLAN.md contains phase goal and tasks", async () => {
setupCIAgentDir(dir);
writeRequirementsMd(dir);
writeRoadmapMd(dir);
const planner = new PlannerAgent();
await planner.execute(makeContext(dir));
const planContent = fs.readFileSync(path.join(dir, ".ciagent", "PLAN.md"), "utf-8");
expect(planContent).toContain("Phase 1 Plan");
expect(planContent).toContain("Phase Goal");
expect(planContent).toContain("Tasks");
});
it("delegates to backend when available", async () => {
setupCIAgentDir(dir);
const mockBackend = new MockBackend();
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const planner = new PlannerAgent();
expect(planner.name).toBe("planner");
});
it("has correct workflow", () => {
const planner = new PlannerAgent();
expect(planner.workflow).toBe("plan");
});
});
+8 -2
View File
@@ -222,7 +222,10 @@ export class PlannerAgent extends BaseAgent {
wave: 1,
requirements: chunk.map((r) => r.id),
dependsOn: [],
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
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`),
});
}
@@ -236,7 +239,10 @@ export class PlannerAgent extends BaseAgent {
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) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
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`),
});
}
+208
View File
@@ -0,0 +1,208 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ResearcherAgent } from "../agents/researcher.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-researcher-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "research",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupCIAgentDir(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), '{"projects":[],"active_project":""}');
}
function writeProjectMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Task API",
"",
"## What This Is",
"",
"A REST API for managing tasks",
"",
"## Requirements",
"",
"### Validated",
"",
"- ✓ User authentication",
"",
"### Active",
"",
"- [ ] Task CRUD",
"",
"### Out of Scope",
"",
"- Admin dashboard",
"",
"## Context",
"",
"Node.js project",
"",
"## Constraints",
"",
"- Must use Node.js",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n");
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), content);
}
function writeArchitectureMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Architecture",
"",
"## Overview",
"",
"Task management system architecture",
"",
"## Components",
"",
"### Core",
"- **Description**: Core module",
"- **Boundaries**: src/core/ — internal module",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Request → Handler → Service → Database",
"",
"## Build Order",
"",
"1. Build core module",
].join("\n");
fs.writeFileSync(path.join(ciDir, "ARCHITECTURE.md"), content);
}
function setupSourceDir(dir: string): void {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(path.join(srcDir, "core"), { recursive: true });
fs.mkdirSync(path.join(srcDir, "agents"), { recursive: true });
fs.writeFileSync(path.join(srcDir, "core", "index.ts"), "export {};\n");
fs.writeFileSync(path.join(srcDir, "agents", "base.ts"), "export {};\n");
}
describe("ResearcherAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("reads .ciagent/ files without backend", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
writeArchitectureMd(dir);
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir));
expect(result.success).toBe(true);
expect(result.output).toContain("findingsCount");
});
it("only modifies .ciagent/ files", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
writeArchitectureMd(dir);
setupSourceDir(dir);
const srcDir = path.join(dir, "src");
const filesBefore = new Set<string>();
function collectFiles(d: string): void {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
collectFiles(full);
} else {
filesBefore.add(full);
}
}
}
collectFiles(srcDir);
const researcher = new ResearcherAgent();
await researcher.execute(makeContext(dir));
collectFiles(srcDir);
for (const f of filesBefore) {
expect(fs.existsSync(f)).toBe(true);
}
});
it("updates ARCHITECTURE.md from source scan", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
setupSourceDir(dir);
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir));
if (result.success) {
const parsed = JSON.parse(result.output);
expect(parsed.filesUpdated).toContain(".ciagent/ARCHITECTURE.md");
}
});
it("delegates to backend when available", async () => {
setupCIAgentDir(dir);
const mockBackend = new MockBackend();
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const researcher = new ResearcherAgent();
expect(researcher.name).toBe("researcher");
});
it("has correct workflow", () => {
const researcher = new ResearcherAgent();
expect(researcher.workflow).toBe("research");
});
});
+94
View File
@@ -0,0 +1,94 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { TesterAgent } from "../agents/tester.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-tester-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "test",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
describe("TesterAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("detects test files when src directory exists", async () => {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.integration.test.ts"), "test('integration', () => {});\n");
const tester = new TesterAgent();
const result = await tester.execute(makeContext(dir));
expect(result.success).toBeDefined();
});
it("does not write test files", async () => {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.test.ts"), "test('unit', () => {});\n");
const testFilesBefore = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
const tester = new TesterAgent();
await tester.execute(makeContext(dir));
const testFilesAfter = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
expect(testFilesAfter.length).toBe(testFilesBefore.length);
});
it("delegates to backend when available", async () => {
const mockBackend = new MockBackend();
const tester = new TesterAgent();
const result = await tester.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const tester = new TesterAgent();
expect(tester.name).toBe("tester");
});
it("has correct workflow", () => {
const tester = new TesterAgent();
expect(tester.workflow).toBe("test");
});
});
+100
View File
@@ -0,0 +1,100 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { VerifierAgent } from "../agents/verifier.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-verifier-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "verify",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupBasicProject(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "index.ts"), 'export const VERSION = "0.7.0";\n');
}
describe("VerifierAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("runs mechanical verification without backend", async () => {
setupBasicProject(dir);
const verifier = new VerifierAgent();
const result = await verifier.execute(makeContext(dir));
expect(result.output).toBeDefined();
});
it("is read-only — does not create new source files", async () => {
setupBasicProject(dir);
const srcDir = path.join(dir, "src");
const filesBefore = fs.readdirSync(srcDir);
const verifier = new VerifierAgent();
await verifier.execute(makeContext(dir));
const filesAfter = fs.readdirSync(srcDir);
expect(filesAfter.length).toBe(filesBefore.length);
});
it("delegates to backend when available", async () => {
setupBasicProject(dir);
const mockBackend = new MockBackend();
const verifier = new VerifierAgent();
const result = await verifier.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const verifier = new VerifierAgent();
expect(verifier.name).toBe("verifier");
});
it("has correct workflow", () => {
const verifier = new VerifierAgent();
expect(verifier.workflow).toBe("verify");
});
});
+125 -1
View File
@@ -15,6 +15,8 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"
import { resolveBackend } from "../backends/index.js";
import { BackendUnavailableError } from "../backends/types.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 path from "node:path";
import { execSync } from "node:child_process";
@@ -119,7 +121,7 @@ export function createInitCommand(): Command {
console.log("\nNext steps:");
console.log(" ciagent run --all # Run full pipeline");
console.log(" ciagent run research # Run specific phase");
console.log(" ci status # Check project status");
console.log(" ciagent status # Check project status");
});
}
@@ -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 {
return new Command("ship")
.description("Auto-complete phase: verify, security, commit, tag")
@@ -713,6 +792,35 @@ export function createShipCommand(): Command {
});
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) {
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
console.log(` ✓ Pushed tag: ${version.tag}`);
@@ -819,4 +927,20 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
} catch {}
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;
}
+13 -1
View File
@@ -2,6 +2,8 @@
import { Command } from "commander";
import { VERSION } from "../version.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { isCIAgentInitialized } from "../core/config.js";
import {
createInitCommand,
createRunCommand,
@@ -14,6 +16,7 @@ import {
createClarifyCommand,
createRollbackCommand,
createShipCommand,
createProjectsCommand,
} from "./commands.js";
const program = new Command();
@@ -22,6 +25,14 @@ program
.name("ciagent")
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
.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(createRunCommand())
.addCommand(createQuickCommand())
@@ -32,6 +43,7 @@ program
.addCommand(createAuditCommand())
.addCommand(createClarifyCommand())
.addCommand(createRollbackCommand())
.addCommand(createShipCommand());
.addCommand(createShipCommand())
.addCommand(createProjectsCommand());
program.parse();
+191
View File
@@ -0,0 +1,191 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { GiteaClient, generateReleaseNotes, GiteaReleaseConfig } from "../core/gitea.js";
const defaultConfig: GiteaReleaseConfig = {
baseUrl: "https://git.example.com",
token: "test-token-123",
owner: "testorg",
repo: "testrepo",
};
function makeReleaseResponse(overrides: Partial<{
id: number;
tag_name: string;
name: string;
body: string;
url: string;
html_url: string;
draft: boolean;
prerelease: boolean;
}> = {}): Record<string, unknown> {
return {
id: overrides.id ?? 1,
tag_name: overrides.tag_name ?? "v1.0.0",
name: overrides.name ?? "v1.0.0",
body: overrides.body ?? "Release notes",
url: overrides.url ?? "https://git.example.com/api/v1/repos/testorg/testrepo/releases/1",
html_url: overrides.html_url ?? "https://git.example.com/testorg/testrepo/releases/tag/v1.0.0",
draft: overrides.draft ?? false,
prerelease: overrides.prerelease ?? false,
};
}
describe("GiteaClient", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("createRelease", () => {
it("creates a release via POST", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => makeReleaseResponse({ tag_name: "v1.0.0", name: "v1.0.0" }),
});
const release = await client.createRelease({
tag_name: "v1.0.0",
name: "v1.0.0",
body: "Initial release",
});
expect(release.tag_name).toBe("v1.0.0");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const call = (globalThis.fetch as jest.Mock).mock.calls[0];
expect(call[0]).toContain("/releases");
expect(call[1].method).toBe("POST");
expect(call[1].headers.Authorization).toBe("token test-token-123");
});
it("throws on non-ok response", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 409,
text: async () => "Conflict: tag already exists",
});
await expect(client.createRelease({
tag_name: "v1.0.0",
name: "v1.0.0",
body: "",
})).rejects.toThrow("Gitea API error: 409");
});
});
describe("listReleases", () => {
it("lists releases via GET", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
makeReleaseResponse({ id: 1, tag_name: "v1.0.0" }),
makeReleaseResponse({ id: 2, tag_name: "v1.1.0" }),
],
});
const releases = await client.listReleases();
expect(releases).toHaveLength(2);
expect(releases[0].tag_name).toBe("v1.0.0");
expect(releases[1].tag_name).toBe("v1.1.0");
});
it("throws on non-ok response", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(client.listReleases()).rejects.toThrow("Gitea API error: 500");
});
});
describe("getReleaseByTag", () => {
it("returns release when found", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => makeReleaseResponse({ tag_name: "v1.0.0" }),
});
const release = await client.getReleaseByTag("v1.0.0");
expect(release).not.toBeNull();
expect(release!.tag_name).toBe("v1.0.0");
});
it("returns null on 404", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 404,
});
const release = await client.getReleaseByTag("v0.0.0");
expect(release).toBeNull();
});
it("throws on other non-ok status", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(client.getReleaseByTag("v1.0.0")).rejects.toThrow("Gitea API error: 500");
});
});
});
describe("generateReleaseNotes", () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-gitea-test-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it("parses git log into categorized sections", () => {
const gitDir = path.join(dir, "repo");
fs.mkdirSync(gitDir, { recursive: true });
const { execSync } = require("node:child_process");
execSync("git init", { cwd: gitDir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { cwd: gitDir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: gitDir, stdio: "pipe" });
fs.writeFileSync(path.join(gitDir, "file1.txt"), "hello");
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
execSync('git commit -m "feat: add authentication"', { cwd: gitDir, stdio: "pipe" });
fs.writeFileSync(path.join(gitDir, "file2.txt"), "world");
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
execSync('git commit -m "fix: resolve login bug"', { cwd: gitDir, stdio: "pipe" });
execSync("git tag v1.0.0", { cwd: gitDir, stdio: "pipe" });
const notes = generateReleaseNotes(gitDir, null, "v1.0.0");
expect(notes).toContain("New Features");
expect(notes).toContain("add authentication");
expect(notes).toContain("Bug Fixes");
expect(notes).toContain("resolve login bug");
});
it("returns no-commits message when no commits found", () => {
const nonExistent = path.join(dir, "nonexistent");
const notes = generateReleaseNotes(nonExistent, null, "v0.0.0");
expect(notes).toContain("No commits found");
});
});
+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")}`;
}
+2
View File
@@ -8,5 +8,7 @@ export { GitContext } from "./git-context.js";
export { GitBranch } from "./git-branch.js";
export { CommitBuilder } from "./commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+171
View File
@@ -0,0 +1,171 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles, ProjectEntry } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig, saveConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-multiproject-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
describe("Multi-project CIAgentFiles operations", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
describe("--project flag behavior via CIAgentFiles", () => {
it("sets active project via setActiveProject", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API");
ciFiles.addProject("auth-svc", "Auth Service");
ciFiles.setActiveProject("auth-svc");
expect(ciFiles.getActiveProject()).toBe("auth-svc");
});
it("lists all added projects", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API");
ciFiles.addProject("auth-svc", "Auth Service");
const projects = ciFiles.listProjects();
expect(projects.length).toBeGreaterThanOrEqual(2);
const slugs = projects.map(p => p.slug);
expect(slugs).toContain("task-api");
expect(slugs).toContain("auth-svc");
});
it("addProject does not duplicate existing slug", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API");
ciFiles.addProject("task-api", "Task API V2");
const projects = ciFiles.listProjects();
const taskApiProjects = projects.filter(p => p.slug === "task-api");
expect(taskApiProjects.length).toBe(1);
});
it("defaults to empty string when no active project set", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
expect(ciFiles.getActiveProject()).toBe("");
});
it("isMultiProject returns false for single or no projects", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
expect(ciFiles.isMultiProject()).toBe(false);
});
it("isMultiProject returns true when projects exist in config", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.addProject("task-api", "Task API");
ciFiles.addProject("auth-svc", "Auth Service");
expect(ciFiles.isMultiProject()).toBe(true);
});
});
describe("config-level project operations", () => {
it("initCIAgent with slug adds project to config", () => {
const config = initCIAgent(dir, undefined, "task-api", "Task API");
expect(config.projects).toHaveLength(1);
expect(config.active_project).toBe("task-api");
});
it("--project override sets active_project in config", () => {
initCIAgent(dir, undefined, "task-api", "Task API");
const config = loadConfig(dir);
config.active_project = "task-api";
config.projects = [
{ slug: "task-api", name: "Task API", default: true },
{ slug: "auth-svc", name: "Auth Service" },
];
saveConfig(dir, config);
const loaded = loadConfig(dir);
expect(loaded.active_project).toBe("task-api");
expect(loaded.projects).toHaveLength(2);
});
it("setActiveProject persists to config", () => {
initCIAgent(dir, undefined, "task-api", "Task API");
const ciFiles = new CIAgentFiles(dir);
ciFiles.addProject("auth-svc", "Auth Service");
ciFiles.setActiveProject("auth-svc");
const config = loadConfig(dir);
expect(config.active_project).toBe("auth-svc");
});
});
describe("project slug and directory structure", () => {
it("multi-project mode uses .ciagent/<slug>/ subdirectory", () => {
const ciFiles = new CIAgentFiles(dir, "task-api");
ciFiles.ensureCIDir();
ciFiles.ensureProjectDir();
const projectDir = path.join(dir, ".ciagent", "task-api");
expect(fs.existsSync(projectDir)).toBe(true);
});
it("single-project mode uses .ciagent/ directly", () => {
const ciFiles = new CIAgentFiles(dir);
ciFiles.ensureCIDir();
ciFiles.ensureProjectDir();
expect(fs.existsSync(path.join(dir, ".ciagent"))).toBe(true);
expect(fs.existsSync(path.join(dir, ".ciagent", "task-api"))).toBe(false);
});
it("writeProjectMd writes to project subdirectory in multi-project", () => {
const ciFiles = new CIAgentFiles(dir, "task-api");
ciFiles.ensureCIDir();
ciFiles.ensureProjectDir();
ciFiles.writeProjectMd({
name: "Task API",
coreValue: "Manage tasks",
requirements: { validated: [], active: ["Task CRUD"], outOfScope: [] },
constraints: ["Node.js"],
context: "REST API",
keyDecisions: [],
}, "test write");
expect(fs.existsSync(path.join(dir, ".ciagent", "task-api", "PROJECT.md"))).toBe(true);
});
it("readProjectMd reads from project subdirectory in multi-project", () => {
const ciFiles = new CIAgentFiles(dir, "task-api");
ciFiles.ensureCIDir();
ciFiles.ensureProjectDir();
ciFiles.writeProjectMd({
name: "Task API",
coreValue: "Manage tasks",
requirements: { validated: [], active: [], outOfScope: [] },
constraints: [],
context: "",
keyDecisions: [],
}, "test write");
const projectMd = ciFiles.readProjectMd();
expect(projectMd).not.toBeNull();
expect(projectMd!.name).toBe("Task API");
});
});
});
+3 -1
View File
@@ -8,6 +8,7 @@ export { GitContext } from "./core/git-context.js";
export { GitBranch } from "./core/git-branch.js";
export { CommitBuilder } from "./core/commit-builder.js";
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js";
@@ -30,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js";
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
export { ToolRegistry } from "./backends/tool-registry.js";
export type { CIAgentConfig, 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 { Escalation, EscalationType } from "./types/escalation.js";
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
@@ -44,5 +45,6 @@ export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, Com
export type { ProjectState, BranchInfo } from "./core/git-context.js";
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.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 { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
+14
View File
@@ -66,6 +66,13 @@ export interface GitConfig {
auto_push: boolean;
}
export interface GiteaConfig {
base_url: string;
api_token_env: string;
owner: string;
repo: string;
}
export interface ProjectEntry {
slug: string;
name: string;
@@ -82,6 +89,7 @@ export interface CIAgentConfig {
security: SecurityConfig;
git: GitConfig;
backend: BackendConfigSection;
gitea?: GiteaConfig;
}
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
@@ -136,4 +144,10 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
},
},
},
gitea: {
base_url: "https://git.cloudinit.dev",
api_token_env: "GITEA_TOKEN",
owner: "",
repo: "",
},
};
+1 -1
View File
@@ -1 +1 @@
export const VERSION = "0.5.0";
export const VERSION = "0.7.0";
+1 -2
View File
@@ -4,14 +4,13 @@
## Decision Log
Decisions are automatically logged to `.ciagent/audit/` with:
Decisions are automatically logged to git commits via `---ci---` YAML blocks with:
- Timestamp
- Decision ID
- What was decided
- Why (reasoning chain)
- Confidence level
- What alternatives were considered
- What the human would have been asked in Learnship mode
## Reviewing Decisions