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)
|
||||
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
|
||||
|
||||
@@ -211,9 +211,9 @@ CIAgent uses `.ciagent/config.json` for project configuration:
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
|
||||
↕ ↕ ↕ ↕
|
||||
(questions) (auto-decide) (auto-run) (auto-verify)
|
||||
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
|
||||
↕ ↕ ↕ ↕ ↕
|
||||
(questions) (auto-decide) (auto-run) (auto-test) (auto-verify)
|
||||
```
|
||||
|
||||
### Git-Native Core Modules
|
||||
@@ -235,7 +235,7 @@ Every autonomous decision is classified by confidence:
|
||||
|
||||
Decisions are committed to git as `decision` type commits. The audit trail is `git log --grep="decisions:"`.
|
||||
|
||||
### 18 Agents
|
||||
### 19 Agents
|
||||
|
||||
| Agent | Role | CIAgent Modification |
|
||||
|-------|------|----------------|
|
||||
@@ -244,17 +244,18 @@ Decisions are committed to git as `decision` type commits. The audit trail is `g
|
||||
| executor | Task execution | Never pauses for checkpoints |
|
||||
| verifier | Output verification | Generates automated tests, not human UAT |
|
||||
| researcher | Domain research | Logs assumptions, never flags for human |
|
||||
| tester | Integration/e2e tests | Detects and runs existing test files, never writes tests |
|
||||
| challenger | Plan stress-testing | Binding verdicts, only escalates <0.60 |
|
||||
| security-auditor | Security audit | Auto-dispositions threats |
|
||||
| debugger | Bug fixing | Auto-fixes when confidence > threshold |
|
||||
| Others | Various | Retained from Learnship |
|
||||
| Others | Various | Delegates to active intelligence backend |
|
||||
|
||||
### Verification Layers
|
||||
|
||||
1. **Structural**: File existence, import/export wiring, no stubs
|
||||
2. **Behavioral**: Generated automated tests for must-haves
|
||||
3. **Security**: STRIDE analysis with auto-disposition
|
||||
4. **Code Quality**: Multi-persona review with P0 auto-fix
|
||||
2. **Behavioral**: Test infrastructure and requirement traceability (partially implemented — static analysis, no test generation yet)
|
||||
3. **Security**: Regex-based threat pattern scanning with auto-disposition (partially implemented — no STRIDE analysis yet)
|
||||
4. **Code Quality**: Regex-based code quality checks (partially implemented — no multi-persona review yet)
|
||||
|
||||
## Specification Format
|
||||
|
||||
@@ -292,9 +293,9 @@ Each escalation is committed as an `escalation` type commit. Resolved escalation
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- **Agent implementations are stubs**: All 18 agents return success immediately. Real LLM-based agent implementations are needed for research, planning, execution, and verification.
|
||||
- **Agent implementations**: 5 core agents have intrinsic logic (planner, executor, verifier, researcher, tester); 13 agents delegate to backends. Full LLM-powered agent behavior requires an intelligence backend.
|
||||
- **Package not published to npm**: Install from source only until a publishing pipeline is configured.
|
||||
- **Behavioral/Security/Quality verification layers**: Structural verification is fully implemented; behavioral, security, and quality layers are partially stubbed.
|
||||
- **Behavioral/Security/Quality verification layers**: Partially implemented — structural verification is complete; behavioral does static analysis; security does regex-based threat scanning; quality does regex-based code quality checks.
|
||||
|
||||
## Differences from Learnship
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.5.0
|
||||
0.7.0
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@continuous-intelligence/ciagent",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"dev": "ts-node src/cli.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build",
|
||||
"prepublishOnly": "npm run build && npm test",
|
||||
"install-opencode": "node scripts/postinstall.js"
|
||||
},
|
||||
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
|
||||
@@ -27,6 +27,18 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent.git"
|
||||
},
|
||||
"homepage": "https://git.cloudinit.dev/continuous-intelligence/ciagent",
|
||||
"bugs": {
|
||||
"url": "https://git.cloudinit.dev/continuous-intelligence/ciagent/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"zod": "^3.23.0"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
|
||||
|
||||
export class DocWriterAgent extends BaseAgent {
|
||||
readonly name = "doc-writer";
|
||||
readonly description = "Autonomous documentation writer. No behavioral changes from Learnship.";
|
||||
readonly description = "Autonomous documentation writer.";
|
||||
readonly workflow = "execute";
|
||||
|
||||
async execute(context: AgentContext): Promise<AgentResult> {
|
||||
|
||||
@@ -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 totalPhases: number = 1;
|
||||
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName>> = {
|
||||
research: "researcher",
|
||||
plan: "planner",
|
||||
execute: "executor",
|
||||
test: "tester",
|
||||
verify: "verifier",
|
||||
private static readonly STAGE_AGENT_MAP: Partial<Record<PipelineStage, AgentName[]>> = {
|
||||
research: ["researcher"],
|
||||
plan: ["planner"],
|
||||
execute: ["executor", "code-reviewer", "security-auditor"],
|
||||
test: ["tester"],
|
||||
verify: ["verifier"],
|
||||
complete: ["doc-writer"],
|
||||
};
|
||||
|
||||
constructor(config?: CIAgentConfig) {
|
||||
@@ -331,29 +332,82 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
context: AgentContext
|
||||
): Promise<PhaseResult> {
|
||||
const stageStart = Date.now();
|
||||
const agentName = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||
const agentNames = OrchestratorAgent.STAGE_AGENT_MAP[stage];
|
||||
|
||||
if (agentName && context.backend) {
|
||||
this.log(`Delegating ${stage} to ${agentName} agent via backend...`);
|
||||
if (agentNames && agentNames.length > 0 && context.backend) {
|
||||
this.log(`Delegating ${stage} to ${agentNames.join(", ")} agent(s) via backend...`);
|
||||
try {
|
||||
const agent = getAgent(agentName);
|
||||
const gitContext = this.buildGitAgentContext(context);
|
||||
const result = await agent.execute(gitContext);
|
||||
let primaryResult: AgentResult | null = null;
|
||||
const allArtifacts: string[] = [];
|
||||
let totalDecisions = 0;
|
||||
let totalEscalations = 0;
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (let i = 0; i < agentNames.length; i++) {
|
||||
const agentName = agentNames[i];
|
||||
const agent = getAgent(agentName);
|
||||
const gitContext = this.buildGitAgentContext(context);
|
||||
|
||||
if (i === 0) {
|
||||
const result = await agent.execute(gitContext);
|
||||
primaryResult = result;
|
||||
if (Array.isArray(result.artifacts_created)) {
|
||||
allArtifacts.push(...result.artifacts_created);
|
||||
}
|
||||
totalDecisions += result.decisions;
|
||||
totalEscalations += result.escalations;
|
||||
|
||||
if (!result.success) {
|
||||
this.warn(`Primary agent ${agentName} failed for ${stage}`);
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage,
|
||||
success: false,
|
||||
artifacts_created: allArtifacts,
|
||||
decisions_made: totalDecisions,
|
||||
escalations_raised: totalEscalations,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: result.error || `Primary agent ${agentName} failed`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const reviewContext: AgentContext = {
|
||||
...gitContext,
|
||||
specification: `${context.specification}\n\nPrimary agent (${agentNames[0]}) completed. Review context:\n- Success: ${primaryResult!.success}\n- Output: ${primaryResult!.output}\n- Artifacts: ${Array.isArray(primaryResult!.artifacts_created) ? primaryResult!.artifacts_created.join(", ") : String(primaryResult!.artifacts_created)}`,
|
||||
};
|
||||
const result = await agent.execute(reviewContext);
|
||||
if (Array.isArray(result.artifacts_created)) {
|
||||
allArtifacts.push(...result.artifacts_created);
|
||||
}
|
||||
totalDecisions += result.decisions;
|
||||
totalEscalations += result.escalations;
|
||||
|
||||
if (!result.success) {
|
||||
this.warn(`Review agent ${agentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
|
||||
lastError = result.error;
|
||||
}
|
||||
} catch (err) {
|
||||
this.warn(`Review agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: this.pipelineState!.current_phase,
|
||||
stage,
|
||||
success: result.success,
|
||||
artifacts_created: Array.isArray(result.artifacts_created) ? result.artifacts_created : [],
|
||||
decisions_made: result.decisions,
|
||||
escalations_raised: result.escalations,
|
||||
success: primaryResult?.success ?? false,
|
||||
artifacts_created: allArtifacts,
|
||||
decisions_made: totalDecisions,
|
||||
escalations_raised: totalEscalations,
|
||||
duration_ms: Date.now() - stageStart,
|
||||
error: result.error,
|
||||
error: lastError,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof BackendUnavailableError) {
|
||||
this.warn(`Backend unavailable for ${stage}, falling back to mechanical execution`);
|
||||
} else {
|
||||
this.warn(`Agent ${agentName} failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
this.warn(`Agents failed for ${stage}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,6 +663,30 @@ export class OrchestratorAgent extends BaseAgent {
|
||||
}
|
||||
}
|
||||
|
||||
const versionTag = `${this.currentMilestone}-P${String(this.pipelineState!.current_phase).padStart(2, "0")}`;
|
||||
try {
|
||||
execSync(`git tag "${versionTag}"`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
this.log(`Created version tag: ${versionTag}`);
|
||||
artifactsCreated.push(`tag:${versionTag}`);
|
||||
} catch (err) {
|
||||
this.warn(`Version tag creation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (this.config.git.auto_push && this.gitContext!.isGitRepo()) {
|
||||
try {
|
||||
execSync(`git push origin ${versionTag}`, {
|
||||
cwd: context.project_path,
|
||||
stdio: "pipe",
|
||||
});
|
||||
this.log(`Pushed version tag: ${versionTag}`);
|
||||
} catch (err) {
|
||||
this.warn(`Version tag push failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
requirements: chunk.map((r) => r.id),
|
||||
dependsOn: [],
|
||||
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
|
||||
tasks: chunk.map((r) => {
|
||||
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
|
||||
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
|
||||
}),
|
||||
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
|
||||
});
|
||||
}
|
||||
@@ -236,7 +239,10 @@ export class PlannerAgent extends BaseAgent {
|
||||
wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2,
|
||||
requirements: chunk.map((r) => r.id),
|
||||
dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name),
|
||||
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
|
||||
tasks: chunk.map((r) => {
|
||||
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
|
||||
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
|
||||
}),
|
||||
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { BackendUnavailableError } from "../backends/types.js";
|
||||
import { getAgent } from "../agents/index.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { GiteaClient, generateReleaseNotes } from "../core/gitea.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -119,7 +121,7 @@ export function createInitCommand(): Command {
|
||||
console.log("\nNext steps:");
|
||||
console.log(" ciagent run --all # Run full pipeline");
|
||||
console.log(" ciagent run research # Run specific phase");
|
||||
console.log(" ci status # Check project status");
|
||||
console.log(" ciagent status # Check project status");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -642,6 +644,83 @@ export function createRollbackCommand(): Command {
|
||||
});
|
||||
}
|
||||
|
||||
export function createProjectsCommand(): Command {
|
||||
const cmd = new Command("projects");
|
||||
cmd.description("Manage CIAgent projects in multi-project mode");
|
||||
|
||||
cmd.command("list")
|
||||
.description("List all registered projects")
|
||||
.action(() => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = loadConfig(projectPath);
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
const projects = ciFiles.listProjects();
|
||||
const activeProject = config.active_project || ciFiles.getActiveProject();
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log("No projects registered.");
|
||||
console.log("Use 'ciagent projects add <slug> <name>' to add a project.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("─── CIAgent Projects ───\n");
|
||||
for (const project of projects) {
|
||||
const isActive = project.slug === activeProject;
|
||||
const marker = isActive ? " *" : "";
|
||||
console.log(` ${project.slug} — ${project.name}${marker}`);
|
||||
}
|
||||
console.log("\n * = active project");
|
||||
});
|
||||
|
||||
cmd.command("add <slug> <name>")
|
||||
.description("Add a new project")
|
||||
.action((slug: string, name: string) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
ciFiles.addProject(slug, name);
|
||||
console.log(`✓ Project added: ${slug} (${name})`);
|
||||
});
|
||||
|
||||
cmd.command("set <slug>")
|
||||
.description("Set the active project")
|
||||
.action((slug: string) => {
|
||||
const projectPath = process.cwd();
|
||||
|
||||
if (!isCIAgentInitialized(projectPath)) {
|
||||
console.error("CIAgent project not initialized. Run 'ciagent init' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = new CIAgentFiles(projectPath);
|
||||
const projects = ciFiles.listProjects();
|
||||
|
||||
if (!projects.some((p) => p.slug === slug)) {
|
||||
console.error(`Project "${slug}" not found. Registered projects: ${projects.map((p) => p.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
ciFiles.setActiveProject(slug);
|
||||
const config = loadConfig(projectPath);
|
||||
config.active_project = slug;
|
||||
saveConfig(projectPath, config);
|
||||
console.log(`✓ Active project set to: ${slug}`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function createShipCommand(): Command {
|
||||
return new Command("ship")
|
||||
.description("Auto-complete phase: verify, security, commit, tag")
|
||||
@@ -713,6 +792,35 @@ export function createShipCommand(): Command {
|
||||
});
|
||||
console.log(` ✓ Tagged: ${version.tag}`);
|
||||
|
||||
if (config.gitea && config.gitea.owner && config.gitea.repo) {
|
||||
const apiToken = process.env[config.gitea.api_token_env];
|
||||
if (apiToken) {
|
||||
try {
|
||||
const previousTag = getPreviousTag(projectPath, version.tag);
|
||||
const releaseNotes = generateReleaseNotes(projectPath, previousTag, version.tag);
|
||||
|
||||
const giteaClient = new GiteaClient({
|
||||
baseUrl: config.gitea.base_url,
|
||||
token: apiToken,
|
||||
owner: config.gitea.owner,
|
||||
repo: config.gitea.repo,
|
||||
});
|
||||
|
||||
const release = await giteaClient.createRelease({
|
||||
tag_name: version.tag,
|
||||
name: version.tag,
|
||||
body: releaseNotes,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
});
|
||||
|
||||
console.log(` ✓ Release created: ${release.html_url}`);
|
||||
} catch (giteaErr) {
|
||||
console.warn(` ⚠ Gitea release failed: ${giteaErr instanceof Error ? giteaErr.message : String(giteaErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.git.auto_push) {
|
||||
execSync(`git push origin ${version.tag}`, { cwd: projectPath, stdio: "pipe" });
|
||||
console.log(` ✓ Pushed tag: ${version.tag}`);
|
||||
@@ -819,4 +927,20 @@ function resolveMergeTarget(projectPath: string, milestoneType: string): string
|
||||
} catch {}
|
||||
|
||||
return "main";
|
||||
}
|
||||
|
||||
function getPreviousTag(projectPath: string, currentTag: string): string | null {
|
||||
try {
|
||||
const tags = execSync("git tag -l --sort=-v:refname", { cwd: projectPath, encoding: "utf-8" })
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const currentIdx = tags.indexOf(currentTag);
|
||||
if (currentIdx >= 0 && currentIdx + 1 < tags.length) {
|
||||
return tags[currentIdx + 1];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
+13
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
import { Command } from "commander";
|
||||
import { VERSION } from "../version.js";
|
||||
import { CIAgentFiles } from "../core/ciagent-files.js";
|
||||
import { isCIAgentInitialized } from "../core/config.js";
|
||||
import {
|
||||
createInitCommand,
|
||||
createRunCommand,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
createClarifyCommand,
|
||||
createRollbackCommand,
|
||||
createShipCommand,
|
||||
createProjectsCommand,
|
||||
} from "./commands.js";
|
||||
|
||||
const program = new Command();
|
||||
@@ -22,6 +25,14 @@ program
|
||||
.name("ciagent")
|
||||
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
|
||||
.version(VERSION)
|
||||
.option("--project <slug>", "Specify which project to operate on")
|
||||
.hook("preAction", () => {
|
||||
const opts = program.opts();
|
||||
if (opts.project && isCIAgentInitialized(process.cwd())) {
|
||||
const ciFiles = new CIAgentFiles(process.cwd());
|
||||
ciFiles.setProjectSlug(opts.project);
|
||||
}
|
||||
})
|
||||
.addCommand(createInitCommand())
|
||||
.addCommand(createRunCommand())
|
||||
.addCommand(createQuickCommand())
|
||||
@@ -32,6 +43,7 @@ program
|
||||
.addCommand(createAuditCommand())
|
||||
.addCommand(createClarifyCommand())
|
||||
.addCommand(createRollbackCommand())
|
||||
.addCommand(createShipCommand());
|
||||
.addCommand(createShipCommand())
|
||||
.addCommand(createProjectsCommand());
|
||||
|
||||
program.parse();
|
||||
@@ -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 { CommitBuilder } from "./commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./gitea.js";
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./gitea.js";
|
||||
export type { CIAgentConfig } from "../types/config.js";
|
||||
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
|
||||
@@ -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 { CommitBuilder } from "./core/commit-builder.js";
|
||||
export { extractCIAgentBlock, parseCIAgentBlock, parseCommitMessage } from "./core/commit-parser.js";
|
||||
export { GiteaClient, generateReleaseNotes } from "./core/gitea.js";
|
||||
export { VerificationPipeline } from "./verification/index.js";
|
||||
export { StructuralVerification } from "./verification/structural.js";
|
||||
export { BehavioralVerification } from "./verification/behavioral.js";
|
||||
@@ -30,7 +31,7 @@ export { OllamaLocalBackend } from "./backends/ollama-local.js";
|
||||
export { OllamaCloudBackend } from "./backends/ollama-cloud.js";
|
||||
export { ToolRegistry } from "./backends/tool-registry.js";
|
||||
|
||||
export type { CIAgentConfig, AutonomyLevel, ModelProfile } from "./types/config.js";
|
||||
export type { CIAgentConfig, AutonomyLevel, ModelProfile, GiteaConfig } from "./types/config.js";
|
||||
export type { Decision, DecisionCategory } from "./types/decisions.js";
|
||||
export type { Escalation, EscalationType } from "./types/escalation.js";
|
||||
export type { PipelineState, PhaseResult, OrchestratorResult } from "./types/pipeline.js";
|
||||
@@ -44,5 +45,6 @@ export type { CIAgentMetadata, ParsedCIAgentCommit, CommitType, CommitScope, Com
|
||||
export type { ProjectState, BranchInfo } from "./core/git-context.js";
|
||||
export type { PhaseBranchInfo, MilestoneBranchInfo, BranchCreateResult, BranchMergeResult } from "./core/git-branch.js";
|
||||
export type { ProjectMd, RoadmapMd, RequirementsMd, ArchitectureMd } from "./core/ciagent-files.js";
|
||||
export type { GiteaReleaseConfig, GiteaRelease } from "./core/gitea.js";
|
||||
export type { IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection, BackendUnavailableError, Artifact, TokenUsage } from "./backends/types.js";
|
||||
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
|
||||
@@ -66,6 +66,13 @@ export interface GitConfig {
|
||||
auto_push: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaConfig {
|
||||
base_url: string;
|
||||
api_token_env: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -82,6 +89,7 @@ export interface CIAgentConfig {
|
||||
security: SecurityConfig;
|
||||
git: GitConfig;
|
||||
backend: BackendConfigSection;
|
||||
gitea?: GiteaConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
@@ -136,4 +144,10 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
gitea: {
|
||||
base_url: "https://git.cloudinit.dev",
|
||||
api_token_env: "GITEA_TOKEN",
|
||||
owner: "",
|
||||
repo: "",
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = "0.5.0";
|
||||
export const VERSION = "0.7.0";
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user