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
@@ -0,0 +1,755 @@
1
+ """``map experiment ...`` sub-app — cli/main.py split.
2
+
3
+ Owns experiment lifecycle commands plus nested ``lock`` / ``review`` / ``plan``.
4
+ Command bodies lazy-import ``cli.main`` helpers to break the
5
+ ``cli.main ↔ cli.commands.*`` import cycle.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import typer
14
+ import yaml
15
+ from map_client.client import MAPClient
16
+ from map_client.exceptions import MAPNotFoundError
17
+ from map_sdk.evidence import (
18
+ EVIDENCE_METADATA_KEYS,
19
+ metadata_has_completion_evidence,
20
+ )
21
+
22
+ from cli.table_render import enum_value, format_datetime, render_table, short_uuid, truncate
23
+ from server.domain.schemas import (
24
+ ExperimentComplete,
25
+ ExperimentCreate,
26
+ ExperimentLogCreate,
27
+ ExperimentResultDecision,
28
+ PlanInput,
29
+ PlanRevise,
30
+ ReviewCreate,
31
+ )
32
+
33
+ experiment_app = typer.Typer(help="Experiment commands", rich_markup_mode=None)
34
+ lock_app = typer.Typer(help="Experiment execution lock commands (per-project).")
35
+ review_app = typer.Typer(help="Review commands")
36
+ plan_app = typer.Typer(help="Plan commands")
37
+ experiment_app.add_typer(lock_app, name="lock")
38
+ experiment_app.add_typer(review_app, name="review")
39
+ experiment_app.add_typer(plan_app, name="plan")
40
+
41
+
42
+ @experiment_app.command("create")
43
+ def experiment_create(
44
+ title: str = typer.Option(..., "--title"),
45
+ plan_file: Path = typer.Option(..., "--plan-file"),
46
+ project: uuid.UUID | None = typer.Option(None, "--project"),
47
+ project_key: str | None = typer.Option(None, "--project-key"),
48
+ description: str | None = typer.Option(None, "--description"),
49
+ submit_for_review: bool = typer.Option(False, "--submit-for-review"),
50
+ topic_id: uuid.UUID | None = typer.Option(None, "--topic-id"),
51
+ force_lint_bypass: bool = typer.Option(
52
+ False,
53
+ "--force-lint-bypass",
54
+ help="Skip the local plan frontmatter lint pre-check (server still enforces it).",
55
+ ),
56
+ ) -> None:
57
+ from cli.main import _read_text_file, _resolve_project, _run # lazy: avoid cycle
58
+ content = _read_text_file(plan_file, kind="plan")
59
+ # Local plan frontmatter lint pre-check (a764abf6 I1.(c)). The server
60
+ # has its own hard validator (``assert_plan_frontmatter_ok``), but a
61
+ # local gate saves a round trip and gives a clearer error message
62
+ # when the author simply forgot the YAML block.
63
+ if not force_lint_bypass:
64
+ from server.services.plan_marker_service import validate_plan_frontmatter
65
+
66
+ result = validate_plan_frontmatter(content)
67
+ missing_fields = [
68
+ w.field for w in result.warnings if w.code == "PLAN_MARKER_MISSING_FIELD"
69
+ ]
70
+ if missing_fields or any(
71
+ w.code == "PLAN_MARKER_FRONT_MATTER_MISSING"
72
+ for w in result.warnings
73
+ ):
74
+ keys = ", ".join(sorted(set(missing_fields))) if missing_fields else "(no frontmatter)"
75
+ typer.echo(
76
+ f"Error: plan frontmatter lint failed ({keys}). "
77
+ f"Run `map experiment plan validate --plan-file {plan_file}` "
78
+ f"for details, or re-run with --force-lint-bypass to skip the local check.",
79
+ err=True,
80
+ )
81
+ raise typer.Exit(2)
82
+
83
+ payload = ExperimentCreate(
84
+ title=title,
85
+ description=description,
86
+ plan=PlanInput(content_md=content),
87
+ submit_for_review=submit_for_review,
88
+ topic_id=topic_id,
89
+ )
90
+
91
+ def action(c: MAPClient):
92
+ pid = _resolve_project(c, project, project_key)
93
+ return c.create_experiment(pid, payload)
94
+
95
+ _run(action)
96
+
97
+
98
+ def _render_experiment_table(experiments: Any) -> str:
99
+ """Render a list of ExperimentSummaryRead as a compact table."""
100
+ headers = ["ID", "Title", "Phase", "Plan v", "Logs", "Topic", "Updated"]
101
+ rows = []
102
+ for e in experiments:
103
+ rows.append([
104
+ short_uuid(e.id),
105
+ truncate(e.title, 50),
106
+ enum_value(e.phase),
107
+ f"v{e.current_plan_version}",
108
+ str(getattr(e, "log_count", 0)),
109
+ short_uuid(e.topic_id) if e.topic_id else "-",
110
+ format_datetime(e.updated_at),
111
+ ])
112
+ return render_table(headers, rows)
113
+
114
+
115
+ @experiment_app.command("list")
116
+ def experiment_list(
117
+ project: uuid.UUID | None = typer.Option(None, "--project"),
118
+ project_key: str | None = typer.Option(None, "--project-key"),
119
+ phase: str | None = typer.Option(None, "--phase"),
120
+ creator_agent_id: uuid.UUID | None = typer.Option(None, "--creator-agent-id"),
121
+ q: str | None = typer.Option(None, "--q"),
122
+ page: int = typer.Option(1, "--page", min=1),
123
+ page_size: int = typer.Option(100, "--page-size", min=1, max=100),
124
+ include_archived: bool = typer.Option(False, "--include-archived"),
125
+ ) -> None:
126
+ """List experiments in the current project.
127
+
128
+ Defaults to a compact table view. Use ``--format yaml`` or
129
+ ``--format json`` for full structured output (scripts / piping).
130
+ """
131
+ from cli.main import _resolve_project, _run # lazy: avoid cycle
132
+ from server.domain.models import ExperimentPhase
133
+
134
+ def action(c: MAPClient):
135
+ pid = _resolve_project(c, project, project_key)
136
+ phase_filter = ExperimentPhase(phase) if phase else None
137
+ return c.list_experiments(
138
+ pid,
139
+ phase=phase_filter,
140
+ creator_agent_id=creator_agent_id,
141
+ q=q,
142
+ page=page,
143
+ page_size=page_size,
144
+ include_archived=include_archived,
145
+ )
146
+
147
+ _run(action, table_renderer=_render_experiment_table)
148
+
149
+
150
+ @experiment_app.command("submit-review")
151
+ def experiment_submit_review(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
152
+ from cli.main import _run # lazy: avoid cycle
153
+ _run(lambda c: c.submit_for_review(experiment_id), experiment_id=experiment_id)
154
+
155
+
156
+ @experiment_app.command("approve")
157
+ def experiment_approve(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
158
+ from cli.main import _run # lazy: avoid cycle
159
+ _run(lambda c: c.approve_experiment(experiment_id), experiment_id=experiment_id)
160
+
161
+
162
+ @experiment_app.command("start")
163
+ def experiment_start(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
164
+ from cli.main import _run # lazy: avoid cycle
165
+ _run(lambda c: c.start_experiment(experiment_id), experiment_id=experiment_id)
166
+
167
+
168
+ @experiment_app.command("pre-complete")
169
+ def experiment_pre_complete(
170
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
171
+ metadata_file: Path | None = typer.Option(
172
+ None,
173
+ "--metadata",
174
+ help="YAML evidence file with keys such as alembic_current, api_health, pytest_summary, image_digest.",
175
+ ),
176
+ ) -> None:
177
+ """Validate local completion evidence before `experiment complete`.
178
+
179
+ This is intentionally local and side-effect free: it checks that the
180
+ experiment exists and that the supplied metadata carries at least one
181
+ deploy/test evidence field.
182
+ """
183
+ from cli.main import _read_yaml_file, _run # lazy: avoid cycle
184
+
185
+ metadata = _read_yaml_file(metadata_file)
186
+ if not metadata_has_completion_evidence(metadata):
187
+ keys = ", ".join(sorted(EVIDENCE_METADATA_KEYS))
188
+ typer.echo(
189
+ "Error: missing completion evidence metadata. "
190
+ f"Accepted keys include: {keys}.",
191
+ err=True,
192
+ )
193
+ raise typer.Exit(2)
194
+
195
+ def _action(client: MAPClient):
196
+ exp = client.get_experiment(experiment_id)
197
+ return {
198
+ "experiment_id": str(exp.id),
199
+ "phase": exp.phase,
200
+ "current_plan_version": exp.current_plan_version,
201
+ "evidence_keys": sorted(str(key) for key in metadata) if isinstance(metadata, dict) else [],
202
+ "ok": True,
203
+ }
204
+
205
+ _run(_action)
206
+
207
+
208
+ @experiment_app.command("complete")
209
+ def experiment_complete(
210
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
211
+ summary: str = typer.Option(..., "--summary"),
212
+ log_file: Path = typer.Option(..., "--file"),
213
+ metadata_file: Path | None = typer.Option(None, "--metadata"),
214
+ allow_missing_evidence: bool = typer.Option(
215
+ False,
216
+ "--allow-missing-evidence",
217
+ help="Bypass metadata evidence check for non-deployment experiments.",
218
+ ),
219
+ schema: bool = typer.Option(
220
+ False,
221
+ "--schema",
222
+ help="cli-ux PR1: print the --metadata YAML template (with field hints) and exit. "
223
+ "Use this to discover accepted keys without grepping the SDK.",
224
+ ),
225
+ ) -> None:
226
+ """Submit experiment result for reviewer approval (running → result_review).
227
+
228
+ The log body must follow the 4-段 template contract (summary / 实施 log /
229
+ 风险 / acceptance). Soft validation runs server-side; any
230
+ ``template_validation.warnings`` are surfaced to stderr (one
231
+ ``[WARN] template: <code>`` line each) and to the stdout YAML/JSON
232
+ payload under ``template_validation``. Warnings never block the
233
+ transition — add a follow-up log to address them.
234
+ """
235
+ from cli.main import ( # lazy: avoid cycle
236
+ _load_complete_metadata,
237
+ _print_complete_metadata_schema_and_exit,
238
+ _read_text_file,
239
+ _run,
240
+ )
241
+ if schema:
242
+ _print_complete_metadata_schema_and_exit()
243
+ metadata = _load_complete_metadata(
244
+ metadata_file,
245
+ allow_missing_evidence=allow_missing_evidence,
246
+ )
247
+ payload = ExperimentComplete(
248
+ summary=summary,
249
+ content_md=_read_text_file(log_file, kind="log"),
250
+ metadata=metadata,
251
+ )
252
+ _run(lambda c: c.complete_experiment(experiment_id, payload), experiment_id=experiment_id)
253
+
254
+
255
+ @experiment_app.command("accept-result")
256
+ def experiment_accept_result(
257
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
258
+ summary: str = typer.Option(..., "--summary"),
259
+ log_file: Path = typer.Option(..., "--file"),
260
+ metadata_file: Path | None = typer.Option(None, "--metadata"),
261
+ review_verdict_file: Path | None = typer.Option(
262
+ None,
263
+ "--review-verdict-file",
264
+ help=(
265
+ "Optional structured verdict file (YAML). When omitted, the call "
266
+ "is treated as legacy free-text (pre_schema_accept_result='true'). "
267
+ "When provided, server validates review_id/item_id ownership and "
268
+ "records verdict breakdown in log metadata."
269
+ ),
270
+ ),
271
+ schema: bool = typer.Option(
272
+ False,
273
+ "--schema",
274
+ help="cli-ux PR1: print the --review-verdict-file YAML template (with field hints) and exit. "
275
+ "Use this to discover the verdict schema without grepping the SDK.",
276
+ ),
277
+ ) -> None:
278
+ from cli.main import ( # lazy: avoid cycle
279
+ _load_review_verdict_file,
280
+ _print_review_verdict_schema_and_exit,
281
+ _read_text_file,
282
+ _read_yaml_file,
283
+ _run,
284
+ )
285
+ if schema:
286
+ _print_review_verdict_schema_and_exit()
287
+ metadata = _read_yaml_file(metadata_file)
288
+ verdict_file = _load_review_verdict_file(review_verdict_file)
289
+ payload = ExperimentResultDecision(
290
+ summary=summary,
291
+ content_md=_read_text_file(log_file, kind="log"),
292
+ metadata=metadata,
293
+ verdict_file=verdict_file,
294
+ )
295
+ _run(lambda c: c.accept_experiment_result(experiment_id, payload), experiment_id=experiment_id)
296
+
297
+
298
+ @experiment_app.command("reject-result")
299
+ def experiment_reject_result(
300
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
301
+ summary: str = typer.Option(..., "--summary"),
302
+ log_file: Path = typer.Option(..., "--file"),
303
+ metadata_file: Path | None = typer.Option(None, "--metadata"),
304
+ review_verdict_file: Path | None = typer.Option(
305
+ None,
306
+ "--review-verdict-file",
307
+ help=(
308
+ "Optional structured verdict file (YAML). When omitted, the call "
309
+ "is treated as legacy free-text (pre_schema_accept_result='true'). "
310
+ "When provided, server validates review_id/item_id ownership and "
311
+ "records verdict breakdown in log metadata."
312
+ ),
313
+ ),
314
+ schema: bool = typer.Option(
315
+ False,
316
+ "--schema",
317
+ help="cli-ux PR1: print the --review-verdict-file YAML template (with field hints) and exit. "
318
+ "Use this to discover the verdict schema without grepping the SDK.",
319
+ ),
320
+ ) -> None:
321
+ from cli.main import ( # lazy: avoid cycle
322
+ _load_review_verdict_file,
323
+ _print_review_verdict_schema_and_exit,
324
+ _read_text_file,
325
+ _read_yaml_file,
326
+ _run,
327
+ )
328
+ if schema:
329
+ _print_review_verdict_schema_and_exit()
330
+ metadata = _read_yaml_file(metadata_file)
331
+ verdict_file = _load_review_verdict_file(review_verdict_file)
332
+ payload = ExperimentResultDecision(
333
+ summary=summary,
334
+ content_md=_read_text_file(log_file, kind="log"),
335
+ metadata=metadata,
336
+ verdict_file=verdict_file,
337
+ )
338
+ _run(lambda c: c.reject_experiment_result(experiment_id, payload), experiment_id=experiment_id)
339
+
340
+
341
+ @experiment_app.command("log")
342
+ def experiment_log(
343
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
344
+ summary: str = typer.Option(..., "--summary"),
345
+ log_file: Path = typer.Option(..., "--file"),
346
+ metadata_file: Path | None = typer.Option(None, "--metadata"),
347
+ force_skip_similarity: bool = typer.Option(
348
+ False,
349
+ "--force-skip-similarity",
350
+ help=(
351
+ "b72d0542 I1.b(2)(e): acknowledge the soft content-similarity "
352
+ "warning when the new log body is >= 70%% similar to a prior "
353
+ "log. The warning is suppressed in stdout and a "
354
+ "``log.force_skip`` audit row is written instead."
355
+ ),
356
+ ),
357
+ ) -> None:
358
+ """Append an execution log entry to an experiment.
359
+
360
+ Output (8ac93d4e I1.d): stdout is the ``LogCreateResponse`` wrapper
361
+ (yaml/json, top-level ``log`` + ``validation``). Soft plan evidence_keys
362
+ warnings appear as ``validation.warnings`` in stdout. When the plan's
363
+ frontmatter existed but its YAML failed to parse, the CLI additionally
364
+ emits ``[WARN] plan evidence_keys 解析失败: <msg>`` on stderr so the
365
+ host notices the malformed plan; the log itself is still saved.
366
+
367
+ b72d0542 I1.b(2)(e): when the server detects content similarity
368
+ >= threshold against a prior log on the same experiment, the
369
+ ``similarity_warning`` block is added to the stdout payload AND a
370
+ ``[WARN] similarity: HIGH_CONTENT_SIMILARITY score=X >= threshold=Y``
371
+ line is emitted on stderr. Pass ``--force-skip-similarity`` to
372
+ suppress the warning and instead write a ``log.force_skip`` audit row.
373
+ """
374
+ from cli.main import _read_text_file, _read_yaml_file, _run # lazy: avoid cycle
375
+ metadata = _read_yaml_file(metadata_file)
376
+ payload = ExperimentLogCreate(
377
+ summary=summary,
378
+ content_md=_read_text_file(log_file, kind="log"),
379
+ metadata=metadata,
380
+ force_skip_similarity=force_skip_similarity,
381
+ )
382
+ _run(lambda c: c.create_log(experiment_id, payload), experiment_id=experiment_id)
383
+
384
+
385
+ @experiment_app.command("logs")
386
+ def experiment_logs(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
387
+ from cli.main import _run # lazy: avoid cycle
388
+ _run(lambda c: c.list_logs(experiment_id), experiment_id=experiment_id)
389
+
390
+
391
+ @experiment_app.command("status")
392
+ def experiment_status(
393
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
394
+ persona_compare: bool = typer.Option(
395
+ False,
396
+ "--persona-compare",
397
+ help="0db51e10 I1(5a): diff the per-actor view of this experiment across "
398
+ "multiple personas (default: host+reviewer+participant). Each persona's "
399
+ "view is fetched independently via its own token.",
400
+ ),
401
+ raw: bool = typer.Option(
402
+ False,
403
+ "--raw",
404
+ help="0db51e10 I1(5a): with --persona-compare, print each persona's full "
405
+ "snapshot side-by-side (YAML) instead of the diff table.",
406
+ ),
407
+ compare_personas: str | None = typer.Option(
408
+ None,
409
+ "--for-personas",
410
+ help="0db51e10 I1(5a): with --persona-compare, comma-separated persona "
411
+ "names to compare (e.g. 'host,reviewer'); default = all known personas.",
412
+ ),
413
+ ) -> None:
414
+ """Show one experiment's phase, actions, blocked_on, and (per-actor) capabilities."""
415
+ from cli.main import _persona_compare_view, _run # lazy: avoid cycle
416
+ if persona_compare:
417
+ _run(
418
+ lambda c: _persona_compare_view(
419
+ c,
420
+ experiment_id,
421
+ personas=(
422
+ [p.strip() for p in compare_personas.split(",") if p.strip()]
423
+ if compare_personas
424
+ else None
425
+ ),
426
+ raw=raw,
427
+ ),
428
+ experiment_id=experiment_id,
429
+ )
430
+ return
431
+
432
+ def _action(client: MAPClient):
433
+ result = client.get_experiment(experiment_id)
434
+ typer.echo(f"actions: {list(result.actions)}")
435
+ typer.echo(f"blocked_on: {result.blocked_on}")
436
+ typer.echo(f"phase_owner: {getattr(result, 'phase_owner', 'host')}")
437
+ typer.echo(f"informational_only: {getattr(result, 'informational_only', False)}")
438
+ if result.blocked_on == "open_unreasonable_item" or "plan_revise" in result.actions:
439
+ typer.echo(
440
+ "obligation: revise plan for open unreasonable items "
441
+ "(see pending_plan_revisions in map todos / map work)"
442
+ )
443
+ elif result.blocked_on == "awaiting_review_for_current_plan_version":
444
+ typer.echo(
445
+ "obligation: reviewer must experiment review add for the current "
446
+ f"plan version (v{result.current_plan_version}); host cannot approve until then"
447
+ )
448
+ elif result.blocked_on == "awaiting_non_creator_review":
449
+ typer.echo(
450
+ "obligation: reviewer must submit the first experiment review "
451
+ "(experiment review add)"
452
+ )
453
+ # f873c287 I1(d): unified "host blocked, waiting on {phase_owner}"
454
+ # catch-all for blocked experiments whose decision authority is
455
+ # held by a non-host persona and have NO blocked_on-specific
456
+ # actionable hint above (e.g. result_review /
457
+ # awaiting_result_approval). When a specific obligation branch
458
+ # already matched, the phase_owner line printed above carries
459
+ # the equivalent "who's holding this" signal — no need to
460
+ # duplicate.
461
+ elif (
462
+ getattr(result, "blocked_on", None)
463
+ and getattr(result, "phase_owner", None)
464
+ and result.phase_owner.value != "host"
465
+ and not result.actions
466
+ ):
467
+ typer.echo(
468
+ f"obligation: blocked, waiting on {result.phase_owner.value} "
469
+ f"(blocked_on={result.blocked_on})"
470
+ )
471
+ return result
472
+
473
+ _run(_action)
474
+
475
+
476
+ @experiment_app.command("show")
477
+ def experiment_show(
478
+ experiment_id: uuid.UUID | None = typer.Option(None, "--id", help="Experiment UUID."),
479
+ ) -> None:
480
+ """Show one experiment (including archived) by UUID."""
481
+ from cli.main import _require_option_uuid, _run # lazy: avoid cycle
482
+ experiment_id = _require_option_uuid(experiment_id)
483
+ _run(lambda c: c.get_experiment(experiment_id), experiment_id=experiment_id)
484
+
485
+
486
+ @experiment_app.command(
487
+ "archive",
488
+ epilog="Use --undo or --unarchive to restore an archived experiment.",
489
+ )
490
+ def experiment_archive(
491
+ experiment_id: uuid.UUID | None = typer.Option(None, "--id", help="Experiment UUID."),
492
+ undo: bool = typer.Option(
493
+ False,
494
+ "--undo",
495
+ help="Unarchive instead of archive. Equivalent to --unarchive.",
496
+ ),
497
+ unarchive: bool = typer.Option(
498
+ False,
499
+ "--unarchive",
500
+ help="Alias of --undo: unarchive instead of archive.",
501
+ ),
502
+ ) -> None:
503
+ """Archive (or unarchive) an experiment.
504
+
505
+ Thin wrapper around ``PATCH /experiments/{id}`` with ``archived=true``
506
+ (or ``false`` when ``--undo``/``--unarchive`` is set). Archive hides the
507
+ experiment from ``experiment list`` by default but ``experiment show``
508
+ still returns it including ``archived_at``. Archive is reversible —
509
+ re-run with ``--undo`` to restore.
510
+
511
+ Examples:
512
+
513
+ # Archive
514
+ map --persona host experiment archive --id <uuid>
515
+
516
+ # Unarchive (two equivalent spellings)
517
+ map --persona host experiment archive --id <uuid> --undo
518
+ map --persona host experiment archive --id <uuid> --unarchive
519
+ """
520
+ from cli.main import _require_option_uuid, _run # lazy: avoid cycle
521
+ experiment_id = _require_option_uuid(experiment_id)
522
+ from server.domain.schemas import ExperimentUpdate
523
+
524
+ payload = ExperimentUpdate(archived=not (undo or unarchive))
525
+ object_kind = "experiment"
526
+
527
+ def action(c: MAPClient):
528
+ try:
529
+ return c.update_experiment(experiment_id, payload)
530
+ except MAPNotFoundError as exc:
531
+ typer.echo(
532
+ f"Error: {object_kind} {experiment_id} not found",
533
+ err=True,
534
+ )
535
+ raise typer.Exit(1) from exc
536
+
537
+ _run(action)
538
+
539
+
540
+ @lock_app.command("acquire")
541
+ def experiment_lock_acquire(
542
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
543
+ ttl: int = typer.Option(1800, "--ttl", min=1, help="Lock TTL in seconds."),
544
+ ) -> None:
545
+ from cli.main import _run # lazy: avoid cycle
546
+ _run(lambda c: c.acquire_experiment_lock(experiment_id, ttl_seconds=ttl), experiment_id=experiment_id)
547
+
548
+
549
+ @lock_app.command("release")
550
+ def experiment_lock_release(experiment_id: uuid.UUID = typer.Option(..., "--id")) -> None:
551
+ from cli.main import _run # lazy: avoid cycle
552
+ _run(lambda c: c.release_experiment_lock(experiment_id), experiment_id=experiment_id)
553
+
554
+
555
+ @lock_app.command("force-release")
556
+ def experiment_lock_force_release(
557
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
558
+ reason: str = typer.Option(..., "--reason"),
559
+ actor: str | None = typer.Option(None, "--actor"),
560
+ ) -> None:
561
+ from cli.main import _run # lazy: avoid cycle
562
+ _run(lambda c: c.force_release_experiment_lock(experiment_id, reason=reason, actor=actor), experiment_id=experiment_id)
563
+
564
+
565
+ @lock_app.command("skip")
566
+ def experiment_lock_skip(
567
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
568
+ next_attempt_at: str = typer.Option(..., "--next-attempt-at"),
569
+ ) -> None:
570
+ from cli.main import _run # lazy: avoid cycle
571
+ _run(lambda c: c.record_experiment_lock_skip(experiment_id, next_attempt_at=next_attempt_at), experiment_id=experiment_id)
572
+
573
+
574
+ @lock_app.command("scan-stalled")
575
+ def experiment_lock_scan_stalled() -> None:
576
+ """Scan running experiment locks and emit no-progress notifications."""
577
+ from cli.main import _run # lazy: avoid cycle
578
+
579
+ _run(lambda c: c.scan_stalled_experiment_locks())
580
+
581
+
582
+ @review_app.command("add")
583
+ def review_add(
584
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
585
+ review_file: Path = typer.Option(..., "--review"),
586
+ ) -> None:
587
+ from cli.main import _read_text_file, _run # lazy: avoid cycle
588
+ raw = yaml.safe_load(_read_text_file(review_file, kind="review"))
589
+ payload = ReviewCreate.model_validate(raw)
590
+ _run(lambda c: c.create_review(experiment_id, payload), experiment_id=experiment_id)
591
+
592
+
593
+ @plan_app.command("revise")
594
+ def plan_revise(
595
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
596
+ plan_file: Path = typer.Option(..., "--plan-file"),
597
+ note: str | None = typer.Option(None, "--note"),
598
+ addressed_item: list[uuid.UUID] = typer.Option(
599
+ [],
600
+ "--addressed-item",
601
+ help="Unreasonable review item UUID to mark addressed (repeatable).",
602
+ ),
603
+ ) -> None:
604
+ from cli.main import _read_text_file, _run # lazy: avoid cycle
605
+ payload = PlanRevise(
606
+ content_md=_read_text_file(plan_file, kind="plan"),
607
+ change_note=note,
608
+ addressed_item_ids=list(addressed_item),
609
+ )
610
+ _run(lambda c: c.revise_plan(experiment_id, payload), experiment_id=experiment_id)
611
+
612
+
613
+ @plan_app.command("validate")
614
+ def plan_validate(
615
+ plan_file: Path = typer.Option(..., "--plan-file"),
616
+ strict: bool = typer.Option(
617
+ False,
618
+ "--strict/--no-strict",
619
+ help="Exit 1 when warnings are present (default: exit 0, warnings only).",
620
+ ),
621
+ ) -> None:
622
+ """Local plan frontmatter lint (no API call).
623
+
624
+ Reads the plan file, parses YAML frontmatter, and lists any
625
+ missing/invalid required fields (``title`` / ``acceptance`` /
626
+ ``evidence_keys`` / ``dependencies``). Soft-validates by default
627
+ (always exits 0 unless the file is unreadable); use ``--strict`` to
628
+ exit 1 when warnings are present, so ``experiment create`` scripts
629
+ can gate on lint outcome.
630
+ """
631
+ from cli.main import _print_json, _project_cli_default_format, _read_text_file # lazy: avoid cycle
632
+ from server.services.plan_marker_service import validate_plan_frontmatter
633
+
634
+ content = _read_text_file(plan_file, kind="plan")
635
+ result = validate_plan_frontmatter(content)
636
+
637
+ payload = {
638
+ "valid": result.valid,
639
+ "fields_present": list(result.fields_present),
640
+ "warnings": [
641
+ {
642
+ "code": w.code,
643
+ "field": w.field,
644
+ "detail": w.detail,
645
+ }
646
+ for w in result.warnings
647
+ ],
648
+ "frontmatter": result.frontmatter,
649
+ }
650
+
651
+ fmt = _project_cli_default_format(None)
652
+ if fmt == "json":
653
+ _print_json(payload)
654
+ else:
655
+ typer.echo(
656
+ f"Plan lint: {'PASS' if result.valid else 'FAIL'} "
657
+ f"(fields_present={len(result.fields_present)}, warnings={len(result.warnings)})"
658
+ )
659
+ if result.frontmatter is not None:
660
+ typer.echo(f"frontmatter: {result.frontmatter}")
661
+ for w in result.warnings:
662
+ field_label = w.field or "-"
663
+ detail = f" ({w.detail})" if w.detail else ""
664
+ typer.echo(f" - [{w.code}] {field_label}{detail}")
665
+
666
+ if strict and result.warnings:
667
+ raise typer.Exit(1)
668
+
669
+
670
+ @review_app.command("list")
671
+ def review_list(
672
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
673
+ # N=2 过渡期默认 true (experiment 18f1d8f6 I1(c));N=2 release 后切到 False。
674
+ # 见 plan 当前_plan_version 的 history 默认展示策略。
675
+ include_archived: bool = typer.Option(
676
+ True,
677
+ "--include-archived/--no-include-archived",
678
+ help="Include archived reviews. Default true during N=2 transition; flips to false at N=2 release.",
679
+ ),
680
+ plan_version: int | None = typer.Option(
681
+ None,
682
+ "--plan-version",
683
+ help="Filter by exact plan_version. Combine with --include-archived to inspect historical review chains.",
684
+ ),
685
+ ) -> None:
686
+ from cli.main import _run # lazy: avoid cycle
687
+ def action(c: MAPClient):
688
+ return c.list_reviews(
689
+ experiment_id,
690
+ include_archived=include_archived,
691
+ plan_version=plan_version,
692
+ )
693
+
694
+ _run(action)
695
+
696
+
697
+ @review_app.command("withdraw")
698
+ def review_withdraw(
699
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
700
+ review_id: uuid.UUID = typer.Option(..., "--review-id"),
701
+ ) -> None:
702
+ from cli.main import _run # lazy: avoid cycle
703
+ _run(lambda c: c.withdraw_review(experiment_id, review_id))
704
+
705
+
706
+ @review_app.command("resolve-item")
707
+ def review_resolve_item(
708
+ item_id: uuid.UUID = typer.Option(..., "--id"),
709
+ status: str = typer.Option(
710
+ "resolved",
711
+ "--status",
712
+ help="Target terminal status for this review item.",
713
+ ),
714
+ ) -> None:
715
+ """Resolve a single review item.
716
+
717
+ ``--status`` accepts ``resolved`` (accept the item) or ``rebutted``
718
+ (host pushes back on the item while keeping the experiment moving).
719
+ Defaults to ``resolved`` so existing scripts that omit ``--status``
720
+ keep their old behaviour — the migration is opt-in.
721
+ """
722
+ from map_types.enums import ReviewItemStatus
723
+
724
+ from cli.main import _run # lazy: avoid cycle
725
+
726
+ _run(lambda c: c.update_review_item(item_id, ReviewItemStatus(status)))
727
+
728
+
729
+ @experiment_app.command("comment")
730
+ def experiment_comment(
731
+ experiment_id: uuid.UUID = typer.Option(..., "--id"),
732
+ anchor_type: str = typer.Option(..., "--anchor-type"),
733
+ anchor_id: uuid.UUID = typer.Option(..., "--anchor-id"),
734
+ body: str | None = typer.Option(None, "--body"),
735
+ body_file: Path | None = typer.Option(None, "--file", help="Read body from a file (avoids shell-quoting issues)."),
736
+ parent: uuid.UUID | None = typer.Option(None, "--parent"),
737
+ ) -> None:
738
+ from cli.main import _read_text_file, _run # lazy: avoid cycle
739
+ from server.domain.models import CommentAnchorType
740
+ from server.domain.schemas import CommentCreate
741
+
742
+ if body is None and body_file is None:
743
+ typer.echo("Error: either --body or --file is required", err=True)
744
+ raise typer.Exit(2)
745
+ if body is not None and body_file is not None:
746
+ typer.echo("Error: use only one of --body or --file", err=True)
747
+ raise typer.Exit(2)
748
+ content = body if body is not None else _read_text_file(body_file, kind="comment")
749
+ payload = CommentCreate(
750
+ anchor_type=CommentAnchorType(anchor_type),
751
+ anchor_id=anchor_id,
752
+ parent_id=parent,
753
+ body=content,
754
+ )
755
+ _run(lambda c: c.create_comment(experiment_id, payload))