okstra 0.220.1 → 0.221.0

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 (56) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/cli.md +7 -3
  5. package/docs/project-structure-overview.md +1 -1
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/prompts/launch.template.md +1 -1
  9. package/runtime/prompts/lead/okstra-lead-contract.md +9 -5
  10. package/runtime/prompts/lead/team-contract.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
  12. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  13. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
  14. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
  15. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
  16. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
  17. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
  18. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
  19. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
  20. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
  21. package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
  22. package/runtime/python/okstra_ctl/correction_cli.py +44 -5
  23. package/runtime/python/okstra_ctl/correction_round.py +40 -11
  24. package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
  25. package/runtime/python/okstra_ctl/domain/provider.py +2 -0
  26. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
  27. package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
  29. package/runtime/python/okstra_ctl/manager_cli.py +11 -1
  30. package/runtime/python/okstra_ctl/manager_sync.py +38 -2
  31. package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
  32. package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
  33. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +4 -3
  34. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  35. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
  36. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
  37. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  38. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
  39. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
  40. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  41. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
  42. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
  43. package/runtime/python/okstra_ctl/render.py +7 -48
  44. package/runtime/python/okstra_ctl/report_language.py +4 -5
  45. package/runtime/python/okstra_ctl/run.py +29 -0
  46. package/runtime/python/okstra_ctl/stage_done.py +37 -3
  47. package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
  48. package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
  49. package/runtime/skills/okstra-discussion/SKILL.md +3 -3
  50. package/runtime/skills/okstra-manager/SKILL.md +1 -0
  51. package/runtime/skills/okstra-run/SKILL.md +3 -3
  52. package/runtime/skills/okstra-setup/SKILL.md +2 -0
  53. package/runtime/skills/okstra-setup/references/project-config.md +34 -0
  54. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  55. package/runtime/templates/reports/task-brief.template.md +3 -3
  56. package/runtime/validators/validate-run.py +13 -0
@@ -1,9 +1,11 @@
1
1
  """Read project-local okstra state into manager-owned snapshots."""
2
2
  from __future__ import annotations
3
3
 
4
+ from datetime import datetime
4
5
  from pathlib import Path
5
6
 
6
7
  from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
8
+ from okstra_project.dirs import TASK_MANIFEST_FILENAME
7
9
 
8
10
  from . import next_phase
9
11
  from .next_phase import STATUS_BLOCKED, STATUS_TERMINAL
@@ -168,12 +170,35 @@ def summary_bucket(row: dict) -> str:
168
170
  return _POINTER_STATUS_BUCKETS.get(pointer_status, "running")
169
171
 
170
172
 
171
- def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
173
+ def _epoch(value: str) -> int | None:
174
+ if not value:
175
+ return None
176
+ return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
177
+
178
+
179
+ def _manifest_changed_since(project: dict | None, task_key: str, synced: int | None) -> bool:
180
+ """프로젝트의 task-manifest 가 마지막 sync 뒤에 바뀌었는지 — 파일 시각만 본다."""
181
+ if project is None or not task_key:
182
+ return False
183
+ root = Path(str(project.get("projectRoot") or ""))
184
+ if not root.is_dir():
185
+ return False
186
+ task_dir = _resolve_task_root_read_only(root, task_key)
187
+ manifest_path = task_dir / TASK_MANIFEST_FILENAME if task_dir is not None else None
188
+ if manifest_path is None or not manifest_path.is_file():
189
+ return False
190
+ # syncedAt 은 초 단위라 mtime 도 초로 내려 같은 초에 쓴 파일을 변경으로 세지 않는다.
191
+ return synced is None or int(manifest_path.stat().st_mtime) > synced
192
+
193
+
194
+ def status_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
172
195
  manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
173
196
  children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
174
197
  snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
175
198
  by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
176
199
  linked = _project_map(home, manager_id)
200
+ synced_at = str(snapshot.get("syncedAt") or "")
201
+ synced = _epoch(synced_at)
177
202
  rows = []
178
203
  summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0, "discussion": 0}
179
204
  for child in children.get("children", []):
@@ -184,6 +209,17 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
184
209
  row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
185
210
  # `remove project` 는 하위 태스크를 남긴다. 등록 목록에 없으면 해제된 프로젝트의 것이다.
