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
@@ -30,11 +30,18 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
30
30
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
31
31
  from . import fix_cycles
32
32
  from .analysis_inputs import ANALYSIS_TASK_TYPES
33
+ from .application.resolve_assignment import (
34
+ resolve_assignment_runner,
35
+ resolve_lead_provider,
36
+ )
37
+ from .application.dispatch_assignments import dispatch_assignments
38
+ from .adapters.dispatch import default_worker_dispatch_port, with_worker_dispatch
39
+ from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
33
40
  from .paths import okstra_home
34
- from .dispatch_state import BACKEND_CMUX_PANE
35
- from .lead_runtime import lead_runtime_info, with_cmux_dispatch
36
41
  from .models import UnknownProviderError, provider_ids, provider_spec
37
- from .runner_resolution import native_provider_for_host
42
+ from .ports.worker_dispatch import WorkerDispatchRequest
43
+ from .registry.host_registry import default_host_registry
44
+ from .registry.provider_registry import default_provider_registry
38
45
  from .path_hints import compact_active_run_context, hydrate_run_context
39
46
  from .paths import task_timeline_file
40
47
  from .worker_artifacts import artifacts_from_context
@@ -80,27 +87,49 @@ def _lead_runtime(ctx: dict) -> str:
80
87
  return ctx.get("LEAD_RUNTIME", "") or "claude-code"
81
88
 
82
89
 
83
- def _lead_info(ctx: dict):
84
- info = lead_runtime_info(_lead_runtime(ctx))
85
- if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
86
- return with_cmux_dispatch(info)
87
- return info
90
+ def _lead_descriptor(ctx: dict):
91
+ return default_host_registry().resolve(_lead_runtime(ctx)).descriptor
92
+
93
+
94
+ def _worker_dispatch_plan(ctx: dict):
95
+ host_adapter = default_host_registry().resolve(_lead_runtime(ctx))
96
+ fallback_port = default_worker_dispatch_port(host_adapter.descriptor)
97
+ port = dispatch_port_for_terminal_backend(
98
+ ctx.get("TERMINAL_BACKEND", ""),
99
+ fallback_port,
100
+ )
101
+ decorated = with_worker_dispatch(host_adapter, port)
102
+ request = WorkerDispatchRequest(
103
+ project_root=Path(ctx.get("PROJECT_ROOT", "")),
104
+ run_manifest=Path(ctx.get("RUN_MANIFEST_PATH", "")),
105
+ )
106
+ return dispatch_assignments(request, decorated.worker_dispatch())
88
107
 
89
108
 
90
109
  def _lead_agent(ctx: dict) -> str:
91
- return _lead_info(ctx).agent
110
+ return _lead_descriptor(ctx).agent_id
92
111
 
93
112
 
94
113
  def _lead_agent_label(ctx: dict) -> str:
95
- return _lead_info(ctx).agent_label
114
+ return _lead_descriptor(ctx).agent_label
96
115
 
97
116
 
98
117
  def _lead_role(ctx: dict) -> str:
99
- return _lead_info(ctx).role
118
+ return _lead_descriptor(ctx).role
119
+
120
+
121
+ def _lead_adapter(ctx: dict, plan) -> dict:
122
+ descriptor = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
123
+ return {
124
+ "name": plan.adapter_name or descriptor.id,
125
+ "dispatchMode": plan.dispatch_mode or descriptor.dispatch_mode,
126
+ "sessionAccounting": descriptor.session_accounting,
127
+ }
100
128
 
101
129
 
102
- def _lead_adapter(ctx: dict) -> dict:
103
- return _lead_info(ctx).adapter_payload()
130
+ def _lead_adapter_contract(ctx: dict, plan) -> str:
131
+ descriptor = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
132
+ return plan.relay_contract or descriptor.relay_contract
104
133
 
105
134
 
106
135
  def _runtime_resolution(ctx: dict) -> dict:
@@ -257,31 +286,71 @@ def _resolve_workers(ctx: dict) -> list[str]:
257
286
  ]
258
287
 
259
288
 
