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.
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +6 -4
- package/docs/cli.md +13 -9
- package/docs/project-structure-overview.md +5 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
- package/runtime/prompts/lead/phase-routing.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
- package/runtime/python/okstra_ctl/convergence.py +155 -19
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
- package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
- package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
- package/runtime/python/okstra_ctl/correction_cli.py +50 -11
- package/runtime/python/okstra_ctl/correction_round.py +80 -11
- package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
- package/runtime/python/okstra_ctl/domain/provider.py +2 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
- package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
- package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
- package/runtime/python/okstra_ctl/manager_cli.py +11 -1
- package/runtime/python/okstra_ctl/manager_sync.py +38 -2
- package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
- package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
- package/runtime/python/okstra_ctl/next_phase.py +22 -9
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
- package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +5 -4
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
- package/runtime/python/okstra_ctl/process_group.py +4 -0
- package/runtime/python/okstra_ctl/render.py +7 -48
- package/runtime/python/okstra_ctl/report_assembly.py +50 -13
- package/runtime/python/okstra_ctl/report_html/common.py +3 -1
- package/runtime/python/okstra_ctl/report_language.py +4 -5
- package/runtime/python/okstra_ctl/report_projections.py +45 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +36 -5
- package/runtime/python/okstra_ctl/stage_done.py +37 -3
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/schemas/final-report-v3.0.schema.json +36 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
- package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
- package/runtime/skills/okstra-discussion/SKILL.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +4 -3
- package/runtime/skills/okstra-setup/SKILL.md +2 -0
- package/runtime/skills/okstra-setup/references/project-config.md +34 -0
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/html/base.template.html +6 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/task-brief.template.md +3 -3
- package/runtime/validators/validate-run.py +18 -2
- 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
|
-
# 라우팅 필드가
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|