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/e2e_collab.py ADDED
@@ -0,0 +1,602 @@
1
+ """End-to-end collaboration scenario driver (demo / E2E validation).
2
+
3
+ Drives a scripted host → participant → host → ... → reviewer flow by spawning
4
+ the per-persona Agent runtime (``PersonaAgentClient`` with
5
+ ``integration="manual"``) and sending one turn per step. Each step's prompt
6
+ tells the agent what to do; the agent reads the relevant Skill under
7
+ ``.cursor/skills/`` and uses ``map --persona <name>`` CLI to act. The
8
+ orchestrator never mutates MAP state directly — it only reads MAP (via
9
+ ``MapCommandClient``) between steps to discover ``topic_id`` / ``experiment_id``
10
+ and to branch when a decision step picks the "no" path.
11
+
12
+ This is a demo / E2E entry, NOT a replacement for ``simple-waker``. The waker
13
+ remains the default production path (reactive, polled, multi-topic); this
14
+ script is a linear, single-scenario conductor for end-to-end validation and
15
+ demos. Architecture boundaries from AGENTS.md still hold:
16
+
17
+ - waker / orchestrator = conductor (no business judgment)
18
+ - Skill = behavior definition
19
+ - spawned Agent = actor (reads Skill, uses `map` CLI, writes back to MAP)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import re
26
+ import sys
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import typer
33
+
34
+ from cli.agent_client import PersonaAgentClient, WakeUpEvent
35
+ from cli.host_worker_types import WorkerError
36
+ from cli.map_command_client import MapCommandClient
37
+ from cli.runtime_chat import (
38
+ default_runtime_home,
39
+ default_state_file,
40
+ ensure_waker_not_running,
41
+ load_runtime_state,
42
+ persona_agent_state,
43
+ resolve_session_id,
44
+ )
45
+ from cli.wake_backend import sync_runtime_skills
46
+
47
+ e2e_app = typer.Typer(help="End-to-end collaboration scenario driver (demo / E2E)")
48
+
49
+ PERSONAS: tuple[str, ...] = ("host", "participant", "reviewer")
50
+
51
+ _DEFAULT_TOPIC_TITLE = "E2E demo: collaboration lifecycle"
52
+ _DEFAULT_SUBJECT = (
53
+ "Validate that host / participant / reviewer personas can drive a topic "
54
+ "through discussion -> experiment -> plan review -> execution -> result "
55
+ "verification -> closure using the map CLI and Skills."
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Data classes
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass
65
+ class StepResult:
66
+ name: str
67
+ persona: str
68
+ status: str
69
+ text: str
70
+
71
+
72
+ @dataclass
73
+ class Scenario:
74
+ subject: str
75
+ topic_title: str
76
+ project_root: Path
77
+ run_dir: Path
78
+ plan_file: Path
79
+ log_file: Path
80
+ new_session: bool = False
81
+ model: str | None = None
82
+ topic_id: str | None = None
83
+ experiment_id: str | None = None
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Helpers
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _now_ts() -> str:
92
+ return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
93
+
94
+
95
+ def _echo(msg: str = "") -> None:
96
+ typer.echo(msg)
97
+
98
+
99
+ def _echo_err(msg: str) -> None:
100
+ typer.echo(msg, err=True)
101
+
102
+
103
+ def build_prompt(persona: str, body: str) -> str:
104
+ """Wrap a step body with the standard E2E preamble + trailing summary cue."""
105
+ return (
106
+ f"You are the **{persona}** persona of the MAP (Multi-Agent Platform) "
107
+ "project. An end-to-end collaboration demo is being driven by an "
108
+ "external orchestrator; this turn is ONE step of that demo. "
109
+ f"Use `map --persona {persona}` CLI and the Skills under "
110
+ "`.cursor/skills/` (start with `map-runtime-waker` → "
111
+ "`map-project-collab` → your persona Skill: topic-host / "
112
+ "topic-participant / experiment-host / experiment-reviewer). Do not "
113
+ "wait for further prompts within this turn — gather state yourself "
114
+ "via the `map` CLI and act.\n\n"
115
+ f"{body}\n\n"
116
+ "When done, end your turn with a one-line summary starting with "
117
+ "`E2E:` so the orchestrator can record it."
118
+ )
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Driver
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ class E2EDriver:
127
+ def __init__(
128
+ self,
129
+ scenario: Scenario,
130
+ clients: dict[str, PersonaAgentClient],
131
+ ) -> None:
132
+ self.scenario = scenario
133
+ self.clients = clients
134
+ self.map_host = MapCommandClient(persona="host", project_root=scenario.project_root)
135
+ self.steps: list[StepResult] = []
136
+
137
+ # ---- single step execution ----
138
+
139
+ async def step(self, name: str, persona: str, body: str) -> StepResult:
140
+ _echo(f"\n=== step {len(self.steps) + 1}: {name} (persona={persona}) ===")
141
+ prompt = build_prompt(persona, body)
142
+ text_parts: list[str] = []
143
+
144
+ def on_event(ev: WakeUpEvent) -> None:
145
+ if ev.get("type") == "text":
146
+ chunk = str(ev.get("content") or "")
147
+ text_parts.append(chunk)
148
+ sys.stdout.write(chunk)
149
+ sys.stdout.flush()
150
+
151
+ status = await self.clients[persona].wake_up(
152
+ prompt, on_event=on_event, event_source="e2e"
153
+ )
154
+ result = StepResult(name=name, persona=persona, status=status, text="".join(text_parts))
155
+ self.steps.append(result)
156
+ _echo("") # newline after streamed text
157
+ if status != "ok":
158
+ _echo_err(f"[error] step {name} returned status={status}")
159
+ return result
160
+
161
+ # ---- read-only state queries between steps ----
162
+
163
+ def find_topic_by_title(self) -> str | None:
164
+ rows = self.map_host.topic_list_open()
165
+ for row in rows:
166
+ if str(row.get("title") or "") == self.scenario.topic_title:
167
+ tid = str(row.get("id") or "") or None
168
+ if tid:
169
+ return tid
170
+ return None
171
+
172
+ def find_experiment_for_topic(self) -> str | None:
173
+ if not self.scenario.topic_id:
174
+ return None
175
+ rows = self.map_host.experiment_list()
176
+ for row in rows:
177
+ if not isinstance(row, dict):
178
+ continue
179
+ if str(row.get("topic_id") or "") == self.scenario.topic_id:
180
+ eid = str(row.get("id") or "") or None
181
+ if eid:
182
+ return eid
183
+ return None
184
+
185
+ def experiment_phase(self) -> str | None:
186
+ if not self.scenario.experiment_id:
187
+ return None
188
+ data = self.map_host.experiment_status(self.scenario.experiment_id)
189
+ if isinstance(data, dict):
190
+ return str(data.get("phase") or "") or None
191
+ return None
192
+
193
+ def topic_status(self) -> str | None:
194
+ if not self.scenario.topic_id:
195
+ return None
196
+ data = self.map_host.topic_show(self.scenario.topic_id)
197
+ if isinstance(data, dict):
198
+ return str(data.get("status") or "") or None
199
+ return None
200
+
201
+ # ---- per-step prompt bodies ----
202
+
203
+ def _ctx(self) -> str:
204
+ lines = [f"subject: {self.scenario.subject}", f"topic_title: {self.scenario.topic_title}"]
205
+ if self.scenario.topic_id:
206
+ lines.append(f"topic_id: {self.scenario.topic_id}")
207
+ if self.scenario.experiment_id:
208
+ lines.append(f"experiment_id: {self.scenario.experiment_id}")
209
+ return "\n".join(lines)
210
+
211
+ def _prompt_create_topic(self) -> str:
212
+ return (
213
+ f"{self._ctx()}\n\n"
214
+ "Task: open a new discussion topic on the subject above, using the "
215
+ "exact topic_title above. Use the topic-host Skill and "
216
+ "`map --persona host topic create ...`. After creating, confirm "
217
+ "the topic_id in your E2E summary line."
218
+ )
219
+
220
+ def _prompt_participant_comment(self, round_label: str) -> str:
221
+ return (
222
+ f"{self._ctx()}\n\n"
223
+ f"Task: this is {round_label} of the discussion. Read the topic "
224
+ "thread with `map --persona participant topic show --id <topic_id>`, "
225
+ "then post a substantive comment that advances the discussion "
226
+ "(question, counterpoint, or supporting evidence). Use the "
227
+ "topic-participant Skill."
228
+ )
229
+
230
+ def _prompt_host_reply(self, round_label: str) -> str:
231
+ return (
232
+ f"{self._ctx()}\n\n"
233
+ f"Task: this is {round_label} of the discussion. Read the latest "
234
+ "participant comment with `map --persona host topic show --id "
235
+ "<topic_id>`, then reply in-thread (use `--parent <comment_id>` on "
236
+ "`map topic comment`) to advance the discussion. Use the topic-host "
237
+ "Skill."
238
+ )
239
+
240
+ def _prompt_host_decide_experiment(self) -> str:
241
+ return (
242
+ f"{self._ctx()}\n\n"
243
+ "Task: make a judgment call. There has been at least one round of "
244
+ "discussion.\n"
245
+ " Option A — continue the discussion (post another reply).\n"
246
+ " Option B — open an experiment to test a concrete hypothesis "
247
+ "derived from the discussion.\n\n"
248
+ "For this demo, prefer Option B so the rest of the lifecycle can "
249
+ "be showcased; but make the judgment yourself based on discussion "
250
+ "quality.\n\n"
251
+ "If you choose Option B:\n"
252
+ f" 1. Write a plan markdown file at {self.scenario.plan_file} "
253
+ "(hypothesis, steps, success criteria).\n"
254
+ " 2. `map --persona host experiment create --title \"<title>\" "
255
+ f"--plan-file {self.scenario.plan_file} --topic-id <topic_id> "
256
+ "--submit-for-review`\n"
257
+ " 3. Use the experiment-host Skill (.cursor/skills/experiment-host/SKILL.md).\n"
258
+ " 4. Confirm the experiment_id in your E2E summary line.\n\n"
259
+ "If you choose Option A, just post the reply and say so in your "
260
+ "E2E summary; the orchestrator will end the demo."
261
+ )
262
+
263
+ def _prompt_reviewer_review_plan(self) -> str:
264
+ return (
265
+ f"{self._ctx()}\n\n"
266
+ "Task: an experiment plan has been submitted for review. Review it:\n"
267
+ " 1. `map --persona reviewer experiment show --id <experiment_id>` "
268
+ "to read the plan.\n"
269
+ " 2. Use the experiment-reviewer Skill to submit your review via "
270
+ "the appropriate `map --persona reviewer experiment review ...` "
271
+ "command.\n\n"
272
+ "For this demo, prefer 'approve with minor comments' so the flow "
273
+ "can proceed to execution; but make the judgment yourself based on "
274
+ "plan quality."
275
+ )
276
+
277
+ def _prompt_host_decide_start(self) -> str:
278
+ return (
279
+ f"{self._ctx()}\n\n"
280
+ "Task: the reviewer has reviewed your plan. Decide the next step:\n"
281
+ " - If approved: start the experiment with "
282
+ "`map --persona host experiment start --id <experiment_id>`, then "
283
+ f"write a brief execution log to {self.scenario.log_file} "
284
+ "(1-2 paragraphs describing what was 'run' — this is a demo, so a "
285
+ "plausible simulated log is fine), then submit the result with "
286
+ "`map --persona host experiment complete --id <experiment_id> "
287
+ f"--summary \"<summary>\" --file {self.scenario.log_file}`.\n"
288
+ " - If rejected with revisions: revise the plan and resubmit.\n\n"
289
+ "For this demo, prefer starting + completing so the verification "
290
+ "step can be showcased; but make the judgment yourself.\n\n"
291
+ "Use the experiment-host Skill."
292
+ )
293
+
294
+ def _prompt_reviewer_verify_result(self) -> str:
295
+ return (
296
+ f"{self._ctx()}\n\n"
297
+ "Task: the experiment result is pending verification. Verify it:\n"
298
+ " 1. `map --persona reviewer experiment logs --id <experiment_id>` "
299
+ "to read the execution log.\n"
300
+ " 2. `map --persona reviewer experiment accept-result --id "
301
+ "<experiment_id> --summary \"<summary>\" --file <review.md>` "
302
+ "(or `reject-result` if the result is inadequate).\n\n"
303
+ "For this demo, prefer 'accept-result' so the flow can proceed to "
304
+ "closure; but make the judgment yourself.\n\n"
305
+ "Use the experiment-reviewer Skill."
306
+ )
307
+
308
+ def _prompt_host_decide_close(self) -> str:
309
+ return (
310
+ f"{self._ctx()}\n\n"
311
+ "Task: the experiment has been verified and accepted. Decide "
312
+ "whether to close the topic or keep it open for further work.\n"
313
+ " - To close: use the topic-host Skill's closure command (e.g. "
314
+ "`map --persona host topic close --id <topic_id>` or "
315
+ "`map --persona host topic resolve --id <topic_id> --file <resolution.yaml>`).\n"
316
+ " - To keep open: post a summary comment and stop.\n\n"
317
+ "For this demo, prefer closing the topic to wrap up the lifecycle; "
318
+ "but make the judgment yourself."
319
+ )
320
+
321
+ # ---- scenario runner ----
322
+
323
+ async def run_scenario(self) -> None:
324
+ try:
325
+ await self._run_steps()
326
+ finally:
327
+ self.write_run_log()
328
+
329
+ async def _run_steps(self) -> None:
330
+ # Step 1: host creates the topic.
331
+ await self.step("create-topic", "host", self._prompt_create_topic())
332
+ self.scenario.topic_id = self.find_topic_by_title()
333
+ if not self.scenario.topic_id:
334
+ self._end("topic not found after create-topic step")
335
+ return
336
+
337
+ # Steps 2-4: two rounds of host ↔ participant discussion.
338
+ await self.step(
339
+ "participant-comment-1", "participant",
340
+ self._prompt_participant_comment("round 1"),
341
+ )
342
+ await self.step(
343
+ "host-reply-1", "host",
344
+ self._prompt_host_reply("round 1"),
345
+ )
346
+ await self.step(
347
+ "participant-comment-2", "participant",
348
+ self._prompt_participant_comment("round 2"),
349
+ )
350
+
351
+ # Step 5: host decides whether to open an experiment.
352
+ await self.step(
353
+ "host-decide-experiment", "host",
354
+ self._prompt_host_decide_experiment(),
355
+ )
356
+ self.scenario.experiment_id = self.find_experiment_for_topic()
357
+ if not self.scenario.experiment_id:
358
+ self._end("host chose not to open an experiment; demo ends here")
359
+ return
360
+
361
+ # Step 6: reviewer reviews the plan.
362
+ await self.step(
363
+ "reviewer-review-plan", "reviewer",
364
+ self._prompt_reviewer_review_plan(),
365
+ )
366
+ phase = self.experiment_phase()
367
+ if phase != "approved":
368
+ self._end(
369
+ f"experiment phase={phase!r} after review (expected 'approved'); "
370
+ "demo ends here"
371
+ )
372
+ return
373
+
374
+ # Step 7: host decides whether to start + complete the experiment.
375
+ await self.step(
376
+ "host-decide-start", "host",
377
+ self._prompt_host_decide_start(),
378
+ )
379
+ phase = self.experiment_phase()
380
+ if phase != "result_review":
381
+ self._end(
382
+ f"experiment phase={phase!r} after start (expected 'result_review'); "
383
+ "demo ends here"
384
+ )
385
+ return
386
+
387
+ # Step 8: reviewer verifies the result.
388
+ await self.step(
389
+ "reviewer-verify-result", "reviewer",
390
+ self._prompt_reviewer_verify_result(),
391
+ )
392
+ phase = self.experiment_phase()
393
+ if phase != "done":
394
+ self._end(
395
+ f"experiment phase={phase!r} after verify (expected 'done'); "
396
+ "demo ends here"
397
+ )
398
+ return
399
+
400
+ # Step 9: host decides whether to close the topic.
401
+ await self.step(
402
+ "host-decide-close", "host",
403
+ self._prompt_host_decide_close(),
404
+ )
405
+ status = self.topic_status()
406
+ self._end(f"final topic status: {status!r}")
407
+
408
+ def _end(self, msg: str) -> None:
409
+ _echo(f"\n[end] {msg}")
410
+
411
+ # ---- run log ----
412
+
413
+ def write_run_log(self) -> None:
414
+ log_file = self.scenario.run_dir / "run.log"
415
+ lines = [
416
+ f"E2E run log — finished {datetime.now(UTC).isoformat()}",
417
+ f"subject: {self.scenario.subject}",
418
+ f"topic_title: {self.scenario.topic_title}",
419
+ f"topic_id: {self.scenario.topic_id}",
420
+ f"experiment_id: {self.scenario.experiment_id}",
421
+ "",
422
+ "Steps:",
423
+ ]
424
+ for i, s in enumerate(self.steps, 1):
425
+ summary = ""
426
+ m = re.search(r"E2E:\s*(.+)$", s.text, re.MULTILINE)
427
+ if m:
428
+ summary = m.group(1).strip()[:200]
429
+ lines.append(
430
+ f" {i}. [{s.persona}] {s.name} — status={s.status} — {summary}"
431
+ )
432
+ log_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
433
+ _echo(f"\nrun log: {log_file}")
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Client setup
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def _build_client(
442
+ *,
443
+ persona: str,
444
+ scenario: Scenario,
445
+ state_file: Path,
446
+ runtime_home: Path,
447
+ ) -> PersonaAgentClient:
448
+ """Create a per-persona PersonaAgentClient that resumes its prior session
449
+ (or starts fresh when ``scenario.new_session`` is set). State is shared
450
+ with `map runtime chat` so the same session can be inspected/resumed
451
+ interactively for debugging."""
452
+ state = load_runtime_state(state_file)
453
+ agent_state = persona_agent_state(state, persona)
454
+ resume_id = resolve_session_id(
455
+ agent_state, session_id=None, new_session=scenario.new_session
456
+ )
457
+ if scenario.new_session:
458
+ agent_state.pop("claude_session_id", None)
459
+ agent_state.pop("runtime_session_id", None)
460
+ elif resume_id:
461
+ agent_state["claude_session_id"] = resume_id
462
+ agent_state["runtime_session_id"] = resume_id
463
+
464
+ sync_runtime_skills(project_root=scenario.project_root, runtime_home=runtime_home)
465
+ runtime_home.mkdir(parents=True, exist_ok=True)
466
+
467
+ def save_state() -> None:
468
+ from cli.bridge_state import save_bridge_state
469
+
470
+ save_bridge_state(state_file, state)
471
+
472
+ extra_env: dict[str, str] = {
473
+ "HOME": str(runtime_home),
474
+ "MAP_RUNTIME_CHAT_PERSONA": persona,
475
+ }
476
+
477
+ client = PersonaAgentClient(
478
+ persona=persona,
479
+ state=agent_state,
480
+ save_state_fn=save_state,
481
+ project_root=scenario.project_root,
482
+ extra_env=extra_env,
483
+ model=scenario.model,
484
+ integration="manual",
485
+ )
486
+ return client
487
+
488
+
489
+ async def _run_e2e_async(scenario: Scenario, ignore_waker: bool) -> None:
490
+ # Refuse to run if any persona's waker is active (would conflict over the
491
+ # same Claude session), unless --ignore-waker is set.
492
+ for persona in PERSONAS:
493
+ ensure_waker_not_running(persona=persona, ignore_waker=ignore_waker)
494
+
495
+ state_files = {
496
+ p: default_state_file(scenario.project_root, p) for p in PERSONAS
497
+ }
498
+ runtime_homes = {
499
+ p: default_runtime_home(scenario.project_root, p) for p in PERSONAS
500
+ }
501
+ clients = {
502
+ p: _build_client(
503
+ persona=p,
504
+ scenario=scenario,
505
+ state_file=state_files[p],
506
+ runtime_home=runtime_homes[p],
507
+ )
508
+ for p in PERSONAS
509
+ }
510
+
511
+ driver = E2EDriver(scenario=scenario, clients=clients)
512
+ try:
513
+ await driver.run_scenario()
514
+ finally:
515
+ for client in clients.values():
516
+ await client.disconnect()
517
+
518
+
519
+ def run_e2e(**kwargs: Any) -> None:
520
+ try:
521
+ asyncio.run(_run_e2e_async(**kwargs))
522
+ except WorkerError as exc:
523
+ _echo_err(f"Error: {exc}")
524
+ raise typer.Exit(1) from exc
525
+ except KeyboardInterrupt:
526
+ _echo("\nInterrupted.")
527
+ raise typer.Exit(130) from None
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # CLI
532
+ # ---------------------------------------------------------------------------
533
+
534
+
535
+ @e2e_app.command("run")
536
+ def e2e_run(
537
+ subject: str = typer.Option(
538
+ _DEFAULT_SUBJECT,
539
+ "--subject",
540
+ help="Discussion subject for the demo topic.",
541
+ ),
542
+ topic_title: str = typer.Option(
543
+ _DEFAULT_TOPIC_TITLE,
544
+ "--topic-title",
545
+ help="Exact title used for the demo topic (orchestrator matches by this).",
546
+ ),
547
+ project_root: Path | None = typer.Option(
548
+ None,
549
+ "--project-root",
550
+ help="Repo root containing .map/ (default: search upward from cwd).",
551
+ ),
552
+ log_dir: Path | None = typer.Option(
553
+ None,
554
+ "--log-dir",
555
+ help="Where to write the per-run E2E log dir (default: .map/e2e-logs/<ts>).",
556
+ ),
557
+ model: str | None = typer.Option(
558
+ None,
559
+ "--model",
560
+ help="Optional Claude model override passed to the Agent runtime.",
561
+ ),
562
+ new_session: bool = typer.Option(
563
+ False,
564
+ "--new-session",
565
+ help="Start a fresh Claude session per persona instead of resuming state.",
566
+ ),
567
+ ignore_waker: bool = typer.Option(
568
+ False,
569
+ "--ignore-waker",
570
+ help="Allow running while a simple-waker is active (may conflict over sessions).",
571
+ ),
572
+ ) -> None:
573
+ """Drive a scripted host ↔ participant ↔ reviewer collaboration flow."""
574
+ from cli.runtime_chat import resolve_project_root
575
+
576
+ root = resolve_project_root(project_root)
577
+ run_dir = log_dir or (root / ".map" / "e2e-logs" / _now_ts())
578
+ run_dir.mkdir(parents=True, exist_ok=True)
579
+
580
+ scenario = Scenario(
581
+ subject=subject,
582
+ topic_title=topic_title,
583
+ project_root=root,
584
+ run_dir=run_dir,
585
+ plan_file=run_dir / "plan.md",
586
+ log_file=run_dir / "execution-log.md",
587
+ new_session=new_session,
588
+ model=model,
589
+ )
590
+
591
+ _echo(
592
+ f"MAP E2E collaboration driver\n"
593
+ f" project_root: {root}\n"
594
+ f" run_dir: {run_dir}\n"
595
+ f" topic_title: {topic_title}\n"
596
+ f" new_session: {new_session}"
597
+ )
598
+
599
+ run_e2e(scenario=scenario, ignore_waker=ignore_waker)
600
+
601
+
602
+ __all__ = ["e2e_app", "run_e2e"]
cli/git_checkpoint.py ADDED
@@ -0,0 +1,68 @@
1
+ """Git checkpoint helpers for MAP experiment execution (before/after code changes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ class GitCheckpointError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def _run_git(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
14
+ result = subprocess.run(
15
+ ["git", *args],
16
+ cwd=repo,
17
+ text=True,
18
+ capture_output=True,
19
+ check=False,
20
+ )
21
+ if result.returncode != 0:
22
+ detail = result.stderr.strip() or result.stdout.strip()
23
+ raise GitCheckpointError(f"git {' '.join(args)} failed: {detail}")
24
+ return result
25
+
26
+
27
+ def _has_staged_changes(repo: Path) -> bool:
28
+ result = subprocess.run(
29
+ ["git", "diff", "--cached", "--quiet"],
30
+ cwd=repo,
31
+ text=True,
32
+ check=False,
33
+ )
34
+ return result.returncode != 0
35
+
36
+
37
+ def _has_unstaged_or_staged_changes(repo: Path) -> bool:
38
+ status = _run_git(repo, ["status", "--porcelain"])
39
+ return bool(status.stdout.strip())
40
+
41
+
42
+ def head_sha(repo: Path) -> str:
43
+ return _run_git(repo, ["rev-parse", "HEAD"]).stdout.strip()
44
+
45
+
46
+ def checkpoint_before(repo: Path, experiment_id: str) -> str:
47
+ """Commit current tree (or empty checkpoint) before experiment modifies the repo."""
48
+ if not (repo / ".git").exists():
49
+ raise GitCheckpointError(f"Not a git repository: {repo}")
50
+ _run_git(repo, ["add", "-A"])
51
+ message = f"map: checkpoint before experiment {experiment_id}"
52
+ if _has_staged_changes(repo):
53
+ _run_git(repo, ["commit", "-m", message])
54
+ else:
55
+ _run_git(repo, ["commit", "--allow-empty", "-m", message])
56
+ return head_sha(repo)
57
+
58
+
59
+ def checkpoint_after(repo: Path, experiment_id: str, summary: str) -> str | None:
60
+ """Commit experiment changes after execution. Returns SHA or None if nothing to commit."""
61
+ if not (repo / ".git").exists():
62
+ raise GitCheckpointError(f"Not a git repository: {repo}")
63
+ _run_git(repo, ["add", "-A"])
64
+ if not _has_staged_changes(repo):
65
+ return None
66
+ short = summary.strip().replace("\n", " ")[:120]
67
+ _run_git(repo, ["commit", "-m", f"map: experiment {experiment_id} — {short}"])
68
+ return head_sha(repo)