okstra 0.220.1 → 0.222.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 (83) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/architecture/storage-model.md +1 -1
  4. package/docs/architecture.md +6 -4
  5. package/docs/cli.md +13 -9
  6. package/docs/project-structure-overview.md +5 -1
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/prompts/launch.template.md +1 -1
  10. package/runtime/prompts/lead/convergence.md +3 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
  12. package/runtime/prompts/lead/phase-routing.md +1 -1
  13. package/runtime/prompts/lead/team-contract.md +1 -1
  14. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
  15. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  16. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
  17. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
  18. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
  19. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
  20. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
  21. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
  22. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
  23. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
  24. package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
  25. package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
  26. package/runtime/python/okstra_ctl/convergence.py +155 -19
  27. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
  28. package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
  29. package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
  30. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
  31. package/runtime/python/okstra_ctl/correction_cli.py +50 -11
  32. package/runtime/python/okstra_ctl/correction_round.py +80 -11
  33. package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
  34. package/runtime/python/okstra_ctl/domain/provider.py +2 -0
  35. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
  36. package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
  37. package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
  38. package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
  39. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
  40. package/runtime/python/okstra_ctl/manager_cli.py +11 -1
  41. package/runtime/python/okstra_ctl/manager_sync.py +38 -2
  42. package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
  43. package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
  44. package/runtime/python/okstra_ctl/next_phase.py +22 -9
  45. package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
  46. package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
  47. package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
  48. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
  49. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  50. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
  51. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
  52. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  53. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
  54. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
  55. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  56. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
  57. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
  58. package/runtime/python/okstra_ctl/process_group.py +4 -0
  59. package/runtime/python/okstra_ctl/render.py +7 -48
  60. package/runtime/python/okstra_ctl/report_assembly.py +50 -13
  61. package/runtime/python/okstra_ctl/report_html/common.py +3 -1
  62. package/runtime/python/okstra_ctl/report_language.py +4 -5
  63. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  64. package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
  65. package/runtime/python/okstra_ctl/run.py +36 -5
  66. package/runtime/python/okstra_ctl/stage_done.py +37 -3
  67. package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
  68. package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
  69. package/runtime/schemas/final-report-v3.0.schema.json +36 -0
  70. package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
  71. package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
  72. package/runtime/skills/okstra-discussion/SKILL.md +3 -3
  73. package/runtime/skills/okstra-manager/SKILL.md +1 -0
  74. package/runtime/skills/okstra-run/SKILL.md +4 -3
  75. package/runtime/skills/okstra-setup/SKILL.md +2 -0
  76. package/runtime/skills/okstra-setup/references/project-config.md +34 -0
  77. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  78. package/runtime/templates/reports/html/base.template.html +6 -1
  79. package/runtime/templates/reports/html/i18n/en.json +1 -0
  80. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  81. package/runtime/templates/reports/task-brief.template.md +3 -3
  82. package/runtime/validators/validate-run.py +18 -2
  83. package/runtime/validators/validate_session_conformance.py +19 -1
@@ -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))
@@ -35,7 +35,7 @@ from okstra_project.phase_pointer import ( # noqa: F401 — 의도적 재노출
35
35
  promote,
36
36
  )
37
37
 
38
- # 라우팅 필드가 없어 항상 terminal 인 task-type.
38
+ # 라우팅 필드가 자유 문자열이라 전달을 마치면 terminal 인 task-type.
39
39
  _TERMINAL_TASK_TYPES = frozenset({"release-handoff"})
40
40
 
41
41
  # 라우팅 필드가 없어 항상 pending 인 사이드트랙 task-type.
@@ -120,14 +120,13 @@ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
120
120
 
121
121
  if task_type in _TERMINAL_TASK_TYPES:
122
122
  # release-handoff 의 라우팅 칸은 자유 문자열이다. phase 로 삼을 값은
123
- # 없지만(생애주기가 여기서 끝난다) 그 문장은 태스크가 왜 끝났는지
124
- # 말하므로 근거 자리에 싣는다.
125
- return make(
126
- status=STATUS_TERMINAL,
127
- rationale=_text(
128
- _block(report_data, "releaseHandoff").get("routingRecommendation")
129
- ),
130
- )
123
+ # 없지만 그 문장은 태스크가 왜 끝났는지(또는 무엇이 남았는지) 말하므로
124
+ # 근거 자리에 싣는다.
125
+ handoff = _block(report_data, "releaseHandoff")
126
+ rationale = _text(handoff.get("routingRecommendation"))
127
+ if _handoff_left_stages_undelivered(handoff):
128
+ return make(phase="release-handoff", status=STATUS_READY, rationale=rationale)
129
+ return make(status=STATUS_TERMINAL, rationale=rationale)
131
130
  if task_type in _SIDETRACK_TASK_TYPES:
132
131
  return make(status=STATUS_PENDING)
133
132
 
@@ -148,6 +147,20 @@ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
148
147
  return make(status=STATUS_PENDING)
149
148
 
150
149
 
150
+ def _handoff_left_stages_undelivered(handoff: Mapping[str, Any]) -> bool:
151
+ """skip 이거나 PR 을 건너뛴 stage 가 있으면 release-handoff 재진입이 남는다.
152
+
153
+ `prompts/lead/phase-routing.md` `## release-handoff` 의 라우팅 규칙이다.
154
+ """
155
+ selections = handoff.get("userSelections")
156
+ if isinstance(selections, Mapping) and selections.get("h1") == "skip":
157
+ return True
158
+ outcomes = handoff.get("pullRequestOutcomes")
159
+ return isinstance(outcomes, list) and any(
160
+ isinstance(row, Mapping) and row.get("kind") == "skipped" for row in outcomes
161
+ )
162
+
163
+
151
164
  def _block(report_data: Mapping[str, Any], key: str) -> Mapping[str, Any]:
152
165
  block = report_data.get(key)
153
166
  return block if isinstance(block, Mapping) else {}
@@ -25,7 +25,7 @@ from okstra_ctl.plan_run_root import plan_run_root_from_approved_plan
25
25
  from okstra_ctl.prepare_error import PrepareError
26
26
  from okstra_ctl.stage_integrate import IntegrateResult
27
27
  from okstra_ctl.stage_map import active_stage_records
28
- from okstra_ctl.stage_reconcile import auto_reconcile_best_effort
28
+ from okstra_ctl.stage_reconcile import require_reconciled_stages
29
29
  from okstra_ctl.stage_targets import (
30
30
  FinalVerificationTarget,
31
31
  StageTargetError,
@@ -106,7 +106,8 @@ def _read_final_verification_done_rows(
106
106
  plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
107
107
  backfill_done_from_carry(plan_run_root)
108
108
  project_id, task_group, task_id = registry_coordinates
109
- auto_reconcile_best_effort(
109
+ # 복원·재배치·통합보다 먼저 거부해야 거부된 prepare 가 git 을 건드리지 않는다.
110
+ require_reconciled_stages(
110
111
  replace(
111
112
  request,
112
113
  project_id=project_id,
@@ -114,6 +115,7 @@ def _read_final_verification_done_rows(
114
115
  task_id=task_id,
115
116
  ),
116
117
  plan_run_root,
118
+ stage=request.stage,
117
119
  )
118
120
  from okstra_ctl.stage_rebase import synchronize_stage_stack
119
121
  from okstra_ctl.stage_targets import StageWorktreeIdentity