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.
Files changed (96) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -5
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. 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
- # 셸에서 프로세스의 cwd 를 바꾸는 명령. verifier 가 워크트리 cwd 에서 실행하는
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 segment in re.split(r"&&|\|\||;|\|", run_command):
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 = 0.0
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
- if heartbeat_seconds > 0 and now - last_heartbeat >= heartbeat_seconds:
857
- print(f"WAITING {len(running)} worker dispatch(es)")
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
 
@@ -14,7 +14,6 @@ if TYPE_CHECKING:
14
14
 
15
15
  _MISSING = object()
16
16
 
17
-
18
17
  @dataclass(frozen=True)
19
18
  class ServedModelAttestation:
20
19
  """Provider-observed model identity after one execution attempt."""
@@ -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 = _content_snapshot(root, generated | _non_source_paths(root))
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 = [value for value in row.get(field) or () if value not in valid_values]
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, stage_ids, step_ids, validation_ids, file_paths
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 structure_failures:
797
+ if failures:
747
798
  return failures
748
- statuses = {row_id: str(row.get("status") or "") for row_id, row in zip(row_ids, rows)}
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" for stage in unmapped_stages
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
- progress_blocking_ids,
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 != NO_VALID_OPTIONS_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 == NO_VALID_OPTIONS_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
- """차단된 run 사용자가 답할 자리를 남겼는지.
438
-
439
- `routing: blocked` 목적지가 없는 유일한 종료 상태다. 차단이
440
- `unresolvedFeasibilityFacts` 때문이면 값이 미정이다, 계약이 없다,
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") != NO_VALID_OPTIONS_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 progress_blocking_ids(
475
- report_data.get("clarificationItems"), USER_INPUT_BLOCKS
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
- return []
478
- return [
479
- "blocked routing leaves the user no answer channel: "
480
- f"{', '.join(unresolved)} carry unresolvedFeasibilityFacts and no open "
481
- "clarification row asks them — open one decision row per answerable "
482
- "fact with `okstra approval-decision open --ledger "
483
- "<approvalDecisionsPath>`, then reassemble"
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
- providers = self._registry.providers
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"] = not advisory_plan_body_gating(
1358
- _planning(source), extracted,
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 bundled_order
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",