okstra 0.172.0 → 0.173.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 (83) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +11 -0
  3. package/docs/architecture.md +16 -14
  4. package/docs/cli.md +36 -5
  5. package/docs/performance-improvement-plan-v2.md +6 -5
  6. package/docs/project-structure-overview.md +21 -13
  7. package/docs/task-process/README.md +5 -3
  8. package/docs/task-process/error-analysis.md +2 -2
  9. package/docs/task-process/final-verification.md +2 -2
  10. package/docs/task-process/implementation-option-selection.md +70 -0
  11. package/docs/task-process/implementation-planning.md +23 -15
  12. package/docs/task-process/requirements-discovery.md +2 -2
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/workers/report-writer-worker.md +30 -6
  16. package/runtime/bin/lib/okstra/cli.sh +5 -1
  17. package/runtime/bin/lib/okstra/globals.sh +1 -0
  18. package/runtime/bin/lib/okstra/usage.sh +3 -0
  19. package/runtime/bin/okstra.sh +2 -0
  20. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  21. package/runtime/prompts/duties/planning-worker.md +12 -4
  22. package/runtime/prompts/lead/context-loader.md +1 -1
  23. package/runtime/prompts/lead/convergence.md +5 -5
  24. package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
  25. package/runtime/prompts/lead/plan-body-verification.md +20 -3
  26. package/runtime/prompts/lead/report-writer.md +27 -5
  27. package/runtime/prompts/profiles/_common-contract.md +1 -1
  28. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  29. package/runtime/prompts/profiles/error-analysis.md +3 -3
  30. package/runtime/prompts/profiles/final-verification.md +3 -3
  31. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  32. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  33. package/runtime/prompts/profiles/implementation-planning.md +50 -38
  34. package/runtime/prompts/profiles/implementation.md +2 -1
  35. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  36. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  37. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  38. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  39. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  40. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  41. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  42. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  43. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  44. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  45. package/runtime/python/okstra_ctl/render.py +1 -0
  46. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  47. package/runtime/python/okstra_ctl/report_contract.py +45 -13
  48. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  49. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  51. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  52. package/runtime/python/okstra_ctl/report_views.py +148 -12
  53. package/runtime/python/okstra_ctl/run.py +350 -2
  54. package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
  55. package/runtime/python/okstra_ctl/user_response.py +75 -0
  56. package/runtime/python/okstra_ctl/wizard.py +144 -0
  57. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  58. package/runtime/python/okstra_ctl/workflow.py +29 -7
  59. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  60. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  61. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  62. package/runtime/templates/reports/html/base.template.html +3 -2
  63. package/runtime/templates/reports/html/i18n/en.json +21 -1
  64. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  65. package/runtime/templates/reports/html/macros/forms.html +21 -2
  66. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  67. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  68. package/runtime/templates/reports/i18n/en.json +13 -0
  69. package/runtime/templates/reports/implementation-input.template.md +4 -2
  70. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  71. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  72. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  73. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  74. package/runtime/templates/reports/report.js +111 -4
  75. package/runtime/templates/reports/task-brief.template.md +9 -3
  76. package/runtime/templates/reports/user-response.template.md +25 -4
  77. package/runtime/templates/worker-prompt-preamble.md +8 -0
  78. package/runtime/validators/validate-implementation-plan-stages.py +106 -1
  79. package/runtime/validators/validate-report-views.py +2 -2
  80. package/runtime/validators/validate-run.py +135 -25
  81. package/runtime/validators/validate_improvement_report.py +5 -1
  82. package/src/commands/execute/codex-run.mjs +1 -0
  83. package/src/commands/execute/render-bundle.mjs +1 -0
@@ -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,
@@ -154,6 +164,7 @@ from .brief_frontmatter import (
154
164
  has_reporter_confirmation_contract,
155
165
  read_brief_frontmatter,
156
166
  )
167
+ from .scope_provenance import brief_end_state_id_sequence
157
168
 