260
- def _assignment_runner_for_ctx(ctx: dict, provider: str) -> str:
261
- native_provider = native_provider_for_host(_lead_runtime(ctx))
262
- return "native-session" if provider == native_provider else "cli-wrapper"
289
+ def _assignment_runner_for_ctx(
290
+ ctx: dict,
291
+ provider: str,
292
+ role: str,
293
+ requested_runner: str = "",
294
+ ) -> str:
295
+ host = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
296
+ spec = default_provider_registry().resolve(provider)
297
+ return resolve_assignment_runner(
298
+ host=host,
299
+ provider=spec,
300
+ role=role,
301
+ requested_runner=requested_runner,
302
+ ).runner
303
+
304
+
305
+ def _resolved_lead_assignment(
306
+ ctx: dict,
307
+ requested_provider: str,
308
+ requested_runner: str = "",
309
+ ):
310
+ host_registry = default_host_registry()
311
+ provider_registry = default_provider_registry()
312
+ assignment = resolve_lead_provider(
313
+ host_id=_lead_runtime(ctx),
314
+ requested_provider=requested_provider,
315
+ host_registry=host_registry,
316
+ provider_registry=provider_registry,
317
+ )
318
+ if requested_runner:
319
+ resolution = resolve_assignment_runner(
320
+ host=host_registry.resolve(_lead_runtime(ctx)).descriptor,
321
+ provider=provider_registry.resolve(assignment.provider),
322
+ role="lead",
323
+ requested_runner=requested_runner,
324
+ )
325
+ return assignment.provider, resolution.runner
326
+ return assignment.provider, assignment.runner
263
327
 
264
328
 
265
329
  def _lead_assignment(ctx: dict) -> dict:
330
+ parsed = None
266
331
  raw = ctx.get("LEAD_ASSIGNMENT_JSON", "")
267
332
  if raw:
268
333
  try:
269
- parsed = json.loads(raw)
270
- if isinstance(parsed, dict):
271
- return parsed
334
+ candidate = json.loads(raw)
335
+ if isinstance(candidate, dict):
336
+ parsed = candidate
272
337
  except json.JSONDecodeError:
273
338
  pass
274
- provider = (
275
- ctx.get("LEAD_PROVIDER", "")
276
- or native_provider_for_host(_lead_runtime(ctx))
277
- or "claude"
339
+ source = parsed or {}
340
+ provider, runner = _resolved_lead_assignment(
341
+ ctx,
342
+ source.get("provider", "") or ctx.get("LEAD_PROVIDER", ""),
343
+ source.get("runner", ""),
278
344
  )
279
345
  return {
346
+ **source,
280
347
  "role": "lead",
281
348
  "provider": provider,
282
- "model": ctx.get("LEAD_MODEL", ""),
283
- "modelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
284
- "runner": _assignment_runner_for_ctx(ctx, provider),
349
+ "model": source.get("model", ctx.get("LEAD_MODEL", "")),
350
+ "modelExecutionValue": source.get(
351
+ "modelExecutionValue", ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
352
+ ),
353
+ "runner": runner,
285
354
  }
286
355
 
287
356
 
@@ -291,7 +360,7 @@ def _worker_assignments(ctx: dict) -> list[dict]:
291
360
  try:
292
361
  parsed = json.loads(raw)
293
362
  if isinstance(parsed, list) and all(isinstance(row, dict) for row in parsed):
294
- return parsed
363
+ return [_resolved_worker_assignment(ctx, row) for row in parsed]
295
364
  except json.JSONDecodeError:
296
365
  pass
297
366
  catalog = _worker_catalog(ctx, include_assignments=False)
@@ -302,12 +371,39 @@ def _worker_assignments(ctx: dict) -> list[dict]:
302
371
  "provider": catalog[worker_id]["agent"],
303
372
  "model": catalog[worker_id]["model"],
304
373
  "modelExecutionValue": catalog[worker_id]["modelExecutionValue"],
305
- "runner": _assignment_runner_for_ctx(ctx, catalog[worker_id]["agent"]),
374
+ "runner": _assignment_runner_for_ctx(
375
+ ctx,
376
+ catalog[worker_id]["agent"],
377
+ "report-writer" if worker_id == "report-writer" else "analyser",
378
+ ),
306
379
  }
307
380
  for worker_id in _resolve_workers(ctx)
308
381
  ]
309
382
 
310
383
 
