Compare commits

..

33 Commits

Author SHA1 Message Date
Jon Chery 8ec7748ccb docs(P05): v0.11 hardening — persona md files, version bump, docs update
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 5
milestone: v0.11
status: complete
requirements:
  covered: [PERSONA-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---

Add 4 persona md files, update package.json to 0.11.0, update AGENTS.md with
v0.11 session/persona documentation.
2026-06-01 20:21:27 +00:00
Jon Chery 9ab3b56b96 docs(P08): run workflow pipeline restructure — multi-project paths, sub-workflow delegation, ship gate, milestone completion gate
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 8
milestone: v0.11
status: complete
requirements:
  covered: [PIPELINE-01, PIPELINE-02, PIPELINE-03, PIPELINE-04, PIPELINE-05, PIPELINE-06, PIPELINE-07]
decisions:
  - id: D-098
    decision: Run pipeline stages delegate to sub-workflows instead of reimplementing inline
    rationale: 'Clarify, ideate, verify are fully defined workflows — duplicating them in run.md causes drift and missing details'
    confidence: 0.95
  - id: D-099
    decision: 'EXECUTE includes ship gate — phase must be shipped via ciagent-ship before advancing'
    rationale: 'Prevents advancing to VERIFY/next phase on unshipped code; ship.md has proper validation gates'
    confidence: 0.93
  - id: D-100
    decision: 'COMPLETE orchestrates review → ship(milestone) → audit with feedback loop'
    rationale: 'Replaces inline merge+tag+release with proper sub-workflow delegation; audit catches stale docs and branch hygiene issues'
    confidence: 0.92
  - id: D-101
    decision: 'Multi-project paths use .ciagent/<slug>/ subdirectories throughout'
    rationale: 'Consistent with ci-files-discipline reference and existing multi-project convention in other workflows'
    confidence: 0.97
  - id: D-102
    decision: 'Multi-persona execution integrated into EXECUTE stage'
    rationale: 'config.json personas section already defines territories; lead-developer decomposition and parallel review personas belong in execution'
    confidence: 0.90
---/ci---
2026-06-01 18:27:35 +00:00
Jon Chery 8c975352b8 feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
  - id: D-092
    decision: Independent sessions via AgentSession (not shared state)
    rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
    confidence: 0.90
  - id: D-093
    decision: Personas as runtime configs (not new Agent classes)
    rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
    confidence: 0.88
  - id: D-094
    decision: Lead developer as task decomposer (not separate pipeline stage)
    rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
    confidence: 0.85
  - id: D-095
    decision: File-based git locking (not DB or IPC)
    rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
    confidence: 0.87
  - id: D-096
    decision: Territory enforcement with warn/strict modes
    rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
    confidence: 0.82
  - id: D-097
    decision: Task decomposition by file patterns + requirement IDs
    rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
    confidence: 0.88
requirements:
  covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
2026-06-01 17:43:06 +00:00
Jon Chery 6d0034dc88 docs(P07): release flow hardening — consistent milestone type taxonomy
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
phase: 7
milestone: v0.10
status: complete
decisions:
  - id: D-092
    decision: Rename schema-breaking → major across all framework files
    rationale: Major aligns with semver terminology and is more descriptive of the version bump impact
    confidence: 0.95
  - id: D-093
    decision: Add Major milestone type to dev context and branch-strategy merge validation gates
    rationale: Release flow was documented but not enforced. Zero-HITL, PR+QA, and branch hierarchy are now hard gates
    confidence: 0.92
---/ci---
2026-06-01 16:14:54 +00:00
grimacing a153291643 Merge pull request 'feat(P06): Integration & hardening — INTEG-01..05, MULTI-04, v0.10.0' (#9) from phase/06-integration-hardening into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 15:41:20 +00:00
Jon Chery a0619f9740 feat(P06): Integration & hardening — INTEG-01..05, MULTI-04
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- INTEG-01: E2E ideation test (19 tests with proper structure)
- INTEG-02: E2E multi-project test (14 tests)
- INTEG-03: Version bump 0.9.0 → 0.10.0
- INTEG-04: AGENTS.md and README updates
- INTEG-05: All 594 tests passing
- MULTI-04: max_concurrent_projects config in ParallelizationConfig
- Fixed e2e-ideation test nesting and assertion issues

---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: INTEG-01
    decision: E2E ideation test covers mechanical, acceptance, cascade, external, cross-project, chaos, spec
    rationale: 19 tests covering all ideation engine methods
    confidence: 0.95
  - id: INTEG-03
    decision: Version bumped to 0.10.0
    rationale: Minor update per semver for new ideation and multi-project features
    confidence: 0.99
  - id: MULTI-04
    decision: max_concurrent_projects added to ParallelizationConfig
    rationale: Controls parallel execution limit for multi-project pipelines
    confidence: 0.90
requirements:
  covered: [INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05, MULTI-04]
---/ci---
2026-06-01 15:39:47 +00:00
Jon Chery f478088797 refactor(P06): rename milestone type schema-breaking → major, reinforce release flow
---ci---
phase: 6
milestone: v0.10
status: execute
decisions:
  - id: D-001
    decision: Rename MilestoneType schema-breaking to major for clarity
    rationale: Major better describes the semver impact (major version bump) and aligns with standard semver terminology
    confidence: 0.95
    alternatives: [schema-breaking, breaking, major-change]
  - id: D-002
    decision: Add autopilot rules, PR+QA gates, and merge validation to ship workflow
    rationale: Release flow was documented but not enforced in the workflow. Zero-HITL rules, branch hierarchy validation, and coreci packaging steps ensure consistent releases
    confidence: 0.90
    alternatives: [keep-as-documentation-only, add-to-AGENTS.md-only]
---/ci---
2026-06-01 15:29:43 +00:00
grimacing e2b749d42e Merge pull request 'feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07' (#8) from phase/05-multi-project-ideation into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-06-01 13:58:10 +00:00
Jon Chery c747d3e8be feat(P05): Multi-project ideation support — MULTI-03, MULTI-05, MULTI-07
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 5
milestone: v0.10
status: execute
decisions:
  - id: MULTI-03
    decision: Parallel project execution via OrchestratorAgent.runForAllProjects
    rationale: Sequential by default, parallel when parallelization.enabled with max_concurrent_projects limit
    confidence: 0.85
    alternatives: [single-project-only, manual-iteration]
  - id: MULTI-05
    decision: ideate --project all iterates all active_projects with deduplication
    rationale: Each project gets its own IdeationEngine; ideas deduplicated by project:title key
    confidence: 0.90
    alternatives: [single-project-only, merge-all-ideas]
  - id: MULTI-07
    decision: project field in ---ci--- commit blocks and CommitScope for multi-project tracking
    rationale: CIAgentMetadata.project and CommitScope.project fields propagated through all commit builders
    confidence: 0.92
    alternatives: [separate-repos-only, branch-prefix-only]
requirements:
  covered: [MULTI-03, MULTI-05, MULTI-07]
  partial: []
---/ci---

- Add max_concurrent_projects to ParallelizationConfig (default: 3)
- Add AgentContext.project_slug optional field for multi-project pipeline tracking
- Implement OrchestratorAgent.runForProject() for single-project execution
- Implement OrchestratorAgent.runForAllProjects() for multi-project iteration
  - Sequential execution by default
  - Parallel when parallelization.enabled with limitConcurrency batching
- Add --project flag to createRunCommand for targeted project execution
  - --project all triggers multi-project pipeline
  - --project slug1,slug2 for comma-separated projects
- Enhance createIdeateCommand --project all support
  - Iterates all active projects from config
  - Deduplicates findings by project:title key
  - Per-project idea acceptance via separate IdeationEngine instances
  - Markdown table output for multi-project results
- Propagate project slug through orchestrator pipeline commits
  - Specify stage: project field in CIAgentMetadata init commit
  - Ideate stage: project field in task commit via buildTaskCommit
  - Orchestrator sets ciFiles with project slug for per-project .ciagent dirs
- 19 new tests covering MULTI-03, MULTI-05, MULTI-07 functionality
- All 561 tests pass, typecheck clean
2026-06-01 13:56:43 +00:00
grimacing d9927558d5 Merge pull request 'feat(P04): Cross-project pipeline integration — IDEATE-16, IDEATE-11, IDEATE-18' (#7) from phase/04-cross-project-pipeline into main
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 21:20:34 +00:00
Jon Chery 895d9f95a1 feat(P04): Add IDEATE stage to orchestrator pipeline — IDEATE-16
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
- Add ideation-agent to STAGE_AGENT_MAP for ideate stage
- Implement ideate case in executeStage() with mechanical ideation,
  config-aware category filtering, idea deduplication, auto-accept,
  and ---ci--- commit with decision block
- Add test verifying ideate position between research and plan in
  STAGE_ORDER
- 542 tests passing
2026-05-30 21:17:21 +00:00
Jon Chery 30352a3603 feat(P03): External/cascade tests + --ideate flag on run (#6)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:59:40 +00:00
Jon Chery d58fd0bdde feat(P03): external/cascade tests + --ideate flag on run — IDEATE-07,08,15
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 3
milestone: v0.10
status: execute
decisions:
  - id: D-084
    decision: Dual integration: standalone ciagent ideate + --ideate flag on run
    confidence: 0.90
requirements:
  covered:
    - IDEATE-07
    - IDEATE-08
    - IDEATE-15
---/ci---

- IDEATE-07: External signal collection (npm audit, dependency staleness) tested
- IDEATE-08: Cascade impact analysis (--affected) tested
- IDEATE-15: --ideate flag on ciagent run inserts IDEATE stage between RESEARCH and PLAN
- Tests for runAffected, runExternal, runCrossProject
- 541 tests passing
2026-05-30 20:58:30 +00:00
Jon Chery 0799cfc644 feat(P02): Backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10 (#5)
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
2026-05-30 20:51:36 +00:00
Jon Chery 70ee21856d feat(P02): backend-enriched tier, chaos engineering, prioritization — IDEATE-04,05,06,09,10
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Has been cancelled
---ci---
phase: 2
milestone: v0.10
status: execute
decisions:
  - id: D-087
    decision: All 6 innovative features in v1 (pattern mining, drift detection, layer inversion, cross-project, chaos, spec)
    rationale: User wants bleeding-edge; all uniquely differentiated
    confidence: 0.82
requirements:
  covered:
    - IDEATE-04
    - IDEATE-05
    - IDEATE-06
    - IDEATE-09
    - IDEATE-10
---/ci---

- IDEATE-04: Verification layer inversion (structural, behavioral, security, quality missing detection)
- IDEATE-05: Architectural drift detection (documented vs actual component comparison)
- IDEATE-06: Spec-driven improvement (ambiguity detection, missing category detection)
- IDEATE-09: Backend-enriched analysis (prioritization, novel suggestions, action plans)
- IDEATE-10: Chaos engineering ideation (backend unavailable, requirement change, coverage drop)
- Deduplicated type exports: IdeationSource/Idea/etc now in types/ideation.ts
- 538 tests passing
2026-05-30 20:50:29 +00:00
Jon Chery b7d02ee4a4 feat(P01): interactive validation + doc updates + multi-project CLI — IDEATE-12,13,14 + MULTI-02,06
CI / build-and-test (pull_request) Has been cancelled
Publish to npm / publish (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-083
    decision: Interactive one-at-a-time validation with accept/skip/modify
    rationale: Gives user full control over ideation results
    confidence: 0.87
  - id: D-085
    decision: Ask-after-validation kickoff of run workflow
    rationale: Balances automation with user control
    confidence: 0.85
  - id: D-091
    decision: Full multi-project support with active_projects array + parallel execution
    rationale: User wants complete multi-project capability
    confidence: 0.85
requirements:
  covered:
    - IDEATE-12
    - IDEATE-13
    - IDEATE-14
    - MULTI-02
    - MULTI-06
---/ci---

- IDEATE-12: Interactive accept/skip/modify validation with readline
- IDEATE-13: acceptIdea/acceptIdeas methods update REQUIREMENTS.md and ROADMAP.md
- IDEATE-14: Ask-after-validation kickoff prompt for
- MULTI-02: --project flag accepts comma-separated or 'all' in pre-action hook
- MULTI-06: ciagent status shows active_projects and ideation config
- projects list shows all active projects with multi-marker
- projects set updates both active_project and active_projects
2026-05-30 20:26:36 +00:00
Jon Chery 8e50049ba5 feat(P01): add ideation engine + ciagent ideate command — IDEATE-01,02,03,17 + MULTI-01
---ci---
phase: 1
milestone: v0.10
status: execute
decisions:
  - id: D-080
    decision: Three-tier ideation (mechanical, backend-enriched, cross-project)
    rationale: Mechanical tier always produces output without backend
    confidence: 0.92
  - id: D-089
    decision: No separate codebase map command
    rationale: Git-native + .ciagent/ covers mapping; avoids tree-sitter dep
    confidence: 0.88
requirements:
  covered:
    - IDEATE-01
    - IDEATE-02
    - IDEATE-03
    - IDEATE-17
    - MULTI-01
---/ci---

Add IdeationEngine core module with 15 signal collectors:
- Uncovered/partial requirements from REQUIREMENTS.md
- Coverage gaps (documented but unimplemented agents)
- Repeated lessons from git history
- Low-confidence decisions from ---ci--- blocks
- Escalation patterns from git history
- Compound solution patterns
- Architecture drift (ARCHITECTURE.md vs src/)
- Verification inversion (missing test files)
- Improvement patterns (cross-referencing lessons + requirements)
- Spec ambiguity (should/could/might patterns)
- Spec missing (common requirement categories)
- Cascade impact (--affected from git diff)
- External signals (npm audit, dependency staleness)
- Cross-project lesson mining

Add ciagent ideate CLI command with flags:
--category, --affected, --spec, --external, --cross-project, --output

Add active_projects to CIAgentConfig (backwards compatible with active_project).
Add IDEATE pipeline stage between RESEARCH and PLAN.
Update IdeationAgent to delegate to IdeationEngine.

533 tests passing.
2026-05-30 20:13:43 +00:00
Jon Chery da528cc493 docs: add ideate workflow + update run workflow with IDEATE stage and multi-project
---ci---
phase: 0
milestone: v0.10
status: specify
decisions:
  - id: D-089
    decision: No separate codebase map command — subsumed by ideation
    rationale: Git-native + .ciagent/ covers all mapping needs; avoids tree-sitter dep
    confidence: 0.88
  - id: D-090
    decision: Milestone v0.10 for ideate + multi-project
    rationale: Significant features but not schema-breaking
    confidence: 0.95
---/ci---

- Add opencode/ci/workflows/ideate.md: full ideation pipeline specification
- Update opencode/ci/workflows/run.md: add IDEATE stage, update multi-project Step 0
2026-05-30 19:45:30 +00:00
Jon Chery a8b50f5109 feat(ci): v0.9.0 — Distribution & Expansion milestone complete
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
---ci---
project: ci
phase: 6
milestone: v0.9
status: complete
artifacts:
  tags: [v0.9.0]
decisions:
  - id: D-047
    decision: v0.9 theme = Distribution & Expansion
    rationale: npm publish + OpenAI/Anthropic backends + agent flesh + parallel execution
    confidence: 0.92
  - id: D-049
    decision: Feature milestone — patch tags v0.8.1-v0.8.6 then v0.9.0
    rationale: OpenAI backend, agent flesh, npm publish all feat
    confidence: 0.95
  - id: D-059
    decision: Rename OllamaBaseBackend to LLMBaseBackend + thin OllamaBaseBackend subclass
    rationale: 15 of 17 methods backend-agnostic
    confidence: 0.92
  - id: D-060
    decision: OpenAI/Anthropic backends use native fetch() not SDK packages
    rationale: No dependency bloat; fetch native in Node 18+
    confidence: 0.85
  - id: D-066
    decision: Concurrency limiter internal (no p-limit dependency)
    rationale: 15 lines; avoids dependency for trivial feature
    confidence: 0.90
  - id: D-067
    decision: Promise.allSettled for review agents at orchestrator lines 373-400
    rationale: Current sequential loop replaced with parallel execution
    confidence: 0.88
requirements:
  covered: [PUBLISH-01, PUBLISH-02, PUBLISH-03, PUBLISH-04, OPENAI-01, OPENAI-02, OPENAI-03, OPENAI-04, OPENAI-05, FLESH-01, FLESH-02, FLESH-03, FLESH-04, FLESH-05, ANTHROPIC-01, ANTHROPIC-02, FLESH-06, FLESH-07, NPM-01, NPM-02, PARALLEL-01, PARALLEL-02, PARALLEL-03, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---

6 phases, 28 tasks, 4077 net lines added, 57 test suites, 527 tests, zero stub agents
2026-05-30 02:19:44 +00:00
Jon Chery 4b7d16247d docs(ci): complete milestone v0.8 — merge phase/01-critical-fixes
---ci---
project: ci
phase: 6
milestone: v0.8
status: complete
decisions:
  - id: D-037
    decision: v0.8.0 — Verification Intelligence + Critical Fixes
    rationale: All 6 phases complete; 44 test suites, 454 tests passing; verification layers now deliver what they claim
    confidence: 0.95
requirements:
  covered: [FIX-01, FIX-02, FIX-03, FIX-04, FIX-05, FIX-06, FIX-07, BEH-01, BEH-02, BEH-03, BEH-04, BEH-05, SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06, QUAL-01, QUAL-02, QUAL-03, QUAL-04, QUAL-05, AGENT-01, AGENT-02, AGENT-03, AGENT-04, INT-01, INT-02, INT-03, INT-04, INT-05, INT-06, INT-07, INT-08]
---/ci---

Merged commits from phase/01-critical-fixes covering:
- Phase 1: Critical Fixes (7 tasks) — orchestrator phase hardcode, Zod validation, opencode fallback, audit git-native, signal handlers
- Phase 2: Behavioral Intelligence (5 tasks) — test execution pipeline, stub generation
- Phase 3: Security Intelligence (6 tasks) — full STRIDE + CWE, reduced FP, confidence disposition
- Phase 4: Quality Intelligence (5 tasks) — 3-persona review, flesh CodeReviewerAgent, fixed L4 pass/fail
- Phase 5: Agent Flesh (4 tasks) — SecurityAuditorAgent, DocWriterAgent, DebuggerAgent, ChallengerAgent
- Phase 6: Integration & Hardening (8 tasks) — E2E test, docs, mechanical fallbacks, v0.8.0
2026-05-29 20:47:53 +00:00
Jon Chery 70f9f720e6 feat(P06): integration \u0026 hardening — version 0.8.0, agent tests, E2E, docs, fallbacks
---ci---
project: ci
phase: 6
milestone: v0.8
status: complete
decisions:
  - id: D-037
    decision: v0.8.0 release with 6 phases complete
    rationale: All verification layers now deliver what they claim
    confidence: 0.95
requirements:
  covered: [INT-01, INT-02, INT-03, INT-04, INT-05, INT-06, INT-07, INT-08]
---/ci---

INT-06: Version bumped to 0.8.0 in package.json and src/version.ts.

INT-07: New test suites for SecurityAuditorAgent (5 tests), DocWriterAgent
(5 tests), DebuggerAgent (5 tests), ChallengerAgent (4 tests).

INT-08: Zod validation test suite with 9 cases: valid input, missing
fields, path traversal, absolute paths, contradictory success+error,
invalid operation, negative tokens, fail+error, emptyBackendResult.

INT-04: ciagent review command now has mechanical fallback — runs
CodeReviewerAgent regex review without backend.

INT-05: ciagent debug command now has mechanical fallback — runs
DebuggerAgent stack trace parsing + git bisect without backend.

INT-01: E2E verification test — fixture with defects fails L3/L4; clean
project passes all 4 layers.

INT-02: AGENTS.md updated — removed 'not yet implemented' caveats for
L2/L3/L4; updated test count to 44 suites, 454 tests.

INT-03: PROJECT.md updated — removed Out of Scope for STRIDE,
multi-persona review, and behavioral test generation.
2026-05-29 20:46:44 +00:00
Jon Chery 93967feb68 feat(P05): flesh 4 agents with intrinsic mechanical logic
---ci---
project: ci
phase: 5
milestone: v0.8
status: complete
decisions:
  - id: D-033
    decision: Flesh SecurityAuditorAgent with STRIDE-aware mechanical scanning
    rationale: Runs L3 security patterns intrinsically; no backend required
    confidence: 0.90
  - id: D-034
    decision: Flesh DocWriterAgent with template-based doc update
    rationale: Updates ROADMAP.md phase status, REQUIREMENTS.md req status, reads git log for new decisions
    confidence: 0.85
  - id: D-035
    decision: Flesh DebuggerAgent with stack trace parsing + git bisect
    rationale: Parses stack traces to find file:line, bisects to find introducing commit
    confidence: 0.80
  - id: D-036
    decision: Flesh ChallengerAgent with plan DAG/wave/must-have/REQ validation
    rationale: Validates plan structure mechanically; catches circular deps and gaps
    confidence: 0.82
requirements:
  covered: [AGENT-01, AGENT-02, AGENT-03, AGENT-04]
---/ci---

AGENT-01: SecurityAuditorAgent.mechanicalAudit() runs STRIDE+ CWE pattern
scan intrinsically. Each finding has stride_category, cwe, severity, and
disposition (accept/mitigate/flag based on confidence threshold).

AGENT-02: DocWriterAgent.mechanicalDocUpdate() reads plan data, updates
.ciagent/ROADMAP.md phase status to complete, .ciagent/REQUIREMENTS.md
pending→covered, and reads git log for new decision entries.

AGENT-03: DebuggerAgent.mechanicalDebug() parses stack traces (4 regex
patterns for different formats), identifies root file:line, runs
git bisect to find introducing commit, suggests git revert.

AGENT-04: ChallengerAgent.mechanicalChallenge() validates plan structure:
circular dependency detection via DFS, wave ordering validation,
must-haves presence check, and requirement coverage check.
2026-05-29 20:30:45 +00:00
Jon Chery 07e5e70c9b feat(P04): 3-persona code review, fix L4 pass/fail, flesh CodeReviewerAgent
---ci---
project: ci
phase: 4
milestone: v0.8
status: complete
decisions:
  - id: D-031
    decision: 3-persona quality review: security, performance, maintainability
    rationale: Each persona detects different class of issues; aggregate gives complete picture
    confidence: 0.82
  - id: D-032
    decision: L4 P0>0 = fail (not P0>3); P1 = warning (not pass)
    rationale: Any P0 finding is critical; P1 findings should never pass silently
    confidence: 0.95
requirements:
  covered: [QUAL-01, QUAL-02, QUAL-03, QUAL-04, QUAL-05]
---/ci---

QUAL-01: Added 3-persona review with distinct pattern sets: SecurityReviewer
(injection, auth, crypto), PerformanceReviewer (sync I/O, timer leaks,
DoS), MaintainabilityReviewer (type safety, dead code, tech debt).

QUAL-02: CodeReviewerAgent fleshed with mechanical 3-persona review. Works
without backend by running regex-based scan across all personas.

QUAL-03: L4 passed=false when ANY P0 finding exists (was >3). P1 findings
now return status='warning' (was always 'pass').

QUAL-04: TypeScript strict mode check remains in quality layer.

QUAL-05: CodeReviewerAgent.mechanicalReview() provides regex-based review
as fallback when no backend is available.
2026-05-29 20:26:21 +00:00
Jon Chery f7fff95cbe feat(P03): full STRIDE + CWE security verification with reduced false positives
---ci---
project: ci
phase: 3
milestone: v0.8
status: complete
decisions:
  - id: D-029
    decision: Full STRIDE 7-category coverage with CWE mapping
    rationale: Industry standard threat classification with actionable CWE remediation
    confidence: 0.88
  - id: D-030
    decision: Reduce exec/eval false positives via string interpolation detection
    rationale: execSync("ls") is safe; execSync(`rm ${x}`) is not
    confidence: 0.85
requirements:
  covered: [SEC-01, SEC-02, SEC-03, SEC-04, SEC-05, SEC-06]
---/ci---

SEC-01: Fixed STRIDE category misassignments. Hardcoded password is
information_disclosure (CWE-259), not spoofing. exec with interpolation
is elevation_of_privilege (CWE-78), not tampering. All 17 patterns
correctly categorized.

SEC-02: Added missing STRIDE categories: repudiation (empty catch blocks,
CWE-778) and spoofing (jwt.decode without verify, CWE-287). Also added
denial_of_service (JSON body parser without size limit, CWE-400) and
prototype pollution (CWE-1321), weak crypto (CWE-328), unsafe
deserialization (CWE-502), path traversal (CWE-22).

SEC-03: Reduced false positives: exec/eval patterns now require string
interpolation (template literal or dynamic concat), not all exec/calls.

SEC-04: Every SECURITY_PATTERNS entry has a cwe field with valid CWE ID.

SEC-05: Confidence-based auto-disposition: each pattern has a confidence
score. High confidence findings are flagged, medium require verification,
low are suppressed. Threshold configurable via constructor.

SEC-06: Security passed=false when any high-severity finding exists
(already enforced by hasHighFail check, now more explicit).
2026-05-29 20:23:09 +00:00
Jon Chery d3186cde06 feat(P02): behavioral verification now executes tests and reports real pass/fail
---ci---
project: ci
phase: 2
milestone: v0.8
status: complete
decisions:
  - id: D-027
    decision: L2 behavioral verification runs npm test via jest --json
    rationale: Static-only checks gave false confidence; real test execution shows actual status
    confidence: 0.92
  - id: D-028
    decision: Add must-have stub test generation to behavioral verification
    rationale: Plans specify must_haves; auto-generating stubs ensures test coverage
    confidence: 0.85
requirements:
  covered: [BEH-01, BEH-02, BEH-03, BEH-04, BEH-05]
---/ci---

BEH-05: Behavioral verification passed=false when any check has status=fail
(added checkTestExecution that returns fail on test failures).

BEH-01: checkTestFramework now actually runs tests via jest --json
--outputFile and parses the JSON results, reporting pass/fail counts.

BEH-02: checkTestFiles now reports per-suite pass/fail from jest output,
not just file existence.

BEH-03: New checkTestExecution() runs npm test, parses Jest JSON output,
collects coverage metrics from coverage-summary.json, and returns
fail/pass based on test execution results.

BEH-04: New generateMustHaveStubTests() method produces .test.ts
skeletons from must-have descriptions.
2026-05-29 20:18:22 +00:00
Jon Chery d6ba76e660 fix(P01): add SIGTERM/SIGINT signal handlers for graceful shutdown
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-026
    decision: Graceful drain on SIGTERM/SIGINT: dispose timers then exit
    rationale: Prevents orphaned setTimeout timers from leaking when process is killed
    confidence: 0.88
requirements:
  covered: [FIX-07]
---/ci---

FIX-07: cli/index.ts registers SIGTERM/SIGINT handlers that call
escalationProtocol.dispose() before process.exit. OrchestratorAgent
registers its EscalationProtocol instance via registerEscalationProtocol().
SIGINT exits with code 130, SIGTERM with 143 (standard signal+128 convention).
2026-05-29 20:05:48 +00:00
Jon Chery 04c4489e70 fix(P01): migrate audit trail to git-native and replace audit_file with commit_hash
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-024
    decision: Audit trail reads from git log instead of .ciagent/audit/*.json
    rationale: Git-native context means audit data should come from commit history, not files
    confidence: 0.88
  - id: D-025
    decision: Replace audit_file with commit_hash in Escalation type
    rationale: Escalations are committed to git; reference by hash instead of deprecated file path
    confidence: 0.90
requirements:
  covered: [FIX-04, FIX-05]
---/ci---

FIX-04: audit.ts logDecision/logEscalation now emit deprecation warnings
and are no-ops (decisions/escalations live in ---ci--- blocks). readAudit()
and getAuditSummary() parse git log for ---ci--- blocks instead of reading
.ciagent/audit/*.json files. ArtifactManager no longer creates audit dir.

FIX-05: Escalation type replaces audit_file: string with commit_hash: string.
All consumers updated (escalation.ts, ollama-base.ts, opencode.ts).
Audit tests rewritten for git-native approach.
2026-05-29 20:02:07 +00:00
Jon Chery 5fb285cf46 fix(P01): add Zod BackendResult validation and fix opencode silent success
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-022
    decision: Validate BackendResult at boundary with Zod schema
    rationale: External backend output is untrusted; runtime validation prevents corrupt commit streams
    confidence: 0.92
  - id: D-023
    decision: opencode parseResult returns success:false on malformed JSON
    rationale: Silent success:true on parse failure masks backend errors; fail loudly instead
    confidence: 0.95
requirements:
  covered: [FIX-02, FIX-03]
---/ci---

FIX-02: Add Zod BackendResultSchema and validateBackendResult() in
backends/types.ts. backendResultToAgentResult() in base.ts now validates
before passing through. Invalid results produce success:false with error
detail. Path traversal protection: artifact paths with '..' or leading '/'
are rejected.

FIX-03: opencode.ts parseResult() no longer defaults to success:true when
JSON parsing fails entirely. Both the inner parse error and the no-JSON
match case now return emptyBackendResult() with descriptive error messages.
2026-05-29 19:52:51 +00:00
Jon Chery 2306493a77 fix(P01): replace hardcoded phase=1 in orchestrator and fix getDecisions double-fetch
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-021
    decision: 6-phase wave-ordered vertical slices for v0.8
    rationale: Each phase independently demoable; critical fixes first
    confidence: 0.90
requirements:
  covered: [FIX-01, FIX-06]
---/ci---

FIX-01: Replace 5 hardcoded phase=1 literals in orchestrator.ts mechanical
execution path with this.pipelineState!.current_phase. The orchestrator
correctly tracks current_phase but commits always embedded literal 1.

FIX-06: Replace getDecisions() redundant double-fetch with single
getRecentCommits(50) call, delegating to existing getDecisionsFromCommits().
Old code called getRecentCommits(50) once per grep match entry (O(N*M)
when it should be O(1)).
2026-05-29 19:46:46 +00:00
Jon Chery a416413c7d feat(P06): docs & hardening — AGENTS.md/README fixes, agent tests, Gitea tests, multi-project tests, version 0.7.0
---ci---
phase: 6
milestone: v0.7.0
plan: 06
task: P06-all
status: execute
---/ci---
2026-05-29 18:20:46 +00:00
Jon Chery e8c6c5c917 feat(P05): ship infrastructure — Gitea API client, release notes, npm publishConfig, ciagent projects cmd, --project flag
---ci---
phase: 5
milestone: v1.0
plan: 05
task: SHIP-01-04 MULTI-01 MULTI-02
status: execute
---/ci---
2026-05-29 18:15:58 +00:00
Jon Chery 4de1f65c10 feat(P04): pipeline stage delegation — EXECUTE=3 agents, TEST=tester, VERIFY=verifier, COMPLETE=doc-writer+ship
---ci---
phase: 4
milestone: v1.0
plan: 04
task: PIPE-01-04
status: execute
---/ci---
2026-05-29 18:13:39 +00:00
Jon Chery 6902c37ced fix(P03): improve planner task descriptions — avoid redundant REQ-ID in task lines
---ci---
phase: 3
milestone: v0.6.0
plan: 03
task: 03-03
status: execute
---/ci---
2026-05-29 18:11:49 +00:00
116 changed files with 13992 additions and 874 deletions
+19
View File
@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main, "phase/*", "milestone/*"]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- run: npm pack --dry-run
+20
View File
@@ -0,0 +1,20 @@
name: Publish to npm
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+42 -28
View File
@@ -19,15 +19,19 @@ src/
backends/ # Intelligence backend layer
types.ts # IntelligenceBackend, BackendRequest, BackendResult, BackendConfigSection
tool-registry.ts # CIAgent-owned tool implementations (readFile, writeFile, editFile, runBash, glob, grep)
ollama-base.ts # Abstract base for Ollama backends (shared tool loop, prompt construction)
llm-base.ts # Abstract base for LLM backends (shared tool loop, prompt construction)
ollama-local.ts # OllamaLocalBackend (localhost:11434)
ollama-cloud.ts # OllamaCloudBackend (remote endpoint, auth, rate limiting)
openai.ts # OpenAIBackend (OpenAI API, gpt-4o)
anthropic.ts # AnthropicBackend (Anthropic API, Claude)
opencode.ts # OpencodeBackend (shells out to opencode --non-interactive)
index.ts # Backend registry + auto-detection
cli/ # Commander.js CLI (commands.ts, index.ts)
core/ # Core engine components
cli/ # Commander.js CLI (commands.ts, index.ts, 14 commands including sessions)
core/ # Core engine components
artifacts.ts # Legacy .ciagent/ artifact management (retained for backward compat)
audit.ts # Legacy audit trail in .ciagent/audit/ (retained for backward compat)
audit.ts # Git-native audit trail — reads decisions/escalations from git log
agent-session.ts # Multi-session support: AgentSession, file-based git locking
session-manager.ts # SessionManager: concurrent session lifecycle management
ciagent-files.ts # .ciagent/ long-lived reference file management (PROJECT.md, ROADMAP.md, etc.)
clarify.ts # Clarify phase: question generation, default acceptance
commit-builder.ts # Structured commit message generation (---ci--- YAML blocks)
@@ -38,14 +42,18 @@ src/
escalation.ts # Escalation protocol: commits escalations as git artifacts
git-branch.ts # Branch lifecycle: phase/NN-slug, milestone/vX.X-slug
git-context.ts # Project state reconstruction from git log + branches
persona-loader.ts # Execute-time persona resolution from .config/opencode/agents/*.md
task-decomposer.ts # Plan decomposition into data/backend/frontend task groups
types/ # Type definitions
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, DEFAULT_CIAGENT_CONFIG (includes backend)
commit-meta.ts # CIAgentMetadata, CommitDecision, CommitEscalation, ParsedCIAgentCommit (includes session field)
config.ts # CIAgentConfig, AutonomyLevel, ModelProfile, SessionConfig, PersonaConfigSection, DEFAULT_CIAGENT_CONFIG (includes backend)
decisions.ts # Decision, ConfidenceLevel, DecisionCategory
escalation.ts # Escalation, EscalationType, EscalationResolution
clarify.ts # ClarifyQuestion, ClarifyResult
specification.ts # Specification parser (objective, requirements, constraints, out_of_scope)
pipeline.ts # PipelineStage, PipelineState, PhaseResult, STAGE_ORDER
persona.ts # ExecutePersonaConfig, PersonaDomain, TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS
session.ts # SessionInfo, SessionStatus, SessionConfig, DEFAULT_SESSION_CONFIG
utils/ # File utilities (readFile, writeFile, ensureDir, readJSON, writeJSON)
verification/ # 4-layer verification pipeline
structural.ts # Layer 1: file existence, imports wired, no stubs
@@ -53,7 +61,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.11.0"
templates/ # Template files (config.json, DECISIONS.md, specification.md)
```
@@ -82,10 +90,10 @@ templates/ # Template files (config.json, DECISIONS.md, specification.md
## Pipeline Flow
```
SPECIFY → CLARIFY → RESEARCH → PLAN → EXECUTE → VERIFY → COMPLETE
SPECIFY → CLARIFY → RESEARCH → IDEATE → 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
@@ -94,7 +102,8 @@ IntelligenceBackend (unified interface)
├── LLMBackend (CIAgent runs tool loop, provides tools, constructs prompts)
│ ├── OllamaLocalBackend (localhost:11434, no auth)
│ ├── OllamaCloudBackend (remote endpoint, API key, rate limits)
── (future: OpenAI, Anthropic, Gemini, etc.)
── OpenAIBackend (OpenAI API, gpt-4o, API key auth)
│ └── AnthropicBackend (Anthropic API, Claude, API key auth)
└── AgentBackend (agent runs own tool loop, CIAgent sends request)
├── OpencodeBackend (opencode --non-interactive)
└── (future: Codex, Claude Code, Hermes, etc.)
@@ -102,8 +111,8 @@ IntelligenceBackend (unified interface)
- **LLM backends**: CIAgent constructs system prompts from persona.md + workflow.md, defines tool schemas, runs the tool-call loop via `ToolRegistry`, and parses structured JSON output
- **Agent backends**: CIAgent serializes `BackendRequest`, invokes the agent, and parses JSON `BackendResult` from stdout
- **Auto-detection** (provider: "auto"): tries opencode → ollama-local → ollama-cloud → fails with instructions
- **Per-command override**: `ciagent run --backend ollama-local` forces a specific backend
- **Auto-detection** (provider: "auto"): tries opencode → openai → ollama-local → ollama-cloud → anthropic → fails with instructions
- **Per-command override**: `ciagent run --backend ollama-local` forces a specific backend (options: opencode, openai, anthropic, ollama-local, ollama-cloud)
- **Config**: `backend` section in `.ciagent/config.json` with provider, fallback, agent_backends, llm_backends
## Agent Modification Rules (from PRD)
@@ -122,16 +131,16 @@ IntelligenceBackend (unified interface)
## Verification Layers
1. **Structural**: Files exist, imports wired, no stubs/TODOs
2. **Behavioral**: Check test infrastructure and requirement traceability (static analysis — test generation not yet implemented)
3. **Security**: Regex-based threat pattern scanning with auto-disposition (STRIDE analysis not yet implemented)
4. **Code Quality**: Regex-based code quality checks (multi-persona review not yet implemented)
2. **Behavioral**: Test execution and requirement traceability — runs test framework, parses results, reports pass/fail per suite
3. **Security**: Full STRIDE threat pattern scanning with CWE mapping and confidence-based auto-disposition
4. **Code Quality**: 3-persona code review (security, performance, maintainability) with P0/P1/P2 findings
## Testing
- Test framework: Jest with ts-jest
- Test file pattern: `**/*.test.ts` in `src/`
- Run: `npm run test`
- 31 test suites, 370 tests covering types, core, git-native, verification, and utility modules
- 62 test suites, 641 tests covering types, core, git-native, verification, agent, backends, ideation, multi-project, session, persona, and utility modules
- Tests use temp directories (os.mkdtempSync) and clean up after each test
- Module resolution in jest uses moduleNameMapper to strip `.js` extensions
@@ -191,16 +200,21 @@ IntelligenceBackend (unified interface)
## Current State
- **v0.6.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
- **v0.11.0**: Multi-Session & Persona Specialization — AgentSession with file-based git locking, SessionManager with concurrent session batches, PersonaLoader reading ci-*.md files, TaskDecomposer with territory conflict resolution, `ciagent sessions` CLI (list/status/cancel/cleanup), `--session <id>` flag on `ciagent run`, `---ci--- session:` field, `sessions` and `personas` config sections, 4 built-in personas (lead-developer, data-engineer, backend-engineer, frontend-engineer), territory enforcement with warn/strict modes
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **v0.9.0**: Integration & hardening — OpenAI and Anthropic backends, all 19 agents with intrinsic mechanical logic, E2E v0.9 integration tests, parallel agent execution
- **v0.8.0**: 11 newly-fleshed agents with mechanical methods, OpenAI/Anthropic config types, Gitea CI workflows
- **New in v0.11**: Multi-session support with `SessionManager` and `AgentSession` for independent project pipelines running concurrently, execute-phase persona specialization (`lead-developer`, `data-engineer`, `backend-engineer`, `frontend-engineer`) with territory enforcement and task decomposition, `ciagent sessions` CLI command with list/status/cancel/cleanup subcommands, `--session <id>` flag on `ciagent run`, `---ci--- session:` commit metadata field, `sessions` and `personas` config sections
- **v0.10.0**: Ideate & Multi-Project — 3-tier ideation engine, `ciagent ideate` command, multi-project execution, `---ci--- project:` blocks, E2E tests
- **New backends (v0.9)**: OpenAIBackend (gpt-4o, API key auth, OpenAI-Organization header), AnthropicBackend (Claude, API key auth, anthropic-version header, tool use translation)
- **Config expansion (v0.11)**: `sessions` section (max_concurrent_sessions, session_timeout_ms, session_isolation), `personas` section (enabled, territory_enforcement, personas[] with name/domain/frameworks/constraints/territory); `---ci--- session:` field in commit blocks
- **Config expansion (v0.10)**: `ideation` section in config with categories, thresholds, external signals, cross-project, chaos; `active_projects` array; `max_concurrent_projects` in parallelization
- **Auto-detection order**: opencode → openai → ollama-local → ollama-cloud → anthropic
- **All agents mechanical**: Every non-orchestrator agent (18/19) produces meaningful output without a backend — no "requires intelligence backend" stub errors
- **Integration tests**: E2E v0.10 tests verify ideation CLI (mechanical tier), multi-project execution, all-agents-mechanical, parallel execution
- **Pipeline stages**: SPECIFY → CLARIFY → RESEARCH → **IDEATE** → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
- **Commit schema**: Every CIAgent-generated commit contains a `---ci---` YAML block with phase, milestone, status, decisions, escalations, requirements, lessons, compound, and **project** metadata
- **Branch strategy**: `phase/NN-slug` and `milestone/vX.X-slug` branches encode project structure; merged = complete, active = in progress
- **Core engine rewrites**: DecisionEngine generates commit messages (not audit JSON), EscalationProtocol commits escalations as git artifacts, OrchestratorAgent uses git log as first impulse
- **Removed**: `.ciagent/audit/` directory (audit trail is git log), `.planning/` directory (dynamic state derived from git history)
- **`.ciagent/` contents**: `config.json`, `PROJECT.md`, `ARCHITECTURE.md`, `ROADMAP.md`, `REQUIREMENTS.md` — long-lived reference docs updated with discipline
- **Reconstruction test**: An agent with only commit message access can reconstruct project state (phase, decisions, requirements coverage, lessons, escalations)
- **Verification layers**: All 4 layers implemented — structural, behavioral, security, quality
- **CLI**: All 11 commands wired up (`init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`)
- **Agent implementations**: Persona loaders that delegate to active backend. Fail honestly when no backend is available (no more fake success).
- **Intelligence backends**: OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → ollama-local → ollama-cloud.
- **Tests**: 31 test suites, 370 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, all 4 verification layers, file utils, backends, tool-registry
- **CLI commands**: `init`, `run`, `quick`, `debug`, `verify`, `review`, `status`, `audit`, `clarify`, `rollback`, `ship`, `ideate`, `projects`, `sessions`
- **Intelligence backends**: 5 options — OpenAI (LLM), Anthropic (LLM), OllamaLocal (LLM, localhost), OllamaCloud (LLM, remote), Opencode (Agent, --non-interactive). Auto-detection: opencode → openai → ollama-local → ollama-cloud → anthropic.
- **Tests**: 62 test suites, 641 tests covering types, config, decision-engine, escalation, clarify, commit-parser, commit-builder, git-context, git-branch, ciagent-files, ideation, multi-project, session-manager, persona-system, all 4 verification layers, file utils, backends (ollama, openai, anthropic, opencode, tool-registry), agents (all 18 non-orchestrator), zod validation, E2E, parallel execution
+133 -14
View File
@@ -8,6 +8,20 @@ CIAgent (Continuous Intelligence) is an autonomous-first software engineering ha
**The git log IS the project memory.** Every decision, escalation, lesson learned, and verification result is encoded in commit messages using structured `---ci---` YAML blocks. An agent's first impulse to gather context is `git log`, not file reads. Another agent with access to only commit messages (no code, no diffs) can reconstruct the project state completely.
## Intelligence Backends
CIAgent supports 5 intelligence backends. Set the appropriate environment variable and use `--backend` to select:
| Backend | Setup | Usage |
|---------|-------|-------|
| **OpenAI** | `export OPENAI_API_KEY=sk-...` | `ciagent run --backend openai` |
| **Anthropic** | `export ANTHROPIC_API_KEY=sk-ant-...` | `ciagent run --backend anthropic` |
| **Ollama Local** | `ollama serve` (localhost:11434) | `ciagent run --backend ollama-local` |
| **Ollama Cloud** | `export OLLAMA_CLOUD_API_KEY=...` | `ciagent run --backend ollama-cloud` |
| **Opencode** | `npm i -g opencode` | `ciagent run --backend opencode` |
Auto-detection (`--backend auto`, the default) tries: opencode → openai → ollama-local → ollama-cloud → anthropic.
## Installation
From source (package not yet published to npm):
@@ -38,12 +52,39 @@ ciagent run plan
ciagent run execute
ciagent run verify
# Run with specific backends
ciagent run --all --backend openai
ciagent run --all --backend anthropic
ciagent run --all --backend ollama-local
# Execute an ad-hoc task
ciagent quick "Add authentication middleware"
# Check project status (reads from git log + branches)
ciagent status
# Discover improvement opportunities
ciagent ideate # Mechanical tier (always available)
ciagent ideate --category security # Focus on specific categories
ciagent ideate --affected # Cascade impact analysis
ciagent ideate --spec # Specification completeness analysis
ciagent ideate --external # npm audit + dependency staleness
ciagent ideate --cross-project # Cross-project pattern mining
ciagent ideate --project all # Run across all active projects
ciagent ideate --output json # JSON output mode
ciagent ideate --output markdown # Markdown output mode
# Manage multiple projects
ciagent projects list # List all registered projects
ciagent projects add <slug> <name> # Add a new project
ciagent projects set <slug> # Set the active project
# Run with ideation stage
ciagent run --ideate # Insert IDEATE stage between RESEARCH and PLAN
# Run across all active projects
ciagent run --project all # Execute pipeline for each project
# Review autonomous decisions (extracted from git log ---ci--- blocks)
ciagent audit
ciagent audit --verbose
@@ -58,7 +99,7 @@ ciagent rollback 1
ciagent ship 1
```
## Git-Native Architecture (v0.2.0)
## Git-Native Architecture (v0.10.0)
### The Commit Schema
@@ -92,7 +133,7 @@ requirements:
| Where | What | Why |
|-------|------|-----|
| `.ciagent/config.json` | Autonomy, thresholds, git strategy | Controls system behavior before any commits exist |
| `.ciagent/config.json` | Autonomy, thresholds, git strategy, ideation, multi-project | Controls system behavior before any commits exist |
| `.ciagent/PROJECT.md` | Vision, core value, requirements, constraints, key decisions table | Long-lived strategic reference |
| `.ciagent/ARCHITECTURE.md` | System architecture, component boundaries, data flow | Long-lived technical reference |
| `.ciagent/ROADMAP.md` | Phase breakdown, milestone mapping, success criteria | Long-lived planning reference |
@@ -185,7 +226,8 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"parallelization": {
"enabled": true,
"max_concurrent_agents": 5,
"min_plans_for_parallel": 2
"min_plans_for_parallel": 2,
"max_concurrent_projects": 3
},
"verification": {
"automated_only": true,
@@ -202,6 +244,25 @@ CIAgent uses `.ciagent/config.json` for project configuration:
"branching_strategy": "phase",
"auto_commit": true,
"auto_push": false
},
"ideation": {
"enabled": true,
"categories": ["security", "quality", "architecture", "coverage", "improvement"],
"confidence_threshold": 0.6,
"max_ideas": 20,
"external_signals": {
"npm_audit": true,
"osv_advisories": true,
"dependency_staleness": true
},
"cross_project": {
"enabled": false,
"similarity_weight": 0.5
},
"chaos": {
"enabled": true,
"scenarios": ["backend_unavailable", "requirement_change", "test_coverage_drop"]
}
}
}
```
@@ -211,9 +272,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 → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
↕ ↕
(questions) (auto-decide) (ideas) (auto-run) (auto-test) (auto-verify)
```
### Git-Native Core Modules
@@ -235,7 +296,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 +305,76 @@ 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 |
| security-auditor | Security audit | Auto-dispositions threats (STRIDE + CWE) |
| debugger | Bug fixing | Auto-fixes when confidence > threshold |
| Others | Various | Retained from Learnship |
| code-reviewer | Code review | 3-persona review (security, performance, maintainability) |
| doc-writer | Documentation | Auto-updates ROADMAP/REQUIREMENTS/PROJECT.md |
| doc-verifier | Doc audit | Cross-checks docs vs. codebase (agent count, version, test count) |
| ideation-agent | Improvement ideas | Feeds uncovered requirements and repeated lessons into planning |
| roadmapper | Roadmap creation | Groups requirements by phase, generates success criteria |
| plan-checker | Plan validation | Checks structure, IDs, must-haves, wave order, requirement coverage |
| project-researcher | Ecosystem research | Detects frameworks, APIs, patterns, tooling from package.json |
| research-synthesizer | Research merge | Cross-references findings across .ciagent/ documents |
| solution-writer | Solution docs | Produces structured solution documents from plan + requirements |
| phase-researcher | Phase research | Extracts decisions, lessons, risks from git log for a specific phase |
### Ideation
CIAgent includes a built-in ideation engine that discovers improvement opportunities from git-native signals:
1. **Tier 1 — Mechanical**: Mines git history for uncovered requirements, repeated lessons, low-confidence decisions, escalation patterns, coverage gaps, architecture drift, and verification inversions
2. **Tier 2 — Backend-enriched**: When a backend is available, prioritizes mechanical findings and suggests novel improvements
3. **Tier 3 — Cross-project**: Mines patterns from other projects in the multi-project registry
```
ciagent ideate # All mechanical tiers
ciagent ideate --category security # Security-focused ideas
ciagent ideate --affected # Cascade impact from current changes
ciagent ideate --spec # Specification completeness analysis
ciagent ideate --external # npm audit + OSV advisories
ciagent ideate --cross-project # Cross-project pattern mining
ciagent ideate --project all # Across all active projects
ciagent ideate --output json # Machine-readable output
```
### Multi-Project
CIAgent supports multi-project workflows with `--project` flags:
```bash
# Initialize multiple projects
ciagent projects add task-api "Task API"
ciagent projects add auth-svc "Auth Service"
# Run ideation across all projects
ciagent ideate --project all
# Run pipeline for a specific project
ciagent run --project task-api
# Run pipeline across all projects
ciagent run --project all
```
Commit messages include project tracking in `---ci---` blocks:
```
---ci---
phase: 5
milestone: v0.10
project: task-api
status: execute
---/ci---
```
### 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 execution and requirement traceability — runs test framework, parses results, reports pass/fail per suite
3. **Security**: STRIDE threat pattern scanning with CWE mapping and confidence-based auto-disposition
4. **Code Quality**: 3-persona code review (security, performance, maintainability) with P0/P1/P2 findings
## Specification Format
@@ -292,9 +412,8 @@ 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**: All 18 non-orchestrator agents have intrinsic mechanical logic. Full LLM-powered agent behavior requires an intelligence backend (OpenAI, Anthropic, Ollama, or Opencode).
- **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.
## Differences from Learnship
+39
View File
@@ -0,0 +1,39 @@
---
name: backend-engineer
domain: backend
frameworks:
- fastify
- hono
constraints:
- api-first
- strict-typing
- dependency-injection
territory:
- "**/api/**"
- "**/routes/**"
- "**/services/**"
- "**/middleware/**"
- "**/controllers/**"
- "**/auth/**"
- "**/handlers/**"
- "**/grpc/**"
- "**/server.ts"
- "**/app.ts"
description: Backend engineer — owns API routes, services, middleware, and auth. Enforces API-first design with strict typing and dependency injection.
---
You are the **backend-engineer** persona in the CIAgent execution pipeline.
Your domain is server-side logic and API design. When implementing tasks:
1. **API-first design** — define routes and contracts before implementation; OpenAPI/similar specs when applicable
2. **Strict typing** — all request/response types are explicit; no `any` types in API boundaries
3. **Dependency injection** — services receive dependencies through constructors/function parameters, not globals
4. **Middleware composition** — auth, validation, error handling are middleware layers, not inline code
5. **Separation of concerns** — controllers handle HTTP, services handle business logic, repositories handle data
You own these file patterns: API routes, services, middleware, controllers, auth, server config.
When a territory conflict arises:
- With data: backend consumes the repository interface; data defines the schema
- With frontend: backend defines the API contract; frontend adapts to it
+39
View File
@@ -0,0 +1,39 @@
---
name: data-engineer
domain: data
frameworks:
- drizzle
- postgresql
constraints:
- schema-first
- type-safe ORM
- migration-driven
territory:
- "**/migrations/**"
- "**/schema/**"
- "**/models/**"
- "**/db/**"
- "prisma/schema.prisma"
- "drizzle/**"
- "**/*.sql"
- "**/seed*"
- "**/repository/**"
- "**/dao/**"
description: Data engineer — owns schema definitions, migrations, database access layers, and ORM configurations. Enforces schema-first design with type-safe ORM patterns.
---
You are the **data-engineer** persona in the CIAgent execution pipeline.
Your domain is data persistence and access. When implementing tasks:
1. **Schema-first design** — define database schema before writing query code
2. **Type-safe ORM** — use Drizzle ORM for all database interactions; prefer typed queries over raw SQL
3. **Migration-driven** — every schema change gets a migration file; no manual schema updates
4. **Repository pattern** — encapsulate data access behind typed repository interfaces
5. **No direct SQL in services** — all data access goes through the repository layer
You own these file patterns: migrations, schemas, models, db config, repository/dao layers.
When a territory conflict arises:
- With backend: provide schema contracts and type definitions; backend implements API contracts
- With frontend: frontend never directly accesses the database; all data flows through backend APIs
+40
View File
@@ -0,0 +1,40 @@
---
name: frontend-engineer
domain: frontend
frameworks:
- react
- next.js
constraints:
- component-first
- server-components
- minimal-client-js
territory:
- "**/components/**"
- "**/pages/**"
- "**/hooks/**"
- "**/styles/**"
- "**/*.tsx"
- "**/*.css"
- "**/*.vue"
- "**/*.svelte"
- "**/layouts/**"
- "**/views/**"
- "**/client/**"
description: Frontend engineer — owns UI components, pages, hooks, and styles. Enforces component-first architecture with server components and minimal client-side JavaScript.
---
You are the **frontend-engineer** persona in the CIAgent execution pipeline.
Your domain is user interface and client-side logic. When implementing tasks:
1. **Component-first architecture** — build UI from composable React components; prefer composition over inheritance
2. **Server components by default** — use React Server Components for data-fetching and static content; client components only for interactivity
3. **Minimal client JavaScript** — ship the smallest possible JS bundle; use server rendering for heavy computations
4. **Type-safe props and state** — all component props and hook return types are explicitly typed
5. **No direct database access** — all data comes through backend API endpoints; frontend never queries the database directly
You own these file patterns: components, pages, hooks, styles, layouts, views, client code.
When a territory conflict arises:
- With backend: adapt to backend's API contract; request changes through shared types module if needed
- With data: never access the database directly; use backend API endpoints for all data
+25
View File
@@ -0,0 +1,25 @@
---
name: lead-developer
domain: coordination
frameworks:
constraints:
- pragmatic
- battle-tested defaults
territory:
description: Lead developer — coordinates task decomposition and resolves conflicts between engineering personas. Makes final architectural decisions when personas disagree.
---
You are the **lead-developer** persona in the CIAgent execution pipeline.
Your role is coordination and conflict resolution. When the TaskDecomposer assigns tasks to data, backend, and frontend personas, you:
1. **Decompose plans** into vertical-slice task groups organized by persona domain
2. **Resolve territory conflicts** between personas using domain expertise:
- data-backend conflicts: backend gets the file; data provides schema contracts
- backend-frontend conflicts: backend defines the API contract; frontend adapts
- data-frontend conflicts: data defines schema; frontend accesses through backend APIs only
3. **Enforce architectural boundaries** — no direct database access from frontend, no UI logic in backend services
4. **Prioritize pragmatism** — battle-tested defaults over novel approaches
5. **Ensure task ordering** respects dependencies across persona boundaries
You do not directly modify code files. You coordinate and resolve conflicts.
+1 -1
View File
@@ -1 +1 @@
0.5.0
0.7.0
+5 -4
View File
@@ -4,7 +4,7 @@ Agent output guidance for CIAgent dev mode. Loaded when the orchestrator operate
---
## Multi-Project and NFR Versioning
## Multi-Project and Milestone Versioning
When in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
- All commits include `project: <slug>` in `---ci---` block
@@ -12,9 +12,10 @@ When in multi-project mode (`.ciagent/config.json` has `projects[]` with length
- `.ciagent/` files are in `.ciagent/<slug>/` subdirectories
- Project scoping applies to all operations
NFR milestone versioning:
- NFR milestones (all phases are fix/chore/docs/perf/refactor/test): progressive patch versions only, no minor tag
- Feature milestones (any feat phase): progressive patch versions + minor milestone tag
Milestone versioning (determined by `getMilestoneType()` before any development):
- **NFR** (all phases: fix/chore/docs/perf/refactor/test): progressive patch versions, no milestone tag — final patch IS the deliverable
- **Feature** (at least one `feat` phase): progressive patch versions + next minor milestone tag
- **Major** (breaking schema changes or complete refactor): progressive minor versions per phase + major milestone tag
## Output Style
+33 -15
View File
@@ -104,22 +104,29 @@ Phase branches can be deleted after merge if desired.
## Versioning and Releases
**Every merge to main creates a release. No exceptions.** Versioning follows a 3-tier model based on milestone type:
**Every merge to main creates a release. No exceptions.** Versioning follows the milestone type model:
### 3-Tier Versioning Model
### Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
| **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**IMPORTANT:** Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the milestone is implicit from the patch sequence
Determine milestone type via `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`.
**Tag rules (CRITICAL):**
- Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Major: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the final patch release IS the deliverable
- Tags must be strictly greater than all existing tags on the same major.minor line
- NEVER create a milestone tag that is semantically below existing phase tags
### Phase completion
@@ -135,7 +142,7 @@ git push origin main --tags
Phase number within the milestone determines the patch version (1st phase = .1, 2nd phase = .2, etc.)
**Schema-breaking (minor release per phase):**
**Major (minor release per phase):**
```bash
git checkout milestone/v0.5-schema-rewrite
git merge --squash phase/01-core-refactor
@@ -145,7 +152,7 @@ git push origin main --tags
# Create Gitea release for v0.5.0
```
Each schema-breaking phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc.
Each major phase bumps the minor. 1st phase = next available minor, 2nd = minor+1, etc.
### Milestone completion
@@ -160,7 +167,7 @@ git push origin main --tags
# Create Gitea release for v0.6.0 with full milestone summary
```
**Schema-breaking (major release):**
**Major (major release):**
```bash
# All phases already merged into milestone branch
git checkout main
@@ -177,9 +184,20 @@ git push origin main --tags
Before creating any tag:
1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be next minor (feature) or next major (schema-breaking)
2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a tag that is semantically below existing phase tags
### Merge Validation Gates
The branch hierarchy `main > milestone/vX.X-slug > phase/NN-slug` is enforced at merge time:
| Merge Type | Rule | Validation |
|------------|------|-------------|
| Phase → Milestone | Must target milestone branch when one exists | REJECTED if milestone branch does not exist for this phase's milestone |
| Phase → Main | Only allowed when no milestone branch exists | REJECTED if a milestone branch exists for this milestone |
| Milestone → Main | Only after all phase branches are merged | REJECTED if any phase branches for this milestone are unmerged |
| Hotfix → Main | Allowed (exception to hierarchy) | Always allowed |
## Multi-Project Branch Naming
When operating in multi-project mode (`.ciagent/config.json` has `projects[]` with length > 0):
+288
View File
@@ -0,0 +1,288 @@
---
description: Run the CIAgent ideation pipeline — analyze project for improvement opportunities, validate recommendations with user, update long-term documents
---
# CIAgent Ideate
Run the CIAgent ideation engine to discover improvement opportunities based on git-native signals, codebase analysis, and cross-project patterns.
**Usage:** `ciagent ideate [options]`
## Step 0: Confirm Active Project
Check `ci listProjects()` or read `.ciagent/config.json` to determine project context.
If `.ciagent/config.json` has `active_projects` array with length > 0:
- Use `--project <slug>` to target a specific project
- Use `--project all` to run ideation across all active projects (deduplicate findings)
- If no `--project` flag, use first project in `active_projects`
If `.ciagent/config.json` has `active_project` string (legacy):
- Use that project as the target
- Backwards-compatible: if both `active_project` and `active_projects` exist, `active_projects` takes precedence
## Step 1: Load Project Context
```bash
git log --max-count=50
git branch -a
```
Read project reference files:
- `.ciagent/PROJECT.md` — Vision, requirements, constraints, key decisions
- `.ciagent/ROADMAP.md` — Phases, milestones, success criteria
- `.ciagent/REQUIREMENTS.md` — REQ-IDs, status, traceability
- `.ciagent/ARCHITECTURE.md` — Component boundaries, data flow
- `.ciagent/config.json` — Ideation configuration, autonomy level
## Step 2: Run Ideation Tiers
Execute tiers in order. Each tier produces `Idea[]` objects. Ideas from all tiers are merged and deduplicated before presentation.
### Tier 1: Mechanical Analysis (Always Available)
No backend required. All signals come from git history, `.ciagent/` files, and filesystem.
#### 2.1 Git-Native Pattern Mining
```bash
git log --all --grep="lessons:" --format="%B" -50
git log --all --grep="decisions:" --format="%B" -50 -- "***confidence***0.*"
git log --all --grep="escalation:" --format="%B" -50
git log --all --grep="compound:" --format="%B" -50
```
Extract:
- **Repeated lessons** — topics appearing > 1 time → systemic issue
- **Low-confidence decisions** — confidence < 0.7 in `---ci---` blocks → improvement targets
- **Escalation types** — each type identifies a process gap
- **Compound solutions** — suggest generalizing patterns that were solved multiple times
- **Partial requirements** — `requirements: partial: [REQ-XX]` in `---ci---` blocks
#### 2.2 Coverage Gap Analysis
- Parse REQUIREMENTS.md for `pending` and `in_progress` status requirements
- Cross-reference with PLAN.md task completion
- Identify requirements with no corresponding implementation tasks
#### 2.3 Verification Layer Inversion
For each verification layer, identify what's MISSING:
- **Structural**: Files referenced but not created, stubs, TODOs, placeholder implementations
- **Behavioral**: Test suites with < 80% coverage, missing test files for covered requirements
- **Security**: No STRIDE analysis for modified components, missing input validation patterns
- **Quality**: P1/P2 review findings unresolved, consistent style violations
#### 2.4 Architectural Drift Detection
- Parse ARCHITECTURE.md component tree
- Compare against actual `src/` directory structure
- Flag components documented but not implemented
- Flag components implemented but not documented
- Check import graph for unauthorized dependencies between components
#### 2.5 Spec-Driven Improvement
- Analyze REQUIREMENTS.md for ambiguous language ("should" vs "must", undefined terms)
- Check for contradictions between requirements
- Compare against common patterns for the project type (identified from package.json keywords)
- Flag requirements with no verification criteria
### Tier 2: Backend-Enriched Analysis (When LLM Available)
Requires an intelligence backend (opencode, openai, anthropic, or ollama).
#### 2.6 Prioritization and Ranking
- Evaluate all mechanical findings for impact and feasibility
- Rank ideas by: (1) number of signals corroborating, (2) severity of the gap, (3) ease of addressing
#### 2.7 Novel Improvement Suggestions
- Suggest improvements beyond pattern matching (e.g., "consider rate limiting" based on industry best practices, not just a repeated lesson)
- Generate concrete action plans for each accepted idea
- Identify bleeding-edge approaches relevant to the project's tech stack
#### 2.8 Chaos Engineering Ideation
- Generate failure scenarios: "What if the backend is unavailable?", "What if a requirement changes mid-implementation?", "What if test coverage drops below threshold?"
- Map failure scenarios to code that would break
- Suggest resilience improvements for each scenario
### Tier 3: Cross-Project Pattern Transfer (When Multi-Project Registry Exists)
#### 2.9 Cross-Project Mining
For each project in `.ciagent/config.json` projects array:
- Read that project's `---ci---` blocks for lessons, decisions, compound solutions
- Find patterns relevant to the current project (same requirement area, same tech stack from package.json)
- Suggest adaptations of lessons learned elsewhere
- Calculate relevance score based on tech stack similarity
## Step 3: Merge and DeduplicateIdeas
Combine ideas from all tiers. Deduplicate by:
- Same `title` strings → keep highest confidence version
- Same `relatedReq` → merge into single idea with combined sources
- Same `category` + overlapping domains → keep most specific
Sort by confidence (descending), then by number of corroborating signals.
## Step 4: Interactive Validation
Present ideas one-at-a-time to the user:
```
═══ Recommendation N of M ═══
Category: [CATEGORY] | Confidence: [0.XX] | Tier: [mechanical/backend-enriched/cross-project]
Title: [idea title]
Rationale: [idea rationale]
Related Req: [REQ-ID or "new requirement"]
Source: [source signal type]
Actions:
1. Accept (add to next milestone as new requirement)
2. Skip
3. Modify (edit title/rationale before accepting)
4. Details (show full analysis including signal sources)
```
For each accepted idea:
1. Generate `IDEATE-NN` requirement ID
2. Prompt for milestone placement (append to existing or create new)
3. Add to REQUIREMENTS.md with status `pending`
4. Add to ROADMAP.md next milestone
## Step 5: Update Long-Term Documents
For each accepted idea:
### REQUIREMENTS.md
Add a new row in the appropriate milestone section:
```
| IDEATE-NN | [idea title] | [priority] | [phase] | pending |
```
### ROADMAP.md
Add the idea to the next milestone's phase structure:
- If next milestone has a matching phase category, append to that phase
- If no matching phase, suggest a new phase
### ARCHITECTURE.md
If the idea involves architectural changes, note the component change needed.
### PROJECT.md
If the idea adds new requirements or key decisions, update accordingly.
Commit all document updates:
```
decision(P##): ideation results — [N] accepted, [M] skipped
```
## Step 6: Ask-After-Validation Kickoff
After all ideas have been validated:
```
Accepted: [N] recommendations
Skipped: [M] recommendations
Would you like to kick off the run workflow for these ideas? (y/n)
```
If yes: Start `ciagent run` with the updated project context. The `--ideate` flag is NOT needed because the ideas are already in ROADMAP.md and REQUIREMENTS.md — the standard pipeline will pick them up.
If no: Output summary and exit.
## Command Flags
| Flag | Description |
|------|-------------|
| `--category <cats>` | Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated) |
| `--affected` | Cascade impact analysis: given current changes, what else needs updating |
| `--spec` | Analyze specification completeness and ambiguity |
| `--external` | Include external signals: npm audit, OSV advisories, dependency staleness |
| `--cross-project` | Mine patterns from all projects in multi-project registry |
| `--output <format>` | Output format: interactive (default), json, markdown |
| `--project <slugs>` | Target project(s): slug, comma-separated, or `all` |
| `--backend <provider>` | Override intelligence backend for enrichment tier |
## Pipeline Integration
When `ciagent run --ideate` is used, the IDEATE stage is inserted between RESEARCH and PLAN:
```
SPECIFY → CLARIFY → RESEARCH → IDEATE → PLAN → EXECUTE → TEST → VERIFY → COMPLETE
```
IDEATE stage commit:
```
---ci---
phase: [phase-number]
milestone: [milestone-version]
status: ideate
decisions:
- id: D-XXX
decision: "Accepted [N] ideation recommendations"
rationale: "[summary of accepted ideas]"
confidence: [avg confidence]
requirements:
covered: [IDEATE-NN, ...]
---/ci---
```
## Output Modes
### Interactive (default)
Presented one-at-a-time with accept/skip/modify actions.
### JSON
```json
{
"project": "[slug]",
"milestone": "[version]",
"ideas": [
{
"id": "IDEATE-NN",
"source": "[source type]",
"category": "[category]",
"title": "[title]",
"rationale": "[rationale]",
"confidence": 0.XX,
"relatedReq": "[REQ-ID or null]",
"actions": ["[action types]"],
"tier": "[mechanical/backend-enriched/cross-project]",
"accepted": true
}
],
"summary": {
"total": 8,
"accepted": 6,
"skipped": 2,
"by_category": { "coverage": 2, "architecture": 1, "security": 1, "quality": 1, "improvement": 1 }
}
}
```
### Markdown
Formatted report suitable for PR descriptions or documentation.
## Error Recovery
On tier failure:
1. Mechanical tier always succeeds (git + filesystem only)
2. Backend-enriched tier: if backend unavailable, fall back to mechanical-only output
3. Cross-project tier: if no other projects in registry, skip silently
On validation failure (no ideas generated):
- Output "No improvement ideas identified for this project."
- Suggest `ciagent ideate --spec` for specification analysis or `--external` for external signals
+166 -34
View File
@@ -1,25 +1,40 @@
---
description: Execute the full CIAgent pipeline — research → plan → execute → verify → complete for the current or specified phase
description: Execute the full CIAgent pipeline — specify → clarify → research → ideate → plan → execute → ship → verify → complete for the current or specified phase
---
# CIAgent Run
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents.
Execute the full CIAgent pipeline from the current stage to completion. The orchestrator iterates through stages and delegates to specialized agents and sub-workflows.
**Usage:** `ciagent-run [phase_number]`
If no phase number specified, continues from the current phase (detected from git log).
## Step 0: Confirm Active Project
## Step 0: Confirm Active Project and Session
Check `ci listProjects()` or read `.ciagent/config.json` to determine if multi-project mode is active.
If `.ciagent/config.json` has `projects[]` with length > 0:
- Confirm `active_project` is correct for this run
- If not, set it with `ci setActiveProject(<slug>)`
If `.ciagent/config.json` has `projects[]` with length > 0, or `active_projects` array exists:
- Confirm `active_projects` is correct for this run
- If `--project all` is specified: iterate over all projects in `active_projects`
- If `--project <slug>` is specified: run for that project only
- If no `--project` flag: use first project in `active_projects`
- All commit messages must include `project: <slug>` in `---ci---` block
- All `.ciagent/` file reads use `.ciagent/<slug>/` subdirectory paths
- Branch names are prefixed with `<slug>/` (e.g., `<slug>/phase/01-auth`, `<slug>/milestone/v0.2-auth`)
If single-project mode: proceed with existing conventions.
For multi-project execution (`--project all`):
- Execute pipeline for each project sequentially by default
- When `parallelization.enabled=true`: execute projects concurrently up to `max_concurrent_agents`
- Each project has independent phase branches and milestone tracking
- Sessions (if configured): each project gets its own `AgentSession` with branch isolation per `config.json sessions.session_isolation`
For multi-persona execution (when `config.json personas.enabled=true`):
- Lead-developer persona decomposes tasks by territory file patterns and requirement IDs
- Each persona group executes tasks within their territory
- Territory enforcement runs in `warn` or `strict` mode per `config.json personas.territory_enforcement`
If single-project mode: proceed with existing conventions (flat `.ciagent/` paths, no project prefix on branches).
## Step 1: Load Git Context
@@ -33,76 +48,193 @@ Determine current state:
- Current milestone from latest `---ci---` block or active milestone branch
- Current pipeline stage from latest `---ci---` status field
- Completed phases from merged `phase/NN-*` branches
- Active project from `---ci---` project field (multi-project mode)
## Step 2: Pre-Flight Check
Verify `.ciagent/config.json` exists. If missing: stop, run `ciagent-init` first.
Read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md` for phase goals.
Resolve project paths based on mode:
- **Multi-project**: read `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/ROADMAP.md` for the active project
- **Single-project**: read `.ciagent/PROJECT.md` and `.ciagent/ROADMAP.md`
Read phase goals and milestone context from the resolved files.
## Step 3: Execute Pipeline Stages
For each stage in order (starting from current or from `specify`):
### SPECIFY
- Parse specification from `.ciagent/PROJECT.md`
- Validate requirements exist in `.ciagent/REQUIREMENTS.md`
- Resolve active project from `config.json`
- Parse specification from `.ciagent/<slug>/PROJECT.md` (multi-project) or `.ciagent/PROJECT.md` (single-project)
- Validate requirements exist in `.ciagent/<slug>/REQUIREMENTS.md` (multi-project) or `.ciagent/REQUIREMENTS.md` (single-project)
- Commit: `docs(init): validate specification`
```
---ci---
project: <slug>
phase: 0
milestone: v0.X
status: specify
---/ci---
```
### CLARIFY
- Generate clarify questions for ambiguities
- Default-accept at `full` autonomy, present at `supervised`/`guided`
- Commit: `decision(P##): clarification decisions`
**Delegate to `ciagent-clarify` workflow.** Do not reimplement inline.
The clarify workflow handles:
- Multi-project active project confirmation
- Git context loading
- Ambiguity identification and question generation
- Autonomy-based resolution (full/supervised/guided)
- Clarification commits with `---ci---` blocks
- `.ciagent/<slug>/PROJECT.md` and `.ciagent/<slug>/REQUIREMENTS.md` updates
Pass the current phase number and active project slug. Collect the result and proceed.
### RESEARCH
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-researcher
- Research domain, ecosystem, prior art
- Update `.ciagent/` static files with conclusions
- Update `.ciagent/<slug>/` static files with conclusions (ARCHITECTURE.md, PROJECT.md, etc.)
- Commit: `docs(P##): research findings`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: research
---/ci---
```
### IDEATE (when --ideate flag is passed)
**Delegate to `ciagent-ideate` workflow.** Do not reimplement inline.
The ideate workflow handles:
- Multi-project context and `--project` flags
- All three tiers (mechanical, backend-enriched, cross-project)
- Interactive validation (accept/skip/modify)
- Updates to `.ciagent/<slug>/REQUIREMENTS.md`, `.ciagent/<slug>/ROADMAP.md`, `.ciagent/<slug>/ARCHITECTURE.md`, `.ciagent/<slug>/PROJECT.md`
- Ideation commit with `---ci---` block
Pass the active project slug and any `--ideate` flags. Collect accepted ideas and proceed.
### PLAN
- Delegate to ci-planner
- Resolve active project from `config.json`; use `.ciagent/<slug>/` paths
- Delegate to ci-planner with full project context
- Create vertical-slice plans with wave ordering
- Plans reference requirement IDs from `.ciagent/<slug>/REQUIREMENTS.md`
- Commit: `docs(P##): create [N] phase plans`
```
---ci---
project: <slug>
phase: [N]
milestone: [vX.X]
status: plan
---/ci---
```
### EXECUTE
- Create phase branch: `phase/NN-slug`
- Create phase branch: `<slug>/phase/NN-slug` (multi-project) or `phase/NN-slug` (single-project)
- Delegate to ci-executor per plan per wave
- **Multi-persona development**: if `config.json personas.enabled=true`:
- Lead-developer decomposes tasks by territory file patterns and requirement IDs
- Each persona executes tasks within their declared territory (config.json `personas[].territory`)
- Territory enforcement runs in configured mode (`warn` or `strict`)
- Primary persona (i=0) executes sequentially; review personas (i>0) execute in parallel
- Persona constraints (frameworks, constraints arrays) guide implementation choices
- Commit each task with `---ci---` block
- After all waves: commit phase completion
- After all waves complete: **ship the phase** by delegating to `ciagent-ship` workflow
**Ship gate**: a phase MUST be shipped before advancing to the next phase. The ship workflow handles:
- Pre-flight validation (milestone type, branch hierarchy, tag sequence, autonomy)
- Test execution (test, typecheck, build)
- PR creation and auto-merge
- Version computation and tagging
- Branch merging (phase → milestone or phase → main)
- Gitea release creation
If the ship fails: do NOT advance to VERIFY. Iterate until the phase ships successfully.
### VERIFY
- Delegate to ci-verifier
- Check must_haves, requirement coverage, integration links
- Auto-generate tests for unverifiable items
- Commit: `verify(P##): verification result`
### COMPLETE
- Merge phase branch into main (squash)
- Tag with patch version (e.g., `v0.2.3` — 3rd phase in milestone v0.2)
- Create Gitea release for the tag
- Update `.ciagent/REQUIREMENTS.md` requirement statuses
- Update `.ciagent/ROADMAP.md` phase status
- Commit: `docs(P##): complete [phase-name] phase`
**Delegate to `ciagent-verify` workflow.** Do not reimplement inline.
Versioning: Major = project-level refactor/schema change, Minor = milestone completion, Patch = every phase.
The verify workflow handles:
- Multi-project scoping and active project confirmation
- Four verification layers (structural, behavioral, security, quality)
- Auto-generated tests for unverifiable items
- Verification commit with `---ci---` block
Pass the current phase number and active project slug. Collect the verification result and proceed.
### COMPLETE (milestone completion gate)
The COMPLETE stage is reached only after ALL phases in the milestone have been shipped and verified. It orchestrates milestone-level finalization through three sub-workflows with a feedback loop:
1. **Trigger `ciagent-review`** — multi-persona code review across all phases in the milestone
- Reviews all changes in the milestone branch
- Auto-applies P0 fixes, flags P1+ for post-hoc review
- If P1+ issues found: send them back to the EXECUTE stage for remediation
2. **Trigger `ciagent-ship` (milestone)** — ship the entire milestone
- Merge milestone branch into main
- Tag with milestone version (minor for feature, major for major milestone)
- Create Gitea release for the milestone with full phase summary
- Build and upload distribution packages
3. **Trigger `ciagent-audit`** — verify project health
- Reconstruction test: verify git log matches `.ciagent/` files
- Check `.ciagent/` file discipline and branch hygiene
- Check commit discipline
- If audit finds issues: document them, send critical issues back to EXECUTE
4. **Feedback loop**: if review or audit produces pending issues that require code changes, loop back to EXECUTE → SHIP → VERIFY for those fixes before re-attempting COMPLETE.
5. **If no pending issues from review/audit and audit is clean**: complete the milestone:
- Update `.ciagent/<slug>/REQUIREMENTS.md` — mark all milestone requirements as complete
- Update `.ciagent/<slug>/ROADMAP.md` — mark milestone as complete
- Commit: `docs(milestone): complete [milestone-name]`
```
---ci---
project: <slug>
phase: 0
milestone: [vX.Y]
status: complete
requirements:
covered: [REQ-01, REQ-02, ...]
partial: []
---/ci---
```
Versioning: Major milestone = breaking schema changes, Feature milestone = milestone completion (minor), Patch = every phase.
## Phase Boundary Checkpoint
Between phases, perform a context reset:
1. Commit all work from the current phase
2. Update `.ciagent/` files (phase status, requirement statuses)
2. Update `.ciagent/<slug>/` files (phase status, requirement statuses)
3. Verify `GitContext.reconstructState()` matches expected state
4. Reset context: spawn fresh agent (opencode) or re-read git context (platforms without subagents)
5. Next phase begins with fresh context from git log only
## NFR Versioning Logic
## Versioning Logic
Before tagging a phase completion, check `isNfrMilestone()`:
Before tagging a phase completion, check `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
- **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag.
- **Feature milestone** (any feat phase): apply progressive patch versions per phase, then tag minor milestone version on completion (e.g., v0.2.0).
- **NFR milestone** (all phases are fix/chore/docs/perf/refactor/test): apply progressive patch versions (v0.1.1, v0.1.2, v0.1.3). No separate milestone tag — the final patch IS the deliverable.
- **Feature milestone** (at least one feat phase): apply progressive patch versions per phase, then tag next minor milestone version on completion (e.g., v0.6.0, NOT v0.5.0).
- **Major milestone** (breaking schema changes or complete refactor): apply progressive minor versions per phase (v0.3.0, v0.4.0), then tag next major on completion (e.g., v1.0.0).
## Step 4: Error Recovery
+132 -32
View File
@@ -1,25 +1,45 @@
---
description: Ship CIAgent phase or milestone — test, tag, release. Every phase and milestone gets a release. Full autopilot.
description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
---
# CIAgent Ship
Ship a CIAgent phase or milestone. Every ship creates a release — no exceptions.
**3-Tier Versioning Model:**
**Usage:** `ciagent-ship [phase_number|milestone]`
## Autopilot Rules
These rules are **non-negotiable**. The ship workflow runs in full autopilot mode:
- **Zero HITL** — no confirmation prompts, no approval gates, no requests for human input. The agent executes the entire release flow autonomously.
- **No Shortcuts** — deep validation, testing, and merge checks must all run in full. The lack of HITL is not an excuse to skip steps.
- **Notification Only** — status updates are informational, not requests for approval. Report outcomes, never ask permission.
- **Autonomous Loop on Failure** — if any step fails (tests, pipeline, merge conflicts), iterate autonomously until success. Do NOT ask the user for guidance on how to fix a failing test or pipeline.
- **Branch Hierarchy Enforced** — `main > milestone/vX.X-slug > phase/NN-slug`. Phase merges into milestone, milestone merges into main. This is validated, not assumed.
## Milestone Type and Versioning
The milestone type is determined **before any development work** and governs all versioning for the entire milestone.
**Define semver at milestone start:** establish the version and milestone type before writing code.
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "major"`:
| Milestone Type | Condition | Phase release | Milestone release |
|---------------|-----------|---------------|-------------------|
| **NFR** | All phases: fix/chore/docs/perf/refactor/test | Patch (`vX.Y.Z`) | None |
| **Feature** | Any phase is `feat`, no schema break | Patch (`vX.Y.Z`) | Minor — `vX.(Y+1).0` |
| **Schema-breaking** | Refactor/schema break/new direction | Minor — `vX.(Y+N).0` per phase | Major — `v(X+1).0.0` |
| **NFR** | All phases are fix/chore/docs/perf/refactor/test | Patch `v1.8.1`, `v1.8.2`, ... | None — final patch IS the deliverable |
| **Feature** | At least one phase has new features (`feat`) | Patch `v1.8.1`, `v1.8.2`, ... | Next minor — `v1.9.0` |
| **Major** | Breaking schema changes or complete refactor | Minor — `v2.1.0`, `v2.2.0`, ... | Major — `v3.0.0` |
**CRITICAL:** Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Schema-breaking: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the milestone is implicit from the patch sequence
**Tag rules (CRITICAL):**
**Usage:** `ciagent-ship [phase_number|milestone]`
- Milestone tags are always the NEXT version, never the base:
- Feature: patches v0.5.1v0.5.5 → milestone tag is v0.6.0 (NOT v0.5.0)
- Major: minors v0.3.0, v0.4.0, v0.5.0 → milestone tag is v1.0.0
- NFR: no milestone tag — the final patch release IS the deliverable
- Tags must be strictly greater than all existing tags on the same major.minor line
- NEVER create a milestone tag that is semantically below existing phase tags
## Step 0: Confirm Active Project
@@ -33,11 +53,12 @@ If `.ciagent/config.json` has `projects[]` with length > 0:
If single-project mode: proceed with existing conventions.
## Step 1: Pre-Flight
## Step 1: Pre-Flight Validation
```bash
git log --max-count=10
git branch -a
git tag -l
```
Determine what is being shipped: a single phase or an entire milestone.
@@ -49,6 +70,16 @@ Read `.ciagent/ROADMAP.md` to determine:
Read `.ciagent/config.json` for autonomy level.
**Validation gates — all must pass before proceeding:**
1. **Milestone type resolved**`getMilestoneType()` must return `"nfr" | "feature" | "major"`. Stop if undefined.
2. **Branch hierarchy correct** — phase branch exists and targets the correct parent (milestone branch, or main if no milestone branch exists).
3. **No unmerged phase branches** — if shipping a milestone, all phase branches for this milestone must be merged into the milestone branch.
4. **Tag sequence valid** — the computed tag must be strictly greater than all existing tags on the same major.minor line. Check with `git tag -l`.
5. **Autonomy confirmed**`.ciagent/config.json` autonomy level must be `full`. This is the zero-HITL enforcement point.
If any validation fails: stop and report. Do NOT proceed past a failed gate.
## Step 2: Run Tests
```bash
@@ -59,33 +90,77 @@ npm run build
If any fail: iterate autonomously until tests pass. Do NOT ask the user for guidance — debug and fix.
## Step 3: Compute Version
## Step 3: Create PR and Quality Assurance
Determine milestone type by calling `getMilestoneType()` which returns `"nfr" | "feature" | "schema-breaking"`:
**Open a Pull Request for the merge target:**
```bash
tea pr create --base <target-branch> --head <source-branch> --title "ship: [phase-name or milestone-name]"
```
- For a phase ship: PR from `phase/NN-slug` into `milestone/vX.Y-slug` (or `main` if no milestone branch).
- For a milestone ship: PR from `milestone/vX.Y-slug` into `main`.
**Auto-merge configuration:**
Set the PR to auto-merge upon pipeline success:
```bash
tea pr merge <pr-number> --auto --squash
```
**Review:**
Conduct a thorough autonomous review of the PR diff. Check:
- All expected files are included
- No unintended changes slipped in
- No secrets or credentials in the diff
- All `---ci---` blocks have correct metadata
**Finalization:**
- **On pipeline success:** the PR auto-merges. Proceed to Step 4.
- **On pipeline failure:** iterate autonomously until the pipeline passes. Do NOT merge a PR with a failing pipeline. Do NOT ask for guidance.
**Strict rule:** Never merge a PR with a failed pipeline. No exceptions.
## Step 4: Compute Version
| What's shipping | Milestone Type | Phase release | Milestone release | Example |
|----------------|---------------|-------------|------------|---------|
|----------------|---------------|---------------|-------------------|---------|
| Single phase | NFR | Patch `vX.Y.Z` | N/A | v0.1.3 (3rd NFR phase) |
| Single phase | Feature | Patch `vX.Y.Z` | N/A | v0.2.3 (3rd feature phase) |
| Single phase | Schema-breaking | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd schema-breaking phase) |
| Single phase | Major | Minor `vX.(Y+N).0` | N/A | v0.4.0 (2nd major phase) |
| Milestone completion | NFR | Patch (last phase) | None | v0.1.3 (no milestone tag) |
| Milestone completion | Feature | Last patch | Minor `vX.(Y+1).0` | v0.3.0 (NOT v0.2.0) |
| Milestone completion | Schema-breaking | Last minor | Major `v(X+1).0.0` | v1.0.0 |
| Milestone completion | Major | Last minor | Major `v(X+1).0.0` | v1.0.0 |
Phase number within the milestone determines the increment:
- NFR/Feature: 1st phase = .1, 2nd = .2, etc. (v0.5.1, v0.5.2)
- Schema-breaking: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0)
- Major: 1st phase = next minor, 2nd = minor+1, etc. (v0.3.0, v0.4.0)
**Before creating ANY tag, validate:**
1. The tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be the next minor (feature) or next major (schema-breaking)
**Tag validation (before creating ANY tag):**
1. Tag must be strictly greater than all existing tags on the same major.minor line
2. Milestone completion tag must be next minor (feature) or next major (major)
3. NEVER create a milestone tag that is semantically below existing phase tags (e.g., v0.5.0 when v0.5.1 already exists)
## Step 4: Merge Branch
## Step 5: Merge Branch
### Branch hierarchy: main > milestone/vX.X-slug > phase/NN-slug
Phases MUST merge into their milestone branch (or to main if no milestone branch exists). Milestones merge into main only after all phases are complete.
### Merge validation gates
**Phase → Milestone:**
- VALIDATED — must target milestone branch when one exists
- REJECTED if milestone branch does not exist for this phase's milestone
**Phase → Main:**
- VALIDATED — only allowed when NO milestone branch exists for this phase's milestone
- REJECTED if a milestone branch exists for this milestone
**Milestone → Main:**
- VALIDATED — only after all phase branches are merged
- REJECTED if any phase branches for this milestone are unmerged
### Phase ship
@@ -123,8 +198,9 @@ requirements:
### Milestone ship (after last phase)
**Validate all phase branches are merged into the milestone branch before proceeding.**
```bash
# Verify all phase branches are merged into milestone branch
git checkout main
git merge --squash milestone/vX.Y-slug
git commit -m "docs(milestone): complete [milestone-name]
@@ -136,7 +212,7 @@ status: complete
---/ci---"
```
## Step 5: Tag and Push
## Step 6: Tag and Push
```bash
git tag -a vX.Y.Z -m "vX.Y.Z: [phase-name or milestone-name]"
@@ -145,21 +221,21 @@ git push origin main --tags
**Tag format by milestone type:**
- NFR/Feature phase: patch format (`v0.5.1`, `v0.5.2`)
- Schema-breaking phase: minor format (`v0.3.0`, `v0.4.0`)
- Major phase: minor format (`v0.3.0`, `v0.4.0`)
- Feature milestone: next minor (`v0.6.0`, NOT `v0.5.0`)
- Schema-breaking milestone: next major (`v1.0.0`)
- Major milestone: next major (`v1.0.0`)
## Step 6: Create Release
## Step 7: Create Release and Package
**Every ship creates a Gitea release. No exceptions.**
Generate release notes from git log:
### Generate release notes
```bash
git log v[previous_tag]..vX.Y.Z --oneline
```
Create the release via Gitea API:
### Create the Gitea release
```bash
curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/releases" \
@@ -170,14 +246,37 @@ curl -X POST "https://git.cloudinit.dev/api/v1/repos/continuous-intelligence/ci/
For milestone releases, include a summary of all phases completed and requirements covered.
## Step 7: Update .ci/ Files
### Create distribution packages
Use coreci to create the necessary distribution packages:
```bash
coreci build --tag vX.Y.Z
coreci package --tag vX.Y.Z
```
Upload packages to the Gitea release:
```bash
coreci release upload --tag vX.Y.Z --files [built-artifacts]
```
### Generate documentation
Include release notes in the Gitea release body with:
- Summary of changes
- Requirements covered
- Known issues (if any)
- Migration notes (for major milestones)
## Step 8: Update .ci/ Files
- Update `.ciagent/REQUIREMENTS.md` — mark shipped requirements as complete
- Update `.ciagent/ROADMAP.md` — mark shipped phase as complete
Commit the file updates.
## Step 8: Report
## Step 9: Report
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -185,7 +284,7 @@ Commit the file updates.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase [N]: [name]
Milestone: [vX.Y] ([nfr|feature|schema-breaking])
Milestone: [vX.Y] ([nfr|feature|major])
Version: vX.Y.Z
Release: https://git.cloudinit.dev/continuous-intelligence/ci/releases/tag/vX.Y.Z
Status: complete
@@ -193,6 +292,7 @@ Status: complete
Tests: PASS
Typecheck: PASS
Build: PASS
Pipeline: PASS
Requirements covered: [N]
Commits: [N]
+2 -2
View File
@@ -1,5 +1,5 @@
---
description: Ship CIAgent phase or milestone — test, commit, tag, push, release. Full autopilot: zero HITL after milestone setup
description: Ship CIAgent phase or milestone — Full autopilot release: validate, test, merge, tag, push, release. Zero HITL
argument-hint: "[phase_number|milestone]"
tools:
read: true
@@ -12,7 +12,7 @@ tools:
---
<execution_context>
@__OPENCODE_DIR__/ci/workflows/ship.md
@/root/.config/opencode/ci/workflows/ship.md
</execution_context>
<context>
+3 -4
View File
@@ -1,13 +1,12 @@
{
"name": "@continuous-intelligence/ciagent",
"version": "0.5.0",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@continuous-intelligence/ciagent",
"version": "0.5.0",
"hasInstallScript": true,
"name": "@continuous-intelligence/ciagent",
"version": "0.10.0",
"license": "MIT",
"dependencies": {
"commander": "^12.1.0",
+17 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@continuous-intelligence/ciagent",
"version": "0.5.0",
"version": "0.11.0",
"description": "Fully autonomous AI-driven software engineering harness - Continuous Intelligence",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -19,7 +19,10 @@
"dev": "ts-node src/cli.ts",
"typecheck": "tsc --noEmit",
"test": "jest",
"prepublishOnly": "npm run build",
"check-version": "node scripts/check-version.js",
"postbuild": "node scripts/ensure-shebang.js",
"prepublishOnly": "npm run build && node scripts/ensure-shebang.js && node scripts/check-version.js && npm test",
"validate-pack": "node scripts/validate-pack.js",
"install-opencode": "node scripts/postinstall.js"
},
"keywords": ["ciagent", "autonomous", "ai", "software-engineering", "agent", "multi-project"],
@@ -27,6 +30,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"
+27
View File
@@ -0,0 +1,27 @@
const fs = require("node:fs");
const path = require("node:path");
const projectRoot = path.resolve(__dirname, "..");
const pkgPath = path.join(projectRoot, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
const pkgVersion = pkg.version;
const versionPath = path.join(projectRoot, "src", "version.ts");
const versionContent = fs.readFileSync(versionPath, "utf-8");
const match = versionContent.match(/VERSION\s*=\s*"([^"]+)"/);
if (!match) {
console.error(`Error: Could not extract VERSION from src/version.ts`);
process.exit(1);
}
const srcVersion = match[1];
if (pkgVersion !== srcVersion) {
console.error(`Error: Version mismatch — package.json=${pkgVersion}, src/version.ts=${srcVersion}`);
process.exit(1);
}
console.log(`Version consistency check passed: ${pkgVersion}`);
process.exit(0);
+23
View File
@@ -0,0 +1,23 @@
const fs = require("node:fs");
const path = require("node:path");
const projectRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(projectRoot, "dist", "cli", "index.js");
const shebang = "#!/usr/bin/env node\n";
if (!fs.existsSync(cliEntry)) {
console.log(`dist/cli/index.js not found — skipping shebang check (build may not have run yet)`);
process.exit(0);
}
const content = fs.readFileSync(cliEntry, "utf-8");
if (content.startsWith(shebang.trim())) {
console.log("Shebang already present in dist/cli/index.js");
process.exit(0);
}
const updated = shebang + content;
fs.writeFileSync(cliEntry, updated, "utf-8");
console.log("Prepended shebang to dist/cli/index.js");
process.exit(0);
+55
View File
@@ -0,0 +1,55 @@
const { execSync } = require("child_process");
const ALLOWED_ENTRIES = ["dist/", "opencode/", "templates/", "LICENSE", "README.md", "package.json"];
function validatePack() {
let output;
try {
output = execSync("npm pack --dry-run --json", { encoding: "utf-8" });
} catch (err) {
console.error("Failed to run npm pack --dry-run:", err.message);
process.exit(1);
}
let packFiles;
try {
const parsed = JSON.parse(output);
packFiles = Array.isArray(parsed) ? parsed[0].files : parsed.files;
} catch (err) {
console.error("Failed to parse npm pack output:", err.message);
process.exit(1);
}
if (!packFiles || !Array.isArray(packFiles)) {
console.error("No files array found in npm pack output");
process.exit(1);
}
const paths = packFiles.map((f) => f.path || f);
const unexpected = [];
for (const p of paths) {
const top = p.split("/")[0] || p;
const allowed = ALLOWED_ENTRIES.some((entry) => {
const e = entry.replace(/\/$/, "");
return top === e || top === entry || p === entry;
});
if (!allowed) {
unexpected.push(p);
}
}
if (unexpected.length > 0) {
console.error("Unexpected files in npm pack output:");
for (const f of unexpected) {
console.error(` - ${f}`);
}
console.error("");
console.error("Allowed top-level entries:", ALLOWED_ENTRIES.join(", "));
process.exit(1);
}
console.log("npm pack validation passed — all entries are allowed.");
}
validatePack();
+152
View File
@@ -0,0 +1,152 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentContext, AgentResult } from "./base.js";
import { PlannerAgent } from "./planner.js";
import { ExecutorAgent } from "./executor.js";
import { VerifierAgent } from "./verifier.js";
import { ResearcherAgent } from "./researcher.js";
import { ChallengerAgent } from "./challenger.js";
import { SecurityAuditorAgent } from "./security-auditor.js";
import { DebuggerAgent } from "./debugger.js";
import { DocWriterAgent } from "./doc-writer.js";
import { DocVerifierAgent } from "./doc-verifier.js";
import { CodeReviewerAgent } from "./code-reviewer.js";
import { IdeationAgent } from "./ideation-agent.js";
import { RoadmapperAgent } from "./roadmapper.js";
import { PlanCheckerAgent } from "./plan-checker.js";
import { ProjectResearcherAgent } from "./project-researcher.js";
import { ResearchSynthesizerAgent } from "./research-synthesizer.js";
import { SolutionWriterAgent } from "./solution-writer.js";
import { PhaseResearcherAgent } from "./phase-researcher.js";
import { TesterAgent } from "./tester.js";
const NON_ORCHESTRATOR_AGENTS: Array<{ name: string; factory: () => { execute(ctx: AgentContext): Promise<AgentResult>; name: string } }> = [
{ name: "planner", factory: () => new PlannerAgent() },
{ name: "executor", factory: () => new ExecutorAgent() },
{ name: "verifier", factory: () => new VerifierAgent() },
{ name: "researcher", factory: () => new ResearcherAgent() },
{ name: "challenger", factory: () => new ChallengerAgent() },
{ name: "security-auditor", factory: () => new SecurityAuditorAgent() },
{ name: "debugger", factory: () => new DebuggerAgent() },
{ name: "doc-writer", factory: () => new DocWriterAgent() },
{ name: "doc-verifier", factory: () => new DocVerifierAgent() },
{ name: "code-reviewer", factory: () => new CodeReviewerAgent() },
{ name: "ideation-agent", factory: () => new IdeationAgent() },
{ name: "roadmapper", factory: () => new RoadmapperAgent() },
{ name: "plan-checker", factory: () => new PlanCheckerAgent() },
{ name: "project-researcher", factory: () => new ProjectResearcherAgent() },
{ name: "research-synthesizer", factory: () => new ResearchSynthesizerAgent() },
{ name: "solution-writer", factory: () => new SolutionWriterAgent() },
{ name: "phase-researcher", factory: () => new PhaseResearcherAgent() },
{ name: "tester", factory: () => new TesterAgent() },
];
describe("All agents have intrinsic mechanical logic", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-mechanical-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
fs.mkdirSync(path.join(tempDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".ciagent", "config.json"),
JSON.stringify({
autonomy: { level: "full", escalation_hooks: [], clarify_budget: 10, decision_confidence_threshold: 0.6, max_revision_iterations: 3, max_verification_retries: 2, escalation_timeout_ms: 300000 },
model_profile: "quality",
parallelization: { enabled: false, max_concurrent_agents: 5, min_plans_for_parallel: 2 },
verification: { automated_only: true, escalate_visual: true, escalate_external_integration: true, test_first: false },
security: { auto_accept_low_severity: true, auto_mitigate_medium_severity: true, escalate_high_severity: true },
git: { branching_strategy: "phase", auto_commit: false, auto_push: false },
backend: { provider: "auto", agent_backends: { opencode: { enabled: false } }, llm_backends: {} },
}, null, 2)
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PROJECT.md"),
"# Project: Mechanical Test\n\n## Core Value\nValidate mechanical agent logic\n\n## Requirements\n### Active\n- REQ-01: Agent runs mechanically\n\n## Key Decisions\n\n## Constraints\n- Test only"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\n## V1\n### Functional\n| ID | Description | Priority |\n|------|------|------|\n| REQ-01 | Agent test | high |\n\n## Traceability\n| Requirement | Phase | Status |\n|------|------|------|\n| REQ-01 | 1 | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ROADMAP.md"),
"# Roadmap\n\n## Phases\n\n| # | Name | Description | Requirements | Depends On | Status |\n|------|------|------|------|------|------|\n| 1 | Test | Agent test phase | REQ-01 | | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\nTest architecture\n\n## Components\n| Name | Description | Boundaries | Depends On |\n|------|------|------|------|\n| core | Core | src/core/ | | \n\n## Build Order\n1. Build core\n\n## Data Flow\nTest flow"
);
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "mech-test", version: "0.1.0", scripts: { test: "echo ok" } })
);
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), "{}");
fs.writeFileSync(path.join(tempDir, "src", "app.ts"), "export function main() { return 1; }");
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
});
it("every non-orchestrator agent produces meaningful output without backend", async () => {
const context: AgentContext = {
project_path: tempDir,
phase: 1,
stage: "plan",
specification: "Test mechanical agent logic execution",
config_path: path.join(tempDir, ".ciagent", "config.json"),
};
expect(NON_ORCHESTRATOR_AGENTS.length).toBe(18);
const results: Record<string, { success: boolean; error?: string; hasStubError: boolean }> = {};
for (const { name, factory } of NON_ORCHESTRATOR_AGENTS) {
const agent = factory();
expect(agent.name).toBe(name);
let result: AgentResult;
try {
result = await agent.execute(context);
} catch (err) {
result = {
success: false,
output: "",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: 0,
error: err instanceof Error ? err.message : String(err),
};
}
const errorText = (result.error || "").toLowerCase();
const hasStubError =
errorText.includes("requires an intelligence backend") ||
errorText.includes("no intelligence backend available");
results[name] = {
success: result.success,
error: result.error,
hasStubError,
};
}
const agentsWithStubErrors = Object.entries(results)
.filter(([, r]) => r.hasStubError)
.map(([name]) => name);
expect(agentsWithStubErrors).toEqual([]);
});
});
+14 -1
View File
@@ -1,4 +1,4 @@
import { IntelligenceBackend, BackendRequest, BackendResult, BackendUnavailableError, emptyBackendResult } from "../backends/types.js";
import { IntelligenceBackend, BackendRequest, BackendResult, BackendUnavailableError, emptyBackendResult, validateBackendResult } from "../backends/types.js";
import { AgentName, AutonomyLevel } from "../types/config.js";
export interface AgentResult {
@@ -18,9 +18,22 @@ export interface AgentContext {
specification: string;
config_path: string;
backend?: IntelligenceBackend;
project_slug?: string;
}
export function backendResultToAgentResult(result: BackendResult): AgentResult {
const validation = validateBackendResult(result);
if (!validation.result) {
return {
success: false,
output: "",
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `BackendResult validation failed: ${validation.errors.join("; ")}`,
};
}
return {
success: result.success,
output: result.output,
+57
View File
@@ -0,0 +1,57 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ChallengerAgent } from "../agents/challenger.js";
describe("ChallengerAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-challenger-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns empty for no plan", () => {
const agent = new ChallengerAgent();
const issues = agent.mechanicalChallenge(tempDir, "/nonexistent/plan.md");
expect(issues).toHaveLength(0);
});
it("agent name is challenger", () => {
const agent = new ChallengerAgent();
expect(agent.name).toBe("challenger");
});
it("detects missing must-haves in plan tasks", () => {
const planDir = path.join(tempDir, ".opencode", "plans");
fs.mkdirSync(planDir, { recursive: true });
const planPath = path.join(planDir, "v0.1-plan.md");
fs.writeFileSync(planPath, `# Plan\n\n| T-01 | 1 | |\n`);
const agent = new ChallengerAgent();
const issues = agent.mechanicalChallenge(tempDir, planPath);
expect(issues.some((i) => i.type === "missing_must_haves")).toBe(true);
});
it("validates clean plan with no issues", () => {
const planDir = path.join(tempDir, ".opencode", "plans");
fs.mkdirSync(planDir, { recursive: true });
const planPath = path.join(planDir, "v0.1-plan.md");
fs.writeFileSync(planPath, `# Plan\n\n| Task | Desc | Wave | Deps | Must-Haves | REQ-ID |\n|------|------|------|------|------------|--------|\n| T-01 | Do X | 1 | none | X works | REQ-01 |\n`);
const agent = new ChallengerAgent();
const issues = agent.mechanicalChallenge(tempDir, planPath);
expect(issues).toHaveLength(0);
});
it("detects issue descriptions contain type", () => {
const agent = new ChallengerAgent();
expect(agent.name).toBe("challenger");
});
});
+90 -4
View File
@@ -1,5 +1,13 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PlanIssue {
type: "circular_dep" | "invalid_wave" | "missing_must_haves" | "uncovered_requirement";
description: string;
taskId?: string;
}
export class ChallengerAgent extends BaseAgent {
readonly name = "challenger";
readonly description = "Stress-tests plans with binding verdicts. Only escalates when confidence < 0.60.";
@@ -8,6 +16,7 @@ export class ChallengerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Challenging plan...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +24,91 @@ export class ChallengerAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const planPath = path.join(context.project_path, ".opencode", "plans", `v0.${context.phase}-plan.md`);
const issues = this.mechanicalChallenge(context.project_path, planPath);
const output = this.formatIssues(issues);
return {
success: false,
output: "Plan challenge requires an intelligence backend. Configure one with: ci init --backend",
success: issues.length === 0,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: issues.filter((i) => i.type === "circular_dep" || i.type === "uncovered_requirement").length,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: issues.length > 0 ? `${issues.length} plan issue(s) found` : undefined,
};
}
mechanicalChallenge(projectPath: string, planPath: string): PlanIssue[] {
const issues: PlanIssue[] = [];
if (!fs.existsSync(planPath)) {
const altPaths = [
path.join(projectPath, "PLAN.md"),
path.join(projectPath, ".opencode", "plans", "plan.md"),
];
const found = altPaths.find((p) => fs.existsSync(p));
if (!found) return issues;
return this.validatePlan(found);
}
return this.validatePlan(planPath);
}
private validatePlan(planPath: string): PlanIssue[] {
const issues: PlanIssue[] = [];
const content = fs.readFileSync(planPath, "utf-8");
const taskLines = content.split("\n").filter((l) => /^\|\s*\w/.test(l) && !l.includes("---") && !/^\|\s*Task/i.test(l));
for (const line of taskLines) {
const cols = line.split("|").map((c) => c.trim()).filter(Boolean);
if (cols.length < 1) continue;
const id = cols[0];
const meaningfulContent = cols.filter((c) => c.length > 5 && c !== id);
if (meaningfulContent.length === 0) {
issues.push({
type: "missing_must_haves",
description: `Task ${id} has no must-haves defined`,
taskId: id,
});
}
}
const phaseSection = content.match(/##\s+Phase[\s\S]*?(?=##\s+|$)/i);
if (phaseSection) {
const reqIds = [...phaseSection[0].matchAll(/([A-Z]+-[A-Z]*\d+)/g)].map((m) => m[1]);
if (reqIds.length > 0) {
const taskHasReq = new Set<string>();
for (const line of taskLines) {
for (const req of reqIds) {
if (line.includes(req)) {
taskHasReq.add(req);
}
}
}
for (const req of reqIds) {
if (!taskHasReq.has(req)) {
issues.push({
type: "uncovered_requirement",
description: `Requirement ${req} is not covered by any task`,
});
}
}
}
}
return issues;
}
private formatIssues(issues: PlanIssue[]): string {
if (issues.length === 0) return "Plan validation passed — no issues found.";
const lines: string[] = ["Plan Issues Found:", ""];
for (const issue of issues) {
lines.push(`[${issue.type}]${issue.taskId ? ` Task ${issue.taskId}:` : ""} ${issue.description}`);
}
return lines.join("\n");
}
}
+121 -4
View File
@@ -1,5 +1,52 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface ReviewFinding {
persona: "security" | "performance" | "maintainability";
severity: "P0" | "P1" | "P2" | "P3";
category: string;
file: string;
message: string;
}
const SECURITY_PATTERNS: Array<{
pattern: RegExp;
severity: "P0" | "P1";
category: string;
message: string;
}> = [
{ pattern: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*[^'"]*[\$`]/g, severity: "P0", category: "command_injection", message: "Command execution with dynamic input" },
{ pattern: /eval\s*\(\s*[^'"]*\$\{/g, severity: "P0", category: "code_injection", message: "eval() with dynamic content" },
{ pattern: /(?:password|secret|api[_-]?key|token)\s*[:=]\s*['"][^'"]{3,}['"]/gi, severity: "P0", category: "credential_exposure", message: "Hardcoded credential in source" },
{ pattern: /catch\s*\(\w*\)\s*\{\s*\}/g, severity: "P0", category: "swallowed_errors", message: "Empty catch block" },
{ pattern: /(?:__proto__|constructor\s*\[|prototype\s*\[)/g, severity: "P0", category: "prototype_pollution", message: "Prototype chain manipulation" },
{ pattern: /(?:md5|sha1|des|rc4)\s*\(/gi, severity: "P1", category: "weak_crypto", message: "Weak cryptographic algorithm" },
];
const PERFORMANCE_PATTERNS: Array<{
pattern: RegExp;
severity: "P1" | "P2";
category: string;
message: string;
}> = [
{ pattern: /(?:execSync|spawnSync)\s*\(\s*['"]/g, severity: "P1", category: "sync_exec", message: "Synchronous process spawn" },
{ pattern: /setTimeout\s*\((?![^)]*clearTimeout)/g, severity: "P2", category: "timer_leak", message: "setTimeout without clearTimeout" },
{ pattern: /express\.json\s*\(\s*\)/g, severity: "P1", category: "no_body_limit", message: "JSON body parser without size limit" },
];
const MAINTAINABILITY_PATTERNS: Array<{
pattern: RegExp;
severity: "P1" | "P2" | "P3";
category: string;
message: string;
}> = [
{ pattern: /(?:as\s+any\b|:\s*any\b|<any>|any\[\s*\])/g, severity: "P1", category: "type_safety", message: "Use of 'any' type" },
{ pattern: /\bvar\s+/g, severity: "P1", category: "modern_js", message: "Use of 'var'" },
{ pattern: /\b(?:TODO|FIXME|HACK|XXX)\b/g, severity: "P2", category: "tech_debt", message: "Technical debt marker" },
{ pattern: /console\.(log|warn|error)\s*\(/g, severity: "P2", category: "logging", message: "Direct console.log usage" },
];
export class CodeReviewerAgent extends BaseAgent {
readonly name = "code-reviewer";
readonly description = "Multi-persona code review. Auto-applies P0 fixes. Flags P1+ for post-hoc review.";
@@ -8,6 +55,7 @@ export class CodeReviewerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Running code review...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +63,83 @@ export class CodeReviewerAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const findings = this.mechanicalReview(context.project_path);
const p0Count = findings.filter((f) => f.severity === "P0").length;
const output = this.formatFindings(findings);
return {
success: false,
output: "Code review requires an intelligence backend. Configure one with: ci init --backend",
success: p0Count === 0,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: p0Count,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: p0Count > 0 ? `${p0Count} P0 finding(s) require immediate attention` : undefined,
};
}
mechanicalReview(projectPath: string): ReviewFinding[] {
const findings: ReviewFinding[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return findings;
const allPatterns: Array<{
patterns: typeof SECURITY_PATTERNS;
persona: ReviewFinding["persona"];
}> = [
{ patterns: SECURITY_PATTERNS as unknown as typeof SECURITY_PATTERNS, persona: "security" },
{ patterns: PERFORMANCE_PATTERNS as unknown as typeof SECURITY_PATTERNS, persona: "performance" },
{ patterns: MAINTAINABILITY_PATTERNS as unknown as typeof SECURITY_PATTERNS, persona: "maintainability" },
];
this.scanDirectory(srcDir, projectPath, allPatterns, findings);
return findings;
}
private scanDirectory(
dir: string,
projectPath: string,
personaPatterns: Array<{ patterns: Array<{ pattern: RegExp; severity: "P0" | "P1" | "P2" | "P3"; category: string; message: string }>; persona: ReviewFinding["persona"] }>,
findings: ReviewFinding[]
): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
this.scanDirectory(fullPath, projectPath, personaPatterns, findings);
} else if (
entry.isFile() &&
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".d.ts")
) {
const content = fs.readFileSync(fullPath, "utf-8");
for (const { patterns, persona } of personaPatterns) {
for (const { pattern, severity, category, message } of patterns) {
pattern.lastIndex = 0;
if (pattern.test(content)) {
findings.push({
persona,
severity: severity as ReviewFinding["severity"],
category,
file: path.relative(projectPath, fullPath),
message,
});
}
}
}
}
}
}
private formatFindings(findings: ReviewFinding[]): string {
if (findings.length === 0) return "No findings — code review passed.";
const lines: string[] = ["Code Review Findings:", ""];
for (const f of findings) {
lines.push(`[${f.persona}|${f.severity}] ${f.category}: ${f.message} (${f.file})`);
}
return lines.join("\n");
}
}
+51
View File
@@ -0,0 +1,51 @@
import { DebuggerAgent } from "../agents/debugger.js";
describe("DebuggerAgent", () => {
it("parses standard V8 stack traces", () => {
const agent = new DebuggerAgent();
const trace = `Error: something broke
at Object.doWork (src/app.ts:42:15)
at processTicksAndRejections (node:internal/process/task_queues:95:5)`;
const frames = (agent as unknown as { parseStackTrace: (t: string) => Array<{ file: string; line: number; function?: string }> }).parseStackTrace(trace);
expect(frames.length).toBeGreaterThan(0);
expect(frames[0].file).toContain("src/app.ts");
expect(frames[0].line).toBe(42);
expect(frames[0].function).toContain("doWork");
});
it("parses simple file:line:column traces", () => {
const agent = new DebuggerAgent();
const trace = "src/utils.ts:10:5";
const frames = (agent as unknown as { parseStackTrace: (t: string) => Array<{ file: string; line: number }> }).parseStackTrace(trace);
expect(frames.length).toBeGreaterThan(0);
expect(frames[0].file).toBe("src/utils.ts");
expect(frames[0].line).toBe(10);
});
it("returns empty for non-stack-trace input", () => {
const agent = new DebuggerAgent();
const frames = (agent as unknown as { parseStackTrace: (t: string) => Array<unknown> }).parseStackTrace("this is just text with no frames");
expect(frames).toHaveLength(0);
});
it("agent name is debugger", () => {
const agent = new DebuggerAgent();
expect(agent.name).toBe("debugger");
});
it("parses multiple stack frames", () => {
const agent = new DebuggerAgent();
const trace = `Error: fail
at foo (src/a.ts:1:1)
at bar (src/b.ts:2:2)
at baz (src/c.ts:3:3)`;
const frames = (agent as unknown as { parseStackTrace: (t: string) => Array<unknown> }).parseStackTrace(trace);
expect(frames.length).toBeGreaterThanOrEqual(3);
});
});
+137 -4
View File
@@ -1,5 +1,21 @@
import { execSync } from "node:child_process";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface StackFrame {
file: string;
line: number;
column?: number;
function?: string;
}
interface DebugResult {
rootFile: string;
rootLine: number;
rootFunction?: string;
introducingCommit?: string;
suggestion?: string;
}
export class DebuggerAgent extends BaseAgent {
readonly name = "debugger";
readonly description = "Autonomous debugging. Auto-fixes when root cause confidence > 0.60, escalates otherwise.";
@@ -8,6 +24,7 @@ export class DebuggerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Running autonomous debug...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +32,130 @@ export class DebuggerAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const debugResult = this.mechanicalDebug(context.project_path, context.specification);
const output = this.formatDebugResult(debugResult);
return {
success: false,
output: "Debugging requires an intelligence backend. Configure one with: ci init --backend",
success: !!debugResult.introducingCommit,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: debugResult.introducingCommit ? 0 : 1,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: debugResult.introducingCommit ? undefined : "Could not identify introducing commit via git bisect",
};
}
mechanicalDebug(projectPath: string, stackTrace: string): DebugResult {
const frames = this.parseStackTrace(stackTrace);
if (frames.length === 0) {
return { rootFile: "", rootLine: 0, suggestion: "No parseable stack frames found in input" };
}
const topFrame = frames[0];
const result: DebugResult = {
rootFile: topFrame.file,
rootLine: topFrame.line,
rootFunction: topFrame.function,
};
try {
const bisectResult = this.gitBisect(projectPath, topFrame.file, topFrame.line);
if (bisectResult) {
result.introducingCommit = bisectResult;
result.suggestion = `git revert ${bisectResult}`;
}
} catch {}
return result;
}
parseStackTrace(trace: string): StackFrame[] {
const frames: StackFrame[] = [];
const patterns = [
/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/g,
/at\s+(.+?)\s+\((.+?):(\d+)\)/g,
/at\s+(.+?):(\d+):(\d+)/g,
/(.+?):(\d+):(\d+)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(trace)) !== null) {
if (pattern === patterns[0] || pattern === patterns[1]) {
frames.push({
function: match[1],
file: match[2],
line: parseInt(match[3]),
column: match[4] ? parseInt(match[4]) : undefined,
});
} else {
frames.push({
file: match[1],
line: parseInt(match[2]),
column: match[3] ? parseInt(match[3]) : undefined,
});
}
}
if (frames.length > 0) break;
}
return frames;
}
private gitBisect(projectPath: string, file: string, line: number): string | null {
try {
execSync("git bisect start", { cwd: projectPath, stdio: "pipe", timeout: 5000 });
execSync("git bisect bad HEAD", { cwd: projectPath, stdio: "pipe", timeout: 5000 });
try {
const firstCommit = execSync("git rev-list --max-parents=0 HEAD", {
cwd: projectPath, encoding: "utf-8", stdio: "pipe", timeout: 5000,
}).trim();
execSync(`git bisect good ${firstCommit}`, { cwd: projectPath, stdio: "pipe", timeout: 5000 });
} catch {
execSync("git bisect good HEAD~20", { cwd: projectPath, stdio: "pipe", timeout: 5000 });
}
let result: string | null = null;
for (let i = 0; i < 50; i++) {
const output = execSync("git bisect run true", {
cwd: projectPath, encoding: "utf-8", stdio: "pipe", timeout: 30000,
});
if (output.includes("is the first bad commit")) {
const hashMatch = output.match(/^([a-f0-9]+)/m);
result = hashMatch ? hashMatch[1] : null;
break;
}
}
try {
execSync("git bisect reset", { cwd: projectPath, stdio: "pipe", timeout: 5000 });
} catch {}
return result;
} catch {
try {
execSync("git bisect reset", { cwd: projectPath, stdio: "pipe", timeout: 5000 });
} catch {}
return null;
}
}
private formatDebugResult(result: DebugResult): string {
const lines: string[] = ["Debug Analysis:", ""];
if (result.rootFile) {
lines.push(`Root location: ${result.rootFile}:${result.rootLine}`);
if (result.rootFunction) lines.push(`Function: ${result.rootFunction}`);
}
if (result.introducingCommit) {
lines.push(`Introduced by: ${result.introducingCommit}`);
}
if (result.suggestion) {
lines.push(`Suggestion: ${result.suggestion}`);
}
return lines.join("\n");
}
}
+167
View File
@@ -0,0 +1,167 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { DocVerifierAgent } from "../agents/doc-verifier.js";
function setupValidProject(tempDir: string): void {
const srcDir = path.join(tempDir, "src");
const agentsDir = path.join(srcDir, "agents");
const agentFiles = [
"orchestrator.ts", "planner.ts", "executor.ts", "verifier.ts",
"researcher.ts", "challenger.ts", "security-auditor.ts", "debugger.ts",
"doc-writer.ts", "doc-verifier.ts", "code-reviewer.ts", "ideation-agent.ts",
"roadmapper.ts", "plan-checker.ts", "project-researcher.ts",
"research-synthesizer.ts", "solution-writer.ts", "phase-researcher.ts", "tester.ts",
];
for (const dir of ["agents", "backends", "cli", "core", "types", "utils", "verification"]) {
fs.mkdirSync(path.join(srcDir, dir), { recursive: true });
}
fs.writeFileSync(path.join(agentsDir, "base.ts"), "");
fs.writeFileSync(path.join(agentsDir, "index.ts"), "");
for (const f of agentFiles) {
fs.writeFileSync(path.join(agentsDir, f), "export class X {}");
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
for (let i = 0; i < 44; i++) {
fs.writeFileSync(path.join(srcDir, `test-${i}.test.ts`), "test('x', () => {});");
}
}
describe("DocVerifierAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-doc-verifier-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("valid project passes with no findings", () => {
setupValidProject(tempDir);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
expect(findings).toHaveLength(0);
});
it("detects missing agent via agent_mismatch", () => {
const srcDir = path.join(tempDir, "src");
const agentsDir = path.join(srcDir, "agents");
fs.mkdirSync(agentsDir, { recursive: true });
const agentFiles = [
"orchestrator.ts", "planner.ts", "executor.ts", "verifier.ts",
"researcher.ts", "challenger.ts", "security-auditor.ts",
];
fs.writeFileSync(path.join(agentsDir, "base.ts"), "");
fs.writeFileSync(path.join(agentsDir, "index.ts"), "");
for (const f of agentFiles) {
fs.writeFileSync(path.join(agentsDir, f), "export class X {}");
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const mismatch = findings.find((f) => f.type === "agent_mismatch");
expect(mismatch).toBeDefined();
expect(mismatch!.severity).toBe("P1");
});
it("detects version drift between package.json and src/version.ts", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.9.0";');
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const drift = findings.find((f) => f.type === "version_drift");
expect(drift).toBeDefined();
expect(drift!.severity).toBe("P0");
expect(drift!.expected).toContain("0.8.0");
expect(drift!.actual).toContain("0.9.0");
});
it("detects architecture stale when expected directory missing", () => {
const srcDir = path.join(tempDir, "src");
const limitedDirs = ["agents", "types"];
for (const dir of limitedDirs) {
fs.mkdirSync(path.join(srcDir, dir), { recursive: true });
}
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "0.8.0";');
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "0.8.0" }));
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n44 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
const stale = findings.filter((f) => f.type === "architecture_stale");
expect(stale.length).toBeGreaterThan(0);
expect(stale.some((f) => f.expected.includes("backends"))).toBe(true);
});
it("agent name is doc-verifier", () => {
const agent = new DocVerifierAgent();
expect(agent.name).toBe("doc-verifier");
});
it("findings include type and severity fields", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ version: "1.0.0" }));
fs.writeFileSync(path.join(srcDir, "version.ts"), 'export const VERSION = "2.0.0";');
fs.writeFileSync(
path.join(tempDir, "AGENTS.md"),
"19 agent implementations\n99 test suites\n"
);
const agent = new DocVerifierAgent();
const findings = agent.mechanicalDocVerify(tempDir);
for (const f of findings) {
expect(f.type).toBeDefined();
expect(f.severity).toBeDefined();
expect(f.expected).toBeDefined();
expect(f.actual).toBeDefined();
expect(["P0", "P1", "P2"]).toContain(f.severity);
expect(["agent_mismatch", "version_drift", "architecture_stale", "test_count_drift"]).toContain(f.type);
}
});
});
+186 -4
View File
@@ -1,13 +1,26 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface DocFinding {
type: "agent_mismatch" | "version_drift" | "architecture_stale" | "test_count_drift";
severity: "P0" | "P1" | "P2";
expected: string;
actual: string;
file?: string;
}
const KNOWN_COMPONENTS = ["agents", "backends", "cli", "core", "types", "utils", "verification"];
export class DocVerifierAgent extends BaseAgent {
readonly name = "doc-verifier";
readonly description = "Verifies documentation matches live codebase.";
readonly description = "Verifies documentation matches live codebase via mechanical cross-checks.";
readonly workflow = "verify";
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Verifying documentation...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +28,183 @@ export class DocVerifierAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const findings = this.mechanicalDocVerify(context.project_path);
const output = this.formatFindings(findings);
return {
success: false,
output: "Documentation verification requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalDocVerify(projectPath: string): DocFinding[] {
const findings: DocFinding[] = [];
const agentFinding = this.checkAgentRegistry(projectPath);
if (agentFinding) findings.push(agentFinding);
const versionFinding = this.checkVersionConsistency(projectPath);
if (versionFinding) findings.push(versionFinding);
const archFindings = this.checkArchitectureTree(projectPath);
findings.push(...archFindings);
const testFinding = this.checkTestCount(projectPath);
if (testFinding) findings.push(testFinding);
return findings;
}
checkAgentRegistry(projectPath: string): DocFinding | null {
const agentsDir = path.join(projectPath, "src", "agents");
if (!fs.existsSync(agentsDir)) return null;
const agentFiles = fs.readdirSync(agentsDir)
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts") && !f.endsWith(".d.ts") && f !== "index.ts" && f !== "base.ts");
const agentsMdPath = path.join(projectPath, "AGENTS.md");
if (!fs.existsSync(agentsMdPath)) return null;
const agentsMdContent = fs.readFileSync(agentsMdPath, "utf-8");
const agentCountMatch = agentsMdContent.match(/(\d+)\s+agent/i);
if (!agentCountMatch) return null;
const claimedCount = parseInt(agentCountMatch[1], 10);
const actualCount = agentFiles.length;
if (actualCount !== claimedCount) {
return {
type: "agent_mismatch",
severity: "P1",
expected: `${claimedCount} agents`,
actual: `${actualCount} agents`,
file: "AGENTS.md",
};
}
return null;
}
checkVersionConsistency(projectPath: string): DocFinding | null {
const pkgPath = path.join(projectPath, "package.json");
const versionPath = path.join(projectPath, "src", "version.ts");
if (!fs.existsSync(pkgPath) || !fs.existsSync(versionPath)) return null;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
const pkgVersion = pkg.version;
const versionContent = fs.readFileSync(versionPath, "utf-8");
const match = versionContent.match(/VERSION\s*=\s*"([^"]+)"/);
if (!match) return null;
const srcVersion = match[1];
if (pkgVersion !== srcVersion) {
return {
type: "version_drift",
severity: "P0",
expected: `package.json=${pkgVersion}`,
actual: `src/version.ts=${srcVersion}`,
file: "src/version.ts",
};
}
return null;
}
checkArchitectureTree(projectPath: string): DocFinding[] {
const findings: DocFinding[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return findings;
const actualDirs = new Set(
fs.readdirSync(srcDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
);
const archMdPath = this.resolveArchMdPath(projectPath);
const archFile = archMdPath ? path.relative(projectPath, archMdPath) : "ARCHITECTURE.md";
for (const expected of KNOWN_COMPONENTS) {
if (!actualDirs.has(expected)) {
findings.push({
type: "architecture_stale",
severity: "P2",
expected: `src/${expected}/ directory`,
actual: "directory not found",
file: archFile,
});
}
}
return findings;
}
checkTestCount(projectPath: string): DocFinding | null {
const agentsMdPath = path.join(projectPath, "AGENTS.md");
if (!fs.existsSync(agentsMdPath)) return null;
const agentsMdContent = fs.readFileSync(agentsMdPath, "utf-8");
const testCountMatch = agentsMdContent.match(/(\d+)\s+test\s+suit/i);
if (!testCountMatch) return null;
const claimedCount = parseInt(testCountMatch[1], 10);
const actualCount = this.countTestFiles(path.join(projectPath, "src"));
if (actualCount !== claimedCount) {
return {
type: "test_count_drift",
severity: "P1",
expected: `${claimedCount} test suites`,
actual: `${actualCount} test suites`,
file: "AGENTS.md",
};
}
return null;
}
private resolveArchMdPath(projectPath: string): string | null {
const ciagentArch = path.join(projectPath, ".ciagent", "ARCHITECTURE.md");
if (fs.existsSync(ciagentArch)) return ciagentArch;
const ciArch = path.join(projectPath, ".ci", "ARCHITECTURE.md");
if (fs.existsSync(ciArch)) return ciArch;
return null;
}
private countTestFiles(dir: string): number {
if (!fs.existsSync(dir)) return 0;
let count = 0;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
count += this.countTestFiles(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
count++;
}
}
return count;
}
private formatFindings(findings: DocFinding[]): string {
if (findings.length === 0) return "Documentation verification passed — no drift detected.";
const lines: string[] = ["Documentation Findings:", ""];
for (const f of findings) {
lines.push(`[${f.type}|${f.severity}] expected: ${f.expected}, actual: ${f.actual}${f.file ? ` (${f.file})` : ""}`);
}
return lines.join("\n");
}
}
+65
View File
@@ -0,0 +1,65 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { DocWriterAgent } from "../agents/doc-writer.js";
describe("DocWriterAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-doc-writer-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("updates ROADMAP.md phase status to complete", () => {
const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n| 1 | Setup | in progress | scaffold |\n");
const agent = new DocWriterAgent();
const updates = agent.mechanicalDocUpdate(tempDir, 1);
const roadmapContent = fs.readFileSync(path.join(ciDir, "ROADMAP.md"), "utf-8");
expect(roadmapContent).toContain("complete");
});
it("returns no updates when no .ciagent dir", () => {
const agent = new DocWriterAgent();
const updates = agent.mechanicalDocUpdate(tempDir, 1);
expect(updates).toHaveLength(0);
});
it("agent name is doc-writer", () => {
const agent = new DocWriterAgent();
expect(agent.name).toBe("doc-writer");
});
it("updates REQUIREMENTS.md pending to covered", () => {
const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"),
"# Req\n\n| REQ-01 | Do thing | P0 | 1 | pending |\n"
);
const agent = new DocWriterAgent();
const updates = agent.mechanicalDocUpdate(tempDir, 1);
const reqContent = fs.readFileSync(path.join(ciDir, "REQUIREMENTS.md"), "utf-8");
expect(reqContent).toContain("covered");
});
it("skips update when status already complete", () => {
const ciDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), "# Roadmap\n\n| 1 | Setup | complete | scaffold |\n");
const agent = new DocWriterAgent();
const updates = agent.mechanicalDocUpdate(tempDir, 1);
expect(updates).toHaveLength(0);
});
});
+162 -5
View File
@@ -1,13 +1,22 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync } from "node:child_process";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface DocUpdate {
file: string;
updates: string[];
}
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> {
const start = Date.now();
this.log("Writing documentation...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +24,162 @@ export class DocWriterAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const updates = this.mechanicalDocUpdate(context.project_path, context.phase);
const output = this.formatUpdates(updates);
return {
success: false,
output: "Documentation writing requires an intelligence backend.",
artifacts_created: [],
success: true,
output,
artifacts_created: updates.map((u) => u.file),
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalDocUpdate(projectPath: string, phase: number): DocUpdate[] {
const updates: DocUpdate[] = [];
const ciDir = path.join(projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return updates;
const roadmapUpdates = this.updateRoadmapPhaseStatus(ciDir, phase);
if (roadmapUpdates.length > 0) {
updates.push({ file: ".ciagent/ROADMAP.md", updates: roadmapUpdates });
}
const reqUpdates = this.updateRequirementsStatus(projectPath, phase);
if (reqUpdates.length > 0) {
updates.push({ file: ".ciagent/REQUIREMENTS.md", updates: reqUpdates });
}
const decisionUpdates = this.updateProjectDecisions(ciDir, phase);
if (decisionUpdates.length > 0) {
updates.push({ file: ".ciagent/PROJECT.md", updates: decisionUpdates });
}
if (updates.length > 0) {
try {
execSync("git add -A", { cwd: projectPath, stdio: "pipe" });
} catch {}
}
return updates;
}
private updateRoadmapPhaseStatus(ciDir: string, phase: number): string[] {
const roadmapPath = path.join(ciDir, "ROADMAP.md");
if (!fs.existsSync(roadmapPath)) return [];
const content = fs.readFileSync(roadmapPath, "utf-8");
const phasePattern = new RegExp(
`\\|\\s*${phase}\\s*\\|([^|]+)\\|([^|]+)\\|`,
"g"
);
let updated = content;
let match;
const updates: string[] = [];
while ((match = phasePattern.exec(content)) !== null) {
const currentStatus = match[2].trim().toLowerCase();
if (currentStatus !== "complete") {
updated = updated.replace(
match[0],
match[0].replace(/in.progress|pending|not.started/i, "complete")
);
updates.push(`Phase ${phase}: status → complete`);
}
}
if (updated !== content) {
fs.writeFileSync(roadmapPath, updated, "utf-8");
}
return updates;
}
private updateRequirementsStatus(projectPath: string, phase: number): string[] {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return [];
const content = fs.readFileSync(reqPath, "utf-8");
let updated = content;
const updates: string[] = [];
const pendingForPhase = content.match(
new RegExp(`\\|[^|]*\\|[^|]*\\|[^|]*\\|\\s*${phase}\\s*\\|\\s*pending\\s*\\|`, "g")
);
if (pendingForPhase) {
for (const line of pendingForPhase) {
updated = updated.replace(line, line.replace(/pending/, "covered"));
updates.push(`Requirement updated to covered (phase ${phase})`);
}
}
if (updated !== content) {
fs.writeFileSync(reqPath, updated, "utf-8");
}
return updates;
}
private updateProjectDecisions(ciDir: string, phase: number): string[] {
const projectPath = path.join(ciDir, "PROJECT.md");
if (!fs.existsSync(projectPath)) return [];
const content = fs.readFileSync(projectPath, "utf-8");
const gitLogDecisions = this.getRecentDecisions(phase);
if (gitLogDecisions.length === 0) return [];
const updates: string[] = [];
for (const d of gitLogDecisions) {
if (!content.includes(d.id)) {
updates.push(`Added decision ${d.id}: ${d.decision}`);
}
}
return updates;
}
private getRecentDecisions(phase: number): Array<{ id: string; decision: string }> {
try {
const raw = execSync(
`git log --all --max-count=20 --format="%B%x01"`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }
);
const decisions: Array<{ id: string; decision: string }> = [];
const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) {
const ciMatch = entry.match(/---ci---[\s\S]*?---\/ci---/);
if (!ciMatch) continue;
const phaseMatch = ciMatch[0].match(/phase:\s*(\d+)/);
if (!phaseMatch || parseInt(phaseMatch[1]) !== phase) continue;
const decMatches = [...ciMatch[0].matchAll(/id:\s*(D-\d+)[\s\S]*?decision:\s*(.+)/g)];
for (const m of decMatches) {
decisions.push({ id: m[1], decision: m[2].trim() });
}
}
return decisions;
} catch {
return [];
}
}
private formatUpdates(updates: DocUpdate[]): string {
if (updates.length === 0) return "No documentation updates needed.";
const lines: string[] = ["Documentation Updates:", ""];
for (const u of updates) {
lines.push(`${u.file}:`);
for (const update of u.updates) {
lines.push(` - ${update}`);
}
}
return lines.join("\n");
}
}
+79
View File
@@ -0,0 +1,79 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ExecutorAgent } from "../agents/executor.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-executor-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "execute",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
describe("ExecutorAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("returns honest failure without backend", async () => {
const executor = new ExecutorAgent();
const result = await executor.execute(makeContext(dir));
expect(result.success).toBe(false);
expect(result.error).toContain("intelligence backend");
});
it("delegates to backend when available", async () => {
const mockBackend = new MockBackend();
const executor = new ExecutorAgent();
const result = await executor.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const executor = new ExecutorAgent();
expect(executor.name).toBe("executor");
});
it("has correct workflow", () => {
const executor = new ExecutorAgent();
expect(executor.workflow).toBe("execute");
});
});
+190 -4
View File
@@ -2,6 +2,11 @@ import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { TerritoryConflict, DecomposedPlan, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { loadConfig } from "../core/config.js";
export interface ExecutorResult {
success: boolean;
@@ -17,6 +22,17 @@ interface MustHaveItem {
passed: boolean;
}
interface PersonaTaskGroup {
persona: string;
domain: string;
tasks: Array<{
id: string;
description: string;
files: string[];
}>;
conflicts: TerritoryConflict[];
}
export class ExecutorAgent extends BaseAgent {
readonly name = "executor";
readonly description = "Executes plan tasks autonomously. Never pauses for checkpoints.";
@@ -27,6 +43,14 @@ export class ExecutorAgent extends BaseAgent {
this.log("Executing tasks...");
if (context.backend) {
const config = this.loadProjectConfig(context);
const personasEnabled = config.personas?.enabled !== false;
if (personasEnabled) {
this.log("Persona-based execution enabled — decomposing plan and assigning to personas");
return this.executeWithPersonas(context, config);
}
const taskPrompt = await this.buildBackendTaskPrompt(context);
const backendResult = await this.executeViaBackend(context, taskPrompt);
@@ -50,6 +74,156 @@ export class ExecutorAgent extends BaseAgent {
};
}
private async executeWithPersonas(
context: AgentContext,
config: CIAgentConfig
): Promise<AgentResult> {
const start = Date.now();
const planContent = this.readPlanFile(context);
if (!planContent) {
this.log("No plan file found — falling back to standard execution");
const taskPrompt = await this.buildBackendTaskPrompt(context);
return this.executeViaBackend(context, taskPrompt);
}
const decomposer = new TaskDecomposer(context.project_path, config, context.project_slug);
const plan = decomposer.decompose(planContent);
const resolvedPlan = decomposer.resolveConflicts(plan);
this.log(`Decomposed plan into ${resolvedPlan.tasks.length} tasks across domains: data=${resolvedPlan.dataTasks.length}, backend=${resolvedPlan.backendTasks.length}, frontend=${resolvedPlan.frontendTasks.length}, coordination=${resolvedPlan.coordinationTasks.length}`);
if (resolvedPlan.conflicts.length > 0) {
this.log(`Resolved ${resolvedPlan.conflicts.length} territory conflicts`);
for (const conflict of resolvedPlan.conflicts) {
this.log(` Conflict: ${conflict.description}${conflict.resolution || "unresolved"}`);
}
}
const personaGroups = this.groupTasksByPersona(resolvedPlan);
const personaLoader = new PersonaLoader(context.project_path, config);
const enforcement = config.personas?.territory_enforcement || "warn";
let totalDecisions = 0;
let totalEscalations = 0;
const allArtifacts: string[] = [];
let lastError: string | undefined;
const domainOrder: string[] = ["data", "backend", "frontend", "coordination"];
const sortedGroups = domainOrder
.flatMap((domain) => personaGroups.filter((g) => g.domain === domain))
.concat(personaGroups.filter((g) => !domainOrder.includes(g.domain)));
for (const group of sortedGroups) {
this.log(`Executing group: persona=${group.persona}, domain=${group.domain}, tasks=${group.tasks.length}`);
for (const conflict of group.conflicts) {
if (enforcement === "strict") {
this.warn(`Territory conflict (strict): ${conflict.description}`);
totalEscalations++;
} else {
this.log(`Territory conflict (warn): ${conflict.description}${conflict.resolution || "auto-resolved"}`);
}
}
const persona = personaLoader.getPersona(group.persona);
const personaContext = this.buildPersonaContext(context, persona, group);
try {
const result = await this.executeViaBackend(personaContext, personaContext.specification);
if (Array.isArray(result.artifacts_created)) {
allArtifacts.push(...result.artifacts_created);
}
totalDecisions += result.decisions;
totalEscalations += result.escalations;
if (!result.success) {
this.warn(`Persona ${group.persona} reported issues: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Persona ${group.persona} failed: ${err instanceof Error ? err.message : String(err)}`);
lastError = err instanceof Error ? err.message : String(err);
}
}
const verification = await this.verifyExecution(context);
return {
success: verification.testsPassing || lastError === undefined,
output: `Executed ${resolvedPlan.tasks.length} tasks across ${personaGroups.length} persona groups. Verification: tests=${verification.testsPassing ? "passing" : "failing"}, must-haves=${verification.mustHavesChecked.length}`,
artifacts_created: allArtifacts,
decisions: totalDecisions,
escalations: totalEscalations,
duration_ms: Date.now() - start,
error: lastError,
};
}
private groupTasksByPersona(plan: DecomposedPlan): PersonaTaskGroup[] {
const groupMap = new Map<string, PersonaTaskGroup>();
for (const task of plan.tasks) {
const key = task.persona;
if (!groupMap.has(key)) {
groupMap.set(key, {
persona: task.persona,
domain: task.domain,
tasks: [],
conflicts: plan.conflicts.filter((c) => c.personas.includes(task.persona)),
});
}
groupMap.get(key)!.tasks.push({
id: task.taskId,
description: task.description,
files: task.files,
});
}
return Array.from(groupMap.values());
}
private buildPersonaContext(
context: AgentContext,
persona: ReturnType<PersonaLoader["getPersona"]>,
group: PersonaTaskGroup
): AgentContext {
const personaPrompt = persona
? `You are the ${persona.name} (${persona.domain} domain). ${persona.systemPromptAdditions || persona.description}.\n\nPreferred frameworks: ${persona.frameworks.join(", ")}.\nDesign constraints: ${persona.constraints.join(", ")}.\nTerritory files: ${persona.territory.join(", ")}.\n\n`
: "";
const taskDescriptions = group.tasks
.map((t) => `- [${t.id}] ${t.description} (files: ${t.files.join(", ") || "TBD"})`)
.join("\n");
const conflictNotes = group.conflicts.length > 0
? `\n\n## Territory Conflicts (resolved by lead developer)\n${group.conflicts.map((c) => `- ${c.description} → Resolution: ${c.resolution || "pending"}`).join("\n")}`
: "";
const specification = [
personaPrompt,
"## Assigned Tasks\n",
taskDescriptions,
conflictNotes,
"\n\n## Specification\n",
context.specification || "No specification provided",
].join("\n");
return {
...context,
specification,
};
}
private loadProjectConfig(context: AgentContext): CIAgentConfig {
try {
return loadConfig(context.project_path);
} catch {
return DEFAULT_CIAGENT_CONFIG as CIAgentConfig;
}
}
private async buildBackendTaskPrompt(context: AgentContext): Promise<string> {
const parts: string[] = [
`Execute implementation for stage ${context.stage}, phase ${context.phase}.`,
@@ -64,8 +238,12 @@ export class ExecutorAgent extends BaseAgent {
}
const ciDir = path.join(context.project_path, ".ciagent");
const roadmapPath = path.join(ciDir, "ROADMAP.md");
const archPath = path.join(ciDir, "ARCHITECTURE.md");
const roadmapPath = context.project_slug
? path.join(ciDir, context.project_slug, "ROADMAP.md")
: path.join(ciDir, "ROADMAP.md");
const archPath = context.project_slug
? path.join(ciDir, context.project_slug, "ARCHITECTURE.md")
: path.join(ciDir, "ARCHITECTURE.md");
if (fs.existsSync(roadmapPath)) {
try {
@@ -91,11 +269,17 @@ export class ExecutorAgent extends BaseAgent {
}
private readPlanFile(context: AgentContext): string | null {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
try {
if (fs.existsSync(planPath)) {
return fs.readFileSync(planPath, "utf-8");
}
const defaultPlanPath = path.join(context.project_path, ".ciagent", "PLAN.md");
if (fs.existsSync(defaultPlanPath)) {
return fs.readFileSync(defaultPlanPath, "utf-8");
}
} catch {}
return null;
}
@@ -139,7 +323,9 @@ export class ExecutorAgent extends BaseAgent {
}
private checkMustHaves(context: AgentContext): MustHaveItem[] {
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
const planPath = context.project_slug
? path.join(context.project_path, ".ciagent", context.project_slug, "PLAN.md")
: path.join(context.project_path, ".ciagent", "PLAN.md");
const results: MustHaveItem[] = [];
try {
+27
View File
@@ -0,0 +1,27 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { IdeationAgent } from "../agents/ideation-agent.js";
describe("IdeationAgent", () => {
it("agent name is ideation-agent", () => {
const agent = new IdeationAgent();
expect(agent.name).toBe("ideation-agent");
});
it("workflow is research", () => {
const agent = new IdeationAgent();
expect(agent.workflow).toBe("research");
});
it("delegates mechanicalIdeate to IdeationEngine", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-agent-test-"));
try {
const agent = new IdeationAgent();
const ideas = agent.mechanicalIdeate(tempDir);
expect(Array.isArray(ideas)).toBe(true);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
+15 -4
View File
@@ -1,13 +1,15 @@
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
import { IdeationEngine } from "../core/ideation.js";
export class IdeationAgent extends BaseAgent {
readonly name = "ideation-agent";
readonly description = "Generates improvement ideas. Output feeds directly into planning pipeline.";
readonly description = "Generates improvement ideas using git-native pattern mining, coverage gap analysis, and architectural drift detection. Output feeds directly into planning pipeline.";
readonly workflow = "research";
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Generating improvement ideas...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +17,23 @@ export class IdeationAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const engine = new IdeationEngine(context.project_path);
const ideas = engine.runMechanical();
const output = engine.formatIdeas(ideas);
return {
success: false,
output: "Ideation requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalIdeate(projectPath: string) {
const engine = new IdeationEngine(projectPath);
return engine.runMechanical();
}
}
+357 -23
View File
@@ -19,6 +19,8 @@ import { Specification, parseSpecification } from "../types/specification.js";
import { loadConfig, saveConfig, isCIAgentInitialized, initCIAgent } from "../core/config.js";
import { getAgent } from "./index.js";
import { IntelligenceBackend, BackendUnavailableError } from "../backends/types.js";
import { registerEscalationProtocol } from "../cli/index.js";
import { SessionManager } from "../core/session-manager.js";
import { execSync } from "node:child_process";
export interface GitAgentContext extends AgentContext {
@@ -44,12 +46,14 @@ 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"],
ideate: ["ideation-agent"],
plan: ["planner"],
execute: ["executor", "code-reviewer", "security-auditor"],
test: ["tester"],
verify: ["verifier"],
complete: ["doc-writer"],
};
constructor(config?: CIAgentConfig) {
@@ -65,9 +69,10 @@ export class OrchestratorAgent extends BaseAgent {
try {
this.config = loadConfig(context.project_path);
const projectSlug = context.project_slug || "";
this.gitContext = new GitContext(context.project_path);
this.gitBranch = new GitBranch(context.project_path);
this.ciFiles = new CIAgentFiles(context.project_path);
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug || undefined);
this.ciFiles.ensureCIDir();
const projectState = this.gitContext.reconstructState();
@@ -86,6 +91,7 @@ export class OrchestratorAgent extends BaseAgent {
this.decisionEngine = new DecisionEngine(this.config, context.project_path, this.currentMilestone);
this.escalationProtocol = new EscalationProtocol(this.config, context.project_path, this.currentMilestone);
registerEscalationProtocol(this.escalationProtocol);
while (this.pipelineState.current_phase <= this.totalPhases) {
this.log(`Processing phase ${this.pipelineState.current_phase} of ${this.totalPhases}`);
@@ -331,29 +337,112 @@ 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);
let primaryResult: AgentResult | null = null;
const allArtifacts: string[] = [];
let totalDecisions = 0;
let totalEscalations = 0;
let lastError: string | undefined;
const primaryAgent = getAgent(agentNames[0]);
const gitContext = this.buildGitAgentContext(context);
const result = await agent.execute(gitContext);
const primaryAgentResult = await primaryAgent.execute(gitContext);
primaryResult = primaryAgentResult;
if (Array.isArray(primaryAgentResult.artifacts_created)) {
allArtifacts.push(...primaryAgentResult.artifacts_created);
}
totalDecisions += primaryAgentResult.decisions;
totalEscalations += primaryAgentResult.escalations;
if (!primaryAgentResult.success) {
this.warn(`Primary agent ${agentNames[0]} 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: primaryAgentResult.error || `Primary agent ${agentNames[0]} failed`,
};
}
if (agentNames.length > 1) {
if (this.config.parallelization?.enabled) {
const reviewFactories = agentNames.slice(1).map((reviewAgentName) => {
return () => {
const agent = getAgent(reviewAgentName);
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)}`,
};
return agent.execute(reviewContext);
};
});
const settled = await this.limitConcurrency(reviewFactories, this.config.parallelization?.max_concurrent_agents ?? 5);
for (let si = 0; si < settled.length; si++) {
const result = settled[si];
if (result.status === "fulfilled") {
const agentResult = result.value;
if (Array.isArray(agentResult.artifacts_created)) allArtifacts.push(...agentResult.artifacts_created);
totalDecisions += agentResult.decisions;
totalEscalations += agentResult.escalations;
if (!agentResult.success) {
this.warn(`Review agent reported issues: ${agentResult.error || "unspecified"}`);
lastError = agentResult.error;
}
} else {
this.warn(`Review agent failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
}
}
} else {
for (let i = 1; i < agentNames.length; i++) {
const reviewAgentName = agentNames[i];
try {
const reviewAgent = getAgent(reviewAgentName);
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 reviewAgent.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 ${reviewAgentName} reported issues for ${stage}: ${result.error || "unspecified"}`);
lastError = result.error;
}
} catch (err) {
this.warn(`Review agent ${reviewAgentName} 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)}`);
}
}
}
@@ -373,6 +462,7 @@ export class OrchestratorAgent extends BaseAgent {
projectName: spec.objective.slice(0, 30),
phaseCount: 0,
milestone: this.currentMilestone,
project: context.project_slug || undefined,
specification: spec.raw_content,
requirements: spec.requirements,
constraints: spec.constraints,
@@ -446,7 +536,7 @@ export class OrchestratorAgent extends BaseAgent {
case "research": {
this.log("Researching project domain...");
this.decisionEngine!.setPhase(1);
this.decisionEngine!.setPhase(this.pipelineState!.current_phase);
const archMd = this.ciFiles!.readArchitectureMd();
if (!archMd) {
@@ -465,7 +555,7 @@ export class OrchestratorAgent extends BaseAgent {
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
const researchCommit = CommitBuilder.buildResearchCommit(
1,
this.pipelineState!.current_phase,
this.currentMilestone,
"initial domain research",
["Research completed. Key findings in .ciagent/ARCHITECTURE.md and .ciagent/PROJECT.md updates."]
@@ -485,11 +575,74 @@ export class OrchestratorAgent extends BaseAgent {
break;
}
case "ideate": {
this.log("Running ideation stage...");
const { IdeationEngine } = await import("../core/ideation.js");
const ideationEngine = new IdeationEngine(context.project_path, context.project_slug || undefined);
const ideas = ideationEngine.runMechanical();
const ideationConfig = this.config.ideation;
if (ideationConfig?.categories && ideationConfig.categories.length > 0) {
const categoryIdeas = ideationEngine.runMechanical(ideationConfig.categories);
const seenTitles = new Set(ideas.map((i) => i.title));
for (const idea of categoryIdeas) {
if (!seenTitles.has(idea.title)) {
ideas.push(idea);
seenTitles.add(idea.title);
}
}
}
ideas.sort((a, b) => b.confidence - a.confidence);
const maxIdeas = ideationConfig?.max_ideas || 20;
const trimmedIdeas = ideas.slice(0, maxIdeas);
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
const { accepted: savedIdeas, results } = ideationEngine.acceptIdeas(trimmedIdeas);
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
const ideationCommit = CommitBuilder.buildTaskCommit({
type: "decision",
phase: this.pipelineState!.current_phase,
milestone: this.currentMilestone,
project: context.project_slug || undefined,
plan: "ideation",
task: "ideation-results",
subject: `ideation results — ${trimmedIdeas.length} total, ${savedCount} accepted`,
status: "ideate",
decisions: savedIdeas.map((idea) => ({
id: idea.id,
decision: idea.title,
rationale: idea.rationale,
confidence: idea.confidence,
alternatives: idea.actions,
})),
});
try {
execSync(`git add -A && git commit -m "${ideationCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: context.project_path,
stdio: "pipe",
});
} catch (err) {
this.warn(`Ideation commit failed: ${err instanceof Error ? err.message : String(err)}`);
}
artifactsCreated.push(".ciagent/REQUIREMENTS.md", ".ciagent/ROADMAP.md");
decisionsMade += savedCount;
}
this.pipelineState!.ideate_completed = true;
this.log(`Ideation stage complete: ${trimmedIdeas.length} ideas generated`);
break;
}
case "plan":
this.log("Planning phase execution...");
if (this.config.git.branching_strategy === "phase" && this.gitBranch && this.gitContext!.isGitRepo()) {
this.gitBranch.createPhaseBranch(1, "initial-phase");
this.gitBranch.createPhaseBranch(this.pipelineState!.current_phase, "initial-phase");
}
this.pipelineState!.plan_completed = true;
@@ -569,7 +722,7 @@ export class OrchestratorAgent extends BaseAgent {
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
const verifyCommit = CommitBuilder.buildVerifyCommit({
phase: 1,
phase: this.pipelineState!.current_phase,
milestone: this.currentMilestone,
subject: "automated verification passed",
requirements: { covered: [], partial: [] },
@@ -592,7 +745,7 @@ export class OrchestratorAgent extends BaseAgent {
if (this.config.git.auto_commit && this.gitContext!.isGitRepo()) {
const completionCommit = CommitBuilder.buildPhaseCompletionCommit({
phase: 1,
phase: this.pipelineState!.current_phase,
milestone: this.currentMilestone,
phaseName: "initial-phase",
tasksCompleted: 0,
@@ -609,6 +762,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;
}
}
@@ -624,6 +801,38 @@ export class OrchestratorAgent extends BaseAgent {
};
}
private async limitConcurrency<T>(
factories: Array<() => Promise<T>>,
maxConcurrency: number
): Promise<PromiseSettledResult<T>[]> {
if (factories.length === 0) {
return [];
}
if (maxConcurrency <= 0 || maxConcurrency >= factories.length) {
return Promise.allSettled(factories.map((f) => f()));
}
const results: Array<PromiseSettledResult<T> | undefined> = new Array(factories.length).fill(undefined);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < factories.length) {
const index = nextIndex++;
try {
const value = await factories[index]();
results[index] = { status: "fulfilled", value };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
};
const workers = Array(Math.min(maxConcurrency, factories.length)).fill(null).map(() => worker());
await Promise.all(workers);
return results as PromiseSettledResult<T>[];
}
private generateCompletionReport(): string {
const lines: string[] = [
"# CIAgent Completion Report",
@@ -648,4 +857,129 @@ export class OrchestratorAgent extends BaseAgent {
return lines.join("\n");
}
async runForProject(projectSlug: string, context: AgentContext): Promise<AgentResult> {
this.log(`Running pipeline for project: ${projectSlug}`);
this.ciFiles = new CIAgentFiles(context.project_path, projectSlug);
this.ciFiles.ensureCIDir();
this.ciFiles.setProjectSlug(projectSlug);
const projectContext: AgentContext = {
...context,
project_path: context.project_path,
};
const result = await this.execute(projectContext);
return {
...result,
output: result.output ? `[${projectSlug}] ${result.output}` : result.output,
};
}
async runForAllProjects(context: AgentContext): Promise<Record<string, AgentResult>> {
const config = loadConfig(context.project_path);
const ciFiles = new CIAgentFiles(context.project_path);
const projects = ciFiles.listProjects();
const activeProjects: string[] = config.active_projects?.length > 0
? config.active_projects
: projects.map((p) => p.slug);
if (activeProjects.length === 0) {
this.log("No active projects found; running for default project");
const result = await this.execute(context);
return { default: result };
}
this.log(`Running pipeline for ${activeProjects.length} project(s): ${activeProjects.join(", ")}`);
const useSessions = config.sessions?.max_concurrent_sessions !== undefined;
if (useSessions) {
return this.runWithSessionManager(context, activeProjects, config);
}
return this.runWithLegacyParallel(context, activeProjects, config);
}
private async runWithSessionManager(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const sessionManager = new SessionManager(context.project_path, config);
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
const contextFactory = (slug: string): AgentContext => ({
...context,
project_slug: slug,
});
return sessionManager.runAllSessions(activeProjects, contextFactory, parallel);
}
private async runWithLegacyParallel(
context: AgentContext,
activeProjects: string[],
config: CIAgentConfig
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = config.parallelization?.max_concurrent_projects ?? 3;
const parallel = config.parallelization?.enabled && activeProjects.length > 1;
if (parallel) {
const limitedConcurrency = Math.min(maxConcurrent, activeProjects.length);
const batches: string[][] = [];
for (let i = 0; i < activeProjects.length; i += limitedConcurrency) {
batches.push(activeProjects.slice(i, i + limitedConcurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const orchestrator = new OrchestratorAgent(config);
const result = await orchestrator.runForProject(slug, context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
this.warn(`Project pipeline failed: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`);
}
}
}
} else {
for (const slug of activeProjects) {
this.log(`Processing project: ${slug}`);
const orchestrator = new OrchestratorAgent(config);
orchestrator.ciFiles = new CIAgentFiles(context.project_path, slug);
orchestrator.ciFiles.ensureCIDir();
orchestrator.ciFiles.setProjectSlug(slug);
try {
const result = await orchestrator.runForProject(slug, context);
results[slug] = result;
} catch (err) {
this.warn(`Failed for project ${slug}: ${err instanceof Error ? err.message : String(err)}`);
results[slug] = {
success: false,
output: `Pipeline failed for project ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
}
return results;
}
}
+217
View File
@@ -0,0 +1,217 @@
async function limitConcurrency<T>(
factories: Array<() => Promise<T>>,
maxConcurrency: number
): Promise<PromiseSettledResult<T>[]> {
if (factories.length === 0) {
return [];
}
if (maxConcurrency <= 0 || maxConcurrency >= factories.length) {
return Promise.allSettled(factories.map((f) => f()));
}
const results: Array<PromiseSettledResult<T> | undefined> = new Array(factories.length).fill(undefined);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < factories.length) {
const index = nextIndex++;
try {
const value = await factories[index]();
results[index] = { status: "fulfilled", value };
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
};
const workers = Array(Math.min(maxConcurrency, factories.length)).fill(null).map(() => worker());
await Promise.all(workers);
return results as PromiseSettledResult<T>[];
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Parallel Execution", () => {
describe("limitConcurrency", () => {
it("returns empty array for zero factories", async () => {
const results = await limitConcurrency([], 5);
expect(results).toEqual([]);
});
it("returns single-element result for one factory", async () => {
const results = await limitConcurrency([() => Promise.resolve(42)], 5);
expect(results).toHaveLength(1);
expect(results[0].status).toBe("fulfilled");
if (results[0].status === "fulfilled") {
expect(results[0].value).toBe(42);
}
});
it("behaves sequentially when maxConcurrency=1", async () => {
const order: number[] = [];
const factories = [1, 2, 3].map((n) => () =>
delay(30).then(() => { order.push(n); return n; })
);
const start = Date.now();
const results = await limitConcurrency(factories, 1);
const elapsed = Date.now() - start;
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.status).toBe("fulfilled");
}
expect(order).toEqual([1, 2, 3]);
expect(elapsed).toBeGreaterThanOrEqual(80);
});
it("runs concurrently when maxConcurrency exceeds factory count", async () => {
const factories = ["a", "b"].map((v) => () =>
delay(50).then(() => v)
);
const start = Date.now();
const results = await limitConcurrency(factories, 10);
const elapsed = Date.now() - start;
expect(results).toHaveLength(2);
expect(elapsed).toBeLessThan(120);
});
it("limits to maxConcurrency=2 with 4 factories", async () => {
const timestamps: number[] = [];
const factories = [0, 1, 2, 3].map((i) => () =>
delay(80).then(() => { timestamps.push(i); return i; })
);
const start = Date.now();
const results = await limitConcurrency(factories, 2);
const elapsed = Date.now() - start;
expect(results).toHaveLength(4);
for (const r of results) {
expect(r.status).toBe("fulfilled");
if (r.status === "fulfilled") {
expect([0, 1, 2, 3]).toContain(r.value);
}
}
expect(elapsed).toBeGreaterThanOrEqual(150);
expect(elapsed).toBeLessThan(350);
});
it("isolates rejected promises from fulfilled ones", async () => {
const factories = [
() => Promise.resolve("success"),
() => Promise.reject(new Error("boom")),
() => Promise.resolve("also-success"),
];
const results = await limitConcurrency(factories, 5);
expect(results).toHaveLength(3);
const fulfilled = results.filter((r) => r.status === "fulfilled");
const rejected = results.filter((r) => r.status === "rejected");
expect(fulfilled).toHaveLength(2);
expect(rejected).toHaveLength(1);
if (fulfilled[0].status === "fulfilled") {
expect(fulfilled[0].value).toBe("success");
}
if (fulfilled[1].status === "fulfilled") {
expect(fulfilled[1].value).toBe("also-success");
}
if (rejected[0].status === "rejected") {
expect((rejected[0].reason as Error).message).toBe("boom");
}
});
it("handles maxConcurrency=0 as no limit", async () => {
const factories = [1, 2, 3].map((v) => () => Promise.resolve(v));
const results = await limitConcurrency(factories, 0);
expect(results).toHaveLength(3);
for (const r of results) {
expect(r.status).toBe("fulfilled");
}
});
});
describe("parallel vs sequential timing", () => {
it("parallel execution is faster than sequential", async () => {
const DELAY_MS = 50;
const parallelFactories = [DELAY_MS, DELAY_MS].map((ms) => () => delay(ms));
const parallelStart = Date.now();
const parallelResults = await limitConcurrency(parallelFactories, 5);
const parallelElapsed = Date.now() - parallelStart;
const sequentialStart = Date.now();
for (const ms of [DELAY_MS, DELAY_MS]) {
await delay(ms);
}
const sequentialElapsed = Date.now() - sequentialStart;
expect(parallelResults).toHaveLength(2);
expect(parallelElapsed).toBeLessThan(sequentialElapsed);
expect(parallelElapsed).toBeLessThan(DELAY_MS * 1.8);
});
});
describe("concurrency limit verification", () => {
it("at most maxConcurrency agents run simultaneously", async () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const MAX = 2;
const factories = [0, 1, 2, 3].map(
(i) => () =>
new Promise<number>((resolve) => {
concurrentCount++;
if (concurrentCount > maxConcurrent) maxConcurrent = concurrentCount;
delay(60).then(() => {
concurrentCount--;
resolve(i);
});
})
);
const results = await limitConcurrency(factories, MAX);
expect(results).toHaveLength(4);
expect(maxConcurrent).toBeLessThanOrEqual(MAX);
});
});
describe("sequential fallback behavior", () => {
it("runs agents in order when parallelization disabled", async () => {
const executionOrder: string[] = [];
await (async () => {
for (const name of ["code-reviewer", "security-auditor"]) {
executionOrder.push(`start:${name}`);
await delay(10);
executionOrder.push(`end:${name}`);
}
})();
expect(executionOrder).toEqual([
"start:code-reviewer",
"end:code-reviewer",
"start:security-auditor",
"end:security-auditor",
]);
});
});
describe("single agent edge case", () => {
it("no review agents means no parallel code path triggered", async () => {
const results = await limitConcurrency([], 5);
expect(results).toHaveLength(0);
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PhaseResearcherAgent } from "../agents/phase-researcher.js";
describe("PhaseResearcherAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-phase-researcher-test-"));
fs.mkdirSync(path.join(tempDir, ".git"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("extracts decisions from commit log content", () => {
const agent = new PhaseResearcherAgent();
const result = agent.extractDecisions(
`some commit\n---ci---\nphase: 1\ndecisions:\n - D-101: Use SQLite for storage confidence: 0.9\n - D-102: Retry on failure confidence: 0.3\n---/ci---\n`
);
expect(result.length).toBe(2);
expect(result[0].id).toBe("D-101");
expect(result[0].confidence).toBe(0.9);
expect(result[1].id).toBe("D-102");
expect(result[1].confidence).toBe(0.3);
});
it("extracts lessons from commit log content", () => {
const agent = new PhaseResearcherAgent();
const result = agent.extractLessons(
`some commit\n---ci---\nphase: 1\nlessons:\n - testing: Flaky tests are a problem\n - build: CI timeouts\n---/ci---\n`
);
expect(result.length).toBe(2);
expect(result[0]).toContain("testing");
});
it("identifies risks from low-confidence decisions and repeated lessons", () => {
const agent = new PhaseResearcherAgent();
const decisions = [
{ id: "D-1", decision: "Risky choice", confidence: 0.4 },
{ id: "D-2", decision: "Safe choice", confidence: 0.9 },
];
const lessons = [
"testing: Flaky tests",
"testing: More flaky tests",
"build: CI timeouts",
];
const risks = agent.identifyRisks(decisions, lessons);
const highRisk = risks.filter((r) => r.severity === "high");
expect(highRisk.length).toBeGreaterThan(0);
expect(highRisk.some((r) => r.description.includes("D-1"))).toBe(true);
});
it("handles empty git log gracefully", () => {
const agent = new PhaseResearcherAgent();
const result = agent.mechanicalPhaseResearch(tempDir, 1);
expect(result.phase).toBe(1);
expect(result.decisions).toEqual([]);
expect(result.lessons).toEqual([]);
expect(result.risks).toEqual([]);
});
it("agent name is phase-researcher", () => {
const agent = new PhaseResearcherAgent();
expect(agent.name).toBe("phase-researcher");
});
});
+130 -4
View File
@@ -1,5 +1,14 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PhaseResearchResult {
phase: number;
decisions: Array<{ id: string; decision: string; confidence: number }>;
lessons: string[];
risks: Array<{ description: string; severity: string }>;
}
export class PhaseResearcherAgent extends BaseAgent {
readonly name = "phase-researcher";
readonly description = "Researches how to implement a specific phase well.";
@@ -8,6 +17,7 @@ export class PhaseResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Researching phase implementation...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +25,130 @@ export class PhaseResearcherAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const result = this.mechanicalPhaseResearch(context.project_path, context.phase);
const output = this.formatResult(result);
return {
success: false,
output: "Phase research requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: result.risks.filter((r) => r.severity === "high").length,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalPhaseResearch(projectPath: string, phase: number): PhaseResearchResult {
const logContent = this.readPhaseGitLog(projectPath, phase);
const decisions = this.extractDecisions(logContent);
const lessons = this.extractLessons(logContent);
const risks = this.identifyRisks(decisions, lessons);
return { phase, decisions, lessons, risks };
}
readPhaseGitLog(projectPath: string, phase: number): string {
try {
const { execSync } = require("node:child_process");
return execSync(
`git log --all --format="%B" -100`,
{ cwd: projectPath, encoding: "utf-8", timeout: 5000 }
);
} catch {
return "";
}
}
extractDecisions(logContent: string): Array<{ id: string; decision: string; confidence: number }> {
const decisions: Array<{ id: string; decision: string; confidence: number }> = [];
const decisionRegex = /(?:decisions|decision):\s*\n((?:\s+-\s+.+\n?)+)/g;
let match;
while ((match = decisionRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
for (const item of items) {
const text = item.replace(/^\s*-\s*/, "").trim();
const idMatch = text.match(/D-(\d+)/);
const id = idMatch ? `D-${idMatch[1]}` : `D-${decisions.length + 1}`;
const confMatch = text.match(/confidence[:\s]+(\d+\.?\d*)/);
const confidence = confMatch ? parseFloat(confMatch[1]) : 0.5;
decisions.push({ id, decision: text, confidence });
}
}
return decisions;
}
extractLessons(logContent: string): string[] {
const lessons: string[] = [];
const lessonsRegex = /lessons:\s*\n((?:\s+-\s+.+\n?)+)/g;
let match;
while ((match = lessonsRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
for (const item of items) {
lessons.push(item.replace(/^\s*-\s*/, "").trim());
}
}
return lessons;
}
identifyRisks(
decisions: Array<{ id: string; decision: string; confidence: number }>,
lessons: string[]
): Array<{ description: string; severity: string }> {
const risks: Array<{ description: string; severity: string }> = [];
for (const decision of decisions) {
if (decision.confidence < 0.5) {
risks.push({
description: `Low-confidence decision ${decision.id}: ${decision.decision.substring(0, 60)}`,
severity: "high",
});
} else if (decision.confidence < 0.7) {
risks.push({
description: `Medium-confidence decision ${decision.id}: ${decision.decision.substring(0, 60)}`,
severity: "medium",
});
}
}
const topicCounts: Record<string, number> = {};
for (const lesson of lessons) {
const topic = lesson.split(":")[0].trim().toLowerCase();
topicCounts[topic] = (topicCounts[topic] || 0) + 1;
}
for (const [topic, count] of Object.entries(topicCounts)) {
if (count > 1) {
risks.push({
description: `Repeated lesson on "${topic}" (${count} occurrences) suggests systemic risk`,
severity: count >= 3 ? "high" : "medium",
});
}
}
return risks;
}
private formatResult(result: PhaseResearchResult): string {
const lines: string[] = [`Phase ${result.phase} Research:`, ""];
lines.push("Decisions:");
for (const d of result.decisions) {
lines.push(` [${d.id}|conf=${d.confidence.toFixed(2)}] ${d.decision}`);
}
lines.push("");
lines.push("Lessons:");
for (const l of result.lessons) {
lines.push(` - ${l}`);
}
lines.push("");
lines.push("Risks:");
for (const r of result.risks) {
lines.push(` [${r.severity}] ${r.description}`);
}
return lines.join("\n");
}
}
+107
View File
@@ -0,0 +1,107 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PlanCheckerAgent } from "../agents/plan-checker.js";
describe("PlanCheckerAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-plan-checker-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("detects missing required sections", () => {
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, "no sections here");
const missing = results.filter((r) => r.type === "missing_section");
expect(missing.length).toBeGreaterThan(0);
expect(missing.some((r) => r.description.includes("# Phase"))).toBe(true);
expect(missing.some((r) => r.description.includes("## Phase Goal"))).toBe(true);
expect(missing.some((r) => r.description.includes("## Plans"))).toBe(true);
});
it("detects task ID gaps", () => {
const plan = `
# Phase 1
## Phase Goal
Build it
## Plans
### Task 1.1: T1.1
stuff
### Task 1.1: T1.3
more stuff
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const gaps = results.filter((r) => r.type === "task_id_gap");
expect(gaps.length).toBeGreaterThan(0);
});
it("detects missing must-haves", () => {
const plan = `
# Phase 1
## Phase Goal
Build
## Plans
### Task 1.1: T1.1
Do the thing
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const missingMustHaves = results.filter((r) => r.type === "missing_must_haves");
expect(missingMustHaves.length).toBeGreaterThan(0);
});
it("detects invalid wave ordering", () => {
const plan = `
# Phase 1
## Phase Goal
Goal
## Plans
## Wave 2
tasks
## Wave 1
more tasks
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const waveInvalid = results.filter((r) => r.type === "wave_order_invalid");
expect(waveInvalid.length).toBeGreaterThan(0);
expect(waveInvalid[0].severity).toBe("P0");
});
it("detects uncovered requirements", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: First requirement\nREQ-2: Second requirement\nREQ-3: Third requirement"
);
const plan = `
# Phase 1
## Phase Goal
Goal
## Plans
REQ-1 is covered
`;
const agent = new PlanCheckerAgent();
const results = agent.mechanicalPlanCheck(tempDir, plan);
const uncovered = results.filter((r) => r.type === "uncovered_requirement");
expect(uncovered.length).toBe(2);
});
it("agent name is plan-checker", () => {
const agent = new PlanCheckerAgent();
expect(agent.name).toBe("plan-checker");
});
});
+153 -4
View File
@@ -1,5 +1,16 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PlanCheckResult {
type: "missing_section" | "task_id_gap" | "missing_must_haves" | "wave_order_invalid" | "uncovered_requirement";
severity: "P0" | "P1" | "P2";
description: string;
taskId?: string;
}
const REQUIRED_SECTIONS = ["# Phase", "## Phase Goal", "## Plans"];
export class PlanCheckerAgent extends BaseAgent {
readonly name = "plan-checker";
readonly description = "Verifies plan quality. On ISSUES FOUND, triggers automatic plan revision (up to 3 iterations).";
@@ -8,6 +19,7 @@ export class PlanCheckerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Checking plan quality...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +27,151 @@ export class PlanCheckerAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const planPath = path.join(context.project_path, ".ciagent", "PLAN.md");
let planContent = "";
if (fs.existsSync(planPath)) {
planContent = fs.readFileSync(planPath, "utf-8");
}
const results = this.mechanicalPlanCheck(context.project_path, planContent);
const p0Count = results.filter((r) => r.severity === "P0").length;
const output = this.formatResults(results);
return {
success: false,
output: "Plan checking requires an intelligence backend.",
success: p0Count === 0,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: p0Count,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: p0Count > 0 ? `${p0Count} P0 issue(s) found` : undefined,
};
}
mechanicalPlanCheck(projectPath: string, planContent: string): PlanCheckResult[] {
const results: PlanCheckResult[] = [];
this.checkStructure(planContent, results);
this.checkTaskIds(planContent, results);
this.checkMustHavesPresent(planContent, results);
this.checkWaveOrdering(planContent, results);
this.checkRequirementCoverage(projectPath, planContent, results);
return results;
}
checkStructure(planContent: string, results: PlanCheckResult[]): void {
for (const section of REQUIRED_SECTIONS) {
if (!planContent.includes(section)) {
results.push({
type: "missing_section",
severity: "P0",
description: `Plan is missing required section: ${section}`,
});
}
}
}
checkTaskIds(planContent: string, results: PlanCheckResult[]): void {
const taskIdRegex = /###?\s+Task\s+[\d.]+[:\s]+T?([\d.]+)/gi;
const ids: number[] = [];
let match;
while ((match = taskIdRegex.exec(planContent)) !== null) {
const idParts = match[1].split(".");
const taskId = parseInt(idParts[idParts.length - 1], 10);
if (!isNaN(taskId)) ids.push(taskId);
}
if (ids.length === 0) return;
for (let i = 1; i <= Math.max(...ids); i++) {
if (!ids.includes(i)) {
results.push({
type: "task_id_gap",
severity: "P1",
description: `Task ID gap: missing Task ${i}`,
taskId: `T${i}`,
});
}
}
}
checkMustHavesPresent(planContent: string, results: PlanCheckResult[]): void {
const taskRegex = /###?\s+Task[^]*?(?=###?\s+Task|$)/g;
const taskBlocks = planContent.match(taskRegex) || [];
for (const block of taskBlocks) {
const headerMatch = block.match(/###?\s+Task\s+([\d.]+)/);
if (!headerMatch) continue;
const taskId = headerMatch[1];
const hasMustHaves = /must.haves|acceptance.criteria|must.?have/i.test(block);
if (!hasMustHaves) {
results.push({
type: "missing_must_haves",
severity: "P1",
description: `Task ${taskId} is missing must-haves/acceptance criteria`,
taskId,
});
}
}
}
checkWaveOrdering(planContent: string, results: PlanCheckResult[]): void {
const waveRegex = /##?\s+Wave\s+(\d+)/gi;
const waves: number[] = [];
let match;
while ((match = waveRegex.exec(planContent)) !== null) {
waves.push(parseInt(match[1], 10));
}
for (let i = 1; i < waves.length; i++) {
if (waves[i] < waves[i - 1]) {
results.push({
type: "wave_order_invalid",
severity: "P0",
description: `Wave ordering invalid: Wave ${waves[i]} appears after Wave ${waves[i - 1]}`,
});
}
}
}
checkRequirementCoverage(projectPath: string, planContent: string, results: PlanCheckResult[]): void {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return;
const reqContent = fs.readFileSync(reqPath, "utf-8");
const reqIdRegex = /REQ-(\d+)/g;
const requirements = new Set<string>();
let reqMatch;
while ((reqMatch = reqIdRegex.exec(reqContent)) !== null) {
requirements.add(`REQ-${reqMatch[1]}`);
}
const planReqIdRegex = /REQ-(\d+)/g;
const coveredReqs = new Set<string>();
let planMatch;
while ((planMatch = planReqIdRegex.exec(planContent)) !== null) {
coveredReqs.add(`REQ-${planMatch[1]}`);
}
for (const req of requirements) {
if (!coveredReqs.has(req)) {
results.push({
type: "uncovered_requirement",
severity: "P2",
description: `Requirement ${req} not covered in plan`,
});
}
}
}
private formatResults(results: PlanCheckResult[]): string {
if (results.length === 0) return "Plan check passed — no issues found.";
const lines: string[] = ["Plan Check Results:", ""];
for (const r of results) {
lines.push(`[${r.type}|${r.severity}] ${r.description}${r.taskId ? ` (task: ${r.taskId})` : ""}`);
}
return lines.join("\n");
}
}
+167
View File
@@ -0,0 +1,167 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { PlannerAgent } from "../agents/planner.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-planner-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "plan",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupCIAgentDir(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
}
function writeRequirementsMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Requirements",
"",
"## v1 Requirements",
"",
"### Core",
"",
"- [ ] **REQ-01**: User authentication",
"- [ ] **REQ-02**: Task CRUD operations",
"- [ ] **REQ-03**: Real-time notifications",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| REQ-01 | Phase 1 | in_progress |",
"| REQ-02 | Phase 1 | pending |",
"| REQ-03 | Phase 1 | blocked |",
].join("\n");
fs.writeFileSync(path.join(ciDir, "REQUIREMENTS.md"), content);
}
function writeRoadmapMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Roadmap",
"",
"## Overview",
"",
"Task management API roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Authentication** - Implement auth",
"",
"## Phase Details",
"",
"### Phase 1: Authentication",
"**Goal**: Implement user authentication",
"**Depends on**: Nothing",
"**Requirements**: REQ-01, REQ-02",
"**Success Criteria**:",
"1. .ciagent/REQUIREMENTS.md exists",
"**Status**: in_progress",
].join("\n");
fs.writeFileSync(path.join(ciDir, "ROADMAP.md"), content);
}
describe("PlannerAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("returns honest failure without backend when no requirements or roadmap", async () => {
setupCIAgentDir(dir);
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir));
expect(result.success).toBe(false);
expect(result.error).toContain("No requirements or roadmap");
});
it("creates PLAN.md from REQUIREMENTS.md without backend", async () => {
setupCIAgentDir(dir);
writeRequirementsMd(dir);
writeRoadmapMd(dir);
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir));
expect(result.success).toBe(true);
expect(result.output).toContain("plan");
expect(fs.existsSync(path.join(dir, ".ciagent", "PLAN.md"))).toBe(true);
});
it("PLAN.md contains phase goal and tasks", async () => {
setupCIAgentDir(dir);
writeRequirementsMd(dir);
writeRoadmapMd(dir);
const planner = new PlannerAgent();
await planner.execute(makeContext(dir));
const planContent = fs.readFileSync(path.join(dir, ".ciagent", "PLAN.md"), "utf-8");
expect(planContent).toContain("Phase 1 Plan");
expect(planContent).toContain("Phase Goal");
expect(planContent).toContain("Tasks");
});
it("delegates to backend when available", async () => {
setupCIAgentDir(dir);
const mockBackend = new MockBackend();
const planner = new PlannerAgent();
const result = await planner.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const planner = new PlannerAgent();
expect(planner.name).toBe("planner");
});
it("has correct workflow", () => {
const planner = new PlannerAgent();
expect(planner.workflow).toBe("plan");
});
});
+8 -2
View File
@@ -222,7 +222,10 @@ export class PlannerAgent extends BaseAgent {
wave: 1,
requirements: chunk.map((r) => r.id),
dependsOn: [],
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
tasks: chunk.map((r) => {
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
}),
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
});
}
@@ -236,7 +239,10 @@ export class PlannerAgent extends BaseAgent {
wave: plans.length > 0 ? Math.max(...plans.map((p) => p.wave)) + 1 : 2,
requirements: chunk.map((r) => r.id),
dependsOn: plans.slice(0, plans.length > 0 ? 1 : 0).map((p) => p.name),
tasks: chunk.map((r) => `Implement ${r.id}: ${r.description.split(": ").slice(1).join(": ") || r.description}`),
tasks: chunk.map((r) => {
const desc = r.description.split(": ").slice(1).join(": ") || r.description;
return desc !== r.id ? `Implement ${r.id}: ${desc}` : `Implement ${r.id}`;
}),
mustHaves: chunk.map((r) => `${r.id} implemented and testable`),
});
}
+93
View File
@@ -0,0 +1,93 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ProjectResearcherAgent } from "../agents/project-researcher.js";
describe("ProjectResearcherAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-project-researcher-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads package.json and categorizes dependencies", () => {
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({
dependencies: { express: "^4.18.0", graphql: "^16.0.0" },
devDependencies: { jest: "^29.0.0", typescript: "^5.0.0" },
})
);
const agent = new ProjectResearcherAgent();
const pkg = agent.readPackageJson(tempDir);
const tsconfig = {};
const summary = agent.categorizeFindings(pkg, tsconfig, []);
expect(summary.frameworks).toContain("express");
expect(summary.frameworks).toContain("jest");
expect(summary.apis).toContain("graphql");
expect(summary.tooling).toContain("typescript");
});
it("reads tsconfig and extracts compiler options", () => {
fs.writeFileSync(
path.join(tempDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: { target: "ES2022", module: "Node16" },
})
);
const agent = new ProjectResearcherAgent();
const tsconfig = agent.readTsconfig(tempDir);
expect((tsconfig.compilerOptions as Record<string, unknown>).target).toBe("ES2022");
expect((tsconfig.compilerOptions as Record<string, unknown>).module).toBe("Node16");
});
it("categorizes tooling from scripts and engines", () => {
const pkg = {
scripts: { build: "tsc", test: "jest", lint: "eslint ." },
engines: { node: ">=18.0.0" },
};
const agent = new ProjectResearcherAgent();
const summary = agent.categorizeFindings(pkg, {}, []);
expect(summary.tooling).toContain("build_script");
expect(summary.tooling).toContain("test_script");
expect(summary.tooling).toContain("lint_script");
expect(summary.tooling).toContain("node:>=18.0.0");
});
it("detects patterns from devDependencies", () => {
const pkg = {
devDependencies: { jest: "^29.0.0", tsyringe: "^4.8.0" },
};
const agent = new ProjectResearcherAgent();
const summary = agent.categorizeFindings(pkg, {}, []);
expect(summary.patterns).toContain("test_driven");
expect(summary.patterns).toContain("dependency_injection");
});
it("returns empty summary when no package.json exists", () => {
const agent = new ProjectResearcherAgent();
const summary = agent.mechanicalProjectResearch(tempDir);
expect(summary.frameworks).toEqual([]);
expect(summary.apis).toEqual([]);
expect(summary.patterns).toEqual([]);
expect(summary.technologyDecisions).toEqual([]);
});
it("agent name is project-researcher", () => {
const agent = new ProjectResearcherAgent();
expect(agent.name).toBe("project-researcher");
});
});
+246 -4
View File
@@ -1,5 +1,57 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface EcosystemSummary {
frameworks: string[];
apis: string[];
patterns: string[];
tooling: string[];
technologyDecisions: Array<{ id: string; decision: string; confidence: number }>;
}
const FRAMEWORK_PATTERNS: Record<string, string[]> = {
react: ["react"],
vue: ["vue"],
angular: ["@angular/core"],
svelte: ["svelte"],
express: ["express"],
fastify: ["fastify"],
nestjs: ["@nestjs/core"],
next: ["next"],
nuxt: ["nuxt"],
koa: ["koa"],
jest: ["jest"],
vitest: ["vitest"],
};
const API_PATTERNS: Record<string, string[]> = {
graphql: ["graphql", "apollo", "@apollo"],
rest: ["express", "fastify", "restana"],
grpc: ["grpc", "@grpc"],
websocket: ["ws", "socket.io"],
};
const PATTERN_PATTERNS: Record<string, string[]> = {
microservices: ["@nestjs/microservices", "amqplib", "kafkajs"],
middleware: ["express", "koa", "fastify"],
cqrs: ["@nestjs/cqrs"],
dependency_injection: ["inversify", "tsyringe", "@nestjs/core"],
test_driven: ["jest", "vitest", "mocha"],
};
const TOOLING_PATTERNS: Record<string, string[]> = {
typescript: ["typescript"],
eslint: ["eslint"],
prettier: ["prettier"],
webpack: ["webpack"],
vite: ["vite"],
rollup: ["rollup"],
esbuild: ["esbuild"],
docker: [],
ci_cd: [],
};
export class ProjectResearcherAgent extends BaseAgent {
readonly name = "project-researcher";
readonly description = "Researches the domain ecosystem for a new project.";
@@ -8,6 +60,7 @@ export class ProjectResearcherAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Researching project domain ecosystem...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +68,203 @@ export class ProjectResearcherAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const summary = this.mechanicalProjectResearch(context.project_path);
const output = this.formatSummary(summary);
return {
success: false,
output: "Project research requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
decisions: summary.technologyDecisions.length,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalProjectResearch(projectPath: string): EcosystemSummary {
const pkg = this.readPackageJson(projectPath);
const tsconfig = this.readTsconfig(projectPath);
const techDecisions = this.readTechDecisions(projectPath);
const summary = this.categorizeFindings(pkg, tsconfig, techDecisions);
return summary;
}
readPackageJson(projectPath: string): Record<string, unknown> {
const pkgPath = path.join(projectPath, "package.json");
if (!fs.existsSync(pkgPath)) return {};
try {
return JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
} catch {
return {};
}
}
readTsconfig(projectPath: string): Record<string, unknown> {
const tsconfigPath = path.join(projectPath, "tsconfig.json");
if (!fs.existsSync(tsconfigPath)) return {};
try {
return JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
} catch {
return {};
}
}
readTechDecisions(projectPath: string): Array<{ id: string; decision: string; confidence: number }> {
const decisions: Array<{ id: string; decision: string; confidence: number }> = [];
const techCategories = ["technology_choice", "implementation_approach", "architecture"];
try {
const { execSync } = require("node:child_process");
const logContent = execSync(
`git log --all --format="%B" -100`,
{ cwd: projectPath, encoding: "utf-8", timeout: 5000 }
);
const categoryRegex = /category:\s*(\S+)/g;
const decisionRegex = /decisions:\s*\n((?:\s+-\s+.+\n?)+)/g;
let catMatch;
let match;
const blocks: Array<{ categories: string[]; items: string[] }> = [];
let currentCategories: string[] = [];
while ((catMatch = categoryRegex.exec(logContent)) !== null) {
currentCategories.push(catMatch[1].toLowerCase());
}
while ((match = decisionRegex.exec(logContent)) !== null) {
const items = match[1].split("\n").filter((l: string) => l.trim().startsWith("-"));
blocks.push({
categories: [...currentCategories],
items: items.map((i: string) => i.replace(/^\s*-\s*/, "").trim()),
});
}
for (const block of blocks) {
const isTech = block.categories.some((c) => techCategories.includes(c));
if (!isTech && block.categories.length > 0) continue;
for (const item of block.items) {
const idMatch = item.match(/D-(\d+)/);
const id = idMatch ? `D-${idMatch[1]}` : `D-${decisions.length + 1}`;
const confMatch = item.match(/confidence[:\s]+(\d+\.?\d*)/);
const confidence = confMatch ? parseFloat(confMatch[1]) : 0.5;
decisions.push({ id, decision: item, confidence });
}
}
} catch {
// git not available or no commits
}
return decisions;
}
categorizeFindings(
pkg: Record<string, unknown>,
tsconfig: Record<string, unknown>,
techDecisions: Array<{ id: string; decision: string; confidence: number }>
): EcosystemSummary {
const allDeps: string[] = [];
const deps = pkg.dependencies as Record<string, string> | undefined;
const devDeps = pkg.devDependencies as Record<string, string> | undefined;
if (deps) allDeps.push(...Object.keys(deps));
if (devDeps) allDeps.push(...Object.keys(devDeps));
const frameworks: string[] = [];
for (const [name, depPatterns] of Object.entries(FRAMEWORK_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
frameworks.push(name);
}
}
const apis: string[] = [];
for (const [name, depPatterns] of Object.entries(API_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
apis.push(name);
}
}
const patterns: string[] = [];
for (const [name, depPatterns] of Object.entries(PATTERN_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
patterns.push(name);
}
}
const tooling: string[] = [];
for (const [name, depPatterns] of Object.entries(TOOLING_PATTERNS)) {
if (depPatterns.some((p) => allDeps.includes(p))) {
tooling.push(name);
}
}
const compilerOptions = tsconfig.compilerOptions as Record<string, unknown> | undefined;
if (compilerOptions) {
const target = compilerOptions.target as string | undefined;
if (target) tooling.push(`es_target:${target.toLowerCase()}`);
const module = compilerOptions.module as string | undefined;
if (module) tooling.push(`module_system:${module.toLowerCase()}`);
}
const scripts = pkg.scripts as Record<string, string> | undefined;
if (scripts) {
if (scripts.build) tooling.push("build_script");
if (scripts.test) tooling.push("test_script");
if (scripts.lint) tooling.push("lint_script");
}
const engines = pkg.engines as Record<string, string> | undefined;
if (engines && engines.node) {
tooling.push(`node:${engines.node}`);
}
return {
frameworks,
apis,
patterns,
tooling,
technologyDecisions: techDecisions,
};
}
private formatSummary(summary: EcosystemSummary): string {
const lines: string[] = ["Ecosystem Summary:", ""];
lines.push("Frameworks:");
for (const f of summary.frameworks) {
lines.push(` - ${f}`);
}
if (summary.frameworks.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("APIs:");
for (const a of summary.apis) {
lines.push(` - ${a}`);
}
if (summary.apis.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Patterns:");
for (const p of summary.patterns) {
lines.push(` - ${p}`);
}
if (summary.patterns.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Tooling:");
for (const t of summary.tooling) {
lines.push(` - ${t}`);
}
if (summary.tooling.length === 0) lines.push(" (none detected)");
lines.push("");
lines.push("Technology Decisions:");
for (const d of summary.technologyDecisions) {
lines.push(` [${d.id}|conf=${d.confidence.toFixed(2)}] ${d.decision}`);
}
if (summary.technologyDecisions.length === 0) lines.push(" (none found)");
return lines.join("\n");
}
}
+72
View File
@@ -0,0 +1,72 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ResearchSynthesizerAgent } from "../agents/research-synthesizer.js";
describe("ResearchSynthesizerAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-synth-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads project files and extracts findings", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n- Component A\n- Component B");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n* REQ-1\n* REQ-2");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
expect(findings.length).toBeGreaterThan(0);
const sources = findings.map((f) => f.source);
expect(sources).toContain("ARCHITECTURE.md");
expect(sources).toContain("REQUIREMENTS.md");
});
it("merges overlapping topics from different sources", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Testing Strategy\n\n- Unit tests required");
fs.writeFileSync(path.join(ciagentDir, "PROJECT.md"), "# Testing Strategy\n\n- Integration tests needed");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
const testingFindings = findings.filter((f) => f.topic.includes("testing strategy"));
expect(testingFindings.length).toBeGreaterThanOrEqual(1);
const refs = testingFindings[0].crossReferences;
expect(refs).toContain("ARCHITECTURE.md");
expect(refs).toContain("PROJECT.md");
});
it("adds cross references between findings with shared topics", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "## API Layer\n\n- REST endpoints");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "## API Layer\n\n* Authentication");
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
const apiFindings = findings.filter((f) => f.topic.includes("api layer"));
expect(apiFindings.length).toBeGreaterThan(0);
});
it("returns empty findings when no files exist", () => {
const agent = new ResearchSynthesizerAgent();
const findings = agent.mechanicalSynthesize(tempDir);
expect(findings).toEqual([]);
});
it("agent name is research-synthesizer", () => {
const agent = new ResearchSynthesizerAgent();
expect(agent.name).toBe("research-synthesizer");
});
});
+118 -3
View File
@@ -1,5 +1,20 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface SynthesisFinding {
source: "ARCHITECTURE.md" | "REQUIREMENTS.md" | "PROJECT.md" | "git_log";
topic: string;
summary: string;
crossReferences: string[];
}
const SOURCE_FILES: Array<{ file: string; source: SynthesisFinding["source"] }> = [
{ file: "ARCHITECTURE.md", source: "ARCHITECTURE.md" },
{ file: "REQUIREMENTS.md", source: "REQUIREMENTS.md" },
{ file: "PROJECT.md", source: "PROJECT.md" },
];
export class ResearchSynthesizerAgent extends BaseAgent {
readonly name = "research-synthesizer";
readonly description = "Synthesizes research files into a cohesive summary for roadmap creation.";
@@ -8,6 +23,7 @@ export class ResearchSynthesizerAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Synthesizing research...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +31,113 @@ export class ResearchSynthesizerAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const findings = this.mechanicalSynthesize(context.project_path);
const output = this.formatFindings(findings);
return {
success: false,
output: "Research synthesis requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalSynthesize(projectPath: string): SynthesisFinding[] {
const fileContents = this.readProjectFiles(projectPath);
const allFindings = this.extractKeyStatements(fileContents);
const merged = this.mergeOverlapping(allFindings);
return this.addCrossReferences(merged);
}
readProjectFiles(projectPath: string): Array<{ source: SynthesisFinding["source"]; content: string }> {
const results: Array<{ source: SynthesisFinding["source"]; content: string }> = [];
const ciagentDir = path.join(projectPath, ".ciagent");
for (const { file, source } of SOURCE_FILES) {
const filePath = path.join(ciagentDir, file);
if (fs.existsSync(filePath)) {
results.push({ source, content: fs.readFileSync(filePath, "utf-8") });
}
}
return results;
}
extractKeyStatements(fileContents: Array<{ source: SynthesisFinding["source"]; content: string }>): SynthesisFinding[] {
const findings: SynthesisFinding[] = [];
const topicPatterns = [
/(?:^|\n)#{1,3}\s+(.+)/g,
/(?:^|\n)\*\s+(.+)/g,
/(?:^|\n)-\s+(.{5,80})/g,
];
for (const { source, content } of fileContents) {
if (!content.trim()) continue;
for (const pattern of topicPatterns) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
const topic = match[1].trim().replace(/[*`#]/g, "").substring(0, 80).trim();
if (topic.length < 3) continue;
findings.push({
source,
topic: topic.toLowerCase(),
summary: topic,
crossReferences: [],
});
}
}
}
return findings;
}
mergeOverlapping(findings: SynthesisFinding[]): SynthesisFinding[] {
const merged: Map<string, SynthesisFinding> = new Map();
for (const finding of findings) {
const key = finding.topic;
const existing = merged.get(key);
if (existing) {
if (!existing.crossReferences.includes(finding.source)) {
existing.crossReferences.push(finding.source);
}
} else {
merged.set(key, {
...finding,
crossReferences: [finding.source],
});
}
}
return Array.from(merged.values());
}
addCrossReferences(findings: SynthesisFinding[]): SynthesisFinding[] {
for (let i = 0; i < findings.length; i++) {
for (let j = 0; j < findings.length; j++) {
if (i === j) continue;
const topicA = findings[i].topic.split(" ").slice(0, 2).join(" ");
const topicB = findings[j].topic.split(" ").slice(0, 2).join(" ");
if (topicA === topicB && !findings[i].crossReferences.includes(findings[j].source)) {
findings[i].crossReferences.push(findings[j].source);
}
}
}
const crossReferenced = findings.filter((f) => f.crossReferences.length > 1);
const standalone = findings.filter((f) => f.crossReferences.length <= 1);
return [...crossReferenced, ...standalone];
}
private formatFindings(findings: SynthesisFinding[]): string {
if (findings.length === 0) return "No findings synthesized — no project files found.";
const lines: string[] = ["Synthesis Findings:", ""];
for (const f of findings) {
const refs = f.crossReferences.length > 0 ? f.crossReferences.join(", ") : "none";
lines.push(`[${f.source}] ${f.summary} (refs: ${refs})`);
}
return lines.join("\n");
}
}
+208
View File
@@ -0,0 +1,208 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { ResearcherAgent } from "../agents/researcher.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-researcher-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "research",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupCIAgentDir(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), '{"projects":[],"active_project":""}');
}
function writeProjectMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Task API",
"",
"## What This Is",
"",
"A REST API for managing tasks",
"",
"## Requirements",
"",
"### Validated",
"",
"- ✓ User authentication",
"",
"### Active",
"",
"- [ ] Task CRUD",
"",
"### Out of Scope",
"",
"- Admin dashboard",
"",
"## Context",
"",
"Node.js project",
"",
"## Constraints",
"",
"- Must use Node.js",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n");
fs.writeFileSync(path.join(ciDir, "PROJECT.md"), content);
}
function writeArchitectureMd(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
const content = [
"# Architecture",
"",
"## Overview",
"",
"Task management system architecture",
"",
"## Components",
"",
"### Core",
"- **Description**: Core module",
"- **Boundaries**: src/core/ — internal module",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Request → Handler → Service → Database",
"",
"## Build Order",
"",
"1. Build core module",
].join("\n");
fs.writeFileSync(path.join(ciDir, "ARCHITECTURE.md"), content);
}
function setupSourceDir(dir: string): void {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(path.join(srcDir, "core"), { recursive: true });
fs.mkdirSync(path.join(srcDir, "agents"), { recursive: true });
fs.writeFileSync(path.join(srcDir, "core", "index.ts"), "export {};\n");
fs.writeFileSync(path.join(srcDir, "agents", "base.ts"), "export {};\n");
}
describe("ResearcherAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("reads .ciagent/ files without backend", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
writeArchitectureMd(dir);
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir));
expect(result.success).toBe(true);
expect(result.output).toContain("findingsCount");
});
it("only modifies .ciagent/ files", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
writeArchitectureMd(dir);
setupSourceDir(dir);
const srcDir = path.join(dir, "src");
const filesBefore = new Set<string>();
function collectFiles(d: string): void {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
collectFiles(full);
} else {
filesBefore.add(full);
}
}
}
collectFiles(srcDir);
const researcher = new ResearcherAgent();
await researcher.execute(makeContext(dir));
collectFiles(srcDir);
for (const f of filesBefore) {
expect(fs.existsSync(f)).toBe(true);
}
});
it("updates ARCHITECTURE.md from source scan", async () => {
setupCIAgentDir(dir);
writeProjectMd(dir);
setupSourceDir(dir);
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir));
if (result.success) {
const parsed = JSON.parse(result.output);
expect(parsed.filesUpdated).toContain(".ciagent/ARCHITECTURE.md");
}
});
it("delegates to backend when available", async () => {
setupCIAgentDir(dir);
const mockBackend = new MockBackend();
const researcher = new ResearcherAgent();
const result = await researcher.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const researcher = new ResearcherAgent();
expect(researcher.name).toBe("researcher");
});
it("has correct workflow", () => {
const researcher = new ResearcherAgent();
expect(researcher.workflow).toBe("research");
});
});
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { RoadmapperAgent } from "../agents/roadmapper.js";
describe("RoadmapperAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-roadmapper-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("generates phases from REQUIREMENTS.md", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Build core\nREQ-2: Phase: 1 — Build utils\nREQ-3: Phase: 2 — Add features"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBeGreaterThanOrEqual(2);
expect(phases[0].requirements).toContain("REQ-1");
expect(phases[0].requirements).toContain("REQ-2");
expect(phases[1].requirements).toContain("REQ-3");
});
it("sets phase dependencies correctly", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Core\nREQ-2: Phase: 2 — Extension"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBe(2);
expect(phases[0].dependencies).toEqual([]);
expect(phases[1].dependencies).toEqual([1]);
});
it("generates success criteria from requirements", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"REQ-1: Phase: 1 — Build core"
);
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases.length).toBe(1);
expect(phases[0].successCriteria.length).toBeGreaterThan(0);
expect(phases[0].successCriteria.some((c) => c.includes("REQ-1"))).toBe(true);
});
it("returns empty when no REQUIREMENTS.md exists", () => {
const agent = new RoadmapperAgent();
const phases = agent.mechanicalRoadmapGenerate(tempDir);
expect(phases).toEqual([]);
});
it("agent name is roadmapper", () => {
const agent = new RoadmapperAgent();
expect(agent.name).toBe("roadmapper");
});
});
+110 -3
View File
@@ -1,5 +1,16 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface PhaseDefinition {
number: number;
name: string;
description: string;
requirements: string[];
dependencies: number[];
successCriteria: string[];
}
export class RoadmapperAgent extends BaseAgent {
readonly name = "roadmapper";
readonly description = "Creates and maintains project roadmaps.";
@@ -8,6 +19,7 @@ export class RoadmapperAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Creating roadmap...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +27,109 @@ export class RoadmapperAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const phases = this.mechanicalRoadmapGenerate(context.project_path);
const output = this.formatPhases(phases);
return {
success: false,
output: "Roadmap creation requires an intelligence backend.",
success: true,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalRoadmapGenerate(projectPath: string): PhaseDefinition[] {
const requirements = this.readRequirements(projectPath);
const grouped = this.groupRequirementsByPhase(requirements);
const phases = this.assignPhases(grouped);
return phases.map((phase) => ({
...phase,
successCriteria: this.generateSuccessCriteria(phase),
}));
}
readRequirements(projectPath: string): Array<{ id: string; phase: number; text: string }> {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return [];
const content = fs.readFileSync(reqPath, "utf-8");
const requirements: Array<{ id: string; phase: number; text: string }> = [];
const reqBlockRegex = /REQ-(\d+)[^]*?(?=REQ-\d+|$)/g;
let match;
while ((match = reqBlockRegex.exec(content)) !== null) {
const block = match[0];
const id = `REQ-${match[1]}`;
const phaseMatch = block.match(/phase[:\s]+(\d+)/i);
const phase = phaseMatch ? parseInt(phaseMatch[1], 10) : 1;
const textMatch = block.match(/(?:title|description|requirement)[:\s]+(.+)/i);
const text = textMatch ? textMatch[1].trim() : id;
requirements.push({ id, phase, text });
}
return requirements;
}
groupRequirementsByPhase(requirements: Array<{ id: string; phase: number; text: string }>): Record<number, Array<{ id: string; text: string }>> {
const groups: Record<number, Array<{ id: string; text: string }>> = {};
for (const req of requirements) {
if (!groups[req.phase]) {
groups[req.phase] = [];
}
groups[req.phase].push({ id: req.id, text: req.text });
}
return groups;
}
assignPhases(grouped: Record<number, Array<{ id: string; text: string }>>): PhaseDefinition[] {
const phaseNumbers = Object.keys(grouped).map(Number).sort((a, b) => a - b);
if (phaseNumbers.length === 0) return [];
return phaseNumbers.map((num, idx) => {
const reqs = grouped[num];
const dependencies = idx === 0 ? [] : [phaseNumbers[idx - 1]];
return {
number: num,
name: `Phase ${num}`,
description: `Implementation phase ${num} covering ${reqs.length} requirement(s).`,
requirements: reqs.map((r) => r.id),
dependencies,
successCriteria: [],
};
});
}
generateSuccessCriteria(phase: PhaseDefinition): string[] {
const criteria: string[] = [];
for (const reqId of phase.requirements) {
criteria.push(`${reqId} fully implemented and verified`);
}
if (phase.requirements.length > 0) {
criteria.push("All tests passing for phase requirements");
}
if (phase.dependencies.length > 0) {
criteria.push(`Phase ${phase.dependencies[0]} completion confirmed`);
}
return criteria;
}
private formatPhases(phases: PhaseDefinition[]): string {
if (phases.length === 0) return "No phases generated — no requirements found.";
const lines: string[] = ["Roadmap:", ""];
for (const phase of phases) {
lines.push(`Phase ${phase.number}: ${phase.name}`);
lines.push(` Description: ${phase.description}`);
lines.push(` Requirements: ${phase.requirements.join(", ") || "none"}`);
lines.push(` Dependencies: ${phase.dependencies.map(String).join(", ") || "none"}`);
lines.push(` Success Criteria:`);
for (const criterion of phase.successCriteria) {
lines.push(` - ${criterion}`);
}
lines.push("");
}
return lines.join("\n");
}
}
+69
View File
@@ -0,0 +1,69 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { SecurityAuditorAgent } from "../agents/security-auditor.js";
describe("SecurityAuditorAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-sec-auditor-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("finds hardcoded passwords via mechanical audit", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "config.ts"), 'const password = "secret123";');
const agent = new SecurityAuditorAgent();
const findings = agent.mechanicalAudit(tempDir);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0].stride_category).toBe("information_disclosure");
expect(findings[0].cwe).toContain("CWE-");
expect(findings[0].severity).toBe("high");
});
it("finds empty catch blocks as repudiation", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "err.ts"), 'try { work(); } catch(e) {}');
const agent = new SecurityAuditorAgent();
const findings = agent.mechanicalAudit(tempDir);
const repudiation = findings.filter((f) => f.stride_category === "repudiation");
expect(repudiation.length).toBeGreaterThan(0);
});
it("returns empty findings for clean code", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.ts"), 'export function main() { return 1; }');
const agent = new SecurityAuditorAgent();
const findings = agent.mechanicalAudit(tempDir);
expect(findings).toHaveLength(0);
});
it("applies confidence-based disposition", () => {
const srcDir = path.join(tempDir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "api.ts"), 'const api_key = "abc123";');
const agent = new SecurityAuditorAgent(0.5);
const findings = agent.mechanicalAudit(tempDir);
expect(findings.some((f) => f.disposition === "flag")).toBe(true);
});
it("agent name is security-auditor", () => {
const agent = new SecurityAuditorAgent();
expect(agent.name).toBe("security-auditor");
});
});
+103 -4
View File
@@ -1,13 +1,52 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface SecurityFinding {
stride_category: string;
cwe: string;
severity: "low" | "medium" | "high";
disposition: "accept" | "mitigate" | "flag";
file: string;
description: string;
}
const SECURITY_PATTERNS: Array<{
pattern: RegExp;
category: string;
cwe: string;
description: string;
severity: "low" | "medium" | "high";
confidence: number;
}> = [
{ pattern: /password\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-259", description: "Hardcoded password", severity: "high", confidence: 0.95 },
{ pattern: /api[_-]?key\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded API key", severity: "high", confidence: 0.95 },
{ pattern: /secret\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded secret", severity: "high", confidence: 0.95 },
{ pattern: /token\s*=\s*['"][^'"]+['"]/gi, category: "information_disclosure", cwe: "CWE-312", description: "Hardcoded token", severity: "medium", confidence: 0.80 },
{ pattern: /eval\s*\(\s*[^'"]*\$\{/g, category: "tampering", cwe: "CWE-94", description: "eval() with dynamic content", severity: "high", confidence: 0.90 },
{ pattern: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*[^'"]*[\$`]/g, category: "elevation_of_privilege", cwe: "CWE-78", description: "Command execution with interpolation", severity: "high", confidence: 0.85 },
{ pattern: /catch\s*\(\w*\)\s*\{\s*\}/g, category: "repudiation", cwe: "CWE-778", description: "Empty catch block", severity: "medium", confidence: 0.85 },
{ pattern: /jwt\.decode\s*\(/g, category: "spoofing", cwe: "CWE-287", description: "JWT decode without verify", severity: "high", confidence: 0.85 },
{ pattern: /(?:__proto__|constructor\s*\[|prototype\s*\[)/g, category: "elevation_of_privilege", cwe: "CWE-1321", description: "Prototype pollution", severity: "high", confidence: 0.90 },
{ pattern: /(?:md5|sha1|des|rc4)\s*\(/gi, category: "information_disclosure", cwe: "CWE-328", description: "Weak crypto", severity: "medium", confidence: 0.90 },
{ pattern: /express\.json\s*\(\s*\)/g, category: "denial_of_service", cwe: "CWE-400", description: "JSON parser without size limit", severity: "medium", confidence: 0.80 },
];
export class SecurityAuditorAgent extends BaseAgent {
readonly name = "security-auditor";
readonly description = "Auto-dispositions threats: low=accept, medium=mitigate, high=escalate.";
readonly workflow = "verify";
private confidenceThreshold: number;
constructor(confidenceThreshold: number = 0.6) {
super();
this.confidenceThreshold = confidenceThreshold;
}
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Running security audit...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +54,74 @@ export class SecurityAuditorAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const findings = this.mechanicalAudit(context.project_path);
const highCount = findings.filter((f) => f.severity === "high").length;
const output = this.formatFindings(findings);
return {
success: false,
output: "Security auditing requires an intelligence backend. Configure one with: ci init --backend",
success: highCount === 0,
output,
artifacts_created: [],
decisions: 0,
escalations: 0,
escalations: highCount,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
error: highCount > 0 ? `${highCount} high-severity finding(s) require escalation` : undefined,
};
}
mechanicalAudit(projectPath: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const srcDir = path.join(projectPath, "src");
if (!fs.existsSync(srcDir)) return findings;
this.scanDirectory(srcDir, projectPath, findings);
return findings;
}
private getDisposition(severity: SecurityFinding["severity"], confidence: number): SecurityFinding["disposition"] {
if (severity === "low") return "accept";
if (confidence >= this.confidenceThreshold) return "flag";
return "mitigate";
}
private scanDirectory(dir: string, projectPath: string, findings: SecurityFinding[]): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
this.scanDirectory(fullPath, projectPath, findings);
} else if (
entry.isFile() &&
(entry.name.endsWith(".ts") || entry.name.endsWith(".js")) &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".d.ts")
) {
const content = fs.readFileSync(fullPath, "utf-8");
for (const { pattern, category, cwe, description, severity, confidence } of SECURITY_PATTERNS) {
pattern.lastIndex = 0;
if (pattern.test(content)) {
findings.push({
stride_category: category,
cwe,
severity,
disposition: this.getDisposition(severity, confidence),
file: path.relative(projectPath, fullPath),
description,
});
}
}
}
}
}
private formatFindings(findings: SecurityFinding[]): string {
if (findings.length === 0) return "No security findings — audit passed.";
const lines: string[] = ["Security Audit Findings:", ""];
for (const f of findings) {
lines.push(`[${f.stride_category}|${f.cwe}|${f.disposition}] ${f.severity.toUpperCase()}: ${f.description} (${f.file})`);
}
return lines.join("\n");
}
}
+89
View File
@@ -0,0 +1,89 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { SolutionWriterAgent } from "../agents/solution-writer.js";
describe("SolutionWriterAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-solution-writer-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("reads PLAN.md and produces implementation plan section", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PLAN.md"),
"# Plan\n\n| T-01 | Setup | 1 | none | Files exist | REQ-01 |\n|-----|-------|---|------|------------|--------|\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("# Solution Document");
expect(result).toContain("## Implementation Plan");
expect(result).toContain("T-01");
});
it("reads REQUIREMENTS.md and extracts verification criteria", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\nREQ-01: System must boot\nREQ-02: System must respond\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("## Verification Criteria");
expect(result).toContain("REQ-01");
expect(result).toContain("REQ-02");
});
it("reads ARCHITECTURE.md and populates approach and risk sections", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Approach\n\nModular monolith with clear boundaries.\n\n## Risks\n\n- Single point of failure in gateway\n"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("## Approach");
expect(result).toContain("Modular monolith");
expect(result).toContain("## Risk Assessment");
expect(result).toContain("gateway");
});
it("produces structured markdown with all five sections", () => {
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("# Solution Document");
expect(result).toContain("## Problem Statement");
expect(result).toContain("## Approach");
expect(result).toContain("## Implementation Plan");
expect(result).toContain("## Verification Criteria");
expect(result).toContain("## Risk Assessment");
});
it("extracts problem statement from requirements objective section", () => {
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Objective\n\nBuild a fast CLI tool.\n\n## Requirements\n\nREQ-01: Speed"
);
const agent = new SolutionWriterAgent();
const result = agent.mechanicalSolutionWrite(tempDir);
expect(result).toContain("Build a fast CLI tool");
});
it("agent name is solution-writer", () => {
const agent = new SolutionWriterAgent();
expect(agent.name).toBe("solution-writer");
});
});
+199 -3
View File
@@ -1,5 +1,12 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { BaseAgent, AgentContext, AgentResult } from "./base.js";
interface SolutionSection {
title: string;
content: string;
}
export class SolutionWriterAgent extends BaseAgent {
readonly name = "solution-writer";
readonly description = "Produces structured solution documents.";
@@ -8,6 +15,7 @@ export class SolutionWriterAgent extends BaseAgent {
async execute(context: AgentContext): Promise<AgentResult> {
const start = Date.now();
this.log("Writing solution document...");
if (context.backend) {
const result = await this.executeViaBackend(
context,
@@ -15,14 +23,202 @@ export class SolutionWriterAgent extends BaseAgent {
);
return { ...result, duration_ms: Date.now() - start };
}
const document = this.mechanicalSolutionWrite(context.project_path);
return {
success: false,
output: "Solution writing requires an intelligence backend.",
success: true,
output: document,
artifacts_created: [],
decisions: 0,
escalations: 0,
duration_ms: Date.now() - start,
error: "No intelligence backend available",
};
}
mechanicalSolutionWrite(projectPath: string): string {
const plan = this.readPlan(projectPath);
const requirements = this.readRequirements(projectPath);
const architecture = this.readArchitecture(projectPath);
const sections: SolutionSection[] = [
{ title: "Problem Statement", content: this.extractProblemStatement(requirements, plan) },
{ title: "Approach", content: this.extractApproach(requirements, architecture) },
{ title: "Implementation Plan", content: this.extractImplementationPlan(plan) },
{ title: "Verification Criteria", content: this.extractVerificationCriteria(requirements) },
{ title: "Risk Assessment", content: this.extractRiskAssessment(architecture) },
];
return this.fillTemplate(sections);
}
readPlan(projectPath: string): string {
const planPath = path.join(projectPath, ".ciagent", "PLAN.md");
if (!fs.existsSync(planPath)) return "";
try {
return fs.readFileSync(planPath, "utf-8");
} catch {
return "";
}
}
readRequirements(projectPath: string): string {
const reqPath = path.join(projectPath, ".ciagent", "REQUIREMENTS.md");
if (!fs.existsSync(reqPath)) return "";
try {
return fs.readFileSync(reqPath, "utf-8");
} catch {
return "";
}
}
readArchitecture(projectPath: string): string {
const archPath = path.join(projectPath, ".ciagent", "ARCHITECTURE.md");
if (!fs.existsSync(archPath)) return "";
try {
return fs.readFileSync(archPath, "utf-8");
} catch {
return "";
}
}
fillTemplate(sections: SolutionSection[]): string {
const lines: string[] = ["# Solution Document", ""];
for (const section of sections) {
lines.push(`## ${section.title}`);
lines.push("");
if (section.content.trim()) {
lines.push(section.content.trim());
} else {
lines.push(`_No ${section.title.toLowerCase()} information available._`);
}
lines.push("");
}
return lines.join("\n");
}
extractSectionContent(content: string, headingPatterns: string[]): string {
if (!content.trim()) return "";
const sections = content.split(/(?=^#{1,3}\s)/m).filter((s) => s.trim());
for (const section of sections) {
for (const pattern of headingPatterns) {
if (section.toLowerCase().startsWith(pattern.toLowerCase())) {
const lines = section.split("\n");
return lines.slice(1).join("\n").trim();
}
}
}
return "";
}
extractProblemStatement(requirements: string, plan: string): string {
const content = this.extractSectionContent(requirements, ["# objective", "## objective", "# problem", "## problem", "# goal", "## goal"]);
if (content) return content;
const planContent = this.extractSectionContent(plan, ["# objective", "## objective", "# problem", "## problem", "# goal", "## goal"]);
if (planContent) return planContent;
const firstReq = requirements.match(/-?\s*(REQ-\d+[:\s]+)/g);
if (firstReq) {
return "Requirements to address: " + firstReq.map((m) => m.trim()).join(", ");
}
if (requirements || plan) {
const src = requirements || plan;
const firstParagraph = src.split("\n\n")[0]?.trim();
if (firstParagraph && !firstParagraph.startsWith("#")) return firstParagraph;
}
return "No problem statement could be extracted from project files.";
}
extractApproach(requirements: string, architecture: string): string {
const archContent = this.extractSectionContent(architecture, ["## approach", "### approach", "## design", "### design", "## architecture overview"]);
if (archContent) return archContent;
const reqContent = this.extractSectionContent(requirements, ["## approach", "### approach", "## design", "### design"]);
if (reqContent) return reqContent;
const compContent = this.extractSectionContent(architecture, ["## components", "### components"]);
if (compContent) return "Architecture-based approach: " + compContent.substring(0, 200);
if (architecture) {
const firstParagraph = architecture.split("\n\n")[0]?.trim();
if (firstParagraph && !firstParagraph.startsWith("#")) return firstParagraph;
}
return "No approach information could be extracted from project files.";
}
extractImplementationPlan(plan: string): string {
if (!plan.trim()) return "No implementation plan available — PLAN.md not found.";
const taskPattern = plan.match(/\|\s*T-\d+.*\|/g);
if (taskPattern) {
const lines: string[] = ["Tasks from plan:"];
for (const task of taskPattern) {
lines.push(` ${task.trim()}`);
}
return lines.join("\n");
}
const waveSections = plan.split(/(?=^#{1,3}\s+Wave\s)/mi).filter((s) => s.trim());
if (waveSections.length > 1) {
const lines: string[] = [];
for (const wave of waveSections.slice(1)) {
lines.push(wave.trim());
}
return lines.join("\n\n");
}
const sections = plan.split(/(?=^#{1,3}\s)/m).filter((s) => s.trim());
const lines: string[] = [];
for (const section of sections.slice(0, 5)) {
lines.push(section.trim());
}
return lines.join("\n\n");
}
extractVerificationCriteria(requirements: string): string {
const lines: string[] = [];
const reqIds = requirements.match(/REQ-\d+/g);
if (reqIds) {
const uniqueIds = [...new Set(reqIds)];
lines.push("Requirements coverage:");
for (const id of uniqueIds) {
lines.push(` - ${id}: verified`);
}
}
const verContent = this.extractSectionContent(requirements, ["## verification", "### verification", "## acceptance", "### acceptance", "## testing", "### testing"]);
if (verContent) {
lines.push(verContent);
}
if (lines.length === 0) lines.push("No verification criteria extracted — add requirements with REQ-IDs or a Verification section.");
return lines.join("\n\n");
}
extractRiskAssessment(architecture: string): string {
const lines: string[] = [];
const riskContent = this.extractSectionContent(architecture, ["## risk", "### risk", "## risks", "### risks", "## concern", "## mitigation"]);
if (riskContent) {
lines.push(riskContent);
}
const depContent = this.extractSectionContent(architecture, ["## dependencies", "### dependencies", "## external", "### external"]);
if (depContent) {
lines.push("Dependency risks:");
lines.push(depContent);
}
if (lines.length === 0) lines.push("No risks identified — review architecture for potential concerns.");
return lines.join("\n\n");
}
}
+94
View File
@@ -0,0 +1,94 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { TesterAgent } from "../agents/tester.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-tester-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "test",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
describe("TesterAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("detects test files when src directory exists", async () => {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.integration.test.ts"), "test('integration', () => {});\n");
const tester = new TesterAgent();
const result = await tester.execute(makeContext(dir));
expect(result.success).toBeDefined();
});
it("does not write test files", async () => {
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "app.test.ts"), "test('unit', () => {});\n");
const testFilesBefore = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
const tester = new TesterAgent();
await tester.execute(makeContext(dir));
const testFilesAfter = fs.readdirSync(srcDir).filter(f => f.endsWith(".test.ts"));
expect(testFilesAfter.length).toBe(testFilesBefore.length);
});
it("delegates to backend when available", async () => {
const mockBackend = new MockBackend();
const tester = new TesterAgent();
const result = await tester.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const tester = new TesterAgent();
expect(tester.name).toBe("tester");
});
it("has correct workflow", () => {
const tester = new TesterAgent();
expect(tester.workflow).toBe("test");
});
});
+100
View File
@@ -0,0 +1,100 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { VerifierAgent } from "../agents/verifier.js";
import { AgentContext } from "../agents/base.js";
import { IntelligenceBackend, BackendRequest, BackendResult } from "../backends/types.js";
import { emptyTokenUsage } from "../backends/types.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type = "llm" as const;
async isAvailable(): Promise<boolean> { return true; }
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed: ${request.task.slice(0, 50)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-verifier-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function makeContext(dir: string, backend?: IntelligenceBackend): AgentContext {
return {
project_path: dir,
phase: 1,
stage: "verify",
specification: "Build a REST API for task management",
config_path: path.join(dir, ".ciagent", "config.json"),
backend,
};
}
function setupBasicProject(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
fs.writeFileSync(path.join(ciDir, "config.json"), "{}");
const srcDir = path.join(dir, "src");
fs.mkdirSync(srcDir, { recursive: true });
fs.writeFileSync(path.join(srcDir, "index.ts"), 'export const VERSION = "0.7.0";\n');
}
describe("VerifierAgent", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
it("runs mechanical verification without backend", async () => {
setupBasicProject(dir);
const verifier = new VerifierAgent();
const result = await verifier.execute(makeContext(dir));
expect(result.output).toBeDefined();
});
it("is read-only — does not create new source files", async () => {
setupBasicProject(dir);
const srcDir = path.join(dir, "src");
const filesBefore = fs.readdirSync(srcDir);
const verifier = new VerifierAgent();
await verifier.execute(makeContext(dir));
const filesAfter = fs.readdirSync(srcDir);
expect(filesAfter.length).toBe(filesBefore.length);
});
it("delegates to backend when available", async () => {
setupBasicProject(dir);
const mockBackend = new MockBackend();
const verifier = new VerifierAgent();
const result = await verifier.execute(makeContext(dir, mockBackend));
expect(result.success).toBe(true);
expect(result.output).toContain("Mock backend executed");
});
it("has correct agent name", () => {
const verifier = new VerifierAgent();
expect(verifier.name).toBe("verifier");
});
it("has correct workflow", () => {
const verifier = new VerifierAgent();
expect(verifier.workflow).toBe("verify");
});
});
+196
View File
@@ -0,0 +1,196 @@
import { AnthropicBackend } from "../backends/anthropic.js";
import { ChatCompletionResponse } from "../backends/llm-base.js";
describe("AnthropicBackend", () => {
const originalFetch = globalThis.fetch;
let fetchCalls: Array<{ url: string; headers: Record<string, string>; body: string }>;
beforeEach(() => {
fetchCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.TEST_ANTHROPIC_KEY;
delete process.env.TEST_ANTHROPIC_KEY_EMPTY;
});
function mockFetch(response: Record<string, unknown>, status = 200): void {
globalThis.fetch = ((url: string, init: RequestInit) => {
fetchCalls.push({
url,
headers: (init.headers as Record<string, string>) || {},
body: init.body as string,
});
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}) as typeof fetch;
}
function makeAnthropicResponse(text: string, usage = { input_tokens: 10, output_tokens: 20 }): Record<string, unknown> {
return {
content: [{ type: "text", text }],
usage,
model: "claude-sonnet-4-20250514",
};
}
describe("isAvailable", () => {
it("returns true when API key is present", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test-key-123";
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(true);
});
it("returns false when API key is absent", async () => {
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "NONEXISTENT_ANTHROPIC_KEY_VAR_99999",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
});
describe("resolveModel", () => {
it("returns config.model when set", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-3-haiku-20240307",
model_profile: "speed",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-3-haiku-20240307");
});
it("defaults to claude-sonnet-4-20250514 when model not specified", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-sonnet-4-20250514");
});
});
describe("callModel request format", () => {
it("sends correct URL, x-api-key header, anthropic-version header, system field, max_tokens", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test-key-abc";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://api.anthropic.com",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "Do the thing",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls.length).toBe(1);
expect(fetchCalls[0].url).toBe("https://api.anthropic.com/v1/messages");
expect(fetchCalls[0].headers["x-api-key"]).toBe("sk-ant-test-key-abc");
expect(fetchCalls[0].headers["anthropic-version"]).toBe("2023-06-01");
expect(fetchCalls[0].headers["Content-Type"]).toBe("application/json");
expect(fetchCalls[0].headers["Authorization"]).toBeUndefined();
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("claude-sonnet-4-20250514");
expect(body.max_tokens).toBe(4096);
expect(typeof body.system).toBe("string");
expect(body.system.length).toBeGreaterThan(0);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBeGreaterThanOrEqual(1);
});
});
describe("custom base_url override", () => {
it("sends request to custom base_url", async () => {
process.env.TEST_ANTHROPIC_KEY = "sk-ant-test";
mockFetch(makeAnthropicResponse('{"success": true, "output": "done"}'));
const backend = new AnthropicBackend({
base_url: "https://custom-proxy.example.com/api",
api_key_env: "TEST_ANTHROPIC_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].url).toBe("https://custom-proxy.example.com/api/v1/messages");
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import { BackendType, AnthropicConfig, emptyBackendResult } from "./types.js";
import { ToolRegistry, ToolDefinition } from "./tool-registry.js";
export class AnthropicBackend extends LLMBaseBackend {
readonly name = "anthropic";
readonly type: BackendType = "llm";
private anthropicConfig: AnthropicConfig;
constructor(config: AnthropicConfig) {
super({ ...config, base_url: config.base_url || "https://api.anthropic.com" });
this.anthropicConfig = config;
}
async isAvailable(): Promise<boolean> {
const key = process.env[this.anthropicConfig.api_key_env];
return !!key && key.length > 0;
}
protected resolveModel(): string {
return this.anthropicConfig.model || "claude-sonnet-4-20250514";
}
protected async fetchAvailableModels(): Promise<string[]> {
return [];
}
protected async callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse> {
const apiKey = process.env[this.anthropicConfig.api_key_env];
if (!apiKey) {
throw new Error(`API key not found. Set ${this.anthropicConfig.api_key_env} environment variable.`);
}
const apiVersion = this.anthropicConfig.api_version || "2023-06-01";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": apiVersion,
};
let systemContent = "";
const filteredMessages: Array<{ role: string; content: Array<{ type: string; text: string }> }> = [];
for (const m of messages) {
if (m.role === "system") {
systemContent += (systemContent ? "\n" : "") + m.content;
} else if (m.role === "tool") {
filteredMessages.push({
role: "user",
content: [{ type: "text", text: m.content }],
});
} else if (m.role === "assistant") {
const contentBlocks: Array<{ type: string; text: string }> = [];
if (m.content) {
contentBlocks.push({ type: "text", text: m.content });
}
if (m.tool_calls) {
for (const tc of m.tool_calls) {
contentBlocks.push({
type: "tool_use",
text: JSON.stringify({ name: tc.function.name, input: JSON.parse(tc.function.arguments) }),
});
}
}
filteredMessages.push({
role: "assistant",
content: contentBlocks,
});
} else {
filteredMessages.push({
role: m.role,
content: [{ type: "text", text: m.content }],
});
}
}
const toolDefinitions = this.getActiveToolSchema(toolRegistry);
const anthropicTools = toolDefinitions.map((tool) => {
const fn = (tool as Record<string, unknown>).function as Record<string, unknown>;
return {
name: fn.name,
description: fn.description,
input_schema: fn.parameters,
};
});
const body: Record<string, unknown> = {
model,
max_tokens: 4096,
messages: filteredMessages,
};
if (systemContent) {
body.system = systemContent;
}
if (anthropicTools.length > 0) {
body.tools = anthropicTools;
}
const timeout = this.anthropicConfig.timeout_ms || 60000;
const baseUrl = this.config.base_url;
const url = `${baseUrl}/v1/messages`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (response.status === 401 || response.status === 403) {
throw new Error(`Authentication failed. Check ${this.anthropicConfig.api_key_env} environment variable.`);
}
if (response.status === 429) {
throw new Error("Rate limited by Anthropic API. Please retry after a delay.");
}
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`Anthropic API error (${response.status}): ${errorText}`);
}
const anthropicResponse = await response.json() as Record<string, unknown>;
return this.translateResponse(anthropicResponse);
}
private translateResponse(response: Record<string, unknown>): ChatCompletionResponse {
const content = (response.content as Array<Record<string, unknown>>) || [];
let textContent = "";
const toolCalls: Array<{ function: { name: string; arguments: string } }> = [];
for (const block of content) {
if (block.type === "text") {
textContent += (block.text as string) || "";
} else if (block.type === "tool_use") {
toolCalls.push({
function: {
name: (block.name as string) || "",
arguments: JSON.stringify(block.input || {}),
},
});
}
}
const usage = response.usage as { input_tokens: number; output_tokens: number } | undefined;
return {
choices: [
{
message: {
content: textContent,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
},
},
],
usage: {
prompt_tokens: usage?.input_tokens || 0,
completion_tokens: usage?.output_tokens || 0,
total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0),
},
};
}
}
+1
View File
@@ -96,6 +96,7 @@ describe("Backend Availability Detection", () => {
it("contains installation hints", () => {
const err = new BackendUnavailableError("auto");
expect(err.message).toContain("opencode");
expect(err.message).toContain("OpenAI");
expect(err.message).toContain("Ollama");
expect(err.message).toContain("OLLAMA_CLOUD_API_KEY");
});
+1
View File
@@ -54,6 +54,7 @@ describe("DEFAULT_BACKEND_CONFIG", () => {
});
it("has ollama-local and ollama-cloud llm backends", () => {
expect(DEFAULT_BACKEND_CONFIG.llm_backends["openai"]).toBeDefined();
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-local"]).toBeDefined();
expect(DEFAULT_BACKEND_CONFIG.llm_backends["ollama-cloud"]).toBeDefined();
});
+19 -2
View File
@@ -1,12 +1,16 @@
import { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
import { OpencodeBackend } from "./opencode.js";
import { OpenAIBackend } from "./openai.js";
import { OllamaLocalBackend } from "./ollama-local.js";
import { OllamaCloudBackend } from "./ollama-cloud.js";
import { AnthropicBackend } from "./anthropic.js";
const AUTO_DETECT_ORDER: Array<"opencode" | "ollama-local" | "ollama-cloud"> = [
const AUTO_DETECT_ORDER: Array<"opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic"> = [
"opencode",
"openai",
"ollama-local",
"ollama-cloud",
"anthropic",
];
export function createBackend(
@@ -16,10 +20,20 @@ export function createBackend(
switch (name) {
case "opencode":
return new OpencodeBackend(config.agent_backends.opencode);
case "openai":
if (!config.llm_backends["openai"]) {
throw new BackendUnavailableError("openai");
}
return new OpenAIBackend(config.llm_backends["openai"]);
case "ollama-local":
return new OllamaLocalBackend(config.llm_backends["ollama-local"]);
case "ollama-cloud":
return new OllamaCloudBackend(config.llm_backends["ollama-cloud"]);
case "anthropic":
if (!config.llm_backends["anthropic"]) {
throw new BackendUnavailableError("anthropic");
}
return new AnthropicBackend(config.llm_backends["anthropic"]);
default:
throw new BackendUnavailableError(name);
}
@@ -49,7 +63,10 @@ export async function resolveBackend(
}
export { IntelligenceBackend, BackendConfigSection, BackendUnavailableError } from "./types.js";
export { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
export { ToolRegistry, ToolDefinition, ToolCall, ToolResult } from "./tool-registry.js";
export { OpencodeBackend } from "./opencode.js";
export { OpenAIBackend } from "./openai.js";
export { OllamaLocalBackend } from "./ollama-local.js";
export { OllamaCloudBackend } from "./ollama-cloud.js";
export { OllamaCloudBackend } from "./ollama-cloud.js";
export { AnthropicBackend } from "./anthropic.js";
+361
View File
@@ -0,0 +1,361 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
LLMBackendConfig,
TokenUsage,
Artifact,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
}
export interface ChatCompletionResponse {
choices?: Array<{
message: {
content: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export abstract class LLMBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
this.projectPath = process.cwd();
}
abstract isAvailable(): Promise<boolean>;
async execute(request: BackendRequest): Promise<BackendResult> {
const startTime = Date.now();
try {
const personaContent = this.loadPersona(request.persona);
const workflowContent = this.loadWorkflow(request.workflow);
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: ChatMessage[] = [];
messages.push({
role: "system",
content: this.buildSystemPrompt(personaContent, workflowContent, request),
});
messages.push({
role: "user",
content: request.task,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
let round = 0;
const allArtifacts: Artifact[] = [];
const allDecisions: Decision[] = [];
const allEscalations: Escalation[] = [];
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
const assistantContent = response.choices?.[0]?.message?.content || "";
const toolCalls = response.choices?.[0]?.message?.tool_calls;
messages.push({
role: "assistant",
content: assistantContent,
tool_calls: toolCalls,
});
if (!toolCalls || toolCalls.length === 0) {
return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
total_tokens: totalInputTokens + totalOutputTokens,
estimated_cost_usd: 0,
});
}
for (const toolCall of toolCalls) {
const call: ToolCall = {
name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
const result = toolRegistry.execute(call);
messages.push({
role: "tool",
name: call.name,
content: result.content,
});
if (call.name === "writeFile" && !result.isError) {
allArtifacts.push({
path: String(call.arguments.path),
content: String(call.arguments.content),
operation: "create",
});
}
}
}
const finalContent = messages
.filter((m) => m.role === "assistant" && m.content)
.map((m) => m.content)
.join("\n");
return this.parseFinalResponse(
`Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`,
allArtifacts,
allDecisions,
allEscalations,
{ input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 }
);
} catch (err) {
return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: ChatMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<ChatCompletionResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse>;
protected abstract resolveModel(): string;
protected abstract fetchAvailableModels(): Promise<string[]>;
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
const parts = [persona];
if (workflow) {
parts.push("", "## Workflow Instructions", workflow);
}
parts.push(
"",
"## Execution Context",
`Autonomy level: ${request.autonomy}`,
`Project path: ${request.context.project_path}`,
`Phase: ${request.context.phase}`,
`Stage: ${request.context.stage}`,
"",
"## Output Format",
"When you have completed your task, output a JSON object with this structure:",
"```json",
'{',
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
);
return parts.join("\n");
}
protected loadPersona(persona: AgentName): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return `You are the CIAgent ${persona} agent. Execute the requested task thoroughly and autonomously.`;
}
protected loadWorkflow(workflow: string): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return "";
}
protected parseFinalResponse(
content: string,
artifacts: Artifact[],
decisions: Decision[],
escalations: Escalation[],
usage: TokenUsage
): BackendResult {
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || content,
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
usage,
};
} catch {}
}
return {
success: true,
output: content,
artifacts,
decisions,
escalations,
usage,
};
}
private parseArtifacts(raw: unknown[]): Artifact[] {
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as Artifact["operation"]) || "create",
}));
}
private parseDecisions(raw: unknown[]): Decision[] {
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as Decision["category"]) || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
}
private parseEscalations(raw: unknown[]): Escalation[] {
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as Escalation["type"]) || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
commit_hash: String(e.commit_hash || ""),
}));
}
}
+7 -356
View File
@@ -1,335 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
IntelligenceBackend,
BackendRequest,
BackendResult,
BackendType,
LLMBackendConfig,
TokenUsage,
Artifact,
emptyTokenUsage,
emptyBackendResult,
} from "./types.js";
import { AgentName, ModelProfile } from "../types/config.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { ToolRegistry, ToolCall, ToolResult, ToolDefinition } from "./tool-registry.js";
const MAX_TOOL_ROUNDS = 50;
const PERSONA_TOOL_MAP: Record<string, string> = {
read: "readFile",
write: "writeFile",
edit: "editFile",
bash: "runBash",
glob: "glob",
grep: "grep",
};
export abstract class OllamaBaseBackend implements IntelligenceBackend {
abstract readonly name: string;
readonly type: BackendType = "llm";
protected config: LLMBackendConfig;
protected projectPath: string;
protected filteredToolSchema: Array<Record<string, unknown>> | null = null;
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import { LLMBackendConfig } from "./types.js";
import { ModelProfile } from "../types/config.js";
import { ToolRegistry } from "./tool-registry.js";
export abstract class OllamaBaseBackend extends LLMBaseBackend {
constructor(config: LLMBackendConfig | undefined) {
this.config = config || { base_url: "http://localhost:11434", model_profile: "balanced" };
this.projectPath = process.cwd();
}
abstract isAvailable(): Promise<boolean>;
async execute(request: BackendRequest): Promise<BackendResult> {
const startTime = Date.now();
try {
const personaContent = this.loadPersona(request.persona);
const workflowContent = this.loadWorkflow(request.workflow);
const model = this.resolveModel();
const toolRegistry = new ToolRegistry(request.context.project_path);
const allowedTools = this.parsePersonaTools(personaContent);
const filteredDefinitions = this.filterToolDefinitions(toolRegistry.getDefinitions(), allowedTools);
this.filteredToolSchema = this.definitionsToOpenAISchema(filteredDefinitions);
const messages: OllamaMessage[] = [];
messages.push({
role: "system",
content: this.buildSystemPrompt(personaContent, workflowContent, request),
});
messages.push({
role: "user",
content: request.task,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
let round = 0;
const allArtifacts: Artifact[] = [];
const allDecisions: Decision[] = [];
const allEscalations: Escalation[] = [];
while (round < MAX_TOOL_ROUNDS) {
round++;
const response = await this.callModelWithTools(messages, model, filteredDefinitions);
totalInputTokens += response.usage?.prompt_tokens || 0;
totalOutputTokens += response.usage?.completion_tokens || 0;
const assistantContent = response.choices?.[0]?.message?.content || "";
const toolCalls = response.choices?.[0]?.message?.tool_calls;
messages.push({
role: "assistant",
content: assistantContent,
tool_calls: toolCalls,
});
if (!toolCalls || toolCalls.length === 0) {
return this.parseFinalResponse(assistantContent, allArtifacts, allDecisions, allEscalations, {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
total_tokens: totalInputTokens + totalOutputTokens,
estimated_cost_usd: 0,
});
}
for (const toolCall of toolCalls) {
const call: ToolCall = {
name: toolCall.function.name,
arguments: JSON.parse(toolCall.function.arguments),
};
const result = toolRegistry.execute(call);
messages.push({
role: "tool",
name: call.name,
content: result.content,
});
if (call.name === "writeFile" && !result.isError) {
allArtifacts.push({
path: String(call.arguments.path),
content: String(call.arguments.content),
operation: "create",
});
}
}
}
const finalContent = messages
.filter((m) => m.role === "assistant" && m.content)
.map((m) => m.content)
.join("\n");
return this.parseFinalResponse(
`Tool loop reached maximum rounds (${MAX_TOOL_ROUNDS}). Partial progress:\n${finalContent}`,
allArtifacts,
allDecisions,
allEscalations,
{ input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens, estimated_cost_usd: 0 }
);
} catch (err) {
return emptyBackendResult(`Backend execution failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
protected parsePersonaTools(personaContent: string): string[] | null {
const frontmatterMatch = personaContent.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) return null;
const frontmatter = frontmatterMatch[1];
const toolsMatch = frontmatter.match(/tools:\s*\n((?:\s+\w+:.+\n?)+)/);
if (!toolsMatch) {
const inlineMatch = frontmatter.match(/tools:\s*\[([^\]]+)\]/);
if (inlineMatch) {
return inlineMatch[1]
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.map((t) => PERSONA_TOOL_MAP[t] || t);
}
return null;
}
const toolsBlock = toolsMatch[1];
const toolNames: string[] = [];
const lineRegex = /^\s+(\w+):/gm;
let lineMatch;
while ((lineMatch = lineRegex.exec(toolsBlock)) !== null) {
const personaToolName = lineMatch[1];
toolNames.push(PERSONA_TOOL_MAP[personaToolName] || personaToolName);
}
return toolNames.length > 0 ? toolNames : null;
}
protected filterToolDefinitions(definitions: ToolDefinition[], allowedTools: string[] | null): ToolDefinition[] {
if (!allowedTools) return definitions;
const allowedSet = new Set(allowedTools);
return definitions.filter((def) => allowedSet.has(def.name));
}
protected async callModelWithTools(
messages: OllamaMessage[],
model: string,
toolDefinitions: ToolDefinition[]
): Promise<OllamaChatResponse> {
return this.callModel(messages, model, new ToolRegistry(this.projectPath));
}
protected definitionsToOpenAISchema(definitions: ToolDefinition[]): Array<Record<string, unknown>> {
return definitions.map((def) => ({
type: "function",
function: {
name: def.name,
description: def.description,
parameters: def.parameters,
},
}));
}
protected getActiveToolSchema(toolRegistry: ToolRegistry): Array<Record<string, unknown>> {
return this.filteredToolSchema || toolRegistry.getOpenAIToolSchema();
}
protected abstract callModel(
messages: OllamaMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<OllamaChatResponse>;
protected abstract resolveModel(): string;
protected buildSystemPrompt(persona: string, workflow: string, request: BackendRequest): string {
const parts = [persona];
if (workflow) {
parts.push("", "## Workflow Instructions", workflow);
}
parts.push(
"",
"## Execution Context",
`Autonomy level: ${request.autonomy}`,
`Project path: ${request.context.project_path}`,
`Phase: ${request.context.phase}`,
`Stage: ${request.context.stage}`,
"",
"## Output Format",
"When you have completed your task, output a JSON object with this structure:",
"```json",
'{',
' "success": true,',
' "output": "Summary of what was accomplished",',
' "artifacts": [{"path": "file/path", "content": "...", "operation": "create"}],',
' "decisions": [{"id": "D-NNN", "decision": "what", "rationale": "why", "confidence": 0.85, "category": "general", "alternatives_considered": [], "human_override": null, "timestamp": ""}],',
' "escalations": []',
'}',
"```"
);
return parts.join("\n");
}
protected loadPersona(persona: AgentName): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "agents", `ci-${persona}.md`),
path.join(process.cwd(), "opencode", "agents", `ci-${persona}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return `You are the CIAgent ${persona} agent. Execute the requested task thoroughly and autonomously.`;
}
protected loadWorkflow(workflow: string): string {
const candidates = [
path.join(os.homedir(), ".config", "opencode", "ci", "workflows", `${workflow}.md`),
path.join(process.cwd(), "opencode", "workflows", `${workflow}.md`),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return fs.readFileSync(candidate, "utf-8");
}
}
return "";
}
protected parseFinalResponse(
content: string,
artifacts: Artifact[],
decisions: Decision[],
escalations: Escalation[],
usage: TokenUsage
): BackendResult {
const jsonMatch = content.match(/\{[\s\S]*"success"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
success: parsed.success ?? true,
output: parsed.output || content,
artifacts: parsed.artifacts?.length ? this.parseArtifacts(parsed.artifacts) : artifacts,
decisions: parsed.decisions?.length ? this.parseDecisions(parsed.decisions) : decisions,
escalations: parsed.escalations?.length ? this.parseEscalations(parsed.escalations) : escalations,
usage,
};
} catch {}
}
return {
success: true,
output: content,
artifacts,
decisions,
escalations,
usage,
};
}
private parseArtifacts(raw: unknown[]): Artifact[] {
return raw.filter((a): a is Record<string, unknown> => !!a).map((a) => ({
path: String(a.path || ""),
content: String(a.content || ""),
operation: (a.operation as Artifact["operation"]) || "create",
}));
}
private parseDecisions(raw: unknown[]): Decision[] {
return raw.filter((d): d is Record<string, unknown> => !!d).map((d) => ({
id: String(d.id || "D-000"),
decision: String(d.decision || ""),
rationale: String(d.rationale || ""),
confidence: Number(d.confidence || 0.5),
category: (d.category as Decision["category"]) || "general",
alternatives_considered: Array.isArray(d.alternatives_considered)
? d.alternatives_considered.map((a: unknown) =>
typeof a === "string"
? { option: a, rejected_reason: "" }
: (a as { option: string; rejected_reason: string })
)
: [],
human_override: d.human_override ? String(d.human_override) : null,
timestamp: String(d.timestamp || new Date().toISOString()),
}));
}
private parseEscalations(raw: unknown[]): Escalation[] {
return raw.filter((e): e is Record<string, unknown> => !!e).map((e) => ({
id: String(e.id || "E-000"),
timestamp: String(e.timestamp || new Date().toISOString()),
type: (e.type as Escalation["type"]) || "specification_ambiguity",
phase: String(e.phase || ""),
description: String(e.description || ""),
context: String(e.context || ""),
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as Escalation["resolution"]) || "pending",
audit_file: String(e.audit_file || ""),
}));
super(config || { base_url: "http://localhost:11434", model_profile: "balanced" });
}
protected modelProfileToModel(profile: ModelProfile, availableModels: string[]): string {
@@ -359,29 +35,4 @@ export abstract class OllamaBaseBackend implements IntelligenceBackend {
}
}
interface OllamaMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
}
interface OllamaChatResponse {
choices?: Array<{
message: {
content: string;
tool_calls?: Array<{
function: { name: string; arguments: string };
}>;
};
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export { OllamaMessage, OllamaChatResponse };
export { ChatMessage as OllamaMessage, ChatCompletionResponse as OllamaChatResponse };
+279
View File
@@ -0,0 +1,279 @@
import { OpenAIBackend } from "../backends/openai.js";
import { ChatCompletionResponse } from "../backends/llm-base.js";
describe("OpenAIBackend", () => {
const originalFetch = globalThis.fetch;
let fetchCalls: Array<{ url: string; headers: Record<string, string>; body: string }>;
beforeEach(() => {
fetchCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.TEST_OPENAI_KEY;
delete process.env.TEST_OPENAI_KEY_EMPTY;
});
function mockFetch(response: ChatCompletionResponse, status = 200): void {
globalThis.fetch = ((url: string, init: RequestInit) => {
fetchCalls.push({
url,
headers: (init.headers as Record<string, string>) || {},
body: init.body as string,
});
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}) as typeof fetch;
}
describe("isAvailable", () => {
it("returns true when API key is present", async () => {
process.env.TEST_OPENAI_KEY = "sk-test-key-123";
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(true);
});
it("returns false when API key is absent", async () => {
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "NONEXISTENT_OPENAI_KEY_VAR_99999",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
it("returns false when API key is empty string", async () => {
process.env.TEST_OPENAI_KEY_EMPTY = "";
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY_EMPTY",
model: "gpt-4o",
model_profile: "quality",
});
expect(await backend.isAvailable()).toBe(false);
});
});
describe("resolveModel", () => {
it("returns config.model when set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o-mini",
model_profile: "speed",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o-mini");
});
it("defaults to gpt-4o when model not specified", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o");
});
});
describe("callModel request format", () => {
it("sends correct URL, Authorization header, and body structure", async () => {
process.env.TEST_OPENAI_KEY = "sk-test-key-abc";
const mockResponse: ChatCompletionResponse = {
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
};
mockFetch(mockResponse);
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "Do the thing",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls.length).toBe(1);
expect(fetchCalls[0].url).toBe("https://api.openai.com/v1/chat/completions");
expect(fetchCalls[0].headers["Authorization"]).toBe("Bearer sk-test-key-abc");
expect(fetchCalls[0].headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(fetchCalls[0].body);
expect(body.model).toBe("gpt-4o");
expect(body.stream).toBe(false);
expect(Array.isArray(body.messages)).toBe(true);
expect(body.messages.length).toBeGreaterThanOrEqual(2);
expect(body.messages[0].role).toBe("system");
expect(body.messages[1].role).toBe("user");
expect(body.messages[1].content).toBe("Do the thing");
expect(Array.isArray(body.tools)).toBe(true);
});
});
describe("custom base_url override", () => {
it("sends request to custom base_url", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://custom-proxy.example.com/api",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].url).toBe("https://custom-proxy.example.com/api/chat/completions");
});
});
describe("organization header", () => {
it("sends OpenAI-Organization header when config.organization is set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
organization: "org-abc123",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].headers["OpenAI-Organization"]).toBe("org-abc123");
});
it("does not send OpenAI-Organization header when config.organization is not set", async () => {
process.env.TEST_OPENAI_KEY = "sk-test";
mockFetch({
choices: [{ message: { content: '{"success": true, "output": "done"}' } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
});
const backend = new OpenAIBackend({
base_url: "https://api.openai.com/v1",
api_key_env: "TEST_OPENAI_KEY",
model: "gpt-4o",
model_profile: "quality",
});
const request = {
persona: "executor" as const,
workflow: "execute",
task: "test",
context: {
project_path: "/tmp",
phase: 1,
stage: "execute" as const,
specification: "",
config_path: "",
},
autonomy: "full" as const,
};
await backend.execute(request);
expect(fetchCalls[0].headers["OpenAI-Organization"]).toBeUndefined();
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import { LLMBaseBackend, ChatMessage, ChatCompletionResponse } from "./llm-base.js";
import { BackendType, OpenAIConfig, emptyBackendResult } from "./types.js";
import { ToolRegistry, ToolDefinition } from "./tool-registry.js";
export class OpenAIBackend extends LLMBaseBackend {
readonly name = "openai";
readonly type: BackendType = "llm";
private openaiConfig: OpenAIConfig;
constructor(config: OpenAIConfig) {
super({ ...config, base_url: config.base_url || "https://api.openai.com/v1" });
this.openaiConfig = config;
}
async isAvailable(): Promise<boolean> {
const key = process.env[this.openaiConfig.api_key_env];
return !!key && key.length > 0;
}
protected resolveModel(): string {
return this.openaiConfig.model || "gpt-4o";
}
protected async fetchAvailableModels(): Promise<string[]> {
return [];
}
protected async callModel(
messages: ChatMessage[],
model: string,
toolRegistry: ToolRegistry
): Promise<ChatCompletionResponse> {
const apiKey = process.env[this.openaiConfig.api_key_env];
if (!apiKey) {
throw new Error(`API key not found. Set ${this.openaiConfig.api_key_env} environment variable.`);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
};
if (this.openaiConfig.organization) {
headers["OpenAI-Organization"] = this.openaiConfig.organization;
}
const body: Record<string, unknown> = {
model,
messages: messages.map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
if (m.name) msg.name = m.name;
if (m.tool_calls) msg.tool_calls = m.tool_calls;
return msg;
}),
tools: this.getActiveToolSchema(toolRegistry),
stream: false,
};
const timeout = this.openaiConfig.timeout_ms || 60000;
const url = `${this.config.base_url}/chat/completions`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (response.status === 401 || response.status === 403) {
throw new Error(`Authentication failed. Check ${this.openaiConfig.api_key_env} environment variable.`);
}
if (response.status === 429) {
throw new Error("Rate limited by OpenAI API. Please retry after a delay.");
}
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`OpenAI API error (${response.status}): ${errorText}`);
}
return (await response.json()) as ChatCompletionResponse;
}
}
+12 -14
View File
@@ -117,8 +117,14 @@ export class OpencodeBackend implements IntelligenceBackend {
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
if (typeof parsed.success !== "boolean") {
return emptyBackendResult(`Backend returned non-boolean success field: ${typeof parsed.success}`);
}
if (parsed.success === false && !parsed.error && !parsed.output) {
return emptyBackendResult("Backend returned failure with no error or output");
}
return {
success: parsed.success ?? true,
success: parsed.success,
output: parsed.output || output,
artifacts: Array.isArray(parsed.artifacts)
? parsed.artifacts.filter((a: unknown) => !!a).map((a: Record<string, unknown>) => ({
@@ -156,7 +162,7 @@ export class OpencodeBackend implements IntelligenceBackend {
options: Array.isArray(e.options) ? e.options : [],
default_option_id: String(e.default_option_id || ""),
resolution: (e.resolution as "approved" | "rejected" | "modified" | "pending" | "timeout_auto_proceed") || "pending",
audit_file: String(e.audit_file || ""),
commit_hash: String(e.commit_hash || ""),
}))
: [],
usage: parsed.usage || {
@@ -164,19 +170,11 @@ export class OpencodeBackend implements IntelligenceBackend {
total_tokens: Math.ceil(output.length / 4),
},
};
} catch {}
} catch {
return emptyBackendResult(`Backend output contained JSON-like structure but failed to parse: ${output.slice(0, 200)}`);
}
}
return {
success: true,
output,
artifacts: [],
decisions: [],
escalations: [],
usage: {
...emptyTokenUsage(),
total_tokens: Math.ceil(output.length / 4),
},
};
return emptyBackendResult(`Backend output did not contain valid JSON result: ${output.slice(0, 200)}`);
}
}
+88 -5
View File
@@ -1,3 +1,4 @@
import { z } from "zod";
import { AgentName, AutonomyLevel, ModelProfile } from "../types/config.js";
import { AgentContext } from "../agents/base.js";
import { Decision } from "../types/decisions.js";
@@ -5,6 +6,55 @@ import { Escalation } from "../types/escalation.js";
export type BackendType = "llm" | "agent";
export const ArtifactSchema = z.object({
path: z.string().min(1, "Artifact path must not be empty"),
content: z.string(),
operation: z.enum(["create", "update", "delete"]),
});
export const TokenUsageSchema = z.object({
input_tokens: z.number().min(0),
output_tokens: z.number().min(0),
total_tokens: z.number().min(0),
estimated_cost_usd: z.number().min(0),
});
export const BackendResultSchema = z.object({
success: z.boolean(),
output: z.string(),
artifacts: z.array(ArtifactSchema),
decisions: z.array(z.unknown()),
escalations: z.array(z.unknown()),
usage: TokenUsageSchema,
error: z.string().optional(),
}).refine(
(r) => !(r.success === true && r.error && r.error.length > 0),
{ message: "Result cannot be both success and have an error message" }
);
export function validateBackendResult(raw: unknown): { result: BackendResult | null; errors: string[] } {
const parseResult = BackendResultSchema.safeParse(raw);
if (!parseResult.success) {
return {
result: null,
errors: parseResult.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`),
};
}
const data = parseResult.data;
if (!Array.isArray(data.artifacts)) {
return { result: null, errors: ["artifacts: expected array"] };
}
for (const a of data.artifacts) {
if (a.path.includes("..")) {
return { result: null, errors: [`artifacts: path "${a.path}" contains ".." (path traversal risk)`] };
}
if (a.path.startsWith("/")) {
return { result: null, errors: [`artifacts: path "${a.path}" is absolute (must be relative)`] };
}
}
return { result: data as BackendResult, errors: [] };
}
export interface BackendRequest {
persona: AgentName;
workflow: string;
@@ -65,20 +115,34 @@ export interface OllamaCloudConfig extends LLMBackendConfig {
timeout_ms?: number;
}
export interface OpenAIConfig extends LLMBackendConfig {
api_key_env: string;
model: string;
organization?: string;
}
export interface AnthropicConfig extends LLMBackendConfig {
api_key_env: string;
model: string;
api_version?: string;
}
export interface OpencodeBackendConfig {
enabled: boolean;
executable?: string;
}
export interface BackendConfigSection {
provider: "auto" | "opencode" | "ollama-local" | "ollama-cloud";
fallback?: "opencode" | "ollama-local" | "ollama-cloud";
provider: "auto" | "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic";
fallback?: "opencode" | "openai" | "ollama-local" | "ollama-cloud" | "anthropic";
agent_backends: {
opencode?: OpencodeBackendConfig;
};
llm_backends: {
"openai"?: OpenAIConfig;
"ollama-local"?: OllamaLocalConfig;
"ollama-cloud"?: OllamaCloudConfig;
"anthropic"?: AnthropicConfig;
};
}
@@ -88,6 +152,13 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
opencode: { enabled: true },
},
llm_backends: {
"openai": {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
},
"ollama-local": {
base_url: "http://localhost:11434",
model_profile: "balanced",
@@ -98,6 +169,14 @@ export const DEFAULT_BACKEND_CONFIG: BackendConfigSection = {
model_profile: "quality",
timeout_ms: 60000,
},
"anthropic": {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
api_version: "2023-06-01",
model_profile: "quality",
timeout_ms: 60000,
},
},
};
@@ -111,8 +190,10 @@ export class BackendUnavailableError extends Error {
`Intelligence backend "${backendName}" is not available${agentMsg}. ` +
`Configure one of:\n` +
` 1. Install opencode: npm i -g opencode\n` +
` 2. Run Ollama locally: ollama serve\n` +
` 3. Set OLLAMA_CLOUD_API_KEY for remote inference`
` 2. Set OPENAI_API_KEY for OpenAI API access\n` +
` 3. Set ANTHROPIC_API_KEY for Anthropic API access\n` +
` 4. Run Ollama locally: ollama serve\n` +
` 5. Set OLLAMA_CLOUD_API_KEY for remote inference`
);
this.name = "BackendUnavailableError";
this.backendName = backendName;
@@ -134,4 +215,6 @@ export function emptyBackendResult(error?: string): BackendResult {
usage: emptyTokenUsage(),
error,
};
}
}
export { ChatMessage, ChatCompletionResponse } from "./llm-base.js";
+129
View File
@@ -0,0 +1,129 @@
import { validateBackendResult, BackendResultSchema, emptyBackendResult } from "../backends/types.js";
describe("BackendResult Zod Validation", () => {
it("accepts valid BackendResult", () => {
const valid = {
success: true,
output: "Task completed",
artifacts: [{ path: "src/app.ts", content: "export const x = 1;", operation: "create" as const }],
decisions: [],
escalations: [],
usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150, estimated_cost_usd: 0.01 },
};
const result = validateBackendResult(valid);
expect(result.result).not.toBeNull();
expect(result.errors).toHaveLength(0);
expect(result.result?.success).toBe(true);
});
it("rejects BackendResult missing success field", () => {
const invalid = {
output: "Task completed",
artifacts: [],
decisions: [],
escalations: [],
usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150, estimated_cost_usd: 0.01 },
};
const result = validateBackendResult(invalid);
expect(result.result).toBeNull();
expect(result.errors.length).toBeGreaterThan(0);
});
it("rejects artifact with path traversal", () => {
const malicious = {
success: true,
output: "ok",
artifacts: [{ path: "../../etc/shadow", content: "pwned", operation: "create" as const }],
decisions: [],
escalations: [],
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
};
const result = validateBackendResult(malicious);
expect(result.result).toBeNull();
expect(result.errors.some((e) => e.includes("path traversal"))).toBe(true);
});
it("rejects artifact with absolute path", () => {
const malicious = {
success: true,
output: "ok",
artifacts: [{ path: "/etc/passwd", content: "", operation: "create" as const }],
decisions: [],
escalations: [],
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
};
const result = validateBackendResult(malicious);
expect(result.result).toBeNull();
expect(result.errors.some((e) => e.includes("absolute"))).toBe(true);
});
it("rejects success=true with error message", () => {
const contradictory = {
success: true,
output: "ok",
artifacts: [],
decisions: [],
escalations: [],
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
error: "Something went wrong",
};
const result = validateBackendResult(contradictory);
expect(result.result).toBeNull();
expect(result.errors.some((e) => e.includes("success") && e.includes("error"))).toBe(true);
});
it("rejects invalid artifact operation", () => {
const invalid = {
success: true,
output: "ok",
artifacts: [{ path: "a.ts", content: "", operation: "explode" }],
decisions: [],
escalations: [],
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
};
const result = validateBackendResult(invalid);
expect(result.result).toBeNull();
});
it("rejects negative token usage", () => {
const invalid = {
success: true,
output: "ok",
artifacts: [],
decisions: [],
escalations: [],
usage: { input_tokens: -10, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
};
const result = validateBackendResult(invalid);
expect(result.result).toBeNull();
});
it("accepts empty success=false with error", () => {
const fail = {
success: false,
output: "",
artifacts: [],
decisions: [],
escalations: [],
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0, estimated_cost_usd: 0 },
error: "Connection refused",
};
const result = validateBackendResult(fail);
expect(result.result).not.toBeNull();
expect(result.result?.success).toBe(false);
});
it("emptyBackendResult returns success=false", () => {
const result = emptyBackendResult("test error");
expect(result.success).toBe(false);
expect(result.error).toBe("test error");
});
});
+713 -16
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { CIAgentConfig, AutonomyLevel } from "../types/config.js";
import { IdeationCategory, Idea } from "../types/ideation.js";
import { initCIAgent, loadConfig, isCIAgentInitialized, saveConfig } from "../core/config.js";
import { Specification, parseSpecification } from "../types/specification.js";
import { saveSpecification } from "../core/clarify.js";
@@ -9,14 +10,19 @@ import { getAuditSummary, readAudit } from "../core/audit.js";
import { VerificationPipeline } from "../verification/index.js";
import { ClarifyPhase } from "../core/clarify.js";
import { loadSpecification as loadSpec } from "../core/clarify.js";
import { AgentContext } from "../agents/base.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { ErrorRecovery } from "../core/error-recovery.js";
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 { SessionManager } from "../core/session-manager.js";
import { AgentSession } from "../core/agent-session.js";
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
import { execSync } from "node:child_process";
export function createInitCommand(): Command {
@@ -75,6 +81,7 @@ export function createInitCommand(): Command {
enabled: options.parallel !== false,
max_concurrent_agents: 5,
min_plans_for_parallel: 2,
max_concurrent_projects: 3,
},
backend: {
provider: options.backend || "auto",
@@ -119,7 +126,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");
});
}
@@ -165,6 +172,9 @@ export function createRunCommand(): Command {
.option("--all", "Execute all remaining phases sequentially")
.option("--phase <number>", "Phase number", "1")
.option("--backend <provider>", "Override intelligence backend for this run")
.option("--ideate", "Insert ideation stage between research and plan")
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
.option("--session <id>", "Resume a specific session by ID")
.action(async (phase, options) => {
const projectPath = process.cwd();
@@ -174,6 +184,141 @@ export function createRunCommand(): Command {
}
const config = loadConfig(projectPath);
const ciFiles = new CIAgentFiles(projectPath);
const runForAllProjects = options.project === "all" || (Array.isArray(config.active_projects) && config.active_projects.length > 1 && !options.project);
if (runForAllProjects) {
console.log("─── Running pipeline across all active projects ───\n");
const orchestrator = new OrchestratorAgent(config);
const context: AgentContext = {
project_path: projectPath,
phase: parseInt(options.phase) || 1,
stage: phase || "all",
specification: "",
config_path: path.join(projectPath, ".ciagent", "config.json"),
backend: undefined,
};
const spec = loadSpec(projectPath);
if (spec) {
context.specification = spec.raw_content;
}
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (backend) {
context.backend = backend;
} else if (backendError) {
console.warn(` ⚠ No intelligence backend available: ${backendError}`);
console.warn(" Continuing with mechanical-only execution (limited functionality).");
}
const results = await orchestrator.runForAllProjects(context);
console.log("\n─── Multi-Project Pipeline Results ───\n");
let allSuccess = true;
for (const [slug, result] of Object.entries(results)) {
const icon = result.success ? "✓" : "✗";
console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`);
if (!result.success) allSuccess = false;
}
if (!allSuccess) {
process.exit(1);
}
return;
}
let projectSlug: string | undefined;
if (options.project && options.project !== "all") {
const slugs = options.project.split(",").map((s: string) => s.trim()).filter(Boolean);
projectSlug = slugs[0];
if (slugs.length > 1) {
console.log("─── Running pipeline across multiple projects ───\n");
const orchestrator = new OrchestratorAgent(config);
const context: AgentContext = {
project_path: projectPath,
phase: parseInt(options.phase) || 1,
stage: phase || "all",
specification: "",
config_path: path.join(projectPath, ".ciagent", "config.json"),
backend: undefined,
};
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (backend) {
context.backend = backend;
} else if (backendError) {
console.warn(` ⚠ No intelligence backend available: ${backendError}`);
}
const allResults: Record<string, AgentResult> = {};
for (const slug of slugs) {
console.log(`\nProcessing project: ${slug}`);
const projOrchestrator = new OrchestratorAgent(config);
const result = await projOrchestrator.runForProject(slug, context);
allResults[slug] = result;
}
console.log("\n─── Multi-Project Pipeline Results ───\n");
let allSuccess = true;
for (const [slug, result] of Object.entries(allResults)) {
const icon = result.success ? "✓" : "✗";
console.log(` ${icon} ${slug}: ${result.success ? "success" : result.error || "failed"}`);
if (!result.success) allSuccess = false;
}
if (!allSuccess) {
process.exit(1);
}
return;
}
}
if (options.ideate) {
console.log("─── CIAgent Ideate (pipeline mode) ───\n");
const currentSlug = projectSlug || ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default";
const { IdeationEngine } = await import("../core/ideation.js");
const engine = new IdeationEngine(projectPath, currentSlug);
const ideas = engine.runMechanical();
const ideaCategory: IdeationCategory[] = options.category
? options.category.split(",").map((c: string) => c.trim() as IdeationCategory)
: [];
if (ideaCategory.length > 0) {
const filtered = engine.runMechanical(ideaCategory);
ideas.push(...filtered);
}
const seen = new Set<string>();
const uniqueIdeas = ideas.filter((idea: Idea) => {
if (seen.has(idea.title)) return false;
seen.add(idea.title);
return true;
});
uniqueIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
console.log(`Found ${uniqueIdeas.length} improvement ${uniqueIdeas.length === 1 ? "idea" : "ideas"} from ideation stage.\n`);
if (uniqueIdeas.length > 0) {
const { accepted: savedIdeas, results } = engine.acceptIdeas(uniqueIdeas);
const savedCount = results.filter((r: { addedToRequirements: boolean; addedToRoadmap: boolean }) => r.addedToRequirements || r.addedToRoadmap).length;
if (savedCount > 0) {
console.log(`${savedCount} idea${savedCount === 1 ? "" : "s"} added to REQUIREMENTS.md and ROADMAP.md.`);
}
const commitMsg = `decision(P${options.phase || 1}): ideation results — ${uniqueIdeas.length} total, ${savedCount} accepted`;
console.log(`\nCommit suggestion: ${commitMsg}`);
}
}
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend && backendError) {
@@ -189,6 +334,7 @@ export function createRunCommand(): Command {
specification: "",
config_path: path.join(projectPath, ".ciagent", "config.json"),
backend,
project_slug: projectSlug || undefined,
};
const spec = loadSpec(projectPath);
@@ -196,7 +342,7 @@ export function createRunCommand(): Command {
context.specification = spec.raw_content;
}
console.log(`Running CIAgent pipeline...`);
console.log(`Running CIAgent pipeline${projectSlug ? ` for project: ${projectSlug}` : ""}...`);
if (options.all) {
console.log(" Mode: Full pipeline (all phases)");
} else {
@@ -283,9 +429,8 @@ export function createDebugCommand(): Command {
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend) {
console.error(`\n✗ "ciagent debug" requires an intelligence backend.`);
if (backendError) console.error(` ${backendError}`);
process.exit(1);
console.warn(`\n ⚠ No intelligence backend available: ${backendError || "none detected"}`);
console.warn(" Running mechanical debug (stack trace parsing + git bisect).");
}
console.log("Starting autonomous debug...");
@@ -380,9 +525,8 @@ export function createReviewCommand(): Command {
const { backend, error: backendError } = await resolveBackendForCommand(config, options.backend);
if (!backend) {
console.error(`\n✗ "ciagent review" requires an intelligence backend.`);
if (backendError) console.error(` ${backendError}`);
process.exit(1);
console.warn(`\n ⚠ No intelligence backend available: ${backendError || "none detected"}`);
console.warn(" Running mechanical code review (limited functionality).");
}
const phaseNum = parseInt(phase) || 1;
@@ -412,7 +556,8 @@ export function createReviewCommand(): Command {
export function createStatusCommand(): Command {
return new Command("status")
.description("Non-interactive project status")
.action(() => {
.option("--project <slug>", "Show status for specific project (comma-separated or 'all')")
.action((options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
@@ -422,14 +567,31 @@ export function createStatusCommand(): Command {
}
const config = loadConfig(projectPath);
const ciFiles = new CIAgentFiles(projectPath);
const artifacts = new ArtifactManager(projectPath);
console.log("─── CIAgent Project Status ───");
console.log(`\nAutonomy: ${config.autonomy.level}`);
const activeProjects: string[] = (config as any).active_projects?.length > 0
? (config as any).active_projects
: config.active_project ? [config.active_project] : [];
console.log("─── CIAgent Project Status ───\n");
if (activeProjects.length > 1 || (options.project && options.project === "all")) {
console.log(`Active Projects: ${activeProjects.join(", ")}`);
console.log(`Total: ${activeProjects.length} projects`);
console.log("");
}
console.log(`Autonomy: ${config.autonomy.level}`);
console.log(`Model Profile: ${config.model_profile}`);
console.log(`Backend: ${config.backend?.provider || "auto"}`);
console.log(`Parallelization: ${config.parallelization.enabled ? "enabled" : "disabled"}`);
const ideationConfig = (config as any).ideation;
if (ideationConfig) {
console.log(`Ideation: ${ideationConfig.enabled ? "enabled" : "disabled"} (categories: ${ideationConfig.categories?.join(", ") || "default"})`);
}
const state = artifacts.readState();
if (state) {
console.log(`\nCurrent Phase: ${state.current_phase}`);
@@ -642,6 +804,89 @@ 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();
const activeProjects: string[] = (config as any).active_projects?.length > 0
? (config as any).active_projects
: activeProject ? [activeProject] : [];
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 = activeProjects.includes(project.slug);
const marker = isActive ? " *" : "";
console.log(` ${project.slug}${project.name}${marker}`);
}
if (activeProjects.length > 0) {
console.log(`\n Active: ${activeProjects.join(", ")}`);
}
});
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;
(config as any).active_projects = [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 +958,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}`);
@@ -731,7 +1005,7 @@ function computeShipVersion(
projectPath: string,
phaseNum: number,
config: CIAgentConfig
): { tag: string; milestoneType: "nfr" | "feature" | "schema-breaking" } {
): { tag: string; milestoneType: "nfr" | "feature" | "major" } {
const tags = execSync("git tag -l", { cwd: projectPath, encoding: "utf-8" })
.split("\n")
.map((t) => t.trim())
@@ -758,7 +1032,7 @@ function computeShipVersion(
const milestoneType = inferMilestoneType(projectPath);
let tag: string;
if (milestoneType === "schema-breaking") {
if (milestoneType === "major") {
tag = `v${major}.${minor + phaseNum}.0`;
} else {
tag = `v${major}.${minor}.${phaseNum}`;
@@ -767,10 +1041,10 @@ function computeShipVersion(
return { tag, milestoneType };
}
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "schema-breaking" {
function inferMilestoneType(projectPath: string): "nfr" | "feature" | "major" {
try {
const log = execSync("git log --oneline -50", { cwd: projectPath, encoding: "utf-8" });
if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "schema-breaking";
if (log.match(/\brefactor\b|\brewrite\b|\bmigrate\b|\brestructure\b/i)) return "major";
if (log.match(/\bfeat\b/)) return "feature";
return "nfr";
} catch {
@@ -819,4 +1093,427 @@ 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;
}
export function createIdeateCommand(): Command {
return new Command("ideate")
.description("Discover improvement opportunities based on git-native signals and codebase analysis")
.option("-c, --category <categories>", "Focus on specific categories: security,quality,architecture,coverage,improvement,spec,chaos (comma-separated)")
.option("--affected", "Cascade impact analysis: given current changes, identify what else needs updating", false)
.option("--spec", "Analyze specification completeness and ambiguity", false)
.option("--external", "Include external signals: npm audit, dependency staleness", false)
.option("--cross-project", "Mine patterns from all projects in multi-project registry", false)
.option("--output <format>", "Output format: interactive, json, markdown", "interactive")
.option("--project <slug>", "Target project slug (comma-separated or 'all')")
.action(async (options) => {
const projectPath = process.cwd();
if (!isCIAgentInitialized(projectPath)) {
console.error("CIAgent project not initialized in this directory.");
console.error("Run 'ciagent init' to get started.");
process.exit(1);
}
const ciFiles = new CIAgentFiles(projectPath);
const config = loadConfig(projectPath);
const allProjects: string[] = options.project === "all"
? ciFiles.listProjects().map((p) => p.slug)
: options.project
? options.project.split(",").map((s: string) => s.trim()).filter(Boolean)
: [ciFiles.getProjectSlug() || ciFiles.getActiveProject() || "default"];
if (allProjects.length > 1) {
console.log(`\n─── CIAgent Ideation (multi-project: ${allProjects.join(", ")}) ───\n`);
} else {
console.log("\n─── CIAgent Ideation ───");
console.log(`Project: ${allProjects[0]}`);
}
const { IdeationEngine } = await import("../core/ideation.js");
const allIdeasByProject: Record<string, Idea[]> = {};
const allIdeas: Idea[] = [];
const seenTitles = new Set<string>();
for (const slug of allProjects) {
const engine = new IdeationEngine(projectPath, slug);
ciFiles.setProjectSlug(slug);
const categories: IdeationCategory[] = options.category
? options.category.split(",").map((c: string) => c.trim() as IdeationCategory)
: [];
console.log(`\nMining git history for patterns in project: ${slug}...`);
let projectIdeas: Idea[] = engine.runMechanical(categories.length > 0 ? categories : undefined);
if (options.affected) {
console.log(`Running cascade impact analysis (--affected) for ${slug}...`);
const affectedIdeas = engine.runAffected();
projectIdeas = [...projectIdeas, ...affectedIdeas];
}
if (options.spec) {
console.log(`Running specification analysis (--spec) for ${slug}...`);
const specIdeas = engine.runMechanical(["spec"]);
const newSpecIdeas = specIdeas.filter(
(idea: Idea) => !projectIdeas.some((existing: Idea) => existing.title === idea.title)
);
projectIdeas = [...projectIdeas, ...newSpecIdeas];
}
if (options.external) {
console.log(`Running external signal analysis (--external) for ${slug}...`);
const externalIdeas = engine.runExternal();
projectIdeas = [...projectIdeas, ...externalIdeas];
}
if (options.crossProject && ciFiles.isMultiProject()) {
console.log(`Running cross-project pattern mining (--cross-project) for ${slug}...`);
const crossProjectIdeas = engine.runCrossProject();
projectIdeas = [...projectIdeas, ...crossProjectIdeas];
}
const uniqueProjectIdeas = projectIdeas.filter((idea: Idea) => {
const dedupeKey = allProjects.length > 1 ? `${slug}:${idea.title}` : idea.title;
if (seenTitles.has(dedupeKey)) return false;
seenTitles.add(dedupeKey);
return true;
});
uniqueProjectIdeas.sort((a: Idea, b: Idea) => b.confidence - a.confidence);
allIdeasByProject[slug] = uniqueProjectIdeas;
allIdeas.push(...uniqueProjectIdeas);
}
allIdeas.sort((a, b) => b.confidence - a.confidence);
const currentSlug = allProjects.length === 1 ? allProjects[0] : "all";
const engine = new IdeationEngine(projectPath, allProjects.length === 1 ? allProjects[0] : undefined);
if (options.output === "json") {
const result = engine.formatIdeasJson(allIdeas);
result.summary.accepted = 0;
result.summary.skipped = allIdeas.length;
result.project = currentSlug;
console.log(JSON.stringify(result, null, 2));
return;
}
if (options.output === "markdown") {
console.log("\n## Ideation Results\n");
if (allIdeas.length === 0) {
console.log("No improvement ideas identified for this project.");
return;
}
if (allProjects.length > 1) {
console.log("| Project | Idea | Category | Confidence | Tier |");
console.log("|---------|-------|----------|------------|------|");
for (const slug of allProjects) {
const projectIdeas = allIdeasByProject[slug] || [];
for (const idea of projectIdeas) {
console.log(`| ${slug} | ${idea.title} | ${idea.category} | ${idea.confidence.toFixed(2)} | ${idea.tier} |`);
}
}
} else {
for (const idea of allIdeas) {
console.log(`### ${idea.title}`);
console.log(`- **Category**: ${idea.category}`);
console.log(`- **Source**: ${idea.source}`);
console.log(`- **Confidence**: ${idea.confidence.toFixed(2)}`);
console.log(`- **Tier**: ${idea.tier}`);
console.log(`- **Rationale**: ${idea.rationale}`);
if (idea.relatedReq) console.log(`- **Related Req**: ${idea.relatedReq}`);
console.log(`- **Actions**: ${idea.actions.join(", ")}`);
console.log("");
}
}
return;
}
console.log(`\nFound ${allIdeas.length} improvement ${allIdeas.length === 1 ? "idea" : "ideas"}${allProjects.length > 1 ? ` across ${allProjects.length} projects` : ""}\n`);
if (allIdeas.length === 0) {
console.log("No improvement ideas identified for this project.");
console.log("Try running with --spec, --external, or --cross-project for additional signals.");
return;
}
if (options.output !== "interactive") {
console.log("Use --output interactive for accept/skip/modify validation.");
return;
}
const accepted: Idea[] = [];
const skipped: Idea[] = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const askQuestion = (question: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(question, (answer: string) => {
resolve(answer.trim().toLowerCase());
});
});
};
for (let i = 0; i < allIdeas.length; i++) {
const idea = allIdeas[i];
const projectLabel = allProjects.length > 1 ? ` [${idea.tier === "cross-project" ? "cross-project" : allProjects[0]}]` : "";
console.log(`\n═══ Recommendation ${i + 1} of ${allIdeas.length} ═══\n`);
console.log(` Category: ${idea.category.toUpperCase()} | Confidence: ${idea.confidence.toFixed(2)} | Tier: ${idea.tier}${projectLabel}`);
console.log(` Title: ${idea.title}`);
console.log(` Rationale: ${idea.rationale}`);
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
console.log(` Source: ${idea.source}`);
console.log(` Actions: ${idea.actions.join(", ")}`);
console.log("");
console.log(" 1) Accept (add to next milestone)");
console.log(" 2) Skip");
console.log(" 3) Details (show full analysis)");
const answer = await askQuestion(" > ");
if (answer === "1" || answer === "a" || answer === "accept") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else if (answer === "3" || answer === "d" || answer === "details") {
console.log(`\n ─── Details for ${idea.id} ───`);
console.log(` ID: ${idea.id}`);
console.log(` Source: ${idea.source}`);
console.log(` Category: ${idea.category}`);
console.log(` Confidence: ${idea.confidence.toFixed(2)}`);
console.log(` Tier: ${idea.tier}`);
console.log(` Title: ${idea.title}`);
console.log(` Rationale: ${idea.rationale}`);
if (idea.relatedReq) console.log(` Related Req: ${idea.relatedReq}`);
console.log(` Actions: ${idea.actions.join(", ")}`);
console.log("");
const retryAnswer = await askQuestion(" Accept this idea? (y/n) > ");
if (retryAnswer === "y" || retryAnswer === "yes") {
accepted.push(idea);
console.log(` ✓ Accepted: ${idea.id}${idea.title}`);
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
} else {
skipped.push(idea);
console.log(` ✗ Skipped: ${idea.id}`);
}
}
rl.close();
console.log("\n─── Summary ───\n");
console.log(`Accepted: ${accepted.length} recommendation${accepted.length === 1 ? "" : "s"}`);
console.log(`Skipped: ${skipped.length} recommendation${skipped.length === 1 ? "" : "s"}`);
if (allProjects.length > 1) {
console.log(`Projects: ${allProjects.join(", ")}`);
}
if (accepted.length > 0) {
console.log("\nAccepted ideas:");
for (const idea of accepted) {
console.log(` ${idea.id}: ${idea.title} (${idea.category.toUpperCase()})`);
}
for (const slug of allProjects) {
const projectAccepted = accepted.filter((idea) => {
return allIdeasByProject[slug]?.some((pi) => pi.id === idea.id);
});
if (projectAccepted.length > 0) {
const projEngine = new IdeationEngine(projectPath, slug);
const { accepted: savedIdeas, results } = projEngine.acceptIdeas(projectAccepted);
const savedCount = results.filter((r) => r.addedToRequirements || r.addedToRoadmap).length;
if (savedCount > 0) {
console.log(`\n${savedCount} idea${savedCount === 1 ? "" : "s"} for project "${slug}" added to REQUIREMENTS.md and ROADMAP.md.`);
}
}
}
const kickoffAnswer = await askQuestion("\nWould you like to kick off the run workflow for these ideas? (y/n) > ");
if (kickoffAnswer === "y" || kickoffAnswer === "yes") {
console.log("\nStarting CIAgent pipeline...");
console.log("Run: ciagent run --ideate\n");
}
}
rl.close();
const byCategory: Record<string, number> = {};
for (const idea of allIdeas) {
byCategory[idea.category] = (byCategory[idea.category] || 0) + 1;
}
console.log("\n─── Category Breakdown ───\n");
for (const [cat, count] of Object.entries(byCategory)) {
console.log(` ${cat}: ${count}`);
}
});
}
export function createSessionsCommand(): Command {
return new Command("sessions")
.description("Manage CIAgent agent sessions")
.addCommand(
new Command("list")
.description("List all sessions")
.option("--project <slug>", "Filter by project slug")
.action(async (options) => {
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 sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const active = sessionManager.listSessions();
const allSessions = [...persisted];
for (const activeSession of active) {
if (!allSessions.find((s) => s.id === activeSession.id)) {
allSessions.push(activeSession);
}
}
if (options.project) {
const filtered = allSessions.filter((s) => s.project_slug === options.project);
displaySessions(filtered);
} else {
displaySessions(allSessions);
}
})
)
.addCommand(
new Command("status")
.description("Show status of a specific session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
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 sessionManager = new SessionManager(projectPath, config);
const persisted = sessionManager.loadPersistedSessions();
const sessionInfo = persisted.find((s) => s.id === sessionId);
if (!sessionInfo) {
const session = sessionManager.getSession(sessionId);
if (!session) {
console.error(`Session ${sessionId} not found.`);
process.exit(1);
}
displaySessionDetail(session.getSessionInfo());
return;
}
displaySessionDetail(sessionInfo);
})
)
.addCommand(
new Command("cancel")
.description("Cancel a running session")
.argument("<session-id>", "Session ID")
.action(async (sessionId) => {
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 sessionManager = new SessionManager(projectPath, config);
const success = sessionManager.cancelSession(sessionId);
if (success) {
console.log(`Session ${sessionId} cancelled.`);
} else {
console.error(`Failed to cancel session ${sessionId}. Session may not be running.`);
process.exit(1);
}
})
)
.addCommand(
new Command("cleanup")
.description("Clean up stale sessions")
.action(async () => {
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 sessionManager = new SessionManager(projectPath, config);
const cleaned = sessionManager.cleanupStaleSessions();
console.log(`Cleaned up ${cleaned} stale session(s).`);
})
);
}
function displaySessions(sessions: Array<import("../types/session.js").SessionInfo>): void {
if (sessions.length === 0) {
console.log("No sessions found.");
return;
}
console.log("\n─── CIAgent Sessions ───\n");
console.log("ID Project Phase Stage Status");
console.log("-------- ---------------- ----- ---------- ---------");
for (const s of sessions) {
const id = s.id.padEnd(8);
const slug = (s.project_slug || "default").padEnd(16);
const phase = String(s.phase).padEnd(5);
const stage = s.stage.padEnd(10);
const statusIcon = s.status === "running" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : s.status === "paused" ? "⏸" : "○";
console.log(`${id} ${slug} ${phase} ${stage} ${statusIcon} ${s.status}`);
}
console.log(`\n${sessions.length} session(s) total.`);
}
function displaySessionDetail(s: import("../types/session.js").SessionInfo): void {
console.log("\n─── Session Detail ───\n");
console.log(` ID: ${s.id}`);
console.log(` Project: ${s.project_slug || "default"}`);
console.log(` Phase: ${s.phase}`);
console.log(` Stage: ${s.stage}`);
console.log(` Status: ${s.status}`);
console.log(` Started: ${s.started_at}`);
console.log(` Last Updated: ${s.last_updated}`);
if (s.error) {
console.log(` Error: ${s.error}`);
}
}
+39 -1
View File
@@ -2,6 +2,8 @@
import { Command } from "commander";
import { VERSION } from "../version.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { isCIAgentInitialized } from "../core/config.js";
import {
createInitCommand,
createRunCommand,
@@ -14,14 +16,47 @@ import {
createClarifyCommand,
createRollbackCommand,
createShipCommand,
createProjectsCommand,
createIdeateCommand,
createSessionsCommand,
} from "./commands.js";
let activeEscalationProtocol: { dispose(): void } | null = null;
export function registerEscalationProtocol(protocol: { dispose(): void }): void {
activeEscalationProtocol = protocol;
}
function gracefulShutdown(signal: string): void {
if (activeEscalationProtocol) {
try {
activeEscalationProtocol.dispose();
} catch {}
activeEscalationProtocol = null;
}
process.exit(signal === "SIGINT" ? 130 : 143);
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
const program = new Command();
program
.name("ciagent")
.description("CIAgent — Continuous Intelligence: autonomous AI-driven software engineering harness")
.version(VERSION)
.option("--project <slug>", "Specify which project to operate on (comma-separated or 'all')")
.hook("preAction", () => {
const opts = program.opts();
if (opts.project && isCIAgentInitialized(process.cwd())) {
const ciFiles = new CIAgentFiles(process.cwd());
const projectSlug = opts.project;
if (projectSlug !== "all" && !projectSlug.includes(",")) {
ciFiles.setProjectSlug(projectSlug);
}
}
})
.addCommand(createInitCommand())
.addCommand(createRunCommand())
.addCommand(createQuickCommand())
@@ -32,6 +67,9 @@ program
.addCommand(createAuditCommand())
.addCommand(createClarifyCommand())
.addCommand(createRollbackCommand())
.addCommand(createShipCommand());
.addCommand(createShipCommand())
.addCommand(createProjectsCommand())
.addCommand(createIdeateCommand())
.addCommand(createSessionsCommand());
program.parse();
+284
View File
@@ -0,0 +1,284 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { execSync } from "node:child_process";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, SessionStatus, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { PipelineStage } from "../types/pipeline.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "../core/config.js";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { GitContext } from "../core/git-context.js";
import { CommitBuilder } from "../core/commit-builder.js";
import { writeFile, readFile, ensureDir, fileExists } from "../utils/file.js";
import { PipelineState, createInitialPipelineState } from "../types/pipeline.js";
export class AgentSession {
private id: string;
private projectSlug: string;
private projectPath: string;
private config: CIAgentConfig;
private sessionConfig: SessionConfig;
private status: SessionStatus;
private pipelineState: PipelineState | null;
private error: string | undefined;
private startedAt: string;
private lastUpdated: string;
private lockAcquired: boolean;
constructor(projectPath: string, projectSlug: string, config?: CIAgentConfig) {
this.id = crypto.randomUUID().slice(0, 8);
this.projectSlug = projectSlug;
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
this.sessionConfig = this.config.sessions || DEFAULT_SESSION_CONFIG;
this.status = "pending";
this.pipelineState = null;
this.error = undefined;
this.startedAt = new Date().toISOString();
this.lastUpdated = this.startedAt;
this.lockAcquired = false;
}
getId(): string {
return this.id;
}
getProjectSlug(): string {
return this.projectSlug;
}
getStatus(): SessionStatus {
return this.status;
}
getSessionInfo(): SessionInfo {
return {
id: this.id,
project_slug: this.projectSlug,
project_path: this.projectPath,
phase: this.pipelineState?.current_phase ?? 0,
stage: this.pipelineState?.current_stage ?? "specify",
status: this.status,
started_at: this.startedAt,
last_updated: this.lastUpdated,
error: this.error,
};
}
acquireLock(): boolean {
const lockPath = this.getLockPath();
ensureDir(path.dirname(lockPath));
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string; timestamp: string; projectSlug: string };
if (lockData.sessionId && lockData.sessionId !== this.id) {
const lockAge = Date.now() - new Date(lockData.timestamp).getTime();
if (lockAge < (this.sessionConfig.session_timeout_ms || 3600000)) {
return false;
}
}
}
writeFile(lockPath, JSON.stringify({
sessionId: this.id,
timestamp: new Date().toISOString(),
projectSlug: this.projectSlug,
}));
this.lockAcquired = true;
return true;
}
releaseLock(): void {
if (!this.lockAcquired) return;
const lockPath = this.getLockPath();
try {
if (fileExists(lockPath)) {
const lockData = JSON.parse(readFile(lockPath) || "{}") as { sessionId: string };
if (lockData.sessionId === this.id) {
fs.unlinkSync(lockPath);
}
}
} catch {}
this.lockAcquired = false;
}
async run(context: AgentContext): Promise<AgentResult> {
if (this.status === "running") {
return {
success: false,
output: `Session ${this.id} is already running`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Session already running",
};
}
const locked = this.acquireLock();
if (!locked) {
return {
success: false,
output: `Failed to acquire lock for session ${this.id}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: "Lock acquisition failed — another session is active for this project",
};
}
this.status = "running";
this.lastUpdated = new Date().toISOString();
this.pipelineState = createInitialPipelineState(this.projectPath);
const gitContext = new GitContext(this.projectPath, this.projectSlug || undefined);
const projectState = gitContext.reconstructState();
if (projectState.currentPhase > 0) {
this.pipelineState.current_phase = projectState.currentPhase;
this.pipelineState.current_stage = projectState.currentStage;
}
this.persistState();
let result: AgentResult;
try {
const { OrchestratorAgent } = await import("../agents/orchestrator.js");
const orchestrator = new OrchestratorAgent(this.config);
result = await orchestrator.runForProject(this.projectSlug, context);
this.status = result.success ? "completed" : "failed";
this.error = result.error;
} catch (err) {
this.status = "failed";
this.error = err instanceof Error ? err.message : String(err);
result = {
success: false,
output: `Session ${this.id} failed: ${this.error}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: this.error,
};
} finally {
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
}
if (this.config.git?.auto_commit && result.success) {
const ciFiles = new CIAgentFiles(this.projectPath, this.projectSlug || undefined);
try {
const sessionCommit = CommitBuilder.buildTaskCommit({
type: "chore",
phase: this.pipelineState?.current_phase ?? 0,
milestone: "session",
project: this.projectSlug || undefined,
plan: "session",
task: this.id,
subject: `session ${this.id} ${this.status}`,
status: "complete" as PipelineStage,
});
if (gitContext.isGitRepo()) {
execSync(`git add -A && git commit -m "${sessionCommit.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: this.projectPath,
stdio: "pipe",
});
}
} catch {}
}
return {
...result,
output: `[session:${this.id}] ${result.output}`,
};
}
cancel(): boolean {
if (this.status !== "running") return false;
this.status = "cancelled";
this.lastUpdated = new Date().toISOString();
this.releaseLock();
this.persistState();
return true;
}
pause(): boolean {
if (this.status !== "running") return false;
this.status = "paused";
this.lastUpdated = new Date().toISOString();
this.persistState();
return true;
}
resume(): boolean {
if (this.status !== "paused") return false;
this.status = "running";
this.lastUpdated = new Date().toISOString();
return true;
}
private getLockPath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, ".session-lock");
}
private getStatePath(): string {
const ciDir = path.join(this.projectPath, ".ciagent");
const slugDir = this.projectSlug ? path.join(ciDir, this.projectSlug) : ciDir;
return path.join(slugDir, `.session-${this.id}.json`);
}
persistState(): void {
const statePath = this.getStatePath();
const stateData = {
id: this.id,
projectSlug: this.projectSlug,
projectPath: this.projectPath,
status: this.status,
startedAt: this.startedAt,
lastUpdated: this.lastUpdated,
error: this.error,
pipelineState: this.pipelineState,
};
ensureDir(path.dirname(statePath));
writeFile(statePath, JSON.stringify(stateData, null, 2));
}
static loadState(projectPath: string, sessionId: string, projectSlug?: string): AgentSession | null {
const ciDir = path.join(projectPath, ".ciagent");
const slugDir = projectSlug ? path.join(ciDir, projectSlug) : ciDir;
const statePath = path.join(slugDir, `.session-${sessionId}.json`);
if (!fileExists(statePath)) return null;
try {
const data = JSON.parse(readFile(statePath) || "{}") as {
id: string;
projectSlug: string;
projectPath: string;
status: SessionStatus;
startedAt: string;
lastUpdated: string;
error?: string;
};
const session = new AgentSession(data.projectPath, data.projectSlug);
(session as any).id = data.id;
(session as any).status = data.status;
(session as any).startedAt = data.startedAt;
(session as any).lastUpdated = data.lastUpdated;
(session as any).error = data.error;
return session;
} catch {
return null;
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ describe("ArtifactManager", () => {
it("creates .ciagent directory structure", () => {
manager.ensureStructure();
expect(fs.existsSync(path.join(tempDir, ".ciagent"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ciagent", "audit"))).toBe(true);
expect(fs.existsSync(path.join(tempDir, ".ciagent", "phases"))).toBe(true);
});
it("is idempotent", () => {
-1
View File
@@ -55,7 +55,6 @@ export class ArtifactManager {
ensureStructure(): void {
ensureDir(this.ciDir);
ensureDir(path.join(this.ciDir, "phases"));
ensureDir(path.join(this.ciDir, "audit"));
}
isInitialized(): boolean {
+128 -64
View File
@@ -1,16 +1,23 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { logDecision, logEscalation, readAudit, getAuditSummary } from "../core/audit.js";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
describe("Audit", () => {
describe("Audit (git-native)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-audit-test-"));
fs.mkdirSync(path.join(tempDir, ".ciagent", "audit"), { recursive: true });
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
execSync("git init", { cwd: tempDir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { cwd: tempDir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: tempDir, stdio: "pipe" });
const placeholder = path.join(tempDir, "README.md");
fs.writeFileSync(placeholder, "# test\n");
execSync("git add -A && git commit -m 'initial'", { cwd: tempDir, stdio: "pipe" });
});
afterEach(() => {
@@ -40,12 +47,48 @@ describe("Audit", () => {
],
default_option_id: "A",
resolution: "pending",
audit_file: ".ciagent/audit/test.json",
commit_hash: "",
};
describe("logDecision", () => {
it("logs a decision to the audit trail", () => {
describe("deprecated log functions", () => {
it("logDecision is a no-op that warns", () => {
logDecision(tempDir, 1, sampleDecision);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(0);
});
it("logEscalation is a no-op that warns", () => {
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(0);
});
});
describe("readAudit from git log", () => {
it("returns empty array when no ci blocks exist", () => {
const audit = readAudit(tempDir);
expect(audit).toEqual([]);
});
it("reads decisions from ---ci--- blocks in git log", () => {
const ciBlock = `docs(P01): test commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
- id: D-001
decision: Use PostgreSQL
rationale: ACID compliance needed
confidence: 0.92
---/ci---`;
execSync(`git add -A && git commit -m "${ciBlock.replace(/"/g, '\\"')}" --allow-empty`, {
cwd: tempDir,
stdio: "pipe",
});
const audit = readAudit(tempDir);
expect(audit).toHaveLength(1);
expect(audit[0].phase).toBe(1);
@@ -53,47 +96,35 @@ describe("Audit", () => {
expect(audit[0].decisions[0].id).toBe("D-001");
});
it("appends multiple decisions to same phase file", () => {
logDecision(tempDir, 1, { ...sampleDecision, id: "D-001" });
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002" });
const audit = readAudit(tempDir);
expect(audit[0].decisions).toHaveLength(2);
});
it("separates decisions into different phase files", () => {
logDecision(tempDir, 1, sampleDecision);
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
const audit = readAudit(tempDir);
expect(audit).toHaveLength(2);
});
});
describe("logEscalation", () => {
it("logs an escalation to the audit trail", () => {
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit).toHaveLength(1);
expect(audit[0].escalations).toHaveLength(1);
});
it("can mix decisions and escalations in same phase", () => {
logDecision(tempDir, 1, sampleDecision);
logEscalation(tempDir, 1, sampleEscalation);
const audit = readAudit(tempDir);
expect(audit[0].decisions).toHaveLength(1);
expect(audit[0].escalations).toHaveLength(1);
});
});
describe("readAudit", () => {
it("returns empty array when no audit files exist", () => {
const audit = readAudit(tempDir);
expect(audit).toEqual([]);
});
it("filters by phase number", () => {
logDecision(tempDir, 1, sampleDecision);
logDecision(tempDir, 2, { ...sampleDecision, id: "D-002" });
const ciBlock1 = `docs(P01): phase 1 commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: complete
decisions:
- id: D-001
decision: Phase 1 decision
rationale: reason
confidence: 0.90
---/ci---`;
const ciBlock2 = `docs(P02): phase 2 commit
---ci---
project: ci
phase: 2
milestone: v0.8
status: in_progress
decisions:
- id: D-002
decision: Phase 2 decision
rationale: reason
confidence: 0.80
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock1.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
execSync(`git commit --allow-empty -m "${ciBlock2.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const phase1 = readAudit(tempDir, 1);
expect(phase1).toHaveLength(1);
@@ -101,29 +132,62 @@ describe("Audit", () => {
});
});
describe("getAuditSummary", () => {
it("returns summary with counts", () => {
logDecision(tempDir, 1, { ...sampleDecision, confidence: 0.95 });
logDecision(tempDir, 1, { ...sampleDecision, id: "D-002", confidence: 0.7 });
logDecision(tempDir, 2, { ...sampleDecision, id: "D-003", confidence: 0.4 });
logEscalation(tempDir, 1, sampleEscalation);
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(3);
expect(summary.total_escalations).toBe(1);
expect(summary.phases).toContain(1);
expect(summary.phases).toContain(2);
expect(summary.decisions_by_confidence.high).toBe(1);
expect(summary.decisions_by_confidence.medium).toBe(1);
expect(summary.decisions_by_confidence.low).toBe(1);
expect(summary.escalations_by_type.irreversible_action).toBe(1);
});
it("returns zeros for empty audit", () => {
describe("getAuditSummary from git log", () => {
it("returns zeros for empty git log with no ci blocks", () => {
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(0);
expect(summary.total_escalations).toBe(0);
expect(summary.phases).toHaveLength(0);
});
it("returns summary with decision counts and confidence breakdown", () => {
const ciBlock = `docs(P01): multi-decision commit
---ci---
project: ci
phase: 1
milestone: v0.8
status: complete
decisions:
- id: D-001
decision: High confidence decision
rationale: reason
confidence: 0.95
- id: D-002
decision: Medium confidence decision
rationale: reason
confidence: 0.70
- id: D-003
decision: Low confidence decision
rationale: reason
confidence: 0.40
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const summary = getAuditSummary(tempDir);
expect(summary.total_decisions).toBe(3);
expect(summary.decisions_by_confidence.high).toBe(1);
expect(summary.decisions_by_confidence.medium).toBe(1);
expect(summary.decisions_by_confidence.low).toBe(1);
expect(summary.phases).toContain(1);
});
it("reads escalations from ci blocks", () => {
const ciBlock = `escalation(P01): test escalation
---ci---
project: ci
phase: 1
milestone: v0.8
escalations:
- type: irreversible_action
description: Deploy to production
---/ci---`;
execSync(`git commit --allow-empty -m "${ciBlock.replace(/"/g, '\\"')}"`, { cwd: tempDir, stdio: "pipe" });
const summary = getAuditSummary(tempDir);
expect(summary.total_escalations).toBe(1);
expect(summary.escalations_by_type.irreversible_action).toBe(1);
});
});
});
+90 -63
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { execSync } from "node:child_process";
import { Decision } from "../types/decisions.js";
import { Escalation } from "../types/escalation.js";
import { confidenceToLevel } from "../types/decisions.js";
export interface AuditEntry {
phase: number;
@@ -9,41 +9,15 @@ export interface AuditEntry {
escalations: Escalation[];
}
const AUDIT_DIR = "audit";
function getAuditDir(projectPath: string): string {
return path.join(projectPath, ".ciagent", AUDIT_DIR);
}
function getAuditFilePath(projectPath: string, phase: number): string {
const date = new Date().toISOString().split("T")[0];
return path.join(getAuditDir(projectPath), `${date}-phase${phase}-decisions.json`);
}
function ensureAuditDir(projectPath: string): void {
const dir = getAuditDir(projectPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function logDecision(
projectPath: string,
phase: number,
decision: Decision
): void {
ensureAuditDir(projectPath);
const filePath = getAuditFilePath(projectPath, phase);
let entry: AuditEntry;
if (fs.existsSync(filePath)) {
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} else {
entry = { phase, decisions: [], escalations: [] };
}
entry.decisions.push(decision);
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
console.warn(
`[DEPRECATED] logDecision() is a no-op. Decisions are now committed to git via ---ci--- blocks. ` +
`Read audit data with readAudit() or getAuditSummary() which derive from git log.`
);
}
export function logEscalation(
@@ -51,41 +25,20 @@ export function logEscalation(
phase: number,
escalation: Escalation
): void {
ensureAuditDir(projectPath);
const filePath = getAuditFilePath(projectPath, phase);
let entry: AuditEntry;
if (fs.existsSync(filePath)) {
entry = JSON.parse(fs.readFileSync(filePath, "utf-8"));
} else {
entry = { phase, decisions: [], escalations: [] };
}
entry.escalations.push(escalation);
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8");
console.warn(
`[DEPRECATED] logEscalation() is a no-op. Escalations are now committed to git via ---ci--- blocks. ` +
`Read audit data with readAudit() or getAuditSummary() which derive from git log.`
);
}
export function readAudit(
projectPath: string,
phase?: number
): AuditEntry[] {
const auditDir = getAuditDir(projectPath);
if (!fs.existsSync(auditDir)) return [];
const files = fs
.readdirSync(auditDir)
.filter((f) => f.endsWith("-decisions.json"))
.sort();
const entries: AuditEntry[] = [];
for (const file of files) {
const content = fs.readFileSync(path.join(auditDir, file), "utf-8");
const entry: AuditEntry = JSON.parse(content);
if (phase === undefined || entry.phase === phase) {
entries.push(entry);
}
const entries = readAuditFromGit(projectPath);
if (phase !== undefined) {
return entries.filter((e) => e.phase === phase);
}
return entries;
}
@@ -96,7 +49,7 @@ export function getAuditSummary(projectPath: string): {
decisions_by_confidence: Record<string, number>;
escalations_by_type: Record<string, number>;
} {
const entries = readAudit(projectPath);
const entries = readAuditFromGit(projectPath);
let total_decisions = 0;
let total_escalations = 0;
const phases = new Set<number>();
@@ -113,8 +66,7 @@ export function getAuditSummary(projectPath: string): {
total_escalations += entry.escalations.length;
for (const d of entry.decisions) {
const level =
d.confidence > 0.85 ? "high" : d.confidence >= 0.6 ? "medium" : "low";
const level = confidenceToLevel(d.confidence);
decisions_by_confidence[level]++;
}
@@ -131,4 +83,79 @@ export function getAuditSummary(projectPath: string): {
decisions_by_confidence,
escalations_by_type,
};
}
function readAuditFromGit(projectPath: string): AuditEntry[] {
try {
const raw = execSync(
`git log --all --max-count=200 --format="%B%x01"`,
{ cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10000 }
);
const phaseMap = new Map<number, AuditEntry>();
const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) {
const ciBlockMatch = entry.match(/---ci---[\s\S]*?---\/ci---/);
if (!ciBlockMatch) continue;
const phaseMatch = ciBlockMatch[0].match(/phase:\s*(\d+)/);
if (!phaseMatch) continue;
const phase = parseInt(phaseMatch[1]);
if (!phaseMap.has(phase)) {
phaseMap.set(phase, { phase, decisions: [], escalations: [] });
}
const auditEntry = phaseMap.get(phase)!;
const decisionsMatch = ciBlockMatch[0].match(/decisions:\s*\n([\s\S]*?)(?=\n[a-z]|---\/ci---)/);
if (decisionsMatch) {
const idMatches = [...decisionsMatch[1].matchAll(/id:\s*(D-\d+)/g)];
const decMatches = [...decisionsMatch[1].matchAll(/decision:\s*(.+)/g)];
const ratMatches = [...decisionsMatch[1].matchAll(/rationale:\s*(.+)/g)];
const confMatches = [...decisionsMatch[1].matchAll(/confidence:\s*([0-9.]+)/g)];
const catMatches = [...decisionsMatch[1].matchAll(/category:\s*(.+)/g)];
for (let i = 0; i < idMatches.length; i++) {
auditEntry.decisions.push({
id: idMatches[i]?.[1] || "D-000",
decision: decMatches[i]?.[1]?.trim() || "",
rationale: ratMatches[i]?.[1]?.trim() || "",
confidence: parseFloat(confMatches[i]?.[1] || "0.5"),
category: (catMatches[i]?.[1]?.trim() as Decision["category"]) || "general",
timestamp: new Date().toISOString(),
alternatives_considered: [],
human_override: null,
});
}
}
const escMatch = ciBlockMatch[0].match(/escalations:\s*\n([\s\S]*?)(?=\n[a-z]|---\/ci---)/);
if (escMatch) {
const escEntries = escMatch[1].split(/-\s*/).filter(Boolean);
for (const escLine of escEntries) {
const typeMatch = escLine.match(/type:\s*(\S+)/);
const descMatch = escLine.match(/description:\s*(.+)/);
if (typeMatch) {
auditEntry.escalations.push({
id: "E-000",
timestamp: new Date().toISOString(),
type: typeMatch[1] as Escalation["type"],
phase: String(phase),
description: descMatch?.[1]?.trim() || "",
context: "",
options: [],
default_option_id: "",
resolution: "pending",
commit_hash: "",
});
}
}
}
}
return [...phaseMap.values()];
} catch {
return [];
}
}
+3 -3
View File
@@ -329,7 +329,7 @@ describe("CIAgentFiles", () => {
expect(ciFiles.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when phases include refactor/rewrite/migrate", () => {
it("returns major when phases include refactor/rewrite/migrate", () => {
const ciFiles = new CIAgentFiles(dir, "schema-proj");
ciFiles.ensureProjectDir();
fs.writeFileSync(path.join(dir, ".ciagent", "config.json"), JSON.stringify({
@@ -337,13 +337,13 @@ describe("CIAgentFiles", () => {
active_project: "schema-proj",
}));
const roadmap: RoadmapMd = {
overview: "schema-breaking",
overview: "major",
phases: [
{ number: 1, name: "refactor-core", description: "Refactor core", status: "in_progress", dependsOn: [], requirements: [], successCriteria: [] },
],
};
ciFiles.writeRoadmapMd(roadmap);
expect(ciFiles.getMilestoneType()).toBe("schema-breaking");
expect(ciFiles.getMilestoneType()).toBe("major");
});
});
+1 -1
View File
@@ -486,7 +486,7 @@ export class CIAgentFiles {
}
}
if (hasSchemaBreak) return "schema-breaking";
if (hasSchemaBreak) return "major";
if (hasFeature) return "feature";
return "nfr";
}
+1
View File
@@ -98,6 +98,7 @@ export class CommitBuilder {
lines.push(`milestone: ${ci.milestone}`);
if (ci.project) lines.push(`project: ${ci.project}`);
if (ci.session) lines.push(`session: ${ci.session}`);
if (ci.plan) lines.push(`plan: ${ci.plan}`);
if (ci.task) lines.push(`task: ${ci.task}`);
+3
View File
@@ -43,6 +43,9 @@ export function parseCIAgentBlock(yaml: string): CIAgentMetadata | null {
const projectMatch = yaml.match(/^project:\s*(.+)$/m);
if (projectMatch) result.project = projectMatch[1].trim();
const sessionMatch = yaml.match(/^session:\s*(.+)$/m);
if (sessionMatch) result.session = sessionMatch[1].trim();
result.decisions = parseDecisionsFromYaml(yaml);
result.escalations = parseEscalationsFromYaml(yaml);
result.requirements = parseRequirementsFromYaml(yaml);
+1 -1
View File
@@ -66,7 +66,7 @@ export class EscalationProtocol {
options: input.options,
default_option_id: input.default_option_id,
resolution: "pending",
audit_file: `.ciagent/audit/deprecated`,
commit_hash: "",
};
this.pendingEscalations.set(id, escalation);
+2 -2
View File
@@ -192,11 +192,11 @@ describe("GitBranch", () => {
expect(tag).toBe("v0.6.0");
});
it("computes next major for schema-breaking milestone", () => {
it("computes next major for major milestone", () => {
execSync(`git tag -a v0.5.1 -m "v0.5.1"`, { cwd: repoDir, stdio: "pipe" });
const gitBranch = new GitBranch(repoDir);
const tag = gitBranch.computeMilestoneTag("schema-breaking");
const tag = gitBranch.computeMilestoneTag("major");
expect(tag).toBe("v1.0.0");
});
+1 -1
View File
@@ -242,7 +242,7 @@ export class GitBranch {
}
}
if (milestoneType === "schema-breaking") {
if (milestoneType === "major") {
return `v${major + 1}.0.0`;
}
+2 -2
View File
@@ -307,7 +307,7 @@ status: execute
expect(ctx.getMilestoneType()).toBe("feature");
});
it("returns schema-breaking when refactor commits exist", () => {
it("returns major when refactor commits exist", () => {
commit(repoDir, `refactor(P01): rewrite core
---ci---
@@ -317,7 +317,7 @@ status: execute
---/ci---`);
const ctx = new GitContext(repoDir);
expect(ctx.getMilestoneType()).toBe("schema-breaking");
expect(ctx.getMilestoneType()).toBe("major");
});
});
});
+3 -21
View File
@@ -185,26 +185,8 @@ export class GitContext {
}
getDecisions(phase?: number): CommitDecision[] {
const grepArg = phase !== undefined ? `--grep="phase: ${phase}"` : '--grep="decisions:"';
const raw = this.git(`log --all ${grepArg} --format="%B%x01"`);
if (!raw) return [];
const decisions: CommitDecision[] = [];
const entries = raw.split("\x01").filter(Boolean);
for (const entry of entries) {
const commits = this.getRecentCommits(50);
for (const commit of commits) {
if (commit.ci?.decisions) {
if (phase === undefined || commit.ci.phase === phase) {
decisions.push(...commit.ci.decisions);
}
}
}
}
return decisions;
const commits = this.getRecentCommits(50);
return this.getDecisionsFromCommits(commits, phase);
}
getDecisionsFromCommits(commits: ParsedCIAgentCommit[], phase?: number): CommitDecision[] {
@@ -351,7 +333,7 @@ export class GitContext {
if (!commit.ci) continue;
hasAnyCiCommit = true;
if (commit.type === "feat") return "feature";
if (commit.type === "refactor" || commit.scope === "init") return "schema-breaking";
if (commit.type === "refactor" || commit.scope === "init") return "major";
}
if (!hasAnyCiCommit) return "nfr";
return "nfr";
+191
View File
@@ -0,0 +1,191 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { GiteaClient, generateReleaseNotes, GiteaReleaseConfig } from "../core/gitea.js";
const defaultConfig: GiteaReleaseConfig = {
baseUrl: "https://git.example.com",
token: "test-token-123",
owner: "testorg",
repo: "testrepo",
};
function makeReleaseResponse(overrides: Partial<{
id: number;
tag_name: string;
name: string;
body: string;
url: string;
html_url: string;
draft: boolean;
prerelease: boolean;
}> = {}): Record<string, unknown> {
return {
id: overrides.id ?? 1,
tag_name: overrides.tag_name ?? "v1.0.0",
name: overrides.name ?? "v1.0.0",
body: overrides.body ?? "Release notes",
url: overrides.url ?? "https://git.example.com/api/v1/repos/testorg/testrepo/releases/1",
html_url: overrides.html_url ?? "https://git.example.com/testorg/testrepo/releases/tag/v1.0.0",
draft: overrides.draft ?? false,
prerelease: overrides.prerelease ?? false,
};
}
describe("GiteaClient", () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("createRelease", () => {
it("creates a release via POST", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => makeReleaseResponse({ tag_name: "v1.0.0", name: "v1.0.0" }),
});
const release = await client.createRelease({
tag_name: "v1.0.0",
name: "v1.0.0",
body: "Initial release",
});
expect(release.tag_name).toBe("v1.0.0");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const call = (globalThis.fetch as jest.Mock).mock.calls[0];
expect(call[0]).toContain("/releases");
expect(call[1].method).toBe("POST");
expect(call[1].headers.Authorization).toBe("token test-token-123");
});
it("throws on non-ok response", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 409,
text: async () => "Conflict: tag already exists",
});
await expect(client.createRelease({
tag_name: "v1.0.0",
name: "v1.0.0",
body: "",
})).rejects.toThrow("Gitea API error: 409");
});
});
describe("listReleases", () => {
it("lists releases via GET", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
makeReleaseResponse({ id: 1, tag_name: "v1.0.0" }),
makeReleaseResponse({ id: 2, tag_name: "v1.1.0" }),
],
});
const releases = await client.listReleases();
expect(releases).toHaveLength(2);
expect(releases[0].tag_name).toBe("v1.0.0");
expect(releases[1].tag_name).toBe("v1.1.0");
});
it("throws on non-ok response", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(client.listReleases()).rejects.toThrow("Gitea API error: 500");
});
});
describe("getReleaseByTag", () => {
it("returns release when found", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => makeReleaseResponse({ tag_name: "v1.0.0" }),
});
const release = await client.getReleaseByTag("v1.0.0");
expect(release).not.toBeNull();
expect(release!.tag_name).toBe("v1.0.0");
});
it("returns null on 404", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 404,
});
const release = await client.getReleaseByTag("v0.0.0");
expect(release).toBeNull();
});
it("throws on other non-ok status", async () => {
const client = new GiteaClient(defaultConfig);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(client.getReleaseByTag("v1.0.0")).rejects.toThrow("Gitea API error: 500");
});
});
});
describe("generateReleaseNotes", () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-gitea-test-"));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it("parses git log into categorized sections", () => {
const gitDir = path.join(dir, "repo");
fs.mkdirSync(gitDir, { recursive: true });
const { execSync } = require("node:child_process");
execSync("git init", { cwd: gitDir, stdio: "pipe" });
execSync('git config user.email "test@test.com"', { cwd: gitDir, stdio: "pipe" });
execSync('git config user.name "Test"', { cwd: gitDir, stdio: "pipe" });
fs.writeFileSync(path.join(gitDir, "file1.txt"), "hello");
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
execSync('git commit -m "feat: add authentication"', { cwd: gitDir, stdio: "pipe" });
fs.writeFileSync(path.join(gitDir, "file2.txt"), "world");
execSync("git add -A", { cwd: gitDir, stdio: "pipe" });
execSync('git commit -m "fix: resolve login bug"', { cwd: gitDir, stdio: "pipe" });
execSync("git tag v1.0.0", { cwd: gitDir, stdio: "pipe" });
const notes = generateReleaseNotes(gitDir, null, "v1.0.0");
expect(notes).toContain("New Features");
expect(notes).toContain("add authentication");
expect(notes).toContain("Bug Fixes");
expect(notes).toContain("resolve login bug");
});
it("returns no-commits message when no commits found", () => {
const nonExistent = path.join(dir, "nonexistent");
const notes = generateReleaseNotes(nonExistent, null, "v0.0.0");
expect(notes).toContain("No commits found");
});
});
+170
View File
@@ -0,0 +1,170 @@
import { execSync } from "node:child_process";
export interface GiteaReleaseConfig {
baseUrl: string;
token: string;
owner: string;
repo: string;
}
export interface GiteaRelease {
id: number;
tag_name: string;
name: string;
body: string;
url: string;
html_url: string;
draft: boolean;
prerelease: boolean;
}
export class GiteaClient {
private config: GiteaReleaseConfig;
constructor(config: GiteaReleaseConfig) {
this.config = config;
}
async createRelease(params: {
tag_name: string;
name: string;
body: string;
draft?: boolean;
prerelease?: boolean;
}): Promise<GiteaRelease> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `token ${this.config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
tag_name: params.tag_name,
name: params.name,
body: params.body,
draft: params.draft ?? false,
prerelease: params.prerelease ?? false,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Gitea API error: ${response.status} ${text}`);
}
return response.json() as Promise<GiteaRelease>;
}
async listReleases(): Promise<GiteaRelease[]> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": `token ${this.config.token}`,
},
});
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`);
}
return response.json() as Promise<GiteaRelease[]>;
}
async getReleaseByTag(tag: string): Promise<GiteaRelease | null> {
const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases/tags/${tag}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Authorization": `token ${this.config.token}`,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`);
}
return response.json() as Promise<GiteaRelease>;
}
}
export function generateReleaseNotes(projectPath: string, fromTag: string | null, toTag: string): string {
let gitLogCmd: string;
if (fromTag) {
gitLogCmd = `git log ${fromTag}..${toTag} --oneline`;
} else {
gitLogCmd = `git log ${toTag} --oneline`;
}
let logOutput: string;
try {
logOutput = execSync(gitLogCmd, { cwd: projectPath, encoding: "utf-8" }).trim();
} catch {
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
}
if (!logOutput) {
return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`;
}
const lines = logOutput.split("\n").filter(Boolean);
const featCommits: string[] = [];
const fixCommits: string[] = [];
const testCommits: string[] = [];
const otherCommits: string[] = [];
for (const line of lines) {
const subject = line.replace(/^[a-f0-9]+\s+/, "");
if (/^feat/i.test(subject)) {
featCommits.push(subject);
} else if (/^fix/i.test(subject)) {
fixCommits.push(subject);
} else if (/^test/i.test(subject)) {
testCommits.push(subject);
} else {
otherCommits.push(subject);
}
}
const sections: string[] = [];
if (featCommits.length > 0) {
sections.push("### New Features\n");
for (const c of featCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (fixCommits.length > 0) {
sections.push("### Bug Fixes\n");
for (const c of fixCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (testCommits.length > 0) {
sections.push("### Tests\n");
for (const c of testCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
if (otherCommits.length > 0) {
sections.push("### Other Changes\n");
for (const c of otherCommits) {
sections.push(`- ${c}`);
}
sections.push("");
}
return `## What's Changed\n\n${sections.join("\n")}`;
}
+386
View File
@@ -0,0 +1,386 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { IdeationAgent } from "../agents/ideation-agent.js";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { Idea, IdeationAction, DEFAULT_IDEATION_CONFIG } from "../types/ideation.js";
describe("IdeationAgent", () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-ideation-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("agent name is ideation-agent", () => {
const agent = new IdeationAgent();
expect(agent.name).toBe("ideation-agent");
});
it("delegates to IdeationEngine for mechanical ideation", () => {
const agent = new IdeationAgent();
const ideas = agent.mechanicalIdeate(tempDir);
expect(Array.isArray(ideas)).toBe(true);
});
});
describe("IdeationEngine", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-engine-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("generates ideas from uncovered requirements", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"# Requirements\n\n## v1 Requirements\n\n### Core\n\n- **REQ-01**: First requirement\n- **REQ-02**: Second requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n| REQ-02 | Phase 1 | pending |\n"
);
fs.writeFileSync(
path.join(ciagentDir, "PROJECT.md"),
"# Test Project\n\n## What This Is\n\nA test project.\n\n## Requirements\n\n### Validated\n\n- REQ-01: First\n\n### Active\n\n- [ ] REQ-02: Second\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [ ] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: not_started\n**Requirements**: REQ-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest architecture.\n\n## Components\n\n### Core\n\n- **Description**: Core module\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple flow.\n\n## Build Order\n\n1. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["coverage"]);
const reqIdeas = ideas.filter((i) => i.source === "uncovered_requirement");
expect(reqIdeas.length).toBeGreaterThanOrEqual(1);
});
it("detects architecture drift when documented components are missing", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest.\n\n## Components\n\n### NonExistentModule\n\n- **Description**: A module that does not exist\n- **Boundaries**: None\n- **Depends on**: None\n\n## Data Flow\n\nFlow.\n\n## Build Order\n\n1. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["architecture"]);
const driftIdeas = ideas.filter((i) => i.source === "architecture_drift");
expect(driftIdeas.length).toBeGreaterThanOrEqual(1);
});
it("detects spec ambiguity or spec missing", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "PROJECT.md"),
"# Test\n\n## What This Is\n\nThe system should handle user input and could process data. It might also log events.\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n- [ ] The system should handle errors\n- [ ] Users could configure settings\n- [ ] It might send notifications\n\n## Constraints\n\n- Must work\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical(["spec"]);
const specIdeas = ideas.filter((i) => i.source === "spec_ambiguity" || i.source === "spec_missing" || i.source === "spec_contradiction");
expect(specIdeas.length).toBeGreaterThanOrEqual(1);
});
it("returns empty ideas when no project files exist", () => {
const engine = new IdeationEngine(tempDir);
const ideas = engine.runMechanical();
expect(Array.isArray(ideas)).toBe(true);
});
it("formats ideas as readable text", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(path.join(ciagentDir, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n");
fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n");
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n");
const engine = new IdeationEngine(tempDir);
const formatted = engine.formatIdeas(engine.runMechanical());
expect(typeof formatted).toBe("string");
});
it("formats ideas as JSON", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(path.join(ciagentDir, "PROJECT.md"), "# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n");
fs.writeFileSync(path.join(ciagentDir, "REQUIREMENTS.md"), "# Requirements\n\n## v1\n\n- REQ-01: Test\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| REQ-01 | Phase 1 | pending |\n");
fs.writeFileSync(path.join(ciagentDir, "ROADMAP.md"), "# Roadmap\n\n## Overview\n\nTest\n\n## Phases\n\n\n## Phase Details\n\n");
fs.writeFileSync(path.join(ciagentDir, "ARCHITECTURE.md"), "# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n");
const engine = new IdeationEngine(tempDir);
const result = engine.formatIdeasJson(engine.runMechanical());
expect(result).toHaveProperty("ideas");
expect(result).toHaveProperty("summary");
expect(result).toHaveProperty("project");
});
describe("acceptIdea", () => {
let acceptDir: string;
beforeEach(() => {
resetIdeaCounter();
acceptDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-accept-test-"));
const ciagentDir = path.join(acceptDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "REQUIREMENTS.md"),
"# Requirements\n\n## v1 Requirements\n\n### Core\n\n- **CORE-01**: Test core requirement\n\n## Traceability\n\n| Requirement | Phase | Status |\n|-------------|-------|--------|\n| CORE-01 | Phase 1 | pending |\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ROADMAP.md"),
"# Roadmap\n\n## Overview\n\nTest roadmap.\n\n## Phases\n\n- [x] **Phase 1: Init** - Starting\n\n## Phase Details\n\n### Phase 1: Init\n\n**Goal.**: Start\n**Status**: complete\n**Requirements**: CORE-01\n**Depends on**: Nothing\n**Success Criteria**:\n1. Project initialized\n"
);
fs.writeFileSync(
path.join(ciagentDir, "PROJECT.md"),
"# Test\n\n## What This Is\n\nTest\n\n## Requirements\n\n### Validated\n\n\n### Active\n\n\n## Constraints\n\n- None\n\n## Key Decisions\n\n| Decision | Rationale | Outcome |\n|----------|-----------|--------|\n"
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest\n\n## Components\n\n## Data Flow\n\nTest\n\n## Build Order\n\n1. Test\n"
);
});
afterEach(() => {
fs.rmSync(acceptDir, { recursive: true, force: true });
});
it("accepts an idea and updates REQUIREMENTS.md and ROADMAP.md", () => {
const engine = new IdeationEngine(acceptDir);
const idea: Idea = {
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting to cloud backends",
rationale: "No rate limiting REQ exists for cloud backends.",
confidence: 0.92,
actions: ["add_requirement", "update_roadmap"],
tier: "mechanical",
};
const result = engine.acceptIdea(idea);
expect(result.addedToRequirements).toBe(true);
expect(result.addedToRoadmap).toBe(true);
expect(result.reqId).toBe("IDEATE-01");
});
it("acceptIdeas accepts multiple ideas", () => {
const engine = new IdeationEngine(acceptDir);
const ideas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Add rate limiting",
rationale: "No rate limiting.",
confidence: 0.9,
actions: ["add_requirement"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "architecture_drift",
category: "architecture",
title: "Fix architecture drift",
rationale: "Component documented but missing.",
confidence: 0.8,
actions: ["update_architecture"],
tier: "mechanical",
},
];
const { accepted, results } = engine.acceptIdeas(ideas);
expect(accepted.length).toBe(2);
expect(results.length).toBe(2);
expect(results.every((r) => r.addedToRequirements || r.addedToRoadmap)).toBe(true);
});
});
describe("Phase 2: Backend-enriched and chaos", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p2-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runBackendEnriched prioritizes mechanical findings", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Missing test",
rationale: "No test file",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
{
id: "IDEATE-02",
source: "escalation_pattern",
category: "security",
title: "Security issue",
rationale: "Repeated escalation",
confidence: 0.8,
actions: ["add_security_pattern"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
expect(enriched.length).toBeGreaterThanOrEqual(2);
const prioritizedIdeas = enriched.filter((i) => i.source === "uncovered_requirement" || i.source === "escalation_pattern");
expect(prioritizedIdeas.length).toBeGreaterThanOrEqual(2);
for (const idea of prioritizedIdeas) {
expect(idea.tier).toBe("backend-enriched");
}
});
it("runBackendEnriched adds novel suggestions for missing categories", () => {
const engine = new IdeationEngine(tempDir);
const mechanicalIdeas: Idea[] = [
{
id: "IDEATE-01",
source: "uncovered_requirement",
category: "coverage",
title: "Cover this",
rationale: "Missing",
confidence: 0.7,
actions: ["add_test"],
tier: "mechanical",
},
];
const enriched = engine.runBackendEnriched(mechanicalIdeas);
const novelIdeas = enriched.filter((i) => i.source === "improvement_pattern");
expect(novelIdeas.length).toBeGreaterThanOrEqual(1);
});
it("generateChaosScenarios uses default scenarios when enabled", () => {
const engine = new IdeationEngine(tempDir);
const chaosIdeas = engine.generateChaosScenarios();
expect(chaosIdeas.length).toBe(3);
expect(chaosIdeas.every((i) => i.source === "chaos_scenario")).toBe(true);
expect(chaosIdeas.every((i) => i.category === "chaos")).toBe(true);
expect(chaosIdeas.every((i) => i.tier === "backend-enriched")).toBe(true);
expect(chaosIdeas.every((i) => i.confidence >= 0.5)).toBe(true);
const titles = chaosIdeas.map((i) => i.title);
expect(titles.some((t) => t.includes("backend"))).toBe(true);
expect(titles.some((t) => t.includes("requirement"))).toBe(true);
expect(titles.some((t) => t.includes("coverage"))).toBe(true);
});
});
describe("Phase 3: External signals and cascade impact", () => {
let tempDir: string;
beforeEach(() => {
resetIdeaCounter();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-p3-test-"));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("runAffected detects cascade from architecture.md", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
fs.writeFileSync(
path.join(ciagentDir, "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\n\nTest.\n\n## Components\n\n### CLI\n\n- **Description**: Command line interface\n- **Boundaries**: User-facing only\n- **Depends on**: Core\n\n### Core\n\n- **Description**: Core engine\n- **Boundaries**: Internal only\n- **Depends on**: None\n\n## Data Flow\n\nSimple.\n\n## Build Order\n\n1. CLI\n2. Core\n"
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runAffected();
expect(Array.isArray(ideas)).toBe(true);
});
it("runExternal handles missing npm gracefully", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [], active_project: "default" })
);
const engine = new IdeationEngine(tempDir);
const ideas = engine.runExternal();
expect(Array.isArray(ideas)).toBe(true);
});
it("runCrossProject returns empty when only one project", () => {
const ciagentDir = path.join(tempDir, ".ciagent");
fs.mkdirSync(ciagentDir, { recursive: true });
fs.writeFileSync(
path.join(ciagentDir, "config.json"),
JSON.stringify({ projects: [{ slug: "default", name: "Default Project", default: true }], active_project: "default" })
);
const engine = new IdeationEngine(tempDir, "default");
const ideas = engine.runCrossProject();
expect(ideas).toEqual([]);
});
});
});
+1012
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -8,5 +8,10 @@ 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 { AgentSession } from "./agent-session.js";
export { SessionManager } from "./session-manager.js";
export { PersonaLoader } from "./persona-loader.js";
export { TaskDecomposer } from "./task-decomposer.js";
export type { CIAgentConfig } from "../types/config.js";
export { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
+584
View File
@@ -0,0 +1,584 @@
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 { CommitBuilder } from "../core/commit-builder.js";
import { IdeationEngine, resetIdeaCounter } from "../core/ideation.js";
import { extractCIAgentBlock, parseCIAgentBlock } from "../core/commit-parser.js";
import { DEFAULT_CIAGENT_CONFIG, ParallelizationConfig } from "../types/config.js";
import { AgentContext } from "../agents/base.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 });
}
function initMultiProjectWithFiles(dir: string, projectList: Array<{ slug: string; name: string }>): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
const projects = projectList.map((p, i) => ({
slug: p.slug,
name: p.name,
default: i === 0,
}));
const config = {
...DEFAULT_CIAGENT_CONFIG,
projects,
active_project: projectList[0].slug,
active_projects: projectList.map((p) => p.slug),
};
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
for (const project of projectList) {
const projectDir = path.join(ciDir, project.slug);
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
`# ${project.name}`,
"",
"## What This Is",
"",
`A ${project.name} project for testing`,
"",
"## Requirements",
"",
"### Active",
"",
"- Build the project",
"",
"### Validated",
"",
"### Out of Scope",
"",
"## Context",
"",
"Testing",
"",
"## Constraints",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
`| REQ-ID | Requirement | Priority | Phase | Status |`,
`|--------|-------------|----------|-------|--------|`,
`| ${project.slug.toUpperCase()}-01 | Core feature | P0 | 1 | pending |`,
"",
"## Traceability",
"",
`| Requirement | Phase | Status |`,
`|-------------|-------|--------|`,
`| ${project.slug.toUpperCase()}-01 | 1 | pending |`,
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
`${project.name} roadmap`,
"",
"## Phases",
"",
"- [ ] **Phase 1: Core** - Build features",
"",
"## Phase Details",
"",
"### Phase 1: Core",
"**Goal.**: Build features",
"**Depends on**: Nothing",
"**Requirements**: CORE-01",
"**Success Criteria**:",
"1. Features work",
"**Status**: not_started",
"",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
`${project.name} testing architecture`,
"",
"## Components",
"",
`### ${project.slug}-api`,
"- **Description**: API",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API",
"",
"## Build Order",
"",
"1. API",
"",
].join("\n"));
}
}
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");
});
});
describe("AgentContext project_slug field", () => {
it("accepts optional project_slug", () => {
const context: AgentContext = {
project_path: "/tmp/test",
phase: 1,
stage: "execute",
specification: "test spec",
config_path: "/tmp/test/.ciagent/config.json",
project_slug: "my-project",
};
expect(context.project_slug).toBe("my-project");
});
it("project_slug is optional", () => {
const context: AgentContext = {
project_path: "/tmp/test",
phase: 1,
stage: "execute",
specification: "test spec",
config_path: "/tmp/test/.ciagent/config.json",
};
expect(context.project_slug).toBeUndefined();
});
});
});
describe("MULTI-03: Parallel project execution", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
});
afterEach(() => {
cleanup(dir);
});
describe("OrchestratorAgent module has multi-project methods", () => {
it("exports OrchestratorAgent class with runForProject and runForAllProjects", () => {
expect(typeof DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe("number");
});
});
describe("active_projects config field", () => {
it("stores active_projects array in config", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" },
]);
const config = loadConfig(dir);
expect(config.active_projects).toEqual(["task-api", "auth-svc"]);
});
it("defaults to empty array when not configured", () => {
initCIAgent(dir);
const config = loadConfig(dir);
expect(config.active_projects).toEqual([]);
});
it("max_concurrent_projects defaults to 3", () => {
expect(DEFAULT_CIAGENT_CONFIG.parallelization.max_concurrent_projects).toBe(3);
});
it("max_concurrent_projects can be configured", () => {
initCIAgent(dir, {
parallelization: {
...DEFAULT_CIAGENT_CONFIG.parallelization,
max_concurrent_projects: 5,
},
});
const config = loadConfig(dir);
expect(config.parallelization.max_concurrent_projects).toBe(5);
});
});
});
describe("MULTI-05: ideate --project all", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
resetIdeaCounter();
});
afterEach(() => {
cleanup(dir);
});
describe("IdeationEngine with project slug for multi-project", () => {
it("runs mechanical ideation for different project slugs", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
]);
resetIdeaCounter();
const engine = new IdeationEngine(dir, "task-api");
const ideas = engine.runMechanical();
expect(Array.isArray(ideas)).toBe(true);
});
it("runs ideation across multiple projects and collects results", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" },
]);
const ciFiles = new CIAgentFiles(dir);
const projects = ciFiles.listProjects();
const allProjectIdeas: Record<string, number> = {};
for (const project of projects) {
resetIdeaCounter();
const engine = new IdeationEngine(dir, project.slug);
const ideas = engine.runMechanical();
allProjectIdeas[project.slug] = ideas.length;
}
expect(Object.keys(allProjectIdeas)).toHaveLength(2);
});
it("deduplicates ideas across projects with project-prefixed keys", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" },
]);
const ciFiles = new CIAgentFiles(dir);
const projects = ciFiles.listProjects();
const allTitles: string[] = [];
const seenKeys = new Set<string>();
for (const project of projects) {
resetIdeaCounter();
const engine = new IdeationEngine(dir, project.slug);
const ideas = engine.runMechanical();
for (const idea of ideas) {
const dedupeKey = `${project.slug}:${idea.title}`;
if (!seenKeys.has(dedupeKey)) {
seenKeys.add(dedupeKey);
allTitles.push(idea.title);
}
}
}
expect(seenKeys.size).toBeGreaterThan(0);
});
it("formats JSON output with project field for each project", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
]);
resetIdeaCounter();
const engine = new IdeationEngine(dir, "task-api");
const ideas = engine.runMechanical();
const result = engine.formatIdeasJson(ideas);
expect(result.project).toBe("task-api");
});
it("runs cross-project analysis on multi-project setup", () => {
initMultiProjectWithFiles(dir, [
{ slug: "task-api", name: "Task API" },
{ slug: "auth-svc", name: "Auth Service" },
]);
resetIdeaCounter();
const engine = new IdeationEngine(dir, "task-api");
const crossIdeas = engine.runCrossProject();
expect(Array.isArray(crossIdeas)).toBe(true);
});
});
});
describe("MULTI-07: ---ci--- project field in commits", () => {
describe("CIAgentMetadata with project", () => {
it("includes project field in ci block when set", () => {
const ci = {
phase: 5,
milestone: "v0.10",
project: "ci",
status: "execute" as const,
};
const block = CommitBuilder.buildCiBlock(ci);
expect(block).toContain("project: ci");
});
it("omits project field when not set", () => {
const ci = {
phase: 5,
milestone: "v0.10",
status: "execute" as const,
};
const block = CommitBuilder.buildCiBlock(ci);
expect(block).not.toContain("project:");
});
it("commits with different project slugs include the correct project", () => {
const projects = ["task-api", "auth-svc", "notification-svc"];
for (const slug of projects) {
const ci = {
phase: 1,
milestone: "v0.10",
project: slug,
status: "plan" as const,
};
const block = CommitBuilder.buildCiBlock(ci);
expect(block).toContain(`project: ${slug}`);
}
});
});
describe("buildTaskCommit with project", () => {
it("includes project prefix in scope and ci block", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 5,
milestone: "v0.10",
project: "ci",
plan: "01-multi-project",
task: "01-config-array",
subject: "parallel project execution config",
status: "execute",
});
expect(msg).toContain("feat(ci/");
expect(msg).toContain("project: ci");
expect(msg).toContain("---ci---");
});
it("builds commit without project when project is undefined", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 5,
milestone: "v0.10",
project: undefined,
plan: "01-multi-project",
task: "01-config-array",
subject: "parallel project execution config",
status: "execute",
});
expect(msg).not.toContain("project:");
expect(msg).toContain("feat(P05");
});
});
describe("buildInitCommit with project", () => {
it("includes project in ci block", () => {
const msg = CommitBuilder.buildInitCommit({
projectName: "CIAgent",
phaseCount: 6,
milestone: "v0.10",
project: "ci",
specification: "Multi-project ideation support",
requirements: ["MULTI-03", "MULTI-05", "MULTI-07"],
});
expect(msg).toContain("project: ci");
expect(msg).toContain("---ci---");
expect(msg).toContain("phase: 0");
});
});
describe("Round-trip parsing with project field", () => {
it("parses commit message with project scope and ci block", () => {
const msg = CommitBuilder.buildTaskCommit({
type: "feat",
phase: 5,
milestone: "v0.10",
project: "ci",
plan: "01-multi",
task: "01-config",
subject: "parallel project execution",
status: "execute",
});
const extracted = extractCIAgentBlock(msg);
expect(extracted).not.toBeNull();
const parsed = parseCIAgentBlock(extracted!);
expect(parsed).not.toBeNull();
expect(parsed!.project).toBe("ci");
expect(parsed!.phase).toBe(5);
expect(parsed!.milestone).toBe("v0.10");
});
});
});
+227
View File
@@ -0,0 +1,227 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS, TerritoryEnforcement } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
export interface PersonaDefinition {
name: string;
domain: PersonaDomain;
frameworks: string[];
constraints: string[];
territory: string[];
description: string;
systemPromptAdditions: string;
}
const PERSONA_SEARCH_PATHS = [
".config/opencode/agents",
"opencode/agents",
];
const PERSONA_FILE_PATTERN = /^ci-(.+)\.md$/;
export class PersonaLoader {
private projectPath: string;
private config: CIAgentConfig;
private cachedPersonas: Map<string, PersonaDefinition> = new Map();
private loaded: boolean = false;
constructor(projectPath: string, config: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config;
}
loadPersonas(): PersonaDefinition[] {
if (this.loaded) {
return Array.from(this.cachedPersonas.values());
}
const configPersonas = this.config.personas?.personas || DEFAULT_PERSONAS;
const configEnabled = this.config.personas?.enabled ?? true;
if (!configEnabled) {
this.loaded = true;
return [];
}
for (const configPersona of configPersonas) {
const filePersona = this.loadPersonaFromFile(configPersona.name);
if (filePersona) {
const merged: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: filePersona.frameworks.length > 0 ? filePersona.frameworks : configPersona.frameworks,
constraints: filePersona.constraints.length > 0 ? filePersona.constraints : configPersona.constraints,
territory: filePersona.territory.length > 0 ? filePersona.territory : configPersona.territory,
description: filePersona.description,
systemPromptAdditions: filePersona.systemPromptAdditions,
};
this.cachedPersonas.set(configPersona.name, merged);
} else {
const definition: PersonaDefinition = {
name: configPersona.name,
domain: configPersona.domain,
frameworks: configPersona.frameworks,
constraints: configPersona.constraints,
territory: configPersona.territory,
description: `${configPersona.name} persona (domain: ${configPersona.domain})`,
systemPromptAdditions: this.buildDefaultPromptAdditions(configPersona),
};
this.cachedPersonas.set(configPersona.name, definition);
}
}
this.loaded = true;
return Array.from(this.cachedPersonas.values());
}
getPersona(name: string): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
return this.cachedPersonas.get(name);
}
getPersonaForDomain(domain: PersonaDomain): PersonaDefinition | undefined {
if (!this.loaded) this.loadPersonas();
for (const persona of this.cachedPersonas.values()) {
if (persona.domain === domain) return persona;
}
return undefined;
}
getLeadDeveloper(): PersonaDefinition {
return this.getPersona("lead-developer") || {
name: "lead-developer",
domain: "coordination",
frameworks: [],
constraints: ["pragmatic", "battle-tested defaults"],
territory: [],
description: "Lead developer — coordinates task decomposition and resolves conflicts",
systemPromptAdditions: "",
};
}
getEngineerPersonas(): PersonaDefinition[] {
if (!this.loaded) this.loadPersonas();
return Array.from(this.cachedPersonas.values()).filter(
(p) => p.domain !== "coordination"
);
}
getTerritoryEnforcement(): TerritoryEnforcement {
return this.config.personas?.territory_enforcement || "warn";
}
private loadPersonaFromFile(name: string): PersonaDefinition | null {
const filename = `ci-${name}.md`;
for (const searchPath of PERSONA_SEARCH_PATHS) {
const filePath = path.join(this.projectPath, searchPath, filename);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, "utf-8");
return this.parsePersonaMd(name, content);
} catch {
continue;
}
}
}
return null;
}
private parsePersonaMd(name: string, content: string): PersonaDefinition {
const frontmatter = this.parseFrontmatter(content);
const body = this.stripFrontmatter(content);
return {
name: (frontmatter.name as string) || name,
domain: (frontmatter.domain as PersonaDomain) || this.inferDomainFromName(name),
frameworks: (frontmatter.frameworks as string[]) || [],
constraints: (frontmatter.constraints as string[]) || [],
territory: (frontmatter.territory as string[]) || [],
description: (frontmatter.description as string) || body.slice(0, 200),
systemPromptAdditions: body,
};
}
private parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: Record<string, unknown> = {};
const lines = yaml.split("\n");
let currentKey = "";
let inArray = false;
let arrayItems: string[] = [];
for (const line of lines) {
const arrMatch = line.match(/^(\w+):\s*$/);
if (arrMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
currentKey = arrMatch[1];
inArray = true;
arrayItems = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && inArray) {
arrayItems.push(itemMatch[1].trim());
continue;
}
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
if (kvMatch) {
if (inArray && currentKey) {
result[currentKey] = arrayItems;
inArray = false;
}
currentKey = kvMatch[1];
result[currentKey] = kvMatch[2].trim();
}
}
if (inArray && currentKey) {
result[currentKey] = arrayItems;
}
return result;
}
private stripFrontmatter(content: string): string {
return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
}
private inferDomainFromName(name: string): PersonaDomain {
if (name.includes("data") || name.includes("db") || name.includes("schema")) return "data";
if (name.includes("backend") || name.includes("api") || name.includes("server")) return "backend";
if (name.includes("frontend") || name.includes("ui") || name.includes("client")) return "frontend";
return "coordination";
}
private buildDefaultPromptAdditions(config: ExecutePersonaConfig): string {
const parts: string[] = [];
parts.push(`You are a ${config.name} persona in the CIAgent execution pipeline.`);
parts.push(`Domain: ${config.domain}.`);
if (config.frameworks.length > 0) {
parts.push(`Preferred frameworks: ${config.frameworks.join(", ")}.`);
}
if (config.constraints.length > 0) {
parts.push(`Design constraints: ${config.constraints.join(", ")}.`);
}
if (config.territory.length > 0) {
parts.push(`You own the following file patterns: ${config.territory.join(", ")}.`);
parts.push(`Do not modify files outside your territory without explicit lead developer approval.`);
}
return parts.join(" ");
}
}
+475
View File
@@ -0,0 +1,475 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {
ExecutePersonaConfig,
PersonaDomain,
TerritoryConflict,
DecomposedTask,
DecomposedPlan,
DEFAULT_PERSONAS,
matchFileToPersona,
globMatch,
detectConflicts,
} from "../types/persona.js";
import { TaskDecomposer } from "../core/task-decomposer.js";
import { PersonaLoader } from "../core/persona-loader.js";
import { CIAgentConfig, DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { initCIAgent } from "../core/config.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-persona-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
const samplePlan = `# Phase 1 Plan — Core API
## Phase Goal
Build core API routes and database schema.
### Wave 1 (foundational)
#### Task 1.1: Create user schema
| **ID** | P1-T1 |
| **REQs** | DATA-01 |
| **Description** | Create the users table schema with Drizzle ORM |
| **Files to create** | \`src/db/schema/users.ts\`, \`src/db/migrations/001_create_users.sql\` |
#### Task 1.2: Create auth routes
| **ID** | P1-T2 |
| **REQs** | API-01 |
| **Description** | Create /api/auth/login and /api/auth/register routes |
| **Files to create** | \`src/api/routes/auth.ts\`, \`src/api/middleware/auth.ts\` |
#### Task 1.3: Create login page
| **ID** | P1-T3 |
| **REQs** | UI-01 |
| **Description** | Create React login page component |
| **Files to create** | \`src/components/LoginForm.tsx\`, \`src/pages/login.tsx\` |
### Wave 2
#### Task 1.4: Create data repository
| **ID** | P1-T4 |
| **REQs** | DATA-02 |
| **Description** | Create UserRepository with typed query methods |
| **Files to create** | \`src/repository/userRepository.ts\` |
`;
describe("ExecutePersona type", () => {
it("DEFAULT_PERSONAS has 4 personas", () => {
expect(DEFAULT_PERSONAS).toHaveLength(4);
});
it("DEFAULT_PERSONAS includes lead-developer", () => {
const lead = DEFAULT_PERSONAS.find((p) => p.name === "lead-developer");
expect(lead).toBeTruthy();
expect(lead!.domain).toBe("coordination");
expect(lead!.territory).toHaveLength(0);
});
it("DEFAULT_PERSONAS includes data-engineer", () => {
const data = DEFAULT_PERSONAS.find((p) => p.name === "data-engineer");
expect(data).toBeTruthy();
expect(data!.domain).toBe("data");
expect(data!.frameworks).toContain("drizzle");
expect(data!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes backend-engineer", () => {
const backend = DEFAULT_PERSONAS.find((p) => p.name === "backend-engineer");
expect(backend).toBeTruthy();
expect(backend!.domain).toBe("backend");
expect(backend!.frameworks).toContain("fastify");
expect(backend!.territory.length).toBeGreaterThan(0);
});
it("DEFAULT_PERSONAS includes frontend-engineer", () => {
const frontend = DEFAULT_PERSONAS.find((p) => p.name === "frontend-engineer");
expect(frontend).toBeTruthy();
expect(frontend!.domain).toBe("frontend");
expect(frontend!.frameworks).toContain("react");
expect(frontend!.territory.length).toBeGreaterThan(0);
});
it("each domain persona has territory patterns", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.territory.length).toBeGreaterThan(0);
}
});
it("each domain persona has constraints", () => {
for (const persona of DEFAULT_PERSONAS) {
if (persona.domain === "coordination") continue;
expect(persona.constraints.length).toBeGreaterThan(0);
}
});
});
describe("matchFileToPersona", () => {
const personas = DEFAULT_PERSONAS;
it("matches data files to data engineer", () => {
const matches = [
"src/db/schema/users.ts",
"src/migrations/001_create_users.sql",
"drizzle/config.ts",
"src/models/User.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("data-engineer");
}
});
it("matches API files to backend engineer", () => {
const matches = [
"src/api/routes/auth.ts",
"src/services/UserService.ts",
"src/middleware/auth.ts",
"src/controllers/userController.ts",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("backend-engineer");
}
});
it("matches component files to frontend engineer", () => {
const matches = [
"src/components/LoginForm.tsx",
"src/pages/login.tsx",
"src/hooks/useAuth.ts",
"src/styles/global.css",
];
for (const file of matches) {
const result = matchFileToPersona(file, personas);
expect(result).toBeTruthy();
expect(result!.name).toBe("frontend-engineer");
}
});
it("returns null for files outside any territory", () => {
const result = matchFileToPersona("src/utils/helpers.ts", personas);
expect(result).toBeNull();
});
it("handles glob patterns correctly", () => {
expect(globMatch("**/db/**", "src/db/schema/users.ts")).toBe(true);
expect(globMatch("**/db/**", "src/api/routes/auth.ts")).toBe(false);
expect(globMatch("**/*.tsx", "src/components/Button.tsx")).toBe(true);
expect(globMatch("**/*.tsx", "src/utils/helpers.ts")).toBe(false);
});
});
describe("detectConflicts", () => {
it("detects data-backend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/db/schema/users.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("data-backend");
expect(conflicts[0].personas).toContain("data-engineer");
expect(conflicts[0].personas).toContain("backend-engineer");
});
it("detects backend-frontend conflicts", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "backend-engineer",
domain: "backend",
description: "Create API types",
files: ["src/api/types/UserTypes.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "frontend-engineer",
domain: "frontend",
description: "Create user component",
files: ["src/api/types/UserTypes.ts"],
dependencies: ["T1"],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(1);
expect(conflicts[0].type).toBe("backend-frontend");
});
it("returns no conflicts for non-overlapping tasks", () => {
const tasks: DecomposedTask[] = [
{
taskId: "T1",
persona: "data-engineer",
domain: "data",
description: "Create schema",
files: ["src/db/schema/users.ts"],
dependencies: [],
},
{
taskId: "T2",
persona: "backend-engineer",
domain: "backend",
description: "Create API routes",
files: ["src/api/routes/auth.ts"],
dependencies: [],
},
];
const conflicts = detectConflicts(tasks, DEFAULT_PERSONAS);
expect(conflicts.length).toBe(0);
});
});
describe("TaskDecomposer", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("decomposes a plan into persona-specific task groups", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config, "test-project");
const plan = decomposer.decompose(samplePlan);
expect(plan.tasks.length).toBeGreaterThan(0);
expect(plan.dataTasks).toBeDefined();
expect(plan.backendTasks).toBeDefined();
expect(plan.frontendTasks).toBeDefined();
expect(plan.coordinationTasks).toBeDefined();
});
it("resolves territory conflicts", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const resolved = decomposer.resolveConflicts(plan);
for (const conflict of resolved.conflicts) {
if (conflict.resolution) {
expect(conflict.resolution.length).toBeGreaterThan(0);
}
}
});
it("assigns data tasks to data-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const dataTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("schema") || f.includes("migration"))
);
if (dataTask) {
expect(dataTask.domain).toBe("data");
}
});
it("assigns API tasks to backend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const apiTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("api") || f.includes("routes"))
);
if (apiTask) {
expect(apiTask.domain).toBe("backend");
}
});
it("assigns component tasks to frontend-engineer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const decomposer = new TaskDecomposer(dir, config);
const plan = decomposer.decompose(samplePlan);
const frontendTask = plan.tasks.find(
(t) => t.files.some((f) => f.includes("components") || f.endsWith(".tsx"))
);
if (frontendTask) {
expect(frontendTask.domain).toBe("frontend");
}
});
});
describe("PersonaLoader", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initCIAgent(dir);
});
afterEach(() => {
cleanup(dir);
});
it("returns default personas when no files exist", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBeGreaterThan(0);
expect(personas.some((p) => p.domain === "data")).toBe(true);
expect(personas.some((p) => p.domain === "backend")).toBe(true);
expect(personas.some((p) => p.domain === "frontend")).toBe(true);
});
it("getLeadDeveloper returns lead developer persona", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
loader.loadPersonas();
const lead = loader.getLeadDeveloper();
expect(lead).toBeTruthy();
expect(lead.domain).toBe("coordination");
expect(lead.name).toBe("lead-developer");
});
it("getEngineerPersonas returns non-coordination personas", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const engineers = loader.getEngineerPersonas();
expect(engineers.length).toBe(3);
expect(engineers.every((p) => p.domain !== "coordination")).toBe(true);
});
it("returns empty personas when personas disabled", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: false,
territory_enforcement: "warn" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
const personas = loader.loadPersonas();
expect(personas.length).toBe(0);
});
it("getTerritoryEnforcement returns configured value", () => {
const config = {
...DEFAULT_CIAGENT_CONFIG,
personas: {
enabled: true,
territory_enforcement: "strict" as const,
personas: DEFAULT_PERSONAS,
},
};
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("strict");
});
it("defaults to warn territory enforcement", () => {
const config = { ...DEFAULT_CIAGENT_CONFIG };
const loader = new PersonaLoader(dir, config);
expect(loader.getTerritoryEnforcement()).toBe("warn");
});
});
+327
View File
@@ -0,0 +1,327 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { CIAgentFiles } from "../core/ciagent-files.js";
import { initCIAgent, loadConfig } from "../core/config.js";
import { DEFAULT_CIAGENT_CONFIG } from "../types/config.js";
import { SessionConfig, SessionInfo, DEFAULT_SESSION_CONFIG } from "../types/session.js";
import { AgentSession } from "../core/agent-session.js";
import { SessionManager } from "../core/session-manager.js";
function createTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-session-test-"));
}
function cleanup(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
function initProjectWithConfig(dir: string): void {
const ciDir = path.join(dir, ".ciagent");
fs.mkdirSync(ciDir, { recursive: true });
const config = {
...DEFAULT_CIAGENT_CONFIG,
projects: [{ slug: "test-project", name: "Test Project", default: true }],
active_project: "test-project",
active_projects: ["test-project"],
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
};
fs.writeFileSync(path.join(ciDir, "config.json"), JSON.stringify(config, null, 2));
const projectDir = path.join(ciDir, "test-project");
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(path.join(projectDir, "PROJECT.md"), [
"# Test Project",
"",
"## What This Is",
"",
"A test project for session testing",
"",
"## Requirements",
"",
"### Active",
"",
"- [ ] Build session management",
"",
"## Constraints",
"",
"- TypeScript",
"",
"## Key Decisions",
"",
"| Decision | Rationale | Outcome |",
"|----------|-----------|---------|",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ROADMAP.md"), [
"# Roadmap",
"",
"## Overview",
"",
"Test project roadmap",
"",
"## Phases",
"",
"- [ ] **Phase 1: Sessions** - Build session management",
"",
"## Phase Details",
"",
"### Phase 1: Sessions",
"**Goal**.: Build session management",
"**Depends on**: Nothing",
"**Requirements**: SESSION-01",
"**Success Criteria**:",
"1. Sessions work",
"**Status**: not_started",
"",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "REQUIREMENTS.md"), [
"# Requirements",
"",
"| REQ-ID | Requirement | Priority | Phase | Status |",
"|--------|-------------|----------|-------|--------|",
"| SESSION-01 | Session management | P0 | 1 | pending |",
"",
"## Traceability",
"",
"| Requirement | Phase | Status |",
"|-------------|-------|--------|",
"| SESSION-01 | Phase 1 | pending |",
].join("\n"));
fs.writeFileSync(path.join(projectDir, "ARCHITECTURE.md"), [
"# Architecture",
"",
"## Overview",
"",
"Test architecture",
"",
"## Components",
"",
"### test-api",
"- **Description**: API",
"- **Boundaries**: HTTP only",
"- **Depends on**: None",
"",
"## Data Flow",
"",
"Client -> API",
"",
"## Build Order",
"",
"1. API",
"",
].join("\n"));
}
describe("Session types", () => {
it("DEFAULT_SESSION_CONFIG has expected values", () => {
expect(DEFAULT_SESSION_CONFIG.max_concurrent_sessions).toBe(3);
expect(DEFAULT_SESSION_CONFIG.session_timeout_ms).toBe(3600000);
expect(DEFAULT_SESSION_CONFIG.session_isolation).toBe("branch");
});
it("SessionInfo interface is constructable", () => {
const info: SessionInfo = {
id: "abc12345",
project_slug: "test-project",
project_path: "/tmp/test",
phase: 1,
stage: "execute",
status: "running",
started_at: new Date().toISOString(),
last_updated: new Date().toISOString(),
};
expect(info.id).toBe("abc12345");
expect(info.status).toBe("running");
expect(info.project_slug).toBe("test-project");
});
it("SessionConfig supports all status values", () => {
const statuses: SessionInfo["status"][] = [
"pending", "running", "paused", "completed", "failed", "cancelled",
];
expect(statuses).toHaveLength(6);
});
});
describe("AgentSession", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates a session with a unique ID", () => {
const session = new AgentSession(dir, "test-project");
expect(session.getId()).toBeTruthy();
expect(session.getId().length).toBeGreaterThan(0);
expect(session.getStatus()).toBe("pending");
});
it("getSessionInfo returns valid SessionInfo", () => {
const session = new AgentSession(dir, "test-project");
const info = session.getSessionInfo();
expect(info.id).toBe(session.getId());
expect(info.project_slug).toBe("test-project");
expect(info.project_path).toBe(dir);
expect(info.status).toBe("pending");
expect(info.phase).toBe(0);
});
it("persists session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const slugDir = path.join(dir, ".ciagent", "test-project");
const files = fs.readdirSync(slugDir);
const stateFile = files.find((f) => f.startsWith(".session-") && f.endsWith(".json"));
expect(stateFile).toBeTruthy();
});
it("loads persisted session state", () => {
const session = new AgentSession(dir, "test-project");
session.persistState();
const loaded = AgentSession.loadState(dir, session.getId(), "test-project");
expect(loaded).not.toBeNull();
expect(loaded!.getId()).toBe(session.getId());
});
it("returns null for non-existent session", () => {
const loaded = AgentSession.loadState(dir, "nonexistent", "test-project");
expect(loaded).toBeNull();
});
it("acquireLock creates a lock file", () => {
const session = new AgentSession(dir, "test-project");
const acquired = session.acquireLock();
expect(acquired).toBe(true);
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(true);
session.releaseLock();
});
it("releaseLock removes the lock file", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
session.releaseLock();
const lockPath = path.join(dir, ".ciagent", "test-project", ".session-lock");
expect(fs.existsSync(lockPath)).toBe(false);
});
it("cancel changes status to cancelled when running", () => {
const session = new AgentSession(dir, "test-project");
session.acquireLock();
(session as any).status = "running";
const cancelled = session.cancel();
expect(cancelled).toBe(true);
expect(session.getStatus()).toBe("cancelled");
session.releaseLock();
});
it("cancel returns false for non-running session", () => {
const session = new AgentSession(dir, "test-project");
const cancelled = session.cancel();
expect(cancelled).toBe(false);
});
it("pause and resume work correctly for non-running session", () => {
const session = new AgentSession(dir, "test-project");
expect(session.pause()).toBe(false);
expect(session.resume()).toBe(false);
});
});
describe("SessionManager", () => {
let dir: string;
beforeEach(() => {
dir = createTempDir();
initProjectWithConfig(dir);
});
afterEach(() => {
cleanup(dir);
});
it("creates sessions for projects", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
expect(session).toBeTruthy();
expect(session.getProjectSlug()).toBe("test-project");
});
it("lists sessions", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const sessions = manager.listSessions();
expect(sessions.length).toBe(1);
expect(sessions[0].project_slug).toBe("test-project");
});
it("lists active sessions as empty when none running", () => {
const manager = new SessionManager(dir);
manager.createSession("test-project");
const active = manager.listActiveSessions();
expect(active.length).toBe(0);
});
it("cancels a session that is not running returns false", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const cancelled = manager.cancelSession(session.getId());
expect(cancelled).toBe(false);
});
it("cleans up stale sessions returns 0", () => {
const manager = new SessionManager(dir);
const cleaned = manager.cleanupStaleSessions();
expect(cleaned).toBe(0);
});
it("loads persisted sessions as empty initially", () => {
const manager = new SessionManager(dir);
const persisted = manager.loadPersistedSessions();
expect(Array.isArray(persisted)).toBe(true);
});
it("gets a session by id", () => {
const manager = new SessionManager(dir);
const session = manager.createSession("test-project");
const retrieved = manager.getSession(session.getId());
expect(retrieved).toBeTruthy();
expect(retrieved!.getId()).toBe(session.getId());
});
it("returns undefined for non-existent session", () => {
const manager = new SessionManager(dir);
const retrieved = manager.getSession("nonexistent");
expect(retrieved).toBeUndefined();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { CIAgentConfig } from "../types/config.js";
import { SessionInfo, SessionStatus } from "../types/session.js";
import { AgentSession } from "./agent-session.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "./config.js";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
export class SessionManager {
private sessions: Map<string, AgentSession> = new Map();
private config: CIAgentConfig;
private projectPath: string;
constructor(projectPath: string, config?: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
}
createSession(projectSlug: string): AgentSession {
const session = new AgentSession(this.projectPath, projectSlug, this.config);
this.sessions.set(session.getId(), session);
return session;
}
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
output: `Session ${sessionId} not found`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `Session ${sessionId} not found`,
};
}
return session.run(context);
}
async runAllSessions(
projectSlugs: string[],
contextFactory: (slug: string) => AgentContext,
parallel: boolean = false
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
if (parallel && projectSlugs.length > 1) {
const batches: string[][] = [];
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
for (let i = 0; i < projectSlugs.length; i += concurrency) {
batches.push(projectSlugs.slice(i, i + concurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
const slug = batch[batchResults.indexOf(settled)];
results[slug] = {
success: false,
output: `Session failed for ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
};
}
}
}
} else {
for (const slug of projectSlugs) {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
results[slug] = result;
}
}
return results;
}
cancelSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.cancel();
}
pauseSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.pause();
}
resumeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.resume();
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
}
listActiveSessions(): SessionInfo[] {
return this.listSessions().filter(
(s) => s.status === "running" || s.status === "paused"
);
}
loadPersistedSessions(): SessionInfo[] {
const ciDir = path.join(this.projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return [];
const sessions: SessionInfo[] = [];
const dirs = [ciDir];
try {
const config = loadConfig(this.projectPath);
if (config.projects && config.projects.length > 0) {
for (const project of config.projects) {
dirs.push(path.join(ciDir, project.slug));
}
}
} catch {}
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith(".session-") && file.endsWith(".json")) {
const sessionId = file.replace(".session-", "").replace(".json", "");
const slug = dir === ciDir ? "" : path.basename(dir);
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
if (session) {
sessions.push(session.getSessionInfo());
}
}
}
}
return sessions;
}
cleanupStaleSessions(): number {
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
const now = Date.now();
let cleaned = 0;
for (const [id, session] of this.sessions.entries()) {
const info = session.getSessionInfo();
const age = now - new Date(info.last_updated).getTime();
if ((info.status === "running" || info.status === "paused") && age > timeout) {
session.cancel();
this.sessions.delete(id);
cleaned++;
}
}
return cleaned;
}
}
+275
View File
@@ -0,0 +1,275 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { matchFileToPersona, detectConflicts, DecomposedTask, DecomposedPlan, TerritoryConflict, ExecutePersonaConfig, PersonaDomain, DEFAULT_PERSONAS } from "../types/persona.js";
import { CIAgentConfig } from "../types/config.js";
import { PersonaLoader, PersonaDefinition } from "./persona-loader.js";
import { CIAgentFiles } from "./ciagent-files.js";
import { readFile } from "../utils/file.js";
const DOMAIN_FILE_PATTERNS: Record<string, string[]> = {
data: [
"**/migrations/**", "**/schema/**", "**/models/**", "**/db/**",
"prisma/schema.prisma", "drizzle/**", "**/*.sql", "**/seed*",
"**/repository/**", "**/dao/**",
],
backend: [
"**/api/**", "**/routes/**", "**/services/**", "**/middleware/**",
"**/controllers/**", "**/auth/**", "**/handlers/**", "**/grpc/**",
"**/server.ts", "**/app.ts",
],
frontend: [
"**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**",
"**/*.tsx", "**/*.css", "**/*.vue", "**/*.svelte",
"**/layouts/**", "**/views/**", "**/client/**",
],
};
const DOMAIN_KEYWORDS: Record<string, string[]> = {
data: [
"schema", "migration", "database", "model", "query", "table", "column",
"index", "seed", "orm", "sql", "repository", "dao", "entity",
],
backend: [
"api", "route", "endpoint", "middleware", "controller", "service",
"handler", "server", "auth", "grpc", "rest", "websocket",
"request", "response", "cors", "rate-limit",
],
frontend: [
"component", "page", "layout", "style", "css", "hook", "view",
"client", "ui", "render", "state", "interactive", "accessible",
"responsive", "animation",
],
};
interface PlanTask {
id: string;
description: string;
files: string[];
requirements: string[];
dependencies: string[];
wave: number;
}
export class TaskDecomposer {
private projectPath: string;
private personaLoader: PersonaLoader;
private config: CIAgentConfig;
private ciFiles: CIAgentFiles;
constructor(projectPath: string, config: CIAgentConfig, projectSlug?: string) {
this.projectPath = projectPath;
this.config = config;
this.personaLoader = new PersonaLoader(projectPath, config);
this.ciFiles = new CIAgentFiles(projectPath, projectSlug || undefined);
}
decompose(planContent: string): DecomposedPlan {
const tasks = this.parsePlanTasks(planContent);
const personas = this.config.personas?.enabled !== false
? this.config.personas?.personas || DEFAULT_PERSONAS
: DEFAULT_PERSONAS;
const decomposedTasks = this.assignTasksToPersonas(tasks, personas);
const conflicts = detectConflicts(decomposedTasks, personas);
return {
tasks: decomposedTasks,
dataTasks: decomposedTasks.filter((t) => t.domain === "data"),
backendTasks: decomposedTasks.filter((t) => t.domain === "backend"),
frontendTasks: decomposedTasks.filter((t) => t.domain === "frontend"),
coordinationTasks: decomposedTasks.filter((t) => t.domain === "coordination"),
conflicts,
};
}
resolveConflicts(plan: DecomposedPlan): DecomposedPlan {
const resolved = { ...plan, conflicts: [...plan.conflicts] };
for (let i = 0; i < resolved.conflicts.length; i++) {
const conflict = resolved.conflicts[i];
const resolution = this.leadDeveloperResolve(conflict);
resolved.conflicts[i] = { ...conflict, resolution };
}
return resolved;
}
private parsePlanTasks(planContent: string): PlanTask[] {
const tasks: PlanTask[] = [];
const taskRegex = /####\s+Task\s+(\d+[\.\d]*)[\s:]+(.+)/g;
const idRegex = /\*\*ID\*\*\s*\|\s*([A-Z]+-\d+(?:-\d+)*)/g;
const filesRegex = /\*\*Files\s+to\s+(?:create|modify)\*\*\s*\|\s*(.+)/g;
const reqRegex = /\*\*REQs\*\*\s*\|\s*(.+)/g;
const depRegex = /\*\*Dependencies\*\*\s*\|\s*(.+)/g;
const waveRegex = /###\s+Wave\s+(\d+)/g;
const sections = planContent.split(/####\s+Task/);
let currentWave = 1;
const waveMatches = [...planContent.matchAll(/###\s+Wave\s+(\d+)/g)];
const wavePositions = waveMatches.map((m) => ({
wave: parseInt(m[1], 10),
position: m.index || 0,
}));
let taskCounter = 0;
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const taskPosition = planContent.indexOf(section);
currentWave = 1;
for (const wp of wavePositions) {
if (wp.position <= taskPosition) {
currentWave = wp.wave;
}
}
const taskIdMatch = section.match(/([A-Z]+-\d+(?:-\d+)*)/);
const taskId = taskIdMatch ? taskIdMatch[1] : `T${++taskCounter}`;
const descriptionMatch = section.match(/^\s*\d*[\.\d]*\s*[:]?\s*(.+)/);
const description = descriptionMatch ? descriptionMatch[1].split("\n")[0].trim() : `Task ${taskId}`;
const files: string[] = [];
const filesMatch = section.match(/\*\*Files?\s+to\s+(?:create|modify)\*\*\s*\|?\s*(.+)/i);
if (filesMatch) {
const fileList = filesMatch[1].split(/[`,]/).map((f: string) => f.trim()).filter(Boolean);
files.push(...fileList);
}
const blockFiles = section.match(/`([^`]+\.(ts|js|json|sql|md|tsx|jsx|vue|svelte|css))`/g);
if (blockFiles) {
for (const bf of blockFiles) {
const cleaned = bf.replace(/`/g, "");
if (!files.includes(cleaned)) files.push(cleaned);
}
}
const requirements: string[] = [];
const reqMatch = section.match(/\*\*REQs?\*\*\s*\|?\s*(.+)/i);
if (reqMatch) {
const reqs = reqMatch[1].split(",").map((r: string) => r.trim()).filter(Boolean);
requirements.push(...reqs);
}
const dependencies: string[] = [];
const depMatch = section.match(/\*\*Dependencies?\*\*\s*\|?\s*(.+)/i);
if (depMatch) {
const deps = depMatch[1].split(",").map((d: string) => d.trim()).filter((d: string) => d && d !== "None");
dependencies.push(...deps);
}
tasks.push({
id: taskId,
description,
files,
requirements,
dependencies,
wave: currentWave,
});
}
return tasks;
}
private assignTasksToPersonas(
tasks: PlanTask[],
personas: ExecutePersonaConfig[]
): DecomposedTask[] {
const leadConfig = personas.find((p) => p.domain === "coordination") || personas[0];
const engineerConfigs = personas.filter((p) => p.domain !== "coordination");
return tasks.map((task) => {
const assignedPersona = this.assignPersona(task, personas);
const domain = this.determineDomain(task, assignedPersona);
return {
taskId: task.id,
persona: assignedPersona.name,
domain,
description: task.description,
files: task.files,
dependencies: task.dependencies,
};
});
}
private assignPersona(
task: PlanTask,
personas: ExecutePersonaConfig[]
): ExecutePersonaConfig {
if (task.files.length === 0 && task.description.length === 0) {
return personas.find((p) => p.domain === "coordination") || personas[0];
}
let bestPersona: ExecutePersonaConfig | null = null;
let bestScore = 0;
for (const persona of personas) {
if (persona.domain === "coordination") continue;
let score = 0;
for (const file of task.files) {
const matched = matchFileToPersona(file, personas);
if (matched && matched.name === persona.name) {
score += 3;
}
}
const domainKeywords = DOMAIN_KEYWORDS[persona.domain] || [];
const descLower = task.description.toLowerCase();
for (const keyword of domainKeywords) {
if (descLower.includes(keyword)) {
score += 1;
}
}
for (const req of task.requirements) {
const reqLower = req.toLowerCase();
for (const keyword of domainKeywords) {
if (reqLower.includes(keyword)) {
score += 1;
}
}
}
if (score > bestScore) {
bestScore = score;
bestPersona = persona;
}
}
if (bestPersona && bestScore > 0) {
return bestPersona;
}
if (task.files.length > 0) {
const firstFile = task.files[0];
const matched = matchFileToPersona(firstFile, personas);
if (matched) return matched;
}
return personas.find((p) => p.domain === "coordination") || personas[0];
}
private determineDomain(
task: PlanTask,
persona: ExecutePersonaConfig
): PersonaDomain {
return persona.domain as PersonaDomain;
}
private leadDeveloperResolve(conflict: TerritoryConflict): string {
switch (conflict.type) {
case "data-backend":
return `Lead developer assigns ${conflict.file} to backend engineer. Data engineer provides schema contract; backend implements API contract. Data changes should be in a separate migration.`;
case "backend-frontend":
return `Lead developer assigns ${conflict.file} to backend engineer. Frontend engineer adapts to backend API contract. If the file is primarily a type definition, create a shared types module.`;
case "data-frontend":
return `Lead developer assigns ${conflict.file} to data engineer for schema definition. Frontend engineer consumes through a backend API endpoint. Direct database access from frontend is prohibited.`;
default:
return `Lead developer arbitrates: ${conflict.file} assigned to ${conflict.personas[0]}. Other persona uses the public interface.`;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { IntelligenceBackend, BackendRequest, BackendResult, BackendType, emptyTokenUsage, OpenAIConfig, AnthropicConfig } from "./backends/types.js";
import { OpenAIBackend } from "./backends/openai.js";
import { AnthropicBackend } from "./backends/anthropic.js";
import { PlannerAgent } from "./agents/planner.js";
import { ResearcherAgent } from "./agents/researcher.js";
import { VerifierAgent } from "./agents/verifier.js";
import { SecurityAuditorAgent } from "./agents/security-auditor.js";
import { CodeReviewerAgent } from "./agents/code-reviewer.js";
import { AgentContext } from "./agents/base.js";
class MockBackend implements IntelligenceBackend {
readonly name = "mock";
readonly type: BackendType = "llm";
async isAvailable(): Promise<boolean> {
return true;
}
async execute(request: BackendRequest): Promise<BackendResult> {
return {
success: true,
output: `Mock backend executed task for ${request.persona}: ${request.task.substring(0, 80)}`,
artifacts: [],
decisions: [],
escalations: [],
usage: emptyTokenUsage(),
};
}
}
describe("E2E v0.9 — Integration with mock backend", () => {
let tempDir: string;
const mockBackend = new MockBackend();
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ciagent-e2e-v09-"));
fs.mkdirSync(path.join(tempDir, ".ciagent"), { recursive: true });
fs.mkdirSync(path.join(tempDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".ciagent", "config.json"),
JSON.stringify({
autonomy: { level: "full", escalation_hooks: [], clarify_budget: 10, decision_confidence_threshold: 0.6, max_revision_iterations: 3, max_verification_retries: 2, escalation_timeout_ms: 300000 },
model_profile: "quality",
parallelization: { enabled: true, max_concurrent_agents: 5, min_plans_for_parallel: 2 },
verification: { automated_only: true, escalate_visual: true, escalate_external_integration: true, test_first: false },
security: { auto_accept_low_severity: true, auto_mitigate_medium_severity: true, escalate_high_severity: true },
git: { branching_strategy: "phase", auto_commit: false, auto_push: false },
backend: { provider: "auto", agent_backends: { opencode: { enabled: false } }, llm_backends: {} },
}, null, 2)
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "PROJECT.md"),
"# Project: E2E Test\n\n## Core Value\nTest CIAgent v0.9 integration\n\n## Requirements\n### Active\n- TEST-01: E2E pipeline completes\n\n## Key Decisions\n\n## Constraints\n- Test environment only"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "REQUIREMENTS.md"),
"# Requirements\n\n## V1\n### Functional\n| ID | Description | Priority |\n|------|------|------|\n| REQ-01 | E2E test completes | high |\n\n## Traceability\n| Requirement | Phase | Status |\n|------|------|------|\n| REQ-01 | 1 | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ROADMAP.md"),
"# Roadmap\n\n## Phases\n\n| # | Name | Description | Requirements | Depends On | Status |\n|------|------|------|------|------|------|\n| 1 | Test Phase | E2E test phase | REQ-01 | | in_progress |"
);
fs.writeFileSync(
path.join(tempDir, ".ciagent", "ARCHITECTURE.md"),
"# Architecture\n\n## Overview\nE2E test architecture\n\n## Components\n| Name | Description | Boundaries | Depends On |\n|------|------|------|------|\n| core | Core module | src/core/ — test support | | \n\n## Build Order\n1. Build core\n\n## Data Flow\nSimple test flow"
);
fs.writeFileSync(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "e2e-test", version: "0.1.0", scripts: { test: "echo 'no tests'" } })
);
fs.writeFileSync(path.join(tempDir, "tsconfig.json"), "{}");
fs.writeFileSync(path.join(tempDir, "src", "app.ts"), "export function main() { return 1; }");
execSync("git init", { cwd: tempDir, stdio: "pipe" });
execSync("git add -A", { cwd: tempDir, stdio: "pipe" });
execSync('git commit -m "init: E2E test project"', { cwd: tempDir, stdio: "pipe" });
});
afterEach(() => {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {}
});
it("runs a multi-agent pipeline with mock backend and collects artifacts", async () => {
const context: AgentContext = {
project_path: tempDir,
phase: 1,
stage: "research",
specification: "Build an E2E test project that validates CIAgent v0.9 integration",
config_path: path.join(tempDir, ".ciagent", "config.json"),
backend: mockBackend as unknown as IntelligenceBackend,
};
const researcher = new ResearcherAgent();
const researcherResult = await researcher.execute(context);
expect(researcherResult).toBeDefined();
expect(typeof researcherResult.success).toBe("boolean");
expect(researcherResult.output.length).toBeGreaterThan(0);
const planner = new PlannerAgent();
const plannerResult = await planner.execute({ ...context, stage: "plan" });
expect(plannerResult).toBeDefined();
expect(typeof plannerResult.success).toBe("boolean");
const auditor = new SecurityAuditorAgent();
const auditorResult = await auditor.execute({ ...context, stage: "verify" });
expect(auditorResult).toBeDefined();
expect(typeof auditorResult.success).toBe("boolean");
const reviewer = new CodeReviewerAgent();
const reviewerResult = await reviewer.execute({ ...context, stage: "review" });
expect(reviewerResult).toBeDefined();
expect(typeof reviewerResult.success).toBe("boolean");
const verifier = new VerifierAgent();
const verifierResult = await verifier.execute({ ...context, stage: "verify" });
expect(verifierResult).toBeDefined();
expect(typeof verifierResult.success).toBe("boolean");
});
it("loads OpenAI and Anthropic config types without runtime errors", () => {
const openaiConfig: OpenAIConfig = {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
};
expect(openaiConfig.model).toBe("gpt-4o");
expect(openaiConfig.api_key_env).toBe("OPENAI_API_KEY");
const anthropicConfig: AnthropicConfig = {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
model_profile: "quality",
timeout_ms: 60000,
api_version: "2023-06-01",
};
expect(anthropicConfig.model).toBe("claude-sonnet-4-20250514");
expect(anthropicConfig.api_key_env).toBe("ANTHROPIC_API_KEY");
const openaiBackend = new OpenAIBackend(openaiConfig);
expect(openaiBackend.name).toBe("openai");
expect(openaiBackend.type).toBe("llm");
const anthropicBackend = new AnthropicBackend(anthropicConfig);
expect(anthropicBackend.name).toBe("anthropic");
expect(anthropicBackend.type).toBe("llm");
});
});
+12 -2
View File
@@ -8,6 +8,11 @@ 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 { AgentSession } from "./core/agent-session.js";
export { SessionManager } from "./core/session-manager.js";
export { PersonaLoader } from "./core/persona-loader.js";
export { TaskDecomposer } from "./core/task-decomposer.js";
export { VerificationPipeline } from "./verification/index.js";
export { StructuralVerification } from "./verification/structural.js";
export { BehavioralVerification } from "./verification/behavioral.js";
@@ -23,6 +28,8 @@ export { ESCALATION_TYPES } from "./types/escalation.js";
export { createClarifyQuestion } from "./types/clarify.js";
export { parseSpecification } from "./types/specification.js";
export { getNextStage, createInitialPipelineState } from "./types/pipeline.js";
export { matchFileToPersona, detectConflicts, DEFAULT_PERSONAS } from "./types/persona.js";
export { DEFAULT_SESSION_CONFIG } from "./types/session.js";
export * as fileUtils from "./utils/file.js";
export { resolveBackend, createBackend } from "./backends/index.js";
export { OpencodeBackend } from "./backends/opencode.js";
@@ -30,7 +37,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 +51,8 @@ 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";
export type { ToolDefinition, ToolCall, ToolResult } from "./backends/tool-registry.js";
export type { SessionInfo, SessionStatus, SessionConfig } from "./types/session.js";
export type { ExecutePersonaConfig, TerritoryEnforcement, PersonaDomain, DecomposedTask, DecomposedPlan, TerritoryConflict } from "./types/persona.js";
+1
View File
@@ -55,6 +55,7 @@ export interface CIAgentMetadata {
phase: number;
milestone: string;
project?: string;
session?: string;
plan?: string;
task?: string;
status: PipelineStage;
+87 -2
View File
@@ -1,4 +1,4 @@
import { BackendConfigSection } from "../backends/types.js";
import { TerritoryEnforcement, ExecutePersonaConfig } from "./persona.js";
export type AutonomyLevel = "full" | "supervised" | "guided";
@@ -6,7 +6,7 @@ export type ModelProfile = "quality" | "speed" | "balanced";
export type BranchingStrategy = "phase" | "feature" | "trunk";
export type MilestoneType = "nfr" | "feature" | "schema-breaking";
export type MilestoneType = "nfr" | "feature" | "major";
export type PhaseName = "research" | "plan" | "execute" | "verify" | "complete";
@@ -45,6 +45,7 @@ export interface ParallelizationConfig {
enabled: boolean;
max_concurrent_agents: number;
min_plans_for_parallel: number;
max_concurrent_projects: number;
}
export interface VerificationConfig {
@@ -66,6 +67,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;
@@ -75,6 +83,7 @@ export interface ProjectEntry {
export interface CIAgentConfig {
projects: ProjectEntry[];
active_project: string;
active_projects: string[];
autonomy: AutonomyConfig;
model_profile: ModelProfile;
parallelization: ParallelizationConfig;
@@ -82,11 +91,31 @@ export interface CIAgentConfig {
security: SecurityConfig;
git: GitConfig;
backend: BackendConfigSection;
gitea?: GiteaConfig;
ideation?: IdeationConfig;
sessions?: SessionConfig;
personas?: PersonaConfigSection;
}
export interface SessionConfig {
max_concurrent_sessions: number;
session_timeout_ms: number;
session_isolation: "branch";
}
export interface PersonaConfigSection {
enabled: boolean;
territory_enforcement: TerritoryEnforcement;
personas: ExecutePersonaConfig[];
}
import { BackendConfigSection } from "../backends/types.js";
import { IdeationConfig, IdeationCategory } from "./ideation.js";
export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
projects: [],
active_project: "",
active_projects: [],
autonomy: {
level: "full",
escalation_hooks: ["deploy", "delete_data", "merge_to_main"],
@@ -101,6 +130,7 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
enabled: true,
max_concurrent_agents: 5,
min_plans_for_parallel: 2,
max_concurrent_projects: 3,
},
verification: {
automated_only: true,
@@ -124,6 +154,13 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
opencode: { enabled: true },
},
llm_backends: {
"openai": {
base_url: "https://api.openai.com/v1",
api_key_env: "OPENAI_API_KEY",
model: "gpt-4o",
model_profile: "quality",
timeout_ms: 60000,
},
"ollama-local": {
base_url: "http://localhost:11434",
model_profile: "balanced",
@@ -134,6 +171,54 @@ export const DEFAULT_CIAGENT_CONFIG: CIAgentConfig = {
model_profile: "quality",
timeout_ms: 60000,
},
"anthropic": {
base_url: "https://api.anthropic.com",
api_key_env: "ANTHROPIC_API_KEY",
model: "claude-sonnet-4-20250514",
api_version: "2023-06-01",
model_profile: "quality",
timeout_ms: 60000,
},
},
},
gitea: {
base_url: "https://git.cloudinit.dev",
api_token_env: "GITEA_TOKEN",
owner: "",
repo: "",
},
ideation: {
enabled: true,
categories: ["security", "quality", "architecture", "coverage", "improvement"] as IdeationCategory[],
confidence_threshold: 0.6,
max_ideas: 20,
external_signals: {
npm_audit: true,
osv_advisories: true,
dependency_staleness: true,
},
cross_project: {
enabled: false,
similarity_weight: 0.5,
},
chaos: {
enabled: true,
scenarios: ["backend_unavailable", "requirement_change", "test_coverage_drop"],
},
},
sessions: {
max_concurrent_sessions: 3,
session_timeout_ms: 3600000,
session_isolation: "branch",
},
personas: {
enabled: true,
territory_enforcement: "warn",
personas: [
{ name: "lead-developer", domain: "coordination", frameworks: [], constraints: ["pragmatic", "battle-tested defaults"], territory: [] },
{ name: "data-engineer", domain: "data", frameworks: ["drizzle", "postgresql"], constraints: ["schema-first", "type-safe ORM", "migration-driven"], territory: ["**/migrations/**", "**/schema/**", "**/models/**", "**/db/**", "prisma/schema.prisma", "drizzle/**", "**/*.sql"] },
{ name: "backend-engineer", domain: "backend", frameworks: ["fastify", "hono"], constraints: ["api-first", "strict-typing", "dependency-injection"], territory: ["**/api/**", "**/routes/**", "**/services/**", "**/middleware/**", "**/controllers/**", "**/auth/**"] },
{ name: "frontend-engineer", domain: "frontend", frameworks: ["react", "next.js"], constraints: ["component-first", "server-components", "minimal-client-js"], territory: ["**/components/**", "**/pages/**", "**/hooks/**", "**/styles/**", "**/*.tsx", "**/*.css", "**/*.vue"] },
],
},
};
+1 -1
View File
@@ -33,7 +33,7 @@ export interface Escalation {
resolution: EscalationResolution;
resolved_at?: string;
resolution_detail?: string;
audit_file: string;
commit_hash: string;
}
export interface EscalationResult {

Some files were not shown because too many files have changed in this diff Show More