okstra 0.220.0 → 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 (67) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/dist/lib/paths.mjs +1 -1
  4. package/dist/lib/paths.mjs.map +1 -1
  5. package/docs/architecture.md +2 -0
  6. package/docs/cli.md +7 -3
  7. package/docs/project-structure-overview.md +1 -1
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/prompts/launch.template.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
  12. package/runtime/prompts/lead/team-contract.md +1 -1
  13. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
  14. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  15. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
  16. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
  17. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
  18. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
  19. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
  20. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
  21. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
  22. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
  23. package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
  24. package/runtime/python/okstra_ctl/business_flow/engine.py +10 -2
  25. package/runtime/python/okstra_ctl/correction_cli.py +44 -5
  26. package/runtime/python/okstra_ctl/correction_round.py +40 -11
  27. package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
  28. package/runtime/python/okstra_ctl/domain/provider.py +2 -0
  29. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
  30. package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
  31. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
  32. package/runtime/python/okstra_ctl/lead_progress.py +56 -11
  33. package/runtime/python/okstra_ctl/manager_cli.py +13 -1
  34. package/runtime/python/okstra_ctl/manager_launch.py +23 -2
  35. package/runtime/python/okstra_ctl/manager_sync.py +38 -2
  36. package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
  37. package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
  38. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +4 -3
  39. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  40. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
  41. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
  42. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
  43. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
  44. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
  45. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
  46. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +2 -2
  47. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +2 -2
  48. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  49. package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
  50. package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
  51. package/runtime/python/okstra_ctl/render.py +7 -48
  52. package/runtime/python/okstra_ctl/report_assembly.py +5 -5
  53. package/runtime/python/okstra_ctl/report_inputs.py +10 -1
  54. package/runtime/python/okstra_ctl/report_language.py +4 -5
  55. package/runtime/python/okstra_ctl/run.py +29 -0
  56. package/runtime/python/okstra_ctl/stage_done.py +37 -3
  57. package/runtime/python/okstra_ctl/wizard/engine.py +1 -2
  58. package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
  59. package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
  60. package/runtime/skills/okstra-discussion/SKILL.md +3 -3
  61. package/runtime/skills/okstra-manager/SKILL.md +3 -2
  62. package/runtime/skills/okstra-run/SKILL.md +3 -3
  63. package/runtime/skills/okstra-setup/SKILL.md +2 -0
  64. package/runtime/skills/okstra-setup/references/project-config.md +34 -0
  65. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  66. package/runtime/templates/reports/task-brief.template.md +3 -3
  67. package/runtime/validators/validate-run.py +13 -0
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from typing import Any, Mapping
5
5
  from urllib.parse import quote
6
6
 
7
+ from okstra_ctl.domain.mcp import McpInventory, McpInventoryProbe, VisibleMcpServer
7
8
  from okstra_ctl.domain.provider import (
8
9
  LeadLaunchSpec,
9
10
  ModelSpec,
@@ -292,6 +293,29 @@ class GrokExecution:
292
293
  )
293
294
 
294
295
 
296
+ def parse_mcp_list(stdout: str) -> McpInventory:
297
+ """실측은 빈 목록뿐이었다(grok 1.0.46). 행 형식을 모르므로 `name` 이 없는
298
+ 행은 빈 목록이 아니라 판정 불가로 돌린다."""
299
+ try:
300
+ rows = json.loads(stdout)
301
+ except json.JSONDecodeError as exc:
302
+ return McpInventory.unavailable(f"grok mcp list --json is not JSON ({exc})")
303
+ if not isinstance(rows, list):
304
+ return McpInventory.unavailable("grok mcp list --json is not a list")
305
+ servers: list[VisibleMcpServer] = []
306
+ for row in rows:
307
+ if not isinstance(row, dict) or not isinstance(row.get("name"), str):
308
+ return McpInventory.unavailable("grok mcp list --json row has no name field")
309
+ enabled = row.get("enabled", True) is not False
310
+ servers.append(VisibleMcpServer(row["name"], "enabled" if enabled else "disabled"))
311
+ return McpInventory(listed=True, servers=tuple(servers))
312
+
313
+
314
+ MCP_INVENTORY = McpInventoryProbe(
315
+ argv=("grok", "mcp", "list", "--json"), parse=parse_mcp_list,
316
+ )
317
+
318
+
295
319
  def create_provider() -> ProviderSpec:
