okstra 0.163.2 → 0.165.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 (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -3,7 +3,7 @@
3
3
  The sidecar format is documented in ``templates/reports/user-response.template.md``
4
4
  and produced byte-identically by ``report_views.serialize_user_response`` (Python)
5
5
  and ``templates/reports/report.js`` (browser). This module owns the read side of
6
- the ``## APPROVAL`` block used by the implementation wizard and the optional
6
+ the ``## PLAN DECISION`` block used by the implementation wizard and the optional
7
7
  ``## ANALYSIS REVIEW`` block used by analysis reruns.
8
8
  """
9
9
  from __future__ import annotations
@@ -19,7 +19,8 @@ from pathlib import Path
19
19
  from typing import Optional
20
20
 
21
21
  from okstra_ctl.report_views import (
22
- serialize_user_response, UserResponseEntry, UserResponseApproval, infer_run_meta,
22
+ PLAN_DECISION_APPROVED,
23
+ serialize_user_response, UserResponseEntry, UserPlanDecision, infer_run_meta,
23
24
  parse_expected_form_options,
24
25
  )
25
26
  from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
@@ -32,7 +33,7 @@ from okstra_ctl.clarification_items import (
32
33
  _section_1_slice,
33
34
  )
34
35
 
35
- _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
36
+ _PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
36
37
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
37
38
  _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
38
39
  _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
@@ -50,13 +51,18 @@ class UserResponseError(ValueError):
50
51
 
51
52
 
52
53
  @dataclass(frozen=True)
53
- class UserResponseApprovalRecord:
54
- """sidecar 의 ``## APPROVAL`` 블록 + 매칭에 필요한 frontmatter 필드."""
55
- approved: bool
54
+ class PlanDecisionRecord:
55
+ """sidecar 의 ``## PLAN DECISION`` 블록 + 매칭에 필요한 frontmatter 필드."""
56
+ status: str
56
57
  implementation_option: str
58
+ reason: str
57
59
  source_report: str
58
60
  seq: str
59
61
 
62
+ @property
63
+ def approved(self) -> bool:
64
+ return self.status == PLAN_DECISION_APPROVED
65
+
60
66
 
61
67
  @dataclass(frozen=True)
62
68
  class AnalysisReviewRecord:
@@ -353,31 +359,37 @@ def load_authoritative_analysis_review(
353
359
  return review
354
360
 
355
361
 
356
- def parse_user_response_approval(
357
- sidecar_text: str,
358
- ) -> Optional[UserResponseApprovalRecord]:
359
- """``## APPROVAL`` 블록이 ``- Approved: true`` 를 가질 때만 record 를
360
- 반환한다. 옵션 라인은 블록 내부에서만 읽는다 (다른 응답 본문의 우연한
361
- 동일 문구를 옵션으로 오인하지 않도록).
362
+ _PLAN_DECISION_STATUS_RE = re.compile(
363
+ r"^- Status:\s*(approved|revision-requested|rejected)\s*$", re.MULTILINE
364
+ )
365
+
366
+
367
+ def parse_plan_decision(sidecar_text: str) -> Optional[PlanDecisionRecord]:
368
+ """``## PLAN DECISION`` 블록을 읽어 record 로 돌려준다. 승인·재작업·반려를
369
+ 구분하지 않고 그대로 싣는다 — 어느 판정을 받아들일지는 소비 측 정책이다.
370
+ 옵션·사유 라인은 블록 내부에서만 읽는다 (다른 응답 본문의 우연한 동일
371
+ 문구를 옵션으로 오인하지 않도록).
362
372
 
363
- strictness 는 의도적이다: producer 출력과 byte-identical 한 소문자
364
- ``true`` 만 인정하며, 손편집 변형(``TRUE``/``True``)은 fail-closed 로
373
+ strictness 는 의도적이다: producer 출력과 byte-identical 한 소문자 status
374
+ 만 인정하며, 손편집 변형(``Approved``/``REJECTED``)은 fail-closed 로
365
375
  불인정한다."""
366
- m = _APPROVAL_HEADING_RE.search(sidecar_text)
376
+ m = _PLAN_DECISION_HEADING_RE.search(sidecar_text)
367
377
  if not m:
368
378
  return None
369
379
  block = sidecar_text[m.end():]
370
380
  nxt = _NEXT_RESPONSE_HEADING_RE.search(block)
371
381
  if nxt:
372
382
  block = block[: nxt.start()]
373
- if re.search(r"^- Approved:\s*true\s*$", block, re.MULTILINE) is None:
383
+ status = _PLAN_DECISION_STATUS_RE.search(block)
384
+ if status is None:
374
385
  return None
375
386
  om = re.search(r"^- Implementation-Option:\s*(\S.*?)\s*$", block, re.MULTILINE)
376
387
  sm = re.search(r"^seq:\s*(\S+)\s*$", sidecar_text, re.MULTILINE)
377
388
  rm = re.search(r"^source-report:\s*(\S.*?)\s*$", sidecar_text, re.MULTILINE)
378
- return UserResponseApprovalRecord(
379
- approved=True,
389
+ return PlanDecisionRecord(
390
+ status=status.group(1),
380
391
  implementation_option=om.group(1) if om else "",
392
+ reason=_quoted_review_value(block, "Reason"),
381
393
  source_report=rm.group(1) if rm else "",
382
394
  seq=sm.group(1) if sm else "",
383
395
  )
@@ -404,8 +416,8 @@ def _value(block: str) -> str:
404
416
 
405
417
  def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
406
418
  """Reverse of ``serialize_user_response`` for the per-response ``## C-*``
407
- blocks. The ``## APPROVAL`` block is skipped (read separately by
408
- ``parse_user_response_approval``)."""
419
+ blocks. The ``## PLAN DECISION`` block is skipped (read separately by
420
+ ``parse_plan_decision``)."""
409
421
  entries: list[UserResponseEntry] = []
410
422
  matches = list(_RESPONSE_HEADING_RE.finditer(sidecar_text))
411
423
  for i, m in enumerate(matches):
@@ -581,7 +593,7 @@ def show_open_rows(report_path: Path) -> dict:
581
593
 
582
594
 
583
595
  def write_sidecar(report_path: Path, answers: list[dict],
584
- approval: Optional[dict], created_at: str,
596
+ plan_decision: Optional[dict], created_at: str,
585
597
  task_key: str = "") -> Path:
586
598
  run_meta = infer_run_meta(report_path, task_key=task_key or None)
587
599
  out_dir = user_responses_dir_for_report(report_path)
@@ -597,13 +609,15 @@ def write_sidecar(report_path: Path, answers: list[dict],
597
609
  response_id=a["id"], kind=a.get("kind", ""), value=a["value"],
598
610
  rationale=a.get("rationale"), disposition=a.get("disposition", "answer"))
599
611
 
600
- appr = None
601
- if approval and approval.get("approved"):
602
- appr = UserResponseApproval(
603
- approved=True, implementation_option=approval.get("implementationOption", ""))
612
+ decision = None
613
+ if plan_decision and plan_decision.get("status"):
614
+ decision = UserPlanDecision(
615
+ status=plan_decision["status"],
616
+ implementation_option=plan_decision.get("implementationOption", ""),
617
+ reason=plan_decision.get("reason", ""))
604
618
  sidecar.write_text(
605
619
  serialize_user_response(run_meta=run_meta, entries=list(merged.values()),
606
- created_at=created_at, approval=appr),
620
+ created_at=created_at, plan_decision=decision),
607
621
  encoding="utf-8")