186
211
  row["projectLinked"] = str(child.get("projectId") or "") in linked
212
+ try:
213
+ row["changedSinceSync"] = _manifest_changed_since(
214
+ linked.get(str(child.get("projectId") or "")), str(child.get("taskKey") or ""), synced)
215
+ except StateError as exc:
216
+ row["changedSinceSync"] = True
217
+ row["error"] = str(exc)
187
218
  summary[row["summaryBucket"]] += 1
188
219
  rows.append(row)
189
- return {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot}
220
+ payload = {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot,
221
+ "syncedAt": synced_at,
222
+ "changedSinceSyncCount": sum(1 for row in rows if row["changedSinceSync"])}
223
+ if synced is not None:
224
+ payload["syncAgeSeconds"] = max(0, (_epoch(now or _now_iso()) or 0) - synced)
225
+ return payload
@@ -0,0 +1,179 @@
1
+ """`okstra mcp` — declare project MCP servers and check provider reachability."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from okstra_project import resolve_project_root
10
+ from okstra_project.resolver import ResolverError
11
+
12
+ from .json_boundary import JsonBoundaryError
13
+ from .mcp_servers import (
14
+ McpDeclarationError,
15
+ assess,
16
+ brief_source_warnings,
17
+ load_declarations,
18
+ parse_declaration,
19
+ probe_inventories,
20
+ provider_probes,
21
+ reachability_sentence,
22
+ remove_declaration,
23
+ upsert_declaration,
24
+ )
25
+
26
+ _CLI_EPILOG = r"""Usage:
27
+ okstra mcp list [--json] [--cwd <dir>]
28
+ okstra mcp add --name <name> --description <text> --tool <tool>... [--alias <name>...] [--notes <text>] [--cwd <dir>]
29
+ okstra mcp remove --name <name> [--cwd <dir>]
30
+ okstra mcp check [--provider <id>...] [--brief <path>] [--json] [--cwd <dir>]
31
+
32
+ Declarations live in .okstra/project.json `mcpServers`. `--alias` names the
33
+ server as a provider CLI lists it (for example `figma-desktop`). `check` runs
34
+ each provider adapter's MCP listing command; it never sends an inference call.
35
+ Add a missing server through that provider CLI's own MCP configuration.
36
+ """
37
+
38
+
39
+ def _project_root(cwd: str) -> Path:
40
+ return resolve_project_root(cwd=str(Path(cwd).resolve()))
41
+
42
+
43
+ def _list(root: Path, as_json: bool) -> int:
44
+ declarations, problems = load_declarations(root)
45
+ if as_json:
46
+ print(json.dumps({
47
+ "servers": [d.to_json() for d in declarations],
48
+ "problems": list(problems),
49
+ }, ensure_ascii=False, indent=2))
50
+ return 1 if problems else 0
51
+ if not declarations:
52
+ print("No MCP servers declared.")
53
+ for declaration in declarations:
54
+ aliases = f" (aliases: {', '.join(declaration.aliases)})" if declaration.aliases else ""
55
+ print(f"{declaration.name}{aliases}: {declaration.description}")
56
+ print(f" tools: {', '.join(declaration.tools)}")
57
+ if declaration.notes:
58
+ print(f" notes: {declaration.notes}")
59
+ for problem in problems:
60
+ print(f"invalid: {problem}")
61
+ return 1 if problems else 0
62
+
63
+
64
+ def _add(root: Path, args: argparse.Namespace) -> int:
65
+ raw = {
66
+ "name": args.name,
67
+ "description": args.description,
68
+ "tools": args.tool or [],
69
+ }
70
+ if args.alias:
71
+ raw["aliases"] = args.alias
72
+ if args.notes:
73
+ raw["notes"] = args.notes
74
+ declaration, errors = parse_declaration(raw)
75
+ if declaration is None:
76
+ print("error: " + "; ".join(errors))
77
+ return 1
78
+ replaced = upsert_declaration(root, declaration)
79
+ print(f"{'Replaced' if replaced else 'Added'} MCP server `{declaration.name}`.")
80
+ print("Run `okstra mcp check` to see which providers can reach it.")
81
+ return 0
82
+
83
+
84
+ def _remove(root: Path, name: str) -> int:
85
+ if not remove_declaration(root, name):
86
+ print(f"error: no MCP server named `{name}` is declared")
87
+ return 1
88
+ print(f"Removed MCP server `{name}`.")
89
+ return 0
90
+
91
+
92
+ def _check(root: Path, providers: list[str], brief: str, as_json: bool) -> int:
93
+ from .registry.provider_registry import default_provider_registry
94
+
95
+ declarations, problems = load_declarations(root)
96
+ provider_ids = providers or list(default_provider_registry().ids())
97
+ rows = assess(declarations, probe_inventories(provider_probes(provider_ids), root))
98
+ brief_warnings = (
99
+ brief_source_warnings(Path(brief).read_text(encoding="utf-8"), declarations, rows)
100
+ if brief else ()
101
+ )
102
+ if as_json:
103
+ print(json.dumps({
104
+ "servers": [
105
+ {
106
+ "name": row.server.name,
107
+ "providers": [
108
+ {
109
+ "provider": cell.provider_id,
110
+ "status": cell.status,
111
+ "visibleName": cell.visible_name,
112
+ "note": cell.note,
113
+ }
114
+ for cell in row.cells
115
+ ],
116
+ }
117
+ for row in rows
118
+ ],
119
+ "problems": list(problems),
120
+ "briefWarnings": list(brief_warnings),
121
+ }, ensure_ascii=False, indent=2))
122
+ return 0
123
+ if not declarations:
124
+ print("No MCP servers declared. Register one with `okstra mcp add`.")
125
+ for row in rows:
126
+ print(f"{row.server.name}: {reachability_sentence(row)}")
127
+ for cell in row.cells:
128
+ if cell.note and cell.status != "missing":
129
+ print(f" {cell.provider_id}: {cell.note}")
130
+ for problem in problems:
131
+ print(f"invalid: {problem}")
132
+ for warning in brief_warnings:
133
+ print(warning)
134
+ return 0
135
+
136
+
137
+ def main(argv: list[str] | None = None) -> int:
138
+ parser = argparse.ArgumentParser(
139
+ description="Declare project MCP servers and check provider reachability.",
140
+ epilog=_CLI_EPILOG,
141
+ formatter_class=argparse.RawDescriptionHelpFormatter,
142
+ prog="okstra mcp",
143
+ )
144
+ sub = parser.add_subparsers(dest="command", required=True)
145
+ listed = sub.add_parser("list")
146
+ listed.add_argument("--json", action="store_true")
147
+ listed.add_argument("--cwd", default=".")
148
+ add = sub.add_parser("add")
149
+ add.add_argument("--name", required=True)
150
+ add.add_argument("--description", required=True)
151
+ add.add_argument("--tool", action="append")
152
+ add.add_argument("--alias", action="append")
153
+ add.add_argument("--notes", default="")
154
+ add.add_argument("--cwd", default=".")
155
+ remove = sub.add_parser("remove")
156
+ remove.add_argument("--name", required=True)
157
+ remove.add_argument("--cwd", default=".")
158
+ check = sub.add_parser("check")
159
+ check.add_argument("--provider", action="append", default=[])
160
+ check.add_argument("--brief", default="")
161
+ check.add_argument("--json", action="store_true")
162
+ check.add_argument("--cwd", default=".")
163
+ args = parser.parse_args(argv)
164
+ try:
165
+ root = _project_root(args.cwd)
166
+ if args.command == "list":
167
+ return _list(root, args.json)
168
+ if args.command == "add":
169
+ return _add(root, args)
170
+ if args.command == "remove":
171
+ return _remove(root, args.name)
172
+ return _check(root, args.provider, args.brief, args.json)
173
+ except (McpDeclarationError, JsonBoundaryError, ResolverError, OSError, ValueError) as exc:
174
+ print(f"error: {exc}", file=sys.stderr)
175
+ return 1
176
+
177
+
178
+ if __name__ == "__main__":
179
+ raise SystemExit(main())
@@ -0,0 +1,421 @@
1
+ """프로젝트 MCP 서버 선언과 provider 별 도달성 판정.
2
+
3
+ 선언은 논리 서버 이름과, provider CLI 가 그 서버를 나열하는 이름(`aliases`)만
4
+ 갖는다. 어느 provider 가 볼 수 있는지는 각 어댑터의 `mcp_inventory` 가 답하며,
5
+ 이 모듈은 provider 이름을 하나도 모른다.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import subprocess
11
+ from collections.abc import Callable, Iterable, Mapping, Sequence
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Literal
16
+
17
+ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
18
+
19
+ from .domain.mcp import McpInventory, McpInventoryProbe
20
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
21
+
22
+ PROBE_TIMEOUT_SECONDS = 20.0
23
+
24
+ Reachability = Literal["reachable", "disabled", "missing", "unknown"]
25
+
26
+
27
+ class McpDeclarationError(ValueError):
28
+ pass
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class McpServerDeclaration:
33
+ name: str
34
+ description: str
35
+ tools: tuple[str, ...]
36
+ aliases: tuple[str, ...] = ()
37
+ notes: str = ""
38
+
39
+ def to_json(self) -> dict[str, Any]:
40
+ payload: dict[str, Any] = {
41
+ "name": self.name,
42
+ "description": self.description,
43
+ "tools": list(self.tools),
44
+ }
45
+ if self.aliases:
46
+ payload["aliases"] = list(self.aliases)
47
+ if self.notes:
48
+ payload["notes"] = self.notes
49
+ return payload
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ProviderCell:
54
+ provider_id: str
55
+ status: Reachability
56
+ visible_name: str = ""
57
+ note: str = ""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ServerReachability:
62
+ server: McpServerDeclaration
63
+ cells: tuple[ProviderCell, ...]
64
+
65
+ def reachable_from(self) -> tuple[str, ...]:
66
+ return tuple(c.provider_id for c in self.cells if c.status == "reachable")
67
+
68
+
69
+ def _string_list(value: object, field: str, errors: list[str]) -> tuple[str, ...]:
70
+ if not isinstance(value, list) or not all(
71
+ isinstance(item, str) and item.strip() for item in value
72
+ ):
73
+ errors.append(f"`{field}` must be a list of non-empty strings")
74
+ return ()
75
+ return tuple(item.strip() for item in value)
76
+
77
+
78
+ def parse_declaration(raw: object) -> tuple[McpServerDeclaration | None, list[str]]:
79
+ if not isinstance(raw, dict):
80
+ return None, ["entry must be an object"]
81
+ errors: list[str] = []
82
+ name = raw.get("name")
83
+ if not isinstance(name, str) or not name.strip():
84
+ errors.append("`name` must be a non-empty string")
85
+ description = raw.get("description")
86
+ if not isinstance(description, str) or not description.strip():
87
+ errors.append("`description` must be a non-empty string")
88
+ tools = _string_list(raw.get("tools"), "tools", errors)
89
+ if raw.get("tools") == []:
90
+ errors.append("`tools` must not be empty")
91
+ aliases = _string_list(raw["aliases"], "aliases", errors) if "aliases" in raw else ()
92
+ notes = raw.get("notes", "")
93
+ if not isinstance(notes, str):
94
+ errors.append("`notes` must be a string")
95
+ if errors:
96
+ return None, errors
97
+ return (
98
+ McpServerDeclaration(
99
+ name=name.strip(),
100
+ description=description.strip(),
101
+ tools=tools,
102
+ aliases=aliases,
103
+ notes=notes.strip(),
104
+ ),
105
+ [],
106
+ )
107
+
108
+
109
+ def load_declarations(
110
+ project_root: Path,
111
+ ) -> tuple[tuple[McpServerDeclaration, ...], tuple[str, ...]]:
112
+ path = project_json_path(project_root)
113
+ if not path.is_file():
114
+ return (), ()
115
+ try:
116
+ raw = load_owned_object(path, artifact="project config")
117
+ except JsonBoundaryError as exc:
118
+ return (), (str(exc),)
119
+ servers = raw.get("mcpServers")
120
+ if servers is None:
121
+ return (), ()
122
+ if not isinstance(servers, list):
123
+ return (), (f"`mcpServers` in {path} must be a list",)
124
+ declarations: list[McpServerDeclaration] = []
125
+ problems: list[str] = []
126
+ seen: set[str] = set()
127
+ for index, entry in enumerate(servers):
128
+ declaration, errors = parse_declaration(entry)
129
+ if declaration is None:
130
+ problems.append(f"mcpServers[{index}]: " + "; ".join(errors))
131
+ continue
132
+ if declaration.name in seen:
133
+ problems.append(f"mcpServers[{index}]: duplicate name `{declaration.name}`")
134
+ continue
135
+ seen.add(declaration.name)
136
+ declarations.append(declaration)
137
+ return tuple(declarations), tuple(problems)
138
+
139
+
140
+ def _read_project_config(project_root: Path) -> tuple[Path, dict[str, Any]]:
141
+ path = project_json_path(project_root)
142
+ if not path.is_file():
143
+ raise McpDeclarationError(
144
+ f"{path} not found; register the project with `okstra setup` first"
145
+ )
146
+ return path, load_owned_object(path, artifact="project config")
147
+
148
+
149
+ def upsert_declaration(project_root: Path, declaration: McpServerDeclaration) -> bool:
150
+ path, data = _read_project_config(project_root)
151
+ servers = data.get("mcpServers") or []
152
+ if not isinstance(servers, list):
153
+ raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
154
+ kept = [
155
+ entry for entry in servers
156
+ if not (isinstance(entry, dict) and entry.get("name") == declaration.name)
157
+ ]
158
+ replaced = len(kept) != len(servers)
159
+ data["mcpServers"] = [*kept, declaration.to_json()]
160
+ write_owned_object_atomic(path, data, artifact="project config")
161
+ return replaced
162
+
163
+
164
+ def remove_declaration(project_root: Path, name: str) -> bool:
165
+ path, data = _read_project_config(project_root)
166
+ servers = data.get("mcpServers") or []
167
+ if not isinstance(servers, list):
168
+ raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
169
+ kept = [
170
+ entry for entry in servers
171
+ if not (isinstance(entry, dict) and entry.get("name") == name)
172
+ ]
173
+ if len(kept) == len(servers):
174
+ return False
175
+ data["mcpServers"] = kept
176
+ write_owned_object_atomic(path, data, artifact="project config")
177
+ return True
178
+
179
+
180
+ def normalize_server_name(name: str) -> str:
181
+ return re.sub(r"[\s._-]+", "", name).lower()
182
+
183
+
184
+ def _match(
185
+ declaration: McpServerDeclaration, inventory: McpInventory,
186
+ ) -> tuple[Reachability, str]:
187
+ if not inventory.listed:
188
+ return "unknown", ""
189
+ wanted = {normalize_server_name(n) for n in (declaration.name, *declaration.aliases)}
190
+ found = [s for s in inventory.servers if normalize_server_name(s.name) in wanted]
191
+ for server in found:
192
+ if server.state == "enabled":
193
+ return "reachable", server.name
194
+ if found:
195
+ return "disabled", found[0].name
196
+ return "missing", ""
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class CommandOutcome:
201
+ stdout: str | None
202
+ failure: str = ""
203
+
204
+
205
+ CommandRunner = Callable[[tuple[str, ...], Path], CommandOutcome]
206
+
207
+
208
+ def run_command(argv: tuple[str, ...], project_root: Path) -> CommandOutcome:
209
+ command = " ".join(argv)
210
+ try:
211
+ completed = subprocess.run(
212
+ list(argv),
213
+ cwd=project_root,
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=PROBE_TIMEOUT_SECONDS,
217
+ check=False,
218
+ )
219
+ except FileNotFoundError:
220
+ return CommandOutcome(None, f"`{argv[0]}` is not installed")
221
+ except subprocess.TimeoutExpired:
222
+ return CommandOutcome(None, f"`{command}` timed out after {PROBE_TIMEOUT_SECONDS:.0f}s")
223
+ except OSError as exc:
224
+ return CommandOutcome(None, f"`{command}` failed to start ({exc})")
225
+ if completed.returncode != 0:
226
+ return CommandOutcome(None, f"`{command}` exited {completed.returncode}")
227
+ return CommandOutcome(completed.stdout)
228
+
229
+
230
+ def probe_inventories(
231
+ probes: Mapping[str, McpInventoryProbe | None],
232
+ project_root: Path,
233
+ runner: CommandRunner | None = None,
234
+ ) -> dict[str, McpInventory]:
235
+ """같은 명령은 한 번만 실행한다. claude 와 zai 처럼 CLI 를 공유하는 어댑터가
236
+ 목록 비용(실측 7.5초)을 두 번 치르지 않게 하려는 것이다."""
237
+ commands = {probe.argv for probe in probes.values() if probe is not None}
238
+ outcomes: dict[tuple[str, ...], CommandOutcome] = {}
239
+ if commands:
240
+ with ThreadPoolExecutor(max_workers=len(commands)) as pool:
241
+ futures = {
242
+ argv: pool.submit(runner or run_command, argv, project_root)
243
+ for argv in commands
244
+ }
245
+ outcomes = {argv: future.result() for argv, future in futures.items()}
246
+ inventories: dict[str, McpInventory] = {}
247
+ for provider_id, probe in probes.items():
248
+ if probe is None:
249
+ inventories[provider_id] = McpInventory.unavailable(
250
+ "adapter declares no MCP inventory probe"
251
+ )
252
+ continue
253
+ outcome = outcomes[probe.argv]
254
+ inventories[provider_id] = (
255
+ probe.parse(outcome.stdout)
256
+ if outcome.stdout is not None
257
+ else McpInventory.unavailable(outcome.failure)
258
+ )
259
+ return inventories
260
+
261
+
262
+ def assess(
263
+ declarations: Sequence[McpServerDeclaration],
264
+ inventories: Mapping[str, McpInventory],
265
+ ) -> tuple[ServerReachability, ...]:
266
+ rows: list[ServerReachability] = []
267
+ for declaration in declarations:
268
+ cells = []
269
+ for provider_id, inventory in inventories.items():
270
+ status, visible = _match(declaration, inventory)
271
+ cells.append(ProviderCell(provider_id, status, visible, inventory.note))
272
+ rows.append(ServerReachability(declaration, tuple(cells)))
273
+ return tuple(rows)
274
+
275
+
276
+ def provider_probes(provider_ids: Iterable[str]) -> dict[str, McpInventoryProbe | None]:
277
+ from .registry.provider_registry import default_provider_registry
278
+
279
+ registry = default_provider_registry()
280
+ return {
281
+ provider_id: registry.resolve(provider_id).mcp_inventory
282
+ for provider_id in dict.fromkeys(provider_ids)
283
+ }
284
+
285
+
286
+ def _cell_text(cell: ProviderCell) -> str:
287
+ if cell.status == "reachable":
288
+ return f"{cell.provider_id} (as `{cell.visible_name}`)"
289
+ if cell.status == "disabled":
290
+ return f"{cell.provider_id} (disabled as `{cell.visible_name}`)"
291
+ return f"{cell.provider_id} ({cell.status})"
292
+
293
+
294
+ def reachability_sentence(row: ServerReachability) -> str:
295
+ reachable = [c for c in row.cells if c.status == "reachable"]
296
+ other = [c for c in row.cells if c.status != "reachable"]
297
+ parts = []
298
+ if reachable:
299
+ parts.append("Reachable from: " + ", ".join(_cell_text(c) for c in reachable))
300
+ if other:
301
+ parts.append("Not reachable: " + ", ".join(_cell_text(c) for c in other))
302
+ return ". ".join(parts)
303
+
304
+
305
+ def reachability_warnings(rows: Sequence[ServerReachability]) -> tuple[str, ...]:
306
+ warnings = []
307
+ for row in rows:
308
+ for cell in row.cells:
309
+ if cell.status == "reachable":
310
+ continue
311
+ detail = _cell_text(cell)
312
+ if cell.note:
313
+ detail += f": {cell.note}"
314
+ warnings.append(
315
+ f"mcp: server `{row.server.name}` is not reachable from provider {detail}"
316
+ )
317
+ return tuple(warnings)
318
+
319
+
320
+ NO_MCP_SERVERS_LINE = (
321
+ f"- No MCP servers are declared in `{OKSTRA_DIR_NAME}/project.json`'s "
322
+ "`mcpServers` array. Treat MCP tools as unavailable for this run. To enable "
323
+ "them, register each server with `okstra mcp add` and re-render the bundle."
324
+ )
325
+
326
+
327
+ def render_block(
328
+ declarations: Sequence[McpServerDeclaration],
329
+ rows: Sequence[ServerReachability] = (),
330
+ ) -> str:
331
+ """툴 이름은 논리 이름으로만 싣는다. 실제 호출 이름은 CLI 마다 규칙이 달라
332
+ 워커가 자기 CLI 의 툴 목록에서 찾는다."""
333
+ if not declarations:
334
+ return NO_MCP_SERVERS_LINE
335
+ by_name = {row.server.name: row for row in rows}
336
+ lines = []
337
+ for declaration in declarations:
338
+ parts = [f"`{declaration.name}`"]
339
+ if declaration.aliases:
340
+ parts[0] += " (also listed as " + ", ".join(
341
+ f"`{alias}`" for alias in declaration.aliases
342
+ ) + ")"
343
+ parts.append(declaration.description)
344
+ parts.append("Tools: " + ", ".join(f"`{tool}`" for tool in declaration.tools))
345
+ if declaration.notes:
346
+ parts.append(declaration.notes)
347
+ row = by_name.get(declaration.name)
348
+ if row is not None and row.cells:
349
+ parts.append(reachability_sentence(row))
350
+ lines.append("- " + ". ".join(p.rstrip(".") for p in parts) + ".")
351
+ return "\n".join(lines)
352
+
353
+
354
+ @dataclass(frozen=True)
355
+ class BriefSource:
356
+ label: str
357
+ pattern: re.Pattern[str]
358
+ keyword: str
359
+
360
+
361
+ BRIEF_SOURCES = (
362
+ BriefSource(
363
+ "Figma",
364
+ re.compile(r"figma\.com/(?:file|design|proto|board)/", re.IGNORECASE),
365
+ "figma",
366
+ ),
367
+ )
368
+
369
+
370
+ def brief_source_warnings(
371
+ brief_text: str,
372
+ declarations: Sequence[McpServerDeclaration],
373
+ rows: Sequence[ServerReachability] = (),
374
+ ) -> tuple[str, ...]:
375
+ warnings = []
376
+ for source in BRIEF_SOURCES:
377
+ if not source.pattern.search(brief_text):
378
+ continue
379
+ matching = [
380
+ d for d in declarations
381
+ if any(source.keyword in normalize_server_name(n) for n in (d.name, *d.aliases))
382
+ ]
383
+ if not matching:
384
+ warnings.append(
385
+ f"mcp: brief cites a {source.label} URL but no mcpServers entry matches "
386
+ f"'{source.keyword}'; register it with `okstra mcp add`"
387
+ )
388
+ continue
389
+ names = {d.name for d in matching}
390
+ checked = [row for row in rows if row.server.name in names]
391
+ if checked and not any(row.reachable_from() for row in checked):
392
+ warnings.append(
393
+ f"mcp: brief cites a {source.label} URL but no selected provider can "
394
+ f"reach {', '.join(sorted(names))}; add it to a provider CLI's MCP "
395
+ "configuration or select a provider that lists it"
396
+ )
397
+ return tuple(warnings)
398
+
399
+
400
+ @dataclass(frozen=True)
401
+ class PreparedMcp:
402
+ block: str
403
+ warnings: tuple[str, ...]
404
+
405
+
406
+ def prepare_mcp(
407
+ project_root: Path,
408
+ provider_ids: Iterable[str],
409
+ brief_text: str,
410
+ runner: CommandRunner | None = None,
411
+ ) -> PreparedMcp:
412
+ """판정 결과는 경고와 렌더에만 쓰고 run 을 거절하지 않는다."""
413
+ declarations, problems = load_declarations(project_root)
414
+ warnings = [f"mcp: {problem}" for problem in problems]
415
+ rows: tuple[ServerReachability, ...] = ()
416
+ if declarations:
417
+ inventories = probe_inventories(provider_probes(provider_ids), project_root, runner)
418
+ rows = assess(declarations, inventories)
419
+ warnings.extend(reachability_warnings(rows))
420
+ warnings.extend(brief_source_warnings(brief_text, declarations, rows))
421
+ return PreparedMcp(render_block(declarations, rows), tuple(warnings))