384
+ def _resolved_worker_assignment(ctx: dict, source: dict) -> dict:
385
+ worker_id = source.get("workerId", "")
386
+ role = source.get("role", "") or (
387
+ "report-writer" if worker_id == "report-writer" else "analyser"
388
+ )
389
+ provider = source.get("provider", "") or _fallback_worker_provider(
390
+ ctx, worker_id
391
+ )
392
+ runner = _assignment_runner_for_ctx(
393
+ ctx,
394
+ provider,
395
+ role,
396
+ source.get("runner", ""),
397
+ )
398
+ return {
399
+ **source,
400
+ "workerId": worker_id,
401
+ "role": role,
402
+ "provider": provider,
403
+ "runner": runner,
404
+ }
405
+
406
+
311
407
  def _worker_catalog(ctx: dict, *, include_assignments: bool = True) -> dict:
312
408
  assignments = {} if not include_assignments else {
313
409
  row.get("workerId"): row for row in _worker_assignments(ctx)
@@ -329,7 +425,11 @@ def _worker_catalog(ctx: dict, *, include_assignments: bool = True) -> dict:
329
425
  "agentLabel": provider_info.display_label,
330
426
  "provider": provider,
331
427
  "runner": assignment.get(
332
- "runner", _assignment_runner_for_ctx(ctx, provider),
428
+ "runner", _assignment_runner_for_ctx(
429
+ ctx,
430
+ provider,
431
+ "report-writer" if worker_id == "report-writer" else "analyser",
432
+ ),
333
433
  ),
334
434
  "model": assignment.get("model", model),
335
435
  "modelExecutionValue": assignment.get("modelExecutionValue", execution),
@@ -551,6 +651,7 @@ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
551
651
  def render_team_state(team_state_path: str, ctx: dict) -> None:
552
652
  selected = _resolve_workers(ctx)
553
653
  catalog = _worker_catalog(ctx)
654
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
554
655
  workers = []
555
656
  for w in selected:
556
657
  m = catalog[w]
@@ -577,7 +678,7 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
577
678
  "leadRuntime": _lead_runtime(ctx),
578
679
  "leadAssignment": _lead_assignment(ctx),
579
680
  "workerAssignments": _worker_assignments(ctx),
580
- "leadAdapter": _lead_adapter(ctx),
681
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
581
682
  "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
582
683
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
583
684
  "workflowState": ctx.get("CURRENT_RUN_STATUS", ""),
@@ -1112,6 +1213,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1112
1213
  except Exception:
1113
1214
  existing = {}
1114
1215
  reviewers = _resolve_workers(ctx)
1216
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1115
1217
  catalog = _worker_catalog(ctx)
1116
1218
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1117
1219
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
@@ -1170,7 +1272,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1170
1272
  "leadRuntime": _lead_runtime(ctx),
1171
1273
  "leadAssignment": _lead_assignment(ctx),
1172
1274
  "workerAssignments": _worker_assignments(ctx),
1173
- "leadAdapter": _lead_adapter(ctx),
1275
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
1174
1276
  "workCategory": work_category,
1175
1277
  # user-managed status (set-work-status CLI) — carried across re-renders
1176
1278
  "workStatus": existing.get("workStatus", ""),
@@ -1308,7 +1410,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1308
1410
  "leadSession": {
1309
1411
  "sessionId": ctx.get("CLAUDE_SESSION_ID", ""),
1310
1412
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
1311
- "accounting": _lead_info(ctx).session_accounting,
1413
+ "accounting": _lead_descriptor(ctx).session_accounting,
1312
1414
  },
1313
1415
  "convergence": convergence_block,
1314
1416
  "createdAt": existing.get("createdAt") or ctx.get("RUN_TIMESTAMP_ISO", ""),
@@ -1418,6 +1520,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1418
1520
  except Exception:
1419
1521
  task_manifest = {}
1420
1522
  reviewers = _resolve_workers(ctx)
1523
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1421
1524
  catalog = _worker_catalog(ctx)
1422
1525
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1423
1526
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
@@ -1452,7 +1555,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1452
1555
  "runtimeResolution": _runtime_resolution(ctx),
1453
1556
  "leadAssignment": _lead_assignment(ctx),
1454
1557
  "workerAssignments": _worker_assignments(ctx),
1455
- "leadAdapter": _lead_adapter(ctx),
1558
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
1456
1559
  "workCategory": task_manifest.get(
1457
1560
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1458
1561
  ),
@@ -1957,6 +2060,7 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1957
2060
  selected = _resolve_workers(ctx)
1958
2061
  catalog = _worker_catalog(ctx)
1959
2062
  lead_runtime = _lead_runtime(ctx)
2063
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1960
2064
  lead_role = _lead_role(ctx)
1961
2065
  lead_agent_label = _lead_agent_label(ctx)
1962
2066
  lead_model = ctx.get("LEAD_MODEL", "")
@@ -1966,7 +2070,7 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1966
2070
  home_prompts / "lead" / "okstra-lead-contract.md"
1967
2071
  )
