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/privacy_audit.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgLoop Privacy Audit (LGPD-04).
|
|
3
|
+
|
|
4
|
+
Local-only diagnostic (`agloop audit --privacy`): checks `.gitignore` coverage
|
|
5
|
+
for `.env`, lists git author e-mails (flagging which are isolated `@agloop.local`
|
|
6
|
+
agent identities), and scans the War Room files for residual unmasked PII —
|
|
7
|
+
reusing the same regexes `sanitize_prompt_pii` already uses in `core.agent`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import Dict, List
|
|
13
|
+
|
|
14
|
+
from core.agent import _CPF_RE, _EMAIL_RE, _SECRET_TOKEN_RE
|
|
15
|
+
|
|
16
|
+
ROOM_FILES = ("war_room.md", "HISTORICO.md")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _gitignore_protects(repo_root: str, pattern: str) -> bool:
|
|
20
|
+
gitignore = os.path.join(repo_root, ".gitignore")
|
|
21
|
+
if not os.path.isfile(gitignore):
|
|
22
|
+
return False
|
|
23
|
+
with open(gitignore, "r", encoding="utf-8") as f:
|
|
24
|
+
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
|
25
|
+
return any(pattern in line for line in lines)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git_author_emails(repo_root: str) -> List[str]:
|
|
29
|
+
try:
|
|
30
|
+
res = subprocess.run(
|
|
31
|
+
["git", "log", "--all", "--format=%ae"],
|
|
32
|
+
cwd=repo_root,
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=15,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
return []
|
|
40
|
+
return sorted({e.strip() for e in res.stdout.splitlines() if e.strip()})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scan_pii(repo_root: str) -> Dict[str, List[str]]:
|
|
44
|
+
findings: Dict[str, List[str]] = {}
|
|
45
|
+
for name in ROOM_FILES:
|
|
46
|
+
path = os.path.join(repo_root, name)
|
|
47
|
+
if not os.path.isfile(path):
|
|
48
|
+
continue
|
|
49
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
50
|
+
text = f.read()
|
|
51
|
+
|
|
52
|
+
hits: List[str] = []
|
|
53
|
+
if _CPF_RE.search(text):
|
|
54
|
+
hits.append("CPF")
|
|
55
|
+
if _SECRET_TOKEN_RE.search(text):
|
|
56
|
+
hits.append("API_SECRET")
|
|
57
|
+
for m in _EMAIL_RE.finditer(text):
|
|
58
|
+
if m.group(1).lower() != "agloop.local":
|
|
59
|
+
hits.append(f"EXTERNAL_EMAIL:{m.group(0)}")
|
|
60
|
+
if hits:
|
|
61
|
+
findings[name] = hits
|
|
62
|
+
return findings
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_privacy_audit(repo_root: str = ".") -> str:
|
|
66
|
+
"""Builds the LGPD-04 privacy diagnostic report as plain text."""
|
|
67
|
+
root = os.path.realpath(repo_root)
|
|
68
|
+
lines = ["AgLoop - Relatorio de Auditoria de Privacidade (LGPD-04)", "=" * 56, ""]
|
|
69
|
+
|
|
70
|
+
env_protected = _gitignore_protects(root, ".env")
|
|
71
|
+
lines.append(f"[.gitignore] .env protegido: {'OK' if env_protected else 'FALTANDO'}")
|
|
72
|
+
|
|
73
|
+
emails = _git_author_emails(root)
|
|
74
|
+
lines.append(f"\n[git log] e-mails de autoria encontrados ({len(emails)}):")
|
|
75
|
+
for email in emails:
|
|
76
|
+
tag = "isolado (agente)" if email.endswith("@agloop.local") else "ATENCAO: nao isolado"
|
|
77
|
+
lines.append(f" - {email} [{tag}]")
|
|
78
|
+
|
|
79
|
+
findings = _scan_pii(root)
|
|
80
|
+
lines.append(f"\n[PII residual] arquivos verificados: {', '.join(ROOM_FILES)}")
|
|
81
|
+
if findings:
|
|
82
|
+
for name, hits in findings.items():
|
|
83
|
+
lines.append(f" - {name}: {len(hits)} ocorrencia(s) -> {hits}")
|
|
84
|
+
else:
|
|
85
|
+
lines.append(" Nenhuma ocorrencia de CPF, chave de API ou e-mail externo nao mascarado encontrada.")
|
|
86
|
+
|
|
87
|
+
return "\n".join(lines)
|
core/runner.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from core.git_lock import GitLock
|
|
8
|
+
from core.war_room import WarRoom
|
|
9
|
+
|
|
10
|
+
_logger = logging.getLogger("agloop.runner")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _mtime(path: str) -> float:
|
|
14
|
+
return os.path.getmtime(path) if os.path.exists(path) else 0.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_agent_loop(
|
|
18
|
+
agent,
|
|
19
|
+
war_room_file: str = "war_room.md",
|
|
20
|
+
sleep_time: Optional[float] = None,
|
|
21
|
+
local_poll: float = 3.0,
|
|
22
|
+
remote_poll: float = 30.0,
|
|
23
|
+
jitter: float = 2.0,
|
|
24
|
+
max_backoff: float = 60.0,
|
|
25
|
+
max_context_turns: int = 6,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Hybrid Watcher (Vigília Híbrida). The LLM is only invoked when something changed:
|
|
29
|
+
* local_poll -> os.path.getmtime() on the War Room (free, catches same-disk agents)
|
|
30
|
+
* remote_poll -> git fetch + "is origin ahead of me?" (network, catches other machines)
|
|
31
|
+
Idle iterations cost zero tokens and never parse the Markdown.
|
|
32
|
+
|
|
33
|
+
`sleep_time` is kept for backward compatibility and maps to `local_poll`.
|
|
34
|
+
"""
|
|
35
|
+
if sleep_time is not None:
|
|
36
|
+
local_poll = float(sleep_time)
|
|
37
|
+
|
|
38
|
+
git_lock = GitLock(repo_path=".")
|
|
39
|
+
war_room = WarRoom(filepath=war_room_file)
|
|
40
|
+
room_paths = war_room.paths
|
|
41
|
+
|
|
42
|
+
_logger.info(
|
|
43
|
+
"starting agloop",
|
|
44
|
+
extra={
|
|
45
|
+
"event": "loop_start",
|
|
46
|
+
"agent": agent.name,
|
|
47
|
+
"model": agent.model,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
print(f"Starting AgLoop for Agent: {agent.name}")
|
|
51
|
+
print(f"Model: {agent.model}")
|
|
52
|
+
print(f"War Room File: {war_room_file}")
|
|
53
|
+
print(f"Hybrid Watcher: local every ~{local_poll}s, remote every ~{remote_poll}s"
|
|
54
|
+
f"{'' if git_lock.origin else ' (no origin: local-only)'}")
|
|
55
|
+
|
|
56
|
+
# mtime observed at the END of our critical section (inside the lock). Reading it
|
|
57
|
+
# after releasing the lock could swallow a write made by another local agent.
|
|
58
|
+
observed = {"mtime": None}
|
|
59
|
+
# Set inside agent_action_callback when think_and_act() returns None (Issue #11):
|
|
60
|
+
# the outer loop must retry without having committed/pushed anything.
|
|
61
|
+
inference_failed = {"flag": False}
|
|
62
|
+
# Set inside agent_action_callback only when a real message was appended, so the
|
|
63
|
+
# outer loop's "turn_appended" log line isn't emitted for paused/no-op cycles.
|
|
64
|
+
turn_outcome = {"spoke": False}
|
|
65
|
+
|
|
66
|
+
def agent_action_callback():
|
|
67
|
+
inference_failed["flag"] = False
|
|
68
|
+
turn_outcome["spoke"] = False
|
|
69
|
+
try:
|
|
70
|
+
state = war_room.get_state()
|
|
71
|
+
if state in ("WAITING_HUMAN", "CONSENSUS"):
|
|
72
|
+
print(f"[{agent.name}] War Room state is {state}. Pausing inference until human/new turn.")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
last_speaker = war_room.get_last_speaker()
|
|
76
|
+
|
|
77
|
+
# If I am the last speaker, I should wait for others.
|
|
78
|
+
if last_speaker == agent.name:
|
|
79
|
+
print(f"[{agent.name}] I was the last speaker. Waiting for others...")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# It's my turn (or no one has spoken)
|
|
83
|
+
print(f"[{agent.name}] My turn to speak!")
|
|
84
|
+
context = war_room.read_recent(max_turns=max_context_turns)
|
|
85
|
+
response = agent.think_and_act(context)
|
|
86
|
+
if response is None:
|
|
87
|
+
_logger.warning(
|
|
88
|
+
"inference failed, skipping turn without writing to the War Room",
|
|
89
|
+
extra={"event": "turn_failed", "agent": agent.name, "model": agent.model},
|
|
90
|
+
)
|
|
91
|
+
inference_failed["flag"] = True
|
|
92
|
+
return
|
|
93
|
+
war_room.append_message(agent.name, response)
|
|
94
|
+
turn_outcome["spoke"] = True
|
|
95
|
+
print(f"[{agent.name}] Appended response to War Room.")
|
|
96
|
+
finally:
|
|
97
|
+
observed["mtime"] = _mtime(war_room_file)
|
|
98
|
+
|
|
99
|
+
def nap(seconds: float):
|
|
100
|
+
time.sleep(seconds + random.uniform(0, jitter))
|
|
101
|
+
|
|
102
|
+
last_mod_time: Optional[float] = None # None forces a first check on startup
|
|
103
|
+
next_remote_check = 0.0
|
|
104
|
+
pending_retry = False
|
|
105
|
+
failures = 0
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
# ---- 1. Cheap trigger checks (no tokens, no Markdown parsing) ----
|
|
110
|
+
reason = None
|
|
111
|
+
current_mod_time = _mtime(war_room_file)
|
|
112
|
+
if pending_retry:
|
|
113
|
+
reason = "retry"
|
|
114
|
+
elif current_mod_time != last_mod_time:
|
|
115
|
+
reason = "local change"
|
|
116
|
+
elif git_lock.origin and time.monotonic() >= next_remote_check:
|
|
117
|
+
next_remote_check = time.monotonic() + remote_poll
|
|
118
|
+
if git_lock.has_remote_updates():
|
|
119
|
+
reason = "remote update"
|
|
120
|
+
|
|
121
|
+
if reason is None:
|
|
122
|
+
nap(local_poll)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# ---- 2. Something happened: take the lock and let the agent act ----
|
|
126
|
+
print(f"\n--- Loop Iteration for {agent.name} ({reason}) ---")
|
|
127
|
+
observed["mtime"] = None
|
|
128
|
+
success = git_lock.acquire_lock_and_push(
|
|
129
|
+
action_callback=agent_action_callback,
|
|
130
|
+
message=f"AgLoop: {agent.name} update in {war_room_file}",
|
|
131
|
+
paths=room_paths,
|
|
132
|
+
author_name=agent.name,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if success:
|
|
136
|
+
last_mod_time = observed["mtime"] if observed["mtime"] is not None else _mtime(war_room_file)
|
|
137
|
+
|
|
138
|
+
if inference_failed["flag"]:
|
|
139
|
+
# Nothing was written/committed: back off like any other transient
|
|
140
|
+
# failure and let the next iteration retry think_and_act cleanly.
|
|
141
|
+
pending_retry = True
|
|
142
|
+
failures += 1
|
|
143
|
+
backoff = min(2 ** failures, max_backoff) + random.uniform(1, 5)
|
|
144
|
+
print(f"[{agent.name}] Inference failed. Retrying in {backoff:.1f}s.")
|
|
145
|
+
time.sleep(backoff)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
pending_retry = False
|
|
149
|
+
failures = 0
|
|
150
|
+
if turn_outcome["spoke"]:
|
|
151
|
+
try:
|
|
152
|
+
commit_sha = git_lock.repo.head.commit.hexsha[:10]
|
|
153
|
+
except Exception:
|
|
154
|
+
commit_sha = None
|
|
155
|
+
_logger.info(
|
|
156
|
+
"turn cycle complete",
|
|
157
|
+
extra={
|
|
158
|
+
"event": "turn_appended",
|
|
159
|
+
"agent": agent.name,
|
|
160
|
+
"model": agent.model,
|
|
161
|
+
"commit_sha": commit_sha,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
nap(local_poll)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# ---- 3. Failure: keep the trigger armed (no lost wake-ups) ----
|
|
168
|
+
pending_retry = True
|
|
169
|
+
status = git_lock.last_status
|
|
170
|
+
if status == "locked":
|
|
171
|
+
# Another local agent holds the lock: not an error, just wait a bit.
|
|
172
|
+
print(f"[{agent.name}] Local lock busy, another agent is acting. Retrying soon.")
|
|
173
|
+
nap(local_poll)
|
|
174
|
+
continue
|
|
175
|
+
if status == "lost_race":
|
|
176
|
+
# Someone pushed first: re-read immediately with fresh context.
|
|
177
|
+
print(f"[{agent.name}] Lost the race to another agent. Re-reading the War Room.")
|
|
178
|
+
failures = 0
|
|
179
|
+
nap(1.0)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
failures += 1
|
|
183
|
+
backoff = min(2 ** failures, max_backoff) + random.uniform(1, 5)
|
|
184
|
+
print(f"[{agent.name}] Sync failed ({status}). Backing off {backoff:.1f}s.")
|
|
185
|
+
time.sleep(backoff)
|
|
186
|
+
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
print("\nExiting AgLoop.")
|
|
189
|
+
break
|
|
190
|
+
except Exception as e:
|
|
191
|
+
pending_retry = True
|
|
192
|
+
failures += 1
|
|
193
|
+
backoff = min(2 ** failures, max_backoff) + random.uniform(1, 5)
|
|
194
|
+
print(f"Unexpected error in loop: {e}. Backing off {backoff:.1f}s.")
|
|
195
|
+
time.sleep(backoff)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def watch_war_room(
|
|
199
|
+
war_room_file: str = "war_room.md",
|
|
200
|
+
agent_name: str = "Gemini",
|
|
201
|
+
local_poll: float = 2.0,
|
|
202
|
+
remote_poll: float = 30.0,
|
|
203
|
+
once: bool = False,
|
|
204
|
+
repo_path: str = ".",
|
|
205
|
+
jitter: float = 2.0,
|
|
206
|
+
max_backoff: float = 60.0,
|
|
207
|
+
) -> Optional[str]:
|
|
208
|
+
"""
|
|
209
|
+
Passive Hybrid Watcher (SSOT shared with `scripts/vigia.py`).
|
|
210
|
+
Monitors `war_room_file` locally (`mtime`, zero cost) and remotely (`GitLock.sync_if_needed`).
|
|
211
|
+
When `once=True`, returns the new speaker's name as soon as the War Room changes and
|
|
212
|
+
the last speaker is not `agent_name` (allowing a background CLI task to exit 0 and wake the agent).
|
|
213
|
+
|
|
214
|
+
Any exception outside the `GitCommandError` cases already handled inside `GitLock`
|
|
215
|
+
(e.g. transient disk I/O, a file briefly missing mid-write) is caught per-iteration and
|
|
216
|
+
retried with exponential backoff, so the watcher never dies silently (Risco R04).
|
|
217
|
+
"""
|
|
218
|
+
git_lock = GitLock(repo_path=repo_path)
|
|
219
|
+
room_path = os.path.join(repo_path, war_room_file) if not os.path.isabs(war_room_file) else war_room_file
|
|
220
|
+
war_room = WarRoom(filepath=room_path)
|
|
221
|
+
|
|
222
|
+
last_mtime = _mtime(room_path)
|
|
223
|
+
last_speaker = war_room.get_last_speaker()
|
|
224
|
+
print(
|
|
225
|
+
f"[Vigia] Monitorando '{war_room_file}' (Agente: {agent_name}, modo: {'once' if once else 'contínuo'}, "
|
|
226
|
+
f"último orador: '{last_speaker}')",
|
|
227
|
+
flush=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
next_remote_check = time.monotonic() + remote_poll
|
|
231
|
+
failures = 0
|
|
232
|
+
|
|
233
|
+
while True:
|
|
234
|
+
try:
|
|
235
|
+
time.sleep(local_poll)
|
|
236
|
+
|
|
237
|
+
changed = False
|
|
238
|
+
current_mtime = _mtime(room_path)
|
|
239
|
+
if current_mtime != last_mtime:
|
|
240
|
+
last_mtime = current_mtime
|
|
241
|
+
changed = True
|
|
242
|
+
print(f"\n[VIGIA ALERTA] Modificação local detectada em {war_room_file}!", flush=True)
|
|
243
|
+
elif git_lock.origin and time.monotonic() >= next_remote_check:
|
|
244
|
+
next_remote_check = time.monotonic() + remote_poll
|
|
245
|
+
if git_lock.has_remote_updates():
|
|
246
|
+
print("\n[VIGIA ALERTA] Novos commits detectados em origin!", flush=True)
|
|
247
|
+
if git_lock.sync_if_needed(war_room.paths):
|
|
248
|
+
last_mtime = _mtime(room_path)
|
|
249
|
+
changed = True
|
|
250
|
+
|
|
251
|
+
if changed:
|
|
252
|
+
speaker = war_room.get_last_speaker()
|
|
253
|
+
print(f"[Vigia] Novo orador: '{speaker}'", flush=True)
|
|
254
|
+
if speaker and speaker != agent_name:
|
|
255
|
+
print(f"[Vigia] >>> ATENÇÃO: {speaker} respondeu! Vez de {agent_name}.", flush=True)
|
|
256
|
+
if once:
|
|
257
|
+
return speaker
|
|
258
|
+
else:
|
|
259
|
+
print(f"[Vigia] {agent_name} falou por último. Aguardando réplica...", flush=True)
|
|
260
|
+
|
|
261
|
+
failures = 0
|
|
262
|
+
except Exception as e:
|
|
263
|
+
failures += 1
|
|
264
|
+
backoff = min(2 ** failures, max_backoff) + random.uniform(0, jitter)
|
|
265
|
+
print(f"[Vigia] Erro inesperado no laço de monitoramento: {e}. Tentando novamente em {backoff:.1f}s.", flush=True)
|
|
266
|
+
time.sleep(backoff)
|
|
267
|
+
|
core/scan.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgLoop Repository Scan (E07.3).
|
|
3
|
+
|
|
4
|
+
`agloop scan` / MCP tool `scan_repository`: read-only inspection of a
|
|
5
|
+
repository — directory structure (bounded depth, defensive caps) and known
|
|
6
|
+
stack manifests — reusing the same confinement/PII primitives already
|
|
7
|
+
enforced for `read_file`/`list_dir` in `core.agent`. Never triggered
|
|
8
|
+
automatically (see the informational-only `SessionStart` hook); always an
|
|
9
|
+
explicit, user-initiated action.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from core.agent import _is_sensitive_path, _is_within_workspace, sanitize_prompt_pii
|
|
17
|
+
|
|
18
|
+
MAX_DEPTH = 3
|
|
19
|
+
MAX_LISTED_ITEMS = 30
|
|
20
|
+
GITIGNORE_CHECK_TIMEOUT = 3
|
|
21
|
+
|
|
22
|
+
_DEFAULT_IGNORED_DIRS = {
|
|
23
|
+
"node_modules", ".venv", "venv", "__pycache__", ".git", ".agloop.lock",
|
|
24
|
+
"target", "dist", "build", ".pytest_cache",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_STACK_MANIFESTS = (
|
|
28
|
+
"pyproject.toml", "requirements.txt", "package.json", "Cargo.toml",
|
|
29
|
+
"go.mod", "pom.xml", "Dockerfile",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_gitignored(repo_root: str, rel_path: str) -> bool:
|
|
34
|
+
"""`git check-ignore` with a short timeout; defensively returns False if not a git repo or on any error."""
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "check-ignore", "-q", rel_path],
|
|
38
|
+
cwd=repo_root,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
timeout=GITIGNORE_CHECK_TIMEOUT,
|
|
41
|
+
check=False,
|
|
42
|
+
)
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
return result.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detect_stack(root: str) -> List[str]:
|
|
49
|
+
return [name for name in _STACK_MANIFESTS if os.path.isfile(os.path.join(root, name))]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _walk(root: str, current: str, depth: int, lines: List[str], counter: List[int]) -> None:
|
|
53
|
+
if depth > MAX_DEPTH or counter[0] >= MAX_LISTED_ITEMS:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
entries = sorted(os.listdir(current))
|
|
57
|
+
except OSError:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
for entry in entries:
|
|
61
|
+
if counter[0] >= MAX_LISTED_ITEMS:
|
|
62
|
+
lines.append(" " * depth + "... e mais itens (limite do relatório atingido)")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
full = os.path.join(current, entry)
|
|
66
|
+
rel = os.path.relpath(full, root).replace("\\", "/")
|
|
67
|
+
|
|
68
|
+
if entry in _DEFAULT_IGNORED_DIRS:
|
|
69
|
+
continue
|
|
70
|
+
if not _is_within_workspace(root, full) or _is_sensitive_path(root, full):
|
|
71
|
+
continue
|
|
72
|
+
if _is_gitignored(root, rel):
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
is_dir = os.path.isdir(full)
|
|
76
|
+
lines.append(" " * depth + entry + ("/" if is_dir else ""))
|
|
77
|
+
counter[0] += 1
|
|
78
|
+
|
|
79
|
+
if is_dir:
|
|
80
|
+
_walk(root, full, depth + 1, lines, counter)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_scan(repo_root: str = ".") -> str:
|
|
84
|
+
"""Builds a read-only structure + stack report as plain text."""
|
|
85
|
+
root = os.path.realpath(repo_root)
|
|
86
|
+
lines = ["AgLoop - Relatorio de Varredura do Repositorio", "=" * 48, ""]
|
|
87
|
+
|
|
88
|
+
stack = _detect_stack(root)
|
|
89
|
+
lines.append(f"[stack detectada] {len(stack)} manifesto(s):")
|
|
90
|
+
if stack:
|
|
91
|
+
for name in stack:
|
|
92
|
+
lines.append(f" - {name}")
|
|
93
|
+
else:
|
|
94
|
+
lines.append(" Nenhum manifesto de stack conhecido encontrado na raiz.")
|
|
95
|
+
|
|
96
|
+
has_war_room = os.path.isfile(os.path.join(root, "war_room.md"))
|
|
97
|
+
has_gestao = os.path.isdir(os.path.join(root, "docs", "gestao"))
|
|
98
|
+
lines.append(f"\n[governanca AgLoop] war_room.md: {'OK' if has_war_room else 'AUSENTE'}")
|
|
99
|
+
lines.append(f"[governanca AgLoop] docs/gestao/: {'OK' if has_gestao else 'AUSENTE'}")
|
|
100
|
+
if not has_war_room:
|
|
101
|
+
lines.append(" Dica: execute 'agloop init' para criar a governanca.")
|
|
102
|
+
|
|
103
|
+
lines.append(f"\n[estrutura de diretorios] profundidade maxima {MAX_DEPTH}, até {MAX_LISTED_ITEMS} itens:")
|
|
104
|
+
tree_lines: List[str] = []
|
|
105
|
+
_walk(root, root, 0, tree_lines, [0])
|
|
106
|
+
lines.extend(tree_lines if tree_lines else [" (vazio)"])
|
|
107
|
+
|
|
108
|
+
return sanitize_prompt_pii("\n".join(lines))
|
core/war_room.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
CLOSE_MARKERS = (
|
|
6
|
+
"<!--FIM_TURNO-->",
|
|
7
|
+
"<!-- AGLOOP_STATE: CONSENSUS -->",
|
|
8
|
+
"STATUS: CONSENSUS",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
WAITING_HUMAN_MARKERS = (
|
|
12
|
+
"<!-- AGLOOP_STATE: WAITING_HUMAN -->",
|
|
13
|
+
"STATUS: WAITING_HUMAN",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WarRoom:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
filepath: str = "war_room.md",
|
|
21
|
+
archive_filepath: Optional[str] = None,
|
|
22
|
+
keep_recent_turns: int = 1,
|
|
23
|
+
):
|
|
24
|
+
self.filepath = filepath
|
|
25
|
+
if archive_filepath is not None:
|
|
26
|
+
self.archive_filepath = archive_filepath
|
|
27
|
+
else:
|
|
28
|
+
directory = os.path.dirname(self.filepath)
|
|
29
|
+
self.archive_filepath = os.path.join(directory, "HISTORICO.md") if directory else "HISTORICO.md"
|
|
30
|
+
self.keep_recent_turns = keep_recent_turns
|
|
31
|
+
|
|
32
|
+
if not os.path.exists(self.filepath):
|
|
33
|
+
with open(self.filepath, "w", encoding="utf-8") as f:
|
|
34
|
+
f.write("# AgLoop War Room\n\nThis is the IPC communication channel for agents.\n\n")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def paths(self) -> List[str]:
|
|
38
|
+
"""Paths managed by this WarRoom (main room + archive file for atomic commits)."""
|
|
39
|
+
return [self.filepath, self.archive_filepath]
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def has_close_marker(text: str) -> bool:
|
|
43
|
+
"""Returns True if `text` contains a turn-closing or consensus marker."""
|
|
44
|
+
return any(marker in text for marker in CLOSE_MARKERS)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def has_waiting_human_marker(text: str) -> bool:
|
|
48
|
+
"""Returns True if `text` contains a waiting-for-human marker."""
|
|
49
|
+
return any(marker in text for marker in WAITING_HUMAN_MARKERS)
|
|
50
|
+
|
|
51
|
+
def read_all(self) -> str:
|
|
52
|
+
"""Reads the entire war room content."""
|
|
53
|
+
with open(self.filepath, "r", encoding="utf-8") as f:
|
|
54
|
+
return f.read()
|
|
55
|
+
|
|
56
|
+
def read_recent(self, max_turns: int = 6) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Reads the War Room header plus at most the last `max_turns` speaker messages
|
|
59
|
+
(sliding context window — E03.1 / Risco R07).
|
|
60
|
+
"""
|
|
61
|
+
content = self.read_all()
|
|
62
|
+
if max_turns <= 0:
|
|
63
|
+
parts = re.split(r"(?=^##\s+(?!Turno\b))", content, flags=re.MULTILINE)
|
|
64
|
+
return (parts[0].rstrip() + "\n") if parts else ""
|
|
65
|
+
|
|
66
|
+
parts = re.split(r"(?=^##\s+(?!Turno\b))", content, flags=re.MULTILINE)
|
|
67
|
+
header = parts[0] if parts else ""
|
|
68
|
+
speaker_blocks = [p for p in parts[1:] if p.strip()]
|
|
69
|
+
if len(speaker_blocks) <= max_turns:
|
|
70
|
+
return content
|
|
71
|
+
|
|
72
|
+
kept = speaker_blocks[-max_turns:]
|
|
73
|
+
return header.rstrip() + "\n\n" + "\n\n".join(b.strip() for b in kept) + "\n"
|
|
74
|
+
|
|
75
|
+
def get_state(self) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Inspects the latest speaker block (after any trailing `<!--INICIANDO_TURNO-->`)
|
|
78
|
+
and returns one of:
|
|
79
|
+
* `"WAITING_HUMAN"` — if a human-intervention marker is present
|
|
80
|
+
* `"CONSENSUS"` — if a consensus / turn-closed marker is present
|
|
81
|
+
* `"ACTIVE"` — otherwise
|
|
82
|
+
"""
|
|
83
|
+
content = self.read_all()
|
|
84
|
+
parts = re.split(r"(?=^##\s+(?!Turno\b))", content, flags=re.MULTILINE)
|
|
85
|
+
speaker_blocks = [p for p in parts[1:] if p.strip()]
|
|
86
|
+
if not speaker_blocks:
|
|
87
|
+
return "ACTIVE"
|
|
88
|
+
|
|
89
|
+
last_segment = speaker_blocks[-1].split("<!--INICIANDO_TURNO-->")[-1]
|
|
90
|
+
if self.has_waiting_human_marker(last_segment):
|
|
91
|
+
return "WAITING_HUMAN"
|
|
92
|
+
if self.has_close_marker(last_segment):
|
|
93
|
+
return "CONSENSUS"
|
|
94
|
+
return "ACTIVE"
|
|
95
|
+
|
|
96
|
+
def append_message(self, agent_name: str, message: str):
|
|
97
|
+
"""
|
|
98
|
+
Appends a message to the war room. If the message carries a close/consensus
|
|
99
|
+
marker (`<!--FIM_TURNO-->` or `CONSENSUS`), older turns are automatically
|
|
100
|
+
archived into `self.archive_filepath` (`HISTORICO.md`) in the same disk operation.
|
|
101
|
+
"""
|
|
102
|
+
with open(self.filepath, "a", encoding="utf-8") as f:
|
|
103
|
+
f.write(f"\n## {agent_name}\n\n{message}\n")
|
|
104
|
+
if self.has_close_marker(message):
|
|
105
|
+
self.archive_old_turns(keep_recent=self.keep_recent_turns)
|
|
106
|
+
|
|
107
|
+
def _split_header_and_blocks(self, content: str):
|
|
108
|
+
"""
|
|
109
|
+
Splits the War Room into (preamble_header, blocks).
|
|
110
|
+
If formal `<!--INICIANDO_TURNO-->` markers exist, splits by turn blocks
|
|
111
|
+
(treating any pre-turn speaker sections as a block before the first formal turn);
|
|
112
|
+
otherwise splits by `## <Speaker>` sections.
|
|
113
|
+
"""
|
|
114
|
+
if "<!--INICIANDO_TURNO-->" in content:
|
|
115
|
+
parts = re.split(r"(?=^(?:---\s*\n+)?<!--INICIANDO_TURNO-->)", content, flags=re.MULTILINE)
|
|
116
|
+
first_part = parts[0]
|
|
117
|
+
turn_blocks = [p for p in parts[1:] if p.strip()]
|
|
118
|
+
# Separate the top `# ...` header from any legacy `## Speaker` messages in first_part
|
|
119
|
+
m = re.search(r"^##\s+", first_part, flags=re.MULTILINE)
|
|
120
|
+
if m:
|
|
121
|
+
header = first_part[: m.start()]
|
|
122
|
+
legacy_block = first_part[m.start() :]
|
|
123
|
+
blocks = ([legacy_block] if legacy_block.strip() else []) + turn_blocks
|
|
124
|
+
else:
|
|
125
|
+
header = first_part
|
|
126
|
+
blocks = turn_blocks
|
|
127
|
+
return header, blocks
|
|
128
|
+
|
|
129
|
+
parts = re.split(r"(?=^##\s+)", content, flags=re.MULTILINE)
|
|
130
|
+
header = parts[0] if parts else ""
|
|
131
|
+
blocks = [p for p in parts[1:] if p.strip()]
|
|
132
|
+
return header, blocks
|
|
133
|
+
|
|
134
|
+
def archive_old_turns(self, keep_recent: Optional[int] = None) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Moves older blocks/turns to `self.archive_filepath` (`HISTORICO.md`),
|
|
137
|
+
keeping the `keep_recent` most recent block(s) in `self.filepath`.
|
|
138
|
+
Returns True if any block was archived.
|
|
139
|
+
"""
|
|
140
|
+
keep = self.keep_recent_turns if keep_recent is None else max(1, int(keep_recent))
|
|
141
|
+
content = self.read_all()
|
|
142
|
+
header, blocks = self._split_header_and_blocks(content)
|
|
143
|
+
if len(blocks) <= keep:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
to_archive = blocks[:-keep]
|
|
147
|
+
to_keep = blocks[-keep:]
|
|
148
|
+
|
|
149
|
+
if not os.path.exists(self.archive_filepath):
|
|
150
|
+
with open(self.archive_filepath, "w", encoding="utf-8") as af:
|
|
151
|
+
af.write("# AgLoop — Histórico da Sala de Guerra\n\n")
|
|
152
|
+
|
|
153
|
+
with open(self.archive_filepath, "a", encoding="utf-8") as af:
|
|
154
|
+
for block in to_archive:
|
|
155
|
+
af.write(block.rstrip() + "\n\n")
|
|
156
|
+
|
|
157
|
+
new_room = header.rstrip() + "\n\n" + "\n\n".join(b.strip() for b in to_keep) + "\n"
|
|
158
|
+
with open(self.filepath, "w", encoding="utf-8") as rf:
|
|
159
|
+
rf.write(new_room)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
def close_turn_and_archive(
|
|
163
|
+
self,
|
|
164
|
+
agent_name: Optional[str] = None,
|
|
165
|
+
closing_message: str = "<!--FIM_TURNO-->",
|
|
166
|
+
keep_recent: Optional[int] = None,
|
|
167
|
+
) -> List[str]:
|
|
168
|
+
"""
|
|
169
|
+
Appends a closing message (ensuring a close marker is present) and archives
|
|
170
|
+
older turns into `HISTORICO.md` in one step so `GitLock` can commit both
|
|
171
|
+
files atomically in a single commit.
|
|
172
|
+
"""
|
|
173
|
+
if not self.has_close_marker(closing_message):
|
|
174
|
+
closing_message = f"{closing_message.rstrip()}\n\n<!--FIM_TURNO-->"
|
|
175
|
+
|
|
176
|
+
if agent_name:
|
|
177
|
+
with open(self.filepath, "a", encoding="utf-8") as f:
|
|
178
|
+
f.write(f"\n## {agent_name}\n\n{closing_message}\n")
|
|
179
|
+
else:
|
|
180
|
+
with open(self.filepath, "a", encoding="utf-8") as f:
|
|
181
|
+
f.write(f"\n{closing_message}\n")
|
|
182
|
+
|
|
183
|
+
self.archive_old_turns(keep_recent=keep_recent)
|
|
184
|
+
return self.paths
|
|
185
|
+
|
|
186
|
+
def get_last_speaker(self) -> Optional[str]:
|
|
187
|
+
"""
|
|
188
|
+
Parses the file to find who spoke last.
|
|
189
|
+
Looks for the last '## AgentName' header.
|
|
190
|
+
"""
|
|
191
|
+
content = self.read_all()
|
|
192
|
+
matches = re.findall(r"^##\s+(.+)$", content, flags=re.MULTILINE)
|
|
193
|
+
if matches:
|
|
194
|
+
return matches[-1].strip()
|
|
195
|
+
return None
|
|
196
|
+
|