okstra 0.172.0 → 0.174.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 (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -82,6 +82,11 @@ from okstra_ctl.design_prep import (
82
82
  resolve_design_prep,
83
83
  write_design_prep_input,
84
84
  )
85
+ from okstra_ctl.implementation_direction import (
86
+ DirectionSelectionError,
87
+ lexical_absolute_path,
88
+ validate_task_artifact_path,
89
+ )
85
90
  from okstra_ctl.final_report_paths import final_report_data_path
86
91
  from okstra_ctl.plan_run_root import list_implementation_planning_reports
87
92
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
@@ -154,6 +159,7 @@ TASK_TYPES: list[tuple[str, str]] = [
154
159
  ("improvement-discovery", "Find improvement candidates within a codebase scope and lens whitelist"),
155
160
  *zip(ANALYSIS_TASK_TYPES, _ANALYSIS_TASK_TYPE_DESCRIPTIONS),
156
161
  ("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
162
+ ("implementation-option-selection", "Compare implementation options (read-only)"),
157
163
  ("implementation-planning", "Plan options + request user approval"),
158
164
  ("implementation", "Execute approved plan (requires approved final-report)"),
159
165
  ("final-verification", "Acceptance + residual-risk review"),
@@ -331,6 +337,7 @@ S_ANALYSIS_TARGET_PICK = "analysis_target_pick"
331
337
  S_ANALYSIS_TARGET = "analysis_target"
332
338
  S_BASE_REF_PICK = "base_ref_pick"
333
339
  S_BASE_REF_TEXT = "base_ref_text"
340
+ S_SELECTED_DIRECTION_PICK = "selected_direction_pick"
334
341
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
335
342
  S_APPROVED_PLAN = "approved_plan"
336
343
  S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
@@ -495,6 +502,7 @@ class WizardState:
495
502
  clarification_response_path: str = ""
496
503
  clarification_pending_text: bool = False
497
504
  last_final_report_cached: str = ""
505
+ selected_direction_path: str = ""
498
506
  # "" | "auto" | "full" | "<stage csv>" — 사용자가 고른 이번 재실행의 재검증
499
507
  # 범위. implementation-planning 재실행에서 좁힐 여지가 있을 때만 채워진다.
500
508
  reverify_scope: str = ""
@@ -595,6 +603,105 @@ def _require_file(path_str: str, project_root: Path, label: str) -> Path:
595
603
  return p
596
604
 
597
605
 
606
+ _SELECTION_REPORT_RE = re.compile(
607
+ r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.md$"
608
+ )
609
+
610
+
611
+ def _selected_direction_candidates(state: WizardState) -> list[str]:
612
+ if not state.project_root or not state.task_group or not state.task_id:
613
+ return []
614
+ project_root = Path(state.project_root).resolve()
615
+ task_root = lexical_absolute_path(
616
+ task_dir(project_root, state.task_group, state.task_id)
617
+ )
618
+ reports = (
619
+ task_runs_dir(project_root, state.task_group, state.task_id)
620
+ / "implementation-option-selection"
621
+ / "reports"
622
+ )
623
+ candidates: list[tuple[int, Path]] = []
624
+ for report in reports.glob("final-report-implementation-option-selection-*.md"):
625
+ match = _SELECTION_REPORT_RE.fullmatch(report.name)
626
+ if match is None:
627
+ continue
628
+ try:
629
+ validated = validate_task_artifact_path(
630
+ report, task_root, "selection report"
631
+ )
632
+ except DirectionSelectionError:
633
+ continue
634
+ candidates.append((int(match.group("seq")), validated))
635
+ return [
636
+ _project_relative_path(path, project_root)
637
+ for _, path in sorted(candidates, reverse=True)[:3]
638
+ ]
639
+
640
+
641
+ def _planning_rerun_selected(state: WizardState) -> bool:
642
+ if (
643
+ not state.clarification_response_path
644
+ or not state.project_root
645
+ or not state.task_group
646
+ or not state.task_id
647
+ ):
648
+ return False
649
+ project_root = Path(state.project_root).resolve()
650
+ raw_path = Path(state.clarification_response_path).expanduser()
651
+ path = lexical_absolute_path(
652
+ raw_path if raw_path.is_absolute() else project_root / raw_path
653
+ )
654
+ task_root = lexical_absolute_path(
655
+ task_dir(project_root, state.task_group, state.task_id)
656
+ )
657
+ reports = lexical_absolute_path(
658
+ task_runs_dir(project_root, state.task_group, state.task_id)
659
+ / "implementation-planning"
660
+ / "reports"
661
+ )
662
+ try:
663
+ validate_task_artifact_path(path, task_root, "planning report")
664
+ except DirectionSelectionError:
665
+ return False
666
+ return (
667
+ path.is_file()
668
+ and not path.is_symlink()
669
+ and re.fullmatch(
670
+ r"final-report-implementation-planning-\d{3,}\.md", path.name
671
+ )
672
+ is not None
673
+ and path.parent == reports
674
+ )
675
+
676
+
677
+ def _build_selected_direction_pick(state: WizardState) -> Prompt:
678
+ candidates = _selected_direction_candidates(state)
679
+ t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
680
+ if not candidates:
681
+ raise WizardError(t["errors"]["none"])
682
+ return Prompt(
683
+ step=S_SELECTED_DIRECTION_PICK,
684
+ kind="pick",
685
+ label=t["label"],
686
+ options=[_opt(path, path) for path in candidates],
687
+ echo_template=t["echo_template"],
688
+ )
689
+
690
+
691
+ def _submit_selected_direction_pick(
692
+ state: WizardState, value: str
693
+ ) -> Optional[str]:
694
+ candidates = _selected_direction_candidates(state)
695
+ if value not in candidates:
696
+ raise WizardError(
697
+ _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)["errors"][
698
+ "unknown"
699
+ ].format(value=value)
700
+ )
701
+ state.selected_direction_path = value
702
+ return f"selected-direction: {value}"
703
+
704
+
598
705
  def _data_json_approved_state(plan_path: Path) -> Optional[bool]:
599
706
  """`approved` flag of the sibling final-report data.json (the SSOT).
600
707
 
@@ -621,6 +728,18 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
621
728
  ``already_fully_approved=False`` — the approve-confirm step offers to flip it.
622
729
  """
623
730
  p = _require_file(path_str, project_root, "approved plan")
731
+ loaded = _load_final_report_data_if_present(p)
732
+ if loaded is not None:
733
+ planning = loaded[1].get("implementationPlanning")
734
+ if (
735
+ isinstance(planning, dict)
736
+ and planning.get("planningContract") == "selected-direction"
737
+ and planning.get("outcome") == "direction-invalidated"
738
+ ):
739
+ raise WizardError(
740
+ "direction-invalidated planning reports are not approvable; "
741
+ "re-enter implementation-option-selection"
742
+ )
624
743
  body = p.read_text(encoding="utf-8", errors="replace")
625
744
  frontmatter = _extract_frontmatter_block(body)
626
745
  if frontmatter is None:
@@ -690,6 +809,14 @@ def _find_html_approval_sidecar(
690
809
  승인이 아닌 판정(반려·재작업 요청)은 여기서 걸러진다 — 이 단계가 묻는 것은
691
810
  "사용자가 이 plan 을 승인해 두었는가" 뿐이고, 반려 사유는 다음 planning
692
811
  run 이 sidecar 를 통째로 읽어 처리한다."""
812
+ loaded = _load_final_report_data_if_present(plan_path)
813
+ if loaded is not None:
814
+ planning = loaded[1].get("implementationPlanning")
815
+ if (
816
+ isinstance(planning, dict)
817
+ and planning.get("planningContract") == "selected-direction"
818
+ ):
819
+ return None
693
820
  responses_dir = plan_path.parent.parent / "user-responses"
694
821
  if not responses_dir.is_dir():
695
822
  return None
@@ -4175,6 +4302,19 @@ STEPS: list[Step] = [
4175
4302
  applies=lambda s: s.base_ref_pending_text,
4176
4303
  build=_build_base_ref_text, submit=_submit_base_ref_text,
4177
4304
  owns=("base_ref", "base_ref_pending_text")),
4305
+ Step(S_SELECTED_DIRECTION_PICK,
4306
+ applies=lambda s: (
4307
+ s.task_type == "implementation-planning"
4308
+ and not s.selected_direction_path
4309
+ and not _planning_rerun_selected(s)
4310
+ and _brief_resolved(s)
4311
+ and _base_ref_ready(s)
4312
+ and not s.base_ref_pending_text
4313
+ and S_SELECTED_DIRECTION_PICK not in s.answered
4314
+ ),
4315
+ build=_build_selected_direction_pick,
4316
+ submit=_submit_selected_direction_pick,
4317
+ owns=("selected_direction_path",)),
4178
4318
  Step(S_APPROVED_PLAN_PICK,
4179
4319
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
4180
4320
  and not s.approved_plan_path
@@ -4556,6 +4696,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
4556
4696
  "directive_pending_text": False,
4557
4697
  "related_tasks_raw": "", "related_tasks_pending_text": False,
4558
4698
  "clarification_response_path": "", "clarification_pending_text": False,
4699
+ "selected_direction_path": "",
4559
4700
  "reverify_scope": "", "reverify_scope_pending_text": False,
4560
4701
  "pr_template_path": "", "pr_template_pending_text": False,
4561
4702
  "pr_template_scope": "",
@@ -4924,6 +5065,7 @@ def render_args(state: WizardState) -> dict[str, str]:
4924
5065
  "report-writer-model": state.report_writer_model,
4925
5066
  "related-tasks": state.related_tasks_raw,
4926
5067
  "clarification-response": state.clarification_response_path,
5068
+ "selected-direction": state.selected_direction_path,
4927
5069
  "reverify-scope": (
4928
5070
  state.reverify_scope
4929
5071
  if state.task_type == "implementation-planning" else ""
@@ -5080,6 +5222,8 @@ def confirmation_block(state: WizardState) -> str:
5080
5222
  reverify_line = _reverify_scope_line(state)
5081
5223
  if reverify_line is not None:
5082
5224
  lines.append(reverify_line)
5225
+ if state.selected_direction_path:
5226
+ lines.append(f" selected-direction: {state.selected_direction_path}")
5083
5227
  if state.task_type == "release-handoff" and state.handoff_mode:
5084
5228
  scope = (
5085
5229
  _msg(state.workspace_root, "confirmation",
@@ -51,6 +51,7 @@ FINAL_VERIFICATION_HEADERS = (
51
51
  SUPPORTED_TASK_TYPES = frozenset({
52
52
  "requirements-discovery",
53
53
  "error-analysis",
54
+ "implementation-option-selection",
54
55
  "implementation-planning",
55
56
  "improvement-discovery",
56
57
  "implementation",
@@ -67,6 +68,7 @@ ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
67
68
  "requirements-discovery": "discovery-worker",
68
69
  "improvement-discovery": "discovery-worker",
69
70
  "error-analysis": "diagnosis-worker",
71
+ "implementation-option-selection": "direction-selection-worker",
70
72
  "implementation-planning": "planning-worker",
71
73
  }
72
74
  WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
@@ -35,6 +35,7 @@ def build_request(
35
35
  worktree_path: Path | None,
36
36
  role: str,
37
37
  idle_timeout_seconds: int,
38
+ session_id: str = "",
38
39
  ) -> WorkerExecRequest:
39
40
  """One dispatch, with every value the strategies will not re-derive.
40
41
 
@@ -55,6 +56,7 @@ def build_request(
55
56
  auto_approve=True, write_scope=write_scope(root, worktree, role)
56
57
  ),
57
58
  idle_timeout_seconds=idle_timeout_seconds,
59
+ session_id=session_id,
58
60
  )
59
61
 
60
62
 
@@ -17,22 +17,34 @@ from okstra_ctl.analysis_inputs import ANALYSIS_TASK_TYPES
17
17
  PHASE_SEQUENCE = [
18
18
  "requirements-discovery",
19
19
  "error-analysis",
20
+ "implementation-option-selection",
20
21
  "implementation-planning",
21
22
  "implementation",
22
23
  "final-verification",
23
24
  "release-handoff",
24
25
  ]
25
26
 
27
+ REQUIREMENTS_DISCOVERY_ROUTING_TARGETS = frozenset(
28
+ {"error-analysis", "implementation-option-selection"}
29
+ )
30
+
31
+ ERROR_ANALYSIS_ROUTING_DIRECTIONS = {
32
+ "error-analysis": "continue-investigation",
33
+ "implementation-option-selection": "begin-option-selection",
34
+ }
35
+
26
36
  DEFAULT_NEXT_PHASE = {
27
- "requirements-discovery": "pending-routing-decision",
37
+ "requirements-discovery": "implementation-option-selection",
28
38
  "improvement-discovery": "pending-routing-decision",
29
39
  **{task_type: "pending-routing-decision" for task_type in ANALYSIS_TASK_TYPES},
30
- "error-analysis": "implementation-planning",
40
+ "error-analysis": "implementation-option-selection",
41
+ "implementation-option-selection": "implementation-planning",
31
42
  "implementation-planning": "implementation",
32
43
  "implementation": "final-verification",
33
44
  # final-verification 의 다음 단계는 verdict 에 따라 갈리므로 정적 매핑은
34
45
  # `pending-release-handoff` 로 둔다 (accepted 일 때만 release-handoff 로
35
- # 진입; 그 외에는 error-analysis / implementation-planning 으로 리라우팅).
46
+ # 진입; 그 외에는 error-analysis / implementation-option-selection /
47
+ # implementation-planning 으로 리라우팅).
36
48
  "final-verification": "pending-release-handoff",
37
49
  "release-handoff": "done-or-follow-up",
38
50
  }
@@ -87,12 +99,22 @@ PHASE_RULES: dict[str, dict[str, str]] = {
87
99
  " - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
88
100
  ),
89
101
  },
102
+ "implementation-option-selection": {
103
+ "allowed": (
104
+ " - candidate-comparison evidence for up to three options per worker\n"
105
+ " - preselected-validation that re-evaluates the merged candidate set\n"
106
+ " - a ranked display of at most three options with requirement mappings\n"
107
+ " - an audit record for every rejected candidate\n"
108
+ " - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
109
+ ),
110
+ },
90
111
  "implementation-planning": {
91
112
  "allowed": (
92
113
  " - pre-planning context exploration notes (files/interfaces inspected, recent commits scanned, ambiguities flagged)\n"
93
- " - at least two implementation option candidates, each with a File Structure list (Create/Modify/Delete with one-line responsibility per file), affected interfaces, and blast-radius estimate\n"
94
- " - trade-off matrix across options (complexity, risk, reversibility, test cost, rollout cost) and recommended option with rationale tied to isolation / single-responsibility / YAGNI principles\n"
95
- " - bite-sized stepwise execution order for the recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
114
+ " - selected-direction planning: realize the one validated selected direction without reopening candidate comparison, with a File Structure list (Create/Modify/Delete with one-line responsibility per file), affected interfaces, and blast-radius estimate\n"
115
+ " - legacy candidate-comparison planning only: at least two implementation option candidates, each with a File Structure list (Create/Modify/Delete with one-line responsibility per file), affected interfaces, and blast-radius estimate\n"
116
+ " - legacy candidate-comparison planning only: a trade-off matrix across options (complexity, risk, reversibility, test cost, rollout cost) and recommended option with rationale tied to isolation / single-responsibility / YAGNI principles\n"
117
+ " - bite-sized stepwise execution order for the selected direction or legacy recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
96
118
  " - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
97
119
  " - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
98
120
  " - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
@@ -117,7 +139,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
117
139
  "allowed": (
118
140
  " - acceptance verdict with requirement coverage assessment\n"
119
141
  " - residual risk and regression notes\n"
120
- " - recommended follow-up routing (`error-analysis` / `implementation-planning` / `release-handoff`) for any defects detected"
142
+ " - recommended follow-up routing (`error-analysis` / `implementation-option-selection` / `implementation-planning` / `release-handoff`) for any defects detected"
121
143
  ),
122
144
  },
123
145
  "release-handoff": {
@@ -77,6 +77,27 @@ DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
77
77
  )
78
78
 
79
79
 
80
+ # Sync dirs materialised as a REAL directory whose children are symlinked one
81
+ # by one, instead of a single symlink standing in for the whole directory.
82
+ #
83
+ # Why the split exists: git does not follow a symlink, so a symlinked directory
84
+ # is one *file* to it. A project that ignores host config by its contents
85
+ # (`.claude/*`) matches every child but never the bare `.claude` path, so the
86
+ # symlink lands in `git status` as
87
+ # `?? .claude` while the same directory is invisible in the main checkout. Any
88
+ # plan step asserting a clean worktree then fails on okstra's own provisioning.
89
+ # Linking the children instead reproduces the main checkout's shape, so
90
+ # whatever the project's ignore rules do there, they do here too.
91
+ #
92
+ # Only `.claude` qualifies. The other sync dirs are shared okstra state that
93
+ # okstra WRITES into, and a directory symlink is what makes a newly created
94
+ # top-level entry land in the main checkout rather than diverging inside the
95
+ # worktree. `.claude` is host configuration okstra reads; its one okstra write
96
+ # is the fixed `settings.local.json` child seeded by
97
+ # `_seed_worktree_settings_symlink`.
98
+ CHILD_LINKED_SYNC_DIRS: tuple[str, ...] = (".claude",)
99
+
100
+
80
101
  # Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
81
102
  # at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
82
103
  # every task sees the live shared file. The split exists because the original
@@ -522,16 +543,27 @@ def is_ancestor(cwd, commit: str, head: str) -> bool:
522
543
  return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
523
544
 
524
545
 
525
- def is_dirty_excluding_okstra(cwd) -> bool:
526
- """True iff the worktree has changes outside okstra-owned paths.
546
+ def dirty_entries_excluding_okstra(cwd) -> list[str]:
547
+ """`git status --short` rows for changes outside okstra-owned paths.
527
548
 
528
549
  okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
529
550
  plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
551
+
552
+ This is the clean-worktree question every okstra gate asks, and the one a
553
+ plan step must ask through `okstra worktree-status` rather than through a
554
+ bare `git status --porcelain`: okstra provisions `.okstra` plus the synced
555
+ entries into every task worktree, so a bare status is never empty there and
556
+ an assertion built on it fails on okstra's own scaffolding.
530
557
  """
531
558
  owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
532
559
  excludes = [f":(exclude){p}" for p in owned]
533
560
  out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
534
- return bool(out.strip())
561
+ return [line for line in out.splitlines() if line.strip()]
562
+
563
+
564
+ def is_dirty_excluding_okstra(cwd) -> bool:
565
+ """True iff the worktree has changes outside okstra-owned paths."""
566
+ return bool(dirty_entries_excluding_okstra(cwd))
535
567
 
536
568
 
537
569
  class MergeError(RuntimeError):
@@ -580,6 +612,9 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
580
612
  version-controlled files.
581
613
  - Parent directories are created as needed for nested entries.
582
614
 
615
+ Entries listed in `CHILD_LINKED_SYNC_DIRS` are materialised as a real
616
+ directory of per-child symlinks instead (see that constant).
617
+
583
618
  Returns a list of human-readable notes (one per linked entry) so the
584
619
  caller can include them in the provisioning note.
585
620
  """
@@ -592,6 +627,10 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
592
627
  if dst.exists() or dst.is_symlink():
593
628
  continue
594
629
  dst.parent.mkdir(parents=True, exist_ok=True)
630
+ if rel in CHILD_LINKED_SYNC_DIRS and src.is_dir():
631
+ _link_dir_children(src, dst)
632
+ notes.append(rel)
633
+ continue
595
634
  try:
596
635
  os.symlink(src, dst)
597
636
  except FileExistsError:
@@ -600,6 +639,33 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
600
639
  return notes
601
640
 
602
641
 
642
+ def _link_dir_children(src: Path, dst: Path) -> None:
643
+ """Create `dst` as a real directory whose entries symlink to `src`'s
644
+ children, so git sees the same directory shape it sees in the main
645
+ checkout (`CHILD_LINKED_SYNC_DIRS`).
646
+
647
+ Each link points at the MAIN checkout's child rather than its resolved
648
+ target, so a child that is itself a symlink (e.g. `.claude/settings.local.json`
649
+ → `~/.okstra/templates/settings.local.json`) keeps following whatever the
650
+ main checkout currently points at. An unreadable source or a child that
651
+ cannot be linked degrades to "that child is absent in this worktree" —
652
+ provisioning is not worth failing over host config.
653
+ """
654
+ dst.mkdir(parents=True, exist_ok=True)
655
+ try:
656
+ children = sorted(src.iterdir())
657
+ except OSError:
658
+ return
659
+ for child in children:
660
+ link = dst / child.name
661
+ if link.exists() or link.is_symlink():
662
+ continue
663
+ try:
664
+ os.symlink(child, link)
665
+ except OSError:
666
+ continue
667
+
668
+
603
669
  def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
604
670
  """File-level counterpart to `_link_sync_dirs` (FU-V2).
605
671
 
@@ -57,7 +57,7 @@ def main() -> int:
57
57
  "from the freshly computed usageSummary, then re-render the "
58
58
  "sibling final-report markdown via the renderer. The data.json "
59
59
  "is the SSOT; the markdown is regenerated from it. "
60
- "See schemas/final-report-v1.0.schema.json for the data shape."
60
+ "See schemas/final-report-v2.0.schema.json for the data shape."
61
61
  ),
62
62
  )
63
63
  parser.add_argument(
@@ -21,6 +21,7 @@ from .antigravity import (
21
21
  )
22
22
  from .paths import claude_project_dir, utc_now
23
23
  from .pricing import antigravity_cost_usd, provider_cost_usd
24
+ from okstra_ctl.dispatch_state import worker_session_ids
24
25
  from okstra_ctl.models import provider_wrappers
25
26
  from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
26
27
 
@@ -864,22 +865,66 @@ def collect_claude_runtime_usage(
864
865
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
865
866
  )
866
867
 
867
- # Workers — match by prefix and aggregate every session that belongs to
868
- # the same role (re-dispatches with `-002`, convergence `-reverify-r1`,
869
- # implementation `-executor`, report-writer `-impl` / `-2`, etc.).
868
+ # Workers — dispatch 가 기록한 세션 id 로 짚은 세션과 agentName prefix 로
869
+ # 찾은 세션의 합집합을 합산한다(재배치 `-002`, convergence `-reverify-r1`,
870
+ # implementation `-executor`, report-writer `-impl` / `-2` 등).
871
+ sessions_dir = claude_project_dir(cwd)
872
+ # sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
873
+ attributed_sids: set[str] = set()
870
874
  for worker in state.get("workers", []):
871
875
  worker_id = worker.get("workerId")
872
876
  agent = worker.get("agent")
873
877
  prefixes = match_prefixes(worker_id) if worker_id else []
874
878
 
879
+ # pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
880
+ # 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
881
+ # 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
882
+ # attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
883
+ # 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
884
+ # 그 워커가 run 의 모든 세션을 흡수한다.
885
+ dispatched_sids = worker_session_ids(state, worker_id) if worker_id else []
875
886
  matched: list[tuple[str, Path, dict]] = []
887
+ matched_sids: set[str] = set()
888
+ for sid in dispatched_sids:
889
+ path = sessions_dir / f"{sid}.jsonl"
890
+ if not path.is_file():
891
+ continue
892
+ totals = claude_session_totals(path, since=run_since, until=run_until,
893
+ incremental=incremental)
894
+ # 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
895
+ # `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
896
+ # usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
897
+ # 남아야 할 상태를 허위 0 이 덮는다.
898
+ if not totals.get("startedAt"):
899
+ continue
900
+ matched.append((sid, path, totals))
901
+ matched_sids.add(sid)
902
+ attributed_sids.add(sid)
903
+
904
+ # 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
905
+ # 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
906
+ # 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
907
+ # 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
876
908
  for agent_name, entries in by_agent.items():
877
- if agent_matches(agent_name, prefixes):
878
- matched.extend(entries)
909
+ if not agent_matches(agent_name, prefixes):
910
+ continue
911
+ for entry in entries:
912
+ if entry[0] in matched_sids:
913
+ continue
914
+ # team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
915
+ # 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
916
+ # startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
917
+ # 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
918
+ # 세션만 남의 것이 된다.
919
+ if not entry[2].get("startedAt"):
920
+ continue
921
+ matched.append(entry)
922
+ matched_sids.add(entry[0])
879
923
 
880
924
  if not matched:
881
925
  worker["usage"] = na_block(
882
- f"no Claude subagent jsonl found with agentName matching prefixes {prefixes}"
926
+ "no Claude session jsonl in the run window for dispatched "
927
+ f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
883
928
  )
884
929
  continue
885
930
 
@@ -919,6 +964,21 @@ def collect_claude_runtime_usage(
919
964
  # session the harness never tagged with `name`. Without this, the run-level
920
965
  # Worker total reads 0 and the report validator hard-fails a legitimate run.
921
966
  # Attribution is aggregate, not per-worker; usageSummary records it openly.
967
+ #
968
+ # 먼저 sid 로 귀속된 세션을 뺀다. 이 두 집합은 `agentName` 유무로 배타적이었지만
969
+ # (by_agent 는 있어야 들어가고 unattributed 는 없어야 들어간다) sid 경로는
970
+ # agentName 을 안 보므로 그 배타성이 더는 성립하지 않는다. team needle 에
971
+ # 걸리면서 agentName 이 없는 세션이 동시에 기록된 dispatch sid 이기도 하면,
972
+ # 빼지 않을 경우 그 토큰이 워커 usage 와 이 폴드 양쪽에 들어가 `_populate_usage_summary`
973
+ # 의 workerTotalTokens 를 부풀린다. 두 리스트는 인덱스가 대응하므로 함께 거른다.
974
+ kept = [
975
+ (sid, totals)
976
+ for sid, totals in zip(unattributed_sessions, unattributed_totals)
977
+ if sid not in attributed_sids
978
+ ]
979
+ unattributed_sessions = [sid for sid, _ in kept]
980
+ unattributed_totals = [totals for _, totals in kept]
981
+
922
982
  unattributed_usage = None
923
983
  if unattributed_totals:
924
984
  unattributed_usage = usage_block(