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
@@ -26,6 +26,7 @@ import tempfile
26
26
  from dataclasses import dataclass, field
27
27
  from datetime import datetime, timezone
28
28
  from pathlib import Path
29
+ from typing import Sequence
29
30
 
30
31
  from okstra_project import project_json_path, upsert_project_json
31
32
  from okstra_project.state import slugify
@@ -48,6 +49,15 @@ from .clarification_items import (
48
49
  )
49
50
  from .error_report import prior_run_error_digest
50
51
  from .incremental_scope import ReverifyScopeError, parse_user_reverify_scope
52
+ from .implementation_direction import (
53
+ DirectionSelectionError,
54
+ SelectedDirection,
55
+ lexical_absolute_path,
56
+ resolve_selected_direction,
57
+ validate_selected_direction_plan,
58
+ validate_task_artifact_path,
59
+ write_selected_direction_snapshot,
60
+ )
51
61
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
52
62
  from .material import (
53
63
  build_analysis_material,
@@ -115,7 +125,11 @@ from .render import (
115
125
  )
116
126
  from okstra_project.dirs import okstra_home
117
127
 
118
- from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
128
+ from .dispatch_state import (
129
+ BACKEND_CMUX_PANE,
130
+ detect_terminal_backend,
131
+ generate_claude_session_id,
132
+ )
119
133
  from .run_context import (
120
134
  compute_and_write_run_context,
121
135
  refresh_run_context_snapshot,
@@ -129,7 +143,6 @@ from .seeding import (
129
143
  verify_installation,
130
144
  )
131
145
  from .session import (
132
- generate_claude_session_id,
133
146
  resolve_inproc_lead_session_id,
134
147
  write_claude_resume_command_file,
135
148
  )
@@ -154,6 +167,7 @@ from .brief_frontmatter import (
154
167
  has_reporter_confirmation_contract,
155
168
  read_brief_frontmatter,
156
169
  )
170
+ from .scope_provenance import brief_end_state_id_sequence
157
171
 
158
172
  # Frontmatter approval-flag matcher.
159
173
  #
@@ -183,6 +197,10 @@ IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN = re.compile(
183
197
  r"^implementation-option:[ \t]*(.*)$",
184
198
  re.MULTILINE,
185
199
  )
200
+ SELECTED_DIRECTION_FRONTMATTER_PATTERN = re.compile(
201
+ r"^selected-direction-ref:[ \t]*(.*)$",
202
+ re.MULTILINE,
203
+ )
186
204
 
187
205
  # validators/validate-run.py:_FRONTMATTER_BLOCK_RE 의 미러 — 선행 BOM/빈 줄을
188
206
  # 허용해 두 모듈이 같은 리포트의 frontmatter 게이트를 동일하게 판정하게 한다.
@@ -281,6 +299,43 @@ def _validate_data_json_approval_consistency(
281
299
  )
282
300
 
283
301
 
302
+ def _validate_approved_plan_conformance(path: Path) -> None:
303
+ """승인 계획의 stage conformance 선언 형식을 승인 경계에서 판정한다.
304
+
305
+ 같은 판정이 지금까지는 구현 런의 마지막 `validate-run` 에서만 나왔다. 그때는
306
+ 워커 배치와 수렴이 이미 끝난 뒤라 런 하나를 통째로 버려야 했다 — 실측된
307
+ 실패에서 승인 계획의 stage 2~7 이 기계 형식이 아니라 산문이었다.
308
+
309
+ 선언이 없는 stage 는 건드리지 않는다(`conformance.malformed_conformance_stages`
310
+ 참조): XOR 부재는 계획 단계의 check S11 이 이미 막고, 구현 진입 게이트도
311
+ validate-run 이 계속 요구한다. 여기서 다시 하면 같은 규칙이 두 곳이 된다.
312
+ """
313
+ from .conformance import malformed_conformance_stages
314
+
315
+ loaded = _load_final_report_data_if_present(path)
316
+ if loaded is None:
317
+ return
318
+ data_path, data = loaded
319
+ bad = malformed_conformance_stages(data)
320
+ if not bad:
321
+ return
322
+ # 한 stage 씩 고치고 다시 막히는 왕복을 피하려면 전부 나열해야 한다 —
323
+ # 실패한 런에서 검증기는 stage 2 만 지목했지만 실제로는 2~7 전부였다.
324
+ stages = ", ".join(str(number) for number in bad)
325
+ raise PrepareError(
326
+ f"approved plan data.json has malformed conformanceTests for stage(s) "
327
+ f"{stages}: {data_path}\n"
328
+ " each declaring stage must read "
329
+ "`<task_root>/qa/scripts/stage-<N>.<ext> "
330
+ "(requires=[db|io|http|external,...])` after the "
331
+ "`Conformance tests: stage-<N> — ` prefix "
332
+ "(prompts/profiles/implementation-planning.md), or carry "
333
+ "`Conformance exemption: <reason>` instead.\n"
334
+ " re-run implementation-planning so the declaration is regenerated in "
335
+ "that form, then approve it."
336
+ )
337
+
338
+
284
339
  def _set_data_json_approved_true_if_present(path: Path) -> bool:
285
340
  loaded = _load_final_report_data_if_present(path)
286
341
  if loaded is None:
@@ -393,6 +448,8 @@ class PrepareInputs:
393
448
  # 별개 채널이다.
394
449
  stages: str = ""
395
450
  clarification_response_path: str = "" # absolute or empty
451
+ # implementation-planning 신규 실행 전용: 검증된 방향 선택 최종 보고서.
452
+ selected_direction_path: str = ""
396
453
  # implementation-planning 전용: 사용자가 고른 이번 재실행의 재검증 범위.
397
454
  # "" / "auto" = 리드의 `okstra incremental-scope` 판정에 맡김, "full" =
398
455
  # 전체 재검증 강제, "<stage csv>" = 그 stage 들을 impacted 로 지정.
@@ -457,6 +514,7 @@ def _validate_approved_plan(path: str) -> None:
457
514
  )
458
515
  _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
459
516
  _validate_data_json_approval_consistency(p, markdown_approved=True)
517
+ _validate_approved_plan_conformance(p)
460
518
  # frontmatter approved == true 상태. §1 Clarification Items 의
461
519
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
462
520
  scan = scan_approval_gate(p)
@@ -781,6 +839,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
781
839
  ("--approved-plan", inp.approved_plan_path),
782
840
  ("--implementation-option", inp.implementation_option),
783
841
  ("--clarification-response", inp.clarification_response_path),
842
+ ("--selected-direction", inp.selected_direction_path),
784
843
  ("--workers", workers),
785
844
  ("--lead-provider", inp.lead_provider or ctx.get("LEAD_PROVIDER", "")),
786
845
  ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL", "")),
@@ -992,6 +1051,13 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
992
1051
  pass
993
1052
  elif not inp.brief_path.is_file():
994
1053
  raise PrepareError(f"task brief not found: {inp.brief_path}")
1054
+ elif inp.task_type == "implementation-option-selection" and not brief_end_state_id_sequence(
1055
+ inp.brief_path
1056
+ ):
1057
+ raise PrepareError(
1058
+ "regenerate the brief with stable end-state IDs before starting "
1059
+ "implementation-option-selection"
1060
+ )
995
1061
  ctx_stage_map: list = []
996
1062
  # implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
997
1063
  # 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope).
@@ -1041,10 +1107,123 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1041
1107
  raise PrepareError(
1042
1108
  f"clarification response file not found: {inp.clarification_response_path}"
1043
1109
  )
1110
+ _validate_planning_entry_inputs(project_root, inp)
1044
1111
  _validate_reverify_scope(inp)
1045
1112
  return ctx_stage_map
1046
1113
 
1047
1114
 
1115
+ _PLANNING_REPORT_RE = re.compile(
1116
+ r"^final-report-implementation-planning-\d{3,}\.md$"
1117
+ )
1118
+
1119
+
1120
+ def _resolve_planning_input_path(
1121
+ path_value: str, project_root: Path
1122
+ ) -> Path | None:
1123
+ """Resolve cwd-first planning inputs without following symbolic links."""
1124
+ raw_path = Path(path_value).expanduser()
1125
+ cwd_candidate = lexical_absolute_path(raw_path)
1126
+ if cwd_candidate.is_file():
1127
+ return cwd_candidate
1128
+ if not raw_path.is_absolute():
1129
+ project_candidate = lexical_absolute_path(project_root / raw_path)
1130
+ if project_candidate.is_file():
1131
+ return project_candidate
1132
+ return None
1133
+
1134
+
1135
+ def _is_existing_planning_report(
1136
+ path_value: str, project_root: Path, inp: PrepareInputs
1137
+ ) -> bool:
1138
+ from .paths import task_dir
1139
+
1140
+ try:
1141
+ report = lexical_absolute_path(Path(path_value))
1142
+ task_root = lexical_absolute_path(
1143
+ task_dir(project_root, inp.task_group, inp.task_id)
1144
+ )
1145
+ relative = report.relative_to(task_root)
1146
+ except ValueError:
1147
+ return False
1148
+ has_planning_layout = (
1149
+ _PLANNING_REPORT_RE.fullmatch(report.name) is not None
1150
+ and relative.parts[:3] == (
1151
+ "runs",
1152
+ "implementation-planning",
1153
+ "reports",
1154
+ )
1155
+ and len(relative.parts) == 4
1156
+ )
1157
+ if not has_planning_layout:
1158
+ return False
1159
+ try:
1160
+ validate_task_artifact_path(report, task_root, "planning report")
1161
+ except DirectionSelectionError:
1162
+ return False
1163
+ return True
1164
+
1165
+
1166
+ def _validate_planning_entry_inputs(
1167
+ project_root: Path, inp: PrepareInputs
1168
+ ) -> None:
1169
+ if inp.task_type != "implementation-planning":
1170
+ if inp.selected_direction_path:
1171
+ raise PrepareError(
1172
+ "--selected-direction is only meaningful with --task-type "
1173
+ f"implementation-planning; got {inp.task_type}"
1174
+ )
1175
+ return
1176
+ if inp.selected_direction_path and inp.clarification_response_path:
1177
+ raise PrepareError(
1178
+ "implementation-planning accepts either --selected-direction for a "
1179
+ "new plan or --clarification-response for a rerun, not both"
1180
+ )
1181
+ if inp.selected_direction_path:
1182
+ return
1183
+ if inp.clarification_response_path:
1184
+ if not _is_existing_planning_report(
1185
+ inp.clarification_response_path, project_root, inp
1186
+ ):
1187
+ raise PrepareError(
1188
+ "implementation-planning rerun --clarification-response must point "
1189
+ "to an existing implementation-planning report for the same task"
1190
+ )
1191
+ return
1192
+ raise PrepareError(
1193
+ "implementation-planning requires --selected-direction for a new plan or "
1194
+ "--clarification-response to an existing implementation-planning report"
1195
+ )
1196
+
1197
+
1198
+ def _resolve_planning_direction(inp: PrepareInputs) -> SelectedDirection | None:
1199
+ if inp.task_type != "implementation-planning" or not inp.selected_direction_path:
1200
+ return None
1201
+ from .paths import task_dir
1202
+
1203
+ try:
1204
+ report = lexical_absolute_path(Path(inp.selected_direction_path))
1205
+ expected_task_root = lexical_absolute_path(
1206
+ task_dir(Path(inp.project_root), inp.task_group, inp.task_id)
1207
+ )
1208
+ report.relative_to(expected_task_root)
1209
+ except ValueError as exc:
1210
+ raise PrepareError(
1211
+ "selected direction report must belong to the same task"
1212
+ ) from exc
1213
+ try:
1214
+ validate_task_artifact_path(
1215
+ report, expected_task_root, "selected direction report"
1216
+ )
1217
+ return resolve_selected_direction(
1218
+ report,
1219
+ expected_task_key=(
1220
+ f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
1221
+ ),
1222
+ )
1223
+ except DirectionSelectionError as exc:
1224
+ raise PrepareError(str(exc)) from exc
1225
+
1226
+
1048
1227
  def _validate_reverify_scope(inp: PrepareInputs) -> None:
1049
1228
  """A pinned re-verification scope is only actionable on a planning re-run.
1050
1229
 
@@ -1070,8 +1249,165 @@ def _validate_reverify_scope(inp: PrepareInputs) -> None:
1070
1249
  raise PrepareError(str(exc)) from exc
1071
1250
 
1072
1251
 
1252
+ def _implementation_plan_contract(
1253
+ inp: PrepareInputs,
1254
+ ) -> tuple[str, Path, dict | None]:
1255
+ """Classify an implementation plan from its sibling data.json contract."""
1256
+ plan_path = Path(inp.approved_plan_path)
1257
+ body = plan_path.read_text(encoding="utf-8", errors="replace")
1258
+ frontmatter = _extract_frontmatter_block(body) or ""
1259
+ markdown_ref_match = SELECTED_DIRECTION_FRONTMATTER_PATTERN.search(frontmatter)
1260
+ markdown_ref: str | None = None
1261
+ if markdown_ref_match is not None:
1262
+ markdown_ref = markdown_ref_match.group(1).strip()
1263
+ if markdown_ref.startswith('"'):
1264
+ try:
1265
+ markdown_ref = json.loads(markdown_ref)
1266
+ except json.JSONDecodeError as exc:
1267
+ raise PrepareError(
1268
+ "selected-direction plan markdown `selected-direction-ref` is "
1269
+ "not a valid quoted string"
1270
+ ) from exc
1271
+ elif len(markdown_ref) >= 2 and markdown_ref[0] == markdown_ref[-1] == "'":
1272
+ markdown_ref = markdown_ref[1:-1].replace("''", "'")
1273
+ if not isinstance(markdown_ref, str) or not markdown_ref.strip():
1274
+ raise PrepareError(
1275
+ "selected-direction plan markdown `selected-direction-ref` must "
1276
+ "be a non-empty string"
1277
+ )
1278
+ loaded = _load_final_report_data_if_present(plan_path)
1279
+ if loaded is None:
1280
+ if markdown_ref_match is not None:
1281
+ raise PrepareError(
1282
+ "selected-direction plan requires its sibling data.json before "
1283
+ f"implementation entry: {_final_report_data_path(plan_path)}"
1284
+ )
1285
+ return "legacy", plan_path, None
1286
+ _, data = loaded
1287
+ planning = data.get("implementationPlanning")
1288
+ if not isinstance(planning, dict):
1289
+ raise PrepareError(
1290
+ "approved plan sibling data.json must contain an "
1291
+ "implementationPlanning object"
1292
+ )
1293
+ contract = planning.get("planningContract")
1294
+ selected_payload = any(
1295
+ field in planning
1296
+ for field in (
1297
+ "selectedDirectionRef",
1298
+ "directionRealization",
1299
+ "directionInvalidation",
1300
+ "outcome",
1301
+ )
1302
+ )
1303
+ if contract is None:
1304
+ if markdown_ref_match is not None or selected_payload:
1305
+ raise PrepareError(
1306
+ "selected-direction markers require "
1307
+ "implementationPlanning.planningContract=`selected-direction`; "
1308
+ "the markdown `selected-direction-ref` and data contract disagree"
1309
+ )
1310
+ return "legacy", plan_path, data
1311
+ if contract != "selected-direction":
1312
+ raise PrepareError(
1313
+ "approved plan sibling data.json has unsupported planningContract "
1314
+ f"{contract!r}"
1315
+ )
1316
+ if markdown_ref_match is None:
1317
+ raise PrepareError(
1318
+ "selected-direction plan markdown frontmatter must contain "
1319
+ "`selected-direction-ref:`"
1320
+ )
1321
+ selected_ref = planning.get("selectedDirectionRef")
1322
+ snapshot_ref = (
1323
+ selected_ref.get("snapshotPath")
1324
+ if isinstance(selected_ref, dict)
1325
+ else None
1326
+ )
1327
+ if not isinstance(markdown_ref, str) or markdown_ref != snapshot_ref:
1328
+ raise PrepareError(
1329
+ "selected-direction plan markdown `selected-direction-ref` must "
1330
+ "exactly match implementationPlanning.selectedDirectionRef.snapshotPath"
1331
+ )
1332
+ if IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.search(frontmatter):
1333
+ raise PrepareError(
1334
+ "selected-direction plan must not contain an `implementation-option:` "
1335
+ "frontmatter field"
1336
+ )
1337
+ return "selected-direction", plan_path, data
1338
+
1339
+
1340
+ def _validate_selected_implementation_plan(
1341
+ inp: PrepareInputs,
1342
+ plan_path: Path,
1343
+ data: dict,
1344
+ ) -> None:
1345
+ """Apply the Task 8 selected-direction semantics at implementation entry."""
1346
+ planning = data.get("implementationPlanning") or {}
1347
+ if planning.get("outcome") != "plan-ready":
1348
+ raise PrepareError(
1349
+ "selected-direction plan is not implementation-ready: "
1350
+ f"outcome={planning.get('outcome')!r}; direction-invalidated plans "
1351
+ "must re-enter implementation-option-selection"
1352
+ )
1353
+ summary = planning.get("coverageSummary") or {}
1354
+ if summary.get("coverageVerdict") != "exact":
1355
+ raise PrepareError(
1356
+ "selected-direction plan requires exact coverage before implementation"
1357
+ )
1358
+
1359
+ from .paths import task_dir
1360
+
1361
+ expected_task_root = lexical_absolute_path(
1362
+ task_dir(Path(inp.project_root), inp.task_group, inp.task_id)
1363
+ )
1364
+ try:
1365
+ validate_task_artifact_path(plan_path, expected_task_root, "approved plan")
1366
+ data_path = validate_task_artifact_path(
1367
+ _final_report_data_path(plan_path),
1368
+ expected_task_root,
1369
+ "approved plan data.json",
1370
+ )
1371
+ if data_path != _final_report_data_path(plan_path):
1372
+ raise DirectionSelectionError("approved plan data.json must be a sibling")
1373
+ snapshot_path = validate_task_artifact_path(
1374
+ expected_task_root / "instruction-set" / "selected-direction.json",
1375
+ expected_task_root,
1376
+ "selected direction snapshot",
1377
+ )
1378
+ brief_path = validate_task_artifact_path(
1379
+ expected_task_root / "instruction-set" / "task-brief.md",
1380
+ expected_task_root,
1381
+ "selected direction task brief",
1382
+ )
1383
+ except DirectionSelectionError as exc:
1384
+ raise PrepareError(f"selected-direction plan input is invalid: {exc}") from exc
1385
+
1386
+ expected_task_key = f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
1387
+ actual_task_key = (data.get("header") or {}).get("taskKey")
1388
+ if actual_task_key != expected_task_key:
1389
+ raise PrepareError(
1390
+ "selected-direction plan header.taskKey must match the implementation "
1391
+ f"task: expected {expected_task_key!r}, got {actual_task_key!r}"
1392
+ )
1393
+ failures = validate_selected_direction_plan(data, brief_path, snapshot_path)
1394
+ if failures:
1395
+ raise PrepareError(
1396
+ "selected-direction plan failed implementation entry validation:\n - "
1397
+ + "\n - ".join(failures)
1398
+ )
1399
+
1400
+
1073
1401
  def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1074
1402
  """Apply approved-plan inputs only after canonical brief preflight succeeds."""
1403
+ contract, plan_path, data = _implementation_plan_contract(inp)
1404
+ if contract == "selected-direction":
1405
+ assert data is not None
1406
+ _validate_selected_implementation_plan(inp, plan_path, data)
1407
+ if inp.implementation_option:
1408
+ raise PrepareError(
1409
+ "--implementation-option is not accepted for a selected-direction plan"
1410
+ )
1075
1411
  if inp.approve_plan_ack or inp.implementation_option:
1076
1412
  with worktree_provision_mutex(
1077
1413
  okstra_home(), inp.project_id,
@@ -1083,7 +1419,11 @@ def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1083
1419
  _apply_cli_implementation_option(
1084
1420
  inp.approved_plan_path, inp.implementation_option
1085
1421
  )
1422
+ contract, plan_path, data = _implementation_plan_contract(inp)
1086
1423
  _validate_approved_plan(inp.approved_plan_path)
1424
+ if contract == "selected-direction":
1425
+ assert data is not None
1426
+ _validate_selected_implementation_plan(inp, plan_path, data)
1087
1427
  _validate_stage_structure(inp.approved_plan_path)
1088
1428
  return _parse_stage_map_into_ctx(inp.approved_plan_path)
1089
1429
 
@@ -1348,9 +1688,23 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1348
1688
  validate_workers_against_profile(workers, profile_workers, optional_workers)
1349
1689
  if not workers:
1350
1690
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1691
+ _validate_option_selection_roster(inp.task_type, workers)
1351
1692
  return workers, ",".join(workers)
1352
1693
 
1353
1694
 
1695
+ def _validate_option_selection_roster(
1696
+ task_type: str, workers: Sequence[str]
1697
+ ) -> None:
1698
+ """Require independent analysis from three workers before option selection."""
1699
+ if task_type != "implementation-option-selection":
1700
+ return
1701
+ analyser_count = sum(worker != "report-writer" for worker in workers)
1702
+ if analyser_count < 3:
1703
+ raise PrepareError(
1704
+ "implementation-option-selection requires at least 3 analyser workers"
1705
+ )
1706
+
1707
+
1354
1708
  def _resolve_pr_template(inp: PrepareInputs) -> tuple[str, str]:
1355
1709
  """release-handoff 전용 PR 본문 템플릿 경로 + 출처를 해소한다 (그 외엔 빈 값)."""
1356
1710
  if inp.task_type != "release-handoff":
@@ -1987,11 +2341,17 @@ def _write_instruction_set_sources(
1987
2341
  profile_content: str,
1988
2342
  review_material: str,
1989
2343
  host_rules_file: Path | None,
2344
+ selected_direction: SelectedDirection | None,
1990
2345
  ) -> Path:
1991
2346
  """instruction-set 디렉터리에 profile/material/brief/clarification/directive 와
1992
2347
  reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
1993
2348
  instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
1994
2349
  instruction_set.mkdir(parents=True, exist_ok=True)
2350
+ if selected_direction is not None:
2351
+ write_selected_direction_snapshot(
2352
+ selected_direction,
2353
+ instruction_set / "selected-direction.json",
2354
+ )
1995
2355
  _write_analysis_evidence_artifact(ctx, instruction_set)
1996
2356
  _write_verification_target_artifact(inp, ctx, instruction_set)
1997
2357
  _write_prior_run_error_digest(ctx, instruction_set)
@@ -2252,6 +2612,7 @@ def _persist_run_inputs(
2252
2612
  "relatedTasks": inp.related_tasks_raw,
2253
2613
  "approvedPlanPath": approved_plan_path,
2254
2614
  "clarificationResponsePath": inp.clarification_response_path,
2615
+ "selectedDirectionPath": inp.selected_direction_path,
2255
2616
  "analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")).get(
2256
2617
  "requestedValue", ""
2257
2618
  ),
