okstra 0.200.0 → 0.201.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/README.md +4 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +7 -3
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/technical-verification-worker.md +44 -0
- package/runtime/prompts/launch.template.md +7 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
- package/runtime/prompts/lead/plan-body-verification.md +3 -1
- package/runtime/prompts/lead/report-writer.md +11 -5
- package/runtime/prompts/lead/team-contract.md +6 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/final-verification.md +5 -0
- package/runtime/prompts/profiles/forbidden-actions.json +6 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/technical-verification.md +53 -0
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
- package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
- package/runtime/python/okstra_ctl/conformance.py +2 -20
- package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
- package/runtime/python/okstra_ctl/domain/provider.py +0 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
- package/runtime/python/okstra_ctl/implementation_options.py +58 -45
- package/runtime/python/okstra_ctl/model_pool.py +2 -5
- package/runtime/python/okstra_ctl/next_phase.py +3 -0
- package/runtime/python/okstra_ctl/plan_items.py +15 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
- package/runtime/python/okstra_ctl/qa_commands.py +30 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_assembly.py +8 -2
- package/runtime/python/okstra_ctl/report_contract.py +3 -0
- package/runtime/python/okstra_ctl/report_corrections.py +209 -93
- package/runtime/python/okstra_ctl/report_finalize.py +25 -8
- package/runtime/python/okstra_ctl/report_html/router.py +2 -0
- package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
- package/runtime/python/okstra_ctl/report_projections.py +4 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
- package/runtime/python/okstra_ctl/run.py +82 -0
- package/runtime/python/okstra_ctl/team.py +4 -1
- package/runtime/python/okstra_ctl/technical_verification.py +195 -0
- package/runtime/python/okstra_ctl/usage_identity.py +54 -0
- package/runtime/python/okstra_ctl/usage_report.py +22 -8
- package/runtime/python/okstra_ctl/verification_target.py +74 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/render.py +8 -55
- package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
- package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
- package/runtime/python/okstra_ctl/wizard/state.py +13 -6
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -0
- package/runtime/python/okstra_ctl/write_policy.py +23 -0
- package/runtime/python/okstra_token_usage/blocks.py +50 -1
- package/runtime/python/okstra_token_usage/claude.py +42 -21
- package/runtime/python/okstra_token_usage/codex.py +17 -0
- package/runtime/python/okstra_token_usage/collect.py +299 -162
- package/runtime/python/okstra_token_usage/cursor.py +2 -3
- package/runtime/python/okstra_token_usage/report.py +35 -30
- package/runtime/python/okstra_token_usage/task_totals.py +3 -12
- package/runtime/schemas/final-report-v2.0.schema.json +298 -7
- package/runtime/schemas/final-report-v3.0.schema.json +298 -7
- package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
- package/runtime/skills/okstra-run/SKILL.md +10 -2
- package/runtime/skills/okstra-setup/SKILL.md +42 -7
- package/runtime/templates/report-writer-prompt-preamble.md +7 -3
- package/runtime/templates/reports/html/i18n/en.json +11 -0
- package/runtime/templates/reports/html/i18n/ko.json +11 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
- package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
- package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
- package/runtime/validators/validate-run.py +9 -4
|
@@ -17,9 +17,7 @@ import re
|
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
# 계약이 이것들로 무력화된다.
|
|
22
|
-
_CWD_CHANGING_COMMANDS: frozenset[str] = frozenset({"cd", "pushd", "popd", "chdir"})
|
|
20
|
+
from .qa_commands import verification_command_defects
|
|
23
21
|
|
|
24
22
|
# diff 가 건드린 표면과 대조할 capability 태그 화이트리스트.
|
|
25
23
|
CAPABILITY_WHITELIST: tuple[str, ...] = ("db", "io", "http", "external")
|
|
@@ -323,24 +321,8 @@ def _check_entry(entry: object, idx: int, errors: list[str]) -> None:
|
|
|
323
321
|
errors.append(f"{path}.script must live under the task qa scripts dir (qa/scripts/), got {script!r}")
|
|
324
322
|
_check_nonempty_str(entry.get("runCommand"), f"{path}.runCommand", errors)
|
|
325
323
|
run_command = entry.get("runCommand")
|
|
326
|
-
# 이 명령은 워크트리 cwd 에서 verbatim 실행되고(_implementation-verifier.md
|
|
327
|
-
# "Otherwise run runCommand in the worktree cwd"), **그 cwd 가 곧 검사 대상**이다.
|
|
328
|
-
# 스크립트·tsconfig 는 `.okstra/` 아래 사는데 워크트리에는 `.okstra/` 가 없으므로
|
|
329
|
-
# (implementation-worker-preamble.md "the worktree may not contain them")
|
|
330
|
-
# 그것들을 절대경로로 가리키는 것은 정상이고 사실상 필수다. 금지되는 것은 cwd
|
|
331
|
-
# 를 옮기는 일뿐이다 — 선행 `cd <메인 체크아웃>` 은 stage diff 가 없는 트리에서
|
|
332
|
-
# 검사를 돌려 미변경 코드를 통과시킨다.
|
|
333
324
|
if isinstance(run_command, str):
|
|
334
|
-
for
|
|
335
|
-
words = segment.split()
|
|
336
|
-
if words and words[0] in _CWD_CHANGING_COMMANDS:
|
|
337
|
-
errors.append(
|
|
338
|
-
f"{path}.runCommand must run in the worktree cwd — that cwd is "
|
|
339
|
-
f"the tree under test; a leading `{words[0]}` repoints it, so "
|
|
340
|
-
"the script checks whichever checkout it lands in instead of "
|
|
341
|
-
"this stage's diff"
|
|
342
|
-
)
|
|
343
|
-
break
|
|
325
|
+
errors.extend(f"{path}.runCommand {error}" for error in verification_command_defects(run_command))
|
|
344
326
|
_check_nonempty_str(entry.get("passContract"), f"{path}.passContract", errors)
|
|
345
327
|
req_ids = entry.get("requirementIds")
|
|
346
328
|
if (
|
|
@@ -271,6 +271,7 @@ class WorkerHandle:
|
|
|
271
271
|
write_policy_digest: str = ""
|
|
272
272
|
write_enforcement: WriteEnforcement | None = None
|
|
273
273
|
runtime_handle: RuntimeHandle | None = None
|
|
274
|
+
started_at: str = ""
|
|
274
275
|
|
|
275
276
|
|
|
276
277
|
@dataclass(frozen=True)
|
|
@@ -841,7 +842,8 @@ def await_dispatches(
|
|
|
841
842
|
) -> int:
|
|
842
843
|
_correct_teardown_marked_dispatches(plan)
|
|
843
844
|
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
|
|
844
|
-
last_heartbeat =
|
|
845
|
+
last_heartbeat: float | None = None
|
|
846
|
+
last_pending: tuple[str, ...] = ()
|
|
845
847
|
while True:
|
|
846
848
|
running = _running_dispatches(plan.team_state_path)
|
|
847
849
|
if not running:
|
|
@@ -853,9 +855,23 @@ def await_dispatches(
|
|
|
853
855
|
if deadline is not None and time.monotonic() >= deadline:
|
|
854
856
|
return 1
|
|
855
857
|
now = time.monotonic()
|
|
856
|
-
|
|
857
|
-
|
|
858
|
+
pending = tuple(sorted(
|
|
859
|
+
f"{row.get('invocationRef') or row.get('worker', '')}"
|
|
860
|
+
f":attempt={row.get('attempt', 1)}"
|
|
861
|
+
for row in running
|
|
862
|
+
))
|
|
863
|
+
# 같은 대기 출력은 작업 진행이 아니며 호스트가 재독할 내용만 늘린다.
|
|
864
|
+
if (
|
|
865
|
+
heartbeat_seconds > 0
|
|
866
|
+
and pending != last_pending
|
|
867
|
+
and (last_heartbeat is None or now - last_heartbeat >= heartbeat_seconds)
|
|
868
|
+
):
|
|
869
|
+
print(
|
|
870
|
+
f"WAITING {len(running)} worker dispatch(es): {', '.join(pending)}",
|
|
871
|
+
flush=True,
|
|
872
|
+
)
|
|
858
873
|
last_heartbeat = now
|
|
874
|
+
last_pending = pending
|
|
859
875
|
time.sleep(max(poll_interval_seconds, 0))
|
|
860
876
|
|
|
861
877
|
|
|
@@ -1579,6 +1595,7 @@ def _spawn_job(
|
|
|
1579
1595
|
def _spawn_cli_job_nonblocking(
|
|
1580
1596
|
plan: DispatchPlan, job: WorkerJob, attempt: int,
|
|
1581
1597
|
) -> WorkerHandle:
|
|
1598
|
+
started_at = _utc_now()
|
|
1582
1599
|
plan = _ensure_runtime_chain(plan)
|
|
1583
1600
|
try:
|
|
1584
1601
|
runtime = open_worker(
|
|
@@ -1596,6 +1613,7 @@ def _spawn_cli_job_nonblocking(
|
|
|
1596
1613
|
status_path_for_prompt(job.prompt_path),
|
|
1597
1614
|
"",
|
|
1598
1615
|
runtime_handle=runtime,
|
|
1616
|
+
started_at=started_at,
|
|
1599
1617
|
)
|
|
1600
1618
|
|
|
1601
1619
|
|
|
@@ -1921,6 +1939,7 @@ def _chain_starting_at(
|
|
|
1921
1939
|
|
|
1922
1940
|
|
|
1923
1941
|
def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
1942
|
+
started_at = _utc_now()
|
|
1924
1943
|
plan = _ensure_runtime_chain(plan)
|
|
1925
1944
|
try:
|
|
1926
1945
|
runtime = open_worker(
|
|
@@ -1946,6 +1965,7 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
|
|
|
1946
1965
|
status_path_for_prompt(settled.prompt_path),
|
|
1947
1966
|
degraded_from,
|
|
1948
1967
|
runtime_handle=runtime,
|
|
1968
|
+
started_at=started_at,
|
|
1949
1969
|
)
|
|
1950
1970
|
|
|
1951
1971
|
|
|
@@ -2812,7 +2832,7 @@ def _record_dispatch(
|
|
|
2812
2832
|
) -> None:
|
|
2813
2833
|
_append_worker_dispatch(
|
|
2814
2834
|
team_state_path,
|
|
2815
|
-
_dispatch_record(
|
|
2835
|
+
{**_dispatch_record(
|
|
2816
2836
|
handle.job,
|
|
2817
2837
|
attempt,
|
|
2818
2838
|
status,
|
|
@@ -2826,7 +2846,7 @@ def _record_dispatch(
|
|
|
2826
2846
|
),
|
|
2827
2847
|
write_policy_digest=handle.write_policy_digest,
|
|
2828
2848
|
write_enforcement=handle.write_enforcement,
|
|
2829
|
-
),
|
|
2849
|
+
), "startedAt": handle.started_at or _utc_now()},
|
|
2830
2850
|
)
|
|
2831
2851
|
|
|
2832
2852
|
|
|
@@ -454,10 +454,12 @@ def append_worker_dispatch(
|
|
|
454
454
|
isinstance(existing, Mapping)
|
|
455
455
|
and existing.get("dispatchId") == new_id
|
|
456
456
|
):
|
|
457
|
+
new_record["startedAt"] = existing.get("startedAt") or _utc_timestamp(None)
|
|
457
458
|
dispatches[index] = new_record
|
|
458
459
|
replaced = True
|
|
459
460
|
break
|
|
460
461
|
if not replaced:
|
|
462
|
+
new_record.setdefault("startedAt", _utc_timestamp(None))
|
|
461
463
|
dispatches.append(new_record)
|
|
462
464
|
write_json(team_state_path, payload)
|
|
463
465
|
|
|
@@ -47,6 +47,7 @@ DUTY_ROLE_IDS = {
|
|
|
47
47
|
"analysis-worker": "analyser",
|
|
48
48
|
"discovery-worker": "analyser",
|
|
49
49
|
"diagnosis-worker": "analyser",
|
|
50
|
+
"technical-verification-worker": "analyser",
|
|
50
51
|
"scope-critic": "critic",
|
|
51
52
|
"acceptance-critic": "critic",
|
|
52
53
|
"direction-selection-worker": "designer",
|
|
@@ -145,7 +145,7 @@ class ExecutionMutationAudit:
|
|
|
145
145
|
rows = tuple(policies)
|
|
146
146
|
root, artifact_root = _validate_batch(rows)
|
|
147
147
|
generated = _generated_paths(rows)
|
|
148
|
-
file_digests =
|
|
148
|
+
file_digests = source_content_snapshot(root, generated)
|
|
149
149
|
artifact_digests = (
|
|
150
150
|
file_digests
|
|
151
151
|
if artifact_root == root
|
|
@@ -360,6 +360,11 @@ final-verification 001).
|
|
|
360
360
|
"""
|
|
361
361
|
|
|
362
362
|
|
|
363
|
+
def source_content_snapshot(root: Path, excluded: frozenset[str] = frozenset()) -> dict[str, str]:
|
|
364
|
+
"""소스 감사와 검사 실행이 동일한 생성·무시 경로 제외 규칙을 사용한다."""
|
|
365
|
+
return _content_snapshot(root, excluded | _non_source_paths(root))
|
|
366
|
+
|
|
367
|
+
|
|
363
368
|
def _content_snapshot(root: Path, excluded: frozenset[str]) -> dict[str, str]:
|
|
364
369
|
"""감사 대상 트리의 내용 다이제스트. `excluded` 는 정책이 선언한 생성
|
|
365
370
|
경로와 레포가 무시하는 경로를 합친 접두사 집합이다."""
|
|
@@ -687,6 +687,7 @@ def _coverage_reference_errors(
|
|
|
687
687
|
step_ids: set[str],
|
|
688
688
|
validation_ids: set[str],
|
|
689
689
|
file_paths: tuple[str, ...],
|
|
690
|
+
dependencies: list[Mapping[str, Any]],
|
|
690
691
|
) -> list[str]:
|
|
691
692
|
failures: list[str] = []
|
|
692
693
|
reference_sets = {
|
|
@@ -694,11 +695,52 @@ def _coverage_reference_errors(
|
|
|
694
695
|
"stepRefs": step_ids,
|
|
695
696
|
"validationRefs": validation_ids,
|
|
696
697
|
"fileRefs": set(file_paths),
|
|
698
|
+
"crossProjectDependencyRefs": {row.get("id") for row in dependencies},
|
|
697
699
|
}
|
|
698
700
|
for row in rows:
|
|
699
701
|
requirement_id = str(row.get("originalRequirementId") or "<missing>")
|
|
702
|
+
external_refs = row.get("crossProjectDependencyRefs") or ()
|
|
703
|
+
if row.get("status") == "externally-tracked":
|
|
704
|
+
if not external_refs:
|
|
705
|
+
failures.append(
|
|
706
|
+
f"requirementCoverage {requirement_id} requires crossProjectDependencyRefs"
|
|
707
|
+
)
|
|
708
|
+
for ref in external_refs:
|
|
709
|
+
matches = [item for item in dependencies if item.get("id") == ref]
|
|
710
|
+
if (
|
|
711
|
+
len(matches) != 1
|
|
712
|
+
or any(
|
|
713
|
+
not isinstance(matches[0].get(field), str)
|
|
714
|
+
or not matches[0][field].strip()
|
|
715
|
+
for field in (
|
|
716
|
+
"project",
|
|
717
|
+
"requiredWork",
|
|
718
|
+
"verificationSignal",
|
|
719
|
+
"linkedWork",
|
|
720
|
+
"howToStart",
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
or matches[0].get("direction")
|
|
724
|
+
not in {"upstream-precondition", "downstream-carry"}
|
|
725
|
+
):
|
|
726
|
+
failures.append(
|
|
727
|
+
f"requirementCoverage {requirement_id} crossProjectDependencyRefs must resolve to one complete dependency: {ref}"
|
|
728
|
+
)
|
|
729
|
+
elif external_refs:
|
|
730
|
+
failures.append(
|
|
731
|
+
f"requirementCoverage {requirement_id} external references require externally-tracked status"
|
|
732
|
+
)
|
|
733
|
+
elif any(
|
|
734
|
+
not row.get(field)
|
|
735
|
+
for field in ("stageRefs", "stepRefs", "validationRefs", "fileRefs")
|
|
736
|
+
):
|
|
737
|
+
failures.append(
|
|
738
|
+
f"requirementCoverage {requirement_id} requires local stage, step, validation and file references"
|
|
739
|
+
)
|
|
700
740
|
for field, valid_values in reference_sets.items():
|
|
701
|
-
dangling = [
|
|
741
|
+
dangling = [
|
|
742
|
+
value for value in row.get(field) or () if value not in valid_values
|
|
743
|
+
]
|
|
702
744
|
if dangling:
|
|
703
745
|
failures.append(
|
|
704
746
|
f"requirementCoverage {requirement_id} {field} contains "
|
|
@@ -740,19 +782,33 @@ def _coverage_summary_errors(
|
|
|
740
782
|
stage_ids, step_ids, validation_ids, file_paths = _plan_reference_sets(planning)
|
|
741
783
|
failures.extend(
|
|
742
784
|
_coverage_reference_errors(
|
|
743
|
-
rows,
|
|
785
|
+
rows,
|
|
786
|
+
stage_ids,
|
|
787
|
+
step_ids,
|
|
788
|
+
validation_ids,
|
|
789
|
+
file_paths,
|
|
790
|
+
[
|
|
791
|
+
row
|
|
792
|
+
for row in planning.get("crossProjectDependencies") or ()
|
|
793
|
+
if isinstance(row, Mapping)
|
|
794
|
+
],
|
|
744
795
|
)
|
|
745
796
|
)
|
|
746
|
-
if
|
|
797
|
+
if failures:
|
|
747
798
|
return failures
|
|
748
|
-
|
|
799
|
+
# 검증된 외부 작업 연결은 계획 범위에만 포함하며 실제 완료 상태는 바꾸지 않는다.
|
|
800
|
+
statuses = {
|
|
801
|
+
row_id: "covered"
|
|
802
|
+
if row.get("status") == "externally-tracked"
|
|
803
|
+
else str(row.get("status") or "")
|
|
804
|
+
for row_id, row in zip(row_ids, rows)
|
|
805
|
+
}
|
|
749
806
|
stage_requirements = {
|
|
750
807
|
stage: _requirement_ids_for_scope(rows, "stageRefs", stage)
|
|
751
808
|
for stage in sorted(stage_ids)
|
|
752
809
|
}
|
|
753
810
|
file_requirements = {
|
|
754
|
-
path: _requirement_ids_for_scope(rows, "fileRefs", path)
|
|
755
|
-
for path in file_paths
|
|
811
|
+
path: _requirement_ids_for_scope(rows, "fileRefs", path) for path in file_paths
|
|
756
812
|
}
|
|
757
813
|
try:
|
|
758
814
|
result, unmapped_stages, unmapped_files = calculate_plan_exact_coverage(
|
|
@@ -761,7 +817,8 @@ def _coverage_summary_errors(
|
|
|
761
817
|
except ExactCoverageError as exc:
|
|
762
818
|
return [*failures, f"plan-ready requires exact 100% coverage: {exc}"]
|
|
763
819
|
failures.extend(
|
|
764
|
-
f"unmapped stage {stage} has no original requirement"
|
|
820
|
+
f"unmapped stage {stage} has no original requirement"
|
|
821
|
+
for stage in unmapped_stages
|
|
765
822
|
)
|
|
766
823
|
failures.extend(
|
|
767
824
|
f"unmapped file change {path} has no original requirement"
|
|
@@ -12,9 +12,12 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from .clarification_items.dispositions import (
|
|
14
14
|
USER_INPUT_BLOCKS,
|
|
15
|
-
|
|
15
|
+
clarification_disposition,
|
|
16
|
+
incorporated_clarification_ids,
|
|
17
|
+
row_blocks_progress,
|
|
16
18
|
)
|
|
17
19
|
from .exact_coverage import ExactCoverageError, calculate_exact_coverage
|
|
20
|
+
from .technical_verification import TechnicalVerificationError, technical_verification_facts
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
EVALUATION_CRITERIA = (
|
|
@@ -354,10 +357,10 @@ def _validate_option_count_and_routing(
|
|
|
354
357
|
if not valid_candidates:
|
|
355
358
|
if recommended is not None:
|
|
356
359
|
errors.append("recommendedOptionId must be null when no valid options exist")
|
|
357
|
-
if routing
|
|
360
|
+
if routing not in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
|
|
358
361
|
errors.append("routing must be blocked only when no valid options exist")
|
|
359
362
|
return
|
|
360
|
-
if routing
|
|
363
|
+
if routing in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
|
|
361
364
|
errors.append("routing may be blocked only when no valid options exist")
|
|
362
365
|
if not options:
|
|
363
366
|
errors.append("recommendedOptionId must name the first ranked option")
|
|
@@ -434,54 +437,64 @@ def vote_gaps(
|
|
|
434
437
|
def validate_blocked_answer_channel(
|
|
435
438
|
report_data: Mapping[str, object],
|
|
436
439
|
) -> list[str]:
|
|
437
|
-
"""
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
`clarificationItems[]` 하나뿐이다. 조립은 그 배열을 승인 결정 원장에서만
|
|
443
|
-
읽고(`report_assembly._clarifications`), `okstra user-response` 는 발행된
|
|
444
|
-
리포트의 그 배열만 읽는다(`user_response._record_clarification_rows`).
|
|
445
|
-
그래서 미해결 사실이 산문으로만 남으면 run 은 "사용자를 기다림" 상태로
|
|
446
|
-
발행되고 사용자에게는 답할 항목이 0건으로 보인다(2026-09-10 실측,
|
|
447
|
-
dev-10630: 후보 5개 전부 미해결 사실을 달고 원장은 빈 배열).
|
|
448
|
-
|
|
449
|
-
앞으로 가는 길은 원장에 행을 여는 것이다 —
|
|
450
|
-
`okstra approval-decision open --ledger <approvalDecisionsPath>` 를 행마다
|
|
451
|
-
한 번. 미해결 사실이 없는 차단(워커가 결과를 못 냈다 같은 실행 차단)은
|
|
452
|
-
사용자가 답할 것이 없으므로 이 검사에 걸리지 않는다.
|
|
453
|
-
|
|
454
|
-
`clarificationItems[]` 는 리드 소유라 작성자 서사에는 없다. 그래서 이
|
|
455
|
-
검사는 조립이 끝난 리포트 레코드 전체를 받고
|
|
456
|
-
`validate_implementation_option_selection`(작성자 소유 의미론) 과 따로
|
|
457
|
-
선다 — 교정 루프의 semantic validator 에 묶으면 원장에 행이 있어도 매번
|
|
458
|
-
발화한다.
|
|
440
|
+
"""사실별 해결 방식과 사용자 질문 연결을 검사하며 차단 상태는 보존한다.
|
|
441
|
+
|
|
442
|
+
구형 보고서의 분류 없는 사실은 사용자 결정으로 추정하지 않는다. 읽기
|
|
443
|
+
스키마는 수용하지만 재발행 전에 작성자가 분류를 보완해야 한다.
|
|
444
|
+
답변의 진행 차단 판정은 공통 처분 규칙을 그대로 사용한다.
|
|
459
445
|
"""
|
|
460
446
|
selection = report_data.get("implementationOptionSelection")
|
|
461
447
|
if not isinstance(selection, Mapping):
|
|
462
448
|
return []
|
|
463
|
-
if selection.get("routing")
|
|
464
|
-
return []
|
|
465
|
-
unresolved = [
|
|
466
|
-
str(candidate.get("id"))
|
|
467
|
-
for key in ("rankedOptions", "candidateAudit")
|
|
468
|
-
for candidate in (selection.get(key) or ())
|
|
469
|
-
if isinstance(candidate, Mapping)
|
|
470
|
-
and candidate.get("unresolvedFeasibilityFacts")
|
|
471
|
-
]
|
|
472
|
-
if not unresolved:
|
|
449
|
+
if selection.get("routing") not in {NO_VALID_OPTIONS_ROUTING, "technical-verification"}:
|
|
473
450
|
return []
|
|
474
|
-
if
|
|
475
|
-
|
|
451
|
+
if selection.get("routing") == "technical-verification":
|
|
452
|
+
try:
|
|
453
|
+
technical_verification_facts(report_data)
|
|
454
|
+
except TechnicalVerificationError as exc:
|
|
455
|
+
return [str(exc)]
|
|
456
|
+
incorporated = incorporated_clarification_ids(report_data)
|
|
457
|
+
answer_channels = {
|
|
458
|
+
row.get("id")
|
|
459
|
+
for row in (report_data.get("clarificationItems") or ())
|
|
460
|
+
if isinstance(row, Mapping)
|
|
461
|
+
and str(row.get("status") or "").strip().lower() != "obsolete"
|
|
462
|
+
and (
|
|
463
|
+
row.get("blocks") in USER_INPUT_BLOCKS
|
|
464
|
+
or not row_blocks_progress(
|
|
465
|
+
str(row.get("status") or ""), clarification_disposition(row),
|
|
466
|
+
incorporated=row.get("id") in incorporated,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
errors: list[str] = []
|
|
471
|
+
for candidate in (
|
|
472
|
+
row for key in ("rankedOptions", "candidateAudit")
|
|
473
|
+
for row in (selection.get(key) or ()) if isinstance(row, Mapping)
|
|
476
474
|
):
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
475
|
+
for index, fact in enumerate(candidate.get("unresolvedFeasibilityFacts") or ()):
|
|
476
|
+
label = f"{candidate.get('id')} unresolvedFeasibilityFacts[{index}]"
|
|
477
|
+
if not isinstance(fact, Mapping):
|
|
478
|
+
errors.append(f"{label}: expected a fact object with resolutionKind")
|
|
479
|
+
continue
|
|
480
|
+
label += f" ({fact.get('fact')})"
|
|
481
|
+
kind = fact.get("resolutionKind")
|
|
482
|
+
if kind == "technical-verification":
|
|
483
|
+
continue
|
|
484
|
+
if kind != "user-decision":
|
|
485
|
+
errors.append(f"{label}: classification required — set resolutionKind to "
|
|
486
|
+
"user-decision or technical-verification; preserve the fact "
|
|
487
|
+
"and evidence, then reassemble; do not infer a user question")
|
|
488
|
+
continue
|
|
489
|
+
refs = fact.get("clarificationRefs")
|
|
490
|
+
if not isinstance(refs, list) or not refs or any(
|
|
491
|
+
not isinstance(ref, str) or ref not in answer_channels for ref in refs
|
|
492
|
+
):
|
|
493
|
+
errors.append(f"{label}: clarificationRefs {refs!r} must link each required "
|
|
494
|
+
"user decision to an existing blocking or answered clarification; "
|
|
495
|
+
"use `okstra approval-decision open --ledger <approvalDecisionsPath>` "
|
|
496
|
+
"only for a missing question, preserve recorded answers, then reassemble")
|
|
497
|
+
return errors
|
|
485
498
|
|
|
486
499
|
|
|
487
500
|
def _validate_candidate_audit(
|
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from types import MappingProxyType
|
|
7
7
|
from typing import TYPE_CHECKING
|
|
8
8
|
|
|
9
|
+
from .registry.provider_registry import provider_display_order
|
|
9
10
|
from .domain.provider import ModelSpec, UnknownModelError
|
|
10
11
|
from .domain.role import ROLE_DEFINITIONS, RoleCatalogError, normalize_role
|
|
11
12
|
from .domain.worker_exec import WorkerWriteCapability
|
|
@@ -15,7 +16,6 @@ if TYPE_CHECKING:
|
|
|
15
16
|
from .registry.provider_registry import ProviderRegistry
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
_BUNDLED_PROVIDER_ORDER = ("claude", "antigravity", "codex", "grok", "kimi")
|
|
19
19
|
_TOKEN = re.compile(r"[a-z0-9][a-z0-9._-]*\Z")
|
|
20
20
|
_VERSION_KINDS = frozenset({"pinned", "channel"})
|
|
21
21
|
|
|
@@ -219,10 +219,7 @@ class ModelPool:
|
|
|
219
219
|
)
|
|
220
220
|
|
|
221
221
|
def _ordered_providers(self) -> tuple[str, ...]:
|
|
222
|
-
|
|
223
|
-
bundled = tuple(provider for provider in _BUNDLED_PROVIDER_ORDER if provider in providers)
|
|
224
|
-
users = tuple(sorted(provider for provider in providers if provider not in bundled))
|
|
225
|
-
return (*bundled, *users)
|
|
222
|
+
return tuple(sorted(self._registry.ids(), key=provider_display_order))
|
|
226
223
|
|
|
227
224
|
def _snapshot_provider_facts(self) -> dict[str, ProviderRuntimeFacts]:
|
|
228
225
|
facts: dict[str, ProviderRuntimeFacts] = {}
|
|
@@ -63,6 +63,7 @@ HANDLED_TASK_TYPES = frozenset(
|
|
|
63
63
|
| {
|
|
64
64
|
"requirements-discovery",
|
|
65
65
|
"error-analysis",
|
|
66
|
+
"technical-verification",
|
|
66
67
|
"implementation-option-selection",
|
|
67
68
|
"implementation-planning",
|
|
68
69
|
"implementation",
|
|
@@ -131,6 +132,8 @@ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
131
132
|
|
|
132
133
|
if task_type == "requirements-discovery":
|
|
133
134
|
return _from_nested_routing(report_data, "requirementsDiscovery")
|
|
135
|
+
if task_type == "technical-verification":
|
|
136
|
+
return _from_nested_routing(report_data, "technicalVerification")
|
|
134
137
|
if task_type == "error-analysis":
|
|
135
138
|
return _from_nested_routing(report_data, "errorAnalysis")
|
|
136
139
|
if task_type == "implementation-option-selection":
|
|
@@ -612,6 +612,19 @@ def reverify_item_ids(
|
|
|
612
612
|
return queue
|
|
613
613
|
|
|
614
614
|
|
|
615
|
+
def requires_plan_repair(verification: Mapping[str, Any]) -> bool:
|
|
616
|
+
"""검사·구현 가능성의 사실 결함은 단계 수에 따른 자문 면제에서 제외한다."""
|
|
617
|
+
return any(
|
|
618
|
+
str(vote.get("verdict") or "").upper() == "DISAGREE"
|
|
619
|
+
and vote.get("claimKind") != "judgement"
|
|
620
|
+
and (vote.get("breakageKind") in {"b", "c", "e"}
|
|
621
|
+
or (vote.get("breakageKind") == "f" and str(item.get("id", "")).startswith("P-Req")))
|
|
622
|
+
for item in verification.get("planItems", []) if isinstance(item, Mapping)
|
|
623
|
+
and not str(item.get("id", "")).upper().startswith("P-RB")
|
|
624
|
+
for vote in item.get("verdicts", []) if isinstance(vote, Mapping)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
615
628
|
def advisory_plan_body_gating(
|
|
616
629
|
planning: Mapping[str, Any],
|
|
617
630
|
extracted: Sequence[Mapping[str, Any]] | None = None,
|
|
@@ -622,6 +635,8 @@ def advisory_plan_body_gating(
|
|
|
622
635
|
초안과 탐지기 스냅샷이 생긴 뒤에만 이 판정을 쓴다. 다단계이거나
|
|
623
636
|
PREP 항목이 있으면 지금 게이트 계약 그대로다.
|
|
624
637
|
"""
|
|
638
|
+
if requires_plan_repair(planning.get("planBodyVerification") or {}):
|
|
639
|
+
return False
|
|
625
640
|
if len(_planning_stage_rows(planning)) != 1:
|
|
626
641
|
return False
|
|
627
642
|
if extracted is not None and any(
|
|
@@ -31,6 +31,7 @@ from .plan_items import (
|
|
|
31
31
|
NextDispatch,
|
|
32
32
|
PlanItemContractError,
|
|
33
33
|
advisory_plan_body_gating,
|
|
34
|
+
requires_plan_repair,
|
|
34
35
|
content_hash,
|
|
35
36
|
correction_prompt_text,
|
|
36
37
|
critic_is_rostered,
|
|
@@ -467,7 +468,7 @@ _PAYLOAD_FIELDS = {
|
|
|
467
468
|
# 거부되므로 줄이지 말 것.
|
|
468
469
|
"Req": ("id", "source", "requirement", "coveredBy", "originalRequirementId",
|
|
469
470
|
"stageRefs", "stepRefs", "validationRefs", "fileRefs", "ticketId", "status",
|
|
470
|
-
"approvalDisposition", "decisionRefs"),
|
|
471
|
+
"approvalDisposition", "decisionRefs", "crossProjectDependencyRefs"),
|
|
471
472
|
"Prep": ("stage", "kind", "evidence"),
|
|
472
473
|
}
|
|
473
474
|
_VAR_ANALYSIS_FIELDS = (
|
|
@@ -1354,8 +1355,9 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1354
1355
|
verification["dispatchQueue"] = _queue_for(
|
|
1355
1356
|
extracted, _planning(source), getattr(args, "run_manifest", None), previous,
|
|
1356
1357
|
)
|
|
1357
|
-
verification["gating"] =
|
|
1358
|
-
|
|
1358
|
+
verification["gating"] = (
|
|
1359
|
+
verification.get("gating") is True or requires_plan_repair(verification)
|
|
1360
|
+
or not advisory_plan_body_gating(_planning(source), extracted)
|
|
1359
1361
|
)
|
|
1360
1362
|
write_json_atomic(target, data)
|
|
1361
1363
|
result = {
|
|
@@ -1451,6 +1453,8 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1451
1453
|
for item in recorded:
|
|
1452
1454
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
1453
1455
|
writer(item, rows[item["id"]], args.round_number, project_root)
|
|
1456
|
+
if requires_plan_repair(verification):
|
|
1457
|
+
verification["gating"] = True
|
|
1454
1458
|
_validate_advisory_round(verification, args.round_number)
|
|
1455
1459
|
write_json_atomic(target, data)
|
|
1456
1460
|
return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
|
|
@@ -1850,6 +1854,8 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1850
1854
|
raise PlanItemContractError("--round must be 1 or greater")
|
|
1851
1855
|
data, current, audit, history = _round_inputs(args)
|
|
1852
1856
|
verification = data["planBodyVerification"]
|
|
1857
|
+
if requires_plan_repair(verification):
|
|
1858
|
+
verification["gating"] = True
|
|
1853
1859
|
_validate_advisory_round(verification, args.round_number)
|
|
1854
1860
|
if verification.get("gating") is False:
|
|
1855
1861
|
if args.self_fix_group or args.self_fix_note or args.self_fix_stop_reason:
|
|
@@ -17,6 +17,7 @@ self-enforce 하는 측면) 의 계약은 `prompts/profiles/implementation.md`
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
|
+
import shlex
|
|
20
21
|
from typing import Iterable
|
|
21
22
|
|
|
22
23
|
# 카테고리 화이트리스트. 알 수 없는 카테고리는 오타 가능성이 높으므로 거부.
|
|
@@ -97,6 +98,35 @@ def find_unfrozen_installs(cmd: str) -> list[str]:
|
|
|
97
98
|
return found
|
|
98
99
|
|
|
99
100
|
|
|
101
|
+
def verification_command_defects(command: str) -> list[str]:
|
|
102
|
+
"""선언된 작업 폴더를 셸에서 다른 체크아웃으로 바꾸는 명령을 거부한다."""
|
|
103
|
+
try:
|
|
104
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|()\n")
|
|
105
|
+
lexer.whitespace = " \t\r"
|
|
106
|
+
lexer.whitespace_split = True
|
|
107
|
+
words = list(lexer)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
return [f"invalid shell command: {exc}"]
|
|
110
|
+
start = True
|
|
111
|
+
shell = False
|
|
112
|
+
for word in words:
|
|
113
|
+
if word and not word.strip(";&|()\n"):
|
|
114
|
+
start, shell = True, False
|
|
115
|
+
continue
|
|
116
|
+
if start and (word in {"command", "builtin", "env", "!", "if", "then", "do"}
|
|
117
|
+
or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", word)):
|
|
118
|
+
continue
|
|
119
|
+
if start:
|
|
120
|
+
if word in {"cd", "pushd", "popd", "chdir", "-C", "--chdir"} or word.startswith("--chdir="):
|
|
121
|
+
return ["command must run in the worktree cwd; remove the checkout-changing command"]
|
|
122
|
+
shell = word.rsplit("/", 1)[-1] in {"sh", "bash", "zsh", "fish"}
|
|
123
|
+
start = False
|
|
124
|
+
elif shell and word.startswith("-") and not word.startswith("--") and "c" in word:
|
|
125
|
+
return ["nested shell command; declare the verification command directly"]
|
|
126
|
+
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
|
|
100
130
|
def find_denied_tokens(cmd: str) -> list[str]:
|
|
101
131
|
"""`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
|
|
102
132
|
if not isinstance(cmd, str):
|
|
@@ -13,6 +13,16 @@ from ..json_boundary import JsonBoundaryError, load_owned_object
|
|
|
13
13
|
from .factory_loader import FactoryLoadError, load_relative_factory
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
_PROVIDER_DISPLAY_ORDER = ("claude", "codex", "grok", "antigravity", "zai", "kimi")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def provider_display_order(provider_id: str) -> tuple[int, str]:
|
|
20
|
+
"""내장 제공자를 지정 순서로, 추가 제공자를 그 뒤에 이름순으로 표시한다."""
|
|
21
|
+
if provider_id in _PROVIDER_DISPLAY_ORDER:
|
|
22
|
+
return _PROVIDER_DISPLAY_ORDER.index(provider_id), provider_id
|
|
23
|
+
return len(_PROVIDER_DISPLAY_ORDER), provider_id
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
class ProviderAdapterContractError(ValueError):
|
|
17
27
|
"""Raised when a discovered provider adapter violates its contract."""
|
|
18
28
|
|
|
@@ -72,17 +82,10 @@ def _registry_for_roots(
|
|
|
72
82
|
user_root: Path,
|
|
73
83
|
) -> ProviderRegistry:
|
|
74
84
|
discovered = ProviderRegistry.from_roots((bundled_root, user_root)).providers
|
|
75
|
-
bundled_order = ("claude", "antigravity", "codex", "grok", "kimi")
|
|
76
85
|
ordered = {
|
|
77
86
|
provider_id: discovered[provider_id]
|
|
78
|
-
for provider_id in
|
|
79
|
-
if provider_id in discovered
|
|
87
|
+
for provider_id in sorted(discovered, key=provider_display_order)
|
|
80
88
|
}
|
|
81
|
-
ordered.update(
|
|
82
|
-
(provider_id, discovered[provider_id])
|
|
83
|
-
for provider_id in sorted(discovered)
|
|
84
|
-
if provider_id not in ordered
|
|
85
|
-
)
|
|
86
89
|
return ProviderRegistry(ordered)
|
|
87
90
|
|
|
88
91
|
|
|
@@ -1755,6 +1755,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1755
1755
|
"taskKey": ctx.get("TASK_KEY", ""),
|
|
1756
1756
|
"taskType": ctx.get("TASK_TYPE", ""),
|
|
1757
1757
|
"runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
1758
|
+
"userAuthorization": json.loads(ctx.get("USER_AUTHORIZATION_JSON", "{}")),
|
|
1758
1759
|
# 리포트 언어의 유일한 해석기. 예전에는 아무도 채우지 않는
|
|
1759
1760
|
# `REPORT_LANGUAGE` 를 읽어 항상 "en" 이 나왔고, report-writer 프롬프트만
|
|
1760
1761
|
# `resolve_report_language()` 를 써서 프롬프트는 ko, 매니페스트는 en 으로
|
|
@@ -2074,6 +2075,8 @@ def _guard_invocation_manifest_rewrite(
|
|
|
2074
2075
|
for key in _IMMUTABLE_INVOCATION_KEYS
|
|
2075
2076
|
if existing.get(key) != proposed.get(key)
|
|
2076
2077
|
]
|
|
2078
|
+
if existing.get("userAuthorization", {}) != proposed.get("userAuthorization", {}):
|
|
2079
|
+
drift.append("userAuthorization")
|
|
2077
2080
|
if drift:
|
|
2078
2081
|
raise ValueError(
|
|
2079
2082
|
"immutable invocation manifest fields changed: " + ", ".join(drift)
|
|
@@ -77,6 +77,7 @@ TASK_DELIVERABLE_TITLES = {
|
|
|
77
77
|
"requirements-discovery": "Requirements Discovery",
|
|
78
78
|
"improvement-discovery": "Improvement Discovery",
|
|
79
79
|
"error-analysis": "Error Analysis",
|
|
80
|
+
"technical-verification": "Technical Verification",
|
|
80
81
|
"implementation-option-selection": "Implementation Option Selection",
|
|
81
82
|
"project-analysis": "Project Analysis",
|
|
82
83
|
"feature-analysis": "Feature Analysis",
|