296
320
  return ProviderSpec(
297
321
  provider="grok",
@@ -324,4 +348,5 @@ def create_provider() -> ProviderSpec:
324
348
  lead_launch=GROK_LEAD_LAUNCH,
325
349
  exec_strategy=GrokExecution(),
326
350
  served_model_normalizer=normalise_served_model,
351
+ mcp_inventory=MCP_INVENTORY,
327
352
  )
@@ -7,7 +7,11 @@ from dataclasses import replace
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from okstra_ctl.adapters.providers.claude.adapter import ClaudeExecution
10
+ from okstra_ctl.adapters.providers.claude.adapter import (
11
+ ClaudeExecution,
12
+ parse_mcp_list as _claude_mcp_list,
13
+ )
14
+ from okstra_ctl.domain.mcp import McpInventory, McpInventoryProbe
11
15
  from okstra_ctl.domain.provider import ModelSpec, ProviderSpec, ServedModelAttestation
12
16
  from okstra_ctl.domain.role import ROLE_DEFINITIONS
13
17
  from okstra_ctl.domain.worker_exec import ExecCommand, PolicySupport, WorkerExecRequest
@@ -111,6 +115,20 @@ class ZaiExecution:
111
115
  return ClaudeExecution().policy_support()
112
116
 
113
117
 
118
+ def parse_mcp_list(stdout: str) -> McpInventory:
119
+ """워커는 `--setting-sources ""` 로 떠서 사용자 범위 서버는 빠지고 claude.ai
120
+ 커넥터만 남는다(2026-10-18, claude 2.1.302 실측)."""
121
+ listed = _claude_mcp_list(stdout)
122
+ return McpInventory(
123
+ listed=True,
124
+ servers=tuple(s for s in listed.servers if s.name.startswith("claude.ai ")),
125
+ note="calls through the z.ai backend are not verified",
126
+ )
127
+
128
+
129
+ MCP_INVENTORY = McpInventoryProbe(argv=("claude", "mcp", "list"), parse=parse_mcp_list)
130
+
131
+
114
132
  def create_provider() -> ProviderSpec:
115
133
  roles = frozenset(role.id for role in ROLE_DEFINITIONS if role.id != "leader")
116
134
  return ProviderSpec(
@@ -126,4 +144,5 @@ def create_provider() -> ProviderSpec:
126
144
  }),
127
145
  exec_strategy=ZaiExecution(),
128
146
  served_model_normalizer=normalise_served_model,
147
+ mcp_inventory=MCP_INVENTORY,
129
148
  )
@@ -340,6 +340,9 @@ def _materialize_run(
340
340
  ),
341
341
  )
342
342
  if args.audience == "report-writer" and uses_report_contract_v3(manifest):