158
169
  # Frontmatter approval-flag matcher.
159
170
  #
@@ -183,6 +194,10 @@ IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN = re.compile(
183
194
  r"^implementation-option:[ \t]*(.*)$",
184
195
  re.MULTILINE,
185
196
  )
197
+ SELECTED_DIRECTION_FRONTMATTER_PATTERN = re.compile(
198
+ r"^selected-direction-ref:[ \t]*(.*)$",
199
+ re.MULTILINE,
200
+ )
186
201
 
187
202
  # validators/validate-run.py:_FRONTMATTER_BLOCK_RE 의 미러 — 선행 BOM/빈 줄을
188
203
  # 허용해 두 모듈이 같은 리포트의 frontmatter 게이트를 동일하게 판정하게 한다.
@@ -393,6 +408,8 @@ class PrepareInputs:
393
408
  # 별개 채널이다.
394
409
  stages: str = ""
395
410
  clarification_response_path: str = "" # absolute or empty
411
+ # implementation-planning 신규 실행 전용: 검증된 방향 선택 최종 보고서.
412
+ selected_direction_path: str = ""
396
413
  # implementation-planning 전용: 사용자가 고른 이번 재실행의 재검증 범위.
397
414
  # "" / "auto" = 리드의 `okstra incremental-scope` 판정에 맡김, "full" =
398
415
  # 전체 재검증 강제, "<stage csv>" = 그 stage 들을 impacted 로 지정.
@@ -781,6 +798,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
781
798
  ("--approved-plan", inp.approved_plan_path),
782
799
  ("--implementation-option", inp.implementation_option),
783
800
  ("--clarification-response", inp.clarification_response_path),
801
+ ("--selected-direction", inp.selected_direction_path),
784
802
  ("--workers", workers),
785
803
  ("--lead-provider", inp.lead_provider or ctx.get("LEAD_PROVIDER", "")),
786
804
  ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL", "")),
@@ -992,6 +1010,13 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
992
1010
  pass
993
1011
  elif not inp.brief_path.is_file():
994
1012
  raise PrepareError(f"task brief not found: {inp.brief_path}")
1013
+ elif inp.task_type == "implementation-option-selection" and not brief_end_state_id_sequence(
1014
+ inp.brief_path
1015
+ ):
1016
+ raise PrepareError(
1017
+ "regenerate the brief with stable end-state IDs before starting "
1018
+ "implementation-option-selection"
1019
+ )
995
1020
  ctx_stage_map: list = []
996
1021
  # implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
997
1022
  # 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope).
@@ -1041,10 +1066,123 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1041
1066
  raise PrepareError(
1042
1067
  f"clarification response file not found: {inp.clarification_response_path}"
1043
1068
  )
1069
+ _validate_planning_entry_inputs(project_root, inp)
1044
1070
  _validate_reverify_scope(inp)
1045
1071
  return ctx_stage_map
1046
1072
 
1047
1073
 