1968
2072
  adapter_contract_path = ctx.get("OKSTRA_LEAD_ADAPTER_CONTRACT_PATH") or str(
1969
- home_prompts / _lead_info(ctx).adapter_contract_relative_path
2073
+ home_prompts / _lead_adapter_contract(ctx, worker_dispatch_plan)
1970
2074
  )
1971
2075
  context_loader_path = ctx.get("OKSTRA_CONTEXT_LOADER_PATH") or str(
1972
2076
  home_prompts / "lead" / "context-loader.md"
@@ -2163,27 +2267,21 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
2163
2267
  "schema and write rules. Do not re-inline either contract."
2164
2268
  )
2165
2269
 
2166
- if lead_runtime == "external":
2270
+ lead_descriptor = _lead_descriptor(ctx)
2271
+ if lead_descriptor.has_claude_session:
2167
2272
  lead_session_block = (
2168
2273
  "## Session\n"
2169
2274
  "\n"
2170
- "- Lead runtime: `external`\n"
2171
- "- Lead session accounting: artifact-only; this harness does not expose Claude Code jsonl session IDs."
2172
- )
2173
- elif lead_runtime == "codex":
2174
- lead_session_block = (
2175
- "## Session\n"
2176
- "\n"
2177
- "- Lead runtime: `codex`\n"
2178
- "- Codex run resume is artifact/checkpoint based; `okstra codex-run` "
2179
- "does not create a Claude session id."
2275
+ f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
2276
+ f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
2180
2277
  )
2181
2278
  else:
2182
2279
  lead_session_block = (
2183
2280
  "## Session\n"
2184
2281
  "\n"
2185
- f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
2186
- f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
2282
+ f"- Lead runtime: `{lead_descriptor.id}`\n"
2283
+ f"- Lead session accounting: {lead_descriptor.session_accounting}\n"
2284
+ "- Resume behavior is provided by the selected host lead-session port."
2187
2285
  )
2188
2286
 
2189
2287
  # Compute results (deterministic from ctx, 덮어쓰기)
@@ -5,21 +5,36 @@ import re
5
5
 
6
6
 
7
7
  # Every block whose rows prose cites but no section of its own renders, with
