cilydian-studio 0.1.0__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.
@@ -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,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: cilydian-studio
3
+ Version: 0.1.0
4
+ Summary: Textual TUI client for CiLydian: human-in-the-loop agentic CI
5
+ Author-email: sleepti3ht <sleepti3ht@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sleepti3ht/cilydian-studio
8
+ Project-URL: Repository, https://github.com/sleepti3ht/cilydian-studio
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: cilydian>=0.2.1
13
+ Requires-Dist: textual>=0.80
14
+ Requires-Dist: langgraph-checkpoint-sqlite>=2.0
15
+ Dynamic: license-file
16
+
17
+ # cilydian-studio
18
+
19
+ Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install cilydian-studio
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
31
+ ```
32
+
33
+ The TUI will pause at interrupt points:
34
+ - **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
35
+ - **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
36
+
37
+ ## Commands
38
+
39
+ | Command | Action |
40
+ |---|---|
41
+ | `continue` | Proceed to next stage |
42
+ | `edit <text>` | Replace spec with `<text>` |
43
+ | `reject` | Discard spec, regenerate |
44
+ | `accept` | Accept failing gates (escalation only) |
45
+ | `retry <feedback>` | Send feedback to coder, retry (escalation only) |
46
+ | `abort` | Stop execution (escalation only) |
47
+
48
+ ## Architecture
49
+
50
+ Studio is a thin client over CiLydian core:
51
+ - **cilydian** (PyPI package): graph, gates, memory
52
+ - **cilydian-studio**: Textual TUI + interrupt/resume loop
53
+
54
+ Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
55
+
56
+ ## Requirements
57
+
58
+ - Python >= 3.12
59
+ - cilydian >= 0.2.0 (installed automatically)
60
+ - Local Ollama or cloud API keys (same as CiLydian)
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,48 @@
1
+ # cilydian-studio
2
+
3
+ Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install cilydian-studio
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
15
+ ```
16
+
17
+ The TUI will pause at interrupt points:
18
+ - **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
19
+ - **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
20
+
21
+ ## Commands
22
+
23
+ | Command | Action |
24
+ |---|---|
25
+ | `continue` | Proceed to next stage |
26
+ | `edit <text>` | Replace spec with `<text>` |
27
+ | `reject` | Discard spec, regenerate |
28
+ | `accept` | Accept failing gates (escalation only) |
29
+ | `retry <feedback>` | Send feedback to coder, retry (escalation only) |
30
+ | `abort` | Stop execution (escalation only) |
31
+
32
+ ## Architecture
33
+
34
+ Studio is a thin client over CiLydian core:
35
+ - **cilydian** (PyPI package): graph, gates, memory
36
+ - **cilydian-studio**: Textual TUI + interrupt/resume loop
37
+
38
+ Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
39
+
40
+ ## Requirements
41
+
42
+ - Python >= 3.12
43
+ - cilydian >= 0.2.0 (installed automatically)
44
+ - Local Ollama or cloud API keys (same as CiLydian)
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: cilydian-studio
3
+ Version: 0.1.0
4
+ Summary: Textual TUI client for CiLydian: human-in-the-loop agentic CI
5
+ Author-email: sleepti3ht <sleepti3ht@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sleepti3ht/cilydian-studio
8
+ Project-URL: Repository, https://github.com/sleepti3ht/cilydian-studio
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: cilydian>=0.2.1
13
+ Requires-Dist: textual>=0.80
14
+ Requires-Dist: langgraph-checkpoint-sqlite>=2.0
15
+ Dynamic: license-file
16
+
17
+ # cilydian-studio
18
+
19
+ Textual TUI client for [CiLydian](https://github.com/sleepti3ht/CiLydian): human-in-the-loop agentic CI pipeline.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install cilydian-studio
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ cilydian-studio "Sliding-window rate limiter: max 5 requests per 10s window"
31
+ ```
32
+
33
+ The TUI will pause at interrupt points:
34
+ - **After SPEC generation**: review spec, type `continue`, `edit <new spec>`, or `reject`
35
+ - **At escalation** (when bounded loop exhausted): type `accept`, `retry <feedback>`, or `abort`
36
+
37
+ ## Commands
38
+
39
+ | Command | Action |
40
+ |---|---|
41
+ | `continue` | Proceed to next stage |
42
+ | `edit <text>` | Replace spec with `<text>` |
43
+ | `reject` | Discard spec, regenerate |
44
+ | `accept` | Accept failing gates (escalation only) |
45
+ | `retry <feedback>` | Send feedback to coder, retry (escalation only) |
46
+ | `abort` | Stop execution (escalation only) |
47
+
48
+ ## Architecture
49
+
50
+ Studio is a thin client over CiLydian core:
51
+ - **cilydian** (PyPI package): graph, gates, memory
52
+ - **cilydian-studio**: Textual TUI + interrupt/resume loop
53
+
54
+ Interrupt points use LangGraph checkpointing: state persists in SQLite, any client can resume.
55
+
56
+ ## Requirements
57
+
58
+ - Python >= 3.12
59
+ - cilydian >= 0.2.0 (installed automatically)
60
+ - Local Ollama or cloud API keys (same as CiLydian)
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ cilydian_studio.egg-info/PKG-INFO
5
+ cilydian_studio.egg-info/SOURCES.txt
6
+ cilydian_studio.egg-info/dependency_links.txt
7
+ cilydian_studio.egg-info/entry_points.txt
8
+ cilydian_studio.egg-info/requires.txt
9
+ cilydian_studio.egg-info/top_level.txt
10
+ studio/__init__.py
11
+ studio/app.py
12
+ studio/session.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cilydian-studio = studio.app:main_entry
@@ -0,0 +1,3 @@
1
+ cilydian>=0.2.1
2
+ textual>=0.80
3
+ langgraph-checkpoint-sqlite>=2.0
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cilydian-studio"
7
+ version = "0.1.0"
8
+ description = "Textual TUI client for CiLydian: human-in-the-loop agentic CI"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ authors = [{name = "sleepti3ht", email = "sleepti3ht@gmail.com"}]
14
+ dependencies = [
15
+ "cilydian>=0.2.1",
16
+ "textual>=0.80",
17
+ "langgraph-checkpoint-sqlite>=2.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ cilydian-studio = "studio.app:main_entry"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/sleepti3ht/cilydian-studio"
25
+ Repository = "https://github.com/sleepti3ht/cilydian-studio"
26
+
27
+ [tool.setuptools]
28
+ packages = ["studio"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """CiLydian Studio: Textual TUI client for human-in-the-loop agentic CI."""
@@ -0,0 +1,122 @@
1
+ """Minimal Textual TUI: chat with the pipeline at interrupt points."""
2
+
3
+ import argparse
4
+
5
+ from textual.app import App, ComposeResult
6
+ from textual.widgets import Footer, Header, Input, RichLog
7
+
8
+ from studio.session import StudioSession
9
+
10
+
11
+ class StudioApp(App):
12
+ """Textual TUI for CiLydian human-in-the-loop sessions."""
13
+
14
+ CSS = """
15
+ RichLog {
16
+ height: 1fr;
17
+ border: solid green;
18
+ }
19
+ Input {
20
+ dock: bottom;
21
+ }
22
+ """
23
+
24
+ def __init__(
25
+ self, idea: str, run_id: str | None = None, config_path: str = "config.toml"
26
+ ):
27
+ super().__init__()
28
+ self.session = StudioSession(run_id=run_id, idea=idea, config_path=config_path)
29
+ self.title = f"CiLydian Studio: {idea[:40]}"
30
+ self._session_finished = False
31
+
32
+ def compose(self) -> ComposeResult:
33
+ yield Header()
34
+ self.rich_log = RichLog(markup=True, max_lines=5000)
35
+ yield self.rich_log
36
+ yield Input(
37
+ placeholder="continue | edit <spec> | retry <feedback> | accept | reject | abort"
38
+ )
39
+ yield Footer()
40
+
41
+ def on_mount(self) -> None:
42
+ """Start session and poll events."""
43
+ try:
44
+ self.session.start()
45
+ except Exception as exc: # noqa: BLE001
46
+ self.rich_log.write(f"[red]Failed to start session:[/red] {exc}")
47
+ self._session_finished = True
48
+ return
49
+ self.set_interval(0.2, self._poll)
50
+ self.rich_log.write("[blue]Session started. Waiting for interrupt...[/blue]")
51
+
52
+ def _poll(self) -> None:
53
+ """Check for events from graph worker."""
54
+ if self._session_finished:
55
+ return
56
+
57
+ while not self.session.events.empty():
58
+ ev = self.session.events.get()
59
+ if ev["type"] == "interrupt":
60
+ payload = ev["payload"]
61
+ stage = payload.get("stage", "?").upper()
62
+ self.rich_log.write(f"[yellow]== {stage} awaits decision ==[/yellow]")
63
+
64
+ if "spec" in payload:
65
+ self.rich_log.write(f"[dim]SPEC:[/dim]\n{payload['spec'][:1000]}")
66
+ if "verdict" in payload:
67
+ self.rich_log.write(f"[dim]VERDICT:[/dim] {payload['verdict']}")
68
+
69
+ actions = payload.get("actions", [])
70
+ self.rich_log.write(f"[cyan]actions: {actions}[/cyan]")
71
+
72
+ elif ev["type"] == "done":
73
+ self.rich_log.write(f"[green]DONE[/green] {ev['verdict']}")
74
+ self.rich_log.write(
75
+ "[dim]Session finished. Press Ctrl+C to exit.[/dim]"
76
+ )
77
+ self._session_finished = True
78
+
79
+ elif ev["type"] == "error":
80
+ self.rich_log.write(f"[red]ERROR:[/red] {ev['message']}")
81
+ self.rich_log.write("[dim]Session failed. Press Ctrl+C to exit.[/dim]")
82
+ self._session_finished = True
83
+
84
+ def on_input_submitted(self, msg: Input.Submitted) -> None:
85
+ self.rich_log.write(f"[blue]> {msg.value}[/blue]")
86
+ if self._session_finished:
87
+ self.rich_log.write("[yellow]session is over; command ignored[/yellow]")
88
+ elif not self.session.awaiting_decision.is_set():
89
+ self.rich_log.write("[yellow]no pending decision; command ignored[/yellow]")
90
+ else:
91
+ self.session.send(msg.value)
92
+ msg.input.value = ""
93
+
94
+ def on_exit(self) -> None:
95
+ """Called when TUI closes."""
96
+ self.session.stop()
97
+
98
+
99
+ def main_entry() -> None:
100
+ """CLI entry point: cilydian-studio [--config PATH] [--run-id ID] "idea" """
101
+ parser = argparse.ArgumentParser(
102
+ description="Textual TUI for CiLydian human-in-the-loop sessions"
103
+ )
104
+ parser.add_argument("idea", nargs="+", help="one-line task description")
105
+ parser.add_argument(
106
+ "--config",
107
+ default="config.toml",
108
+ help="path to role/model config (default: config.toml in CWD)",
109
+ )
110
+ parser.add_argument(
111
+ "--run-id",
112
+ default=None,
113
+ help="artifact dir under runs/ (default: studio-<uuid>)",
114
+ )
115
+ args = parser.parse_args()
116
+ StudioApp(
117
+ idea=" ".join(args.idea), config_path=args.config, run_id=args.run_id
118
+ ).run()
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main_entry()
@@ -0,0 +1,150 @@
1
+ """Interrupt/resume session bridge: worker thread runs the graph, TUI talks to human."""
2
+
3
+ import queue
4
+ import sqlite3
5
+ import threading
6
+ import uuid
7
+ from typing import Any
8
+
9
+ import cilydian.pipeline as core
10
+ from langgraph.checkpoint.sqlite import SqliteSaver
11
+ from langgraph.types import Command
12
+
13
+
14
+ class StudioSession:
15
+ """Wraps CiLydian graph with interrupt/resume for human-in-the-loop."""
16
+
17
+ def __init__(
18
+ self, idea: str, run_id: str | None = None, config_path: str = "config.toml"
19
+ ):
20
+ self.run_id = run_id or f"studio-{uuid.uuid4().hex[:8]}"
21
+ self.idea = idea
22
+ self.events: queue.Queue = queue.Queue()
23
+ self.commands: queue.Queue = queue.Queue()
24
+ self.awaiting_decision = threading.Event()
25
+ self._worker: threading.Thread | None = None
26
+ self._stop_requested = threading.Event()
27
+
28
+ # Initialize core pipeline
29
+ core.init_from_config(config_path, hitl=True)
30
+ core.preflight()
31
+ core.begin_run(self.run_id)
32
+
33
+ # Checkpointer for interrupt/resume
34
+ self._db_conn = sqlite3.connect(
35
+ f"studio-{self.run_id}.db", check_same_thread=False
36
+ )
37
+ self.checkpointer = SqliteSaver(self._db_conn)
38
+ self.graph = core.build_app(checkpointer=self.checkpointer)
39
+ self.thread_config = {"configurable": {"thread_id": self.run_id}}
40
+
41
+ def _log(self, msg: str) -> None:
42
+ """Write directly to a file, bypassing Textual's stdout capture."""
43
+ with open("force_debug.log", "a", encoding="utf-8") as f:
44
+ f.write(f"{msg}\n")
45
+
46
+ def start(self) -> None:
47
+ """Start graph execution in worker thread."""
48
+ self._log("--- START CALLED ---")
49
+ self._spawn(None)
50
+
51
+ def _spawn(self, resume: Any) -> None:
52
+ self._worker = threading.Thread(target=self._run, args=(resume,), daemon=True)
53
+ self._worker.start()
54
+
55
+ def is_alive(self) -> bool:
56
+ return self._worker is not None and self._worker.is_alive()
57
+
58
+ def stop(self) -> None:
59
+ """Signal the graph to stop and close the DB connection."""
60
+ self._log("--- STOP CALLED ---")
61
+ self._stop_requested.set()
62
+ self.commands.put(None) # Unblock get()
63
+ if hasattr(self, "_db_conn"):
64
+ try:
65
+ self._db_conn.close()
66
+ except Exception:
67
+ pass
68
+
69
+ def _initial_state(self) -> dict:
70
+ return {
71
+ "idea": self.idea,
72
+ "spec": "",
73
+ "spec_path": "",
74
+ "code": "",
75
+ "verdict": {},
76
+ "iteration": 0,
77
+ "human_action": "",
78
+ "code_hash": "",
79
+ "prev_code_hash": "",
80
+ }
81
+
82
+ def _run(self, resume: Any) -> None:
83
+ """Run graph; catch interrupts, wait for human decision, resume."""
84
+ self._log(f"--- RUN STARTED, resume={resume} ---")
85
+ try:
86
+ if resume is not None:
87
+ payload = Command(resume=resume)
88
+ else:
89
+ payload = self._initial_state()
90
+
91
+ self._log("Calling graph.invoke()...")
92
+ result = self.graph.invoke(payload, config=self.thread_config)
93
+ self._log("graph.invoke() returned.")
94
+
95
+ state = self.graph.get_state(self.thread_config)
96
+ if state.next: # paused at an interrupt point
97
+ self._log("Graph paused at interrupt.")
98
+ value = {}
99
+ if state.tasks and state.tasks[0].interrupts:
100
+ value = state.tasks[0].interrupts[0].value
101
+
102
+ self.events.put({"type": "interrupt", "payload": value})
103
+ self.awaiting_decision.set()
104
+
105
+ # Wait for command without timing out the user
106
+ while not self._stop_requested.is_set():
107
+ try:
108
+ answer = self.commands.get(timeout=0.5)
109
+ if answer is None: # Stop signal
110
+ self._log("Received None command (stop). Exiting.")
111
+ return
112
+ self._log(f"Received command: {answer}")
113
+ self.awaiting_decision.clear()
114
+ self._spawn(answer)
115
+ return
116
+ except queue.Empty:
117
+ continue
118
+
119
+ self._log("Stop requested while waiting. Exiting.")
120
+ return
121
+ else:
122
+ self._log("Graph finished.")
123
+ verdict = (
124
+ (result or {}).get("verdict", {})
125
+ if isinstance(result, dict)
126
+ else {}
127
+ )
128
+ self.events.put({"type": "done", "verdict": verdict})
129
+
130
+ except Exception as exc: # noqa: BLE001
131
+ self._log(f"EXCEPTION in _run: {type(exc).__name__}: {exc}")
132
+ self.events.put(
133
+ {"type": "error", "message": f"{type(exc).__name__}: {exc}"}
134
+ )
135
+
136
+ def send(self, raw: str) -> None:
137
+ """Parse human input and send to graph."""
138
+ if not self.awaiting_decision.is_set():
139
+ return
140
+
141
+ raw = raw.strip()
142
+ if raw.startswith("edit "):
143
+ value = {"action": "edit", "spec": raw[5:]}
144
+ elif raw.startswith("retry"):
145
+ value = {"action": "retry", "feedback": raw[5:].strip()}
146
+ elif raw in ("reject", "abort", "accept", "continue"):
147
+ value = {"action": raw}
148
+ else:
149
+ value = {"action": "continue"}
150
+ self.commands.put(value)