Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a416413c7d | |||
| e8c6c5c917 | |||
| 4de1f65c10 | |||
| 6902c37ced |
@@ -53,7 +53,7 @@ src/
|
|||||||
security.ts # Layer 3: regex-based threat pattern scanning (no STRIDE analysis yet)
|
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)
|
quality.ts # Layer 4: regex-based code quality checks (no multi-persona review yet)
|
||||||
index.ts # Public API exports
|
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)
|
templates/ # Template files (config.json, DECISIONS.md, specification.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,10 +82,10 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
|
|||||||
## Pipeline Flow
|
## 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
|
## Intelligence Backend Architecture
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ IntelligenceBackend (unified interface)
|
|||||||
|
|
||||||
## Current State
|
## 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)
|
- **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
|
- **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
|
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
|
||||||
|
|||||||
@@ -211,9 +211,9 @@ CIAgent uses `.ciagent/config.json` for project configuration:
|
|||||||
### Pipeline
|
### Pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
|
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
|
||||||
↕ ↕ ↕ ↕
|
↕ ↕ ↕ ↕ ↕
|
||||||
(questions) (auto-decide) (auto-run) (auto-verify)
|
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Git-Native Core Modules
|
### 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:"`.
|
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 |
|
| 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 |
|
| executor | Task execution | Never pauses for checkpoints |
|
||||||
| verifier | Output verification | Generates automated tests, not human UAT |
|
| verifier | Output verification | Generates automated tests, not human UAT |
|
||||||
| researcher | Domain research | Logs assumptions, never flags for human |
|
| 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 |
|
| challenger | Plan stress-testing | Binding verdicts, only escalates <0.60 |
|
||||||
| security-auditor | Security audit | Auto-dispositions threats |
|
| security-auditor | Security audit | Auto-dispositions threats |
|
||||||
| debugger | Bug fixing | Auto-fixes when confidence > threshold |
|
| debugger | Bug fixing | Auto-fixes when confidence > threshold |
|
||||||
| Others | Various | Retained from Learnship |
|
| Others | Various | Delegates to active intelligence backend |
|
||||||
|
|
||||||
### Verification Layers
|
### Verification Layers
|
||||||
|
|
||||||
1. **Structural**: File existence, import/export wiring, no stubs
|
1. **Structural**: File existence, import/export wiring, no stubs
|
||||||
2. **Behavioral**: Generated automated tests for must-haves
|
2. **Behavioral**: Test infrastructure and requirement traceability (partially implemented — static analysis, no test generation yet)
|
||||||
3. **Security**: STRIDE analysis with auto-disposition
|
3. **Security**: Regex-based threat pattern scanning with auto-disposition (partially implemented — no STRIDE analysis yet)
|
||||||
4. **Code Quality**: Multi-persona review with P0 auto-fix
|
4. **Code Quality**: Regex-based code quality checks (partially implemented — no multi-persona review yet)
|
||||||
|
|
||||||
## Specification Format
|
## Specification Format
|
||||||
|
|
||||||
@@ -292,9 +293,9 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation
|
|||||||
|
|
||||||
## Current Limitations
|
## 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.
|
- **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
|
## Differences from Learnship
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.5.0
|
0.7.0
|
||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@continuous-intelligence/ciagent",
|
"name": "@continuous-intelligence/ciagent",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"dev": "ts-node src/cli.ts",
|
"dev": "ts-node src/cli.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build && npm test",
|
||||||
"install-opencode": "node scripts/postinstall.js"
|
"install-opencode": "node scripts/postinstall.js"
|
||||||
},
|
},
|
||||||
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||||
@@ -27,6 +27,18 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://registry.npmjs.org/",
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.cloudinit.dev/continuous-intelligence/ciagent",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent/issues"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
|||||||
|
|
||||||
export class DocWriterAgent extends BaseAgent {
|
export class DocWriterAgent extends BaseAgent {
|
||||||
readonly name = "doc-writer";
|
readonly name = "doc-writer";
|
||||||
readonly description = "Autonomous documentation writer. No behavioral changes from Learnship.";
|
readonly description = "Autonomous documentation writer.";
|
||||||
readonly workflow = "execute";
|
readonly workflow = "execute";
|
||||||
|
|
||||||
async execute(context: AgentContext): Promise<AgentResult> {
|
async execute(context: AgentContext): Promise<AgentResult> {
|
||||||
|
|||||||
@@ -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
@@ -44,12 +44,13 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
private phaseResults: PhaseResult[] = [];
|
private phaseResults: PhaseResult[] = [];
|
||||||
private totalPhases: number = 1;
|
private totalPhases: number = 1;
|
||||||
|
|
||||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
|
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
||||||
research: "researcher",
|
research: ["researcher"],
|
||||||
plan: "planner",
|
plan: ["planner"],
|
||||||
execute: "executor",
|
execute: ["executor", "code-reviewer", "security-auditor"],
|
||||||
test: "tester",
|
test: ["tester"],
|
||||||
verify: "verifier",
|
verify: ["verifier"],
|
||||||
|
complete: ["doc-writer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config?: CIAgentConfig) {
|
constructor(config?: CIAgentConfig) {
|
||||||
@@ -331,29 +332,82 @@ export class OrchestratorAgent extends BaseAgent {
|
|||||||
context: AgentContext
|
context: AgentContext
|
||||||
): Promise<PhaseResult> {
|
): Promise<PhaseResult> {
|
||||||
const stageStart = Date.now();
|
const stageStart = Date.now();
|
||||||
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||||
|
|
||||||
if (agentName && context.backend) {
|
if (agentNames && agentNames.length > 0 && context.backend) {
|
||||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
|
||||||
try {
|
try {
|
||||||
const agent = getAgent(agentName);
|
let primaryResult: AgentResult | null = null;
|
||||||
const gitContext = this.buildGitAgentContext(context);
|
const allArtifacts: string[] = [];
|
||||||
const result = await agent.execute(gitContext);
|
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 {
|
return {
|
||||||
phase: this.pipelineState!.current_phase,
|
phase: this.pipelineState!.current_phase,
|
||||||
stage,
|
stage,
|
||||||
success: result.success,
|
success: primaryResult?.success ?? false,
|
||||||
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
|
artifacts_created: allArtifacts,
|
||||||
decisions_made: result.decisions,
|
decisions_made: totalDecisions,
|
||||||
escalations_raised: result.escalations,
|
escalations_raised: totalEscalations,
|
||||||
duration_ms: Date.now() - stageStart,
|
duration_ms: Date.now() - stageStart,
|
||||||
error: result.error,
|
error: lastError,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BackendUnavailableError) {
|
if (err instanceof BackendUnavailableError) {
|
||||||
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
||||||
} else {
|
} else {
|
||||||
this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
this.warn(`Agents failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -222,7 +222,10 @@ export class PlannerAgent extends BaseAgent {
|
|||||||
wave: 1,
|
wave: 1,
|
||||||
requirements: chunk.map((r) => r.id),
|
requirements: chunk.map((r) => r.id),
|
||||||
dependsOn: [],
|
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`),
|
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,
|
wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2,
|
||||||
requirements: chunk.map((r) => r.id),
|
requirements: chunk.map((r) => r.id),
|
||||||
dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name),
|
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`),
|
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -15,6 +15,8 @@ import { PipelineState, createInitialPipelineState } from "../types/pipeline.js"
|
|||||||
import { resolveBackend } from "../backends/index.js";
|
import { resolveBackend } from "../backends/index.js";
|
||||||
import { BackendUnavailableError } from "../backends/types.js";
|
import { BackendUnavailableError } from "../backends/types.js";
|
||||||
import { getAgent } from "../agents/index.js";
|
import { getAgent } from "../agents/index.js";
|
||||||
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||||
|
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
@@ -119,7 +121,7 @@ export function createInitCommand(): Command {
|
|||||||
console.log("\nNext steps:");
|
console.log("\nNext steps:");
|
||||||
console.log(" ciagent run --all # Run full pipeline");
|
console.log(" ciagent run --all # Run full pipeline");
|
||||||
console.log(" ciagent run research # Run specific phase");
|
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 {
|
export function createShipCommand(): Command {
|
||||||
return new Command("ship")
|
return new Command("ship")
|
||||||
.description("Auto-complete phase: verify, security, commit, tag")
|
.description("Auto-complete phase: verify, security, commit, tag")
|
||||||
@@ -713,6 +792,35 @@ export function createShipCommand(): Command {
|
|||||||
});
|
});
|
||||||
console.log(` ✓ Tagged: ${version.tag}`);
|
console.log(` ✓ Tagged: ${version.tag}`);
|
||||||
|
|
||||||
|
if (config.gitea && config.gitea.owner && config.gitea.repo) {
|
||||||
|
const apiToken = process.env[config.gitea.api_token_env];
|
||||||
|
if (apiToken) {
|
||||||
|
try {
|
||||||
|
const previousTag = getPreviousTag(projectPath, version.tag);
|
||||||
|
const releaseNotes = generateReleaseNotes(projectPath, previousTag, version.tag);
|
||||||
|
|
||||||
|
const giteaClient = new GiteaClient({
|
||||||
|
baseUrl: config.gitea.base_url,
|
||||||
|
token: apiToken,
|
||||||
|
owner: config.gitea.owner,
|
||||||
|
repo: config.gitea.repo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await giteaClient.createRelease({
|
||||||
|
tag_name: version.tag,
|
||||||
|
name: version.tag,
|
||||||
|
body: releaseNotes,
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Release created: ${release.html_url}`);
|
||||||
|
} catch (giteaErr) {
|
||||||
|
console.warn(` ⚠ Gitea release failed: ${giteaErr instanceof Error ? giteaErr.message : String(giteaErr)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.git.auto_push) {
|
if (config.git.auto_push) {
|
||||||
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||||
console.log(` ✓ Pushed tag: ${version.tag}`);
|
console.log(` ✓ Pushed tag: ${version.tag}`);
|
||||||
@@ -820,3 +928,19 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
|
|||||||
|
|
||||||
return "main";
|
return "main";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPreviousTag(projectPath: string, currentTag: string): string | null {
|
||||||
|
try {
|
||||||
|
const tags = execSync("git tag -l --sort=-v:refname", { cwd: projectPath, encoding: "utf-8" })
|
||||||
|
.split("\n")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const currentIdx = tags.indexOf(currentTag);
|
||||||
|
if (currentIdx >= 0 && currentIdx + 1 < tags.length) {
|
||||||
|
return tags[currentIdx + 1];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+13
-1
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||||
|
import { isCIAgentInitialized } from "../core/config.js";
|
||||||
import {
|
import {
|
||||||
createInitCommand,
|
createInitCommand,
|
||||||
createRunCommand,
|
createRunCommand,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
createClarifyCommand,
|
createClarifyCommand,
|
||||||
createRollbackCommand,
|
createRollbackCommand,
|
||||||
createShipCommand,
|
createShipCommand,
|
||||||
|
createProjectsCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -22,6 +25,14 @@ program
|
|||||||
.name("ciagent")
|
.name("ciagent")
|
||||||
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
||||||
.version(VERSION)
|
.version(VERSION)
|
||||||
|
.option("--project <slug>", "Specify which project to operate on")
|
||||||
|
.hook("preAction", () => {
|
||||||
|
const opts = program.opts();
|
||||||
|
if (opts.project && isCIAgentInitialized(process.cwd())) {
|
||||||
|
const ciFiles = new CIAgentFiles(process.cwd());
|
||||||
|
ciFiles.setProjectSlug(opts.project);
|
||||||
|
}
|
||||||
|
})
|
||||||
.addCommand(createInitCommand())
|
.addCommand(createInitCommand())
|
||||||
.addCommand(createRunCommand())
|
.addCommand(createRunCommand())
|
||||||
.addCommand(createQuickCommand())
|
.addCommand(createQuickCommand())
|
||||||
@@ -32,6 +43,7 @@ program
|
|||||||
.addCommand(createAuditCommand())
|
.addCommand(createAuditCommand())
|
||||||
.addCommand(createClarifyCommand())
|
.addCommand(createClarifyCommand())
|
||||||
.addCommand(createRollbackCommand())
|
.addCommand(createRollbackCommand())
|
||||||
.addCommand(createShipCommand());
|
.addCommand(createShipCommand())
|
||||||
|
.addCommand(createProjectsCommand());
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -8,5 +8,7 @@ export { GitContext } from "./git-context.js";
|
|||||||
export { GitBranch } from "./git-branch.js";
|
export { GitBranch } from "./git-branch.js";
|
||||||
export { CommitBuilder } from "./commit-builder.js";
|
export { CommitBuilder } from "./commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.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 type { CIAgentConfig } from "../types/config.js";
|
||||||
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||||
@@ -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
@@ -8,6 +8,7 @@ export { GitContext } from "./core/git-context.js";
|
|||||||
export { GitBranch } from "./core/git-branch.js";
|
export { GitBranch } from "./core/git-branch.js";
|
||||||
export { CommitBuilder } from "./core/commit-builder.js";
|
export { CommitBuilder } from "./core/commit-builder.js";
|
||||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||||
|
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||||
export { VerificationPipeline } from "./verification/index.js";
|
export { VerificationPipeline } from "./verification/index.js";
|
||||||
export { StructuralVerification } from "./verification/structural.js";
|
export { StructuralVerification } from "./verification/structural.js";
|
||||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||||
@@ -30,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js";
|
|||||||
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
||||||
export { ToolRegistry } from "./backends/tool-registry.js";
|
export { ToolRegistry } from "./backends/tool-registry.js";
|
||||||
|
|
||||||
export type { 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 { Decision, DecisionCategory } from "./types/decisions.js";
|
||||||
export type { Escalation, EscalationType } from "./types/escalation.js";
|
export type { Escalation, EscalationType } from "./types/escalation.js";
|
||||||
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
|
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
|
||||||
@@ -44,5 +45,6 @@ export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, Com
|
|||||||
export type { ProjectState, BranchInfo } from "./core/git-context.js";
|
export type { ProjectState, BranchInfo } from "./core/git-context.js";
|
||||||
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
|
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
|
||||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
||||||
|
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
||||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||||
@@ -66,6 +66,13 @@ export interface GitConfig {
|
|||||||
auto_push: boolean;
|
auto_push: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GiteaConfig {
|
||||||
|
base_url: string;
|
||||||
|
api_token_env: string;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectEntry {
|
export interface ProjectEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -82,6 +89,7 @@ export interface CIAgentConfig {
|
|||||||
security: SecurityConfig;
|
security: SecurityConfig;
|
||||||
git: GitConfig;
|
git: GitConfig;
|
||||||
backend: BackendConfigSection;
|
backend: BackendConfigSection;
|
||||||
|
gitea?: GiteaConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
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
@@ -1 +1 @@
|
|||||||
export const VERSION = "0.5.0";
|
export const VERSION = "0.7.0";
|
||||||
@@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
Decisions are automatically logged to `.ciagent/audit/` with:
|
Decisions are automatically logged to git commits via `---ci---` YAML blocks with:
|
||||||
- Timestamp
|
- Timestamp
|
||||||
- Decision ID
|
- Decision ID
|
||||||
- What was decided
|
- What was decided
|
||||||
- Why (reasoning chain)
|
- Why (reasoning chain)
|
||||||
- Confidence level
|
- Confidence level
|
||||||
- What alternatives were considered
|
- What alternatives were considered
|
||||||
- What the human would have been asked in Learnship mode
|
|
||||||
|
|
||||||
## Reviewing Decisions
|
## Reviewing Decisions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user