faure 0.1.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b6caa6c0d2ed4a8ad383112454acb77a37df2af5db57cb1dc4f4cc6a23fc8191
4
+ data.tar.gz: 0de6f2b9d21361bcb0b8c3566187ed2ea5f08b35723a2a5da8a6c7f1c0471f1f
5
+ SHA512:
6
+ metadata.gz: 3c7e2438a5e882f4fde1d94692452914b86786acaaeaca4882efff96f16853839a4d43d1556aa60968cb3520c8cc8b5b94dd21198803332faf6868f85cd5c857
7
+ data.tar.gz: 6c705864faf21d94c6213757d5e722eb747fd2d1a34a96d338ed980afae21c734ab80656e930be550486e1df8f7ab2409829c2cadb3c0b5c6c644684cad9149a
data/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # ShamoX Agent
2
+
3
+ Agent codeur autonome GitLab CI/CD + MLX local.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Issue GitLab + commentaire @shamox-codeur
9
+
10
+ Webhook GitLab → trigger pipeline CI
11
+
12
+ Job `codeur` (Qwen3-Coder-30B via mlx_lm)
13
+ - lit l'issue et l'historique des commentaires
14
+ - produit les modifications de fichiers en XML
15
+ - commit + push sur feature/issue-NNN
16
+
17
+ Job `agent-git` (Qwen3.5-4B via mlx_lm)
18
+ - analyse le diff
19
+ - génère la description de MR
20
+ - crée la MR et assigne le reviewer
21
+
22
+ Review manuelle + merge
23
+ ```
24
+
25
+ ## Variables CI/CD requises
26
+
27
+ | Variable | Description |
28
+ |---|---|
29
+ | `SHAMOX_BOT_ID` | ID GitLab du compte de service shamox-bot |
30
+ | `SHAMOX_BOT_API_TOKEN` | Project Access Token avec scope `api` |
31
+ | `SHAMOX_REVIEWER_ID` | ID GitLab numérique du reviewer |
32
+
33
+ ## Intégration dans un projet
34
+
35
+ Dans le `.gitlab-ci.yml` du projet cible, inclure :
36
+
37
+ ```yaml
38
+ include:
39
+ - project: 'rlaures.pro/shamox-agent'
40
+ ref: main
41
+ file: '.gitlab-ci.yml'
42
+ ```
43
+
44
+ ## Stack
45
+
46
+ - Ruby stdlib uniquement (pas de gems externes)
47
+ - mlx_lm sur Apple Silicon (M1/M4)
48
+ - GitLab CI/CD Runners headless (chacana, hecaton-macmini-001)
data/bin/faure-agent ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/faure/agent'
3
+ Faure::Agent.new.run
data/bin/faure-codeur ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/faure/codeur'
3
+ Faure::Codeur.new.run
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env ruby
2
+ # lib/faure/agent.rb
3
+
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+ require_relative 'version'
8
+ require_relative 'config'
9
+
10
+ module Faure
11
+ class Agent
12
+
13
+ def initialize
14
+ payload = JSON.parse(ENV.fetch('TRIGGER_PAYLOAD', '{}'))
15
+ @issue_iid = payload.dig('issue', 'iid').to_s
16
+ end
17
+
18
+ def run
19
+ branch_name = "feature/issue-#{@issue_iid}"
20
+ diff = `git diff origin/#{Config::TARGET_BRANCH}...#{branch_name} --stat`
21
+ diff_full = `git diff origin/#{Config::TARGET_BRANCH}...#{branch_name}`
22
+
23
+ if diff.strip.empty?
24
+ puts '[faure:agent] Aucun diff détecté, abandon.'
25
+ exit 0
26
+ end
27
+
28
+ puts "[faure:agent] Diff détecté :\n#{diff}"
29
+
30
+ description = call_model([
31
+ { role: 'system', content: mr_system_prompt },
32
+ { role: 'user', content: <<~PROMPT }
33
+ Voici le diff de la branche #{branch_name} :
34
+ #{diff_full[0..3000]}
35
+ Rédige une description de MR en 3-5 phrases : ce qui a été fait, pourquoi, et ce qu'il faut vérifier.
36
+ PROMPT
37
+ ])
38
+
39
+ puts '[faure:agent] Description MR générée'
40
+
41
+ res = gitlab_post("/projects/#{Config::PROJECT_ID}/merge_requests", {
42
+ source_branch: branch_name,
43
+ target_branch: Config::TARGET_BRANCH,
44
+ title: "faure: résolution issue ##{@issue_iid}",
45
+ description: "Closes ##{@issue_iid}\n\n#{description}",
46
+ reviewer_ids: [Config::REVIEWER_ID],
47
+ remove_source_branch: true,
48
+ squash: false
49
+ })
50
+ body = JSON.parse(res.body)
51
+
52
+ if body['web_url']
53
+ puts "[faure:agent] MR créée : #{body['web_url']}"
54
+ else
55
+ puts "[faure:agent] Erreur création MR : #{res.body}"
56
+ exit 1
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def mr_system_prompt
63
+ if Config::LANG == 'fr'
64
+ 'Tu es un assistant qui rédige des descriptions de Merge Request claires et concises en français. Réponds uniquement avec le texte de la description, sans balises.'
65
+ else
66
+ 'You are an assistant that writes clear and concise Merge Request descriptions in English. Reply only with the description text, no markup.'
67
+ end
68
+ end
69
+
70
+ def call_model(messages, max_tokens: 512)
71
+ uri = URI("#{Config::OPENAI_URL}/v1/chat/completions")
72
+ req = Net::HTTP::Post.new(uri)
73
+ req['Content-Type'] = 'application/json'
74
+ req.body = { model: Config::AGENT_MODEL, messages: messages, max_tokens: max_tokens }.to_json
75
+ res = Net::HTTP.start(uri.host, uri.port) { |h| h.request(req) }
76
+ JSON.parse(res.body).dig('choices', 0, 'message', 'content')
77
+ end
78
+
79
+ def gitlab_post(path, payload)
80
+ uri = URI("#{Config::GITLAB_URL}/api/v4#{path}")
81
+ req = Net::HTTP::Post.new(uri)
82
+ req['PRIVATE-TOKEN'] = Config::API_TOKEN
83
+ req['Content-Type'] = 'application/json'
84
+ req.body = payload.to_json
85
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |h| h.request(req) }
86
+ end
87
+
88
+ end
89
+ end
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env ruby
2
+ # lib/faure/codeur.rb
3
+
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+ require 'fileutils'
8
+ require_relative 'version'
9
+ require_relative 'config'
10
+
11
+ module Faure
12
+ class Codeur
13
+
14
+ def initialize
15
+ payload = JSON.parse(ENV.fetch('TRIGGER_PAYLOAD', '{}'))
16
+ @note = payload.dig('object_attributes', 'note') || ''
17
+ @noteable_type = payload.dig('object_attributes', 'noteable_type')
18
+ @author_id = payload.dig('user', 'id').to_i
19
+ @issue_iid = payload.dig('issue', 'iid').to_s
20
+ end
21
+
22
+ def run
23
+ guard!
24
+ puts "[faure] Issue ##{@issue_iid} — auteur_id=#{@author_id}"
25
+
26
+ issue = gitlab_get("/projects/#{Config::PROJECT_ID}/issues/#{@issue_iid}")
27
+ comments = gitlab_get("/projects/#{Config::PROJECT_ID}/issues/#{@issue_iid}/notes?sort=asc&per_page=100")
28
+
29
+ previous_summary = comments
30
+ .select { |n| n['body'].include?('<!-- faure:context -->') }
31
+ .last&.dig('body')
32
+
33
+ pending_question = comments
34
+ .select { |n| n['body'].include?('🤔 **faure demande :**') }
35
+ .last
36
+
37
+ mode = if pending_question && !@note.include?('@faure-codeur')
38
+ :answering_question
39
+ else
40
+ :new_instruction
41
+ end
42
+
43
+ puts "[faure] Mode : #{mode}"
44
+
45
+ branch_name = "feature/issue-#{@issue_iid}"
46
+ user_content = build_prompt(mode, issue, previous_summary, pending_question)
47
+
48
+ puts "[faure] Appel #{Config::CODER_MODEL}..."
49
+ response = call_model(Config::CODER_MODEL, [
50
+ { role: 'system', content: system_prompt },
51
+ { role: 'user', content: user_content }
52
+ ])
53
+ puts "[faure] Réponse reçue (#{response.length} chars)"
54
+
55
+ files = parse_files(response)
56
+ question = parse_question(response)
57
+
58
+ if files.empty? && question.nil?
59
+ post_comment("⚠️ faure: réponse non structurée du modèle. Relancer ou reformuler l'issue.")
60
+ exit 1
61
+ end
62
+
63
+ commit_files(files, branch_name) unless files.empty?
64
+ post_comment("🤔 **faure demande :** #{question}") if question
65
+
66
+ summary = summarize_context(issue['title'], previous_summary, response, branch_name)
67
+ post_comment("<!-- faure:context -->\n#{summary}")
68
+
69
+ puts '[faure] Codeur terminé.'
70
+ end
71
+
72
+ private
73
+
74
+ def guard!
75
+ abort '[faure] Pas un commentaire sur issue, abandon.' unless @noteable_type == 'Issue'
76
+ abort '[faure] Commentaire du bot, abandon.' if @author_id == Config::BOT_ID
77
+ abort '[faure] Commentaire faure:context, abandon.' if @note.include?('<!-- faure:context -->')
78
+ abort '[faure] Pas de mention @faure-codeur ni réponse attendue.' \
79
+ unless @note.include?('@faure-codeur') || @note.match?(/^[^@]/)
80
+ end
81
+
82
+ def system_prompt
83
+ lang_instruction = Config::LANG == 'fr' ? 'Réponds en français.' : 'Reply in English.'
84
+ <<~SYSTEM
85
+ Tu es un agent codeur expert. Tu reçois une tâche de développement et tu dois produire les modifications de fichiers nécessaires.
86
+ #{lang_instruction}
87
+ Réponds UNIQUEMENT avec des balises XML :
88
+ - <file path="chemin/fichier">contenu complet du fichier</file> pour chaque fichier modifié
89
+ - <question>ta question</question> si tu as besoin de clarifications (peut coexister avec des <file>)
90
+ Ne produis rien en dehors de ces balises XML.
91
+ SYSTEM
92
+ end
93
+
94
+ def build_prompt(mode, issue, previous_summary, pending_question)
95
+ case mode
96
+ when :answering_question
97
+ <<~PROMPT
98
+ Contexte de la tâche :
99
+ #{previous_summary || issue['description']}
100
+
101
+ Question que tu avais posée :
102
+ #{pending_question['body']}
103
+
104
+ Réponse :
105
+ #{@note}
106
+ PROMPT
107
+ when :new_instruction
108
+ if previous_summary
109
+ <<~PROMPT
110
+ Contexte de la tâche :
111
+ #{previous_summary}
112
+
113
+ Nouvelle instruction : #{@note}
114
+ PROMPT
115
+ else
116
+ <<~PROMPT
117
+ Issue ##{@issue_iid} : #{issue['title']}
118
+
119
+ Description :
120
+ #{issue['description']}
121
+
122
+ #{@note.include?('@faure-codeur') ? "Instruction : #{@note}" : ''}
123
+ PROMPT
124
+ end
125
+ end
126
+ end
127
+
128
+ def commit_files(files, branch_name)
129
+ puts "[faure] #{files.size} fichier(s) à écrire..."
130
+ files.each do |f|
131
+ full_path = File.join(Config::PROJECT_DIR, f[:path])
132
+ FileUtils.mkdir_p(File.dirname(full_path))
133
+ File.write(full_path, f[:content])
134
+ puts " → #{f[:path]}"
135
+ end
136
+
137
+ system("git config user.email 'faure@liant.dev'")
138
+ system("git config user.name 'Faure Codeur'")
139
+ system("git checkout -B #{branch_name}")
140
+ system('git add -A')
141
+ system("git commit -m 'faure(issue-#{@issue_iid}): modifications automatiques'")
142
+ system("git push origin #{branch_name} --force-with-lease 2>/dev/null || git push origin #{branch_name} --force")
143
+ puts "[faure] Push sur #{branch_name} effectué"
144
+ end
145
+
146
+ def summarize_context(issue_title, previous_summary, response, branch_name)
147
+ messages = [
148
+ { role: 'system', content: "Tu es un assistant qui résume de façon concise l'état d'avancement d'une tâche de développement. Réponds uniquement en XML valide." },
149
+ { role: 'user', content: <<~PROMPT }
150
+ Issue : #{issue_title}
151
+ Résumé précédent : #{previous_summary || 'aucun'}
152
+ Dernière réponse du codeur :
153
+ #{response[0..2000]}
154
+
155
+ Produis un résumé structuré :
156
+ <context>
157
+ <task>description courte de la tâche</task>
158
+ <done>ce qui a été fait</done>
159
+ <pending>ce qui reste ou les questions en suspens</pending>
160
+ <branch>#{branch_name}</branch>
161
+ </context>
162
+ PROMPT
163
+ ]
164
+ call_model(Config::SUMMARY_MODEL, messages, max_tokens: 512)
165
+ end
166
+
167
+ def parse_files(content)
168
+ content.scan(/<file path="([^"]+)">(.*?)<\/file>/m).map do |path, body|
169
+ { path: path.strip, content: body.strip }
170
+ end
171
+ end
172
+
173
+ def parse_question(content)
174
+ m = content.match(/<question>(.*?)<\/question>/m)
175
+ m ? m[1].strip : nil
176
+ end
177
+
178
+ def call_model(model, messages, max_tokens: 4096)
179
+ uri = URI("#{Config::OPENAI_URL}/v1/chat/completions")
180
+ req = Net::HTTP::Post.new(uri)
181
+ req['Content-Type'] = 'application/json'
182
+ req.body = { model: model, messages: messages, max_tokens: max_tokens }.to_json
183
+ res = Net::HTTP.start(uri.host, uri.port) { |h| h.request(req) }
184
+ data = JSON.parse(res.body)
185
+ data.dig('choices', 0, 'message', 'content') or raise "Réponse inattendue : #{res.body}"
186
+ end
187
+
188
+ def gitlab_get(path)
189
+ uri = URI("#{Config::GITLAB_URL}/api/v4#{path}")
190
+ req = Net::HTTP::Get.new(uri)
191
+ req['PRIVATE-TOKEN'] = Config::API_TOKEN
192
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |h| h.request(req) }
193
+ JSON.parse(res.body)
194
+ end
195
+
196
+ def gitlab_post(path, payload)
197
+ uri = URI("#{Config::GITLAB_URL}/api/v4#{path}")
198
+ req = Net::HTTP::Post.new(uri)
199
+ req['PRIVATE-TOKEN'] = Config::API_TOKEN
200
+ req['Content-Type'] = 'application/json'
201
+ req.body = payload.to_json
202
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') { |h| h.request(req) }
203
+ end
204
+
205
+ def post_comment(body)
206
+ gitlab_post("/projects/#{Config::PROJECT_ID}/issues/#{@issue_iid}/notes", { body: body })
207
+ puts "[faure] Commentaire posté sur l'issue ##{@issue_iid}"
208
+ end
209
+
210
+ end
211
+ end
@@ -0,0 +1,23 @@
1
+ module Faure
2
+ module Config
3
+ # Obligatoires
4
+ API_TOKEN = ENV.fetch('FAURE_API_TOKEN')
5
+ REVIEWER_ID = ENV.fetch('FAURE_REVIEWER_ID').to_i
6
+ BOT_ID = ENV.fetch('FAURE_BOT_ID', '0').to_i
7
+ OPENAI_URL = ENV.fetch('OPENAI_API_URL', 'http://host.containers.internal:8080')
8
+
9
+ # Modèles — configurables par projet
10
+ CODER_MODEL = ENV.fetch('FAURE_CODER_MODEL', 'mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bit')
11
+ AGENT_MODEL = ENV.fetch('FAURE_AGENT_MODEL', 'mlx-community/Qwen3.5-4B-4bit')
12
+ SUMMARY_MODEL = ENV.fetch('FAURE_SUMMARY_MODEL', 'mlx-community/Qwen3.5-4B-4bit')
13
+
14
+ # Options
15
+ TARGET_BRANCH = ENV.fetch('FAURE_TARGET_BRANCH', 'main')
16
+ LANG = ENV.fetch('FAURE_LANG', 'fr')
17
+
18
+ # GitLab CI — injectées automatiquement
19
+ GITLAB_URL = ENV.fetch('CI_SERVER_URL', 'https://gitlab.com')
20
+ PROJECT_ID = ENV.fetch('CI_PROJECT_ID')
21
+ PROJECT_DIR = ENV.fetch('CI_PROJECT_DIR', Dir.pwd)
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Faure
2
+ VERSION = '0.1.1'
3
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: faure
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Roland Laurès
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Faure orchestre un agent codeur et un agent git via GitLab CI/CD et un
14
+ backend OpenAI-compatible (mlx_lm, Ollama, OpenAI...).
15
+ email:
16
+ - roland@liant.dev
17
+ executables:
18
+ - faure-codeur
19
+ - faure-agent
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - README.md
24
+ - bin/faure-agent
25
+ - bin/faure-codeur
26
+ - lib/faure/agent.rb
27
+ - lib/faure/codeur.rb
28
+ - lib/faure/config.rb
29
+ - lib/faure/version.rb
30
+ homepage: https://gitlab.com/rol-public/shamox-agent
31
+ licenses:
32
+ - MIT
33
+ metadata: {}
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.5.22
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Agent codeur autonome GitLab CI/CD + backend OpenAI-compatible
53
+ test_files: []