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
|
@@ -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<
|
|
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(
|
|
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(
|
|
105
|
-
|
|
111
|
+
def discovery_attempts(
|
|
112
|
+
manifest: ExecutionManifest, scope: str | None = None,
|
|
113
|
+
) -> dict[str, list[Attempt]]:
|
|
114
|
+
"""한 수렴 범위의 발견 결과. 교정 실행은 초기 결과를 대체하지만 재검증 투표는 발견 출처가 아니다.
|
|
106
115
|
|
|
107
|
-
수정 라운드(`correction-r<N>`)
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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-{
|
|
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,
|
|
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-{
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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> [--
|
|
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
|
-
|
|
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
|
|
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=[],
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|