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/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()