8
- # the keys each one names its content and its provenance by. A key hunt across
9
- # all of them would misread `crossVerification.consensus`, whose `evidence` key
10
- # holds provenance while `evidence.primary` uses the same word for content —
11
- # so the block a row came from is named here rather than guessed.
8
+ # the keys each one names its content, its provenance, and its confidence by. A
9
+ # key hunt across all of them would misread `crossVerification.consensus`, whose
10
+ # `evidence` key holds provenance while `evidence.primary` uses the same word
11
+ # for content — so the block a row came from is named here rather than guessed.
12
+ #
13
+ # Source and confidence are separate columns because most blocks carry only one
14
+ # of the two: `evidence.primary` cites a file and never rates itself, while
15
+ # `evidence.secondary` rates itself and cites nothing. Folding them into one
16
+ # field is what made the ledger label every row "source · confidence" and then
17
+ # print a single value under it.
18
+ #
19
+ # `endStateCoverage` is deliberately absent. Its rows hold an id pair
20
+ # (`EB-001` covered by `R-001`) rather than a statement, so the ledger rendered
21
+ # them as an id with no body, and the requirement-coverage table already names
22
+ # every one of them in its Source column.
23
+ #
24
+ # The kind each row carries is a vocabulary key, not the words the reader sees:
25
+ # the labels live in the i18n `ledgerKind` table so they arrive in the reader's
26
+ # language. `evidence.primary` was previously stamped "unclassified", which
27
+ # named the renderer's own indecision rather than anything about the row.
12
28
  _LEDGER_BLOCKS = (
13
- (("evidence", "primary"), "evidence", "source", "unclassified"),
14
- (("evidence", "secondary"), "hypothesis", "confidence", "hypothesis"),
15
- (("analysisCommon", "confirmedFacts"), "statement", "", "confirmed fact"),
16
- (("analysisCommon", "inferences"), "statement", "confidence", "inference"),
17
- (("analysisCommon", "unknowns"), "question", "reason", "unknown"),
18
- (("crossVerification", "consensus"), "statement", "evidence", "cross-check consensus"),
19
- (("crossVerification", "differences"), "disagreement", "workersPosition", "cross-check dissent"),
20
- (("missingInformation",), "item", "risk", "missing information"),
21
- (("endStateCoverage",), "coveredBy", "evidence", "exit contract"),
22
- (("followUpTasks",), "title", "reason", "follow-up"),
29
+ (("evidence", "primary"), "evidence", "source", "", "code-evidence"),
30
+ (("evidence", "secondary"), "hypothesis", "", "confidence", "hypothesis"),
31
+ (("analysisCommon", "confirmedFacts"), "statement", "", "", "confirmed-fact"),
32
+ (("analysisCommon", "inferences"), "statement", "", "confidence", "inference"),
33
+ (("analysisCommon", "unknowns"), "question", "reason", "", "unknown"),
34
+ (("crossVerification", "consensus"), "statement", "evidence", "", "cross-check-consensus"),
35
+ (("crossVerification", "differences"), "disagreement", "workersPosition", "", "cross-check-dissent"),
36
+ (("missingInformation",), "item", "risk", "", "missing-information"),
37
+ (("followUpTasks",), "title", "reason", "", "follow-up"),
23
38
  )
24
39
 
25
40
 
@@ -32,23 +47,39 @@ def _dig(data: dict, path: tuple[str, ...]) -> list:
32
47
  return node if isinstance(node, list) else []
33
48
 
34
49
 
35
- def _ledger_row(row: dict, text_key: str, source_key: str, kind: str) -> dict[str, object]:
50
+ def _joined(value: object) -> str:
51
+ """One reader-facing string for a cell a block may fill with either a
52
+ sentence or a list of citations. `str()` on a list prints Python's own
53
+ repr — quotes, brackets and all — straight into the report.
54
+ """
55
+ if isinstance(value, (list, tuple)):
56
+ return " · ".join(str(item) for item in value if item)
57
+ return str(value or "")
58
+
59
+
60
+ def _ledger_row(
61
+ row: dict, text_key: str, source_key: str, confidence_key: str, kind: str
62
+ ) -> dict[str, object]:
36
63
  return {
37
64
  "id": row.get("id", ""),
38
65
  "kind": kind,
39
- "text": str(row.get(text_key) or ""),
66
+ "text": _joined(row.get(text_key)),
40
67
  "codeEvidence": row.get("currentCodeEvidence") or [],
41
- "source": str(row.get(source_key) or "") if source_key else "",
68
+ "source": _joined(row.get(source_key)) if source_key else "",
69
+ "confidence": _joined(row.get(confidence_key)) if confidence_key else "",
42
70
  }
43
71
 
44
72
 
45
- def _own_section_ids(data: dict) -> set[str]:
73
+ def _own_section_ids(data: dict, omitted_fields: tuple[str, ...] = ()) -> set[str]:
46
74
  """Ids a section of the report already renders and anchors.
47
75
 
48
76
  The ledger is the fallback home for a cited row, so it must not claim an id
49
77
  that has one — two elements with the same anchor send half the links to the
50
78
  wrong place. `crossVerification.consensus` numbers its rows `C-001` in some
51
79
  runs, exactly where a clarification lives.
80
+
81
+ `omitted_fields` names task-block fields the HTML template does not render,
82
+ so their ids do not count as anchored.
52
83
  """