608
622
  return sidecar
609
623
 
@@ -623,7 +637,9 @@ def main(argv: Optional[list[str]] = None) -> int:
623
637
  pw = sub.add_parser("write")
624
638
  pw.add_argument("--report", required=True)
625
639
  pw.add_argument("--answers", required=True, help="JSON array of answer entries")
626
- pw.add_argument("--approval", default="", help="JSON approval object")
640
+ pw.add_argument(
641
+ "--plan-decision", default="",
642
+ help='JSON plan decision, e.g. {"status":"rejected","reason":"..."}')
627
643
  pw.add_argument("--task-key", default="", help="task-key from list/show context")
628
644
 
629
645
  ns = parser.parse_args(argv)
@@ -636,9 +652,9 @@ def main(argv: Optional[list[str]] = None) -> int:
636
652
  return 0
637
653
  if ns.cmd == "write":
638
654
  answers = json.loads(ns.answers)
639
- approval = json.loads(ns.approval) if ns.approval else None
655
+ decision = json.loads(ns.plan_decision) if ns.plan_decision else None
640
656
  created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
641
- p = write_sidecar(Path(ns.report), answers, approval, created_at,
657
+ p = write_sidecar(Path(ns.report), answers, decision, created_at,
642
658
  task_key=ns.task_key)
643
659
  json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
644
660
  return 0
@@ -29,6 +29,23 @@ from datetime import datetime
29
29
  from pathlib import Path
30
30
  from typing import Any, Callable, Optional
31
31
 
32
+ from okstra_ctl.application.advance_wizard import plan_prompt
33
+ from okstra_ctl.application.resolve_assignment import resolve_lead_provider
34
+ from okstra_ctl.domain.host import (
35
+ HostCapabilityMismatch,
36
+ HostNotRegistered,
37
+ HostSessionContext,
38
+ ProviderUnavailable,
39
+ )
40
+ from okstra_ctl.domain.wizard.interaction import (
41
+ InteractionPlan,
42
+ WizardAnswerError,
43
+ WizardOption,
44
+ WizardPrompt,
45
+ normalize_planned_answer,
46
+ )
47
+ from okstra_ctl.registry.host_registry import default_host_registry
48
+ from okstra_ctl.registry.provider_registry import default_provider_registry
32
49
  from okstra_ctl.ids import slugify_task_segment
33
50
  from okstra_ctl.brief_frontmatter import read_brief_frontmatter
34
51
  from okstra_ctl.analysis_inputs import (
@@ -44,13 +61,12 @@ from okstra_ctl.analysis_inputs import (
44
61
  from okstra_ctl.models import (
45
62
  PROVIDER_MAPPINGS,
46
63
  UnknownModelError,
64
+ UnknownProviderError,
47
65
  picker_options,
48
66
  provider_ids,
49
67
  provider_supports_role,
50
68
  resolve_model_metadata,
51
69
  )
52
- from okstra_ctl.lead_runtime import lead_runtime_info
53
- from okstra_ctl.runner_resolution import native_provider_for_host
54
70
  from okstra_ctl.clarification_items import (
55
71
  scan_approval_gate,
56
72
  sidecar_answers,
@@ -85,8 +101,8 @@ from okstra_ctl.stage_map import (
85
101
  stage_map_records,
86
102
  )
87
103
  from okstra_ctl.user_response import (
88
- UserResponseApprovalRecord,
89
- parse_user_response_approval,
104
+ PlanDecisionRecord,
105
+ parse_plan_decision,
90
106
  )
91
107
  from okstra_ctl.workers import (
92
108
  ALLOWED_WORKERS,
@@ -176,7 +192,6 @@ _BRIEF_ENTRY_TASK_TYPES = (
176
192
 
177
193
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
178
194
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
179
- HOST_PICK_OPTION_CAP = 4
180
195
 
181
196
  CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
182
197
  CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
@@ -389,6 +404,8 @@ class WizardState:
389
404
  project_root: str = ""
390
405
  project_id: str = ""
391
406
  host_runtime: str = "claude-code"
407
+ host_entry_mode: str = "current-session"
408
+ available_functions: list[str] = field(default_factory=list)
392
409
 
393
410
  # task identity
394
411
  is_new_task: Optional[bool] = None
@@ -535,8 +552,6 @@ class Prompt:
535
552
  "echoTemplate": self.echo_template,
536
553
  "multi": self.multi,
537
554
  }
538
- if self.kind == "pick" and len(self.options) > HOST_PICK_OPTION_CAP:
539
- out["presentation"] = "numbered-text"
540
555
  if self.kind == "pick_group":
541
556
  out["questions"] = [
542
557
  {"step": q.step, "label": q.label,
@@ -551,35 +566,6 @@ class WizardError(Exception):
551
566
  """validation failure surfaced to user."""
552
567
 
553
568
 
554
- def _normalize_numbered_pick_item(prompt: Prompt, answer: str) -> str:
555
- candidate = (answer or "").strip()
556
- exact_values = [option.value for option in prompt.options if option.value == candidate]
557
- if exact_values:
558
- return exact_values[0]
559
-
560
- if candidate.isdecimal():
561
- number = int(candidate)
562
- if 1 <= number <= len(prompt.options):
563
- return prompt.options[number - 1].value
564
- raise WizardError(f"numbered-text answer is out of range: {candidate}")
565
-
566
- label_values = [option.value for option in prompt.options if option.label == candidate]
567
- if len(label_values) == 1:
568
- return label_values[0]
569
- if len(label_values) > 1:
570
- raise WizardError(f"numbered-text answer label is ambiguous: {candidate}")
571
- raise WizardError(f"numbered-text answer is not an option: {candidate}")
572
-
573
-
574
- def _normalize_numbered_pick_answer(prompt: Prompt, answer: str) -> str:
575
- if prompt.multi:
576
- return ",".join(
577
- _normalize_numbered_pick_item(prompt, item)
578
- for item in (answer or "").split(",")
579
- )
580
- return _normalize_numbered_pick_item(prompt, answer)
581
-
582
-
583
569
  # ---- Validation helpers --------------------------------------------------
584
570
 
585
571
  _SLUG_OK = re.compile(r"[a-z0-9]")
@@ -696,23 +682,27 @@ def _approve_plan_in_place(plan_path: Path) -> None:
696
682
 
697
683
  def _find_html_approval_sidecar(
698
684
  plan_path: Path,
699
- ) -> Optional[tuple[Path, UserResponseApprovalRecord]]:
700
- """plan 의 run 디렉토리 sibling ``user-responses/`` 에서 APPROVAL 블록을
701
- 가진 sidecar 를 찾는다. source-report 파일명과 seq 가 plan 과 일치해야
702
- 하며, 복수면 mtime 최신을 택한다."""
685
+ ) -> Optional[tuple[Path, PlanDecisionRecord]]:
686
+ """plan 의 run 디렉토리 sibling ``user-responses/`` 에서 승인 판정을 담은
687
+ sidecar 를 찾는다. source-report 파일명과 seq 가 plan 과 일치해야 하며,
688
+ 복수면 mtime 최신을 택한다.
689
+
690
+ 승인이 아닌 판정(반려·재작업 요청)은 여기서 걸러진다 — 이 단계가 묻는 것은
691
+ "사용자가 이 plan 을 승인해 두었는가" 뿐이고, 반려 사유는 다음 planning
692
+ run 이 sidecar 를 통째로 읽어 처리한다."""
703
693
  responses_dir = plan_path.parent.parent / "user-responses"
704
694
  if not responses_dir.is_dir():
705
695
  return None
706
696
  m = re.search(r"-(\d+)\.md$", plan_path.name)
707
697
  plan_seq = m.group(1) if m else ""
708
- best: Optional[tuple[float, Path, UserResponseApprovalRecord]] = None
698
+ best: Optional[tuple[float, Path, PlanDecisionRecord]] = None
709
699
  for f in sorted(responses_dir.glob("user-response-*.md")):
710
700
  try:
711
701
  text = f.read_text(encoding="utf-8", errors="replace")
712
702
  except OSError:
713
703
  continue
714
- rec = parse_user_response_approval(text)
715
- if rec is None or rec.seq != plan_seq:
704
+ rec = parse_plan_decision(text)
705
+ if rec is None or not rec.approved or rec.seq != plan_seq:
716
706
  continue
717
707
  if Path(rec.source_report).name != plan_path.name:
718
708
  continue
@@ -3369,6 +3359,28 @@ def _build_reuse_previous(state: WizardState) -> Prompt:
3369
3359
  )
3370
3360
 
3371
3361
 
3362
+ def _restore_reused_lead(state: WizardState, inputs: dict) -> None:
3363
+ requested_provider = _safe_provider(inputs.get("leadProvider"), "lead")
3364
+ try:
3365
+ assignment = resolve_lead_provider(
3366
+ host_id=state.host_runtime,
3367
+ requested_provider=requested_provider,
3368
+ host_registry=default_host_registry(),
3369
+ provider_registry=default_provider_registry(),
3370
+ )
3371
+ except (
3372
+ HostCapabilityMismatch,
3373
+ HostNotRegistered,
3374
+ ProviderUnavailable,
3375
+ UnknownProviderError,
3376
+ ) as exc:
3377
+ raise WizardError(
3378
+ f"previous run cannot be reused: {exc}; choose step-by-step re-entry"
3379
+ ) from exc
3380
+ state.lead_provider = assignment.provider
3381
+ state.lead_model = _safe_model(assignment.provider, inputs.get("leadModel"))
3382
+
3383
+
3372
3384
  def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
3373
3385
  if value not in ("yes", "no"):
3374
3386
  raise WizardError(f"expected 'yes' or 'no', got: {value!r}")
@@ -3383,16 +3395,7 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
3383
3395
  state.workers_override = (
3384
3396
  ",".join(w for w in workers if isinstance(w, str))
3385
3397
  if isinstance(workers, list) else "")
3386
- state.lead_provider = _safe_provider(inputs.get("leadProvider"), "lead")
3387
- lead_provider = state.lead_provider or "claude"
3388
- native_provider = native_provider_for_host(state.host_runtime)
3389
- if native_provider and lead_provider != native_provider:
3390
- raise WizardError(
3391
- f"previous run used provider {lead_provider!r}, but "
3392
- f"{state.host_runtime} requires provider {native_provider!r}; "
3393
- "choose step-by-step re-entry"
3394
- )
3395
- state.lead_model = _safe_model(lead_provider, inputs.get("leadModel"))
3398
+ _restore_reused_lead(state, inputs)
3396
3399
  state.claude_model = _safe_model("claude", inputs.get("claudeModel"))
3397
3400
  state.codex_model = _safe_model("codex", inputs.get("codexModel"))
3398
3401
  state.antigravity_model = _safe_model("antigravity", inputs.get("antigravityModel"))
@@ -3601,16 +3604,27 @@ def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
3601
3604
 
3602
3605
 
3603
3606
  def _qualified_role_model_options(role: str) -> list[str]:
3604
- options = list(CLAUDE_MODEL_OPTIONS)
3605
- for provider in provider_ids(role):
3607
+ registry = default_provider_registry()
3608
+ options = ["default", *_provider_picker_options("claude", registry)]
3609
+ for provider in registry.ids(role):
3606
3610
  if provider == "claude":
3607
3611
  continue
3608
3612
  options.extend(
3609
- f"{provider}:{model}" for model in picker_options(provider)
3613
+ f"{provider}:{model}"
3614
+ for model in _provider_picker_options(provider, registry)
3610
3615
  )
3611
3616
  return options
3612
3617
 
3613
3618
 
3619
+ def _provider_picker_options(provider: str, registry=None) -> list[str]:
3620
+ provider_registry = registry or default_provider_registry()
3621
+ return [
3622
+ alias
3623
+ for alias, model in provider_registry.resolve(provider).models.items()
3624
+ if model.in_picker
3625
+ ]
3626
+
3627
+
3614
3628
  def _submit_qualified_role_model(
3615
3629
  state: WizardState, value: str, role: str, model_field: str, provider_field: str,
3616
3630
  ) -> str:
@@ -3629,32 +3643,44 @@ def _submit_qualified_role_model(
3629
3643
 
3630
3644
  def _build_lead_model(state: WizardState) -> Prompt:
3631
3645
  t = _p(state.workspace_root, "lead_model")
3632
- provider = native_provider_for_host(state.host_runtime)
3646
+ provider = default_host_registry().resolve(
3647
+ state.host_runtime
3648
+ ).descriptor.native_provider_id
3633
3649
  options = (
3634
- ["default", *picker_options(provider)]
3650
+ ["default", *_provider_picker_options(provider)]
3635
3651
  if provider else _qualified_role_model_options("lead")
3636
3652
  )
3637
3653
  return _model_pick(S_LEAD_MODEL, t["label"], options, t["echo_template"])
3638
3654
 
3639
3655
 
3640
3656
  def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
3641
- native_provider = native_provider_for_host(state.host_runtime)
3657
+ native_provider = default_host_registry().resolve(
3658
+ state.host_runtime
3659
+ ).descriptor.native_provider_id
3642
3660
  if native_provider:
3643
3661
  raw = (value or "").strip()
3662
+ requested_provider = ""
3644
3663
  if ":" in raw:
3645
- selected_provider, raw = (
3664
+ requested_provider, raw = (
3646
3665
  part.strip().lower() for part in raw.split(":", 1)
3647
3666
  )
3648
- if selected_provider != native_provider:
3649
- host_label = lead_runtime_info(state.host_runtime).agent_label
3650
- raise WizardError(
3651
- f"{host_label.removesuffix(' CLI')} host requires provider "
3652
- f"{native_provider!r} "
3653
- "for the in-session lead"
3654
- )
3655
- state.lead_provider = native_provider
3656
- state.lead_model = _validate_model(native_provider, raw)
3657
- return f"lead-model: {native_provider}:{state.lead_model or 'default'}"
3667
+ try:
3668
+ assignment = resolve_lead_provider(
3669
+ host_id=state.host_runtime,
3670
+ requested_provider=requested_provider,
3671
+ host_registry=default_host_registry(),
3672
+ provider_registry=default_provider_registry(),
3673
+ )
3674
+ except (
3675
+ HostCapabilityMismatch,
3676
+ HostNotRegistered,
3677
+ ProviderUnavailable,
3678
+ UnknownProviderError,
3679
+ ) as exc:
3680
+ raise WizardError(str(exc)) from exc
3681
+ state.lead_provider = assignment.provider
3682
+ state.lead_model = _validate_model(assignment.provider, raw)
3683
+ return f"lead-model: {assignment.provider}:{state.lead_model or 'default'}"
3658
3684
  selected = _submit_qualified_role_model(
3659
3685
  state, value, "lead", "lead_model", "lead_provider",
3660
3686
  )
@@ -4529,6 +4555,8 @@ def _reset_field(state: WizardState, fname: str) -> None:
4529
4555
  def init_state(
4530
4556
  *, workspace_root: str, project_root: str, project_id: str,
4531
4557
  host_runtime: str = "claude-code",
4558
+ host_entry_mode: str = "current-session",
4559
+ available_functions: Optional[list[str]] = None,
4532
4560
  ) -> WizardState:
4533
4561
  """Bootstrap a new wizard state."""
4534
4562
  return WizardState(
@@ -4536,6 +4564,8 @@ def init_state(
4536
4564
  project_root=project_root,
4537
4565
  project_id=project_id,
4538
4566
  host_runtime=host_runtime,
4567
+ host_entry_mode=host_entry_mode,
4568
+ available_functions=list(available_functions or []),
4539
4569
  )
4540
4570
 
4541
4571
 
@@ -4668,9 +4698,73 @@ def _screen_progress(state: WizardState) -> dict[str, Any]:
4668
4698
  }
4669
4699
 
4670
4700
 
4701
+ def _domain_prompt(prompt: Prompt) -> WizardPrompt:
4702
+ return WizardPrompt(
4703
+ step=prompt.step,
4704
+ kind=prompt.kind,
4705
+ label=prompt.label,
4706
+ options=tuple(
4707
+ WizardOption(option.value, option.label, option.description)
4708
+ for option in prompt.options
4709
+ ),
4710
+ help=prompt.help,
4711
+ echo_template=prompt.echo_template,
4712
+ multi=prompt.multi,
4713
+ questions=tuple(_domain_prompt(question) for question in prompt.questions),
4714
+ )
4715
+
4716
+
4717
+ def _interaction_plan(state: WizardState, prompt: Prompt) -> InteractionPlan:
4718
+ context = HostSessionContext(
4719
+ host_id=state.host_runtime,
4720
+ entry_mode=state.host_entry_mode,
4721
+ available_functions=frozenset(state.available_functions),
4722
+ interaction_surface="terminal",
4723
+ )
4724
+ adapter = default_host_registry().resolve(state.host_runtime)
4725
+ return plan_prompt(_domain_prompt(prompt), context, adapter.interaction()).interaction
4726
+
4727
+
4728
+ def _interaction_payload(plan: InteractionPlan) -> dict[str, Any]:
4729
+ return {
4730
+ "kind": plan.kind,
4731
+ "answerProtocol": {
4732
+ "kind": plan.answer_protocol.kind,
4733
+ "multi": plan.answer_protocol.multi,
4734
+ },
4735
+ }
4736
+
4737
+
4738
+ def _normalize_interaction_answer(
4739
+ state: WizardState,
4740
+ prompt: Prompt,
4741
+ plan: InteractionPlan,
4742
+ value: str,
4743
+ ) -> str:
4744
+ try:
4745
+ return normalize_planned_answer(
4746
+ _domain_prompt(prompt),
4747
+ plan.answer_protocol,
4748
+ value,
4749
+ )
4750
+ except WizardAnswerError as exc:
4751
+ is_legacy_raw_answer = (
4752
+ not state.available_functions
4753
+ and "not an option" in str(exc)
4754
+ and not (value or "").strip().isdecimal()
4755
+ )
4756
+ if is_legacy_raw_answer:
4757
+ return value
4758
+ raise WizardError(str(exc)) from exc
4759
+
4760
+
4671
4761
  def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
4672
4762
  """Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
4673
4763
  out = prompt.to_json()
4764
+ plan = _interaction_plan(state, prompt)
4765
+ out["interaction"] = _interaction_payload(plan)
4766
+ if plan.kind in {"numbered-single", "numbered-multi"}:
4767
+ out["presentation"] = "numbered-text"
4674
4768
  if prompt.kind not in ("done", "aborted"):
4675
4769
  out["progress"] = _screen_progress(state)
4676
4770
  return out
@@ -4711,10 +4805,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
4711
4805
  prompt = next_prompt(state)
4712
4806
  if prompt.kind in ("done", "aborted"):
4713
4807
  return {"echo": "", "next": prompt_payload(state, prompt)}
4808
+ plan = _interaction_plan(state, prompt)
4809
+ value = _normalize_interaction_answer(state, prompt, plan, value)
4714
4810
  if prompt.kind == "pick_group":
4715
4811
  return _submit_group(state, prompt, value)
4716
- if prompt.kind == "pick" and len(prompt.options) > HOST_PICK_OPTION_CAP:
4717
- value = _normalize_numbered_pick_answer(prompt, value)
4718
4812
  step = STEP_BY_ID[prompt.step]
4719
4813
  echo = step.submit(state, value or "")
4720
4814
  if prompt.step not in state.answered:
@@ -5048,8 +5142,14 @@ def _cli(argv: list[str]) -> int:
5048
5142
  p_init.add_argument(
5049
5143
  "--host-runtime",
5050
5144
  default="claude-code",
5051
- choices=("claude-code", "codex", "antigravity", "external"),
5145
+ choices=default_host_registry().ids(),
5146
+ )
5147
+ p_init.add_argument(
5148
+ "--entry-mode",
5149
+ default="current-session",
5150
+ choices=("spawn-process", "current-session"),
5052
5151
  )
5152
+ p_init.add_argument("--available-function", action="append", default=[])
5053
5153
  p_init.add_argument("--critic", default="")
5054
5154
 
5055
5155
  p_step = sub.add_parser("step")
@@ -5079,6 +5179,8 @@ def _cli(argv: list[str]) -> int:
5079
5179
  project_root=args.project_root,
5080
5180
  project_id=args.project_id,
5081
5181
  host_runtime=args.host_runtime,
5182
+ host_entry_mode=args.entry_mode,
5183
+ available_functions=args.available_function,
5082
5184
  )
5083
5185
  if args.critic:
5084
5186
  state.critic = args.critic
@@ -29,8 +29,13 @@ READING_CONFIRMATION_HEADING_RE = re.compile(
29
29
  r"^##[ \t]+0\.[ \t]+Reading Confirmation\b", re.MULTILINE
30
30
  )
31
31
 
32
+ # The row's prefix opens it and the end of the line closes it, so the backticks
33
+ # the template prescribes are decoration and are accepted either way. Requiring
34
+ # them once cost a run: a worker wrote six correct paths bare, none of the rows
35
+ # parsed, and every citation it made read as unbacked — a file it demonstrably
36
+ # opened counted as unread over markdown punctuation.
32
37
  _EVIDENCE_READ_RE = re.compile(
33
- r"^- Evidence read: `(?P<path>[^`\n]+)`\s*$",
38
+ r"^- Evidence read: `?(?P<path>[^`\n]+?)`?\s*$",
34
39
  re.MULTILINE,
35
40
  )
36
41
  _FILE_LINE_CITATION_RE = re.compile(
@@ -106,17 +111,37 @@ def _evidence_read_ledger_failures(
106
111
  except OSError as exc:
107
112
  return [f"worker audit sidecar unreadable: {audit_path.name} ({exc})"]
108
113
 
109
- missing_paths = sorted(
110
- _cited_file_paths(result_content) - _audit_evidence_read_paths(audit_content)
111
- )
114
+ read_paths = _audit_evidence_read_paths(audit_content)
115
+ missing_paths = sorted(_cited_file_paths(result_content) - read_paths)
112
116
  return [
113
117
  f"worker `{worker_role}` result `{result_name}` cites "
114
118
  f"`{missing_path}:line` without an Evidence read row for "
115
119
  f"`{missing_path}` in `{audit_path.name}`"
120
+ + _ledger_path_hint(missing_path, read_paths)
116
121
  for missing_path in missing_paths
117
122
  ]
118
123
 
119
124
 
125
+ def _ledger_path_hint(cited_path: str, read_paths: set[str]) -> str:
126
+ """What to cite instead, when the ledger plainly holds the same file.
127
+
128
+ A worker that cites the full path once and the bare filename afterwards
129
+ reads to this check as never having opened the file, and the bare failure
130
+ text sends the reader looking for a missing read that is not missing. One
131
+ unambiguous ledger row makes the remedy a rewritten citation; two rows
132
+ ending the same way make it a guess, so those say nothing.
133
+ """
134
+ if "/" in cited_path:
135
+ return ""
136
+ same_name = [path for path in read_paths if path.rsplit("/", 1)[-1] == cited_path]
137
+ if len(same_name) != 1:
138
+ return ""
139
+ return (
140
+ f" — the ledger records it as `{same_name[0]}`; cite that "
141
+ f"project-relative path, not the bare filename"
142
+ )
143
+
144
+
120
145
  def _result_files(run_dir: Path, task_type: str, seq: str | None, worker: str | None):
121
146
  """Every worker-results file in *run_dir* this check owns, in name order."""
122
147
  for path in sorted((run_dir / "worker-results").glob("*.md")):
@@ -81,6 +81,13 @@ def resolve_prompt_plan(
81
81
  raise ValueError("worker ID is required")
82
82
  if dispatch_kind.startswith("reverify-r"):
83
83
  return _plan("reverify")
84
+ # A critic pass keeps the full analysis contract — worker anchor headers, the
85
+ # audit sidecar, the packet boundary — but is exempt from the equality group.
86
+ # Its body is deliberately unlike the initial one (it asks for coverage gaps
87
+ # and unrequested work, not findings), so grouping it with the initial
88
+ # analysis prompts makes `validate_analysis_prompt_set` compare two prompts
89
+ # that are *supposed* to differ and fail every critic-enabled run.
90
+ analysis_equality_group = None if dispatch_kind == "critic" else "analysis-core"
84
91
  if task_type == "implementation" and not executor_worker_id:
85
92
  raise ValueError("implementation executor worker ID is required")
86
93
  if worker_id == "report-writer":
@@ -101,7 +108,7 @@ def resolve_prompt_plan(
101
108
  if task_type == "final-verification":
102
109
  return _plan(
103
110
  "analysis",
104
- equality_group="analysis-core",
111
+ equality_group=analysis_equality_group,
105
112
  packet_only=True,
106
113
  required_headers=FINAL_VERIFICATION_HEADERS,
107
114
  max_body_lines=96,
@@ -110,11 +117,11 @@ def resolve_prompt_plan(
110
117
  if task_type == "improvement-discovery":
111
118
  return _plan(
112
119
  "analysis",
113
- equality_group="analysis-core",
120
+ equality_group=analysis_equality_group,
114
121
  packet_only=True,
115
122
  required_headers=(GRILLING_LOG_HEADER,),
116
123
  )
117
- return _plan("analysis", equality_group="analysis-core", packet_only=True)
124
+ return _plan("analysis", equality_group=analysis_equality_group, packet_only=True)
118
125
 
119
126
 
120
127
  def resolve_prompt_plan_for_manifest(