cilydian 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cilydian-0.1.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sleepti3ht
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: cilydian
3
+ Version: 0.1.3
4
+ Summary: Self-hosted agentic CI pipeline with spec-driven codegen
5
+ Author-email: sleepti3ht <sleepti3ht@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sleepti3ht/CiLydian
8
+ Project-URL: Repository, https://github.com/sleepti3ht/CiLydian
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: langgraph>=1.0
13
+ Requires-Dist: langchain-ollama>=1.0
14
+ Requires-Dist: langchain-openai>=1.0
15
+ Dynamic: license-file
16
+
17
+ <div align="center">
18
+
19
+ # CiLydian
20
+
21
+ [![python](https://img.shields.io/badge/Python-3.12%2B-fdf6e3?style=flat&logo=python&logoColor=657b83)](https://python.org)
22
+ [![langgraph](https://img.shields.io/badge/LangGraph-1.0%2B-b58900?style=flat)](https://langchain-ai.github.io/langgraph/)
23
+ [![status](https://img.shields.io/badge/status-active-859900?style=flat)](https://github.com)
24
+ [![license](https://img.shields.io/badge/license-MIT-fdf6e3?style=flat)](LICENSE)
25
+
26
+ </div>
27
+
28
+ > Self-hosted agentic CI pipeline on LangGraph: **spec → coder → LLM judge (advisory) → deterministic gates (ruff + pytest)**, with a bounded repair loop and human escalation. Models are pluggable per role — local Ollama or any OpenAI-compatible endpoint.
29
+
30
+ ---
31
+
32
+ ## Why CiLydian?
33
+
34
+ Most multi-agent coding tools stop at "generate and hope." CiLydian treats LLM output as untrusted input: mechanical gates (ruff, pytest) are the source of truth, the LLM judge is advisory feedback, and the loop is bounded. If the pipeline can't converge in `MAX_ITER`, it exits with a status code and the full log — never hangs, never burns tokens indefinitely.
35
+
36
+ Compared to AutoGen Studio or CrewAI: those are chat-orchestration frameworks. CiLydian is a **CI primitive** — it runs, exits with a status code, and can be wired into git hooks, GitHub Actions, or cron.
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # Clone and setup
44
+ git clone https://github.com/sleepti3ht/cilydian.git
45
+ cd cilydian
46
+
47
+ # Unix / macOS
48
+ python -m venv .venv && source .venv/bin/activate
49
+ # Windows (PowerShell)
50
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
51
+
52
+ pip install -r requirements.txt
53
+
54
+ # Run with local Ollama (default)
55
+ python pipeline.py "Your task description"
56
+
57
+ # Or use cloud profile (free tiers)
58
+ cp config.cloud.toml.example config.toml
59
+ cp .env.example .env # fill with OpenRouter/Groq keys
60
+ python pipeline.py "Your task description"
61
+ ```
62
+
63
+ Stack: `langgraph`, `langchain-ollama`, `langchain-openai`, `ruff`, `pytest`. See `requirements.txt`.
64
+
65
+ ---
66
+
67
+ ## Features
68
+
69
+ ### Core
70
+ - **Spec-driven generation**: turn one-line ideas into a `SPEC.md` and a `test_candidate.py` skeleton before any code is written
71
+ - **Bounded repair loop**: coder iterates on judge feedback up to `MAX_ITER` times, then escalates to a human
72
+ - **Advisory LLM judge**: semantic review that never blocks on its own verdict (see [Design notes](#design-notes))
73
+ - **Deterministic gates**: `ruff` (auto-fix + check) and `pytest`; these are what actually block the pipeline
74
+ - **Short-circuit**: pytest is skipped if ruff fails, saves 10–20s per failed cycle
75
+ - **Config-driven models**: mix Ollama local and OpenAI-compatible cloud per role
76
+ - **Rate-limit resilience**: exponential backoff for 429s on free tiers
77
+ - **UTF-8 on Windows**: subprocess encoding prevents `cp1251` mojibake in logs
78
+
79
+ ### Reliability
80
+ - **Defensive JSON parsing**: judge verdicts normalized via `try: int(float(raw))`
81
+ - **Programmatic guardrail**: `score < 4` with `verdict="pass"` is auto-downgraded to `fail` (see [Design notes](#design-notes))
82
+ - **Markdown fence stripping**: handles LLM code wrapped in \`\`\` fences
83
+ - **Log rotation**: 5 MB cap with atomic `os.replace()`; single writer, no race
84
+ - **Isolated gates**: `logs/ruff.toml` and `logs/pytest.ini` are auto-created on first run to prevent parent-config bleed
85
+ - **Preflight checks**: verifies `ruff` / `pytest` are on PATH and the coder endpoint is reachable before spending a single token
86
+
87
+ ---
88
+
89
+ ## Design notes
90
+
91
+ **The judge is advisory, the guardrails are not.** Local 7B–14B judges drift beyond SPEC — they request absent features, over- or under-rate code, and sometimes contradict themselves. So the LLM's verdict alone never blocks the pipeline. A separate programmatic rule handles one specific pathological case: if the LLM outputs `verdict="pass"` while scoring below 4, the guardrail downgrades it to `fail`. That's not the judge blocking — it's a deterministic check that doesn't trust a self-contradicting model. Mechanical gates (ruff, pytest) remain the ultimate source of truth.
92
+
93
+ **The repair loop is bounded on purpose.** `MAX_ITER` (default 3) caps attempts. Exhaustion means human escalation, not a hang. On complex tasks this may give up earlier than a human reviewer would prefer — acceptable for CI, where the cost of a false-negative is a re-run, not a lost afternoon.
94
+
95
+ **Mechanical fixes belong to the tool.** `ruff --fix` runs before `ruff check` and auto-corrects imports and formatting. LLM tokens are too expensive for style fixes a Rust binary does in 40 milliseconds.
96
+
97
+ ---
98
+
99
+ ## Architecture
100
+
101
+ ```mermaid
102
+ flowchart LR
103
+ classDef solarizedBase3 fill:#fdf6e3,stroke:#93a1a1,color:#657b83,stroke-width:1px
104
+ classDef solarizedBase02 fill:#eee8d5,stroke:#93a1a1,color:#586e75,stroke-width:1px
105
+ classDef solarizedYellow fill:#b58900,stroke:#b58900,color:#fdf6e3,stroke-width:2px
106
+ classDef solarizedOrange fill:#cb4b16,stroke:#cb4b16,color:#fdf6e3,stroke-width:2px
107
+ classDef solarizedGreen fill:#859900,stroke:#859900,color:#fdf6e3,stroke-width:2px
108
+ classDef solarizedBlue fill:#268bd2,stroke:#268bd2,color:#fdf6e3,stroke-width:2px
109
+ classDef solarizedCyan fill:#2aa198,stroke:#2aa198,color:#fdf6e3,stroke-width:2px
110
+
111
+ A[idea]:::solarizedYellow
112
+ S[spec_writer<br>role: spec]:::solarizedBlue
113
+ C[coder<br>role: coder]:::solarizedGreen
114
+ J[judge<br>advisory]:::solarizedOrange
115
+ T[tester<br>ruff + pytest]:::solarizedCyan
116
+ E[final verdict<br>exit code]:::solarizedBase02
117
+
118
+ A --> S
119
+ S --> C
120
+ C --> J
121
+ J -->|advisory| T
122
+ T -->|fail and iter < max_iter| C
123
+ T -->|pass or budget exhausted| E
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Configuration
129
+
130
+ ### Local Ollama profile (default)
131
+
132
+ ```toml
133
+ # config.toml
134
+ [pipeline]
135
+ max_iter = 3
136
+ judge_blocking = false
137
+
138
+ [roles.spec]
139
+ provider = "ollama"
140
+ model = "qwen2.5-coder:7b"
141
+
142
+ [roles.coder]
143
+ provider = "ollama"
144
+ model = "qwen2.5-coder:7b"
145
+
146
+ [roles.judge]
147
+ provider = "ollama"
148
+ model = "qwen2.5:14b"
149
+ ```
150
+
151
+ ### Cloud hybrid profile (free tiers)
152
+
153
+ ```toml
154
+ # config.cloud.toml.example
155
+ [pipeline]
156
+ max_iter = 3
157
+ judge_blocking = false
158
+
159
+ [roles.spec]
160
+ provider = "openai_compat"
161
+ base_url = "https://openrouter.ai/api/v1"
162
+ model = "deepseek/deepseek-chat"
163
+ api_key_env = "OPENROUTER_API_KEY"
164
+
165
+ [roles.coder]
166
+ provider = "openai_compat"
167
+ base_url = "https://openrouter.ai/api/v1"
168
+ model = "deepseek/deepseek-chat"
169
+ api_key_env = "OPENROUTER_API_KEY"
170
+
171
+ [roles.judge]
172
+ provider = "openai_compat"
173
+ base_url = "https://api.groq.com/openai/v1"
174
+ model = "llama-3.3-70b-versatile"
175
+ api_key_env = "GROQ_API_KEY"
176
+ ```
177
+
178
+ ```bash
179
+ # .env
180
+ OPENROUTER_API_KEY=your_key_here
181
+ GROQ_API_KEY=your_key_here
182
+ ```
183
+
184
+ > ⚠️ Never commit `.env` — keep secrets private. Config references env var names, not values.
185
+
186
+ ---
187
+
188
+ ## Performance
189
+
190
+ | Metric | Local (7B coder) | Cloud (frontier) |
191
+ |---|---|---|
192
+ | Coder throughput | ~15 tok/s | ~50–100 tok/s |
193
+ | Judge throughput | ~3–6 tok/s (partial CPU offload) | ~30–60 tok/s |
194
+ | Full cycle wall time | 4–15 min | 1–3 min |
195
+ | Exit code | 0 = gates passed, 1 = failed | 0 = gates passed, 1 = failed |
196
+
197
+ **Typical run:**
198
+
199
+ ```log
200
+ [preflight] ruff: OK
201
+ [preflight] pytest: OK
202
+ [preflight] coder endpoint: reachable
203
+ [spec] Generated SPEC.md + test_candidate.py (1.2s)
204
+ [coder] Iteration 1/3 — wrote candidate.py (3.4s)
205
+ [judge] score=3, verdict=fail (advisory)
206
+ [tester] ruff rc=0, pytest rc=1 (fail)
207
+ [coder] Iteration 2/3 — fixed issues (2.8s)
208
+ [judge] score=4, verdict=pass (advisory)
209
+ [tester] ruff rc=0, pytest rc=0 (pass)
210
+ ✓ Pipeline completed: gates passed (exit code 0)
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Honest Limitations
216
+
217
+ - **Local models (7b-14b) fail convergence** on mid-complexity tasks (e.g., sliding-window rate limiter with exact boolean return) within 3 iterations.
218
+ - **Cloud free-tier models (Groq gpt-oss-120b)** provide better judge quality but have rate limits (200K tokens/day).
219
+ - **CiLydian is an experiment and portfolio artifact**, not a daily development tool. For production code, use manual multi-vendor workflow (DeepSeek/Qwen for prompting, Claude for architecture, Gemini for review, local ruff/pytest for gates).
220
+
221
+ ---
222
+
223
+ ## Screenshot
224
+
225
+ ![CiLydian Terminal](screenshots/terminal.png)
226
+
227
+ ---
228
+
229
+ ## Roadmap
230
+
231
+ - [ ] **GitHub Actions integration** — trigger CiLydian on issue labels
232
+ - [ ] **Multi-language support** — extend beyond Python (JavaScript, Go)
233
+ - [ ] **Persistent state** — resume interrupted runs from checkpoint
234
+ - [ ] **Web dashboard** — visualize pipeline runs and logs
235
+ - [ ] **Plugin system** — custom gates beyond ruff/pytest (mypy, bandit)
236
+
237
+ ---
238
+
239
+ ## Contributing
240
+
241
+ Contributions are welcome! Please open an issue or submit a PR.
242
+
243
+ ### Development setup
244
+
245
+ ```bash
246
+ git clone https://github.com/sleepti3ht/cilydian.git
247
+ cd cilydian
248
+
249
+ # Unix / macOS
250
+ python -m venv .venv && source .venv/bin/activate
251
+ # Windows (PowerShell)
252
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
253
+
254
+ pip install -r requirements.txt
255
+ pip install pre-commit
256
+ pre-commit install # enforces ruff + pytest on commit
257
+
258
+ # Run the pipeline on a sample task
259
+ python pipeline.py "Test task"
260
+ ```
261
+
262
+ ### Code style
263
+
264
+ - Commit messages: Conventional Commits (`feat(pipeline): ...`, `fix(tester): ...`)
265
+ - Run `ruff check` and `pytest` before pushing (enforced by pre-commit)
266
+
267
+ ---
268
+
269
+ ## License
270
+
271
+ MIT
@@ -0,0 +1,255 @@
1
+ <div align="center">
2
+
3
+ # CiLydian
4
+
5
+ [![python](https://img.shields.io/badge/Python-3.12%2B-fdf6e3?style=flat&logo=python&logoColor=657b83)](https://python.org)
6
+ [![langgraph](https://img.shields.io/badge/LangGraph-1.0%2B-b58900?style=flat)](https://langchain-ai.github.io/langgraph/)
7
+ [![status](https://img.shields.io/badge/status-active-859900?style=flat)](https://github.com)
8
+ [![license](https://img.shields.io/badge/license-MIT-fdf6e3?style=flat)](LICENSE)
9
+
10
+ </div>
11
+
12
+ > Self-hosted agentic CI pipeline on LangGraph: **spec → coder → LLM judge (advisory) → deterministic gates (ruff + pytest)**, with a bounded repair loop and human escalation. Models are pluggable per role — local Ollama or any OpenAI-compatible endpoint.
13
+
14
+ ---
15
+
16
+ ## Why CiLydian?
17
+
18
+ Most multi-agent coding tools stop at "generate and hope." CiLydian treats LLM output as untrusted input: mechanical gates (ruff, pytest) are the source of truth, the LLM judge is advisory feedback, and the loop is bounded. If the pipeline can't converge in `MAX_ITER`, it exits with a status code and the full log — never hangs, never burns tokens indefinitely.
19
+
20
+ Compared to AutoGen Studio or CrewAI: those are chat-orchestration frameworks. CiLydian is a **CI primitive** — it runs, exits with a status code, and can be wired into git hooks, GitHub Actions, or cron.
21
+
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ ```bash
27
+ # Clone and setup
28
+ git clone https://github.com/sleepti3ht/cilydian.git
29
+ cd cilydian
30
+
31
+ # Unix / macOS
32
+ python -m venv .venv && source .venv/bin/activate
33
+ # Windows (PowerShell)
34
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
35
+
36
+ pip install -r requirements.txt
37
+
38
+ # Run with local Ollama (default)
39
+ python pipeline.py "Your task description"
40
+
41
+ # Or use cloud profile (free tiers)
42
+ cp config.cloud.toml.example config.toml
43
+ cp .env.example .env # fill with OpenRouter/Groq keys
44
+ python pipeline.py "Your task description"
45
+ ```
46
+
47
+ Stack: `langgraph`, `langchain-ollama`, `langchain-openai`, `ruff`, `pytest`. See `requirements.txt`.
48
+
49
+ ---
50
+
51
+ ## Features
52
+
53
+ ### Core
54
+ - **Spec-driven generation**: turn one-line ideas into a `SPEC.md` and a `test_candidate.py` skeleton before any code is written
55
+ - **Bounded repair loop**: coder iterates on judge feedback up to `MAX_ITER` times, then escalates to a human
56
+ - **Advisory LLM judge**: semantic review that never blocks on its own verdict (see [Design notes](#design-notes))
57
+ - **Deterministic gates**: `ruff` (auto-fix + check) and `pytest`; these are what actually block the pipeline
58
+ - **Short-circuit**: pytest is skipped if ruff fails, saves 10–20s per failed cycle
59
+ - **Config-driven models**: mix Ollama local and OpenAI-compatible cloud per role
60
+ - **Rate-limit resilience**: exponential backoff for 429s on free tiers
61
+ - **UTF-8 on Windows**: subprocess encoding prevents `cp1251` mojibake in logs
62
+
63
+ ### Reliability
64
+ - **Defensive JSON parsing**: judge verdicts normalized via `try: int(float(raw))`
65
+ - **Programmatic guardrail**: `score < 4` with `verdict="pass"` is auto-downgraded to `fail` (see [Design notes](#design-notes))
66
+ - **Markdown fence stripping**: handles LLM code wrapped in \`\`\` fences
67
+ - **Log rotation**: 5 MB cap with atomic `os.replace()`; single writer, no race
68
+ - **Isolated gates**: `logs/ruff.toml` and `logs/pytest.ini` are auto-created on first run to prevent parent-config bleed
69
+ - **Preflight checks**: verifies `ruff` / `pytest` are on PATH and the coder endpoint is reachable before spending a single token
70
+
71
+ ---
72
+
73
+ ## Design notes
74
+
75
+ **The judge is advisory, the guardrails are not.** Local 7B–14B judges drift beyond SPEC — they request absent features, over- or under-rate code, and sometimes contradict themselves. So the LLM's verdict alone never blocks the pipeline. A separate programmatic rule handles one specific pathological case: if the LLM outputs `verdict="pass"` while scoring below 4, the guardrail downgrades it to `fail`. That's not the judge blocking — it's a deterministic check that doesn't trust a self-contradicting model. Mechanical gates (ruff, pytest) remain the ultimate source of truth.
76
+
77
+ **The repair loop is bounded on purpose.** `MAX_ITER` (default 3) caps attempts. Exhaustion means human escalation, not a hang. On complex tasks this may give up earlier than a human reviewer would prefer — acceptable for CI, where the cost of a false-negative is a re-run, not a lost afternoon.
78
+
79
+ **Mechanical fixes belong to the tool.** `ruff --fix` runs before `ruff check` and auto-corrects imports and formatting. LLM tokens are too expensive for style fixes a Rust binary does in 40 milliseconds.
80
+
81
+ ---
82
+
83
+ ## Architecture
84
+
85
+ ```mermaid
86
+ flowchart LR
87
+ classDef solarizedBase3 fill:#fdf6e3,stroke:#93a1a1,color:#657b83,stroke-width:1px
88
+ classDef solarizedBase02 fill:#eee8d5,stroke:#93a1a1,color:#586e75,stroke-width:1px
89
+ classDef solarizedYellow fill:#b58900,stroke:#b58900,color:#fdf6e3,stroke-width:2px
90
+ classDef solarizedOrange fill:#cb4b16,stroke:#cb4b16,color:#fdf6e3,stroke-width:2px
91
+ classDef solarizedGreen fill:#859900,stroke:#859900,color:#fdf6e3,stroke-width:2px
92
+ classDef solarizedBlue fill:#268bd2,stroke:#268bd2,color:#fdf6e3,stroke-width:2px
93
+ classDef solarizedCyan fill:#2aa198,stroke:#2aa198,color:#fdf6e3,stroke-width:2px
94
+
95
+ A[idea]:::solarizedYellow
96
+ S[spec_writer<br>role: spec]:::solarizedBlue
97
+ C[coder<br>role: coder]:::solarizedGreen
98
+ J[judge<br>advisory]:::solarizedOrange
99
+ T[tester<br>ruff + pytest]:::solarizedCyan
100
+ E[final verdict<br>exit code]:::solarizedBase02
101
+
102
+ A --> S
103
+ S --> C
104
+ C --> J
105
+ J -->|advisory| T
106
+ T -->|fail and iter < max_iter| C
107
+ T -->|pass or budget exhausted| E
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Configuration
113
+
114
+ ### Local Ollama profile (default)
115
+
116
+ ```toml
117
+ # config.toml
118
+ [pipeline]
119
+ max_iter = 3
120
+ judge_blocking = false
121
+
122
+ [roles.spec]
123
+ provider = "ollama"
124
+ model = "qwen2.5-coder:7b"
125
+
126
+ [roles.coder]
127
+ provider = "ollama"
128
+ model = "qwen2.5-coder:7b"
129
+
130
+ [roles.judge]
131
+ provider = "ollama"
132
+ model = "qwen2.5:14b"
133
+ ```
134
+
135
+ ### Cloud hybrid profile (free tiers)
136
+
137
+ ```toml
138
+ # config.cloud.toml.example
139
+ [pipeline]
140
+ max_iter = 3
141
+ judge_blocking = false
142
+
143
+ [roles.spec]
144
+ provider = "openai_compat"
145
+ base_url = "https://openrouter.ai/api/v1"
146
+ model = "deepseek/deepseek-chat"
147
+ api_key_env = "OPENROUTER_API_KEY"
148
+
149
+ [roles.coder]
150
+ provider = "openai_compat"
151
+ base_url = "https://openrouter.ai/api/v1"
152
+ model = "deepseek/deepseek-chat"
153
+ api_key_env = "OPENROUTER_API_KEY"
154
+
155
+ [roles.judge]
156
+ provider = "openai_compat"
157
+ base_url = "https://api.groq.com/openai/v1"
158
+ model = "llama-3.3-70b-versatile"
159
+ api_key_env = "GROQ_API_KEY"
160
+ ```
161
+
162
+ ```bash
163
+ # .env
164
+ OPENROUTER_API_KEY=your_key_here
165
+ GROQ_API_KEY=your_key_here
166
+ ```
167
+
168
+ > ⚠️ Never commit `.env` — keep secrets private. Config references env var names, not values.
169
+
170
+ ---
171
+
172
+ ## Performance
173
+
174
+ | Metric | Local (7B coder) | Cloud (frontier) |
175
+ |---|---|---|
176
+ | Coder throughput | ~15 tok/s | ~50–100 tok/s |
177
+ | Judge throughput | ~3–6 tok/s (partial CPU offload) | ~30–60 tok/s |
178
+ | Full cycle wall time | 4–15 min | 1–3 min |
179
+ | Exit code | 0 = gates passed, 1 = failed | 0 = gates passed, 1 = failed |
180
+
181
+ **Typical run:**
182
+
183
+ ```log
184
+ [preflight] ruff: OK
185
+ [preflight] pytest: OK
186
+ [preflight] coder endpoint: reachable
187
+ [spec] Generated SPEC.md + test_candidate.py (1.2s)
188
+ [coder] Iteration 1/3 — wrote candidate.py (3.4s)
189
+ [judge] score=3, verdict=fail (advisory)
190
+ [tester] ruff rc=0, pytest rc=1 (fail)
191
+ [coder] Iteration 2/3 — fixed issues (2.8s)
192
+ [judge] score=4, verdict=pass (advisory)
193
+ [tester] ruff rc=0, pytest rc=0 (pass)
194
+ ✓ Pipeline completed: gates passed (exit code 0)
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Honest Limitations
200
+
201
+ - **Local models (7b-14b) fail convergence** on mid-complexity tasks (e.g., sliding-window rate limiter with exact boolean return) within 3 iterations.
202
+ - **Cloud free-tier models (Groq gpt-oss-120b)** provide better judge quality but have rate limits (200K tokens/day).
203
+ - **CiLydian is an experiment and portfolio artifact**, not a daily development tool. For production code, use manual multi-vendor workflow (DeepSeek/Qwen for prompting, Claude for architecture, Gemini for review, local ruff/pytest for gates).
204
+
205
+ ---
206
+
207
+ ## Screenshot
208
+
209
+ ![CiLydian Terminal](screenshots/terminal.png)
210
+
211
+ ---
212
+
213
+ ## Roadmap
214
+
215
+ - [ ] **GitHub Actions integration** — trigger CiLydian on issue labels
216
+ - [ ] **Multi-language support** — extend beyond Python (JavaScript, Go)
217
+ - [ ] **Persistent state** — resume interrupted runs from checkpoint
218
+ - [ ] **Web dashboard** — visualize pipeline runs and logs
219
+ - [ ] **Plugin system** — custom gates beyond ruff/pytest (mypy, bandit)
220
+
221
+ ---
222
+
223
+ ## Contributing
224
+
225
+ Contributions are welcome! Please open an issue or submit a PR.
226
+
227
+ ### Development setup
228
+
229
+ ```bash
230
+ git clone https://github.com/sleepti3ht/cilydian.git
231
+ cd cilydian
232
+
233
+ # Unix / macOS
234
+ python -m venv .venv && source .venv/bin/activate
235
+ # Windows (PowerShell)
236
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
237
+
238
+ pip install -r requirements.txt
239
+ pip install pre-commit
240
+ pre-commit install # enforces ruff + pytest on commit
241
+
242
+ # Run the pipeline on a sample task
243
+ python pipeline.py "Test task"
244
+ ```
245
+
246
+ ### Code style
247
+
248
+ - Commit messages: Conventional Commits (`feat(pipeline): ...`, `fix(tester): ...`)
249
+ - Run `ruff check` and `pytest` before pushing (enforced by pre-commit)
250
+
251
+ ---
252
+
253
+ ## License
254
+
255
+ MIT
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: cilydian
3
+ Version: 0.1.3
4
+ Summary: Self-hosted agentic CI pipeline with spec-driven codegen
5
+ Author-email: sleepti3ht <sleepti3ht@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sleepti3ht/CiLydian
8
+ Project-URL: Repository, https://github.com/sleepti3ht/CiLydian
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: langgraph>=1.0
13
+ Requires-Dist: langchain-ollama>=1.0
14
+ Requires-Dist: langchain-openai>=1.0
15
+ Dynamic: license-file
16
+
17
+ <div align="center">
18
+
19
+ # CiLydian
20
+
21
+ [![python](https://img.shields.io/badge/Python-3.12%2B-fdf6e3?style=flat&logo=python&logoColor=657b83)](https://python.org)
22
+ [![langgraph](https://img.shields.io/badge/LangGraph-1.0%2B-b58900?style=flat)](https://langchain-ai.github.io/langgraph/)
23
+ [![status](https://img.shields.io/badge/status-active-859900?style=flat)](https://github.com)
24
+ [![license](https://img.shields.io/badge/license-MIT-fdf6e3?style=flat)](LICENSE)
25
+
26
+ </div>
27
+
28
+ > Self-hosted agentic CI pipeline on LangGraph: **spec → coder → LLM judge (advisory) → deterministic gates (ruff + pytest)**, with a bounded repair loop and human escalation. Models are pluggable per role — local Ollama or any OpenAI-compatible endpoint.
29
+
30
+ ---
31
+
32
+ ## Why CiLydian?
33
+
34
+ Most multi-agent coding tools stop at "generate and hope." CiLydian treats LLM output as untrusted input: mechanical gates (ruff, pytest) are the source of truth, the LLM judge is advisory feedback, and the loop is bounded. If the pipeline can't converge in `MAX_ITER`, it exits with a status code and the full log — never hangs, never burns tokens indefinitely.
35
+
36
+ Compared to AutoGen Studio or CrewAI: those are chat-orchestration frameworks. CiLydian is a **CI primitive** — it runs, exits with a status code, and can be wired into git hooks, GitHub Actions, or cron.
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # Clone and setup
44
+ git clone https://github.com/sleepti3ht/cilydian.git
45
+ cd cilydian
46
+
47
+ # Unix / macOS
48
+ python -m venv .venv && source .venv/bin/activate
49
+ # Windows (PowerShell)
50
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
51
+
52
+ pip install -r requirements.txt
53
+
54
+ # Run with local Ollama (default)
55
+ python pipeline.py "Your task description"
56
+
57
+ # Or use cloud profile (free tiers)
58
+ cp config.cloud.toml.example config.toml
59
+ cp .env.example .env # fill with OpenRouter/Groq keys
60
+ python pipeline.py "Your task description"
61
+ ```
62
+
63
+ Stack: `langgraph`, `langchain-ollama`, `langchain-openai`, `ruff`, `pytest`. See `requirements.txt`.
64
+
65
+ ---
66
+
67
+ ## Features
68
+
69
+ ### Core
70
+ - **Spec-driven generation**: turn one-line ideas into a `SPEC.md` and a `test_candidate.py` skeleton before any code is written
71
+ - **Bounded repair loop**: coder iterates on judge feedback up to `MAX_ITER` times, then escalates to a human
72
+ - **Advisory LLM judge**: semantic review that never blocks on its own verdict (see [Design notes](#design-notes))
73
+ - **Deterministic gates**: `ruff` (auto-fix + check) and `pytest`; these are what actually block the pipeline
74
+ - **Short-circuit**: pytest is skipped if ruff fails, saves 10–20s per failed cycle
75
+ - **Config-driven models**: mix Ollama local and OpenAI-compatible cloud per role
76
+ - **Rate-limit resilience**: exponential backoff for 429s on free tiers
77
+ - **UTF-8 on Windows**: subprocess encoding prevents `cp1251` mojibake in logs
78
+
79
+ ### Reliability
80
+ - **Defensive JSON parsing**: judge verdicts normalized via `try: int(float(raw))`
81
+ - **Programmatic guardrail**: `score < 4` with `verdict="pass"` is auto-downgraded to `fail` (see [Design notes](#design-notes))
82
+ - **Markdown fence stripping**: handles LLM code wrapped in \`\`\` fences
83
+ - **Log rotation**: 5 MB cap with atomic `os.replace()`; single writer, no race
84
+ - **Isolated gates**: `logs/ruff.toml` and `logs/pytest.ini` are auto-created on first run to prevent parent-config bleed
85
+ - **Preflight checks**: verifies `ruff` / `pytest` are on PATH and the coder endpoint is reachable before spending a single token
86
+
87
+ ---
88
+
89
+ ## Design notes
90
+
91
+ **The judge is advisory, the guardrails are not.** Local 7B–14B judges drift beyond SPEC — they request absent features, over- or under-rate code, and sometimes contradict themselves. So the LLM's verdict alone never blocks the pipeline. A separate programmatic rule handles one specific pathological case: if the LLM outputs `verdict="pass"` while scoring below 4, the guardrail downgrades it to `fail`. That's not the judge blocking — it's a deterministic check that doesn't trust a self-contradicting model. Mechanical gates (ruff, pytest) remain the ultimate source of truth.
92
+
93
+ **The repair loop is bounded on purpose.** `MAX_ITER` (default 3) caps attempts. Exhaustion means human escalation, not a hang. On complex tasks this may give up earlier than a human reviewer would prefer — acceptable for CI, where the cost of a false-negative is a re-run, not a lost afternoon.
94
+
95
+ **Mechanical fixes belong to the tool.** `ruff --fix` runs before `ruff check` and auto-corrects imports and formatting. LLM tokens are too expensive for style fixes a Rust binary does in 40 milliseconds.
96
+
97
+ ---
98
+
99
+ ## Architecture
100
+
101
+ ```mermaid
102
+ flowchart LR
103
+ classDef solarizedBase3 fill:#fdf6e3,stroke:#93a1a1,color:#657b83,stroke-width:1px
104
+ classDef solarizedBase02 fill:#eee8d5,stroke:#93a1a1,color:#586e75,stroke-width:1px
105
+ classDef solarizedYellow fill:#b58900,stroke:#b58900,color:#fdf6e3,stroke-width:2px
106
+ classDef solarizedOrange fill:#cb4b16,stroke:#cb4b16,color:#fdf6e3,stroke-width:2px
107
+ classDef solarizedGreen fill:#859900,stroke:#859900,color:#fdf6e3,stroke-width:2px
108
+ classDef solarizedBlue fill:#268bd2,stroke:#268bd2,color:#fdf6e3,stroke-width:2px
109
+ classDef solarizedCyan fill:#2aa198,stroke:#2aa198,color:#fdf6e3,stroke-width:2px
110
+
111
+ A[idea]:::solarizedYellow
112
+ S[spec_writer<br>role: spec]:::solarizedBlue
113
+ C[coder<br>role: coder]:::solarizedGreen
114
+ J[judge<br>advisory]:::solarizedOrange
115
+ T[tester<br>ruff + pytest]:::solarizedCyan
116
+ E[final verdict<br>exit code]:::solarizedBase02
117
+
118
+ A --> S
119
+ S --> C
120
+ C --> J
121
+ J -->|advisory| T
122
+ T -->|fail and iter < max_iter| C
123
+ T -->|pass or budget exhausted| E
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Configuration
129
+
130
+ ### Local Ollama profile (default)
131
+
132
+ ```toml
133
+ # config.toml
134
+ [pipeline]
135
+ max_iter = 3
136
+ judge_blocking = false
137
+
138
+ [roles.spec]
139
+ provider = "ollama"
140
+ model = "qwen2.5-coder:7b"
141
+
142
+ [roles.coder]
143
+ provider = "ollama"
144
+ model = "qwen2.5-coder:7b"
145
+
146
+ [roles.judge]
147
+ provider = "ollama"
148
+ model = "qwen2.5:14b"
149
+ ```
150
+
151
+ ### Cloud hybrid profile (free tiers)
152
+
153
+ ```toml
154
+ # config.cloud.toml.example
155
+ [pipeline]
156
+ max_iter = 3
157
+ judge_blocking = false
158
+
159
+ [roles.spec]
160
+ provider = "openai_compat"
161
+ base_url = "https://openrouter.ai/api/v1"
162
+ model = "deepseek/deepseek-chat"
163
+ api_key_env = "OPENROUTER_API_KEY"
164
+
165
+ [roles.coder]
166
+ provider = "openai_compat"
167
+ base_url = "https://openrouter.ai/api/v1"
168
+ model = "deepseek/deepseek-chat"
169
+ api_key_env = "OPENROUTER_API_KEY"
170
+
171
+ [roles.judge]
172
+ provider = "openai_compat"
173
+ base_url = "https://api.groq.com/openai/v1"
174
+ model = "llama-3.3-70b-versatile"
175
+ api_key_env = "GROQ_API_KEY"
176
+ ```
177
+
178
+ ```bash
179
+ # .env
180
+ OPENROUTER_API_KEY=your_key_here
181
+ GROQ_API_KEY=your_key_here
182
+ ```
183
+
184
+ > ⚠️ Never commit `.env` — keep secrets private. Config references env var names, not values.
185
+
186
+ ---
187
+
188
+ ## Performance
189
+
190
+ | Metric | Local (7B coder) | Cloud (frontier) |
191
+ |---|---|---|
192
+ | Coder throughput | ~15 tok/s | ~50–100 tok/s |
193
+ | Judge throughput | ~3–6 tok/s (partial CPU offload) | ~30–60 tok/s |
194
+ | Full cycle wall time | 4–15 min | 1–3 min |
195
+ | Exit code | 0 = gates passed, 1 = failed | 0 = gates passed, 1 = failed |
196
+
197
+ **Typical run:**
198
+
199
+ ```log
200
+ [preflight] ruff: OK
201
+ [preflight] pytest: OK
202
+ [preflight] coder endpoint: reachable
203
+ [spec] Generated SPEC.md + test_candidate.py (1.2s)
204
+ [coder] Iteration 1/3 — wrote candidate.py (3.4s)
205
+ [judge] score=3, verdict=fail (advisory)
206
+ [tester] ruff rc=0, pytest rc=1 (fail)
207
+ [coder] Iteration 2/3 — fixed issues (2.8s)
208
+ [judge] score=4, verdict=pass (advisory)
209
+ [tester] ruff rc=0, pytest rc=0 (pass)
210
+ ✓ Pipeline completed: gates passed (exit code 0)
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Honest Limitations
216
+
217
+ - **Local models (7b-14b) fail convergence** on mid-complexity tasks (e.g., sliding-window rate limiter with exact boolean return) within 3 iterations.
218
+ - **Cloud free-tier models (Groq gpt-oss-120b)** provide better judge quality but have rate limits (200K tokens/day).
219
+ - **CiLydian is an experiment and portfolio artifact**, not a daily development tool. For production code, use manual multi-vendor workflow (DeepSeek/Qwen for prompting, Claude for architecture, Gemini for review, local ruff/pytest for gates).
220
+
221
+ ---
222
+
223
+ ## Screenshot
224
+
225
+ ![CiLydian Terminal](screenshots/terminal.png)
226
+
227
+ ---
228
+
229
+ ## Roadmap
230
+
231
+ - [ ] **GitHub Actions integration** — trigger CiLydian on issue labels
232
+ - [ ] **Multi-language support** — extend beyond Python (JavaScript, Go)
233
+ - [ ] **Persistent state** — resume interrupted runs from checkpoint
234
+ - [ ] **Web dashboard** — visualize pipeline runs and logs
235
+ - [ ] **Plugin system** — custom gates beyond ruff/pytest (mypy, bandit)
236
+
237
+ ---
238
+
239
+ ## Contributing
240
+
241
+ Contributions are welcome! Please open an issue or submit a PR.
242
+
243
+ ### Development setup
244
+
245
+ ```bash
246
+ git clone https://github.com/sleepti3ht/cilydian.git
247
+ cd cilydian
248
+
249
+ # Unix / macOS
250
+ python -m venv .venv && source .venv/bin/activate
251
+ # Windows (PowerShell)
252
+ python -m venv .venv; .\.venv\Scripts\Activate.ps1
253
+
254
+ pip install -r requirements.txt
255
+ pip install pre-commit
256
+ pre-commit install # enforces ruff + pytest on commit
257
+
258
+ # Run the pipeline on a sample task
259
+ python pipeline.py "Test task"
260
+ ```
261
+
262
+ ### Code style
263
+
264
+ - Commit messages: Conventional Commits (`feat(pipeline): ...`, `fix(tester): ...`)
265
+ - Run `ruff check` and `pytest` before pushing (enforced by pre-commit)
266
+
267
+ ---
268
+
269
+ ## License
270
+
271
+ MIT
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pipeline.py
4
+ pyproject.toml
5
+ cilydian.egg-info/PKG-INFO
6
+ cilydian.egg-info/SOURCES.txt
7
+ cilydian.egg-info/dependency_links.txt
8
+ cilydian.egg-info/entry_points.txt
9
+ cilydian.egg-info/requires.txt
10
+ cilydian.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cilydian = pipeline:main
@@ -0,0 +1,3 @@
1
+ langgraph>=1.0
2
+ langchain-ollama>=1.0
3
+ langchain-openai>=1.0
@@ -0,0 +1 @@
1
+ pipeline
@@ -0,0 +1,379 @@
1
+ """CiLydian: self-hosted agentic CI pipeline.
2
+
3
+ Flow: spec -> coder -> LLM judge (advisory by default) -> deterministic gates
4
+ (ruff + pytest), with a bounded repair loop and human escalation.
5
+
6
+ Models are pluggable per role via config.toml: local Ollama or any
7
+ OpenAI-compatible endpoint (OpenRouter, Groq, Gemini compat, DeepSeek, vLLM).
8
+ API keys are never stored in config: roles reference environment variable names.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tomllib
20
+ from typing import Literal, TypedDict
21
+
22
+ from langchain_ollama import ChatOllama
23
+ from langchain_openai import ChatOpenAI
24
+ from langgraph.graph import END, StateGraph
25
+
26
+ LOG_DIR = "logs"
27
+ LOG_CAP = 5_000_000 # rotate a log once it exceeds this many bytes
28
+ DEFAULT_IDEA = "Rate limiter on asyncio.Semaphore for CVE scanner"
29
+ _FENCE_BLOCK = re.compile(r"```[\w-]*[ \t]*\n(.*?)\n[ \t]*```", re.DOTALL)
30
+
31
+ # Populated by init_models(); graph nodes read these globals at call time.
32
+ SPEC_LLM = None
33
+ CODER = None
34
+ JUDGE = None
35
+ MAX_ITER = 3
36
+ JUDGE_BLOCKING = False
37
+
38
+
39
+ def load_dotenv(path: str = ".env") -> None:
40
+ """Minimal .env loader; avoids a python-dotenv dependency."""
41
+ if not os.path.exists(path):
42
+ return
43
+ with open(path, encoding="utf-8") as f:
44
+ for line in f:
45
+ line = line.strip()
46
+ if not line or line.startswith("#") or "=" not in line:
47
+ continue
48
+ key, _, value = line.partition("=")
49
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
50
+
51
+
52
+ def build_llm(cfg: dict):
53
+ """Instantiate a chat model for one pipeline role.
54
+
55
+ provider = "ollama" -> local Ollama server
56
+ provider = "openai_compat" -> any OpenAI-compatible API (OpenRouter, Groq,
57
+ Gemini compat, DeepSeek, vLLM, ...)
58
+ """
59
+ provider = cfg.get("provider", "ollama")
60
+ if provider == "ollama":
61
+ return ChatOllama(
62
+ model=cfg["model"],
63
+ base_url=cfg.get("base_url", "http://localhost:11434"),
64
+ num_ctx=cfg.get("num_ctx", 4096),
65
+ )
66
+ if provider == "openai_compat":
67
+ key_env = cfg.get("api_key_env", "")
68
+ api_key = os.environ.get(key_env, "")
69
+ if not api_key:
70
+ raise SystemExit(
71
+ f"[config] role needs env var {key_env!r} but it is not set. "
72
+ f"Copy .env.example to .env and fill it in."
73
+ )
74
+ return ChatOpenAI(
75
+ model=cfg["model"],
76
+ base_url=cfg["base_url"],
77
+ api_key=api_key,
78
+ temperature=cfg.get("temperature", 0.2),
79
+ max_retries=3, # exponential backoff on 429 (free-tier rate limits)
80
+ )
81
+ raise SystemExit(f"[config] unknown provider: {provider!r}")
82
+
83
+
84
+ def load_config(path: str) -> dict:
85
+ try:
86
+ with open(path, "rb") as f:
87
+ return tomllib.load(f)
88
+ except FileNotFoundError:
89
+ raise SystemExit(f"[config] {path} not found; see config.toml in repo root")
90
+
91
+
92
+ def init_models(cfg: dict) -> None:
93
+ global SPEC_LLM, CODER, JUDGE
94
+ roles = cfg.get("roles", {})
95
+ for role in ("spec", "coder", "judge"):
96
+ if role not in roles:
97
+ raise SystemExit(f"[config] missing [roles.{role}] section")
98
+ SPEC_LLM = build_llm(roles["spec"])
99
+ CODER = build_llm(roles["coder"])
100
+ JUDGE = build_llm(roles["judge"])
101
+
102
+
103
+ def strip_fences(text: str) -> str:
104
+ """Extract the first fenced code block; models append prose after it."""
105
+ m = _FENCE_BLOCK.search(text)
106
+ if m:
107
+ return m.group(1)
108
+ t = text.strip()
109
+ if t.startswith("```"):
110
+ t = t.split("\n", 1)[1] if "\n" in t else t[3:]
111
+ if "```" in t: # closing fence with prose after it
112
+ t = t.split("```", 1)[0]
113
+ return t.rstrip()
114
+ return t
115
+
116
+
117
+ def log(agent: str, msg: str) -> None:
118
+ path = os.path.join(LOG_DIR, f"{agent}.log")
119
+ if os.path.exists(path) and os.path.getsize(path) > LOG_CAP:
120
+ os.replace(path, path + ".old") # atomic on Windows; single writer, no race
121
+ with open(path, "a", encoding="utf-8") as f:
122
+ f.write(msg + "\n")
123
+
124
+
125
+ def reset_logs() -> None:
126
+ """Truncate per run: live-tail panels stay alive, history = one cycle."""
127
+ os.makedirs(LOG_DIR, exist_ok=True)
128
+ for agent in ("spec", "coder", "judge", "tester"):
129
+ open(os.path.join(LOG_DIR, f"{agent}.log"), "w", encoding="utf-8").close()
130
+
131
+
132
+ def ensure_isolation() -> None:
133
+ """Stop ruff/pytest from inheriting the parent project's configs."""
134
+ os.makedirs(LOG_DIR, exist_ok=True) # ← Добавленная строка
135
+ if not os.path.exists(os.path.join(LOG_DIR, "ruff.toml")):
136
+ open(os.path.join(LOG_DIR, "ruff.toml"), "w", encoding="utf-8").close()
137
+ if not os.path.exists(os.path.join(LOG_DIR, "pytest.ini")):
138
+ with open(os.path.join(LOG_DIR, "pytest.ini"), "w", encoding="utf-8") as f:
139
+ f.write("[pytest]\nasyncio_mode = auto\n")
140
+
141
+
142
+ def run(cmd: list[str], cwd: str) -> subprocess.CompletedProcess:
143
+ """Run a gate tool; force UTF-8 decoding (Windows locales default to cp1251)."""
144
+ return subprocess.run(
145
+ cmd,
146
+ check=False,
147
+ capture_output=True,
148
+ text=True,
149
+ encoding="utf-8",
150
+ errors="replace",
151
+ cwd=cwd,
152
+ )
153
+
154
+
155
+ def preflight() -> None:
156
+ for tool in ("ruff", "pytest"):
157
+ if shutil.which(tool) is None:
158
+ raise SystemExit(
159
+ f"[preflight] {tool} not found on PATH (see requirements.txt)"
160
+ )
161
+ try:
162
+ CODER.invoke("ping")
163
+ except Exception as exc:
164
+ raise SystemExit(f"[preflight] coder endpoint unreachable: {exc}") from exc
165
+
166
+
167
+ class State(TypedDict):
168
+ idea: str
169
+ spec: str
170
+ code: str
171
+ verdict: dict
172
+ iteration: int
173
+
174
+
175
+ def spec_writer(s: State):
176
+ """Stage 0: turn a one-line idea into a SPEC plus a pytest skeleton."""
177
+ spec = SPEC_LLM.invoke(
178
+ "Turn this idea into a MINIMAL SPEC. Include ONLY what the idea states.\n"
179
+ "Sections: Role; Interface (exact PYTHON class/method signatures the "
180
+ "implementation MUST expose - never Java/Go/other languages); Constraints; "
181
+ "Measurable Acceptance Criteria (max 5, unit-testable); Test Plan (max 5 "
182
+ "tests, 1:1 mapping to criteria).\n"
183
+ "If the task involves time, windows, or delays: the Interface MUST include "
184
+ "an injectable clock parameter (e.g. clock: Callable[[], float] = "
185
+ "time.monotonic), and the Test Plan MUST drive time through a fake clock. "
186
+ "Real sleeps and wall-clock measurements are FORBIDDEN in tests.\n"
187
+ "FORBIDDEN unless the idea explicitly requests them: security compliance, "
188
+ "abuse prevention, authentication, monitoring, logging, UAT, post-release "
189
+ "monitoring, performance measurement, config files, documentation.\n"
190
+ f"Idea: {s['idea']}"
191
+ ).content
192
+ tests = CODER.invoke(
193
+ f"Generate a pytest file for this spec:\n{spec}\n"
194
+ "Write only test functions, no implementation. "
195
+ "Import the module under test as `from candidate import ...`, "
196
+ "NOT as `from logs.candidate import ...`. "
197
+ "Output raw python code only, no markdown fences."
198
+ "Tests must call ONLY the class and methods listed in the SPEC Interface "
199
+ "section, with exact names and signatures. No invented API.\n"
200
+ "Tests must NEVER call time.sleep or measure wall-clock time. If the SPEC "
201
+ "defines an injectable clock, construct a fake clock and advance it "
202
+ "explicitly to simulate window expiry.\n"
203
+ ).content
204
+ with open(os.path.join(LOG_DIR, "test_candidate.py"), "w", encoding="utf-8") as f:
205
+ f.write(strip_fences(tests))
206
+ log("spec", spec)
207
+ return {"spec": spec}
208
+
209
+
210
+ def coder(s: State):
211
+ """Generate/repair the implementation; tests are the naming contract."""
212
+ feedback = s["verdict"].get("issues", [])
213
+ prompt = f"SPEC:\n{s['spec']}\n"
214
+ try:
215
+ with open(os.path.join(LOG_DIR, "test_candidate.py"), encoding="utf-8") as f:
216
+ prompt += (
217
+ f"TESTS that must pass (match names/signatures exactly):\n{f.read()}\n"
218
+ )
219
+ except OSError:
220
+ pass
221
+ prompt += (
222
+ f"Issues to fix: {feedback}\n"
223
+ "Write only the Python implementation for module `candidate`. "
224
+ "Raw code only, no markdown fences, no tests. "
225
+ "NO explanations, NO headers, NO prose after the code."
226
+ )
227
+ code = strip_fences(CODER.invoke(prompt).content)
228
+ log("coder", code)
229
+ return {"code": code, "iteration": s["iteration"] + 1}
230
+
231
+
232
+ def judge(s: State):
233
+ """LLM review with defensive parsing: models ignore JSON schemas regularly."""
234
+ raw = JUDGE.invoke(
235
+ "Rate 1-5: correctness, security, simplicity. Judge ONLY against "
236
+ "the SPEC's measurable acceptance criteria; do not request features "
237
+ f"beyond the SPEC. Check against SPEC:\n{s['spec']}\nCODE:\n{s['code']}\n"
238
+ 'Return JSON only: {"score": int, "verdict": "pass"|"fail", "issues": [str]}\n\n'
239
+ 'CRITICAL: Output ONLY raw JSON. NO markdown fences.\n'
240
+ 'CORRECT: {"score": 4, "verdict": "pass", "issues": []}\n'
241
+ 'WRONG: ```json{"score": 4, "verdict": "pass", "issues": []}```'
242
+ ).content
243
+ a, b = raw.find("{"), raw.rfind("}")
244
+ try:
245
+ verdict = json.loads(raw[a : b + 1]) if a != -1 else {}
246
+ except json.JSONDecodeError:
247
+ verdict = {}
248
+ if not isinstance(verdict, dict):
249
+ verdict = {}
250
+ verdict.setdefault("issues", [])
251
+ raw_score = verdict.get("score", 5)
252
+ try:
253
+ score = int(float(raw_score))
254
+ except (TypeError, ValueError):
255
+ score = 0
256
+ verdict["issues"].append(f"Unparseable score: {raw_score!r}")
257
+ verdict["verdict"] = str(verdict.get("verdict", "fail")).lower()
258
+ if verdict["verdict"] not in ("pass", "fail"):
259
+ verdict["verdict"] = "fail"
260
+ if score < 4 and verdict["verdict"] == "pass":
261
+ verdict["verdict"] = "fail"
262
+ verdict["issues"].append(f"Auto-downgraded: score={score} contradicts 'pass'.")
263
+ verdict["gate"] = "judge"
264
+ log("judge", json.dumps(verdict, ensure_ascii=False))
265
+ return {"verdict": verdict}
266
+
267
+
268
+ def tester(s: State):
269
+ """Deterministic gates: ruff (auto-fix then check) and pytest. These block."""
270
+ candidate = os.path.join(LOG_DIR, "candidate.py")
271
+ with open(candidate, "w", encoding="utf-8") as f:
272
+ f.write(s["code"])
273
+ with open(os.path.join(LOG_DIR, "conftest.py"), "w", encoding="utf-8") as f:
274
+ f.write("import sys, os\nsys.path.insert(0, os.path.dirname(__file__))\n")
275
+ run(["ruff", "check", "--fix", "candidate.py"], LOG_DIR)
276
+ with open(candidate, encoding="utf-8") as f:
277
+ fixed = f.read() # State must match the artifact on disk after --fix
278
+ ruff = run(["ruff", "check", "candidate.py"], LOG_DIR)
279
+ if ruff.returncode != 0:
280
+ # Short-circuit: no point running pytest on code that fails lint/syntax.
281
+ log("tester", f"ruff rc={ruff.returncode}\n{ruff.stdout[:500]}")
282
+ return {
283
+ "code": fixed,
284
+ "verdict": {
285
+ **s["verdict"],
286
+ "gate": "ruff+pytest",
287
+ "verdict": "fail",
288
+ "issues": s["verdict"].get("issues", [])
289
+ + [f"ruff:{ruff.stdout[:300]}"],
290
+ },
291
+ }
292
+ pytest_run = run(["pytest", "test_candidate.py", "-q", "--tb=short"], LOG_DIR)
293
+ ok = pytest_run.returncode == 0
294
+ log(
295
+ "tester",
296
+ f"ruff rc=0\npytest rc={pytest_run.returncode}\n{pytest_run.stdout[-500:]}",
297
+ )
298
+ extra_issues = [] if ok else [f"pytest:{pytest_run.stdout[:300]}"]
299
+ return {
300
+ "code": fixed,
301
+ "verdict": {
302
+ **s["verdict"],
303
+ "gate": "ruff+pytest",
304
+ "verdict": "pass" if ok else "fail",
305
+ "issues": s["verdict"].get("issues", []) + extra_issues,
306
+ },
307
+ }
308
+
309
+
310
+ def route(s: State) -> Literal["coder", "tester"]:
311
+ """Judge is advisory by default; flip JUDGE_BLOCKING to make it a gate."""
312
+ if (
313
+ JUDGE_BLOCKING
314
+ and s["verdict"]["verdict"] == "fail"
315
+ and s["iteration"] < MAX_ITER
316
+ ):
317
+ return "coder"
318
+ return "tester"
319
+
320
+
321
+ def route_tester(s: State) -> Literal["coder", "end"]:
322
+ """Deterministic failures loop back to coder, bounded by MAX_ITER."""
323
+ if s["verdict"]["verdict"] == "fail" and s["iteration"] < MAX_ITER:
324
+ return "coder"
325
+ return "end"
326
+
327
+
328
+ g = StateGraph(State)
329
+ g.add_node("spec", spec_writer)
330
+ g.add_node("coder", coder)
331
+ g.add_node("judge", judge)
332
+ g.add_node("tester", tester)
333
+ g.set_entry_point("spec")
334
+ g.add_edge("spec", "coder")
335
+ g.add_edge("coder", "judge")
336
+ g.add_conditional_edges("judge", route, {"coder": "coder", "tester": "tester"})
337
+ g.add_conditional_edges("tester", route_tester, {"coder": "coder", "end": END})
338
+ app = g.compile()
339
+
340
+
341
+ def main() -> int:
342
+ global MAX_ITER, JUDGE_BLOCKING
343
+ parser = argparse.ArgumentParser(
344
+ description="Agentic CI pipeline: spec -> coder -> judge -> deterministic gates"
345
+ )
346
+ parser.add_argument(
347
+ "idea",
348
+ nargs="?",
349
+ default=DEFAULT_IDEA,
350
+ help="One-line task description (e.g., 'Async rate limiter with token bucket')",
351
+ )
352
+ parser.add_argument(
353
+ "--config",
354
+ default="config.toml",
355
+ help="path to role/model config (default: config.toml)",
356
+ )
357
+ args = parser.parse_args()
358
+
359
+ load_dotenv()
360
+ cfg = load_config(args.config)
361
+ init_models(cfg)
362
+ pipe = cfg.get("pipeline", {})
363
+ MAX_ITER = int(pipe.get("max_iter", 3))
364
+ JUDGE_BLOCKING = bool(pipe.get("judge_blocking", False))
365
+
366
+ preflight()
367
+ ensure_isolation()
368
+ reset_logs()
369
+ result = app.invoke(
370
+ {"idea": args.idea, "spec": "", "code": "", "verdict": {}, "iteration": 0}
371
+ )
372
+ print(json.dumps(result["verdict"], ensure_ascii=False, indent=2))
373
+ return (
374
+ 0 if result["verdict"].get("verdict") == "pass" else 1
375
+ ) # CI-friendly exit code
376
+
377
+
378
+ if __name__ == "__main__":
379
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cilydian"
7
+ version = "0.1.3"
8
+ description = "Self-hosted agentic CI pipeline with spec-driven codegen"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ authors = [
14
+ {name = "sleepti3ht", email = "sleepti3ht@gmail.com"}
15
+ ]
16
+ dependencies = [
17
+ "langgraph>=1.0",
18
+ "langchain-ollama>=1.0",
19
+ "langchain-openai>=1.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ cilydian = "pipeline:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/sleepti3ht/CiLydian"
27
+ Repository = "https://github.com/sleepti3ht/CiLydian"
28
+
29
+ [tool.setuptools]
30
+ py-modules = ["pipeline"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+