1074
+ _PLANNING_REPORT_RE = re.compile(
1075
+ r"^final-report-implementation-planning-\d{3,}\.md$"
1076
+ )
1077
+
1078
+
1079
+ def _resolve_planning_input_path(
1080
+ path_value: str, project_root: Path
1081
+ ) -> Path | None:
1082
+ """Resolve cwd-first planning inputs without following symbolic links."""
1083
+ raw_path = Path(path_value).expanduser()
1084
+ cwd_candidate = lexical_absolute_path(raw_path)
1085
+ if cwd_candidate.is_file():
1086
+ return cwd_candidate
1087
+ if not raw_path.is_absolute():
1088
+ project_candidate = lexical_absolute_path(project_root / raw_path)
1089
+ if project_candidate.is_file():
1090
+ return project_candidate
1091
+ return None
1092
+
1093
+
1094
+ def _is_existing_planning_report(
1095
+ path_value: str, project_root: Path, inp: PrepareInputs
1096
+ ) -> bool:
1097
+ from .paths import task_dir
1098
+
1099
+ try:
1100
+ report = lexical_absolute_path(Path(path_value))
1101
+ task_root = lexical_absolute_path(
1102
+ task_dir(project_root, inp.task_group, inp.task_id)
1103
+ )
1104
+ relative = report.relative_to(task_root)
1105
+ except ValueError:
1106
+ return False
1107
+ has_planning_layout = (
1108
+ _PLANNING_REPORT_RE.fullmatch(report.name) is not None
1109
+ and relative.parts[:3] == (
1110
+ "runs",
1111
+ "implementation-planning",
1112
+ "reports",
1113
+ )
1114
+ and len(relative.parts) == 4
1115
+ )
1116
+ if not has_planning_layout:
1117
+ return False
1118
+ try:
1119
+ validate_task_artifact_path(report, task_root, "planning report")
1120
+ except DirectionSelectionError:
1121
+ return False
1122
+ return True
1123
+
1124
+
1125
+ def _validate_planning_entry_inputs(
1126
+ project_root: Path, inp: PrepareInputs
1127
+ ) -> None:
1128
+ if inp.task_type != "implementation-planning":
1129
+ if inp.selected_direction_path:
1130
+ raise PrepareError(
1131
+ "--selected-direction is only meaningful with --task-type "
1132
+ f"implementation-planning; got {inp.task_type}"
1133
+ )
1134
+ return
1135
+ if inp.selected_direction_path and inp.clarification_response_path:
1136
+ raise PrepareError(
1137
+ "implementation-planning accepts either --selected-direction for a "
1138
+ "new plan or --clarification-response for a rerun, not both"
1139
+ )
1140
+ if inp.selected_direction_path:
1141
+ return
1142
+ if inp.clarification_response_path:
1143
+ if not _is_existing_planning_report(
1144
+ inp.clarification_response_path, project_root, inp
1145
+ ):
1146
+ raise PrepareError(
1147
+ "implementation-planning rerun --clarification-response must point "
1148
+ "to an existing implementation-planning report for the same task"
1149
+ )
1150
+ return
1151
+ raise PrepareError(
1152
+ "implementation-planning requires --selected-direction for a new plan or "
1153
+ "--clarification-response to an existing implementation-planning report"
1154
+ )
1155
+
1156
+
1157
+ def _resolve_planning_direction(inp: PrepareInputs) -> SelectedDirection | None:
1158
+ if inp.task_type != "implementation-planning" or not inp.selected_direction_path:
1159
+ return None
1160
+ from .paths import task_dir
1161
+
1162
+ try:
1163
+ report = lexical_absolute_path(Path(inp.selected_direction_path))
1164
+ expected_task_root = lexical_absolute_path(
1165
+ task_dir(Path(inp.project_root), inp.task_group, inp.task_id)
1166
+ )
1167
+ report.relative_to(expected_task_root)
1168
+ except ValueError as exc:
1169
+ raise PrepareError(
1170
+ "selected direction report must belong to the same task"
1171
+ ) from exc
1172
+ try:
1173
+ validate_task_artifact_path(
1174
+ report, expected_task_root, "selected direction report"
1175
+ )
1176
+ return resolve_selected_direction(
1177
+ report,
1178
+ expected_task_key=(
1179
+ f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
1180
+ ),
1181
+ )
1182
+ except DirectionSelectionError as exc:
1183
+ raise PrepareError(str(exc)) from exc
1184
+
1185
+
1048
1186
  def _validate_reverify_scope(inp: PrepareInputs) -> None:
