From a416413c7d6fa518a21172a62aad01ec89667afe Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 29 May 2026 18:20:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(P06):=20docs=20&=20hardening=20=E2=80=94?= =?UTF-8?q?=20AGENTS.md/README=20fixes,=20agent=20tests,=20Gitea=20tests,?= =?UTF-8?q?=20multi-project=20tests,=20version=200.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- phase: 6 milestone: v0.7.0 plan: 06 task: P06-all status: execute ---/ci--- --- AGENTS.md | 8 +- README.md | 21 ++-- opencode/ci/VERSION | 2 +- package.json | 2 +- src/agents/doc-writer.ts | 2 +- src/agents/executor.test.ts | 79 +++++++++++++ src/agents/planner.test.ts | 167 ++++++++++++++++++++++++++ src/agents/researcher.test.ts | 208 +++++++++++++++++++++++++++++++++ src/agents/tester.test.ts | 94 +++++++++++++++ src/agents/verifier.test.ts | 100 ++++++++++++++++ src/cli/commands.ts | 2 +- src/core/gitea.test.ts | 191 ++++++++++++++++++++++++++++++ src/core/multi-project.test.ts | 171 +++++++++++++++++++++++++++ src/version.ts | 2 +- templates/DECISIONS.md | 3 +- 15 files changed, 1031 insertions(+), 21 deletions(-) create mode 100644 src/agents/executor.test.ts create mode 100644 src/agents/planner.test.ts create mode 100644 src/agents/researcher.test.ts create mode 100644 src/agents/tester.test.ts create mode 100644 src/agents/verifier.test.ts create mode 100644 src/core/gitea.test.ts create mode 100644 src/core/multi-project.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0798ee4..7673275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 5968bc9..8821c0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/opencode/ci/VERSION b/opencode/ci/VERSION index 79a2734..bcaffe1 100644 --- a/opencode/ci/VERSION +++ b/opencode/ci/VERSION @@ -1 +1 @@ -0.5.0 \ No newline at end of file +0.7.0 \ No newline at end of file diff --git a/package.json b/package.json index 936b626..755e942 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agents/doc-writer.ts b/src/agents/doc-writer.ts index 4b45d88..150d463 100644 --- a/src/agents/doc-writer.ts +++ b/src/agents/doc-writer.ts @@ -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 { diff --git a/src/agents/executor.test.ts b/src/agents/executor.test.ts new file mode 100644 index 0000000..c682d33 --- /dev/null +++ b/src/agents/executor.test.ts @@ -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 { return true; } + async execute(request: BackendRequest): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/agents/planner.test.ts b/src/agents/planner.test.ts new file mode 100644 index 0000000..9c2f319 --- /dev/null +++ b/src/agents/planner.test.ts @@ -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 { return true; } + async execute(request: BackendRequest): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/agents/researcher.test.ts b/src/agents/researcher.test.ts new file mode 100644 index 0000000..c7fd143 --- /dev/null +++ b/src/agents/researcher.test.ts @@ -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 { return true; } + async execute(request: BackendRequest): Promise { + 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(); + 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"); + }); +}); \ No newline at end of file diff --git a/src/agents/tester.test.ts b/src/agents/tester.test.ts new file mode 100644 index 0000000..3999cb0 --- /dev/null +++ b/src/agents/tester.test.ts @@ -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 { return true; } + async execute(request: BackendRequest): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/agents/verifier.test.ts b/src/agents/verifier.test.ts new file mode 100644 index 0000000..d63ae1f --- /dev/null +++ b/src/agents/verifier.test.ts @@ -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 { return true; } + async execute(request: BackendRequest): Promise { + 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"); + }); +}); \ No newline at end of file diff --git a/src/cli/commands.ts b/src/cli/commands.ts index e18ae75..aa391a3 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -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"); }); } diff --git a/src/core/gitea.test.ts b/src/core/gitea.test.ts new file mode 100644 index 0000000..1f41afa --- /dev/null +++ b/src/core/gitea.test.ts @@ -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 { + 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"); + }); +}); \ No newline at end of file diff --git a/src/core/multi-project.test.ts b/src/core/multi-project.test.ts new file mode 100644 index 0000000..c8bf96b --- /dev/null +++ b/src/core/multi-project.test.ts @@ -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// 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"); + }); + }); +}); \ No newline at end of file diff --git a/src/version.ts b/src/version.ts index 3be7a7a..930d8cf 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.5.0"; \ No newline at end of file +export const VERSION = "0.7.0"; \ No newline at end of file diff --git a/templates/DECISIONS.md b/templates/DECISIONS.md index 6028bb7..e5d72ad 100644 --- a/templates/DECISIONS.md +++ b/templates/DECISIONS.md @@ -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