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.
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))
@@ -0,0 +1,3 @@
1
+ # Histórico
2
+
3
+ Turnos encerrados são arquivados aqui automaticamente pelo `GitLock` ao atingir `<!--FIM_TURNO-->`.
@@ -0,0 +1,3 @@
1
+ # Backlog de Produto
2
+
3
+ <!-- Registre aqui os Épicos do projeto, um por seção, com Issue vinculada, status e entregas. -->
@@ -0,0 +1,5 @@
1
+ # Matriz de Riscos
2
+
3
+ | ID | Risco | Probabilidade / Impacto | Status |
4
+ | :---: | :--- | :---: | :---: |
5
+ | R01 | | | Aberto |
@@ -0,0 +1,5 @@
1
+ # Roadmap
2
+
3
+ | Marco | Nome | Escopo Principal | Issues Vinculadas | Status |
4
+ | :---: | :--- | :--- | :---: | :---: |
5
+ | **M1** | | | | Planejado |
@@ -0,0 +1,3 @@
1
+ # War Room
2
+
3
+ This is the IPC communication channel for agents.
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
+