agloop 0.6.0__py3-none-any.whl
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.
- agloop-0.6.0.dist-info/METADATA +43 -0
- agloop-0.6.0.dist-info/RECORD +21 -0
- agloop-0.6.0.dist-info/WHEEL +5 -0
- agloop-0.6.0.dist-info/entry_points.txt +4 -0
- agloop-0.6.0.dist-info/licenses/LICENSE +21 -0
- agloop-0.6.0.dist-info/top_level.txt +3 -0
- core/agent.py +523 -0
- core/git_lock.py +406 -0
- core/init_scaffold.py +59 -0
- core/mcp_server.py +224 -0
- core/privacy_audit.py +87 -0
- core/runner.py +267 -0
- core/scan.py +108 -0
- core/templates/HISTORICO.md +3 -0
- core/templates/gestao/backlog-produto.md +3 -0
- core/templates/gestao/matriz-riscos.md +5 -0
- core/templates/gestao/roadmap.md +5 -0
- core/templates/war_room.md +3 -0
- core/war_room.py +196 -0
- main.py +83 -0
- scripts/vigia.py +72 -0
core/git_lock.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Iterable, Optional
|
|
10
|
+
|
|
11
|
+
from git import Repo
|
|
12
|
+
from git.exc import GitCommandError
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger("agloop.git_lock")
|
|
15
|
+
|
|
16
|
+
# Markers git prints when a push is rejected because the remote moved ahead
|
|
17
|
+
# (i.e. another agent won the race). Anything else (network, auth...) is
|
|
18
|
+
# treated as a transient failure and the local commit is kept for later.
|
|
19
|
+
_REJECTION_MARKERS = ("rejected", "non-fast-forward", "fetch first", "failed to push some refs")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitLock:
|
|
23
|
+
"""
|
|
24
|
+
Hybrid lock for AgLoop.
|
|
25
|
+
|
|
26
|
+
- Local agents (same disk / same working tree) are serialized by an atomic
|
|
27
|
+
directory lock (`.agloop.lock/`), with stale-lock recovery.
|
|
28
|
+
- Remote agents (other machines) are synchronized through Git, used as a
|
|
29
|
+
serverless message broker (fetch / rebase / push).
|
|
30
|
+
|
|
31
|
+
Safety rules (the working tree may be shared with other agents writing code):
|
|
32
|
+
* Only the War Room paths are ever staged / committed.
|
|
33
|
+
* Never `reset --hard`: other agents' uncommitted work must survive.
|
|
34
|
+
* Never pull unless the remote actually has new commits.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, repo_path: str = ".", lock_timeout: Optional[float] = None, push_retries: int = 3):
|
|
38
|
+
self.repo_path = repo_path
|
|
39
|
+
self.repo = Repo(repo_path)
|
|
40
|
+
try:
|
|
41
|
+
self.origin = self.repo.remotes.origin
|
|
42
|
+
except (IndexError, AttributeError):
|
|
43
|
+
self.origin = None
|
|
44
|
+
|
|
45
|
+
if os.getenv("AGENT_LOOP_LOCAL_MODE") is not None:
|
|
46
|
+
warnings.warn(
|
|
47
|
+
"AGENT_LOOP_LOCAL_MODE is deprecated and ignored: AgLoop now always runs in "
|
|
48
|
+
"hybrid mode (local mtime + remote git fetch). You can remove it from .env.",
|
|
49
|
+
FutureWarning,
|
|
50
|
+
stacklevel=2,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.lock_dir = os.path.join(repo_path, ".agloop.lock")
|
|
54
|
+
self.lock_timeout = float(lock_timeout if lock_timeout is not None else os.getenv("AGLOOP_LOCK_TIMEOUT", 300))
|
|
55
|
+
self.push_retries = push_retries
|
|
56
|
+
# Status of the last acquire_lock_and_push() call, for the runner's logs:
|
|
57
|
+
# ok | locked | sync_failed | commit_failed | push_failed | lost_race
|
|
58
|
+
self.last_status = "ok"
|
|
59
|
+
self._warned = set()
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------ #
|
|
62
|
+
# Local lock (atomic mkdir) with stale-lock recovery
|
|
63
|
+
# ------------------------------------------------------------------ #
|
|
64
|
+
def _owner_file(self, lock_dir: Optional[str] = None) -> str:
|
|
65
|
+
return os.path.join(lock_dir or self.lock_dir, "owner.json")
|
|
66
|
+
|
|
67
|
+
def _read_owner(self, lock_dir: Optional[str] = None) -> Optional[dict]:
|
|
68
|
+
try:
|
|
69
|
+
with open(self._owner_file(lock_dir), "r", encoding="utf-8") as f:
|
|
70
|
+
return json.load(f)
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _pid_alive(pid: int) -> bool:
|
|
76
|
+
try:
|
|
77
|
+
os.kill(pid, 0)
|
|
78
|
+
except ProcessLookupError:
|
|
79
|
+
return False
|
|
80
|
+
except PermissionError:
|
|
81
|
+
return True # exists, owned by another user
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def _lock_is_stale(self) -> bool:
|
|
85
|
+
owner = self._read_owner()
|
|
86
|
+
if owner is None:
|
|
87
|
+
# Owner file not written yet (or corrupted): fall back to the dir age,
|
|
88
|
+
# so a process that died right after mkdir doesn't block forever.
|
|
89
|
+
try:
|
|
90
|
+
age = time.time() - os.path.getmtime(self.lock_dir)
|
|
91
|
+
except OSError:
|
|
92
|
+
return False # lock vanished meanwhile
|
|
93
|
+
return age > self.lock_timeout
|
|
94
|
+
|
|
95
|
+
if time.time() - float(owner.get("ts", 0)) > self.lock_timeout:
|
|
96
|
+
return True
|
|
97
|
+
if owner.get("host") == socket.gethostname():
|
|
98
|
+
pid = owner.get("pid")
|
|
99
|
+
if isinstance(pid, int) and not self._pid_alive(pid):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def _break_stale_lock(self):
|
|
104
|
+
# Rename first (atomic) so concurrent breakers don't delete each other's
|
|
105
|
+
# fresh locks with rmtree; then clean up the renamed directory.
|
|
106
|
+
tomb = f"{self.lock_dir}.stale.{os.getpid()}.{random.randint(0, 1_000_000)}"
|
|
107
|
+
try:
|
|
108
|
+
os.rename(self.lock_dir, tomb)
|
|
109
|
+
except OSError:
|
|
110
|
+
return
|
|
111
|
+
shutil.rmtree(tomb, ignore_errors=True)
|
|
112
|
+
_logger.warning("stale local lock recovered", extra={"event": "stale_lock_recovered"})
|
|
113
|
+
|
|
114
|
+
def _acquire_local_lock(self) -> bool:
|
|
115
|
+
"""Uses atomic directory creation as a local lock."""
|
|
116
|
+
for _ in range(2):
|
|
117
|
+
try:
|
|
118
|
+
os.mkdir(self.lock_dir)
|
|
119
|
+
except FileExistsError:
|
|
120
|
+
if self._lock_is_stale():
|
|
121
|
+
self._break_stale_lock()
|
|
122
|
+
continue
|
|
123
|
+
return False
|
|
124
|
+
owner = {"pid": os.getpid(), "host": socket.gethostname(), "ts": time.time()}
|
|
125
|
+
with open(self._owner_file(), "w", encoding="utf-8") as f:
|
|
126
|
+
json.dump(owner, f)
|
|
127
|
+
return True
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def _release_local_lock(self):
|
|
131
|
+
"""Releases the local lock, but only if we still own it."""
|
|
132
|
+
owner = self._read_owner()
|
|
133
|
+
if owner is not None and (owner.get("pid") != os.getpid() or owner.get("host") != socket.gethostname()):
|
|
134
|
+
return # our lock was considered stale and taken over; don't delete theirs
|
|
135
|
+
shutil.rmtree(self.lock_dir, ignore_errors=True)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ #
|
|
138
|
+
# Git helpers
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
def _warn_once(self, key: str, msg: str):
|
|
141
|
+
if key not in self._warned:
|
|
142
|
+
self._warned.add(key)
|
|
143
|
+
_logger.warning(msg, extra={"event": f"git_{key}_warning"})
|
|
144
|
+
|
|
145
|
+
def _branch(self) -> str:
|
|
146
|
+
try:
|
|
147
|
+
return self.repo.active_branch.name
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
return "main"
|
|
150
|
+
|
|
151
|
+
def _remote_ref(self) -> str:
|
|
152
|
+
return f"origin/{self._branch()}"
|
|
153
|
+
|
|
154
|
+
def _rev_count(self, range_spec: str) -> int:
|
|
155
|
+
try:
|
|
156
|
+
return int(self.repo.git.rev_list("--count", range_spec).strip() or 0)
|
|
157
|
+
except (GitCommandError, ValueError):
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
def _commits_ahead(self) -> int:
|
|
161
|
+
if not self.origin:
|
|
162
|
+
return 0
|
|
163
|
+
try:
|
|
164
|
+
self.repo.git.rev_parse("--verify", "--quiet", self._remote_ref())
|
|
165
|
+
except GitCommandError:
|
|
166
|
+
# Branch doesn't exist on the remote yet: everything local is unpushed.
|
|
167
|
+
return self._rev_count("HEAD")
|
|
168
|
+
return self._rev_count(f"{self._remote_ref()}..HEAD")
|
|
169
|
+
|
|
170
|
+
def _fetch(self) -> bool:
|
|
171
|
+
if not self.origin:
|
|
172
|
+
return False
|
|
173
|
+
try:
|
|
174
|
+
self.repo.git.fetch("--quiet", "origin", self._branch())
|
|
175
|
+
self._warned.discard("fetch")
|
|
176
|
+
return True
|
|
177
|
+
except GitCommandError as e:
|
|
178
|
+
# Ref-lock collision with another local fetch, or no network: just skip.
|
|
179
|
+
self._warn_once("fetch", f"[AgLoop] git fetch failed (will keep trying silently): {e.stderr.strip() if e.stderr else e}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def has_remote_updates(self) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Cheap remote check: fetch + count commits that exist on origin but not locally.
|
|
185
|
+
Being *ahead* of origin (unpushed commits) does NOT count as an update.
|
|
186
|
+
"""
|
|
187
|
+
if not self.origin:
|
|
188
|
+
return False
|
|
189
|
+
if not self._fetch():
|
|
190
|
+
return False
|
|
191
|
+
return self._rev_count(f"HEAD..{self._remote_ref()}") > 0
|
|
192
|
+
|
|
193
|
+
def _rebase_onto_remote(self) -> bool:
|
|
194
|
+
"""Rebase local commits onto origin/<branch>, stashing others' dirty files around it."""
|
|
195
|
+
try:
|
|
196
|
+
self.repo.git.rebase("--autostash", self._remote_ref())
|
|
197
|
+
return True
|
|
198
|
+
except GitCommandError as e:
|
|
199
|
+
_logger.warning(
|
|
200
|
+
"rebase onto remote failed: %s",
|
|
201
|
+
e.stderr.strip() if e.stderr else e,
|
|
202
|
+
extra={"event": "rebase_failed"},
|
|
203
|
+
)
|
|
204
|
+
try:
|
|
205
|
+
self.repo.git.rebase("--abort")
|
|
206
|
+
except GitCommandError:
|
|
207
|
+
pass
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
def _path_in_tree(self, rev: str, path: str) -> bool:
|
|
211
|
+
try:
|
|
212
|
+
self.repo.git.cat_file("-e", f"{rev}:{path}")
|
|
213
|
+
return True
|
|
214
|
+
except GitCommandError:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
def _resolve_lost_race(self, paths: Iterable[str]) -> bool:
|
|
218
|
+
"""
|
|
219
|
+
Another agent pushed a War Room update first. Drop OUR unpushed room changes
|
|
220
|
+
(they were based on stale context), fast-forward to the remote and let the
|
|
221
|
+
agent re-read and decide again. Only `paths` are reverted; everything else in
|
|
222
|
+
the working tree (other agents' code) is preserved.
|
|
223
|
+
"""
|
|
224
|
+
remote = self._remote_ref()
|
|
225
|
+
try:
|
|
226
|
+
base = self.repo.git.merge_base("HEAD", remote).strip()
|
|
227
|
+
self.repo.git.reset("--soft", base)
|
|
228
|
+
for p in paths:
|
|
229
|
+
if self._path_in_tree(base, p):
|
|
230
|
+
self.repo.git.checkout(base, "--", p)
|
|
231
|
+
else:
|
|
232
|
+
self.repo.git.rm("--cached", "--quiet", "--ignore-unmatch", "--", p)
|
|
233
|
+
if os.path.exists(os.path.join(self.repo_path, p)):
|
|
234
|
+
os.remove(os.path.join(self.repo_path, p))
|
|
235
|
+
except GitCommandError as e:
|
|
236
|
+
_logger.error("could not drop local room changes: %s", e, extra={"event": "lost_race_recovery_failed"})
|
|
237
|
+
return False
|
|
238
|
+
return self._rebase_onto_remote()
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ #
|
|
241
|
+
# Public API
|
|
242
|
+
# ------------------------------------------------------------------ #
|
|
243
|
+
def sync_if_needed(self, paths: Iterable[str] = ()) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Pull remote updates ONLY if they exist. In a shared working tree with no remote
|
|
246
|
+
news, this never touches the disk (fixes 'cannot pull with rebase: You have
|
|
247
|
+
unstaged changes').
|
|
248
|
+
"""
|
|
249
|
+
if not self.origin:
|
|
250
|
+
return True
|
|
251
|
+
if not self.has_remote_updates():
|
|
252
|
+
return True # nothing new, or fetch failed (push will tell us later)
|
|
253
|
+
if self._rebase_onto_remote():
|
|
254
|
+
return True
|
|
255
|
+
# Rebase conflicted: our pending room commit collides with a remote message.
|
|
256
|
+
return self._resolve_lost_race(paths)
|
|
257
|
+
|
|
258
|
+
def pull(self) -> bool:
|
|
259
|
+
"""Backward-compatible alias: syncs with the remote only when needed."""
|
|
260
|
+
return self.sync_if_needed()
|
|
261
|
+
|
|
262
|
+
def _existing_paths(self, paths: Iterable[str]) -> list:
|
|
263
|
+
result = []
|
|
264
|
+
for p in paths:
|
|
265
|
+
full = os.path.join(self.repo_path, p) if not os.path.isabs(p) else p
|
|
266
|
+
if os.path.exists(full) or self._path_in_tree("HEAD", p):
|
|
267
|
+
result.append(p)
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
def has_changes(self, paths: Iterable[str]) -> bool:
|
|
271
|
+
"""Checks if any of `paths` is modified, staged or untracked."""
|
|
272
|
+
active = self._existing_paths(paths)
|
|
273
|
+
if not active:
|
|
274
|
+
return False
|
|
275
|
+
return bool(self.repo.git.status("--porcelain", "--", *active).strip())
|
|
276
|
+
|
|
277
|
+
def has_uncommitted_changes(self) -> bool:
|
|
278
|
+
"""Checks if there are any modified or untracked files (whole working tree)."""
|
|
279
|
+
return self.repo.is_dirty(untracked_files=True)
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _author_env(author_name: Optional[str], author_email: Optional[str]) -> dict:
|
|
283
|
+
if not author_name:
|
|
284
|
+
return {}
|
|
285
|
+
slug = author_name.strip().lower().replace(" ", ".")
|
|
286
|
+
email = author_email or f"{slug}@agloop.local"
|
|
287
|
+
return {
|
|
288
|
+
"GIT_AUTHOR_NAME": author_name,
|
|
289
|
+
"GIT_AUTHOR_EMAIL": email,
|
|
290
|
+
"GIT_COMMITTER_NAME": author_name,
|
|
291
|
+
"GIT_COMMITTER_EMAIL": email,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
def commit(
|
|
295
|
+
self,
|
|
296
|
+
message: str,
|
|
297
|
+
paths: Iterable[str],
|
|
298
|
+
author_name: Optional[str] = None,
|
|
299
|
+
author_email: Optional[str] = None,
|
|
300
|
+
) -> bool:
|
|
301
|
+
"""
|
|
302
|
+
Stages and commits ONLY `paths`. `git commit -- <paths>` also ignores anything
|
|
303
|
+
another agent may have staged in the shared index.
|
|
304
|
+
When `author_name` is provided, sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*`
|
|
305
|
+
scoped strictly to the git commit subprocess (never mutates global os.environ).
|
|
306
|
+
"""
|
|
307
|
+
active = self._existing_paths(paths)
|
|
308
|
+
if not active:
|
|
309
|
+
return True
|
|
310
|
+
try:
|
|
311
|
+
self.repo.git.add("--", *active)
|
|
312
|
+
if not self.has_changes(active):
|
|
313
|
+
return True
|
|
314
|
+
env = self._author_env(author_name, author_email)
|
|
315
|
+
with self.repo.git.custom_environment(**env):
|
|
316
|
+
self.repo.git.commit("-m", message, "--", *active)
|
|
317
|
+
return True
|
|
318
|
+
except GitCommandError as e:
|
|
319
|
+
_logger.error("git commit error: %s", e, extra={"event": "commit_failed"})
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
def push(self, paths: Iterable[str] = ()) -> bool:
|
|
323
|
+
"""
|
|
324
|
+
Pushes local commits. On rejection (another agent pushed first):
|
|
325
|
+
- clean rebase (remote changed other files) -> retry push;
|
|
326
|
+
- conflicting rebase (remote has a new room message) -> lost race:
|
|
327
|
+
drop our room changes and fast-forward. Never touches other files.
|
|
328
|
+
On network/auth errors the commit is kept and pushed on a later iteration.
|
|
329
|
+
"""
|
|
330
|
+
if not self.origin:
|
|
331
|
+
return True
|
|
332
|
+
paths = list(paths)
|
|
333
|
+
branch = self._branch()
|
|
334
|
+
for attempt in range(1, self.push_retries + 1):
|
|
335
|
+
try:
|
|
336
|
+
self.repo.git.push("origin", branch)
|
|
337
|
+
return True
|
|
338
|
+
except GitCommandError as e:
|
|
339
|
+
err = (e.stderr or str(e)).lower()
|
|
340
|
+
if not any(m in err for m in _REJECTION_MARKERS):
|
|
341
|
+
_logger.warning(
|
|
342
|
+
"push failed (kept locally, will retry): %s",
|
|
343
|
+
e.stderr.strip() if e.stderr else e,
|
|
344
|
+
extra={"event": "push_failed"},
|
|
345
|
+
)
|
|
346
|
+
self.last_status = "push_failed"
|
|
347
|
+
return False
|
|
348
|
+
_logger.info(
|
|
349
|
+
"push rejected, another agent pushed first (attempt %d/%d)",
|
|
350
|
+
attempt,
|
|
351
|
+
self.push_retries,
|
|
352
|
+
extra={"event": "push_rejected"},
|
|
353
|
+
)
|
|
354
|
+
if not self._fetch():
|
|
355
|
+
self.last_status = "push_failed"
|
|
356
|
+
return False
|
|
357
|
+
if not self._rebase_onto_remote():
|
|
358
|
+
self._resolve_lost_race(paths)
|
|
359
|
+
self.last_status = "lost_race"
|
|
360
|
+
return False
|
|
361
|
+
time.sleep(random.uniform(0.2, 1.0) * attempt)
|
|
362
|
+
self.last_status = "push_failed"
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
def acquire_lock_and_push(
|
|
366
|
+
self,
|
|
367
|
+
action_callback,
|
|
368
|
+
message: str,
|
|
369
|
+
paths: Iterable[str],
|
|
370
|
+
author_name: Optional[str] = None,
|
|
371
|
+
author_email: Optional[str] = None,
|
|
372
|
+
) -> bool:
|
|
373
|
+
"""
|
|
374
|
+
1. acquire local lock (always)
|
|
375
|
+
2. sync with remote only if it has news
|
|
376
|
+
3. action_callback() modifies the War Room
|
|
377
|
+
4. commit only `paths` (with optional isolated per-agent Git author/committer)
|
|
378
|
+
5. push any unpushed commit (including leftovers from earlier failures)
|
|
379
|
+
6. release local lock
|
|
380
|
+
Check `self.last_status` for the reason of a False return.
|
|
381
|
+
"""
|
|
382
|
+
paths = list(paths)
|
|
383
|
+
if not self._acquire_local_lock():
|
|
384
|
+
self.last_status = "locked"
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
if not self.sync_if_needed(paths):
|
|
389
|
+
self.last_status = "sync_failed"
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
action_callback()
|
|
393
|
+
|
|
394
|
+
if self.has_changes(paths) and not self.commit(
|
|
395
|
+
message, paths, author_name=author_name, author_email=author_email
|
|
396
|
+
):
|
|
397
|
+
self.last_status = "commit_failed"
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
if self._commits_ahead() > 0 and not self.push(paths):
|
|
401
|
+
return False # push() sets last_status
|
|
402
|
+
|
|
403
|
+
self.last_status = "ok"
|
|
404
|
+
return True
|
|
405
|
+
finally:
|
|
406
|
+
self._release_local_lock()
|
core/init_scaffold.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgLoop Init Scaffold (E07.2).
|
|
3
|
+
|
|
4
|
+
`agloop init`: copies the War Room + governance templates bundled under
|
|
5
|
+
`core/templates/` into the user's repository, without ever overwriting a
|
|
6
|
+
file that already exists at the destination.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from typing import List, Tuple
|
|
12
|
+
|
|
13
|
+
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
|
14
|
+
|
|
15
|
+
# (template path relative to _TEMPLATES_DIR, destination path relative to repo_root)
|
|
16
|
+
_FILES: Tuple[Tuple[str, str], ...] = (
|
|
17
|
+
("war_room.md", "war_room.md"),
|
|
18
|
+
("HISTORICO.md", "HISTORICO.md"),
|
|
19
|
+
("gestao/roadmap.md", "docs/gestao/roadmap.md"),
|
|
20
|
+
("gestao/backlog-produto.md", "docs/gestao/backlog-produto.md"),
|
|
21
|
+
("gestao/matriz-riscos.md", "docs/gestao/matriz-riscos.md"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_EMPTY_DIRS: Tuple[str, ...] = ("docs/arquitetura/decisoes",)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_init(repo_root: str = ".") -> str:
|
|
28
|
+
"""Scaffolds War Room governance files into `repo_root`. Returns a plain-text report."""
|
|
29
|
+
root = os.path.realpath(repo_root)
|
|
30
|
+
created: List[str] = []
|
|
31
|
+
skipped: List[str] = []
|
|
32
|
+
|
|
33
|
+
for template_rel, dest_rel in _FILES:
|
|
34
|
+
dest_path = os.path.join(root, dest_rel)
|
|
35
|
+
if os.path.exists(dest_path):
|
|
36
|
+
skipped.append(dest_rel)
|
|
37
|
+
continue
|
|
38
|
+
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
39
|
+
shutil.copyfile(os.path.join(_TEMPLATES_DIR, template_rel), dest_path)
|
|
40
|
+
created.append(dest_rel)
|
|
41
|
+
|
|
42
|
+
for dir_rel in _EMPTY_DIRS:
|
|
43
|
+
dir_path = os.path.join(root, dir_rel)
|
|
44
|
+
if os.path.isdir(dir_path):
|
|
45
|
+
skipped.append(f"{dir_rel}/")
|
|
46
|
+
else:
|
|
47
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
48
|
+
created.append(f"{dir_rel}/")
|
|
49
|
+
|
|
50
|
+
lines = ["AgLoop - Inicializacao da Sala de Guerra", "=" * 42, ""]
|
|
51
|
+
lines.append(f"[criado] {len(created)} item(ns):")
|
|
52
|
+
for item in created:
|
|
53
|
+
lines.append(f" - {item}")
|
|
54
|
+
if skipped:
|
|
55
|
+
lines.append(f"\n[ja existia, preservado] {len(skipped)} item(ns):")
|
|
56
|
+
for item in skipped:
|
|
57
|
+
lines.append(f" - {item}")
|
|
58
|
+
|
|
59
|
+
return "\n".join(lines)
|
core/mcp_server.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgLoop MCP Server (E04.2 / LGPD-03).
|
|
3
|
+
|
|
4
|
+
Lightweight MCP (Model Context Protocol) server over JSON-RPC 2.0 on stdio —
|
|
5
|
+
no external MCP SDK, reusing the existing `WarRoom`, `GitLock` and the
|
|
6
|
+
workspace-confinement primitives from `core.agent` (DRY, same safety model
|
|
7
|
+
already enforced for `read_file`/`list_dir`).
|
|
8
|
+
|
|
9
|
+
Exposes 3 tools, all confined to the War Room files only (never an arbitrary
|
|
10
|
+
path inside the repository — LGPD-03):
|
|
11
|
+
* `read_war_room` — read the recent transcript, PII-sanitized.
|
|
12
|
+
* `post_war_room_message` — append a message to `war_room.md` (commit+push via GitLock).
|
|
13
|
+
* `get_lock_status` — inspect GitLock / War Room state.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
from core.agent import _is_sensitive_path, _is_within_workspace, sanitize_prompt_pii
|
|
22
|
+
from core.git_lock import GitLock
|
|
23
|
+
from core.scan import run_scan
|
|
24
|
+
from core.war_room import WarRoom
|
|
25
|
+
|
|
26
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
27
|
+
SERVER_NAME = "agloop"
|
|
28
|
+
SERVER_VERSION = "0.5.0"
|
|
29
|
+
|
|
30
|
+
# Only these two files are ever reachable through the MCP tools — fixed by basename,
|
|
31
|
+
# so an attacker-supplied directory prefix (e.g. "../outside/war_room.md") is simply
|
|
32
|
+
# discarded rather than followed (LGPD-03: workspace sandboxing).
|
|
33
|
+
ALLOWED_WAR_ROOM_FILES = {"war_room.md", "HISTORICO.md"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_allowed_file(repo_root: str, filepath: str) -> str:
|
|
37
|
+
"""Resolves `filepath` to an absolute path inside `repo_root`, enforcing the allowlist."""
|
|
38
|
+
basename = os.path.basename(filepath)
|
|
39
|
+
if basename not in ALLOWED_WAR_ROOM_FILES:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Access denied: '{filepath}' is not a War Room file (allowed: {sorted(ALLOWED_WAR_ROOM_FILES)})."
|
|
42
|
+
)
|
|
43
|
+
target = os.path.join(repo_root, basename)
|
|
44
|
+
if not _is_within_workspace(repo_root, target):
|
|
45
|
+
raise ValueError("Access denied: path is outside the repository workspace.")
|
|
46
|
+
if _is_sensitive_path(repo_root, target):
|
|
47
|
+
raise ValueError("Access denied: sensitive path blocked.")
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_war_room(max_turns: int = 6, filepath: str = "war_room.md", repo_root: Optional[str] = None) -> str:
|
|
52
|
+
"""Reads the recent War Room transcript (sliding window), PII-sanitized."""
|
|
53
|
+
root = os.path.realpath(repo_root or os.getcwd())
|
|
54
|
+
target = _resolve_allowed_file(root, filepath)
|
|
55
|
+
war_room = WarRoom(filepath=target)
|
|
56
|
+
return sanitize_prompt_pii(war_room.read_recent(max_turns=max_turns))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def post_war_room_message(
|
|
60
|
+
agent_name: str, message: str, push: bool = True, repo_root: Optional[str] = None
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""Appends `message` to `war_room.md` as `agent_name`; commits+pushes via `GitLock` when `push`."""
|
|
63
|
+
root = os.path.realpath(repo_root or os.getcwd())
|
|
64
|
+
target = _resolve_allowed_file(root, "war_room.md")
|
|
65
|
+
war_room = WarRoom(filepath=target)
|
|
66
|
+
|
|
67
|
+
if not push:
|
|
68
|
+
war_room.append_message(agent_name, message)
|
|
69
|
+
return {"pushed": False, "status": "ok"}
|
|
70
|
+
|
|
71
|
+
git_lock = GitLock(repo_path=root)
|
|
72
|
+
ok = git_lock.acquire_lock_and_push(
|
|
73
|
+
action_callback=lambda: war_room.append_message(agent_name, message),
|
|
74
|
+
message=f"AgLoop: {agent_name} update in war_room.md (via MCP)",
|
|
75
|
+
paths=war_room.paths,
|
|
76
|
+
author_name=agent_name,
|
|
77
|
+
)
|
|
78
|
+
return {"pushed": ok, "status": git_lock.last_status}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_lock_status(filepath: str = "war_room.md", repo_root: Optional[str] = None) -> Dict[str, Any]:
|
|
82
|
+
"""Inspects the local lock and the War Room state for `filepath`."""
|
|
83
|
+
root = os.path.realpath(repo_root or os.getcwd())
|
|
84
|
+
target = _resolve_allowed_file(root, filepath)
|
|
85
|
+
|
|
86
|
+
git_lock = GitLock(repo_path=root)
|
|
87
|
+
lock_dir_real = os.path.realpath(git_lock.lock_dir)
|
|
88
|
+
if not _is_within_workspace(root, lock_dir_real):
|
|
89
|
+
raise ValueError("Access denied: lock directory is outside the repository workspace.")
|
|
90
|
+
|
|
91
|
+
locked = os.path.isdir(git_lock.lock_dir)
|
|
92
|
+
stale = locked and git_lock._lock_is_stale()
|
|
93
|
+
war_room = WarRoom(filepath=target)
|
|
94
|
+
return {
|
|
95
|
+
"locked": locked,
|
|
96
|
+
"stale": stale,
|
|
97
|
+
"last_speaker": war_room.get_last_speaker(),
|
|
98
|
+
"state": war_room.get_state(),
|
|
99
|
+
"paths": war_room.paths,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def scan_repository(repo_root: Optional[str] = None) -> str:
|
|
104
|
+
"""Read-only structure + stack scan of the repository, confined to the workspace (E07.3)."""
|
|
105
|
+
root = os.path.realpath(repo_root or os.getcwd())
|
|
106
|
+
return run_scan(repo_root=root)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
TOOLS = [
|
|
110
|
+
{
|
|
111
|
+
"name": "read_war_room",
|
|
112
|
+
"description": "Read the recent War Room transcript (war_room.md or HISTORICO.md), PII-sanitized.",
|
|
113
|
+
"inputSchema": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"max_turns": {"type": "integer", "default": 6},
|
|
117
|
+
"filepath": {"type": "string", "default": "war_room.md"},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "post_war_room_message",
|
|
123
|
+
"description": "Append a message to war_room.md as agent_name, committing and pushing via GitLock.",
|
|
124
|
+
"inputSchema": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"agent_name": {"type": "string"},
|
|
128
|
+
"message": {"type": "string"},
|
|
129
|
+
"push": {"type": "boolean", "default": True},
|
|
130
|
+
},
|
|
131
|
+
"required": ["agent_name", "message"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "get_lock_status",
|
|
136
|
+
"description": "Inspect the War Room's GitLock state and last speaker.",
|
|
137
|
+
"inputSchema": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"filepath": {"type": "string", "default": "war_room.md"},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "scan_repository",
|
|
146
|
+
"description": "Read-only scan of the repository: directory structure (bounded) and known stack manifests.",
|
|
147
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
_TOOL_FUNCS = {
|
|
152
|
+
"read_war_room": read_war_room,
|
|
153
|
+
"post_war_room_message": post_war_room_message,
|
|
154
|
+
"get_lock_status": get_lock_status,
|
|
155
|
+
"scan_repository": scan_repository,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _dispatch(method: str, params: Dict[str, Any]) -> Any:
|
|
160
|
+
if method == "initialize":
|
|
161
|
+
return {
|
|
162
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
163
|
+
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
164
|
+
"capabilities": {"tools": {}},
|
|
165
|
+
}
|
|
166
|
+
if method == "notifications/initialized":
|
|
167
|
+
return None
|
|
168
|
+
if method == "tools/list":
|
|
169
|
+
return {"tools": TOOLS}
|
|
170
|
+
if method == "tools/call":
|
|
171
|
+
name = params.get("name")
|
|
172
|
+
arguments = params.get("arguments") or {}
|
|
173
|
+
fn = _TOOL_FUNCS.get(name)
|
|
174
|
+
if fn is None:
|
|
175
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
176
|
+
try:
|
|
177
|
+
result = fn(**arguments)
|
|
178
|
+
except TypeError as e:
|
|
179
|
+
raise ValueError(f"Invalid arguments for '{name}': {e}")
|
|
180
|
+
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
|
|
181
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
182
|
+
raise ValueError(f"Unknown method: {method}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _handle_line(line: str) -> Optional[Dict[str, Any]]:
|
|
186
|
+
"""Parses one JSON-RPC 2.0 request/notification and returns the reply (or None for notifications)."""
|
|
187
|
+
try:
|
|
188
|
+
request = json.loads(line)
|
|
189
|
+
except json.JSONDecodeError as e:
|
|
190
|
+
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {e}"}}
|
|
191
|
+
|
|
192
|
+
req_id = request.get("id")
|
|
193
|
+
method = request.get("method", "")
|
|
194
|
+
params = request.get("params") or {}
|
|
195
|
+
is_notification = "id" not in request
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
result = _dispatch(method, params)
|
|
199
|
+
except ValueError as e:
|
|
200
|
+
return None if is_notification else {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": str(e)}}
|
|
201
|
+
except Exception as e:
|
|
202
|
+
return None if is_notification else {
|
|
203
|
+
"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": f"Internal error: {e}"}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if is_notification:
|
|
207
|
+
return None
|
|
208
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
"""Reads JSON-RPC requests from stdin (one per line) and writes replies to stdout."""
|
|
213
|
+
for line in sys.stdin:
|
|
214
|
+
line = line.strip()
|
|
215
|
+
if not line:
|
|
216
|
+
continue
|
|
217
|
+
response = _handle_line(line)
|
|
218
|
+
if response is not None:
|
|
219
|
+
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
220
|
+
sys.stdout.flush()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
main()
|