multi-agent-platform 0.1.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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
cli/agent_client.py ADDED
@@ -0,0 +1,554 @@
1
+ """Persistent in-process Claude SDK client with session resume.
2
+
3
+ Used by map-runtime-waker (event-driven wake) and legacy bridges. The process
4
+ holds ONE ``ClaudeSDKClient`` for its lifetime; ``claude_session_id`` is
5
+ persisted in local state so the next restart resumes the same Claude session
6
+ (via ``ClaudeAgentOptions(resume=...)``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ import re
15
+ from collections.abc import Callable
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any, Literal, Protocol, TypedDict
19
+
20
+ from cli.session_wake_log import (
21
+ DEFAULT_SESSION_LOG_DIR,
22
+ append_session_event,
23
+ append_session_wake_log,
24
+ resolve_session_log_path,
25
+ text_summary,
26
+ tool_result_summary,
27
+ tool_use_summary,
28
+ )
29
+
30
+ logger = logging.getLogger("map.agent_client")
31
+
32
+ DEFAULT_ALLOWED_TOOLS: list[str] = [
33
+ "Skill",
34
+ "Bash",
35
+ "Read",
36
+ "Write",
37
+ "Edit",
38
+ "Glob",
39
+ "Grep",
40
+ "TodoWrite",
41
+ ]
42
+
43
+ _CREDENTIAL_ENV_KEYS: tuple[str, ...] = (
44
+ "ANTHROPIC_API_KEY",
45
+ "ANTHROPIC_AUTH_TOKEN",
46
+ "ANTHROPIC_BASE_URL",
47
+ )
48
+ _MODEL_ENV_KEYS: tuple[str, ...] = ("ANTHROPIC_MODEL", "CLAUDE_MODEL")
49
+
50
+ _EXPORT_RE = re.compile(r"^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
51
+
52
+ IntegrationMode = Literal["bridge", "waker", "manual"]
53
+
54
+
55
+ class WakeUpEvent(TypedDict, total=False):
56
+ """Streamed wake-up event consumed by bridge / waker cycle logs."""
57
+
58
+ type: str # "text" | "result" | "tool"
59
+ content: str
60
+ session_id: str
61
+ is_error: bool
62
+
63
+
64
+ class PersonaAgentLike(Protocol):
65
+ """Minimal Claude SDK client surface used by PersonaAgentClient."""
66
+
67
+ async def connect(self, prompt: str | None = None) -> None: ...
68
+ async def disconnect(self) -> None: ...
69
+ async def query(self, prompt: str) -> None: ...
70
+ def receive_response(self) -> Any: ...
71
+
72
+
73
+ class PersonaAgentClient:
74
+ """Per-persona persistent Claude SDK client with long-lived connection."""
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ persona: str,
80
+ state: dict[str, Any],
81
+ save_state_fn: Callable[[], None],
82
+ project_root: Path,
83
+ extra_env: dict[str, str] | None = None,
84
+ model: str | None = None,
85
+ allowed_tools: list[str] | None = None,
86
+ integration: IntegrationMode = "bridge",
87
+ session_log_dir: Path | None = None,
88
+ _client_factory: Callable[[Any], PersonaAgentLike] | None = None,
89
+ wake_timeout: float = 1800.0,
90
+ ) -> None:
91
+ self.persona = persona
92
+ self.state = state
93
+ self._save_state_fn = save_state_fn
94
+ self.project_root = project_root
95
+ self.extra_env = dict(extra_env or {})
96
+ self.model = model or self._resolve_model()
97
+ self.allowed_tools = list(allowed_tools or DEFAULT_ALLOWED_TOOLS)
98
+ self.integration = integration
99
+ self.session_log_dir = session_log_dir
100
+ # 单次 wake 内,两条 agent 消息之间的最大间隔(秒)。超过则判定 Agent
101
+ # Runtime 卡死并中断,避免 bridge / waker 永久阻塞在 receive_response()
102
+ # 上。合法长任务期间 Agent 会持续流式发消息,不会触发;真正挂起(网络
103
+ # 掉线、SDK 无响应)才会被超时打断。
104
+ self.wake_timeout = wake_timeout
105
+ self._client_factory = _client_factory
106
+ self._client: PersonaAgentLike | None = None
107
+ self._connected = False
108
+
109
+ async def connect(self) -> None:
110
+ if self._connected:
111
+ return
112
+ resume_session_id = self.state.get("claude_session_id")
113
+ options = self._build_options(resume=resume_session_id)
114
+ client = self._make_client(options)
115
+ await client.connect()
116
+ self._client = client
117
+ self._connected = True
118
+ logger.info(
119
+ "[%s] Claude SDK connected (resume=%s, integration=%s)",
120
+ self.persona,
121
+ resume_session_id or "<new>",
122
+ self.integration,
123
+ )
124
+
125
+ async def wake_up(
126
+ self,
127
+ prompt: str,
128
+ *,
129
+ on_event: Callable[[WakeUpEvent], None] | None = None,
130
+ event_id: str | None = None,
131
+ event_source: str = "polling",
132
+ fingerprint: str | None = None,
133
+ ) -> str:
134
+ if not self._connected or self._client is None:
135
+ await self.connect()
136
+ assert self._client is not None
137
+
138
+ from claude_agent_sdk import (
139
+ AssistantMessage,
140
+ ResultMessage,
141
+ TextBlock,
142
+ ToolResultBlock,
143
+ ToolUseBlock,
144
+ UserMessage,
145
+ )
146
+
147
+ resume_session_id = self.state.get("claude_session_id")
148
+ # Resolve the session log path up front so every event of this wake
149
+ # (wake/text/tool_use/tool_result/result) lands in one file, even when
150
+ # the real session_id only arrives with the ResultMessage. A long
151
+ # running turn is then observable live — a stuck agent shows up as the
152
+ # last event ts going stale.
153
+ pre_sid = resume_session_id or f"new-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%f')}"
154
+ log_path = resolve_session_log_path(self._resolve_session_log_dir(), pre_sid, self.persona)
155
+ self._log_event(
156
+ log_path,
157
+ event="wake",
158
+ summary=text_summary(prompt),
159
+ event_id=event_id,
160
+ event_source=event_source,
161
+ fingerprint=fingerprint,
162
+ )
163
+
164
+ await self._client.query(prompt)
165
+ result: ResultMessage | None = None
166
+ response_parts: list[str] = []
167
+ _response_iter = self._client.receive_response().__aiter__()
168
+ while True:
169
+ # 单步超时:两条 agent 消息之间最多等 wake_timeout 秒。Agent 流式
170
+ # 响应期间持续有消息;只有真正卡死(无任何消息)才超时中断,
171
+ # 防止 bridge / waker 永久阻塞在 receive_response() 上。
172
+ try:
173
+ msg = await asyncio.wait_for(
174
+ _response_iter.__anext__(), timeout=self.wake_timeout
175
+ )
176
+ except StopAsyncIteration:
177
+ break
178
+ except TimeoutError:
179
+ self._log_event(
180
+ log_path,
181
+ event="timeout",
182
+ summary=f"no agent message for {self.wake_timeout}s; aborting wake",
183
+ event_id=event_id,
184
+ event_source=event_source,
185
+ fingerprint=fingerprint,
186
+ )
187
+ result = None
188
+ break
189
+ if isinstance(msg, AssistantMessage):
190
+ for block in msg.content:
191
+ if isinstance(block, TextBlock):
192
+ response_parts.append(block.text)
193
+ if on_event is not None:
194
+ on_event({"type": "text", "content": block.text})
195
+ self._log_event(
196
+ log_path,
197
+ event="text",
198
+ summary=text_summary(block.text),
199
+ event_id=event_id,
200
+ event_source=event_source,
201
+ fingerprint=fingerprint,
202
+ )
203
+ elif isinstance(block, ToolUseBlock):
204
+ if on_event is not None:
205
+ on_event(
206
+ {
207
+ "type": "tool_use",
208
+ "name": block.name,
209
+ "content": tool_use_summary(block.name, block.input),
210
+ }
211
+ )
212
+ self._log_event(
213
+ log_path,
214
+ event="tool_use",
215
+ summary=tool_use_summary(block.name, block.input),
216
+ event_id=event_id,
217
+ event_source=event_source,
218
+ fingerprint=fingerprint,
219
+ )
220
+ elif isinstance(msg, UserMessage):
221
+ # UserMessage.content may be a plain str; only scan block lists.
222
+ if isinstance(msg.content, list):
223
+ for block in msg.content:
224
+ if isinstance(block, ToolResultBlock):
225
+ if on_event is not None:
226
+ on_event(
227
+ {
228
+ "type": "tool_result",
229
+ "is_error": bool(block.is_error),
230
+ "content": tool_result_summary(block.content, block.is_error),
231
+ }
232
+ )
233
+ self._log_event(
234
+ log_path,
235
+ event="tool_result",
236
+ summary=tool_result_summary(block.content, block.is_error),
237
+ event_id=event_id,
238
+ event_source=event_source,
239
+ fingerprint=fingerprint,
240
+ )
241
+ elif isinstance(msg, ResultMessage):
242
+ result = msg
243
+ if on_event is not None:
244
+ on_event(
245
+ {
246
+ "type": "result",
247
+ "session_id": msg.session_id,
248
+ "is_error": bool(getattr(msg, "is_error", False)),
249
+ }
250
+ )
251
+
252
+ if result is not None and getattr(result, "session_id", None):
253
+ new_sid = result.session_id
254
+ if new_sid != self.state.get("claude_session_id"):
255
+ self.state["claude_session_id"] = new_sid
256
+ self._save_state_fn()
257
+ if result is not None and getattr(result, "is_error", False):
258
+ status = "error"
259
+ elif result is None:
260
+ status = "no_response"
261
+ else:
262
+ status = "ok"
263
+
264
+ self.state["last_wakeup_at"] = datetime.now(UTC).isoformat()
265
+ self.state["last_wakeup_status"] = status
266
+ self._save_state_fn()
267
+
268
+ response_text = "".join(response_parts)
269
+ log_session_id = self._wake_log_session_id(result, resume_session_id)
270
+ # Result summary goes into the same file as the live events above; keep
271
+ # the A3-audit field set by routing through append_session_wake_log with
272
+ # the pre-resolved log_path.
273
+ self._append_wake_session_log(
274
+ session_id=log_session_id,
275
+ prompt=prompt,
276
+ response_text=response_text,
277
+ status=status,
278
+ event_id=event_id,
279
+ event_source=event_source,
280
+ fingerprint=fingerprint,
281
+ log_path=log_path,
282
+ )
283
+ return status
284
+
285
+ async def disconnect(self) -> None:
286
+ if self._client is None or not self._connected:
287
+ self._connected = False
288
+ return
289
+ try:
290
+ await self._client.disconnect()
291
+ except Exception as exc: # noqa: BLE001
292
+ logger.warning("[%s] Claude SDK disconnect failed: %s", self.persona, exc)
293
+ finally:
294
+ self._client = None
295
+ self._connected = False
296
+
297
+ def _make_client(self, options: Any) -> PersonaAgentLike:
298
+ if self._client_factory is not None:
299
+ return self._client_factory(options)
300
+ from claude_agent_sdk import ClaudeSDKClient
301
+
302
+ return ClaudeSDKClient(options=options)
303
+
304
+ def _build_options(self, *, resume: str | None) -> Any:
305
+ from claude_agent_sdk import ClaudeAgentOptions
306
+
307
+ env = self._resolve_env()
308
+ append_prompt = self._system_append_prompt()
309
+ kwargs: dict[str, Any] = dict(
310
+ cwd=str(self.project_root),
311
+ setting_sources=["project"],
312
+ system_prompt={"type": "preset", "preset": "claude_code", "append": append_prompt},
313
+ allowed_tools=self.allowed_tools,
314
+ permission_mode="acceptEdits",
315
+ env=env,
316
+ )
317
+ if resume:
318
+ kwargs["resume"] = resume
319
+ if self.model:
320
+ kwargs["model"] = self.model
321
+ return ClaudeAgentOptions(**kwargs)
322
+
323
+ def _resolve_session_log_dir(self) -> Path:
324
+ if self.session_log_dir is not None:
325
+ return self.session_log_dir
326
+ override = os.environ.get("MAP_SESSION_WAKE_LOG_DIR", "").strip()
327
+ if override:
328
+ return Path(override)
329
+ return self.project_root / DEFAULT_SESSION_LOG_DIR
330
+
331
+ @staticmethod
332
+ def _wake_log_session_id(
333
+ result: Any,
334
+ resume_session_id: str | None,
335
+ ) -> str:
336
+ if result is not None and getattr(result, "session_id", None):
337
+ return str(result.session_id)
338
+ if resume_session_id:
339
+ return str(resume_session_id)
340
+ return f"unknown-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}"
341
+
342
+ def _log_event(
343
+ self,
344
+ log_path: Path,
345
+ *,
346
+ event: str,
347
+ summary: str,
348
+ event_id: str | None = None,
349
+ event_source: str = "polling",
350
+ fingerprint: str | None = None,
351
+ ) -> None:
352
+ if os.environ.get("MAP_SESSION_WAKE_LOG", "1").strip().lower() in {
353
+ "0",
354
+ "false",
355
+ "no",
356
+ "off",
357
+ }:
358
+ return
359
+ try:
360
+ append_session_event(
361
+ log_path=log_path,
362
+ persona=self.persona,
363
+ integration=self.integration,
364
+ event=event,
365
+ summary=summary,
366
+ event_id=event_id,
367
+ event_source=event_source,
368
+ fingerprint=fingerprint,
369
+ )
370
+ except OSError as exc:
371
+ logger.warning("[%s] session event log write failed: %s", self.persona, exc)
372
+
373
+ def _append_wake_session_log(
374
+ self,
375
+ *,
376
+ session_id: str,
377
+ prompt: str,
378
+ response_text: str,
379
+ status: str,
380
+ event_id: str | None = None,
381
+ event_source: str = "polling",
382
+ fingerprint: str | None = None,
383
+ log_path: Path | None = None,
384
+ ) -> None:
385
+ if os.environ.get("MAP_SESSION_WAKE_LOG", "1").strip().lower() in {
386
+ "0",
387
+ "false",
388
+ "no",
389
+ "off",
390
+ }:
391
+ return
392
+ try:
393
+ append_session_wake_log(
394
+ log_dir=self._resolve_session_log_dir(),
395
+ session_id=session_id,
396
+ persona=self.persona,
397
+ integration=self.integration,
398
+ prompt=prompt,
399
+ response_text=response_text,
400
+ status=status,
401
+ event_id=event_id,
402
+ event_source=event_source,
403
+ fingerprint=fingerprint,
404
+ log_path=log_path,
405
+ )
406
+ except OSError as exc:
407
+ logger.warning("[%s] session wake log write failed: %s", self.persona, exc)
408
+
409
+ def _system_append_prompt(self) -> str:
410
+ if self.integration == "waker":
411
+ return (
412
+ f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
413
+ "project. When woken by map-runtime-waker you receive a single wake event. "
414
+ f"Confirm identity with `map --persona {self.persona} persona whoami`, read "
415
+ f"latest work with `map --persona {self.persona} todos`, then handle only the "
416
+ "event in the wake prompt using the appropriate skill from `.cursor/skills/`. "
417
+ "Gather missing context via the `map` CLI; do not wait for the waker to supply "
418
+ "a full plan."
419
+ )
420
+ if self.integration == "manual":
421
+ return (
422
+ f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
423
+ "project. A human operator resumed this session via `map runtime chat`. "
424
+ f"Use `map --persona {self.persona}` CLI and skills under `.cursor/skills/` "
425
+ "(topic-host, topic-participant, experiment-host, experiment-reviewer, "
426
+ "map-project-collab). Follow the operator's instructions for the current task. "
427
+ "Do not assume map-runtime-waker will send another wake for the same work item."
428
+ )
429
+ return (
430
+ f"You are the **{self.persona}** persona of the MAP (Multi-Agent Platform) "
431
+ "project. When woken up by the bridge, ALWAYS start by running "
432
+ f"`map --persona {self.persona} todos` to inspect pending work, then invoke "
433
+ "the appropriate skill from `.cursor/skills/` (topic-host, topic-participant, "
434
+ "topic-reviewer, experiment-host, or map-project-collab) to act on each item. "
435
+ "Do not wait for the bridge to feed you a complete plan; gather state "
436
+ "yourself via the `map` CLI."
437
+ )
438
+
439
+ def _resolve_env(self) -> dict[str, str]:
440
+ env: dict[str, str] = {}
441
+ for key in _CREDENTIAL_ENV_KEYS:
442
+ value = self._resolve_env_key(key)
443
+ if value:
444
+ env[key] = value
445
+ env.update(self.extra_env)
446
+ return env
447
+
448
+ def _resolve_env_key(self, name: str) -> str | None:
449
+ existing = os.environ.get(name, "").strip()
450
+ if existing:
451
+ return existing
452
+ for path in self._candidate_rc_files():
453
+ found = self._read_export(path, name)
454
+ if found:
455
+ return found
456
+ return None
457
+
458
+ def _resolve_model(self) -> str | None:
459
+ for key in _MODEL_ENV_KEYS:
460
+ value = self._resolve_env_key(key)
461
+ if value:
462
+ return value
463
+ return None
464
+
465
+ def _candidate_rc_files(self) -> list[Path]:
466
+ home = Path.home()
467
+ return [
468
+ self.project_root / ".map" / ".claude-env",
469
+ home / ".bashrc",
470
+ home / ".profile",
471
+ home / ".bash_profile",
472
+ ]
473
+
474
+ @staticmethod
475
+ def _read_export(path: Path, name: str) -> str | None:
476
+ if not path.is_file():
477
+ return None
478
+ try:
479
+ text = path.read_text(encoding="utf-8")
480
+ except OSError:
481
+ return None
482
+ for line in text.splitlines():
483
+ match = _EXPORT_RE.match(line)
484
+ if match and match.group(1) == name:
485
+ value = match.group(2).strip()
486
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
487
+ value = value[1:-1]
488
+ elif value and value[0] not in {"'", '"'}:
489
+ value = re.sub(r"\s+#.*$", "", value).strip()
490
+ if value:
491
+ return value
492
+ return None
493
+
494
+
495
+ def make_wakeup_prompt(persona: str, todos: dict[str, Any]) -> str:
496
+ """Build a brief bridge wake-up prompt from a todos snapshot."""
497
+ lines = [
498
+ f"You are the **{persona}** persona of MAP.",
499
+ "",
500
+ "You have pending work. Inspect it, then act:",
501
+ "",
502
+ f" $ map --persona {persona} todos",
503
+ "",
504
+ ]
505
+ pending_replies = todos.get("pending_replies") or []
506
+ pending_topic_replies = todos.get("pending_topic_replies") or []
507
+ pending_reviews = todos.get("pending_reviews") or []
508
+ my_open_experiments = todos.get("my_open_experiments") or []
509
+ my_open_topics = todos.get("my_open_topics") or []
510
+ mentions = todos.get("mentions") or []
511
+
512
+ if pending_replies:
513
+ lines.append(f"- {len(pending_replies)} pending review reply(ies)")
514
+ if pending_topic_replies:
515
+ lines.append(f"- {len(pending_topic_replies)} pending topic reply(ies)")
516
+ if pending_reviews:
517
+ lines.append(f"- {len(pending_reviews)} pending review(s)")
518
+ if my_open_experiments:
519
+ lines.append(f"- {len(my_open_experiments)} open experiment(s)")
520
+ if my_open_topics:
521
+ lines.append(f"- {len(my_open_topics)} open topic(s)")
522
+ if mentions:
523
+ lines.append(f"- {len(mentions)} mention(s)")
524
+ if not any(
525
+ [
526
+ pending_replies,
527
+ pending_topic_replies,
528
+ pending_reviews,
529
+ my_open_experiments,
530
+ my_open_topics,
531
+ mentions,
532
+ ]
533
+ ):
534
+ lines.append("(no pending items — short-circuit and report idle)")
535
+
536
+ lines.extend(
537
+ [
538
+ "",
539
+ "Use the appropriate skill from `.cursor/skills/` (topic-host, "
540
+ "topic-participant, topic-reviewer, experiment-host, or "
541
+ "map-project-collab). Report a brief status when done.",
542
+ ]
543
+ )
544
+ return "\n".join(lines)
545
+
546
+
547
+ __all__ = [
548
+ "DEFAULT_ALLOWED_TOOLS",
549
+ "IntegrationMode",
550
+ "PersonaAgentClient",
551
+ "PersonaAgentLike",
552
+ "WakeUpEvent",
553
+ "make_wakeup_prompt",
554
+ ]
cli/bridge_state.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from cli.host_worker_types import WorkerError
8
+
9
+
10
+ def load_bridge_state(
11
+ path: Path | None,
12
+ *,
13
+ bridge_name: str,
14
+ default_collections: tuple[str, ...],
15
+ validate_schema: bool = True,
16
+ ) -> dict[str, Any]:
17
+ if path is None or not path.exists():
18
+ return _default_state(default_collections)
19
+ try:
20
+ state = json.loads(path.read_text(encoding="utf-8"))
21
+ except json.JSONDecodeError as exc:
22
+ raise WorkerError(f"Invalid {bridge_name} bridge state file: {path}") from exc
23
+ if not isinstance(state, dict):
24
+ raise WorkerError(f"Invalid {bridge_name} bridge state file: {path}")
25
+ if validate_schema and state.get("schema_version", 1) != 1:
26
+ raise WorkerError(f"Unsupported {bridge_name} bridge state schema: {state.get('schema_version')}")
27
+ state.setdefault("schema_version", 1)
28
+ for collection in default_collections:
29
+ state.setdefault(collection, {})
30
+ return state
31
+
32
+
33
+ def save_bridge_state(path: Path | None, state: dict[str, Any]) -> None:
34
+ if path is None:
35
+ return
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
38
+ tmp_path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
39
+ tmp_path.replace(path)
40
+
41
+
42
+ def _default_state(default_collections: tuple[str, ...]) -> dict[str, Any]:
43
+ return {"schema_version": 1, **{collection: {} for collection in default_collections}}
@@ -0,0 +1,13 @@
1
+ """cli.commands — sub-app command modules (arch experiment 0519e2a3 PR3+).
2
+
3
+ Each module in this package owns one Typer ``*_app`` sub-application
4
+ (``agent_app``, ``topic_app``, etc.) and registers its commands. The
5
+ parent ``cli/main.py`` only does ``app.add_typer(..., name=...)`` to
6
+ expose them under the user-visible ``map <name> ...`` path.
7
+
8
+ Helpers (``_run``, ``_client_ctx``, ``_print_json``, ``_cli_options``)
9
+ live in ``cli/main.py`` and are imported lazily inside command bodies
10
+ to keep ``cli.main`` importable without instantiating the sub-apps
11
+ (avoids the circular import: ``cli.main → cli.commands.agent →
12
+ cli.main._run``).
13
+ """