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
@@ -21,7 +21,6 @@ from pathlib import Path
21
21
  from typing import Mapping
22
22
 
23
23
  from okstra_ctl.convergence_engine import grouped_input_digest
24
- from okstra_ctl.correction_round import correction_round
25
24
  from okstra_ctl.execution_identity import Attempt, ExecutionManifest
26
25
  from okstra_ctl.execution_manifest import read_execution_manifest_view
27
26
  from okstra_ctl.json_boundary import (
@@ -30,10 +29,16 @@ from okstra_ctl.json_boundary import (
30
29
  from okstra_ctl.worker_prompt_policy import CENSUS_GAPFILL_DISPATCH_KIND
31
30
 
32
31
  GROUPS_BASENAME_RE = re.compile(
33
- r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
32
+ r"^convergence-groups-(?:(?P<scope>correction-r[1-9][0-9]*)-)?"
33
+ r"(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
34
34
  )
35
35
 
36
36
 
37
+ def scoped_suffix(suffix: str, scope: str | None) -> str:
38
+ """`implementation-001` in the initial namespace, `correction-r1-implementation-001` in round 1's."""
39
+ return f"{scope}-{suffix}" if scope else suffix
40
+
41
+
37
42
  def _nonempty_string(value: object) -> bool:
38
43
  return isinstance(value, str) and bool(value.strip())
39
44
 
@@ -81,7 +86,9 @@ def group_claims(document: object) -> list[tuple[str, str, str]] | None:
81
86
  return claims
82
87
 
83
88
 
84
- def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
89
+ def groups_digest_ok(
90
+ state_dir: Path, suffix: str, document: dict, scope: str | None = None,
91
+ ) -> bool:
85
92
  """False only when a recorded groupsDigest exists and no longer matches.
86
93
 
87
94
  ``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
@@ -90,7 +97,7 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
90
97
  evidence of fabrication — so the caller refuses to judge it. A missing
91
98
  working state or missing digest means judge the file as parsed.
92
99
  """
93
- work_path = state_dir / f"convergence-work-{suffix}.json"
100
+ work_path = state_dir / f"convergence-work-{scoped_suffix(suffix, scope)}.json"
94
101
  try:
95
102
  work = load_owned_object(work_path, artifact="convergence work state")
96
103
  except (OSError, ValueError):
@@ -101,11 +108,14 @@ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
101
108
  return grouped_input_digest(document) == recorded
102
109
 
103
110
 
104
- def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
105
- """교정 실행과 수정 라운드 재검증은 발견 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
111
+ def discovery_attempts(
112
+ manifest: ExecutionManifest, scope: str | None = None,
113
+ ) -> dict[str, list[Attempt]]:
114
+ """한 수렴 범위의 발견 결과. 교정 실행은 초기 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
106
115
 
107
- 수정 라운드(`correction-r<N>`) 뒤 수렴을 다시 시작하면 마지막 라운드의 verifier 결과가
108
- 출처여야 한다. 빠지면 고치기 전 FAIL 결과로 다시 수렴한다.
116
+ 수정 라운드 N 의 재수렴(`scope` = `correction-r<N>`)은 그 라운드의 재검증 결과만 읽고, 초기
117
+ 수렴은 초기 결과만 읽는다. 하나로 섞으면 이미 끝난 초기 grouping 의 출처가 라운드 결과로
118
+ 바뀌어 그 grouping 이 출처 검사에서 깨진다.
109
119
  """
110
120
  latest: dict[str, Attempt] = {}
111
121
  for attempt in manifest.attempts:
@@ -114,8 +124,8 @@ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
114
124
  latest[attempt.invocation_ref] = attempt
115
125
  by_role: dict[str, list[Attempt]] = {}
116
126
  for invocation in manifest.invocations:
117
- if (invocation.dispatch_kind not in {"initial", "correction"}
118
- and correction_round(invocation.dispatch_kind) is None):
127
+ kind = invocation.dispatch_kind
128
+ if (kind != scope if scope else kind not in {"initial", "correction"}):
119
129
  continue
120
130
  attempt = latest.get(invocation.invocation_ref)
121
131
  if attempt is not None:
@@ -124,13 +134,13 @@ def discovery_attempts(manifest: ExecutionManifest) -> dict[str, list[Attempt]]:
124
134
 
125
135
 
126
136
  def worker_result_paths(
127
- run_dir: Path, document: Mapping, suffix: str
137
+ run_dir: Path, document: Mapping, suffix: str, scope: str | None = None,
128
138
  ) -> dict[str, Path]:
129
139
  """실행 원장의 채택된 발견 결과를 고른다. 구형 원장은 파일명 규칙을 쓴다."""
130
140
  results_dir = run_dir / "worker-results"
131
141
  workers = document.get("workers") or []
132
142
  paths = {
133
- row["workerId"]: results_dir / f"{row['workerId']}-worker-{suffix}.md"
143
+ row["workerId"]: results_dir / f"{row['workerId']}-worker-{scoped_suffix(suffix, scope)}.md"
134
144
  for row in workers if isinstance(row, Mapping) and row.get("workerId")
135
145
  }
136
146
  if document.get("schemaVersion") != "2.0":
@@ -142,7 +152,7 @@ def worker_result_paths(
142
152
  manifest, authority = read_execution_manifest_view(manifest_path)
143
153
  if manifest.legacy:
144
154
  return paths
145
- attempts = discovery_attempts(manifest)
155
+ attempts = discovery_attempts(manifest, scope)
146
156
  for row in workers:
147
157
  if not isinstance(row, Mapping) or not row.get("workerId"):
148
158
  continue
@@ -208,6 +218,7 @@ def provenance_errors(
208
218
  worker_results_dir: Path,
209
219
  suffix: str,
210
220
  groups_label: str,
221
+ scope: str | None = None,
211
222
  ) -> list[str]:
212
223
  """Every claim in ``document`` whose cited ID is absent from its worker's
213
224
  result file. An unjudgeable document yields no errors — a caller that
@@ -215,22 +226,23 @@ def provenance_errors(
215
226
  claims = group_claims(document)
216
227
  if claims is None:
217
228
  return []
218
- paths = worker_result_paths(worker_results_dir.parent, document, suffix)
229
+ paths = worker_result_paths(worker_results_dir.parent, document, suffix, scope)
230
+ result_suffix = scoped_suffix(suffix, scope)
219
231
  contents: dict[str, str | None] = {}
220
232
  errors: list[str] = []
221
233
  for finding_id, worker, item_id in claims:
222
234
  if worker not in contents:
223
235
  candidate = paths.get(worker)
224
- if candidate is not None and candidate.name != f"{worker}-worker-{suffix}.md":
236
+ if candidate is not None and candidate.name != f"{worker}-worker-{result_suffix}.md":
225
237
  contents[worker] = candidate.read_text(encoding="utf-8")
226
238
  else:
227
239
  contents[worker] = read_canonical_worker_result(
228
- worker_results_dir, worker, suffix
240
+ worker_results_dir, worker, result_suffix
229
241
  )
230
242
  text = contents[worker]
231
243
  if text is None or id_occurs_wordbounded(text, item_id):
232
244
  continue
233
- gapfill = read_canonical_worker_result(
245
+ gapfill = None if scope else read_canonical_worker_result(
234
246
  worker_results_dir, worker, f"{CENSUS_GAPFILL_DISPATCH_KIND}-{suffix}"
235
247
  )
236
248
  # A finding the census gap-fill raised lives in that worker's gap-fill
@@ -240,7 +252,7 @@ def provenance_errors(
240
252
  errors.append(
241
253
  f"convergence groups `{groups_label}` group {finding_id} "
242
254
  f"cites source item `{worker}:{item_id}`, but that ID does not "
243
- f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{suffix}.md')).name}` — a "
255
+ f"occur in the worker's result file `{paths.get(worker, Path(f'{worker}-worker-{result_suffix}.md')).name}` — a "
244
256
  "grouping may not invent a provenance link to an item the "
245
257
  "worker never reported."
246
258
  )
@@ -297,7 +309,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
297
309
  match = GROUPS_BASENAME_RE.match(groups_path.name)
298
310
  if match is None:
299
311
  continue
300
- suffix = match.group("suffix")
312
+ suffix, scope = match.group("suffix"), match.group("scope")
301
313
  try:
302
314
  document = load_owned_object(
303
315
  groups_path, artifact="convergence groups input"
@@ -306,7 +318,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
306
318
  continue
307
319
  if not isinstance(document, dict):
308
320
  continue
309
- if not groups_digest_ok(state_dir, suffix, document):
321
+ if not groups_digest_ok(state_dir, suffix, document, scope):
310
322
  continue
311
323
  result_suffix = worker_result_suffix(Path(run_dir), document)
312
324
  if result_suffix is None:
@@ -317,6 +329,7 @@ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
317
329
  worker_results_dir=worker_results_dir,
318
330
  suffix=result_suffix,
319
331
  groups_label=groups_path.name,
332
+ scope=scope,
320
333
  )
321
334
  )
322
335
  return errors
@@ -329,7 +342,9 @@ DISCARDED_ATTEMPT_STATUSES = frozenset(
329
342
  )
330
343
 
331
344
 
332
- def discarded_workers(document: object, execution_manifest: object) -> dict[str, str]:
345
+ def discarded_workers(
346
+ document: object, execution_manifest: object, scope: str | None = None,
347
+ ) -> dict[str, str]:
333
348
  """``{workerId: "<invocationRef>#<attempt> <status>"}`` — 발견 attempt 가 전부
334
349
  폐기돼 채택할 결과가 없는 분석 워커.
335
350
 
@@ -341,7 +356,7 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
341
356
  return {}
342
357
  if execution_manifest is None or getattr(execution_manifest, "legacy", False):
343
358
  return {}
344
- attempts = discovery_attempts(execution_manifest)
359
+ attempts = discovery_attempts(execution_manifest, scope)
345
360
  result: dict[str, str] = {}
346
361
  for row in document["workers"]:
347
362
  if not isinstance(row, dict):
@@ -360,7 +375,9 @@ def discarded_workers(document: object, execution_manifest: object) -> dict[str,
360
375
  return result
361
376
 
362
377
 
363
- def discarded_worker_errors(document: object, execution_manifest: object) -> list[str]:
378
+ def discarded_worker_errors(
379
+ document: object, execution_manifest: object, scope: str | None = None,
380
+ ) -> list[str]:
364
381
  """Groups that cite a worker whose only initial attempts were discarded.
365
382
 
366
383
  The mutation audit discards a result by setting the attempt terminal status
@@ -371,7 +388,7 @@ def discarded_worker_errors(document: object, execution_manifest: object) -> lis
371
388
  3 named it ``originWorker`` — the ledger said "discarded" while convergence
372
389
  said "adopted".
373
390
  """
374
- discarded = discarded_workers(document, execution_manifest)
391
+ discarded = discarded_workers(document, execution_manifest, scope)
375
392
  if not discarded or not isinstance(document, dict):
376
393
  return []
377
394
  errors: list[str] = []
@@ -393,3 +410,31 @@ def discarded_worker_errors(document: object, execution_manifest: object) -> lis
393
410
  "as a new invocation and cite that result"
394
411
  )
395
412
  return errors
413
+
414
+
415
+ def missing_scope_results(
416
+ document: object, execution_manifest: object, scope: str | None,
417
+ ) -> list[str]:
418
+ """Analysis workers of a correction round's grouping with no accepted result from that round.
419
+
420
+ Without one the provenance check would read nothing and pass, and the round would converge
421
+ on items the worker never re-verified.
422
+ """
423
+ if scope is None or execution_manifest is None or getattr(execution_manifest, "legacy", False):
424
+ return []
425
+ if not isinstance(document, dict):
426
+ return []
427
+ attempts = discovery_attempts(execution_manifest, scope)
428
+ missing = sorted({
429
+ str(row.get("workerId"))
430
+ for row in document.get("workers") or []
431
+ if isinstance(row, dict) and row.get("audience") == "analysis"
432
+ and not any(
433
+ item.status == "ok" for item in attempts.get(row.get("sourceRoleExecutionRef"), [])
434
+ )
435
+ })
436
+ return [
437
+ f"`{worker}` has no accepted `{scope}` result; await that round's verifiers "
438
+ "before converging it"
439
+ for worker in missing
440
+ ]
@@ -136,6 +136,7 @@ def reverify_findings(
136
136
  *,
137
137
  project_root: Path,
138
138
  run_dir: Path,
139
+ scope: str | None = None,
139
140
  ) -> list[ReverifyFinding]:
140
141
  """계획 행의 finding 을 계획 순서대로, 원 워커의 실물 인용 위치와 함께."""
141
142
  row = plan_row_for_worker(plan, worker_id)
@@ -146,7 +147,9 @@ def reverify_findings(
146
147
  }
147
148
  result_paths = {
148
149
  result.worker_id: result.result_path
149
- for result in analyser_results(groups, project_root=project_root, run_dir=run_dir)
150
+ for result in analyser_results(
151
+ groups, project_root=project_root, run_dir=run_dir, scope=scope,
152
+ )
150
153
  }
151
154
  findings: list[ReverifyFinding] = []
152
155
  for finding_id in row["findingIds"]:
@@ -2,23 +2,48 @@
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
- from .convergence import canonical_run_state_artifact, validated_run_authority
9
- from .correction_round import CorrectionRoundError, correction_instruction
9
+ from .convergence import scoped_convergence_state, validated_run_authority
10
+ from .correction_round import CorrectionRoundError, convergence_scope, 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 convergence record that preceded the round: the run's
22
+ initial one for round 1, round N-1's re-convergence (`convergence-correction-r<N-1>-…`)
23
+ for round N; --stage-base adds the file-size
24
+ findings of `<stage-base>..<base>` (the 400-line limit only). Exit 1 on a refusal.
25
+
26
+ `file-size` prints one `FILE-SIZE:<path>:401` line per file the stage diff leaves over
27
+ the 400-line limit (blocking) and one `FILE-SIZE-ADVISORY:<path>:301` line per file it
28
+ leaves over only the 300-line target, then `QA-RESULT: PASS|FAIL`; only a limit line
29
+ fails. Exit 1 on FAIL, 2 when git cannot read the range.
19
30
  """
20
31
 
21
32
 
33
+ def _file_size(args: argparse.Namespace) -> int:
34
+ try:
35
+ violations = file_size_violations(Path(args.repo), args.base, args.head)
36
+ except subprocess.CalledProcessError as exc:
37
+ print(f"error: git: {exc.stderr.decode(errors='replace').strip()}", file=sys.stderr)
38
+ return 2
39
+ for violation in violations:
40
+ print(violation.line())
41
+ failed = any(v.blocking for v in violations)
42
+ print(f"QA-RESULT: {'FAIL' if failed else 'PASS'} "
43
+ f"(file-size limit {FILE_LINE_LIMIT}, target {FILE_LINE_TARGET})")
44
+ return 1 if failed else 0
45
+
46
+
22
47
  def main(argv: list[str] | None = None) -> int:
23
48
  parser = argparse.ArgumentParser(
24
49
  prog="okstra correction",
@@ -33,22 +58,36 @@ def main(argv: list[str] | None = None) -> int:
33
58
  prompt.add_argument("--role", choices=("executor", "verifier"), required=True)
34
59
  prompt.add_argument("--base", required=True,
35
60
  help="the stage HEAD before this round")
36
- prompt.add_argument("--finding", action="append", default=[], required=True,
61
+ prompt.add_argument("--finding", action="append", default=[],
37
62
  help="a convergence finding id to fix; repeat for several")
63
+ prompt.add_argument("--stage-base", default="",
64
+ help="the stage base commit; adds the stage diff's file-size limit findings")
65
+ size = commands.add_parser("file-size", help="check the stage diff against the file-size limit and target")
66
+ size.add_argument("--base", required=True, help="the stage base commit")
67
+ size.add_argument("--head", default="HEAD")
68
+ size.add_argument("--repo", default=".", help="the stage worktree (default: cwd)")
38
69
  args = parser.parse_args(argv)
70
+ if args.command == "file-size":
71
+ return _file_size(args)
39
72
 
40
73
  try:
41
74
  authority = validated_run_authority(args.run_manifest)
42
75
  if authority.task_type != "implementation":
43
76
  raise CorrectionRoundError("correction rounds exist only in implementation runs")
44
- convergence_path = canonical_run_state_artifact(
45
- authority, manifest_field="convergenceStatePath", prefix="convergence",
46
- label="convergence state",
47
- )
77
+ previous = args.round - 1 if args.round > 1 else None
78
+ convergence_path = scoped_convergence_state(authority, convergence_scope(previous))
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
@@ -31,6 +32,46 @@ def correction_round(dispatch_kind: str) -> int | None:
31
32
  return int(match.group(1)) if match else None
32
33
 
33
34
 
35
+ def convergence_scope(round_number: int | None) -> str | None:
36
+ """The convergence namespace of correction round N (`correction-r<N>`); None is the initial one.
37
+
38
+ Re-converging a round's re-verification reuses no initial-round artifact: its groups, working
39
+ and final state, round plans and reverify invocations carry this token, so the initial final
40
+ stays as written and its provenance keeps reading the initial results.
41
+ """
42
+ if round_number is None:
43
+ return None
44
+ if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
45
+ raise CorrectionRoundError(f"--correction-round must be 1..{MAX_CORRECTION_ROUNDS}")
46
+ return f"{CORRECTION_DISPATCH_KIND_PREFIX}{round_number}"
47
+
48
+
49
+ def correction_convergence_states(initial_final: Path) -> list[tuple[str, Path]]:
50
+ """Each correction round's re-convergence final written beside the run's initial one."""
51
+ prefix = "convergence-"
52
+ if not initial_final.name.startswith(prefix):
53
+ return []
54
+ rest = initial_final.name[len(prefix):]
55
+ rows = []
56
+ for round_number in range(1, MAX_CORRECTION_ROUNDS + 1):
57
+ scope = convergence_scope(round_number)
58
+ path = initial_final.with_name(f"{prefix}{scope}-{rest}")
59
+ if path.is_file():
60
+ rows.append((scope, path))
61
+ return rows
62
+
63
+
64
+ def reverify_invocation_in_scope(invocation_ref: str, scope: str | None, round_number: int) -> bool:
65
+ """Whether a `reverify-r<N>` invocation belongs to this convergence namespace.
66
+
67
+ Both namespaces dispatch kind `reverify-r<N>`, so the invocation id tells them apart: a
68
+ correction re-convergence names it `…-correction-r<M>-reverify-r<N>-<worker>`.
69
+ """
70
+ if scope is None:
71
+ return f"-{CORRECTION_DISPATCH_KIND_PREFIX}" not in invocation_ref
72
+ return f"-{scope}-reverify-r{round_number}-" in invocation_ref
73
+
74
+
34
75
  def validate_correction_dispatch(
35
76
  manifest: ExecutionManifest, jobs: Sequence[object],
36
77
  ) -> None:
@@ -101,17 +142,11 @@ def _finding_lines(findings: Sequence[Mapping[str, object]]) -> list[str]:
101
142
  return lines
102
143
 
103
144
 
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}")
145
+ def _convergence_findings(
146
+ convergence_path: Path, finding_ids: Sequence[str],
147
+ ) -> list[Mapping[str, object]]:
111
148
  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")
149
+ return []
115
150
  record = load_owned_object(convergence_path, artifact="convergence record")
116
151
  by_id = {
117
152
  str(row.get("findingId")): row for row in record.get("findings") or []
@@ -123,14 +158,40 @@ def correction_instruction(
123
158
  f"findings not in {convergence_path.name}: {', '.join(missing)}; "
124
159
  f"it holds {', '.join(sorted(by_id)) or 'none'}"
125
160
  )
126
- findings = [by_id[fid] for fid in finding_ids]
161
+ return [by_id[fid] for fid in finding_ids]
162
+
163
+
164
+ def correction_instruction(
165
+ *, convergence_path: Path, round_number: int, role: str, base: str,
166
+ finding_ids: Sequence[str], stage_base: str = "",
167
+ size_violations: Sequence[FileSizeViolation] = (),
168
+ ) -> str:
169
+ """The instruction body one correction-round prompt carries.
170
+
171
+ Verifier findings are read from the convergence record. File-size findings come from
172
+ `file_size_cap.file_size_violations` over `stage_base..base`, so they need no verifier
173
+ to have reported them.
174
+ """
175
+ if not 1 <= round_number <= MAX_CORRECTION_ROUNDS:
176
+ raise CorrectionRoundError(f"--round must be 1..{MAX_CORRECTION_ROUNDS}")
177
+ if not finding_ids and not size_violations:
178
+ raise CorrectionRoundError(
179
+ "name at least one --finding, or pass --stage-base for a stage whose diff "
180
+ "breaks the 400-line file-size limit"
181
+ )
182
+ if not base.strip():
183
+ raise CorrectionRoundError("--base is required: the stage HEAD before this round")
184
+ findings = _convergence_findings(convergence_path, finding_ids)
127
185
  results_dir = convergence_path.parent.parent / "worker-results"
186
+ stage_base_ref = stage_base.strip() or "<the stage base>"
128
187
  header = [
129
188
  f"- Convergence record: `{convergence_path}`",
130
189
  f"- Verifier results (each finding's suggested fix): `{results_dir}`",
131
190
  f"- Stage HEAD before this round: `{base.strip()}`",
191
+ *([f"- Stage base (file-size limit): `{stage_base.strip()}`"] if stage_base.strip() else []),
132
192
  "- Findings:",
133
193
  *(" " + line for line in _finding_lines(findings)),
194
+ *(f" - `{v.finding_id}` (file-size limit; okstra): {v.line()}" for v in size_violations),
134
195
  "",
135
196
  ]
136
197
  if role == "executor":
@@ -144,6 +205,11 @@ def correction_instruction(
144
205
  "dependency, or a schema or migration change is not fixed here: stop and say so.",
145
206
  "- Update a test's expected value only where the finding shows the oracle itself is "
146
207
  "wrong; never weaken or delete an assertion to make a test pass.",
208
+ f"- A `FILE-SIZE:` finding is fixed by moving whole responsibilities (a group of "
209
+ f"functions, a class, a `describe` block) into new files beside the oversized one; "
210
+ f"those files count as inside this stage. Never compress code or drop comments to "
211
+ f"meet the number. Done when `okstra correction file-size --base {stage_base_ref}` "
212
+ f"prints `QA-RESULT: PASS` (limit {FILE_LINE_LIMIT} lines; aim for 300).",
147
213
  "- Follow the executor sidecar's TDD, pre-commit diff review and self-check, and "
148
214
  "commit with a Conventional Commit whose body names the finding ids.",
149
215
  "- Re-run the plan validation commands that cover the fix, and end with a "
@@ -161,6 +227,9 @@ def correction_instruction(
161
227
  "the stage was reviewed in the initial round.",
162
228
  "- Re-run every plan validation command, as the verifier sidecar's Fix-run "
163
229
  "incremental scope requires.",
230
+ f"- Run `okstra correction file-size --base {stage_base_ref}` against the stage base, "
231
+ "not this round's base: each `FILE-SIZE:` line it prints is a blocking finding, and each "
232
+ "`FILE-SIZE-ADVISORY:` line a recommendation.",
164
233
  "- Verdict `PASS` when every finding is fixed and no blocking check hits the "
165
234
  "correction diff; otherwise `FAIL`, with each remaining or new finding in the "
166
235
  "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,