1049
1187
  """A pinned re-verification scope is only actionable on a planning re-run.
1050
1188
 
@@ -1070,8 +1208,165 @@ def _validate_reverify_scope(inp: PrepareInputs) -> None:
1070
1208
  raise PrepareError(str(exc)) from exc
1071
1209
 
1072
1210
 
1211
+ def _implementation_plan_contract(
1212
+ inp: PrepareInputs,
1213
+ ) -> tuple[str, Path, dict | None]:
1214
+ """Classify an implementation plan from its sibling data.json contract."""
1215
+ plan_path = Path(inp.approved_plan_path)
1216
+ body = plan_path.read_text(encoding="utf-8", errors="replace")
1217
+ frontmatter = _extract_frontmatter_block(body) or ""
1218
+ markdown_ref_match = SELECTED_DIRECTION_FRONTMATTER_PATTERN.search(frontmatter)
1219
+ markdown_ref: str | None = None
1220
+ if markdown_ref_match is not None:
1221
+ markdown_ref = markdown_ref_match.group(1).strip()
1222
+ if markdown_ref.startswith('"'):
1223
+ try:
1224
+ markdown_ref = json.loads(markdown_ref)
1225
+ except json.JSONDecodeError as exc:
1226
+ raise PrepareError(
1227
+ "selected-direction plan markdown `selected-direction-ref` is "
1228
+ "not a valid quoted string"
1229
+ ) from exc
1230
+ elif len(markdown_ref) >= 2 and markdown_ref[0] == markdown_ref[-1] == "'":
1231
+ markdown_ref = markdown_ref[1:-1].replace("''", "'")
1232
+ if not isinstance(markdown_ref, str) or not markdown_ref.strip():
1233
+ raise PrepareError(
1234
+ "selected-direction plan markdown `selected-direction-ref` must "
1235
+ "be a non-empty string"
1236
+ )
1237
+ loaded = _load_final_report_data_if_present(plan_path)
1238
+ if loaded is None:
1239
+ if markdown_ref_match is not None:
1240
+ raise PrepareError(
1241
+ "selected-direction plan requires its sibling data.json before "
1242
+ f"implementation entry: {_final_report_data_path(plan_path)}"
1243
+ )
1244
+ return "legacy", plan_path, None
1245
+ _, data = loaded
1246
+ planning = data.get("implementationPlanning")
1247
+ if not isinstance(planning, dict):
1248
+ raise PrepareError(
1249
+ "approved plan sibling data.json must contain an "
1250
+ "implementationPlanning object"
1251
+ )
1252
+ contract = planning.get("planningContract")
1253
+ selected_payload = any(
1254
+ field in planning
1255
+ for field in (
1256
+ "selectedDirectionRef",
1257
+ "directionRealization",
1258
+ "directionInvalidation",
1259
+ "outcome",
1260
+ )
1261
+ )
1262
+ if contract is None:
1263
+ if markdown_ref_match is not None or selected_payload:
1264
+ raise PrepareError(
1265
+ "selected-direction markers require "
1266
+ "implementationPlanning.planningContract=`selected-direction`; "
1267
+ "the markdown `selected-direction-ref` and data contract disagree"
1268
+ )
1269
+ return "legacy", plan_path, data
1270
+ if contract != "selected-direction":
1271
+ raise PrepareError(
1272
+ "approved plan sibling data.json has unsupported planningContract "
1273
+ f"{contract!r}"
1274
+ )
1275
+ if markdown_ref_match is None:
1276
+ raise PrepareError(
1277
+ "selected-direction plan markdown frontmatter must contain "
1278
+ "`selected-direction-ref:`"
1279
+ )
1280
+ selected_ref = planning.get("selectedDirectionRef")
1281
+ snapshot_ref = (
1282
+ selected_ref.get("snapshotPath")
1283
+ if isinstance(selected_ref, dict)
1284
+ else None
1285
+ )
1286
+ if not isinstance(markdown_ref, str) or markdown_ref != snapshot_ref:
1287
+ raise PrepareError(
1288
+ "selected-direction plan markdown `selected-direction-ref` must "
1289
+ "exactly match implementationPlanning.selectedDirectionRef.snapshotPath"
1290
+ )
1291
+ if IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.search(frontmatter):
1292
+ raise PrepareError(
1293
+ "selected-direction plan must not contain an `implementation-option:` "
1294
+ "frontmatter field"
1295
+ )
1296
+ return "selected-direction", plan_path, data
1297
+
1298
+
1299
+ def _validate_selected_implementation_plan(
1300
+ inp: PrepareInputs,
1301
+ plan_path: Path,
1302
+ data: dict,
1303
+ ) -> None:
1304
+ """Apply the Task 8 selected-direction semantics at implementation entry."""
1305
+ planning = data.get("implementationPlanning") or {}
1306
+ if planning.get("outcome") != "plan-ready":
1307
+ raise PrepareError(
1308
+ "selected-direction plan is not implementation-ready: "
1309
+ f"outcome={planning.get('outcome')!r}; direction-invalidated plans "
1310
+ "must re-enter implementation-option-selection"
1311
+ )
1312
+ summary = planning.get("coverageSummary") or {}
1313
+ if summary.get("coverageVerdict") != "exact":
1314
+ raise PrepareError(
1315
+ "selected-direction plan requires exact coverage before implementation"
1316
+ )
1317
+
1318
+ from .paths import task_dir
1319
+
1320
+ expected_task_root = lexical_absolute_path(
1321
+ task_dir(Path(inp.project_root), inp.task_group, inp.task_id)
1322
+ )
1323
+ try:
1324
+ validate_task_artifact_path(plan_path, expected_task_root, "approved plan")
1325
+ data_path = validate_task_artifact_path(
1326
+ _final_report_data_path(plan_path),
1327
+ expected_task_root,
1328
+ "approved plan data.json",
1329
+ )
1330
+ if data_path != _final_report_data_path(plan_path):
1331
+ raise DirectionSelectionError("approved plan data.json must be a sibling")
1332
+ snapshot_path = validate_task_artifact_path(
1333
+ expected_task_root / "instruction-set" / "selected-direction.json",
1334
+ expected_task_root,
1335
+ "selected direction snapshot",
1336
+ )
1337
+ brief_path = validate_task_artifact_path(
1338
+ expected_task_root / "instruction-set" / "task-brief.md",
1339
+ expected_task_root,
1340
+ "selected direction task brief",
1341
+ )
1342
+ except DirectionSelectionError as exc:
1343
+ raise PrepareError(f"selected-direction plan input is invalid: {exc}") from exc
1344
+
1345
+ expected_task_key = f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
1346
+ actual_task_key = (data.get("header") or {}).get("taskKey")
1347
+ if actual_task_key != expected_task_key:
1348
+ raise PrepareError(
1349
+ "selected-direction plan header.taskKey must match the implementation "
1350
+ f"task: expected {expected_task_key!r}, got {actual_task_key!r}"
1351
+ )
1352
+ failures = validate_selected_direction_plan(data, brief_path, snapshot_path)
1353
+ if failures:
1354
+ raise PrepareError(
1355
+ "selected-direction plan failed implementation entry validation:\n - "
1356
+ + "\n - ".join(failures)
1357
+ )
1358
+
1359
+
1073
1360
  def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1074
1361
  """Apply approved-plan inputs only after canonical brief preflight succeeds."""
1362
+ contract, plan_path, data = _implementation_plan_contract(inp)
1363
+ if contract == "selected-direction":
1364
+ assert data is not None
1365
+ _validate_selected_implementation_plan(inp, plan_path, data)
1366
+ if inp.implementation_option:
1367
+ raise PrepareError(
1368
+ "--implementation-option is not accepted for a selected-direction plan"
1369
+ )
1075
1370
  if inp.approve_plan_ack or inp.implementation_option:
1076
1371
  with worktree_provision_mutex(
1077
1372
  okstra_home(), inp.project_id,
@@ -1083,7 +1378,11 @@ def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1083
1378
  _apply_cli_implementation_option(
1084
1379
  inp.approved_plan_path, inp.implementation_option
1085
1380
  )
1381
+ contract, plan_path, data = _implementation_plan_contract(inp)
1086
1382
  _validate_approved_plan(inp.approved_plan_path)
1383
+ if contract == "selected-direction":
1384
+ assert data is not None
1385
+ _validate_selected_implementation_plan(inp, plan_path, data)
1087
1386
  _validate_stage_structure(inp.approved_plan_path)
1088
1387
  return _parse_stage_map_into_ctx(inp.approved_plan_path)
1089
1388
 
@@ -1348,9 +1647,23 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1348
1647
  validate_workers_against_profile(workers, profile_workers, optional_workers)
1349
1648
  if not workers:
1350
1649
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1650
+ _validate_option_selection_roster(inp.task_type, workers)
1351
1651
  return workers, ",".join(workers)
1352
1652
 
1353
1653
 
1654
+ def _validate_option_selection_roster(
1655
+ task_type: str, workers: Sequence[str]
1656
+ ) -> None:
1657
+ """Require independent analysis from three workers before option selection."""
1658
+ if task_type != "implementation-option-selection":
1659
+ return
1660
+ analyser_count = sum(worker != "report-writer" for worker in workers)
1661
+ if analyser_count < 3:
1662
+ raise PrepareError(
1663
+ "implementation-option-selection requires at least 3 analyser workers"
1664
+ )
1665
+
1666
+
1354
1667
  def _resolve_pr_template(inp: PrepareInputs) -> tuple[str, str]:
1355
1668
  """release-handoff 전용 PR 본문 템플릿 경로 + 출처를 해소한다 (그 외엔 빈 값)."""
1356
1669
  if inp.task_type != "release-handoff":
@@ -1987,11 +2300,17 @@ def _write_instruction_set_sources(
1987
2300
  profile_content: str,
1988
2301
  review_material: str,
1989
2302
  host_rules_file: Path | None,
2303
+ selected_direction: SelectedDirection | None,
1990
2304
  ) -> Path:
1991
2305
  """instruction-set 디렉터리에 profile/material/brief/clarification/directive 와
