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/commands/action.py ADDED
@@ -0,0 +1,142 @@
1
+ """``map action ...`` sub-app — arch experiment 0519e2a3 PR7.
2
+
3
+ Topic action item lifecycle. Action items are surfaced in
4
+ ``map todos.action_items`` (waker D6 stage: WAKE / STALE escalation).
5
+ All command bodies lazy-import ``cli.main._run`` to break the
6
+ ``cli.main ↔ cli.commands.*`` import cycle.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ import typer
13
+ from map_client.client import MAPClient
14
+
15
+ action_app = typer.Typer(help="Topic action item commands")
16
+
17
+
18
+ @action_app.command("list")
19
+ def action_list(
20
+ project: uuid.UUID | None = typer.Option(None, "--project"),
21
+ project_key: str | None = typer.Option(None, "--project-key"),
22
+ owner_agent_id: uuid.UUID | None = typer.Option(None, "--owner-agent-id"),
23
+ mine: bool = typer.Option(False, "--mine", help="Only action items assigned to the current agent."),
24
+ status: str | None = typer.Option("open", "--status"),
25
+ limit: int = typer.Option(100, "--limit", min=1, max=200),
26
+ ) -> None:
27
+ from map_types.enums import TopicActionItemStatus
28
+
29
+ from cli.main import _resolve_project, _run # lazy: avoid cli.main ↔ cli.commands.* cycle
30
+
31
+ def action(c: MAPClient):
32
+ pid = _resolve_project(c, project, project_key)
33
+ owner = owner_agent_id
34
+ if mine:
35
+ owner = c.get_me().id
36
+ status_filter = TopicActionItemStatus(status) if status else None
37
+ return c.list_project_action_items(
38
+ pid,
39
+ owner_agent_id=owner,
40
+ status=status_filter,
41
+ limit=limit,
42
+ )
43
+
44
+ _run(action)
45
+
46
+
47
+ @action_app.command("complete")
48
+ def action_complete(
49
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to mark done."),
50
+ ) -> None:
51
+ """Close an action item as done (open -> done)."""
52
+ from cli.main import _run # lazy
53
+
54
+ def action(c: MAPClient):
55
+ return c.complete_action_item(action_item_id)
56
+
57
+ _run(action)
58
+
59
+
60
+ @action_app.command("deliver")
61
+ def action_deliver(
62
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to deliver."),
63
+ ) -> None:
64
+ """Deliver an open action item (source topic may be closed/archived)."""
65
+ from cli.main import _run # lazy
66
+
67
+ def action(c: MAPClient):
68
+ return c.deliver_action_item(action_item_id)
69
+
70
+ _run(action)
71
+
72
+
73
+ @action_app.command("cancel")
74
+ def action_cancel(
75
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to cancel."),
76
+ reason: str = typer.Option(..., "--reason", help="Cancellation reason (length-validated by category)."),
77
+ category: str | None = typer.Option(
78
+ None,
79
+ "--category",
80
+ help="implementation | decision | unspecified (default). Affects reason length threshold.",
81
+ ),
82
+ ) -> None:
83
+ """Close an action item as cancelled (open -> cancelled)."""
84
+ from map_types.enums import ActionItemCategory
85
+ from map_types.schemas import ActionItemCancel
86
+
87
+ from cli.main import _run # lazy
88
+
89
+ def action(c: MAPClient):
90
+ cat = ActionItemCategory(category) if category else None
91
+ payload = ActionItemCancel(reason=reason, category=cat)
92
+ return c.cancel_action_item(action_item_id, payload)
93
+
94
+ _run(action)
95
+
96
+
97
+ @action_app.command("link")
98
+ def action_link(
99
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to link."),
100
+ experiment_id: uuid.UUID = typer.Option(
101
+ ...,
102
+ "--experiment-id",
103
+ help="Experiment UUID to attach to the action item (must share project).",
104
+ ),
105
+ ) -> None:
106
+ """Attach an experiment to an open action item so future experiment
107
+ ``done`` cascades the action item automatically."""
108
+ from cli.main import _run # lazy
109
+
110
+ def action(c: MAPClient):
111
+ return c.link_action_item(action_item_id, experiment_id)
112
+
113
+ _run(action)
114
+
115
+
116
+ @action_app.command("mark-wake-sent")
117
+ def action_mark_wake_sent(
118
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to wake."),
119
+ ) -> None:
120
+ """Bump wake_count + stamp last_woken_at + write ``action_item.wake_sent``
121
+ audit row. Used by the runtime-waker CLI to advance the three-stage
122
+ escalation timeline (experiment B / I4). Owner or admin only."""
123
+ from cli.main import _run # lazy
124
+
125
+ def action(c: MAPClient):
126
+ return c.mark_wake_sent(action_item_id)
127
+
128
+ _run(action)
129
+
130
+
131
+ @action_app.command("mark-stale")
132
+ def action_mark_stale(
133
+ action_item_id: uuid.UUID = typer.Option(..., "--id", help="Action item UUID to mark stale."),
134
+ ) -> None:
135
+ """Stamp stale_at + write the ``action_item.stale`` audit row after the
136
+ 4th unanswered wake. Admin only (system escalation, experiment B / I4)."""
137
+ from cli.main import _run # lazy
138
+
139
+ def action(c: MAPClient):
140
+ return c.mark_stale(action_item_id)
141
+
142
+ _run(action)
cli/commands/agent.py ADDED
@@ -0,0 +1,117 @@
1
+ """``map agent ...`` sub-app — arch experiment 0519e2a3 PR3.
2
+
3
+ Mirrors ``GET /api/v1/agents`` + ``/api/v1/agents/me`` for the CLI.
4
+ Distinct from ``map persona ...`` which manages the local
5
+ ``.map/agents.yaml`` identity config.
6
+
7
+ Commands:
8
+ * ``map agent show`` — current MAP agent identity (== ``map me``)
9
+ * ``map agent list`` — agents visible to the caller
10
+ * ``map agent escalation-target`` — STATE_MACHINE.* error escalation
11
+ contact resolver (I1(c))
12
+
13
+ The user-facing ``map persona list`` / ``map persona whoami`` path is
14
+ unchanged — this sub-app is additive.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+ from typing import Any
20
+
21
+ import typer
22
+
23
+ agent_app = typer.Typer(help="Agent commands (server-side identity)")
24
+
25
+
26
+ def _parse_uuid(raw: str | None, *, field: str) -> uuid.UUID | None:
27
+ if raw is None:
28
+ return None
29
+ try:
30
+ return uuid.UUID(raw)
31
+ except ValueError as exc:
32
+ raise typer.BadParameter(f"{field} must be a UUID, got {raw!r}") from exc
33
+
34
+
35
+ @agent_app.command("show")
36
+ def agent_show() -> None:
37
+ """Show the current MAP agent identity (== ``map me`` / ``map persona whoami``)."""
38
+ from cli.main import _run # lazy: avoid cli.main ↔ cli.commands.agent cycle
39
+
40
+ def action(c: Any) -> Any:
41
+ return c.get_me()
42
+
43
+ _run(action)
44
+
45
+
46
+ @agent_app.command("list")
47
+ def agent_list(
48
+ project_id: str | None = typer.Option(None, "--project-id", help="Filter by project UUID."),
49
+ role: str | None = typer.Option(
50
+ None, "--role", help="Filter by role (admin | agent)."
51
+ ),
52
+ ) -> None:
53
+ """List agents visible to the caller.
54
+
55
+ Admins see every agent. Project-bound agents see admins and agents
56
+ in their own project. Use ``--project-id`` / ``--role`` to narrow.
57
+ """
58
+ from cli.main import _run # lazy: see agent_show
59
+
60
+ pid = _parse_uuid(project_id, field="project_id")
61
+
62
+ def action(c: Any) -> Any:
63
+ return c.list_agents(project_id=pid, role=role)
64
+
65
+ _run(action)
66
+
67
+
68
+ @agent_app.command("escalation-target")
69
+ def agent_escalation_target(
70
+ experiment_id: str | None = typer.Option(
71
+ None,
72
+ "--experiment-id",
73
+ help="Experiment UUID — its escalation_target_agent_id override takes precedence.",
74
+ ),
75
+ ) -> None:
76
+ """Resolve the escalation contact for a STATE_MACHINE.* error.
77
+
78
+ Server runs the 3-tier rule (override → caller → same-role → admin)
79
+ and returns the chosen agent + the tier that picked it. CLI uses
80
+ this on ``MAPHTTPError`` to append ``Escalation: @<name>`` to the
81
+ error output.
82
+ """
83
+ from cli.main import _run # lazy: see agent_show
84
+
85
+ eid = _parse_uuid(experiment_id, field="experiment_id")
86
+
87
+ def action(c: Any) -> Any:
88
+ return c.get_escalation_target(experiment_id=eid)
89
+
90
+ _run(action)
91
+
92
+
93
+ @agent_app.command("register")
94
+ def agent_register(
95
+ name: str = typer.Option(..., "--name", help="Unique agent name (1..128 chars)."),
96
+ role: str = typer.Option("agent", "--role", help="admin | agent (default agent)."),
97
+ project_key: str | None = typer.Option(
98
+ None, "--project-key", help="Project key for role=agent (mutually exclusive with --project-id)."
99
+ ),
100
+ project_id: str | None = typer.Option(
101
+ None, "--project-id", help="Project UUID for role=agent (mutually exclusive with --project-key)."
102
+ ),
103
+ ) -> None:
104
+ """Register a new agent (admin-only; role=agent requires a project)."""
105
+ from cli.main import _run # lazy: see agent_show
106
+
107
+ pid = _parse_uuid(project_id, field="project_id")
108
+
109
+ def action(c: Any) -> Any:
110
+ return c.register_agent(
111
+ name=name,
112
+ role=role,
113
+ project_key=project_key,
114
+ project_id=pid,
115
+ )
116
+
117
+ _run(action, admin=True)
cli/commands/audit.py ADDED
@@ -0,0 +1,68 @@
1
+ """``map audit ...`` sub-app — arch experiment 0519e2a3 PR5.
2
+
3
+ Admin-only audit log surface — wraps ``GET /api/v1/audit``.
4
+
5
+ Bypasses the shared ``_run`` wrapper on purpose: the API returns
6
+ ``(rows, total)`` which we render as a header line + YAML rows rather
7
+ than a raw tuple. The shared wrapper would YAML-dump the tuple as
8
+ ``[rows, total]`` which is the wrong shape for a greppable timeline.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+
14
+ import typer
15
+ from map_client.exceptions import MAPHTTPError
16
+
17
+ audit_app = typer.Typer(help="Audit log commands (admin)")
18
+
19
+
20
+ @audit_app.command("list")
21
+ def audit_list(
22
+ kind: str | None = typer.Option(
23
+ None,
24
+ "--kind",
25
+ help="Filter by AuditLog.action (e.g. review_item.mutation).",
26
+ ),
27
+ experiment_id: uuid.UUID | None = typer.Option(
28
+ None,
29
+ "--experiment",
30
+ help="Filter to a single experiment (matches payload_json.experiment_id).",
31
+ ),
32
+ page: int = typer.Option(1, "--page", min=1),
33
+ page_size: int = typer.Option(50, "--page-size", min=1, max=200),
34
+ ) -> None:
35
+ """List global audit log entries (admin only).
36
+
37
+ Examples::
38
+
39
+ map audit list
40
+ map audit list --kind review_item.mutation
41
+ map audit list --kind review_item.mutation --experiment <uuid>
42
+ """
43
+ from cli.main import _admin_client_ctx, _print_json
44
+
45
+ try:
46
+ with _admin_client_ctx() as client:
47
+ items, total = client.list_audit_global(
48
+ page=page,
49
+ page_size=page_size,
50
+ kind=kind,
51
+ experiment_id=experiment_id,
52
+ )
53
+ except MAPHTTPError as exc:
54
+ suffix = ""
55
+ error_code = getattr(exc, "error_code", None)
56
+ hint = getattr(exc, "hint", None)
57
+ if error_code:
58
+ suffix += f" [error_code={error_code}]"
59
+ if hint:
60
+ suffix += f"\nHint: {hint}"
61
+ typer.echo(f"Error {exc.status_code}: {exc.detail}{suffix}", err=True)
62
+ raise typer.Exit(1) from exc
63
+
64
+ typer.echo(
65
+ f"# audit_log total={total} returned={len(items)} "
66
+ f"kind={kind or '*'} experiment={experiment_id or '*'}"
67
+ )
68
+ _print_json(items)
cli/commands/docs.py ADDED
@@ -0,0 +1,179 @@
1
+ """``map docs ...`` sub-app — arch experiment 0519e2a3 PR7.
2
+
3
+ Repo-bundled documentation surface — no MAP API required.
4
+ ``docs error-codes`` reads ``docs/error-codes/index.json`` from the
5
+ repo root and renders the catalog as a YAML table or filtered JSON.
6
+
7
+ The repo-root discovery helper (``_resolve_repo_root``) and the index
8
+ loader (``_load_error_codes_index``) are co-located here because they
9
+ are only used by this command. The loader reads ``_cli_options`` from
10
+ ``cli.main`` lazily inside the function body to break the
11
+ ``cli.main ↔ cli.commands.*`` import cycle.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import typer
19
+ import yaml
20
+
21
+ docs_app = typer.Typer(help="Repo-bundled documentation commands (no API).")
22
+
23
+
24
+ _ERROR_CODES_INDEX_RELATIVE = "docs/error-codes/index.json"
25
+
26
+ _ERROR_CODES_SEARCH_FIELDS: tuple[str, ...] = (
27
+ "code",
28
+ "category",
29
+ "title",
30
+ "description",
31
+ "hint",
32
+ "docs_url",
33
+ "owner_experiment",
34
+ "since_version",
35
+ )
36
+
37
+
38
+ def _resolve_repo_root(start: Path | None = None) -> Path:
39
+ """Walk upward from ``start`` (or cwd) until a directory containing
40
+ ``docs/error-codes/index.json`` is found. Falls back to the start
41
+ directory if not found, so the loader can produce a clean error.
42
+ """
43
+ current = (start or Path.cwd()).resolve()
44
+ for candidate in [current, *current.parents]:
45
+ if (candidate / _ERROR_CODES_INDEX_RELATIVE).is_file():
46
+ return candidate
47
+ return current
48
+
49
+
50
+ def _load_error_codes_index(
51
+ project_root: Path | None = None,
52
+ ) -> tuple[Path, dict[str, Any]]:
53
+ from cli.main import _cli_options # lazy: avoid cli.main ↔ cli.commands.* cycle
54
+
55
+ repo_root = _resolve_repo_root(project_root or _cli_options.get("project_root"))
56
+ index_path = repo_root / _ERROR_CODES_INDEX_RELATIVE
57
+ if not index_path.is_file():
58
+ typer.echo(
59
+ f"Error: error codes index not found at {index_path}. "
60
+ "Run from the repo root or pass --project-root.",
61
+ err=True,
62
+ )
63
+ raise typer.Exit(2)
64
+ try:
65
+ raw = yaml.safe_load(index_path.read_text(encoding="utf-8"))
66
+ except yaml.YAMLError as exc:
67
+ typer.echo(f"Error: failed to parse {index_path}: {exc}", err=True)
68
+ raise typer.Exit(2) from None
69
+ if not isinstance(raw, dict) or not isinstance(raw.get("codes"), list):
70
+ typer.echo(
71
+ f"Error: {index_path} must be a JSON object with a 'codes' list.",
72
+ err=True,
73
+ )
74
+ raise typer.Exit(2)
75
+ return index_path, raw
76
+
77
+
78
+ def _match_error_code(code_entry: dict[str, Any], keywords: list[str]) -> bool:
79
+ if not keywords:
80
+ return True
81
+ haystack_parts: list[str] = []
82
+ for field in _ERROR_CODES_SEARCH_FIELDS:
83
+ value = code_entry.get(field)
84
+ if value is None:
85
+ continue
86
+ haystack_parts.append(str(value))
87
+ haystack = "\n".join(haystack_parts).lower()
88
+ return all(kw.lower() in haystack for kw in keywords)
89
+
90
+
91
+ def _search_keywords_to_list(raw: list[str] | None) -> list[str]:
92
+ """Normalize --search repeats: split comma-separated, strip, drop empties,
93
+ preserve order, dedupe while preserving order.
94
+ """
95
+ if not raw:
96
+ return []
97
+ out: list[str] = []
98
+ seen: set[str] = set()
99
+ for item in raw:
100
+ for piece in str(item).split(","):
101
+ kw = piece.strip()
102
+ if not kw or kw in seen:
103
+ continue
104
+ seen.add(kw)
105
+ out.append(kw)
106
+ return out
107
+
108
+
109
+ @docs_app.command("error-codes")
110
+ def docs_error_codes(
111
+ search: list[str] | None = typer.Option(
112
+ None,
113
+ "--search",
114
+ help=(
115
+ "Keyword filter (case-insensitive substring match against "
116
+ "code / category / title / description / hint / docs_url / "
117
+ "owner_experiment / since_version). Repeat for AND of "
118
+ "multiple keywords, or comma-separate in one value."
119
+ ),
120
+ ),
121
+ project_root: Path | None = typer.Option(
122
+ None,
123
+ "--project-root",
124
+ help="Repo root containing docs/error-codes/index.json (default: walk up from cwd).",
125
+ ),
126
+ as_json: bool = typer.Option(
127
+ False,
128
+ "--json",
129
+ help="Emit the raw index.json payload (machine-readable) instead of the default YAML table.",
130
+ ),
131
+ ) -> None:
132
+ """List MAP error codes from ``docs/error-codes/index.json``."""
133
+ from cli.main import _print_json # lazy
134
+
135
+ index_path, payload = _load_error_codes_index(project_root)
136
+ keywords = _search_keywords_to_list(search)
137
+ codes = payload.get("codes") or []
138
+ matched = [entry for entry in codes if isinstance(entry, dict) and _match_error_code(entry, keywords)]
139
+
140
+ if as_json:
141
+ if keywords:
142
+ _print_json({"schema_version": payload.get("schema_version"), "codes": matched})
143
+ else:
144
+ _print_json(payload)
145
+ return
146
+
147
+ schema_version = payload.get("schema_version")
148
+ generated_at = payload.get("generated_at")
149
+ owner_experiment = payload.get("owner_experiment")
150
+ header_lines = [
151
+ f"# error codes index: {index_path}",
152
+ f"# schema_version: {schema_version}",
153
+ f"# generated_at: {generated_at}",
154
+ f"# owner_experiment: {owner_experiment}",
155
+ ]
156
+ if keywords:
157
+ header_lines.append(f"# search: {', '.join(keywords)} (AND)")
158
+ header_lines.append(f"# matched: {len(matched)}/{len(codes)}")
159
+ typer.echo("\n".join(header_lines))
160
+ if not matched:
161
+ return
162
+ table_rows: list[list[str]] = []
163
+ for entry in matched:
164
+ table_rows.append(
165
+ [
166
+ str(entry.get("code", "")),
167
+ str(entry.get("category", "")),
168
+ str(entry.get("http_status", "")),
169
+ str(entry.get("title", "")),
170
+ str(entry.get("owner_experiment", "")),
171
+ ]
172
+ )
173
+ header = ["code", "category", "http", "title", "owner_experiment"]
174
+ widths = [max(len(h), *(len(row[i]) for row in table_rows)) for i, h in enumerate(header)]
175
+ sep = "-+-".join("-" * w for w in widths)
176
+ typer.echo(" | ".join(h.ljust(widths[i]) for i, h in enumerate(header)))
177
+ typer.echo(sep)
178
+ for row in table_rows:
179
+ typer.echo(" | ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))