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.
- package/README.md +8 -6
- package/docs/architecture/storage-model.md +11 -0
- package/docs/architecture.md +16 -14
- package/docs/cli.md +36 -5
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +21 -13
- package/docs/task-process/README.md +5 -3
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +23 -15
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +6 -5
- package/runtime/prompts/lead/plan-body-verification.md +20 -3
- package/runtime/prompts/lead/report-writer.md +27 -5
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +3 -3
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +50 -38
- package/runtime/prompts/profiles/implementation.md +2 -1
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +1 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_contract.py +45 -13
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +350 -2
- package/runtime/python/okstra_ctl/scope_provenance.py +15 -9
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
- package/runtime/templates/reports/final-report-v2.template.md +4 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +21 -1
- package/runtime/templates/reports/html/i18n/ko.json +21 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
- package/runtime/templates/reports/i18n/en.json +13 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +111 -4
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +25 -4
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/validate-implementation-plan-stages.py +106 -1
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +135 -25
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/src/commands/execute/codex-run.mjs +1 -0
- 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,
|
|
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 =
|
|
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
|
|
93
|
-
"""End-state ids the brief declares,
|
|
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
|
|
110
|
-
the downstream conditional gate keys on: a legacy brief keeps the
|
|
111
|
-
path instead of being wedged by a contract it was never written
|
|
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
|
|
117
|
+
return ()
|
|
117
118
|
|
|
118
|
-
ids:
|
|
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.
|
|
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
|