1992
2306
  reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
1993
2307
  instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
1994
2308
  instruction_set.mkdir(parents=True, exist_ok=True)
2309
+ if selected_direction is not None:
2310
+ write_selected_direction_snapshot(
2311
+ selected_direction,
2312
+ instruction_set / "selected-direction.json",
2313
+ )
1995
2314
  _write_analysis_evidence_artifact(ctx, instruction_set)
1996
2315
  _write_verification_target_artifact(inp, ctx, instruction_set)
1997
2316
  _write_prior_run_error_digest(ctx, instruction_set)
@@ -2252,6 +2571,7 @@ def _persist_run_inputs(
2252
2571
  "relatedTasks": inp.related_tasks_raw,
2253
2572
  "approvedPlanPath": approved_plan_path,
2254
2573
  "clarificationResponsePath": inp.clarification_response_path,
2574
+ "selectedDirectionPath": inp.selected_direction_path,
2255
2575
  "analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")).get(
2256
2576
  "requestedValue", ""
2257
2577
  ),
@@ -2796,6 +3116,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2796
3116
  inp.brief_path,
2797
3117
  assets.brief_validator,
2798
3118
  )
3119
+ selected_direction = _resolve_planning_direction(inp)
2799
3120
  if inp.task_type == "implementation":
