Compare commits

...

1 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
15 changed files with 1031 additions and 21 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
+1 -1
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",
+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");
});
});
+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");
});
});
+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");
});
});
+1 -1
View File
@@ -121,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");
});
}
+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");
});
});
+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");
});
});
});
+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