53
84
  from ..report_contract import TASK_TYPE_DATA_PROPERTY
54
85
 
@@ -56,20 +87,34 @@ def _own_section_ids(data: dict) -> set[str]:
56
87
  _collect_ids(data.get("clarificationItems", []), found)
57
88
  property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
58
89
  if property_name:
59
- _collect_ids(data.get(property_name, {}), found)
90
+ block = data.get(property_name, {})
91
+ if omitted_fields and isinstance(block, dict):
92
+ block = {
93
+ key: value for key, value in block.items() if key not in omitted_fields
94
+ }
95
+ _collect_ids(block, found)
60
96
  return found
61
97
 
62
98
 
63
99
  def evidence_index(data: dict) -> dict[str, object]:
100
+ """The rows the ledger carries, keyed by id.
101
+
102
+ A row whose statement came out empty is dropped rather than listed: the
103
+ ledger exists so a cited id resolves to something the reader can read, and
104
+ an id above a blank line resolves to nothing.
105
+ """
64
106
  owned = _own_section_ids(data)
65
107
  rows: dict[str, object] = {}
66
- for path, text_key, source_key, kind in _LEDGER_BLOCKS:
108
+ for path, text_key, source_key, confidence_key, kind in _LEDGER_BLOCKS:
67
109
  for row in _dig(data, path):
68
110
  if not isinstance(row, dict):
69
111
  continue
70
112
  row_id = row.get("id")
71
- if row_id and row_id not in owned and row_id not in rows:
72
- rows[row_id] = _ledger_row(row, text_key, source_key, kind)
113
+ if not row_id or row_id in owned or row_id in rows:
114
+ continue
115
+ entry = _ledger_row(row, text_key, source_key, confidence_key, kind)
116
+ if entry["text"]:
117
+ rows[row_id] = entry
73
118
  return rows
74
119
 
75
120
 
@@ -88,7 +133,7 @@ def _collect_ids(value: object, found: set[str]) -> None:
88
133
  _ROW_ID = re.compile(r"[A-Z]{1,3}-\d+")
89
134
 
90
135
 
91
- def anchor_index(data: dict) -> dict[str, str]:
136
+ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
92
137
  """Map every row a reader can reach to the anchor name that lands on it.
93
138
 
94
139
  Prose cites ids across section boundaries — a hotspot names a
@@ -98,9 +143,10 @@ def anchor_index(data: dict) -> dict[str, str]:
98
143
 
99
144
  It stops there. `summary` is the AI-facing digest and
100
145
  `analysisCommon.scope` describes the analysis target rather than listing
101
- rows; neither renders, so a link to one would land nowhere.
146
+ rows; neither renders, so a link to one would land nowhere. Same for the
147
+ blocks a template declares in `omitted_fields`.
102
148
  """
103
- found = _own_section_ids(data) | set(evidence_index(data))
149
+ found = _own_section_ids(data, omitted_fields) | set(evidence_index(data))
104
150
  return {row_id: f"id-{row_id}" for row_id in sorted(found) if _ROW_ID.fullmatch(row_id)}
105
151
 
106
152
 
@@ -66,6 +66,11 @@ class HumanReportView:
66
66
  template_name: str
67
67
  context: dict[str, object]
68
68
  figures: tuple[FigureModel, ...]
69
+ # Task-block fields this template leaves out of the HTML. Their rows still
70
+ # exist in the data and the markdown, so prose keeps citing their ids — but
71
+ # an anchor to a row this document never renders scrolls nowhere, which
72
+ # reads as a broken report rather than as a deliberate omission.
73
+ omitted_fields: tuple[str, ...] = ()
69
74
 
70
75
 
71
76
  @dataclass(frozen=True)