2800
3121
  ctx_stage_map = _prepare_implementation_approved_plan(inp)
2801
3122
 
@@ -2929,6 +3250,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2929
3250
  relative_to_project_root(Path(inp.clarification_response_path), project_root)
2930
3251
  if inp.clarification_response_path else ""
2931
3252
  )
3253
+ selected_direction_relative = (
3254
+ relative_to_project_root(Path(inp.selected_direction_path), project_root)
3255
+ if inp.selected_direction_path else ""
3256
+ )
2932
3257
 
2933
3258
  forbidden_by_phase = load_phase_forbidden(workspace_root)
2934
3259
 
@@ -2946,6 +3271,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2946
3271
  "CLAUDE_SESSION_ID": claude_session_id,
2947
3272
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
2948
3273
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
3274
+ "SELECTED_DIRECTION_PATH": inp.selected_direction_path,
3275
+ "SELECTED_DIRECTION_RELATIVE_PATH": selected_direction_relative,
2949
3276
  **_reverify_scope_ctx(inp.reverify_scope),
2950
3277
  "BRIEF_FILE_PATH": str(inp.brief_path),
2951
3278
  "BRIEF_RELATIVE_PATH": brief_relative,
@@ -2978,7 +3305,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2978
3305
 
2979
3306
  # ---- write instruction-set scaffolding ----
