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
@@ -51,7 +51,8 @@ from okstra_ctl.conformance import ( # noqa: E402
51
51
  validate_conformance_manifest,
52
52
  )
53
53
  from okstra_ctl.paths import RunRef # noqa: E402
54
- from okstra_ctl.lead_runtime import ARTIFACT_ONLY_LEAD_RUNTIMES # noqa: E402
54
+ from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
55
+ from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
55
56
  from okstra_ctl.build_tools import ( # noqa: E402
56
57
  command_invokes_build_tool,
57
58
  resolve_build_tool_tokens,
@@ -65,7 +66,7 @@ from okstra_ctl.report_translation import ( # noqa: E402
65
66
  HANGUL_PROSE_LIMIT,
66
67
  hangul_share,
67
68
  )
68
- from okstra_ctl.stage_citations import cited_stage_numbers # noqa: E402
69
+ from okstra_ctl.stage_citations import enumerated_stage_numbers # noqa: E402
69
70
  from okstra_ctl.incremental_scope import ( # noqa: E402
70
71
  coverage_row_blocked_on,
71
72
  stages_for_clarification,
@@ -170,11 +171,11 @@ def _session_accounting(team_state: dict) -> str:
170
171
  value = str(adapter.get("sessionAccounting", "")).strip()
171
172
  if value:
172
173
  return value
173
- return (
174
- "artifact-only"
175
- if team_state.get("leadRuntime") in ARTIFACT_ONLY_LEAD_RUNTIMES
176
- else "claude-jsonl"
177
- )
174
+ runtime = str(team_state.get("leadRuntime", "")).strip()
175
+ try:
176
+ return default_host_registry().resolve(runtime).descriptor.session_accounting
177
+ except HostNotRegistered:
178
+ return "claude-jsonl"
178
179
 
179
180
 
180
181
 
@@ -742,7 +743,7 @@ def validate_team_state(
742
743
  dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
743
744
  if (
744
745
  dispatch_mode in WORKER_DISPATCH_MODES
745
- or team_state.get("leadRuntime") in ARTIFACT_ONLY_LEAD_RUNTIMES
746
+ or _session_accounting(team_state) == "artifact-only"
746
747
  ):
747
748
  if dispatch_mode not in WORKER_DISPATCH_MODES:
748
749
  expected = ", ".join(sorted(WORKER_DISPATCH_MODES))
@@ -3132,6 +3133,7 @@ def validate_final_report_data(
3132
3133
  # Called here rather than from a task-type branch: four profiles raise
3133
3134
  # clarification rows, and the gate scopes itself by task type internally.
3134
3135
  _validate_clarification_options(data, failures)
3136
+ _validate_open_approval_blocker_provenance(data, failures)
3135
3137
 
3136
3138
  task_type = (data.get("header") or {}).get("taskType")
3137
3139
  _validate_verifier_fail_blocks_verdict(data, failures)
@@ -4125,6 +4127,80 @@ def _validate_option_set(
4125
4127
  )
4126
4128
 
4127
4129
 
4130
+ def _validate_open_approval_blocker_provenance(
4131
+ data: dict, failures: list[str]
4132
+ ) -> None:
4133
+ """An open approval blocker records who raised it and what the lead did about it.
4134
+
4135
+ A lead can foresee a blocker, write it into the report as a
4136
+ `blocks: approval` row, and let the run finish — the row then withholds
4137
+ approval until a separate user-response cycle answers it. That path cost a
4138
+ full planning run: the user pointed at a reduction document and said "remove
4139
+ what it flags", the lead read that document's own "user decision required"
4140
+ note as outranking the instruction, and told the report writer to raise the
4141
+ item instead of applying it. The two verifiers then agreed unanimously that
4142
+ it was a user decision — not an independent finding, but the lead's own
4143
+ instruction returning to it. The run spent its whole self-fix budget on a
4144
+ gate no round could clear, because the thing blocking it was already
4145
+ answered before the run started.
4146
+
4147
+ `origin` names who produced the row, so a lead-authored blocker can no
4148
+ longer arrive dressed as a worker consensus. `userConfirmation` records what
4149
+ the lead did before writing it. Neither is required on a row the user has
4150
+ already answered (`answered` / `resolved`) — the record is in `userInput`.
4151
+
4152
+ The one shape rejected outright is a lead-directed blocker raised with no
4153
+ interactive session: with nobody to ask, a defect the lead itself surfaced
4154
+ belongs in `## 5. Missing Information and Risks` as a Working Assumption,
4155
+ the same outlet the surviving planner-fixable items already use. Blocking a
4156
+ plan on the lead's own judgment, in a run where that judgment cannot be put
4157
+ to the user, only defers the work to a re-run.
4158
+ """
4159
+ for row in data.get("clarificationItems") or []:
4160
+ if not isinstance(row, dict):
4161
+ continue
4162
+ if row.get("blocks") != "approval" or row.get("status") != "open":
4163
+ continue
4164
+ row_id = str(row.get("id") or "<unknown>")
4165
+ origin = row.get("origin")
4166
+ confirmation = row.get("userConfirmation")
4167
+ if not origin:
4168
+ failures.append(
4169
+ f"final-report data.json: clarification `{row_id}` is an open "
4170
+ "approval blocker with no `origin`. Record who raised it — "
4171
+ "`worker-finding` (an analyser or verifier reached it "
4172
+ "independently), `material-gap` (the brief and the codebase "
4173
+ "both leave it unanswered), or `lead-directed` (the lead's own "
4174
+ "judgment, including anything the lead instructed a worker to "
4175
+ "raise). A lead-authored blocker recorded as a worker consensus "
4176
+ "is how a run blocks on a question the user already answered."
4177
+ )
4178
+ if not confirmation:
4179
+ failures.append(
4180
+ f"final-report data.json: clarification `{row_id}` is an open "
4181
+ "approval blocker with no `userConfirmation`. Record what "
4182
+ "happened before the row was written — `asked-and-answered`, "
4183
+ "`asked-awaiting`, or `deferred-no-interactive-session`. "
4184
+ "Withholding approval is the most expensive thing a report can "
4185
+ "do to a run; doing it without recording whether the user was "
4186
+ "ever asked leaves the next run unable to tell a real blocker "
4187
+ "from a question nobody put to them."
4188
+ )
4189
+ if (
4190
+ origin == "lead-directed"
4191
+ and confirmation == "deferred-no-interactive-session"
4192
+ ):
4193
+ failures.append(
4194
+ f"final-report data.json: clarification `{row_id}` blocks "
4195
+ "approval on the lead's own judgment (`origin: lead-directed`) "
4196
+ "in a run with no interactive session to answer it. Record it "
4197
+ "as a Working Assumption in `## 5. Missing Information and "
4198
+ "Risks` and let the plan proceed — that is the outlet surviving "
4199
+ "planner-fixable items already use. A blocker nobody can answer "
4200
+ "inside this run only moves the work to a re-run."
4201
+ )
4202
+
4203
+
4128
4204
  def _has_clarification_backtrace(
4129
4205
  row_id: str, plan_items: object, coverage: object
4130
4206
  ) -> bool:
@@ -6346,10 +6422,14 @@ def _validate_requirement_provenance(
6346
6422
  # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
6347
6423
  # what "no match" means — forward treats it as nothing to verify and passes,
6348
6424
  # reverse treats it as nothing cited and orphans every stage — so the reverse
6349
- # reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too. The
6350
- # grammar is shared with the incremental-scope back-trace, which resolves the
6351
- # same citations in the opposite direction.
6352
- _cited_stage_numbers = cited_stage_numbers
6425
+ # reader must cover `Stages 1, 2` and `Stage 1 and 2` too.
6426
+ #
6427
+ # It reads hand-enumerated numbers only. A range's interior is deliberately NOT
6428
+ # coverage evidence: one `Stages 1-64` cell would otherwise stamp every stage in
6429
+ # the map while the planner confirmed none of them. The incremental-scope
6430
+ # back-trace keeps the widening reader (`cited_stage_numbers`) because "could
6431
+ # this answer reach stage 5" is the opposite question and must over-approximate.
6432
+ _enumerated_stage_numbers = enumerated_stage_numbers
6353
6433
 
6354
6434
 
6355
6435
  # Statuses under which a row asserts the plan does work for the requirement.
@@ -6386,14 +6466,16 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
6386
6466
  continue
6387
6467
  if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
6388
6468
  continue
6389
- cited |= _cited_stage_numbers(str(row.get("coveredBy") or ""))
6469
+ cited |= _enumerated_stage_numbers(str(row.get("coveredBy") or ""))
6390
6470
 
6391
6471
  for orphan in sorted(stage_numbers - cited):
6392
6472
  failures.append(
6393
6473
  f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
6394
6474
  "row — it traces back to nothing the brief asked for. Either cite the "
6395
6475
  "requirement it serves, or drop it and raise it as a `Blocks=approval` "
6396
- "clarification (profile: scope provenance)."
6476
+ "clarification (profile: scope provenance). A range cites only its "
6477
+ "endpoints here: write `Stages 1, 2, 3` rather than `Stages 1-3`, so each "
6478
+ "stage this requirement covers is one the planner named."
6397
6479
  )
6398
6480
 
6399
6481
 
@@ -39,7 +39,8 @@ from okstra_ctl.worker_heartbeat import ( # noqa: E402
39
39
  IN_STAGE_PREFIX,
40
40
  max_gap_seconds_after,
41
41
  )
42
- from okstra_ctl.lead_runtime import ARTIFACT_ONLY_LEAD_RUNTIMES # noqa: E402
42
+ from okstra_ctl.domain.host import HostNotRegistered # noqa: E402
43
+ from okstra_ctl.registry.host_registry import default_host_registry # noqa: E402
43
44
  from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
44
45
 
45
46
  _DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
@@ -414,7 +415,11 @@ def _conformance_evidence_source(
414
415
  return None, f"unsupported sessionAccounting: `{accounting}`"
415
416
 
416
417
  legacy_runtime = str(team_state.get("leadRuntime", "") or "claude-code")
417
- if legacy_runtime in ARTIFACT_ONLY_LEAD_RUNTIMES:
418
+ try:
419
+ accounting = default_host_registry().resolve(legacy_runtime).descriptor.session_accounting
420
+ except HostNotRegistered:
421
+ accounting = "claude-jsonl"
422
+ if accounting == "artifact-only":
418
423
  return "artifact-only", None
419
424
  return "claude-jsonl", None
420
425
 
@@ -452,6 +457,61 @@ def _plan_body_rounds_ran(run_dir: Path, suffix: str | None) -> int:
452
457
  return rounds if isinstance(rounds, int) and rounds > 0 else 0
453
458
 
454
459
 
460
+ def _ids_reported_as_asked(report_path: Path) -> list[str]:
461
+ """리포트가 "사용자에게 물었다"고 기록한 열린 승인 차단 행의 id."""
462
+ name = report_path.name
463
+ if not name.endswith(".md"):
464
+ return []
465
+ data_path = report_path.with_name(name.removesuffix(".md") + ".data.json")
466
+ try:
467
+ doc = json.loads(data_path.read_text())
468
+ except (OSError, json.JSONDecodeError):
469
+ return []
470
+ if not isinstance(doc, dict):
471
+ return []
472
+ asked: list[str] = []
473
+ for row in doc.get("clarificationItems") or []:
474
+ if not isinstance(row, dict):
475
+ continue
476
+ if row.get("blocks") != "approval" or row.get("status") != "open":
477
+ continue
478
+ if not str(row.get("userConfirmation") or "").startswith("asked-"):
479
+ continue
480
+ row_id = str(row.get("id") or "").strip()
481
+ if row_id:
482
+ asked.append(row_id)
483
+ return asked
484
+
485
+
486
+ def _check_user_confirm_checkpoints(
487
+ by_phase: dict[str, list[tuple[str, str]]],
488
+ asked_ids: list[str],
489
+ errors: list[str],
490
+ ) -> None:
491
+ """"물어봤다"는 리포트의 기록에는 물어본 흔적이 세션에 남아 있어야 한다.
492
+
493
+ 승인을 막는 행은 run 하나를 통째로 대기 상태로 만든다. 그런 행을 세우기 전에
494
+ 사용자에게 그 자리에서 묻는 것이 계약인데, 그 이행 여부를 리포트의 자기 신고
495
+ 말고는 확인할 길이 없었다. 한 번은 리드가 차단이 생길 것을 미리 예측해 말해
496
+ 놓고도 묻지 않은 채 행으로 만들었고, run 은 사용자가 이미 답해 둔 질문 앞에서
497
+ self-fix 예산을 전부 썼다. 체크포인트가 그 격차를 좁힌다 — 세션에 질문이 남지
498
+ 않았다면 리드는 묻지 않은 것이다."""
499
+ if not asked_ids:
500
+ return
501
+ detail = "prompts/lead/okstra-lead-contract.md 'User confirmation before an approval blocker'"
502
+ asked_lines = " ".join(line for _ts, line in by_phase.get("user-confirm", []))
503
+ for row_id in asked_ids:
504
+ if row_id in asked_lines:
505
+ continue
506
+ errors.append(
507
+ f"PROGRESS checkpoint missing: `user-confirm {row_id}` — the final "
508
+ f"report records `{row_id}` as an approval blocker the user was "
509
+ "asked about, but no such line exists in this run's evidence. Either "
510
+ "ask before writing the row, or record what actually happened "
511
+ f"(`deferred-no-interactive-session`) ({detail})."
512
+ )
513
+
514
+
455
515
  def _phase_mentions_worker(lines: list[tuple[str, str]], needles: list[str]) -> bool:
456
516
  return any(needle in _norm(line) for _ts, line in lines for needle in needles)
457
517
 
@@ -555,6 +615,7 @@ def _check_progress_checkpoints(
555
615
  team_state: dict,
556
616
  run_dir: Path,
557
617
  suffix: str | None,
618
+ report_path: Path,
558
619
  errors: list[str],
559
620
  ) -> None:
560
621
  by_phase: dict[str, list[tuple[str, str]]] = {}
@@ -617,6 +678,9 @@ def _check_progress_checkpoints(
617
678
  _check_plan_verify_cleanup_checkpoints(
618
679
  by_phase, _plan_body_rounds_ran(run_dir, suffix), errors
619
680
  )
681
+ _check_user_confirm_checkpoints(
682
+ by_phase, _ids_reported_as_asked(report_path), errors
683
+ )
620
684
 
621
685
 
622
686
  def _parse_iso(ts: str) -> datetime | None:
@@ -869,7 +933,9 @@ def validate_session_conformance(
869
933
  if error:
870
934
  result.errors.append(error)
871
935
  return result
872
- _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
936
+ _check_progress_checkpoints(
937
+ evidence, team_state, run_dir, suffix, report_path, result.errors
938
+ )
873
939
  _check_cmux_adapter_read(evidence, team_state, result.errors)
874
940
  if task_type == "implementation":
875
941
  _check_implementation_entry_guard(
@@ -218,13 +218,6 @@ export const COMMAND_REGISTRY = [
218
218
  category: "introspection",
219
219
  summary: ["Bundle cross-project okstra error logs into a zip"],
220
220
  },
221
- {
222
- name: "error-issue",
223
- module: "./commands/inspect/error-issue.mjs",
224
- export: "run",
225
- category: "introspection",
226
- summary: ["Turn okstra run anomalies into GitHub issue candidates"],
227
- },
228
221
  {
229
222
  name: "run-audit",
230
223
  module: "./commands/inspect/run-audit.mjs",
@@ -1,6 +1,6 @@
1
1
  import { runPythonModule } from "../../lib/python-helper.mjs";
2
2
  import { resolvePaths } from "../../lib/paths.mjs";
3
- import { resolveRuntime } from "../../lib/runtime-resolver.mjs";
3
+ import { resolveRuntime } from "../../lib/host-registry-client.mjs";
4
4
 
5
5
  const USAGE = `okstra render-bundle — preview the task bundle without launching a lead
6
6
 
@@ -18,7 +18,7 @@ Usage:
18
18
  [--analysis-target <text>] [--evidence-inputs <csv>] \\
19
19
  [--lead-model <m>] [--claude-model <m>] [--codex-model <m>] \\
20
20
  [--antigravity-model <m>] [--report-writer-model <m>] \\
21
- [--lead-runtime claude-code|codex|antigravity|external] \\
21
+ [--lead-runtime auto|<host-id-or-alias>] \\
22
22
  [--related-tasks <list>] [--base-ref <ref>] \\
23
23
  [--clarification-response <path>] [--work-category <cat>] \\
24
24
  [--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>] \\
@@ -26,7 +26,7 @@ Usage:
26
26
 
27
27
  --stage implementation / final-verification only
28
28
  --stages release-handoff only: PR stage bundle csv (empty = whole-task)
29
- --lead-runtime host-native lead adapter marker
29
+ --lead-runtime registered host ID or alias
30
30
  --pr-template-path release-handoff only
31
31
  --fix-cycle entry phase only: record this re-entry of a done task as a
32
32
  bug-fix cycle (yes opens/continues the cycle)
@@ -85,7 +85,7 @@ export async function run(args) {
85
85
  }
86
86
 
87
87
  const requestedRuntime = findFlagValue(args, "--lead-runtime") || "auto";
88
- const runtimeResolution = resolveRuntime({
88
+ const runtimeResolution = await resolveRuntime({
89
89
  requestedRuntime,
90
90
  command: "render",
91
91
  env: process.env,
@@ -5,14 +5,14 @@ import { join } from "node:path";
5
5
  import { runEnsureInstalled } from "../lifecycle/install.mjs";
6
6
  import { runPythonModule } from "../../lib/python-helper.mjs";
7
7
  import { resolvePaths } from "../../lib/paths.mjs";
8
- import { resolveRuntime } from "../../lib/runtime-resolver.mjs";
8
+ import { resolveRuntime } from "../../lib/host-registry-client.mjs";
9
9
 
10
10
  const USAGE = `okstra run — host-aware okstra execution front door
11
11
 
12
12
  Usage:
13
- okstra run [claude|codex|antigravity|external] --project-root <dir> \\
13
+ okstra run [<host-id-or-alias>] --project-root <dir> \\
14
14
  --project-id <id> --task-group <tg> --task-id <tid> --task-type <type> \\
15
- [--lead-runtime auto|claude-code|codex|antigravity|external] [--yes]
15
+ [--lead-runtime auto|<host-id-or-alias>] [--yes]
16
16
 
17
17
  The leading word selects the lead and is an alias for --lead-runtime, so
18
18
  'okstra run codex' starts a Codex lead. Naming a lead starts that CLI with the
@@ -21,22 +21,6 @@ team. Starting a lead whose CLI sandboxes itself asks for confirmation first,
21
21
  which --yes answers.
22
22
  `;
23
23
 
24
- // Leads okstra can start itself. `external` is not one: it names a harness
25
- // okstra does not own, so those runs are orchestrated rather than launched.
26
- const LAUNCHABLE_RUNTIMES = new Set(["claude-code", "codex", "antigravity"]);
27
-
28
- // The leading word users actually type. `claude` is the provider's name while
29
- // the runtime is `claude-code`; the rest coincide.
30
- const RUNTIME_ALIASES = new Map([
31
- ["claude", "claude-code"],
32
- ["claude-code", "claude-code"],
33
- ["codex", "codex"],
34
- ["antigravity", "antigravity"],
35
- ["avg", "antigravity"],
36
- ["agy", "antigravity"],
37
- ["external", "external"],
38
- ]);
39
-
40
24
  function flagValue(args, flag) {
41
25
  const equals = args.find((arg) => arg.startsWith(`${flag}=`));
42
26
  if (equals) return equals.slice(flag.length + 1);
@@ -46,8 +30,7 @@ function flagValue(args, flag) {
46
30
 
47
31
  export function positionalRuntime(args) {
48
32
  const first = args[0];
49
- if (!first || first.startsWith("-")) return "";
50
- return RUNTIME_ALIASES.get(first) || "";
33
+ return !first || first.startsWith("-") ? "" : first;
51
34
  }
52
35
 
53
36
  export function requestedRuntimeFromArgs(args) {
@@ -91,7 +74,7 @@ function renderArgs(args, resolution) {
91
74
  export function buildRunPlan({ args, paths, resolution, runManifestPath = "" }) {
92
75
  void paths;
93
76
  const projectRoot = projectRootFromArgs(args);
94
- if (resolution.resolvedRuntime === "external") {
77
+ if (resolution.launchMode === "team") {
95
78
  return {
96
79
  ok: true,
97
80
  kind: "team",
@@ -104,7 +87,7 @@ export function buildRunPlan({ args, paths, resolution, runManifestPath = "" })
104
87
  };
105
88
  }
106
89
 
107
- if (LAUNCHABLE_RUNTIMES.has(resolution.resolvedRuntime)) {
90
+ if (resolution.launchMode === "lead") {
108
91
  return {
109
92
  ok: true,
110
93
  kind: "launch-lead",
@@ -130,7 +113,7 @@ export function buildRunPlan({ args, paths, resolution, runManifestPath = "" })
130
113
  };
131
114
  }
132
115
 
133
- return { ok: false, reason: `unsupported resolved runtime: ${resolution.resolvedRuntime}`, commands: [] };
116
+ return { ok: false, reason: `unsupported launch mode: ${resolution.launchMode}`, commands: [] };
134
117
  }
135
118
 
136
119
  async function ensureRuntime(requestedRuntime) {
@@ -182,7 +165,7 @@ export async function run(args) {
182
165
 
183
166
  const paths = await resolvePaths();
184
167
  const requestedRuntime = requestedRuntimeFromArgs(args);
185
- const resolution = resolveRuntime({
168
+ const resolution = await resolveRuntime({
186
169
  requestedRuntime,
187
170
  command: "run",
188
171
  env: process.env,
@@ -20,7 +20,8 @@ Subcommands:
20
20
 
21
21
  Usage:
22
22
  okstra wizard new-state-file
23
- okstra wizard init --state-file <path> --project-root <p> --project-id <id> --host-runtime <runtime>
23
+ okstra wizard init --state-file <path> --project-root <p> --project-id <id> --host-runtime <runtime> \\
24
+ --entry-mode current-session --available-function <semantic-name>
24
25
  okstra wizard step --state-file <path> [--answer <value>]
25
26
  okstra wizard render-args --state-file <path>
26
27
  okstra wizard confirmation --state-file <path>
@@ -40,6 +41,13 @@ All subcommands emit a single JSON object on stdout. On validation failure
40
41
  // callers pass any value (including empty) unambiguously.
41
42
  export function parseFlags(args) {
42
43
  const out = {};
44
+ const setFlag = (key, value) => {
45
+ if (key === "available-function") {
46
+ out[key] = [...(out[key] ?? []), value];
47
+ return;
48
+ }
49
+ out[key] = value;
50
+ };
43
51
  for (let i = 0; i < args.length; i += 1) {
44
52
  const a = args[i];
45
53
  if (!a.startsWith("--")) {
@@ -47,19 +55,38 @@ export function parseFlags(args) {
47
55
  }
48
56
  const eq = a.indexOf("=");
49
57
  if (eq !== -1) {
50
- out[a.slice(2, eq)] = a.slice(eq + 1);
58
+ setFlag(a.slice(2, eq), a.slice(eq + 1));
51
59
  continue;
52
60
  }
53
61
  const key = a.slice(2);
54
62
  if (i + 1 >= args.length) {
55
63
  throw new Error(`flag --${key} requires a value`);
56
64
  }
57
- out[key] = args[i + 1];
65
+ setFlag(key, args[i + 1]);
58
66
  i += 1;
59
67
  }
60
68
  return out;
61
69
  }
62
70
 
71
+ export function buildPythonArgs(sub, flags, workspaceRoot = "") {
72
+ const pyArgs = [sub, "--state-file", flags["state-file"]];
73
+ if (sub === "init") {
74
+ pyArgs.push("--workspace-root", workspaceRoot);
75
+ pyArgs.push("--project-root", flags["project-root"]);
76
+ pyArgs.push("--project-id", flags["project-id"]);
77
+ if (flags["host-runtime"]) {
78
+ pyArgs.push("--host-runtime", flags["host-runtime"]);
79
+ }
80
+ for (const value of flags["available-function"] ?? []) {
81
+ pyArgs.push("--available-function", value);
82
+ }
83
+ pyArgs.push("--entry-mode", flags["entry-mode"] ?? "current-session");
84
+ } else if (sub === "step" && flags.answer !== undefined) {
85
+ pyArgs.push("--answer", flags.answer);
86
+ }
87
+ return pyArgs;
88
+ }
89
+
63
90
  export async function run(args) {
64
91
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
65
92
  process.stdout.write(USAGE);
@@ -95,23 +122,16 @@ export async function run(args) {
95
122
  return 2;
96
123
  }
97
124
 
98
- // build python argv
99
- const pyArgs = [sub, "--state-file", flags["state-file"]];
125
+ let workspaceRoot = "";
100
126
  if (sub === "init") {
101
127
  if (!flags["project-root"] || !flags["project-id"]) {
102
128
  process.stderr.write("error: init requires --project-root and --project-id\n");
103
129
  return 2;
104
130
  }
105
131
  const paths = await resolvePaths();
106
- pyArgs.push("--workspace-root", paths.workspace);
107
- pyArgs.push("--project-root", flags["project-root"]);
108
- pyArgs.push("--project-id", flags["project-id"]);
109
- if (flags["host-runtime"]) {
110
- pyArgs.push("--host-runtime", flags["host-runtime"]);
111
- }
112
- } else if (sub === "step" && flags.answer !== undefined) {
113
- pyArgs.push("--answer", flags.answer);
132
+ workspaceRoot = paths.workspace;
114
133
  }
134
+ const pyArgs = buildPythonArgs(sub, flags, workspaceRoot);
115
135
 
116
136
  const result = await runPythonModule({
117
137
  module: "okstra_ctl.wizard",
@@ -5,7 +5,7 @@ import { resolvePaths } from "../../lib/paths.mjs";
5
5
  import { fileExists, runProcess } from "../../lib/proc.mjs";
6
6
  import { runPythonSnippet } from "../../lib/python-helper.mjs";
7
7
  import { runtimeIncludesClaudeAssets, validateInstallRuntime } from "./install.mjs";
8
- import { resolveRuntime } from "../../lib/runtime-resolver.mjs";
8
+ import { resolveRuntime } from "../../lib/host-registry-client.mjs";
9
9
 
10
10
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
11
11
  const REQUIRED_SKILLS = ["okstra-setup", "okstra-run"];
@@ -14,7 +14,7 @@ const USAGE = `okstra doctor — diagnose the installed runtime
14
14
 
15
15
  Usage:
16
16
  okstra doctor Run all checks and print a summary
17
- okstra doctor --runtime <claude-code|codex|antigravity|external|all>
17
+ okstra doctor --runtime <host-id|alias|all>
18
18
  okstra doctor --phase <phase>
19
19
  okstra doctor --json Machine-readable result
20
20
  Checks:
@@ -48,7 +48,7 @@ export function parseDoctorArgs(args) {
48
48
  } else if (arg === "--runtime" || arg.startsWith("--runtime=")) {
49
49
  const value = arg.startsWith("--runtime=") ? arg.slice("--runtime=".length) : args[i + 1];
50
50
  if (!value || value.startsWith("--")) {
51
- throw new Error("--runtime requires one of: claude-code, codex, antigravity, external, all");
51
+ throw new Error("--runtime requires a host ID, alias, or all");
52
52
  }
53
53
  result.runtime = validateInstallRuntime(value);
54
54
  if (arg === "--runtime") i++;
@@ -66,8 +66,8 @@ export function parseDoctorArgs(args) {
66
66
  return result;
67
67
  }
68
68
 
69
- export function requiredSkillNamesForRuntime(runtime) {
70
- return runtimeIncludesClaudeAssets(runtime) ? REQUIRED_SKILLS : [];
69
+ export function requiredSkillNamesForRuntime(resolution) {
70
+ return runtimeIncludesClaudeAssets(resolution) ? REQUIRED_SKILLS : [];
71
71
  }
72
72
 
73
73
  export function phaseDiagnosticsToChecks(phase, phaseDiagnostics) {
@@ -194,7 +194,7 @@ export async function run(args) {
194
194
  return 2;
195
195
  }
196
196
  const paths = await resolvePaths();
197
- const runtimeResolution = resolveRuntime({
197
+ const runtimeResolution = await resolveRuntime({
198
198
  requestedRuntime: opts.runtime,
199
199
  command: "doctor",
200
200
  env: process.env,
@@ -204,8 +204,8 @@ export async function run(args) {
204
204
  // auto-detection only means we cannot attribute the optional skill checks to a
205
205
  // runtime, so degrade it to a warning and keep running the host-independent
206
206
  // checks. Dispatch callers of resolveRuntime stay fail-closed by design — this
207
- // leniency lives only here. With resolvedRuntime null the skill checks skip
208
- // (requiredSkillNamesForRuntime → []); pass --runtime to include them.
207
+ // leniency lives only here. A failed resolution has no install targets, so
208
+ // skill checks skip; pass --runtime to include them.
209
209
  const resolvedRuntime = runtimeResolution.ok ? runtimeResolution.resolvedRuntime : null;
210
210
 
211
211
  // docker is OPTIONAL for okstra (only the container runtime needs it). Like
@@ -231,7 +231,7 @@ export async function run(args) {
231
231
  await check("bin: okstra-provider-exec.py", () => checkBashEntry(paths.bin, "okstra-provider-exec.py")),
232
232
  await check("bin: okstra-central.sh", () => checkBashEntry(paths.bin, "okstra-central.sh")),
233
233
  ...(await Promise.all(
234
- requiredSkillNamesForRuntime(resolvedRuntime).map((name) =>
234
+ requiredSkillNamesForRuntime(runtimeResolution.ok ? runtimeResolution : null).map((name) =>
235
235
  check(`skill: ${name}`, async () => {
236
236
  const p = join(CLAUDE_SKILLS_DIR, name, "SKILL.md");
237
237
  return (await fileExists(p))
@@ -278,7 +278,7 @@ export async function run(args) {
278
278
 
279
279
  if (!runtimeResolution.ok) {
280
280
  process.stdout.write(
281
- ` [WARN] runtime host: ${runtimeResolution.reason} skill checks skipped — pass --runtime <claude-code|codex|antigravity|external> to include them.\n`,
281
+ ` [WARN] runtime host: ${runtimeResolution.reason} skill checks skipped — pass --runtime <host-id|alias> to include them.\n`,
282
282
  );
283
283
  }
284
284
  if (dockerWarning) {