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/main.py ADDED
@@ -0,0 +1,1553 @@
1
+ import os
2
+ import uuid
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import typer
11
+ import yaml
12
+ from map_client.bootstrap import admin_client, bootstrap_project_map
13
+ from map_client.client import MAPClient
14
+ from map_client.exceptions import MAPHTTPError
15
+ from map_client.project_config import find_map_dir, load_project_map_config, resolve_client
16
+
17
+ # arch experiment (0519e2a3) PR1: shared SDK umbrella. Later PRs move
18
+ # shared helpers here (see plan). The CLI must stay importable even
19
+ # when ``map_sdk`` is unavailable, so we don't gate startup on it.
20
+ from map_sdk.evidence import (
21
+ EVIDENCE_METADATA_KEYS,
22
+ metadata_has_completion_evidence,
23
+ )
24
+ from pydantic import BaseModel, ConfigDict
25
+
26
+ # arch experiment (0519e2a3) PR3/PR5: agent / notification /
27
+ # inbound-event / audit sub-app splits. Each module only exports the
28
+ # sub-app instance here; the helpers the commands use
29
+ # (``_run``, ``_admin_client_ctx``, ``_read_text_file``, ``_print_json``)
30
+ # are pulled in lazily inside each command body to break the
31
+ # ``cli.main ↔ cli.commands.*`` cycles.
32
+ from cli.commands.action import action_app
33
+ from cli.commands.agent import agent_app
34
+ from cli.commands.audit import audit_app
35
+ from cli.commands.docs import docs_app
36
+ from cli.commands.experiment import experiment_app
37
+ from cli.commands.feedback import feedback_app
38
+ from cli.commands.notification import inbound_event_app, notification_app
39
+ from cli.commands.persona import persona_app
40
+ from cli.commands.project import project_app
41
+ from cli.commands.runtime import runtime_app
42
+ from cli.commands.topic import mention_app, todo_app, topic_app
43
+ from cli.e2e_collab import e2e_app
44
+ from server.domain.models import AgentRole
45
+ from server.domain.schemas import TopicResolve
46
+
47
+ app = typer.Typer(name="map", help="Multi-Agent Platform CLI", rich_markup_mode=None)
48
+ # Sub-apps live in cli/commands/*; imported here only to register via
49
+ # add_typer. Command bodies lazy-import helpers from this module to
50
+ # break ``cli.main ↔ cli.commands.*`` cycles.
51
+ app.add_typer(project_app, name="project")
52
+ app.add_typer(experiment_app, name="experiment")
53
+ app.add_typer(persona_app, name="persona")
54
+ app.add_typer(runtime_app, name="runtime")
55
+ app.add_typer(agent_app, name="agent")
56
+ app.add_typer(notification_app, name="notification")
57
+ app.add_typer(inbound_event_app, name="inbound-event")
58
+ app.add_typer(audit_app, name="audit")
59
+ app.add_typer(topic_app, name="topic")
60
+ app.add_typer(mention_app, name="mention")
61
+ app.add_typer(todo_app, name="todo")
62
+ app.add_typer(action_app, name="action")
63
+ app.add_typer(feedback_app, name="feedback")
64
+ app.add_typer(docs_app, name="docs")
65
+ app.add_typer(e2e_app, name="e2e")
66
+
67
+ _transport: httpx.BaseTransport | None = None
68
+ _cli_options: dict[str, Any] = {"persona": None, "project_root": None, "format": "yaml"}
69
+
70
+
71
+ # 8a8822b5 (a): N=2 release cutoff. ``MAP_CLI_RELEASE_VERSION`` controls
72
+ # whether the post-N=2 behavior is active. The build / release script is
73
+ # expected to set this env var at install time once N=2 ships; for now
74
+ # we default to "0.x" so legacy yaml stays default. The hard-cutover
75
+ # behavior is:
76
+ # * if release >= N=2, ``yaml`` / ``legacy`` formats are force-overridden
77
+ # to ``json`` regardless of flag / env, with a one-shot stderr warning;
78
+ # * if ``.map/config.yaml`` declares ``cli.default_format: yaml`` under
79
+ # N=2 release, the same warning fires and the value is ignored.
80
+ _N2_RELEASE_MAJOR = 1 # bump here when N=2 ships
81
+ _N2_DEFAULT_RELEASE = "0.9"
82
+
83
+
84
+ def _parse_release_version(raw: str | None) -> tuple[int, int]:
85
+ """Parse ``MAJOR.MINOR`` release tuple; return ``(0, 9)`` on any failure.
86
+
87
+ The parser is intentionally permissive — anything we cannot interpret
88
+ is treated as pre-N=2 so we never accidentally hard-cutover a script.
89
+ """
90
+ if not raw:
91
+ return (0, 9)
92
+ raw = raw.strip()
93
+ if not raw:
94
+ return (0, 9)
95
+ parts = raw.split(".")
96
+ try:
97
+ major = int(parts[0])
98
+ except (ValueError, IndexError):
99
+ return (0, 9)
100
+ try:
101
+ minor = int(parts[1]) if len(parts) > 1 else 0
102
+ except ValueError:
103
+ return (major, 0)
104
+ return (major, minor)
105
+
106
+
107
+ def _is_n2_released() -> bool:
108
+ """True iff ``MAP_CLI_RELEASE_VERSION`` parses to ``>= (_N2_RELEASE_MAJOR, 0)``."""
109
+ raw = os.environ.get("MAP_CLI_RELEASE_VERSION")
110
+ major, minor = _parse_release_version(raw)
111
+ if major > _N2_RELEASE_MAJOR:
112
+ return True
113
+ if major < _N2_RELEASE_MAJOR:
114
+ return False
115
+ return minor >= 0 # any minor in the N=2 major line is in release
116
+
117
+
118
+ def _project_cli_default_format(project_root: Path | None) -> str | None:
119
+ """Read ``cli.default_format`` from ``.map/config.yaml``.
120
+
121
+ Returns ``None`` when the project root is not provided, when
122
+ ``.map/config.yaml`` is missing, or when the key is absent. The check
123
+ is intentionally narrow — we only look at the explicit key the release
124
+ checklist flips; everything else (including unknown keys) is ignored.
125
+ """
126
+ if project_root is None:
127
+ return None
128
+ config_path = project_root / ".map" / "config.yaml"
129
+ if not config_path.is_file():
130
+ return None
131
+ try:
132
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
133
+ except (yaml.YAMLError, OSError):
134
+ return None
135
+ if not isinstance(data, dict):
136
+ return None
137
+ cli_block = data.get("cli")
138
+ if not isinstance(cli_block, dict):
139
+ return None
140
+ value = cli_block.get("default_format")
141
+ if not isinstance(value, str):
142
+ return None
143
+ return value.strip().lower() or None
144
+
145
+
146
+ def _apply_n2_hard_cutover(
147
+ *,
148
+ current_format: str,
149
+ current_source: str,
150
+ project_root: Path | None,
151
+ ) -> tuple[str, str, list[str]]:
152
+ """Apply 8a8822b5 (a) post-N=2 yaml hard-cutover.
153
+
154
+ Returns ``(new_format, new_source, warnings)``. Warnings are emitted
155
+ by the caller; this function is pure so it is unit-testable without
156
+ touching ``typer.echo``.
157
+ """
158
+ if not _is_n2_released():
159
+ return current_format, current_source, []
160
+
161
+ warnings: list[str] = []
162
+ config_default = _project_cli_default_format(project_root)
163
+ if config_default == "yaml":
164
+ warnings.append(
165
+ "Warning: .map/config.yaml `cli.default_format: yaml` is "
166
+ "deprecated in N=2; CLI is forcing json output."
167
+ )
168
+ return "json", "n2-release-cutover", warnings
169
+
170
+ # Only warn when the user EXPLICITLY asked for yaml (flag / env / config).
171
+ # The implicit default is the CLI's own choice — N=2 silently swaps it
172
+ # to json without a deprecation warning, since the user never asked for
173
+ # yaml in the first place.
174
+ if current_format == "yaml" and current_source in {
175
+ "explicit --format",
176
+ "MAP_CLI_FORMAT env",
177
+ "config cli.default_format",
178
+ }:
179
+ warnings.append(
180
+ "Warning: yaml output format is removed in N=2; "
181
+ "CLI is forcing json output."
182
+ )
183
+ return "json", "n2-release-cutover", warnings
184
+
185
+ # Silent default-yaml → json transition (no warning).
186
+ if current_format == "yaml" and current_source == "default":
187
+ return "json", "n2-release-cutover", []
188
+
189
+ return current_format, current_source, warnings
190
+
191
+
192
+ @app.callback()
193
+ def cli_global_options(
194
+ persona: str | None = typer.Option(
195
+ None,
196
+ "--persona",
197
+ "-p",
198
+ help="Persona from .map/agents.local.yaml (host, participant, reviewer, …)",
199
+ ),
200
+ project_root: Path | None = typer.Option(
201
+ None,
202
+ "--project-root",
203
+ help="Code repo root containing .map/ (default: search upward from cwd)",
204
+ ),
205
+ output_format: str | None = typer.Option(
206
+ None,
207
+ "--format",
208
+ "-o",
209
+ help=(
210
+ "Output format: 'table' (list commands default) renders a compact "
211
+ "scannable table; 'yaml' (default for non-list commands) dumps the "
212
+ "full structured object; 'json' emits machine-parseable JSON to "
213
+ "stdout with a structured error envelope on stderr. 'legacy' is an "
214
+ "alias for 'yaml' and will be removed in N=2. "
215
+ "Explicit --format always wins over the MAP_CLI_FORMAT env var."
216
+ ),
217
+ ),
218
+ ) -> None:
219
+ # 8a8822b5 (f): resolve --format / MAP_CLI_FORMAT priority.
220
+ # Explicit --format flag > MAP_CLI_FORMAT env var > default 'yaml'.
221
+ env_format = os.environ.get("MAP_CLI_FORMAT", "").strip().lower() or None
222
+ resolved: str | None
223
+ source: str
224
+ if output_format is not None:
225
+ resolved = output_format.lower()
226
+ source = "explicit --format"
227
+ if env_format and env_format != resolved:
228
+ typer.echo(
229
+ f"Warning: explicit --format={resolved} overrides "
230
+ f"MAP_CLI_FORMAT={env_format}",
231
+ err=True,
232
+ )
233
+ elif env_format:
234
+ resolved = env_format
235
+ source = "MAP_CLI_FORMAT env"
236
+ else:
237
+ resolved = "yaml"
238
+ source = "default"
239
+
240
+ # 8a8822b5 (a): post-N=2 — ``.map/config.yaml cli.default_format`` becomes
241
+ # an explicit project-level source (sits between env var and the implicit
242
+ # default). Pre-N=2 keeps the legacy default to avoid breaking existing
243
+ # scripts that have not migrated.
244
+ if (
245
+ _is_n2_released()
246
+ and output_format is None
247
+ and env_format is None
248
+ ):
249
+ config_default = _project_cli_default_format(project_root)
250
+ if config_default in ("yaml", "json"):
251
+ resolved = config_default
252
+ source = "config cli.default_format"
253
+
254
+ if resolved == "legacy":
255
+ typer.echo(
256
+ "Warning: MAP_CLI_FORMAT=legacy is deprecated; "
257
+ "use 'yaml' explicitly. The 'legacy' alias will be removed in N=2.",
258
+ err=True,
259
+ )
260
+ resolved = "yaml"
261
+
262
+ if resolved not in ("yaml", "json", "table"):
263
+ typer.echo(
264
+ f"Error: unknown --format {resolved!r}; expected 'table', 'yaml', 'json', or 'legacy'.",
265
+ err=True,
266
+ )
267
+ raise typer.Exit(2)
268
+
269
+ # 8a8822b5 (a): post-N=2 yaml hard-cutover.
270
+ resolved, source, n2_warnings = _apply_n2_hard_cutover(
271
+ current_format=resolved,
272
+ current_source=source,
273
+ project_root=project_root,
274
+ )
275
+ for warning in n2_warnings:
276
+ typer.echo(warning, err=True)
277
+
278
+ _cli_options["persona"] = persona
279
+ _cli_options["project_root"] = project_root
280
+ _cli_options["format"] = resolved
281
+ _cli_options["format_source"] = source
282
+
283
+
284
+ @contextmanager
285
+ def _client_ctx() -> Iterator[MAPClient]:
286
+ try:
287
+ client = resolve_client(
288
+ persona=_cli_options.get("persona"),
289
+ project_root=_cli_options.get("project_root"),
290
+ transport=_transport,
291
+ )
292
+ except ValueError as exc:
293
+ typer.echo(f"Error: {exc}", err=True)
294
+ raise typer.Exit(1) from exc
295
+ try:
296
+ yield client
297
+ finally:
298
+ client.close()
299
+
300
+
301
+ def _resolve_admin_api_url() -> str:
302
+ """Resolve the API URL for admin-only commands.
303
+
304
+ Admin commands (audit / feedback triage) authenticate with an admin
305
+ token, not a project persona token, so resolution must not depend on
306
+ ``agents.local.yaml``. Prefer the project ``.map/config.yaml``; fall
307
+ back to ``MAP_API_URL`` so admin commands also work outside a
308
+ bootstrapped project.
309
+ """
310
+ project_root = _cli_options.get("project_root")
311
+ try:
312
+ return load_project_map_config(project_root=project_root).api_url
313
+ except ValueError:
314
+ pass
315
+ api_url = os.environ.get("MAP_API_URL")
316
+ if not api_url:
317
+ raise ValueError(
318
+ "No MAP API URL found for admin command. Set --project-root to a repo "
319
+ "with .map/config.yaml, or set the MAP_API_URL env var."
320
+ )
321
+ return api_url.rstrip("/")
322
+
323
+
324
+ @contextmanager
325
+ def _admin_client_ctx() -> Iterator[MAPClient]:
326
+ """Yield a MAPClient authenticated with the admin token.
327
+
328
+ Token source: ``MAP_ADMIN_TOKEN`` env, then ``~/.map/admin.yaml``
329
+ (via ``map_client.bootstrap.admin_client``). Mirrors ``_client_ctx``:
330
+ token/api_url resolution failures become a clean ``typer.Exit(1)``
331
+ rather than a propagated ``ValueError``, so callers (``_run`` with
332
+ ``admin=True`` and ``audit_list``'s inline handler) behave exactly
333
+ like the persona path.
334
+ """
335
+ try:
336
+ api_url = _resolve_admin_api_url()
337
+ client = admin_client(api_url, transport=_transport)
338
+ except ValueError as exc:
339
+ typer.echo(f"Error: {exc}", err=True)
340
+ raise typer.Exit(1) from exc
341
+ try:
342
+ yield client
343
+ finally:
344
+ client.close()
345
+
346
+
347
+ def _print_json(data: Any) -> None:
348
+ typer.echo(yaml.safe_dump(_to_yamlable(data), allow_unicode=True, sort_keys=False))
349
+
350
+
351
+ def _to_yamlable(value: Any) -> Any:
352
+ if hasattr(value, "model_dump"):
353
+ return value.model_dump(mode="json")
354
+ if isinstance(value, Enum):
355
+ return value.value
356
+ if isinstance(value, list):
357
+ return [_to_yamlable(item) for item in value]
358
+ if isinstance(value, tuple):
359
+ return [_to_yamlable(item) for item in value]
360
+ if isinstance(value, dict):
361
+ return {key: _to_yamlable(item) for key, item in value.items()}
362
+ return value
363
+
364
+
365
+ # 0db51e10 I1(5a): per-persona view diff for ``map experiment status
366
+ # --persona-compare``. Each persona calls ``get_experiment`` with its own
367
+ # token; the four diff_fields are surfaced in a compact table by default
368
+ # or as raw YAML via ``--raw``. Defaults to every persona known in
369
+ # ``.map/agents.local.yaml`` (sorted); ``--for-personas`` overrides.
370
+ _PERSONA_COMPARE_DIFF_FIELDS: tuple[str, ...] = (
371
+ "actions",
372
+ "blocked_on",
373
+ "phase_owner",
374
+ "informational_only",
375
+ )
376
+
377
+
378
+ # 0db51e10 I4(5a partition): four-way classification of the
379
+ # ``acceptance_status`` partition across personas. plan v2 (a) calls for
380
+ # these four buckets to be unit-tested for the ``--persona-compare`` view:
381
+ #
382
+ # * ``all_agree`` — every persona sees the same set of
383
+ # acceptance_status entries.
384
+ # * ``partial_diff`` — at least two personas agree on some
385
+ # entries but at least one differs.
386
+ # * ``full_diff`` — every persona's set is distinct (no
387
+ # pairwise equality).
388
+ # * ``cross_phase_fold`` — at least one persona is folded via
389
+ # ``hidden_for_current_persona=True``;
390
+ # acceptance_status is collapsed by the
391
+ # server-side phase whitelist, so the
392
+ # diff is reported as "folded" instead
393
+ # of being compared element-wise.
394
+ #
395
+ # The classifier is a pure function over the already-fetched ``views``
396
+ # dict (persona -> payload dict). It does NOT fetch any data itself;
397
+ # the caller is responsible for collecting ``hidden_for_current_persona``
398
+ # and ``acceptance_status`` from each persona's response.
399
+ _PERSONA_COMPARE_PARTITION_ALL_AGREE = "all_agree"
400
+ _PERSONA_COMPARE_PARTITION_PARTIAL_DIFF = "partial_diff"
401
+ _PERSONA_COMPARE_PARTITION_FULL_DIFF = "full_diff"
402
+ _PERSONA_COMPARE_PARTITION_CROSS_PHASE_FOLD = "cross_phase_fold"
403
+
404
+
405
+ def _classify_persona_compare_partition(
406
+ views: dict[str, dict[str, Any]],
407
+ ) -> str:
408
+ """Classify the ``acceptance_status`` partition across personas.
409
+
410
+ The four-way bucket is the unit-test target of plan v2 (a). Order of
411
+ precedence is fixed:
412
+
413
+ 1. ``cross_phase_fold`` wins if any persona has
414
+ ``hidden_for_current_persona=True`` — the diff is reported as a
415
+ phase-whitelist fold rather than an acceptance_status diff, so
416
+ we never want to misleadingly classify it as ``partial_diff`` or
417
+ ``full_diff``.
418
+ 2. ``full_diff`` if no two personas see the same ``acceptance_status``
419
+ set (every pair is disjoint).
420
+ 3. ``partial_diff`` if at least two personas agree but not all.
421
+ 4. ``all_agree`` as the default — every persona sees the same set.
422
+
423
+ ``views`` is keyed by persona name; values are per-persona payload
424
+ dicts (as returned by ``MAPClient.get_experiment`` and serialised
425
+ via ``model_dump(mode="json")``).
426
+
427
+ Returns one of the four ``_PERSONA_COMPARE_PARTITION_*`` constants.
428
+ """
429
+ if not views:
430
+ # No data to compare — degenerate case; fold is the safest
431
+ # answer (matches "we cannot tell what the others see").
432
+ return _PERSONA_COMPARE_PARTITION_CROSS_PHASE_FOLD
433
+
434
+ hidden = [
435
+ persona
436
+ for persona, payload in views.items()
437
+ if payload.get("hidden_for_current_persona") is True
438
+ ]
439
+ if hidden:
440
+ return _PERSONA_COMPARE_PARTITION_CROSS_PHASE_FOLD
441
+
442
+ # Build frozenset of (id, evidence_provided, reviewer_verdict) per
443
+ # persona so two personas with semantically identical acceptance
444
+ # entries hash to the same bucket.
445
+ def _signature(payload: dict[str, Any]) -> frozenset:
446
+ rows = payload.get("acceptance_status") or []
447
+ return frozenset(
448
+ (
449
+ str(entry.get("id")),
450
+ bool(entry.get("evidence_provided", False)),
451
+ entry.get("reviewer_verdict"),
452
+ )
453
+ for entry in rows
454
+ )
455
+
456
+ signatures = {persona: _signature(payload) for persona, payload in views.items()}
457
+ distinct = set(signatures.values())
458
+
459
+ if len(views) == 1 or len(distinct) == 1:
460
+ return _PERSONA_COMPARE_PARTITION_ALL_AGREE
461
+ if len(distinct) == len(views):
462
+ return _PERSONA_COMPARE_PARTITION_FULL_DIFF
463
+ return _PERSONA_COMPARE_PARTITION_PARTIAL_DIFF
464
+
465
+
466
+ def _persona_compare_view(
467
+ host_client: MAPClient,
468
+ experiment_id: uuid.UUID,
469
+ *,
470
+ personas: list[str] | None = None,
471
+ raw: bool = False,
472
+ ) -> None:
473
+ """Diff one experiment's per-actor view across multiple personas.
474
+
475
+ Each persona's view is fetched via its own bearer token
476
+ (``ProjectMapConfig.client_for``), so the snapshot reflects what that
477
+ persona would actually see when running ``map experiment status`` —
478
+ including the per-actor ``actions`` / ``blocked_on`` computed by
479
+ ``compute_experiment_capabilities``.
480
+
481
+ The output is a compact diff table by default; ``--raw`` prints the
482
+ full per-persona snapshot as YAML (useful for snapshot testing and
483
+ e2e diff review).
484
+
485
+ 0db51e10 I2(5e): after rendering, write an audit row
486
+ (``action=cross_persona_call``) capturing the per-field persona view
487
+ diff + result_partition_count + diff_size. ``host_client`` is the
488
+ default host-token client supplied by ``_run``; audit write failures
489
+ are surfaced as stderr warnings but do NOT block the table output.
490
+ """
491
+ map_dir = find_map_dir(_cli_options.get("project_root"))
492
+ if map_dir is None:
493
+ typer.echo(
494
+ "Error: --persona-compare needs .map/agents.local.yaml; "
495
+ "run `map bootstrap` first.",
496
+ err=True,
497
+ )
498
+ raise typer.Exit(2)
499
+ config = load_project_map_config(map_dir=map_dir)
500
+
501
+ selected = personas or sorted(config.tokens.keys())
502
+
503
+ views: dict[str, dict[str, Any]] = {}
504
+ for persona in selected:
505
+ if persona not in config.tokens:
506
+ typer.echo(
507
+ f"[skip] {persona}: no token in .map/agents.local.yaml",
508
+ err=True,
509
+ )
510
+ continue
511
+ client = MAPClient(config.api_url, config.tokens[persona], transport=_transport)
512
+ try:
513
+ exp = client.get_experiment(experiment_id)
514
+ if hasattr(exp, "model_dump"):
515
+ payload = exp.model_dump(mode="json")
516
+ elif isinstance(exp, dict):
517
+ payload = exp
518
+ elif hasattr(exp, "__dict__"):
519
+ # SimpleNamespace / dataclass-like object: serialize via __dict__.
520
+ payload = {
521
+ k: v for k, v in vars(exp).items() if not k.startswith("_")
522
+ }
523
+ else:
524
+ payload = {}
525
+ views[persona] = payload
526
+ except Exception as exc:
527
+ typer.echo(f"[skip] {persona}: {exc}", err=True)
528
+ finally:
529
+ client.close()
530
+
531
+ if not views:
532
+ typer.echo("Error: no persona view succeeded; aborting.", err=True)
533
+ raise typer.Exit(2)
534
+
535
+ if raw:
536
+ typer.echo(
537
+ yaml.safe_dump(
538
+ _to_yamlable({k: v for k, v in views.items()}),
539
+ allow_unicode=True,
540
+ sort_keys=False,
541
+ )
542
+ )
543
+ return
544
+
545
+ persona_names = list(views.keys())
546
+ col_widths = [
547
+ max(len(field), max((len(_format_compare_value(views[p].get(field))) for p in persona_names), default=0))
548
+ for field in _PERSONA_COMPARE_DIFF_FIELDS
549
+ ]
550
+
551
+ def _row(values: list[str]) -> str:
552
+ return " ".join(v.ljust(w) for v, w in zip(values, [max(len("field"), max(col_widths))] + col_widths, strict=False))
553
+
554
+ typer.echo(f"persona_compare (experiment {experiment_id}):")
555
+ typer.echo(_row(["field", *persona_names]))
556
+ for field in _PERSONA_COMPARE_DIFF_FIELDS:
557
+ typer.echo(_row([field, *[_format_compare_value(views[p].get(field)) for p in persona_names]]))
558
+
559
+ differing = [
560
+ field
561
+ for field in _PERSONA_COMPARE_DIFF_FIELDS
562
+ if len({_format_compare_value(views[p].get(field)) for p in persona_names}) > 1
563
+ ]
564
+ if differing:
565
+ typer.echo(f"diff fields (vary across personas): {', '.join(differing)}")
566
+ else:
567
+ typer.echo("diff fields: (none — all selected personas agree)")
568
+
569
+ # 0db51e10 I4(5a partition): four-way acceptance_status partition
570
+ # classification — plan v2 (a) requires the CLI to surface the bucket
571
+ # so reviewers can quickly tell all-agree / partial / full / folded.
572
+ partition = _classify_persona_compare_partition(views)
573
+ typer.echo(f"acceptance_status partition: {partition}")
574
+
575
+ # 0db51e10 I2(5e): write audit row via host-token client. Audit write
576
+ # failure does not block the user-facing diff table output.
577
+ _write_cross_persona_call_audit(
578
+ host_client=host_client,
579
+ experiment_id=experiment_id,
580
+ views=views,
581
+ differing_fields=differing,
582
+ acceptance_partition=partition,
583
+ )
584
+
585
+
586
+ def _write_cross_persona_call_audit(
587
+ *,
588
+ host_client: MAPClient,
589
+ experiment_id: uuid.UUID,
590
+ views: dict[str, dict[str, Any]],
591
+ differing_fields: list[str],
592
+ acceptance_partition: str | None = None,
593
+ ) -> None:
594
+ """0db51e10 I2(5e): best-effort audit write.
595
+
596
+ Builds the ``visibility_diff`` payload (per-field persona view dict)
597
+ and the ``diff_size`` / ``result_partition_count`` counters, then
598
+ calls ``MAPClient.record_cross_persona_call``. Any failure is
599
+ reported to stderr but does not raise — the diff table has already
600
+ been rendered by the time we get here, and we never want the audit
601
+ write to mask the user's primary output.
602
+
603
+ ``acceptance_partition`` is the four-way bucket label produced by
604
+ ``_classify_persona_compare_partition`` (plan v2 (a)). When provided,
605
+ it is folded into the audit ``visibility_diff`` payload under the
606
+ ``acceptance_status_partition`` key so admin audit queries can
607
+ filter by bucket without re-deriving the classifier.
608
+ """
609
+ visibility_diff: dict[str, dict[str, Any]] = {}
610
+ for field in _PERSONA_COMPARE_DIFF_FIELDS:
611
+ visibility_diff[field] = {
612
+ persona: views[persona].get(field) for persona in views
613
+ }
614
+ if acceptance_partition is not None:
615
+ visibility_diff["acceptance_status_partition"] = acceptance_partition
616
+ try:
617
+ host_client.record_cross_persona_call(
618
+ experiment_id,
619
+ visibility_diff=visibility_diff,
620
+ result_partition_count=len(views),
621
+ diff_size=len(differing_fields),
622
+ )
623
+ except Exception as exc:
624
+ typer.echo(f"[warn] audit write failed: {exc}", err=True)
625
+
626
+
627
+ def _format_compare_value(value: Any) -> str:
628
+ """Render a per-persona diff cell.
629
+
630
+ Lists are rendered as ``[a, b]`` so reviewers can scan the action
631
+ set at a glance; ``None`` and empty lists are distinguished (the
632
+ former means the persona cannot see any action hint at all; the
633
+ latter means the action set is empty).
634
+ """
635
+ if value is None:
636
+ return "(none)"
637
+ if isinstance(value, list):
638
+ return "[" + ", ".join(str(item) for item in value) + "]"
639
+ return str(value)
640
+
641
+
642
+ def _print_warnings(warnings: list[str] | None) -> None:
643
+ for code in warnings or []:
644
+ if code == "no_topic_id":
645
+ typer.echo(
646
+ "Warning: no_topic_id — project has open topics; "
647
+ "consider --topic-id <uuid> to bind this experiment.",
648
+ err=True,
649
+ )
650
+ elif code == "topic_not_ready_for_experiment":
651
+ typer.echo(
652
+ "Warning: topic_not_ready_for_experiment — linked topic is not marked ready; "
653
+ "continuing because this is a host override.",
654
+ err=True,
655
+ )
656
+ else:
657
+ typer.echo(f"Warning: {code}", err=True)
658
+
659
+
660
+ def _emit_evidence_parse_error(parse_error: str | None) -> None:
661
+ """8ac93d4e I1.d: emit a single stderr [WARN] line when the plan
662
+ frontmatter existed but its YAML failed to parse. The structured
663
+ warnings still go to stdout JSON (``validation.warnings == []``);
664
+ stderr only signals the un-parseable plan so the host notices the
665
+ mistake.
666
+ """
667
+ if not parse_error:
668
+ return
669
+ typer.echo(f"[WARN] plan evidence_keys 解析失败: {parse_error}", err=True)
670
+
671
+
672
+ def _emit_template_warnings(template_validation: Any) -> None:
673
+ """b72d0542 I1.c: emit one stderr ``[WARN]`` line per template warning
674
+ so the host notices missing sections / malformed links without
675
+ parsing the YAML/JSON output. The structured
676
+ ``template_validation`` block still surfaces on stdout (via
677
+ ``_print_json``), keeping a single machine-parseable source of truth.
678
+
679
+ Stderr lines have the form::
680
+
681
+ [WARN] template: <code> (section=<name>) — <detail>
682
+
683
+ Soft validation invariant: warnings never block ``complete``; the
684
+ experiment transitions to ``result_review`` regardless. The host is
685
+ expected to add a follow-up log when warnings indicate missing
686
+ sections.
687
+ """
688
+ warnings = getattr(template_validation, "warnings", None) or []
689
+ for warning in warnings:
690
+ code = getattr(warning, "code", None) or "UNKNOWN"
691
+ section = getattr(warning, "section", None)
692
+ detail = getattr(warning, "detail", None)
693
+ parts = [f"[WARN] template: {code}"]
694
+ if section:
695
+ parts.append(f"(section={section})")
696
+ if detail:
697
+ parts.append(f"— {detail}")
698
+ typer.echo(" ".join(parts), err=True)
699
+
700
+
701
+ def _emit_similarity_warning(similarity_warning: Any) -> None:
702
+ """b72d0542 I1.b(2)(e): emit one stderr ``[WARN]`` line when the
703
+ server's content-similarity check fires. The structured
704
+ ``similarity_warning`` block surfaces on stdout via
705
+ ``_print_json`` so scripts can react; stderr is the human-friendly
706
+ nudge to either rewrite the log or pass ``--force-skip-similarity``.
707
+
708
+ Stderr line shape::
709
+
710
+ [WARN] similarity: HIGH_CONTENT_SIMILARITY score=1.00 >= threshold=0.70 (ref_log=<uuid>)
711
+
712
+ Suppressed entirely when ``force_skip_similarity=True`` was passed
713
+ (server echoes ``response.force_skip=True`` and the helper short-
714
+ circuits).
715
+ """
716
+ code = getattr(similarity_warning, "code", None) or "UNKNOWN"
717
+ score = getattr(similarity_warning, "score", None)
718
+ threshold = getattr(similarity_warning, "threshold", None)
719
+ ref_log_id = getattr(similarity_warning, "ref_log_id", None)
720
+ parts = [f"[WARN] similarity: {code}"]
721
+ if score is not None and threshold is not None:
722
+ parts.append(f"score={score:.2f} >= threshold={threshold:.2f}")
723
+ if ref_log_id is not None:
724
+ parts.append(f"(ref_log={ref_log_id})")
725
+ typer.echo(" ".join(parts), err=True)
726
+
727
+
728
+ _CLEAR_ACTION_TEMPLATES: dict[str, str] = {
729
+ "comment": "map topic comment --topic {topic_id} --reply-to {source_comment_id}",
730
+ "ack": "map topic advance-round --topic {topic_id} --ack accept",
731
+ "dismiss": "map mention dismiss --id {mention_id}",
732
+ "read": "Read latest comments on topic '{topic_title}'",
733
+ }
734
+
735
+
736
+ def render_clear_action_template(work_item: dict[str, Any]) -> str:
737
+ """Render a deterministic CLI hint for a topic work item's clear_action.
738
+
739
+ Returns the literal string for the ``clear_action`` value with the
740
+ placeholder fields replaced from the work item payload. The four
741
+ ``clear_action`` values are mapped to the matching ``map`` CLI
742
+ command; ``read`` is a free-form reading instruction (no CLI verb).
743
+
744
+ The output is purely mechanical — no LLM, no environment lookups —
745
+ so callers can diff the rendered strings in tests.
746
+ """
747
+ clear_action = work_item.get("clear_action")
748
+ template = _CLEAR_ACTION_TEMPLATES.get(clear_action or "")
749
+ if template is None:
750
+ return f"(unknown clear_action: {clear_action})"
751
+ if clear_action == "read":
752
+ topic_title = work_item.get("topic_title") or "(untitled)"
753
+ return template.format(topic_title=topic_title)
754
+ if clear_action == "dismiss":
755
+ mention_id = (
756
+ work_item.get("mention_id")
757
+ or work_item.get("idempotency_key")
758
+ or work_item.get("source_comment_id")
759
+ or ""
760
+ )
761
+ return template.format(mention_id=mention_id)
762
+ topic_id = work_item.get("topic_id") or ""
763
+ source_comment_id = work_item.get("source_comment_id") or ""
764
+ return template.format(topic_id=topic_id, source_comment_id=source_comment_id)
765
+
766
+
767
+ _DEPRECATED_ALIAS_RENAMES: dict[str, str] = {
768
+ "advance_round_pending_since": "stale_since",
769
+ "partition_visibility": "visibility",
770
+ }
771
+
772
+
773
+ def detect_deprecated_aliases(payload: Any) -> list[str]:
774
+ """Walk a YAML/JSON-decodable payload and warn when legacy alias keys are present.
775
+
776
+ Returns one human-readable warning per deprecated key found anywhere in
777
+ the structure. The output is deterministic (sorted, deduped) so tests
778
+ can assert exact text.
779
+ """
780
+ found: set[str] = set()
781
+
782
+ def walk(node: Any) -> None:
783
+ if isinstance(node, dict):
784
+ for key, value in node.items():
785
+ if key in _DEPRECATED_ALIAS_RENAMES:
786
+ found.add(key)
787
+ walk(value)
788
+ elif isinstance(node, list):
789
+ for item in node:
790
+ walk(item)
791
+
792
+ walk(payload)
793
+ return [
794
+ f"Warning: '{legacy}' is deprecated; use '{_DEPRECATED_ALIAS_RENAMES[legacy]}' instead."
795
+ for legacy in sorted(found)
796
+ ]
797
+
798
+
799
+ def _emit_deprecation_warnings(payload: Any) -> None:
800
+ for msg in detect_deprecated_aliases(payload):
801
+ typer.echo(msg, err=True)
802
+
803
+
804
+ def _run(
805
+ action,
806
+ *,
807
+ detect_deprecated: bool = False,
808
+ experiment_id: uuid.UUID | None = None,
809
+ output_format: str | None = None,
810
+ admin: bool = False,
811
+ table_renderer=None,
812
+ ) -> None:
813
+ """Run an SDK action with MAP-aware error rendering (I1(c)~(e)).
814
+
815
+ Args:
816
+ action: Callable that takes a ``MAPClient`` and returns the
817
+ command result (or None).
818
+ detect_deprecated: When True, scan the result for legacy aliases
819
+ and emit deprecation warnings.
820
+ experiment_id: When provided, the CLI uses the
821
+ ``/agents/me/escalation-target`` endpoint on STATE_MACHINE.*
822
+ errors to surface the chosen escalation contact (Tier 1
823
+ override → Tier 2 caller → Tier 2 same-role → Tier 2 admin).
824
+ output_format: ``"table"`` / ``"yaml"`` / ``"json"``. When None,
825
+ falls back to the global ``--format`` option. List commands
826
+ (``table_renderer`` is not None) default to ``"table"`` when
827
+ the user hasn't explicitly chosen a format; non-list commands
828
+ fall back to ``"yaml"`` when ``"table"`` is passed.
829
+ table_renderer: Optional callable that takes the action result
830
+ and returns a string for ``"table"`` format output. When
831
+ provided, this command is treated as a list command.
832
+ """
833
+ if output_format is None:
834
+ output_format = _cli_options.get("format", "yaml")
835
+
836
+ # List commands (table_renderer is not None) default to table when the
837
+ # user hasn't explicitly chosen a format via --format / env / config.
838
+ if table_renderer is not None and output_format == "yaml":
839
+ source = _cli_options.get("format_source", "default")
840
+ if source == "default":
841
+ output_format = "table"
842
+
843
+ # Non-list commands don't support table; fall back to yaml.
844
+ if table_renderer is None and output_format == "table":
845
+ output_format = "yaml"
846
+
847
+ # For error rendering, table behaves like yaml (human-friendly).
848
+ error_format = "yaml" if output_format == "table" else output_format
849
+
850
+ try:
851
+ ctx = _admin_client_ctx() if admin else _client_ctx()
852
+ with ctx as client:
853
+ result = action(client)
854
+ if result is not None:
855
+ warnings = getattr(result, "warnings", None)
856
+ if warnings:
857
+ _print_warnings(warnings)
858
+ # 8ac93d4e I1.d: surface plan frontmatter parse failures on stderr.
859
+ # Result may be a LogCreateResponse wrapper with ``.validation``;
860
+ # older SDK responses don't have it and are no-ops here.
861
+ validation = getattr(result, "validation", None)
862
+ if validation is not None:
863
+ _emit_evidence_parse_error(getattr(validation, "parse_error", None))
864
+ # b72d0542 I1.c: surface 4-段 template soft-validation warnings
865
+ # on stderr. Set by ``complete_experiment`` only (other endpoints
866
+ # return ``template_validation=None``).
867
+ template_validation = getattr(result, "template_validation", None)
868
+ if template_validation is not None:
869
+ _emit_template_warnings(template_validation)
870
+ # b72d0542 I1.b(2)(e): surface content-similarity warning on
871
+ # stderr unless caller acknowledged via --force-skip-similarity
872
+ # (server echoes ``response.force_skip=True`` and omits the
873
+ # warning).
874
+ similarity_warning = getattr(result, "similarity_warning", None)
875
+ if similarity_warning is not None:
876
+ _emit_similarity_warning(similarity_warning)
877
+ if detect_deprecated:
878
+ _emit_deprecation_warnings(_to_yamlable(result))
879
+ if output_format == "table" and table_renderer is not None:
880
+ typer.echo(table_renderer(result))
881
+ else:
882
+ _print_json(result)
883
+ except MAPHTTPError as exc:
884
+ _emit_maphttp_error(exc, experiment_id=experiment_id, output_format=error_format)
885
+ raise typer.Exit(1) from exc
886
+ except ValueError as exc:
887
+ if error_format == "json":
888
+ _emit_json_error_envelope(
889
+ error_code=None,
890
+ message=str(exc),
891
+ hint=None,
892
+ retryable=None,
893
+ recovery_command=None,
894
+ )
895
+ else:
896
+ typer.echo(f"Error: {exc}", err=True)
897
+ raise typer.Exit(1) from exc
898
+
899
+
900
+ def _emit_maphttp_error(
901
+ exc: MAPHTTPError,
902
+ *,
903
+ experiment_id: uuid.UUID | None,
904
+ output_format: str,
905
+ ) -> None:
906
+ """Render a MAPHTTPError to stderr in either YAML or JSON shape (I1(e)).
907
+
908
+ JSON shape is the canonical envelope scripts can rely on::
909
+
910
+ {
911
+ "error_code": "STATE_MACHINE_REJECT_RESULT_MISUSE",
912
+ "message": "host creator cannot reject own result",
913
+ "hint": "single-item rebutted → resolve-item --status rebutted; ...",
914
+ "retryable": false,
915
+ "recovery_command": "map --persona reviewer experiment reject-result --id <uuid>"
916
+ }
917
+
918
+ YAML shape keeps the existing human-friendly line + Hint + Escalation
919
+ layout. ``recovery_command`` mirrors ``hint`` here so the two views
920
+ carry the same actionable information — see
921
+ ``sdk/python/map_client/errors.py:RECOVERY_HINTS``.
922
+ """
923
+ error_code = getattr(exc, "error_code", None)
924
+ hint = getattr(exc, "hint", None)
925
+ retryable = getattr(exc, "retryable", None)
926
+ recovery_command = hint # hints are already actionable commands
927
+
928
+ if output_format == "json":
929
+ _emit_json_error_envelope(
930
+ error_code=error_code,
931
+ message=exc.detail,
932
+ hint=hint,
933
+ retryable=retryable,
934
+ recovery_command=recovery_command,
935
+ )
936
+ return
937
+
938
+ suffix = ""
939
+ if error_code:
940
+ suffix += f" [error_code={error_code}]"
941
+ if hint:
942
+ suffix += f"\nHint: {hint}"
943
+ # I1(c): STATE_MACHINE.* errors get an Escalation: line so the user
944
+ # knows who to ping. Only fetch escalation when the error is a
945
+ # state-machine refusal (other error families don't apply the
946
+ # experiment-scoped resolver). Falls back to silent skip if the
947
+ # endpoint itself errors out — never crash on top of an error.
948
+ if error_code and error_code.startswith(("STATE_MACHINE_", "REVIEW_")):
949
+ try:
950
+ with _client_ctx() as client:
951
+ target = client.get_escalation_target(experiment_id=experiment_id)
952
+ if target.escalation_target_id is not None:
953
+ suffix += f"\nEscalation: {target.escalation_label} (tier={target.tier})"
954
+ except Exception: # noqa: BLE001 - escalate lookup must never crash the error path
955
+ pass
956
+ typer.echo(f"Error {exc.status_code}: {exc.detail}{suffix}", err=True)
957
+
958
+
959
+ # 8a8822b5 (b): canonical JSON error envelope schema. Pydantic BaseModel
960
+ # pins the field names + types so any drift fails ``model_validate`` at
961
+ # the CLI boundary rather than silently breaking script consumers.
962
+ # The contract is the v1 stable surface (per 8a8822b5 plan v2 (b)):
963
+ # { error_code, message, hint?, docs_url?, retryable?, recovery_command? }
964
+ # All fields except ``message`` are optional (``None`` when absent) so
965
+ # the envelope stays parseable even when the server / SDK does not
966
+ # surface every key.
967
+ class CLIErrorEnvelope(BaseModel):
968
+ """CLI JSON error envelope (8a8822b5 (b))."""
969
+
970
+ model_config = ConfigDict(extra="forbid")
971
+
972
+ error_code: str | None = None
973
+ message: str
974
+ hint: str | None = None
975
+ docs_url: str | None = None
976
+ retryable: bool | None = None
977
+ recovery_command: str | None = None
978
+
979
+
980
+ def _emit_json_error_envelope(
981
+ *,
982
+ error_code: str | None,
983
+ message: str,
984
+ hint: str | None,
985
+ retryable: bool | None,
986
+ recovery_command: str | None,
987
+ docs_url: str | None = None,
988
+ ) -> None:
989
+ """Emit a JSON error envelope to stderr for ``--format json`` (I1(e) + 8a8822b5 (b)).
990
+
991
+ The envelope is the canonical contract for scripts; never changes
992
+ field names without bumping the SDK minor version. ``null`` is used
993
+ for missing optional fields so consumers can use ``obj.get(...)``.
994
+ The envelope is built + validated through the :class:`CLIErrorEnvelope`
995
+ Pydantic model so any future drift in field names fails the model
996
+ rather than silently breaking scripts.
997
+
998
+ The 8a8822b5 (b) contract adds ``docs_url`` as an optional field so
999
+ error_code consumers can link out to human-readable documentation.
1000
+ The server currently does not emit ``docs_url``; consumers should
1001
+ treat ``null`` as "no docs entry yet" and fall back to the existing
1002
+ ``hint`` / ``error_code`` keys.
1003
+ """
1004
+
1005
+ envelope = CLIErrorEnvelope(
1006
+ error_code=error_code,
1007
+ message=message,
1008
+ hint=hint,
1009
+ docs_url=docs_url,
1010
+ retryable=retryable,
1011
+ recovery_command=recovery_command,
1012
+ )
1013
+ typer.echo(envelope.model_dump_json(), err=True)
1014
+
1015
+
1016
+ def _require_option_uuid(value: uuid.UUID | None, *, option: str = "--id") -> uuid.UUID:
1017
+ """Typer 0.16 + nested subcommands do not enforce required UUID options."""
1018
+ if value is None:
1019
+ typer.echo(f"Error: Missing option '{option}'.", err=True)
1020
+ raise typer.Exit(2)
1021
+ return value
1022
+
1023
+
1024
+ def _require_map_dir(project_root: Path | None = None) -> Path:
1025
+ """Require `.map/config.yaml`; exit with bootstrap hint if missing."""
1026
+ root = project_root or _cli_options.get("project_root")
1027
+ map_dir = find_map_dir(root)
1028
+ if map_dir is None:
1029
+ from map_client.project_config import missing_map_config_message
1030
+
1031
+ typer.echo(f"Error: {missing_map_config_message()}", err=True)
1032
+ raise typer.Exit(1)
1033
+ return map_dir
1034
+
1035
+
1036
+ def _resolve_project(client: MAPClient, project: uuid.UUID | None, project_key: str | None) -> uuid.UUID:
1037
+ map_dir = find_map_dir(_cli_options.get("project_root"))
1038
+ if map_dir is not None:
1039
+ try:
1040
+ cfg = load_project_map_config(map_dir=map_dir)
1041
+ if project is None and project_key is None:
1042
+ return client.get_project_by_key(cfg.project_key).id
1043
+ except ValueError:
1044
+ pass
1045
+ cfg_key = None
1046
+ try:
1047
+ from map_client.config import load_config
1048
+
1049
+ cfg_key = load_config().get("project_key")
1050
+ except Exception:
1051
+ pass
1052
+ key = project_key or cfg_key
1053
+ return client.resolve_project_id(project, project_key=key)
1054
+
1055
+
1056
+ def _resolve_creator_agent_id(
1057
+ client: MAPClient,
1058
+ project_id: uuid.UUID,
1059
+ creator: str | None,
1060
+ creator_agent_id: uuid.UUID | None,
1061
+ ) -> uuid.UUID | None:
1062
+ """Resolve --creator (name or UUID) and --creator-agent-id into a single creator_agent_id.
1063
+
1064
+ - Neither set → None (no filter).
1065
+ - Both set with same value → that value (alias use).
1066
+ - Both set with different values → error.
1067
+ - --creator is a valid UUID → pass through (skip /agents lookup).
1068
+ - --creator is a name → look up via list_agents(project_id); exact match within current
1069
+ project, ignoring admin rows. 0 hits → error + list available names;
1070
+ >1 hits → error (project-internal name collision).
1071
+ """
1072
+ if not creator:
1073
+ return creator_agent_id
1074
+ try:
1075
+ creator_uuid = uuid.UUID(creator)
1076
+ except ValueError:
1077
+ creator_uuid = None
1078
+ if creator_uuid is not None:
1079
+ if creator_agent_id is not None and creator_agent_id != creator_uuid:
1080
+ typer.echo(
1081
+ "Error: --creator and --creator-agent-id resolve to different UUIDs.",
1082
+ err=True,
1083
+ )
1084
+ raise typer.Exit(1)
1085
+ return creator_uuid
1086
+ if creator_agent_id is not None:
1087
+ typer.echo(
1088
+ "Error: --creator is a name but --creator-agent-id was also passed; "
1089
+ "pass one or the other.",
1090
+ err=True,
1091
+ )
1092
+ raise typer.Exit(1)
1093
+ agents = client.list_agents(project_id=project_id)
1094
+ matches = [
1095
+ a
1096
+ for a in agents
1097
+ if a.role.value != "admin" and a.project_id == project_id and a.name == creator
1098
+ ]
1099
+ if len(matches) == 0:
1100
+ available = sorted(
1101
+ a.name for a in agents if a.role.value != "admin" and a.project_id == project_id
1102
+ )
1103
+ available_hint = (
1104
+ f" Available agent_name in this project: {', '.join(available)}."
1105
+ if available
1106
+ else " No project-bound agents found in this project."
1107
+ )
1108
+ typer.echo(
1109
+ f"Error: agent_name '{creator}' not found in current project.{available_hint}",
1110
+ err=True,
1111
+ )
1112
+ raise typer.Exit(1)
1113
+ if len(matches) > 1:
1114
+ typer.echo(
1115
+ f"Error: agent_name '{creator}' matches {len(matches)} agents in current project; "
1116
+ "name is ambiguous. Pass --creator-agent-id <UUID> instead.",
1117
+ err=True,
1118
+ )
1119
+ raise typer.Exit(1)
1120
+ return matches[0].id
1121
+
1122
+
1123
+ def _load_topic_resolve_payload(path: Path) -> TopicResolve:
1124
+ text = _read_text_file(path, kind="resolve")
1125
+ if path.suffix.lower() in {".yaml", ".yml"}:
1126
+ raw = yaml.safe_load(text) or {}
1127
+ if not isinstance(raw, dict):
1128
+ raise ValueError("resolve YAML must be a mapping")
1129
+ return TopicResolve.model_validate(raw)
1130
+ return TopicResolve(decision=text)
1131
+
1132
+
1133
+ @app.command("bootstrap")
1134
+ def map_bootstrap(
1135
+ key: str = typer.Option(..., "--key", help="MAP project_key for this code repo"),
1136
+ name: str | None = typer.Option(None, "--name", help="Human-readable MAP project name"),
1137
+ path: Path | None = typer.Option(None, "--path", help="workspace_path stored on MAP project"),
1138
+ description: str | None = typer.Option(None, "--description"),
1139
+ api_url: str | None = typer.Option(None, "--api-url", help="MAP API base URL"),
1140
+ project_root: Path | None = typer.Option(None, "--project-root", help="Where to write .map/"),
1141
+ force: bool = typer.Option(False, "--force", help="Overwrite existing .map/agents.local.yaml"),
1142
+ ) -> None:
1143
+ """Register MAP project + persona agents; write .map/ config (requires admin token)."""
1144
+ root = (project_root or Path.cwd()).resolve()
1145
+ workspace = path or root
1146
+ display_name = name or key
1147
+ try:
1148
+ result = bootstrap_project_map(
1149
+ project_key=key,
1150
+ project_name=display_name,
1151
+ workspace_path=workspace,
1152
+ project_root=root,
1153
+ api_url=api_url,
1154
+ description=description,
1155
+ force=force,
1156
+ transport=_transport,
1157
+ )
1158
+ except ValueError as exc:
1159
+ typer.echo(f"Error: {exc}", err=True)
1160
+ raise typer.Exit(1) from exc
1161
+ except MAPHTTPError as exc:
1162
+ typer.echo(f"Error {exc.status_code}: {exc.detail}", err=True)
1163
+ raise typer.Exit(1) from exc
1164
+
1165
+ typer.echo(f"Wrote {result.config.map_dir}/")
1166
+ typer.echo(f"MAP project_key={result.config.project_key} id={result.config.project_id}")
1167
+ if result.created_project:
1168
+ typer.echo("Created new MAP project.")
1169
+ else:
1170
+ typer.echo("Reused existing MAP project.")
1171
+ if result.skipped_agent_names:
1172
+ typer.echo(
1173
+ "Skipped existing agents (tokens not recoverable): "
1174
+ + ", ".join(result.skipped_agent_names),
1175
+ err=True,
1176
+ )
1177
+ typer.echo("Keep your existing .map/agents.local.yaml or delete agents on MAP before re-bootstrap.")
1178
+ typer.echo("Personas: " + ", ".join(result.config.tokens.keys()))
1179
+ typer.echo("Try: map --persona host status")
1180
+
1181
+
1182
+ @app.command("me")
1183
+ def map_me() -> None:
1184
+ """Alias for `map persona whoami`."""
1185
+
1186
+ def action(c: MAPClient):
1187
+ me = c.get_me()
1188
+ payload = me.model_dump(mode="json")
1189
+ if _cli_options.get("persona"):
1190
+ payload["persona"] = _cli_options["persona"]
1191
+ elif find_map_dir(_cli_options.get("project_root")):
1192
+ payload["persona"] = load_project_map_config(
1193
+ project_root=_cli_options.get("project_root")
1194
+ ).default_persona
1195
+ return payload
1196
+
1197
+ _run(action)
1198
+
1199
+
1200
+ @app.command("todos")
1201
+ def map_todos() -> None:
1202
+ _run(lambda c: c.get_todos())
1203
+
1204
+
1205
+ @app.command("work")
1206
+ def map_work(
1207
+ notification_limit: int = typer.Option(50, "--notification-limit", min=1, max=200),
1208
+ notification_category: str = typer.Option(
1209
+ "all",
1210
+ "--notification-category",
1211
+ help="all (human default), wakeable (waker view), or digest",
1212
+ ),
1213
+ summary: bool = typer.Option(
1214
+ False,
1215
+ "--summary",
1216
+ help="Return the compact 6-bucket by_kind summary instead of the full work snapshot.",
1217
+ ),
1218
+ include_all_personas: bool = typer.Option(
1219
+ False,
1220
+ "--include-all-personas",
1221
+ help="Include host-only buckets (explicit_only, informational_only, action_items) when the current persona would otherwise hide them.",
1222
+ ),
1223
+ topics_limit: int = typer.Option(
1224
+ 10,
1225
+ "--summary-topics-limit",
1226
+ min=1,
1227
+ max=100,
1228
+ help="Max topics per summary bucket (default 10).",
1229
+ ),
1230
+ experiments_limit: int = typer.Option(
1231
+ 5,
1232
+ "--summary-experiments-limit",
1233
+ min=1,
1234
+ max=50,
1235
+ help="Max experiments per summary bucket (default 5).",
1236
+ ),
1237
+ ) -> None:
1238
+ """Unified work snapshot: whoami + topic-progress + todos + unread notifications.
1239
+
1240
+ CLI defaults to ``all`` so humans see the same unread count as
1241
+ ``notification list --unread-only``. Wakers should pass
1242
+ ``--notification-category wakeable`` explicitly.
1243
+
1244
+ Pass ``--summary`` to get the compact 6-bucket by_kind summary for the
1245
+ /work top card; combine with ``--include-all-personas`` for full
1246
+ visibility.
1247
+ """
1248
+ if summary:
1249
+
1250
+ def action(c: MAPClient):
1251
+ return c.get_agent_work_summary(
1252
+ include_all_personas=include_all_personas,
1253
+ topics_limit=topics_limit,
1254
+ experiments_limit=experiments_limit,
1255
+ )
1256
+
1257
+ _run(action, detect_deprecated=True)
1258
+ return
1259
+
1260
+ def action(c: MAPClient):
1261
+ return c.get_agent_work(
1262
+ notification_limit=notification_limit,
1263
+ notification_category=notification_category,
1264
+ )
1265
+
1266
+ _run(action, detect_deprecated=True)
1267
+
1268
+
1269
+ def _read_text_file(path: Path, *, kind: str) -> str:
1270
+ """Read a required ``--file``/``--metadata`` argument.
1271
+
1272
+ Converts missing-file and not-a-file OS errors into a clean CLI error
1273
+ (exit code 2) instead of letting Python emit a raw traceback, so that
1274
+ user-facing mistakes like ``--metadata ./missing.yaml`` stay legible.
1275
+ """
1276
+ try:
1277
+ return path.read_text(encoding="utf-8")
1278
+ except FileNotFoundError:
1279
+ typer.echo(f"Error: {kind} file not found: {path}", err=True)
1280
+ raise typer.Exit(2) from None
1281
+ except IsADirectoryError:
1282
+ typer.echo(f"Error: {kind} path is a directory, not a file: {path}", err=True)
1283
+ raise typer.Exit(2) from None
1284
+
1285
+
1286
+ def _read_yaml_file(path: Path | None, *, kind: str = "metadata") -> Any:
1287
+ if path is None:
1288
+ return None
1289
+ return yaml.safe_load(_read_text_file(path, kind=kind))
1290
+
1291
+
1292
+ def _load_complete_metadata(path: Path | None, *, allow_missing_evidence: bool) -> dict | None:
1293
+ metadata = _read_yaml_file(path)
1294
+ if allow_missing_evidence:
1295
+ if not isinstance(metadata, dict):
1296
+ metadata = {}
1297
+ metadata["allow_missing_evidence"] = True
1298
+ return metadata
1299
+ if not metadata_has_completion_evidence(metadata):
1300
+ keys = ", ".join(sorted(EVIDENCE_METADATA_KEYS))
1301
+ typer.echo(
1302
+ "Error: experiment complete now requires --metadata with deployment/test evidence "
1303
+ f"(accepted keys include: {keys}). Use --allow-missing-evidence only for explicit exceptions.\n"
1304
+ " schema: docs/cli-schemas.md#experiment-complete-metadata",
1305
+ err=True,
1306
+ )
1307
+ raise typer.Exit(2)
1308
+ return metadata
1309
+
1310
+
1311
+ def _load_review_verdict_file(path: Path | None):
1312
+ """Load and validate a --review-verdict-file YAML against ReviewVerdictFile.
1313
+
1314
+ Returns ``None`` when path is omitted (legacy free-text path). Surface a
1315
+ clean CLI error (exit 2) on malformed YAML or schema validation failure —
1316
+ never let raw Pydantic tracebacks leak to the reviewer.
1317
+ """
1318
+ if path is None:
1319
+ return None
1320
+ from map_types.schemas import ReviewVerdictFile
1321
+
1322
+ raw = _read_yaml_file(path, kind="review-verdict")
1323
+ try:
1324
+ return ReviewVerdictFile.model_validate(raw)
1325
+ except Exception as exc:
1326
+ typer.echo(
1327
+ f"Error: invalid --review-verdict-file {path}: {exc}\n"
1328
+ " schema: sdk/python/map_types/schemas.py:ReviewVerdictFile "
1329
+ "(also see docs/cli-schemas.md#review-verdict-file)",
1330
+ err=True,
1331
+ )
1332
+ raise typer.Exit(2) from None
1333
+
1334
+
1335
+ # --- schema discovery (cli-ux PR1) ------------------------------------------
1336
+ # `map experiment accept-result --schema` / `map experiment complete --schema`
1337
+ # print a copy-paste-ready YAML template with field-level hints. This is the
1338
+ # cheapest fix for "I don't know what fields the SDK wants" — the same
1339
+ # information as grepping the SDK, but in 200ms via the CLI.
1340
+ _REVIEW_VERDICT_SCHEMA_YAML = """\
1341
+ # Review verdict file — schema: sdk/python/map_types/schemas.py:ReviewVerdictFile
1342
+ # (also docs/cli-schemas.md#review-verdict-file)
1343
+ #
1344
+ # review_id: REQUIRED — UUID from `map experiment review list --id <exp-id>`
1345
+ # verdicts: list of per-item verdicts; one verdict per review item
1346
+ # invariants: optional list of verification checks (item_id, verified, note)
1347
+ review_id: 00000000-0000-0000-0000-000000000000 # <-- replace
1348
+ verdicts:
1349
+ - item_id: 00000000-0000-0000-0000-000000000000 # <-- replace with review item UUID
1350
+ verdict: passed # accepted values: passed | failed | waived
1351
+ # CLI also accepts aliases: accept|reject|dismiss (mapped to canonical values)
1352
+ # reason: REQUIRED only when verdict == waived (50-1000 chars)
1353
+ invariants:
1354
+ - item_id: 00000000-0000-0000-0000-000000000000 # <-- replace
1355
+ verified: true
1356
+ # note: optional, max 1000 chars
1357
+ """
1358
+
1359
+ _COMPLETE_METADATA_SCHEMA_YAML = """\
1360
+ # Experiment complete --metadata file —
1361
+ # schema: docs/cli-schemas.md#experiment-complete-metadata
1362
+ # helper: `map experiment complete --schema` reprints this template
1363
+ #
1364
+ # At least ONE of the following keys MUST be present (else --allow-missing-evidence).
1365
+ # Accepted keys: api_health | alembic_current | pytest_summary | test_summary |
1366
+ # smoke | smoke_result | image_digest | health | acceptance
1367
+ api_health: ok
1368
+ alembic_current:
1369
+ head: "<revision>" # alembic current revision id
1370
+ upgrade_clean: true
1371
+ pytest_summary:
1372
+ total: 0
1373
+ passed: 0
1374
+ failed: 0
1375
+ skipped: 0
1376
+ smoke:
1377
+ api_health: ok
1378
+ notes: "..."
1379
+ """
1380
+
1381
+
1382
+ def _print_review_verdict_schema_and_exit() -> None:
1383
+ """cli-ux PR1: print the review verdict file template + exit 0.
1384
+
1385
+ Lets a reviewer / host learn the schema without grepping the SDK.
1386
+ """
1387
+ typer.echo(_REVIEW_VERDICT_SCHEMA_YAML.rstrip())
1388
+ raise typer.Exit(0)
1389
+
1390
+
1391
+ def _print_complete_metadata_schema_and_exit() -> None:
1392
+ """cli-ux PR1: print the experiment-complete metadata template + exit 0."""
1393
+ typer.echo(_COMPLETE_METADATA_SCHEMA_YAML.rstrip())
1394
+ raise typer.Exit(0)
1395
+
1396
+
1397
+ @app.command("status")
1398
+ def project_or_global_status(
1399
+ project: uuid.UUID | None = typer.Option(None, "--project"),
1400
+ project_key: str | None = typer.Option(None, "--project-key"),
1401
+ ) -> None:
1402
+ def action(c: MAPClient):
1403
+ if project is not None or project_key is not None:
1404
+ pid = _resolve_project(c, project, project_key)
1405
+ return c.get_project_status(pid)
1406
+ me = c.get_me()
1407
+ if me.role == AgentRole.admin:
1408
+ return c.get_global_status()
1409
+ pid = _resolve_project(c, None, None)
1410
+ return c.get_project_status(pid)
1411
+
1412
+ _run(action)
1413
+
1414
+
1415
+ @app.command("dashboard")
1416
+ def map_dashboard() -> None:
1417
+ """One-glance markdown overview: identity, open topics, experiments, todos.
1418
+
1419
+ Aggregates multiple read-only API calls into a single human-friendly
1420
+ markdown snapshot. Unlike ``status`` (which returns the server's
1421
+ status_md narrative) or ``work`` (which returns structured YAML for
1422
+ wakers), ``dashboard`` renders a compact, scannable view designed for
1423
+ humans who want to see "what's going on" without running several
1424
+ commands.
1425
+
1426
+ Data is fetched live on each invocation — no stale snapshots.
1427
+ """
1428
+ from datetime import datetime
1429
+
1430
+ from map_types import ExperimentPhase, TopicStatus
1431
+
1432
+ try:
1433
+ ctx = _client_ctx()
1434
+ with ctx as client:
1435
+ me = client.get_me()
1436
+ project_id = _resolve_project(client, None, None)
1437
+ open_topics = client.list_topics(project_id, status=TopicStatus.open)
1438
+ experiments = client.list_experiments(project_id)
1439
+ todos = client.get_todos()
1440
+ except MAPHTTPError as exc:
1441
+ _emit_maphttp_error(exc, experiment_id=None, output_format=_cli_options.get("format", "yaml"))
1442
+ raise typer.Exit(1) from exc
1443
+ except ValueError as exc:
1444
+ typer.echo(f"Error: {exc}", err=True)
1445
+ raise typer.Exit(1) from exc
1446
+
1447
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1448
+ persona = _cli_options.get("persona") or "default"
1449
+ lines: list[str] = []
1450
+ lines.append(f"# MAP Dashboard — {me.name}")
1451
+ lines.append(f"_Generated: {now} (persona: {persona})_")
1452
+ lines.append("")
1453
+
1454
+ # --- Open Topics ---
1455
+ lines.append(f"## Open Topics ({len(open_topics)})")
1456
+ if open_topics:
1457
+ lines.append("| # | Title | Round | Comments | Experiments | Creator | Created |")
1458
+ lines.append("|---|-------|-------|----------|-------------|---------|---------|")
1459
+ for i, t in enumerate(open_topics, 1):
1460
+ title = (t.title or "").replace("|", "\\|")
1461
+ if len(title) > 60:
1462
+ title = title[:57] + "..."
1463
+ creator = (t.creator_name or "-").replace("|", "\\|")
1464
+ created = (t.created_at.strftime("%Y-%m-%d %H:%M") if t.created_at else "-")
1465
+ round_label = t.discussion_round.value if hasattr(t.discussion_round, "value") else str(t.discussion_round)
1466
+ lines.append(
1467
+ f"| {i} | {title} | {round_label} | {t.comment_count} | "
1468
+ f"{t.experiment_count} | {creator} | {created} |"
1469
+ )
1470
+ else:
1471
+ lines.append("_(none)_")
1472
+ lines.append("")
1473
+
1474
+ # --- Experiments by phase ---
1475
+ active_phases = {
1476
+ ExperimentPhase.draft,
1477
+ ExperimentPhase.review,
1478
+ ExperimentPhase.approved,
1479
+ ExperimentPhase.running,
1480
+ ExperimentPhase.result_review,
1481
+ }
1482
+ active_exps = [e for e in experiments if e.phase in active_phases]
1483
+ done_exps = [e for e in experiments if e.phase == ExperimentPhase.done]
1484
+ cancelled_exps = [e for e in experiments if e.phase == ExperimentPhase.cancelled]
1485
+
1486
+ lines.append(f"## Experiments ({len(active_exps)} active / {len(done_exps)} done / {len(cancelled_exps)} cancelled)")
1487
+ if active_exps:
1488
+ lines.append("| # | Title | Phase | Plan v | Topic | Updated |")
1489
+ lines.append("|---|-------|-------|--------|-------|---------|")
1490
+ for i, e in enumerate(active_exps, 1):
1491
+ title = (e.title or "").replace("|", "\\|")
1492
+ if len(title) > 50:
1493
+ title = title[:47] + "..."
1494
+ updated = (e.updated_at.strftime("%Y-%m-%d %H:%M") if e.updated_at else "-")
1495
+ topic = str(e.topic_id)[:8] + "…" if e.topic_id else "-"
1496
+ phase_label = e.phase.value if hasattr(e.phase, "value") else str(e.phase)
1497
+ lines.append(
1498
+ f"| {i} | {title} | {phase_label} | v{e.current_plan_version} | "
1499
+ f"{topic} | {updated} |"
1500
+ )
1501
+ else:
1502
+ lines.append("_(no active experiments)_")
1503
+ lines.append("")
1504
+
1505
+ # --- Todos summary ---
1506
+ todo_buckets = [
1507
+ ("pending_reviews", "Pending Reviews", todos.pending_reviews),
1508
+ ("pending_result_reviews", "Result Reviews", todos.pending_result_reviews),
1509
+ ("pending_topic_replies", "Topic Replies", todos.pending_topic_replies),
1510
+ ("pending_round_acks", "Round Acks", todos.pending_round_acks),
1511
+ ("pending_advance_rounds", "Advance Rounds", todos.pending_advance_rounds),
1512
+ ("stale_open_topics", "Stale Topics", todos.stale_open_topics),
1513
+ ("mentions", "Mentions", todos.mentions),
1514
+ ("action_items", "Action Items", todos.action_items),
1515
+ ]
1516
+ obligation_count = sum(len(items) for _, _, items in todo_buckets)
1517
+ lines.append(f"## Todos ({obligation_count} obligation items)")
1518
+ for _label, display, items in todo_buckets:
1519
+ if items:
1520
+ lines.append(f"- **{display}**: {len(items)}")
1521
+ if obligation_count == 0:
1522
+ lines.append("_(no pending obligations)_")
1523
+ lines.append("")
1524
+
1525
+ # --- Contextual lists ---
1526
+ if todos.my_open_topics:
1527
+ lines.append(f"### My Open Topics ({len(todos.my_open_topics)})")
1528
+ for t in todos.my_open_topics:
1529
+ title = (t.title or "").replace("|", "\\|")
1530
+ if len(title) > 60:
1531
+ title = title[:57] + "..."
1532
+ round_label = t.discussion_round.value if hasattr(t.discussion_round, "value") else str(t.discussion_round)
1533
+ lines.append(f"- `{t.id}` — {title} ({round_label}, {t.comment_count} comments)")
1534
+ lines.append("")
1535
+ if todos.my_open_experiments:
1536
+ lines.append(f"### My Open Experiments ({len(todos.my_open_experiments)})")
1537
+ for e in todos.my_open_experiments:
1538
+ title = (e.title or "").replace("|", "\\|")
1539
+ if len(title) > 50:
1540
+ title = title[:47] + "..."
1541
+ phase_label = e.phase.value if hasattr(e.phase, "value") else str(e.phase)
1542
+ lines.append(f"- `{e.id}` — {title} ({phase_label})")
1543
+ lines.append("")
1544
+
1545
+ typer.echo("\n".join(lines))
1546
+
1547
+
1548
+ def main() -> None:
1549
+ app()
1550
+
1551
+
1552
+ if __name__ == "__main__":
1553
+ main()