2980
3307
  instruction_set = _write_instruction_set_sources(
2981
- inp, ctx, profile_content, review_material, assets.host_rules_file
3308
+ inp,
3309
+ ctx,
3310
+ profile_content,
3311
+ review_material,
3312
+ assets.host_rules_file,
3313
+ selected_direction,
2982
3314
  )
2983
3315
  # ---- run-inputs persistence ----
2984
3316
  _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
@@ -3134,6 +3466,7 @@ def main(argv: list[str]) -> int:
3134
3466
  ),
3135
3467
  )
3136
3468
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
3469
+ p.add_argument("--selected-direction", default="", dest="selected_direction_path")
3137
3470
  p.add_argument(
3138
3471
  "--reverify-scope",
3139
3472
  default="",
@@ -3212,7 +3545,9 @@ def main(argv: list[str]) -> int:
3212
3545
  return 1
3213
3546
  clarification_abs = ""
3214
3547
  if args.clarification_response_path:
3215
- cr = resolve_user_file(args.clarification_response_path, project_root)
3548
+ cr = _resolve_planning_input_path(
3549
+ args.clarification_response_path, project_root
3550
+ )
3216
3551
  if cr is None:
3217
3552
  print(
3218
3553
  f"clarification response file not found: {args.clarification_response_path}",
@@ -3220,6 +3555,18 @@ def main(argv: list[str]) -> int:
3220
3555
  )
3221
3556
  return 1
3222
3557
  clarification_abs = str(cr)
3558
+ selected_direction_abs = ""
3559
+ if args.selected_direction_path:
3560
+ selected = _resolve_planning_input_path(
3561
+ args.selected_direction_path, project_root
3562
+ )
3563
+ if selected is None:
3564
+ print(
3565
+ f"selected direction report not found: {args.selected_direction_path}",
3566
+ file=__import__("sys").stderr,
3567
+ )
3568
+ return 1
3569
+ selected_direction_abs = str(selected)
3223
3570
 
3224
3571
  inputs = PrepareInputs(
3225
3572
  workspace_root=Path(args.workspace_root).resolve(),
@@ -3254,6 +3601,7 @@ def main(argv: list[str]) -> int:
3254
3601
  stage=args.stage,
3255
3602
  stages=args.stages,
3256
3603
  clarification_response_path=clarification_abs,
3604
+ selected_direction_path=selected_direction_abs,
3257
3605
  reverify_scope=args.reverify_scope,
3258
3606
  pr_template_path=args.pr_template_path,
3259
3607
  render_only=args.render_only,
@@ -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)
@@ -20,6 +20,7 @@ from typing import Optional
20
20
 
21
21
  from okstra_ctl.report_views import (
22
22
  PLAN_DECISION_APPROVED,
23
+ normalize_direction_selection_identity,
23
24
  serialize_user_response, UserResponseEntry, UserPlanDecision, infer_run_meta,
24
25
  parse_expected_form_options,
25
26
  )
@@ -36,6 +37,9 @@ from okstra_ctl.clarification_items import (
36
37
  _PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
37
38
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
38
39
  _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
40
+ _DIRECTION_SELECTION_HEADING_RE = re.compile(
41
+ r"^## DIRECTION SELECTION\s*$", re.MULTILINE
42
+ )
39
43
  _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
40
44
  r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
41
45
  )
@@ -79,6 +83,20 @@ class AnalysisReviewRecord:
79
83
  seq: str
80
84
 
81
85
 
86
+ @dataclass(frozen=True)
87
+ class DirectionSelectionRecord:
88
+ status: str
89
+ option_id: str
90
+ option_name: str
91
+ confirmed: bool
92
+ selection_note: str
93
+ constraints: str
94
+ source_report: str
95
+ source_data: str
96
+ source_data_sha256: str
97
+ seq: str
98
+
99
+
82
100
  _ANALYSIS_REVIEW_STATUSES = frozenset({
83
101
  "accepted",
84
102
  "revision-requested",
@@ -405,6 +423,19 @@ def _field(block: str, key: str) -> Optional[str]:
405
423
  return m.group(1) if m else None
406
424
 
407
425
 
426
+ def _direction_identity_fields(block: str) -> tuple[str, str]:
427
+ match = re.search(
428
+ r"^- Option-ID:[ \t]*(?P<option_id>[^\r\n]*)\r?\n"
429
+ r"- Option-Name:[ \t]*(?P<option_name>[^\r\n]*)\r?\n"
430
+ r"- Confirmed:",
431
+ block,
432
+ re.MULTILINE,
433
+ )
434
+ if match is None:
435
+ return "", ""
436
+ return match.group("option_id"), match.group("option_name")
437
+
438
+
408
439
  def _value(block: str) -> str:
409
440
  # Value 는 "- Value:" 다음 줄들의 " > " 인용 블록.
410
441
  m = re.search(r"^- Value:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE)
@@ -414,6 +445,50 @@ def _value(block: str) -> str:
414
445
  return "\n".join(lines).strip()
415
446
 
416
447
 
448
+ def parse_direction_selection(
449
+ sidecar_text: str,
450
+ ) -> DirectionSelectionRecord | None:
451
+ matches = list(_DIRECTION_SELECTION_HEADING_RE.finditer(sidecar_text))
452
+ if not matches:
453
+ return None
454
+ if len(matches) != 1:
455
+ raise UserResponseError(
456
+ "DIRECTION SELECTION requires exactly one block"
457
+ )
458
+ block = sidecar_text[matches[0].end():]
459
+ next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
460
+ if next_heading:
461
+ block = block[:next_heading.start()]
462
+ status = _field(block, "Status")
463
+ if status != "selected":
464
+ raise UserResponseError("DIRECTION SELECTION Status must be selected")
465
+ confirmed = _field(block, "Confirmed")
466
+ if confirmed != "true":
467
+ raise UserResponseError("DIRECTION SELECTION Confirmed must be true")
468
+ raw_option_id, raw_option_name = _direction_identity_fields(block)
469
+ try:
470
+ option_id, option_name = normalize_direction_selection_identity(
471
+ raw_option_id,
472
+ raw_option_name,
473
+ )
474
+ except ValueError as error:
475
+ raise UserResponseError(str(error)) from error
476
+ return DirectionSelectionRecord(
477
+ status=status,
478
+ option_id=option_id,
479
+ option_name=option_name,
480
+ confirmed=True,
481
+ selection_note=_quoted_review_value(block, "Selection-Note"),
482
+ constraints=_quoted_review_value(block, "Constraints"),
483
+ source_report=_sidecar_metadata_value(sidecar_text, "source-report"),
484
+ source_data=_sidecar_metadata_value(sidecar_text, "source-data"),
485
+ source_data_sha256=_sidecar_metadata_value(
486
+ sidecar_text, "source-data-sha256"
487
+ ),
488
+ seq=_sidecar_metadata_value(sidecar_text, "seq"),
489
+ )
490
+
491
+
417
492
  def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
418
493
  """Reverse of ``serialize_user_response`` for the per-response ``## C-*``
419
494
  blocks. The ``## PLAN DECISION`` block is skipped (read separately by