@@ -2796,6 +3157,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2796
3157
  inp.brief_path,
2797
3158
  assets.brief_validator,
2798
3159
  )
3160
+ selected_direction = _resolve_planning_direction(inp)
2799
3161
  if inp.task_type == "implementation":
2800
3162
  ctx_stage_map = _prepare_implementation_approved_plan(inp)
2801
3163
 
@@ -2929,6 +3291,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2929
3291
  relative_to_project_root(Path(inp.clarification_response_path), project_root)
2930
3292
  if inp.clarification_response_path else ""
2931
3293
  )
3294
+ selected_direction_relative = (
3295
+ relative_to_project_root(Path(inp.selected_direction_path), project_root)
3296
+ if inp.selected_direction_path else ""
3297
+ )
2932
3298
 
2933
3299
  forbidden_by_phase = load_phase_forbidden(workspace_root)
2934
3300
 
@@ -2946,6 +3312,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2946
3312
  "CLAUDE_SESSION_ID": claude_session_id,
2947
3313
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
2948
3314
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
3315
+ "SELECTED_DIRECTION_PATH": inp.selected_direction_path,
3316
+ "SELECTED_DIRECTION_RELATIVE_PATH": selected_direction_relative,
2949
3317
  **_reverify_scope_ctx(inp.reverify_scope),