343
+ # 조립이 `meta.reportLanguage` 로 옮기는 값과 같은 출처여야 한다.
344
+ report_language = str(manifest.get("reportLanguage") or "en")
345
+ anchor_lines = anchor_lines + (f"**Report Language:** {report_language}",)
343
346
  body = _with_report_writer_sections(
344
347
  args,
345
348
  project_root,
@@ -65,6 +65,7 @@ WORKER_PROFILE_SECTIONS_BY_TASK_TYPE = {
65
65
  "Worker direction-selection procedure",
66
66
  "Pre-selection context exploration",
67
67
  "Option evaluation rules",
68
+ "Technical evidence loop",
68
69
  ),
69
70
  "implementation-planning": (
70
71
  "Brief consumption",
@@ -114,6 +115,8 @@ def build_analysis_packet(
114
115
  business_knowledge_text: str = "",
115
116
  coverage_census_text: str = "",
116
117
  has_selected_direction: bool = False,
118
+ mcp_servers_text: str = "",
119
+ technical_evidence_text: str = "",
117
120
  ) -> str:
118
121
  """Return the primary compact input for Claude/Codex/Antigravity analysers.
119
122
 
@@ -130,6 +133,12 @@ def build_analysis_packet(
130
133
 
131
134
  `coverage_census_text` is the `## Coverage Census` block
132
135
  `coverage_census.render_census_section` rendered; empty means no census.
136
+
137
+ `mcp_servers_text` 는 prepare 가 provider 별 도달성까지 렌더한 선언 서버
138
+ 목록이다. 빈 문자열이면 선언이 없는 것이고 절을 싣지 않는다.
139
+
140
+ `technical_evidence_text` 는 carry-in 이 technical-verification 결과일 때
141
+ `technical_evidence_packet_block` 이 렌더한 증거 위치 절이다.
133
142
  """
134
143
  brief_text = task_brief_path.read_text(encoding="utf-8")
135
144
  group_context_text = _read_optional(group_context_path) if group_context_path else ""
@@ -150,6 +159,9 @@ def build_analysis_packet(
150
159
  has_selected_direction,
151
160
  )
152
161
  )
162
+ # 짧은 포인터 절이라 46KB 에서 잘리는 읽기에서도 도착하도록 브리프보다 앞에 둔다.
163
+ if technical_evidence_text:
164
+ parts.extend(["", technical_evidence_text])
153
165
  # 그룹 문서는 사람 절과 okstra 메모리 영역으로 갈라 싣는다. 사람 절("왜")은
154
166
  # 브리프 발췌보다 앞 — 짧고, 46KB 에서 잘리는 읽기(agy view_file)에서도
155
167
  # 도착해야 한다. 형제 task 의 결론은 브리프 뒤 — 브리프를 밀어내면 안 된다.
@@ -158,6 +170,8 @@ def build_analysis_packet(
158
170
  )
159
171
  parts.extend(_group_context_block(group_human))
160
172
  parts.extend(_brief_block(brief_text))
173
+ if mcp_servers_text:
174
+ parts.extend(["", "## Available MCP Servers", "", mcp_servers_text])
161
175
  if direct_work_text:
162
176
  parts.extend(["", direct_work_text, ""])
163
177
  parts.extend(_group_memory_block(
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import re
5
6
  from dataclasses import asdict, replace
6
7
  from datetime import datetime, timezone
7
8
  from pathlib import Path
@@ -197,10 +198,15 @@ def _accept_return(call: PreparedCall, returned: bytes) -> str:
197
198
  ).returned_body
198
199
 
199
200
 
201
+ _FENCED_JSON = re.compile(r"^```json\n(.*?)\n```$", re.MULTILINE | re.DOTALL)
202
+
203
+
200
204
  def _parse_result(body: str, path: Path) -> BusinessResult:
201
205
  text = body.strip()
202
- if text.startswith("```json\n") and text.endswith("\n```"):
203
- text = text[8:-4]
206
+ # 조사자는 "JSON만 반환" 지시에도 객체를 짧은 서문·맺음말과 함께 펜스로 감싸 돌려준다.
207
+ fenced = _FENCED_JSON.findall(text)
208
+ if len(fenced) == 1:
209
+ text = fenced[0]
204
210
  try:
205
211
  result = json.loads(text)
206
212
  except json.JSONDecodeError as exc:
@@ -379,6 +385,8 @@ def _instructions(
379
385
  "Return only one JSON object matching the schema below.",
380
386
  "Read the listed source roots and source artifacts only. Do not edit files, execute tests, start services, deploy or migrate.",
381
387
  "Inspect inbound AND outbound dependencies across all candidates, including indirect call/event/queue/shared-data/file/batch relationships. Name every uninspected candidate or missing branch in gaps.",
388
+ "In relationships, from and to must each be a projectId listed under projects in the Source Material; put modules, files and planned work in data, not in from/to.",
389
+ "Cite evidence only from files Git tracks or would add in a candidate root. Git-ignored files (for example .project-docs/) are outside the captured source inventory and are rejected; name what you learned from them in gaps instead.",
382
390
  "Explain the product business from start to finish for a new developer: purpose, rules, input/output, states, failure, retry and recovery. Link each step and relationship to factKeys.",
383
391
  "Use stable keys in business/subject/attribute form. Facts are source-backed assertions; expected changes remain expected. Cite inspected evidence with one-based line/endLine; each excerpt must copy the whole line..endLine range verbatim, including indentation, with no elision or omitted lines. Tests are static evidence unless a supplied existing verification record supports the execution claim.",
384
392
  "For post mode, use the supplied project-specific baseline. Never invent missing before-state. Describe the actual delta and distinguish confirmed side effects from risks.",
@@ -2,23 +2,46 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
+ import subprocess
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
8
9
  from .convergence import canonical_run_state_artifact, validated_run_authority
9
10
  from .correction_round import CorrectionRoundError, correction_instruction
11
+ from .file_size_cap import FILE_LINE_LIMIT, FILE_LINE_TARGET, file_size_violations
10
12
 
11
13
 
12
14
  _CLI_EPILOG = r"""Usage:
13
15
  okstra correction prompt --run-manifest <path> --round <N> --role <executor|verifier> \
14
- --base <sha> --finding <F-id> [--finding <F-id> ...]
16
+ --base <sha> [--finding <F-id> ...] [--stage-base <sha>]
17
+ okstra correction file-size --base <stage base sha> [--head <rev>] [--repo <path>]
15
18
 
16
- Prints the instruction body for one correction-round prompt; pass it to
19
+ `prompt` prints the instruction body for one correction-round prompt; pass it to
17
20
  `okstra agent-prompt materialize --instruction` with --dispatch-kind correction-r<N>.
18
- Findings are read from the run's convergence record. Exit 1 on a refusal.
21
+ Findings are read from the run's convergence record; --stage-base adds the file-size
22
+ findings of `<stage-base>..<base>` (the 400-line limit only). Exit 1 on a refusal.
23
+
24
+ `file-size` prints one `FILE-SIZE:<path>:401` line per file the stage diff leaves over
25
+ the 400-line limit (blocking) and one `FILE-SIZE-ADVISORY:<path>:301` line per file it
26
+ leaves over only the 300-line target, then `QA-RESULT: PASS|FAIL`; only a limit line
27
+ fails. Exit 1 on FAIL, 2 when git cannot read the range.
19
28
  """
20
29
 
21
30
 
31
+ def _file_size(args: argparse.Namespace) -> int:
32
+ try:
33
+ violations = file_size_violations(Path(args.repo), args.base, args.head)
34
+ except subprocess.CalledProcessError as exc:
35
+ print(f"error: git: {exc.stderr.decode(errors='replace').strip()}", file=sys.stderr)
36
+ return 2
37
+ for violation in violations:
38
+ print(violation.line())
39
+ failed = any(v.blocking for v in violations)
40
+ print(f"QA-RESULT: {'FAIL' if failed else 'PASS'} "
41
+ f"(file-size limit {FILE_LINE_LIMIT}, target {FILE_LINE_TARGET})")
42
+ return 1 if failed else 0
43
+
44
+
22
45
  def main(argv: list[str] | None = None) -> int:
23
46
  parser = argparse.ArgumentParser(
24
47
  prog="okstra correction",
@@ -33,9 +56,17 @@ def main(argv: list[str] | None = None) -> int:
33
56
  prompt.add_argument("--role", choices=("executor", "verifier"), required=True)
34
57
  prompt.add_argument("--base", required=True,
35
58
  help="the stage HEAD before this round")
36
- prompt.add_argument("--finding", action="append", default=[], required=True,
59
+ prompt.add_argument("--finding", action="append", default=[],
37
60
  help="a convergence finding id to fix; repeat for several")
61
+ prompt.add_argument("--stage-base", default="",
62
+ help="the stage base commit; adds the stage diff's file-size limit findings")
63
+ size = commands.add_parser("file-size", help="check the stage diff against the file-size limit and target")
64
+ size.add_argument("--base", required=True, help="the stage base commit")
65
+ size.add_argument("--head", default="HEAD")
66
+ size.add_argument("--repo", default=".", help="the stage worktree (default: cwd)")
38
67
  args = parser.parse_args(argv)
68
+ if args.command == "file-size":
69
+ return _file_size(args)
39
70
 
40
71
  try:
41
72
  authority = validated_run_authority(args.run_manifest)
@@ -45,10 +76,18 @@ def main(argv: list[str] | None = None) -> int:
45
76
  authority, manifest_field="convergenceStatePath", prefix="convergence",
46
77
  label="convergence state",
47
78
  )
79
+ size_violations = [
80
+ v for v in file_size_violations(Path.cwd(), args.stage_base, args.base)
81
+ if v.blocking
82
+ ] if args.stage_base else []
48
83
  print(correction_instruction(
49
84
  convergence_path=convergence_path, round_number=args.round, role=args.role,
50
- base=args.base, finding_ids=args.finding,
85
+ base=args.base, finding_ids=args.finding, stage_base=args.stage_base,
86
+ size_violations=size_violations,
51
87
  ))
88
+ except subprocess.CalledProcessError as exc:
89
+ print(f"error: git: {exc.stderr.decode(errors='replace').strip()}", file=sys.stderr)
90
+ return 1
52
91
  except (CorrectionRoundError, OSError, ValueError) as exc:
53
92
  print(f"error: {exc}", file=sys.stderr)
54
93
  return 1
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Mapping, Sequence
15
15
 
16
16
  from .execution_identity import ExecutionManifest
17
+ from .file_size_cap import FILE_LINE_LIMIT, FileSizeViolation
17
18
  from .json_boundary import load_owned_object
18
19
 
19
20
  MAX_CORRECTION_ROUNDS = 2
@@ -101,17 +102,11 @@ def _finding_lines(findings: Sequence[Mapping[str, object]]) -> list[str]:
101
102
  return lines
102
103
 
103
104
 
104
- def correction_instruction(
105
- *, convergence_path: Path, round_number: int, role: str, base: str,
106
- finding_ids: Sequence[str],
107
- ) -> str:
108
- """The instruction body one correction-round prompt carries, read from the convergence record."""
109
- if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
110
- raise CorrectionRoundError(f"--round must be 1..{MAX_CORRECTION_ROUNDS}")
105
+ def _convergence_findings(
106
+ convergence_path: Path, finding_ids: Sequence[str],
107
+ ) -> list[Mapping[str, object]]:
111
108
  if not finding_ids:
112
- raise CorrectionRoundError("name at least one --finding")
113
- if not base.strip():
114
- raise CorrectionRoundError("--base is required: the stage HEAD before this round")
109
+ return []
115
110
  record = load_owned_object(convergence_path, artifact="convergence record")
116
111
  by_id = {
117
112
  str(row.get("findingId")): row for row in record.get("findings") or []
@@ -123,14 +118,40 @@ def correction_instruction(
123
118
  f"findings not in {convergence_path.name}: {', '.join(missing)}; "
124
119
  f"it holds {', '.join(sorted(by_id)) or 'none'}"
125
120
  )
126
- findings = [by_id[fid] for fid in finding_ids]
121
+ return [by_id[fid] for fid in finding_ids]
122
+
123
+
124
+ def correction_instruction(
125
+ *, convergence_path: Path, round_number: int, role: str, base: str,
126
+ finding_ids: Sequence[str], stage_base: str = "",
127
+ size_violations: Sequence[FileSizeViolation] = (),
128
+ ) -> str:
129
+ """The instruction body one correction-round prompt carries.
130
+
131
+ Verifier findings are read from the convergence record. File-size findings come from
132
+ `file_size_cap.file_size_violations` over `stage_base..base`, so they need no verifier
133
+ to have reported them.
134
+ """
135
+ if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
136
+ raise CorrectionRoundError(f"--round must be 1..{MAX_CORRECTION_ROUNDS}")
137
+ if not finding_ids and not size_violations:
138
+ raise CorrectionRoundError(
139
+ "name at least one --finding, or pass --stage-base for a stage whose diff "
140
+ "breaks the 400-line file-size limit"
141
+ )
142
+ if not base.strip():
143
+ raise CorrectionRoundError("--base is required: the stage HEAD before this round")
144
+ findings = _convergence_findings(convergence_path, finding_ids)
127
145
  results_dir = convergence_path.parent.parent / "worker-results"
146
+ stage_base_ref = stage_base.strip() or "<the stage base>"
128
147
  header = [
129
148
  f"- Convergence record: `{convergence_path}`",
130
149
  f"- Verifier results (each finding's suggested fix): `{results_dir}`",
131
150
  f"- Stage HEAD before this round: `{base.strip()}`",
151
+ *([f"- Stage base (file-size limit): `{stage_base.strip()}`"] if stage_base.strip() else []),
132
152
  "- Findings:",
133
153
  *(" " + line for line in _finding_lines(findings)),
154
+ *(f" - `{v.finding_id}` (file-size limit; okstra): {v.line()}" for v in size_violations),
134
155
  "",
135
156
  ]
136
157
  if role == "executor":
@@ -144,6 +165,11 @@ def correction_instruction(
144
165
  "dependency, or a schema or migration change is not fixed here: stop and say so.",
145
166
  "- Update a test's expected value only where the finding shows the oracle itself is "
146
167
  "wrong; never weaken or delete an assertion to make a test pass.",
168
+ f"- A `FILE-SIZE:` finding is fixed by moving whole responsibilities (a group of "
169
+ f"functions, a class, a `describe` block) into new files beside the oversized one; "
170
+ f"those files count as inside this stage. Never compress code or drop comments to "
171
+ f"meet the number. Done when `okstra correction file-size --base {stage_base_ref}` "
172
+ f"prints `QA-RESULT: PASS` (limit {FILE_LINE_LIMIT} lines; aim for 300).",
147
173
  "- Follow the executor sidecar's TDD, pre-commit diff review and self-check, and "
148
174
  "commit with a Conventional Commit whose body names the finding ids.",
149
175
  "- Re-run the plan validation commands that cover the fix, and end with a "
@@ -161,6 +187,9 @@ def correction_instruction(
161
187
  "the stage was reviewed in the initial round.",
162
188
  "- Re-run every plan validation command, as the verifier sidecar's Fix-run "
163
189
  "incremental scope requires.",
190
+ f"- Run `okstra correction file-size --base {stage_base_ref}` against the stage base, "
191
+ "not this round's base: each `FILE-SIZE:` line it prints is a blocking finding, and each "
192
+ "`FILE-SIZE-ADVISORY:` line a recommendation.",
164
193
  "- Verdict `PASS` when every finding is fixed and no blocking check hits the "
165
194
  "correction diff; otherwise `FAIL`, with each remaining or new finding in the "
166
195
  "usual format.",
@@ -0,0 +1,43 @@
1
+ """provider 어댑터와 핵심 판정이 함께 쓰는 벤더 중립 MCP 목록 값."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ McpServerState = Literal["enabled", "disabled", "needs-auth"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class VisibleMcpServer:
13
+ name: str
14
+ state: McpServerState
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class McpInventory:
19
+ """``listed`` 가 False 면 목록 자체를 못 얻은 것이라 선언 서버를 ``missing``
20
+ 이 아니라 ``unknown`` 으로 판정한다."""
21
+
22
+ listed: bool
23
+ servers: tuple[VisibleMcpServer, ...] = ()
24
+ note: str = ""
25
+
26
+ @classmethod
27
+ def unavailable(cls, note: str) -> McpInventory:
28
+ return cls(listed=False, note=note)
29
+
30
+
31
+ McpInventoryParser = Callable[[str], McpInventory]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class McpInventoryProbe:
36
+ """추론 호출 없이 MCP 서버를 나열하는 명령과 그 출력 파서.
37
+
38
+ 실행은 어댑터가 아니라 핵심 코드가 맡아 모든 provider 에 같은 타임아웃과
39
+ cwd(프로젝트 루트)를 적용한다.
40
+ """
41
+
42
+ argv: tuple[str, ...]
43
+ parse: McpInventoryParser
@@ -5,6 +5,7 @@ from collections.abc import Callable
5
5
  from dataclasses import dataclass, field
6
6
  from typing import TYPE_CHECKING, Literal, Mapping, Optional, Protocol
7
7
 
8
+ from .mcp import McpInventoryProbe
8
9
  from .role import RoleCatalogError, normalize_role, ROLE_CATALOG
9
10
  from .worker_exec import WorkerWriteCapability
10
11
 
@@ -195,6 +196,7 @@ class ProviderSpec:
195
196
  exec_strategy: Optional["ExecutionStrategy"] = None
196
197
  execution_normalizer: Optional[ExecutionNormalizer] = None
197
198
  served_model_normalizer: ServedModelNormalizer = unavailable_served_model
199
+ mcp_inventory: Optional[McpInventoryProbe] = None
198
200
  _legacy_role_restriction: frozenset[str] = field(
199
201
  init=False,
200
202
  repr=False,
@@ -33,7 +33,13 @@ class WizardPrompt:
33
33
 
34
34
  @dataclass(frozen=True)
35
35
  class AnswerProtocol:
36
- kind: Literal["exact-value", "numbered", "group-json", "numbered-group-json"]
36
+ kind: Literal[
37
+ "exact-value",
38
+ "numbered",
39
+ "selected-labels",
40
+ "group-json",
41
+ "numbered-group-json",
42
+ ]
37
43
  multi: bool = False
38
44
 
39
45
 
@@ -120,6 +126,76 @@ def _normalize_numbered_answer(
120
126
  )
121
127
 
122
128
 
129
+ _LABEL_SEPARATOR = ", "
130
+
131
+
132
+ def labels_decode_uniquely(labels: tuple[str, ...]) -> bool:
133
+ """이 라벨들을 `", "` 로 이은 어떤 선택이든 한 가지로만 되쪼개지는가.
134
+
135
+ 네이티브 다중 선택 호스트는 고른 라벨을 `", "` 로 이어 돌려주는데 라벨 자체도
136
+ `", "` 를 품을 수 있다. 선택 문자열은 `label + ", "` 낱말의 연접이므로 그
137
+ 낱말 집합에 대한 Sardinas-Patterson 유일 복호 판정이다.
138
+ """
139
+ words = {label + _LABEL_SEPARATOR for label in labels}
140
+ if len(words) != len(labels):
141
+ return False
142
+
143
+ def dangling(prefixes: set[str], strings: set[str]) -> set[str]:
144
+ return {
145
+ string[len(prefix):]
146
+ for prefix in prefixes
147
+ for string in strings
148
+ if string != prefix and string.startswith(prefix)
149
+ }
150
+
151
+ seen: set[str] = set()
152
+ suffixes = dangling(words, words)
153
+ while suffixes:
154
+ if suffixes & words:
155
+ return False
156
+ seen |= suffixes
157
+ suffixes = (dangling(words, suffixes) | dangling(suffixes, words)) - seen
158
+ return True
159
+
160
+
161
+ def _normalize_selected_labels(prompt: WizardPrompt, answer: str) -> str:
162
+ text = answer or ""
163
+ if not text.strip():
164
+ return ""
165
+ target = text + _LABEL_SEPARATOR
166
+ words = tuple(option.label + _LABEL_SEPARATOR for option in prompt.options)
167
+ # ways[i] 는 target[i:] 를 쪼개는 방법 수(2 에서 멈춤), first[i] 는 방법이
168
+ # 하나일 때 그 첫 옵션 위치다.
169
+ ways = [0] * len(target) + [1]
170
+ first = [-1] * (len(target) + 1)
171
+ for start in range(len(target) - 1, -1, -1):
172
+ for index, word in enumerate(words):
173
+ if target.startswith(word, start) and ways[start + len(word)]:
174
+ ways[start] = min(2, ways[start] + ways[start + len(word)])
175
+ first[start] = index
176
+ if ways[0] == 0:
177
+ values = {option.value for option in prompt.options}
178
+ # 라벨 대신 값 CSV 를 낸 제출(직접 CLI 입력, 값으로 바꿔 내는 리드)도 받는다.
179
+ if all(item.strip() in values for item in text.split(",")):
180
+ return ",".join(item.strip() for item in text.split(","))
181
+ raise WizardAnswerError(
182
+ f"multi-select answer is not a {_LABEL_SEPARATOR!r}-joined list "
183
+ f"of option labels: {text}"
184
+ )
185
+ if ways[0] > 1:
186
+ raise WizardAnswerError(f"multi-select answer labels are ambiguous: {text}")
187
+ selected: set[int] = set()
188
+ position = 0
189
+ while position < len(target):
190
+ selected.add(first[position])
191
+ position += len(words[first[position]])
192
+ return ",".join(
193
+ option.value
194
+ for index, option in enumerate(prompt.options)
195
+ if index in selected
196
+ )
197
+
198
+
123
199
  def _group_answers(answer: str) -> dict[str, object]:
124
200
  try:
125
201
  answers = json.loads(answer or "{}")
@@ -137,19 +213,22 @@ def _normalize_group_answer(
137
213
  numbered: bool,
138
214
  ) -> str:
139
215
  answers = _group_answers(answer)
140
- if not numbered:
141
- return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
142
216
  questions = {question.step: question for question in prompt.questions}
143
217
  normalized = dict(answers)
144
218
  for step, raw_answer in answers.items():
145
219
  question = questions.get(step)
146
220
  if question is None:
147
221
  continue
148
- normalized[step] = _normalize_numbered_answer(
149
- question,
150
- str(raw_answer or ""),
151
- multi=question.multi,
152
- )
222
+ if numbered:
223
+ normalized[step] = _normalize_numbered_answer(
224
+ question,
225
+ str(raw_answer or ""),
226
+ multi=question.multi,
227
+ )
228
+ elif question.multi:
229
+ normalized[step] = _normalize_selected_labels(
230
+ question, str(raw_answer or "")
231
+ )
153
232
  return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
154
233
 
155
234
 
@@ -162,6 +241,8 @@ def normalize_planned_answer(
162
241
  return answer
163
242
  if protocol.kind == "numbered":
164
243
  return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
244
+ if protocol.kind == "selected-labels":
245
+ return _normalize_selected_labels(prompt, answer)
165
246
  if protocol.kind in {"group-json", "numbered-group-json"}:
166
247
  return _normalize_group_answer(
167
248
  prompt,