@@ -130,7 +130,7 @@ def render_v2_html_view(
130
130
  env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
131
131
  # Binding the index here is what lets a template cite an id without
132
132
  # threading the index through every macro and call site.
133
- anchors = anchor_index(data)
133
+ anchors = anchor_index(data, view.omitted_fields)
134
134
  chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
135
135
  translate = make_jinja_global(chrome)
136
136
  env.globals["t"] = translate
@@ -23,6 +23,7 @@ def _totals_row(row: object) -> dict[str, str]:
23
23
  values = row if isinstance(row, dict) else {}
24
24
  return {
25
25
  "rawTokens": format_int(values.get("totalTokens")),
26
+ "cacheReadTokens": format_int(values.get("cacheReadTokens")),
26
27
  "billableTokens": format_int(values.get("billableTokens")),
27
28
  "cost": format_usd(values.get("costUsd")),
28
29
  }
@@ -43,6 +44,7 @@ def _agent_row(row: dict) -> dict[str, str]:
43
44
  "model": str(row.get("model") or ""),
44
45
  "status": str(row.get("status") or ""),
45
46
  "rawTokens": format_int(row.get("totalTokens")),
47
+ "cacheReadTokens": format_int(row.get("cacheReadTokens")),
46
48
  "billableTokens": format_int(row.get("billableTokens")),
47
49
  "cost": format_usd(row.get("costUsd")),
48
50
  "duration": format_duration_ms(row.get("durationMs")),
@@ -70,15 +72,31 @@ def _unaccounted(rows: list[dict], grand: dict) -> dict[str, str] | None:
70
72
  gap = grand_tokens - _sum(rows, "totalTokens")
71
73
  if gap <= 0:
72
74
  return None
75
+ cache_gap = (_number(grand.get("cacheReadTokens")) or 0) - _sum(rows, "cacheReadTokens")
73
76
  billable_gap = (_number(grand.get("billableTokens")) or 0) - _sum(rows, "billableTokens")
74
77
  cost_gap = (_number(grand.get("costUsd")) or 0) - _sum(rows, "costUsd")
75
78
  return {
76
79
  "rawTokens": format_int(gap),
80
+ "cacheReadTokens": format_int(max(0, cache_gap)),
77
81
  "billableTokens": format_int(max(0, billable_gap)),
78
82
  "cost": format_usd(max(0.0, cost_gap)),
79
83
  }
80
84
 
81
85
 
86
+ def _task_cumulative(row: object) -> dict[str, str] | None:
87
+ """What the task has spent over every run, when more than this one exists.
88
+
89
+ A single-run task would repeat the grand total word for word, and a reader
90
+ seeing the same figure twice reads it as a second charge.
91
+ """
92
+ if not isinstance(row, dict):
93
+ return None
94
+ run_count = _number(row.get("runCount")) or 0
95
+ if run_count < 2:
96
+ return None
97
+ return {**_totals_row(row), "runCount": format_int(run_count)}
98
+
99
+
82
100
  _MEASURED_KEYS = ("totalTokens", "billableTokens", "costUsd", "durationMs")
83
101
 
84
102
 
@@ -106,5 +124,6 @@ def run_usage(data: dict) -> dict[str, object] | None:
106
124
  "rows": [_agent_row(row) for row in rows],
107
125
  "unaccounted": _unaccounted(rows, usage.get("grand") or {}),
108
126
  **totals,
127
+ "taskCumulative": _task_cumulative(usage.get("taskCumulative")),
109
128
  "cliCost": format_usd(cli_cost) if cli_cost else "",
110
129
  }
@@ -74,6 +74,19 @@ def _stage_figure(planning: dict):
74
74
  return stage_map_figure(nodes=nodes, edges=edges, title="Implementation stage dependencies")
75
75
 
76
76
 
77
+ # Blocks the approver's view leaves to the markdown report: the checklist the
78
+ # implementer works from, the dependency and rollback tables an incident reader
79
+ # needs, and the verification rounds the audit trail keeps. Declared here so the
80
+ # ids they carry stop being anchor targets in this document.
81
+ _OMITTED_FIELDS = (
82
+ "validationChecklist",
83
+ "crossProjectDependencies",
84
+ "dependencyMigrationRisk",
85
+ "rollbackStrategy",
86
+ "planBodyVerification",
87
+ )
88
+
89
+
77
90
  def build_implementation_planning_view(data: dict) -> HumanReportView:
78
91
  planning = data["implementationPlanning"]
79
92
  figure = _stage_figure(planning)
@@ -97,4 +110,5 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
97
110
  "html/tasks/implementation-planning.template.html",
98
111
  context,
99
112
  (figure,),
113
+ _OMITTED_FIELDS,
100
114
  )