2950
3318
  "BRIEF_FILE_PATH": str(inp.brief_path),
2951
3319
  "BRIEF_RELATIVE_PATH": brief_relative,
@@ -2978,7 +3346,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2978
3346
 
2979
3347
  # ---- write instruction-set scaffolding ----
2980
3348
  instruction_set = _write_instruction_set_sources(
2981
- inp, ctx, profile_content, review_material, assets.host_rules_file
3349
+ inp,
3350
+ ctx,
3351
+ profile_content,
3352
+ review_material,
3353
+ assets.host_rules_file,
3354
+ selected_direction,
2982
3355
  )
2983
3356
  # ---- run-inputs persistence ----
2984
3357
  _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
@@ -3134,6 +3507,7 @@ def main(argv: list[str]) -> int:
3134
3507
  ),
3135
3508
  )
3136
3509
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
3510
+ p.add_argument("--selected-direction", default="", dest="selected_direction_path")
3137
3511
  p.add_argument(
3138
3512
  "--reverify-scope",
3139
3513
  default="",
@@ -3212,7 +3586,9 @@ def main(argv: list[str]) -> int:
3212
3586
  return 1
3213
3587
  clarification_abs = ""
3214
3588
  if args.clarification_response_path:
3215
- cr = resolve_user_file(args.clarification_response_path, project_root)
3589
+ cr = _resolve_planning_input_path(
3590
+ args.clarification_response_path, project_root
3591
+ )
3216
3592
  if cr is None:
3217
3593
  print(
3218
3594
  f"clarification response file not found: {args.clarification_response_path}",
@@ -3220,6 +3596,18 @@ def main(argv: list[str]) -> int:
3220
3596
  )
3221
3597
  return 1
3222
3598
  clarification_abs = str(cr)
3599
+ selected_direction_abs = ""
3600
+ if args.selected_direction_path:
3601
+ selected = _resolve_planning_input_path(
3602
+ args.selected_direction_path, project_root
3603
+ )
3604
+ if selected is None:
3605
+ print(
3606
+ f"selected direction report not found: {args.selected_direction_path}",
3607
+ file=__import__("sys").stderr,
3608
+ )
3609
+ return 1
3610
+ selected_direction_abs = str(selected)
3223
3611
 
3224
3612
  inputs = PrepareInputs(
3225
3613
  workspace_root=Path(args.workspace_root).resolve(),
@@ -3254,6 +3642,7 @@ def main(argv: list[str]) -> int:
3254
3642
  stage=args.stage,
3255
3643
  stages=args.stages,
3256
3644
  clarification_response_path=clarification_abs,
3645
+ selected_direction_path=selected_direction_abs,
3257
3646
  reverify_scope=args.reverify_scope,
3258
3647
  pr_template_path=args.pr_template_path,
3259
3648
  render_only=args.render_only,
@@ -1,6 +1,6 @@
1
1
  """Build a task-type-scoped excerpt of the final-report schema.
2
2
 
3
- The full schema (``schemas/final-report-v1.0.schema.json``) carries the
3
+ The full schema (``schemas/final-report-v2.0.schema.json``) carries the
4
4
  deliverable property blocks for ALL task-types (``errorAnalysis``, the three
5
5
  read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
6
6
  ``implementation``, and ``finalVerification``) plus a
@@ -21,7 +21,7 @@ CONTRACT_RULES = frozenset(
21
21
  )
22
22
 
23
23
  # Mirrors the report schema's requirement id pattern
24
- # (schemas/final-report-v1.0.schema.json: `^R-\d{3,}$`).
24
+ # (schemas/final-report-v2.0.schema.json: `^R-\d{3,}$`).
25
25
  _REQ_ID_RE = re.compile(r"^R-\d{3,}$")
26
26
  _BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
27
27
  _DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
@@ -89,8 +89,8 @@ def brief_headings(brief_path: Path) -> set[str]:
89
89
  return headings
90
90
 
91
91
 
92
- def brief_end_state_ids(brief_path: Path) -> set[str]:
93
- """End-state ids the brief declares, across all three sections.
92
+ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
93
+ """End-state ids the brief declares, preserving section and item order.
94
94
 
95
95
  The reading rules mirror validators/validate-brief.py exactly, because that
96
96
  file checks the very same lines for their format and the two readers must
@@ -106,16 +106,17 @@ def brief_end_state_ids(brief_path: Path) -> set[str]:
106
106
  accepted — is the worse failure: the brief passes, the id is absent from the
107
107
  declared set, and no phase is ever asked to account for it.
108
108
 
109
- An empty set means the brief predates the end-state sections, which is what
110
- the downstream conditional gate keys on: a legacy brief keeps the legacy
111
- path instead of being wedged by a contract it was never written against.
109
+ An empty sequence means the brief predates the end-state sections, which is
110
+ what the downstream conditional gate keys on: a legacy brief keeps the
111
+ legacy path instead of being wedged by a contract it was never written
112
+ against.
112
113
  """
113
114
  try:
114
115
  text = _HTML_COMMENT_RE.sub("", Path(brief_path).read_text(encoding="utf-8"))
115
116
  except OSError:
116
- return set()
117
+ return ()
117
118
 
118
- ids: set[str] = set()
119
+ ids: list[str] = []
119
120
  section = ""
120
121
  for line in text.splitlines():
121
122
  if m := _SECTION_HEADING_RE.match(line):
@@ -125,8 +126,13 @@ def brief_end_state_ids(brief_path: Path) -> set[str]:
125
126
  if not section:
126
127
  continue
127
128
  if m := _END_STATE_BULLET_RE.match(line.strip()):
128
- ids.add(m.group("id"))
129
- return ids
129
+ ids.append(m.group("id"))
130
+ return tuple(ids)
131
+
132
+
133
+ def brief_end_state_ids(brief_path: Path) -> set[str]:
134
+ """End-state ids the brief declares, across all three sections."""
135
+ return set(brief_end_state_id_sequence(brief_path))
130
136
 
131
137
 
132
138
  @dataclass(frozen=True)