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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- 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()
|