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
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  import okstra_vendor # noqa: F401 # registers vendored dependency aliases
10
10
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
11
11
 
12
- from ..final_report_paths import translation_sidecar_path
12
+ from ..final_report_paths import final_report_data_path, translation_sidecar_path
13
13
  from ..final_report_schema import load_schema_for_data, validate
14
14
  from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
15
15
  from ..report_translation import overlay
@@ -142,11 +142,13 @@ def render_v2_html_view(
142
142
  env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
143
143
  response_js = (root / "report.js").read_text(encoding="utf-8")
144
144
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
145
+ source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
145
146
  context = {
146
147
  **view.context,
147
148
  "runMeta": run_meta,
148
149
  "reportMeta": _report_meta(data, run_meta),
149
150
  "taskType": view.task_type,
151
+ "sourceData": source_data,
150
152
  "dataSha256": _sha256(data_path),
151
153
  "markdownSha256": _sha256(markdown_path),
152
154
  "clarificationItems": data.get("clarificationItems", []),
@@ -161,7 +163,7 @@ def render_v2_html_view(
161
163
  output_path.write_text(
162
164
  inject_report_index(document, label=translate("base.contents")), encoding="utf-8"
163
165
  )
164
- if context["clarificationItems"]:
166
+ if context["clarificationItems"] or context.get("directionSelection"):
165
167
  # The footer tells the reader to drop the exported file here, so the
166
168
  # directory has to exist before they go looking for it.
167
169
  user_responses_dir_for_report(data_path).mkdir(parents=True, exist_ok=True)
@@ -8,6 +8,9 @@ from .view_models.error_analysis import build_error_analysis_view
8
8
  from .view_models.feature_analysis import build_feature_analysis_view
9
9
  from .view_models.final_verification import build_final_verification_view
10
10
  from .view_models.improvement_discovery import build_improvement_discovery_view
11
+ from .view_models.implementation_option_selection import (
12
+ build_implementation_option_selection_view,
13
+ )
11
14
  from .view_models.implementation import build_implementation_view
12
15
  from .view_models.implementation_planning import build_implementation_planning_view
13
16
  from .view_models.project_analysis import build_project_analysis_view
@@ -23,6 +26,7 @@ VIEW_BUILDERS = {
23
26
  "requirements-discovery": build_requirements_discovery_view,
24
27
  "improvement-discovery": build_improvement_discovery_view,
25
28
  "error-analysis": build_error_analysis_view,
29
+ "implementation-option-selection": build_implementation_option_selection_view,
26
30
  "project-analysis": build_project_analysis_view,
27
31
  "feature-analysis": build_feature_analysis_view,
28
32
  "change-impact-analysis": build_change_impact_analysis_view,
@@ -0,0 +1,32 @@
1
+ """Human-first implementation-direction comparison view."""
2
+ from __future__ import annotations
3
+
4
+ from ..common import evidence_index
5
+ from ..models import HumanReportView
6
+
7
+
8
+ def build_implementation_option_selection_view(data: dict) -> HumanReportView:
9
+ selection = data["implementationOptionSelection"]
10
+ context = {
11
+ "humanSummary": data["humanSummary"],
12
+ "selection": selection,
13
+ "directionSelection": (
14
+ selection if selection["mode"] == "candidate-comparison" else None
15
+ ),
16
+ "narrative": selection["userNarrative"],
17
+ "recommendedOption": next(
18
+ (
19
+ option
20
+ for option in selection["rankedOptions"]
21
+ if option["id"] == selection["recommendedOptionId"]
22
+ ),
23
+ None,
24
+ ),
25
+ "evidenceIndex": evidence_index(data),
26
+ }
27
+ return HumanReportView(
28
+ task_type="implementation-option-selection",
29
+ template_name="html/tasks/implementation-option-selection.template.html",
30
+ context=context,
31
+ figures=(),
32
+ )
@@ -15,6 +15,8 @@ class PlanApprovalState:
15
15
  recommended_option: str
16
16
  disabled_reason: str
17
17
  blocker_ids: tuple[str, ...]
18
+ show_option_selector: bool = True
19
+ reentry_command: str = ""
18
20
 
19
21
 
20
22
  def resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
@@ -32,6 +34,26 @@ def plan_approval_state(data: dict) -> PlanApprovalState | None:
32
34
  planning = data.get("implementationPlanning")
33
35
  if not isinstance(planning, dict):
34
36
  return None
37
+ blockers = tuple(
38
+ row["id"]
39
+ for row in data.get("clarificationItems", [])
40
+ if row.get("blocks") == "approval"
41
+ and row.get("status") in {"open", "answered"}
42
+ )
43
+ reason = f"{len(blockers)} approval blocker(s) unresolved" if blockers else ""
44
+ if planning.get("planningContract") == "selected-direction":
45
+ if planning.get("outcome") == "direction-invalidated" and not reason:
46
+ reason = "selected direction invalidated"
47
+ task_key = (data.get("header") or {}).get("taskKey", "")
48
+ reentry_command = (
49
+ f"/okstra-run task-key={task_key} "
50
+ "task-type=implementation-option-selection"
51
+ if planning.get("outcome") == "direction-invalidated" and task_key
52
+ else ""
53
+ )
54
+ return PlanApprovalState(
55
+ (), "", reason, blockers, False, reentry_command
56
+ )
35
57
  names = tuple(
36
58
  row["name"]
37
59
  for row in planning.get("optionCandidates", [])
@@ -40,13 +62,6 @@ def plan_approval_state(data: dict) -> PlanApprovalState | None:
40
62
  if not names:
41
63
  return None
42
64
  recommended = planning.get("recommendedOption", {}).get("name", "")
43
- blockers = tuple(
44
- row["id"]
45
- for row in data.get("clarificationItems", [])
46
- if row.get("blocks") == "approval"
47
- and row.get("status") in {"open", "answered"}
48
- )
49
- reason = f"{len(blockers)} approval blocker(s) unresolved" if blockers else ""
50
65
  return PlanApprovalState(
51
66
  names,
52
67
  resolve_recommended_option(recommended, names),
@@ -89,7 +104,7 @@ _OMITTED_FIELDS = (
89
104
 
90
105
  def build_implementation_planning_view(data: dict) -> HumanReportView:
91
106
  planning = data["implementationPlanning"]
92
- figure = _stage_figure(planning)
107
+ figure = _stage_figure(planning) if planning.get("stageMap") else None
93
108
  approval = plan_approval_state(data)
94
109
  activities = tuple(
95
110
  row for row in data.get("agentActivity", []) if isinstance(row, dict)
@@ -104,7 +119,7 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
104
119
  context = {
105
120
  "humanSummary": data["humanSummary"],
106
121
  "planning": planning,
107
- "narrative": planning["userNarrative"],
122
+ "narrative": planning.get("userNarrative", {}),
108
123
  "stageFigure": figure,
109
124
  "approval": approval,
110
125
  "openDecisions": [
@@ -125,6 +140,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
125
140
  "implementation-planning",
126
141
  "html/tasks/implementation-planning.template.html",
127
142
  context,
128
- (figure,),
143
+ (figure,) if figure is not None else (),
129
144
  _OMITTED_FIELDS,
130
145
  )
@@ -111,6 +111,8 @@ class RunMeta:
111
111
  task_type: str
112
112
  seq: str
113
113
  source_report: str # relative path of the .md the HTML is derived from
114
+ source_data: str = ""
115
+ source_data_sha256: str = ""
114
116
 
115
117
 
116
118
  @dataclass(frozen=True)
@@ -154,7 +156,9 @@ def _infer_run_meta_from_body(text: str) -> dict:
154
156
 
155
157
  def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
156
158
  task_type: Optional[str] = None, seq: Optional[str] = None,
157
- source_report: Optional[str] = None) -> RunMeta:
159
+ source_report: Optional[str] = None,
160
+ source_data: Optional[str] = None,
161
+ source_data_sha256: Optional[str] = None) -> RunMeta:
158
162
  """Derive a ``RunMeta`` from a final-report path/body, honouring any
159
163
  explicit override.
160
164
 
@@ -166,11 +170,22 @@ def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
166
170
  """
167
171
  text = report_path.read_text(encoding="utf-8")
168
172
  inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
173
+ resolved_source_report = source_report or report_path.name
174
+ data_path = final_report_data_path(report_path)
175
+ inferred_source_data = ""
176
+ inferred_source_data_sha256 = ""
177
+ if data_path.is_file():
178
+ inferred_source_data = final_report_data_path(
179
+ Path(resolved_source_report)
180
+ ).as_posix()
181
+ inferred_source_data_sha256 = hashlib.sha256(data_path.read_bytes()).hexdigest()
169
182
  return RunMeta(
170
183
  task_key=task_key or inferred.get("task_key") or "unknown",
171
184
  task_type=task_type or inferred.get("task_type") or "unknown",
172
185
  seq=seq or inferred.get("seq") or "000",
173
- source_report=source_report or report_path.name,
186
+ source_report=resolved_source_report,
187
+ source_data=source_data or inferred_source_data,
188
+ source_data_sha256=source_data_sha256 or inferred_source_data_sha256,
174
189
  )
175
190
 
176
191
 
@@ -271,6 +286,32 @@ def render_report_view_model(
271
286
  sidecar_name = html.escape(model.sidecar_name)
272
287
  sidecar_dir = html.escape(model.sidecar_dir)
273
288
  run_meta = model.run_meta
289
+ source_data_json = (
290
+ f'"source-data":"{html.escape(run_meta.source_data)}",'
291
+ if run_meta.source_data else ""
292
+ )
293
+ source_data_sha256_json = (
294
+ f'"source-data-sha256":"{html.escape(run_meta.source_data_sha256)}",'
295
+ if run_meta.source_data_sha256 else ""
296
+ )
297
+ reentry_only = bool(
298
+ model.approval_ctx and model.approval_ctx.reentry_command
299
+ )
300
+ export_header = (
301
+ ' <button type="button" data-action="export-user-response">Export user response</button>\n'
302
+ if not reentry_only else ""
303
+ )
304
+ export_footer = (
305
+ f'<footer class="report-footer">\n'
306
+ f' <button type="button" data-action="export-user-response">Export user response</button>\n'
307
+ f' <p class="user-response-hint">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — '
308
+ f'<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n'
309
+ f' <pre id="user-response-output" aria-live="polite"></pre>\n'
310
+ f' <button type="button" data-action="copy-user-response">Copy</button>\n'
311
+ f' <button type="button" data-action="dismiss-user-response" class="user-response-dismiss" aria-label="출력 닫기" title="닫기" hidden>×</button>\n'
312
+ f'</footer>\n'
313
+ if not reentry_only else ""
314
+ )
274
315
  return (
275
316
  f"<!DOCTYPE html>\n"
276
317
  f"<html lang=\"ko\">\n"
@@ -283,24 +324,19 @@ def render_report_view_model(
283
324
  f"<body>\n"
284
325
  f"<header class=\"report-header\">\n"
285
326
  f" <div>{title}</div>\n"
286
- f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
327
+ f"{export_header}"
287
328
  f"</header>\n"
288
329
  f"<main>{_reader_dashboard(model.reader_ctx)}{model.body_html}</main>\n"
289
330
  f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
290
331
  f"{_analysis_review_section(model.analysis_review_ctx) if model.analysis_review_ctx else ''}"
291
- f"<footer class=\"report-footer\">\n"
292
- f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
293
- f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
294
- f"<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n"
295
- f" <pre id=\"user-response-output\" aria-live=\"polite\"></pre>\n"
296
- f" <button type=\"button\" data-action=\"copy-user-response\">Copy</button>\n"
297
- f" <button type=\"button\" data-action=\"dismiss-user-response\" class=\"user-response-dismiss\" aria-label=\"출력 닫기\" title=\"닫기\" hidden>×</button>\n"
298
- f"</footer>\n"
332
+ f"{export_footer}"
299
333
  f"<script id=\"run-meta\" type=\"application/json\">"
300
334
  f"{{\"task-key\":\"{html.escape(run_meta.task_key)}\","
301
335
  f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
302
336
  f"\"seq\":\"{html.escape(run_meta.seq)}\","
303
337
  f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
338
+ f"{source_data_json}"
339
+ f"{source_data_sha256_json}"
304
340
  f"\"source-sha256\":\"{model.source_digest}\","
305
341
  f"\"response-ids\":{response_ids_json}}}"
306
342
  f"</script>\n"
@@ -1062,6 +1098,15 @@ class UserPlanDecision:
1062
1098
  reason: str = ""
1063
1099
 
1064
1100
 
1101
+ @dataclass(frozen=True)
1102
+ class UserDirectionSelection:
1103
+ option_id: str
1104
+ option_name: str
1105
+ confirmed: bool
1106
+ selection_note: str = ""
1107
+ constraints: str = ""
1108
+
1109
+
1065
1110
  @dataclass(frozen=True)
1066
1111
  class UserResponseAnalysisReview:
1067
1112
  status: str
@@ -1084,6 +1129,44 @@ _PLAN_DECISION_STATUSES = frozenset({
1084
1129
  "rejected",
1085
1130
  })
1086
1131
 
1132
+ _DIRECTION_IDENTITY_WHITESPACE = frozenset(
1133
+ "\u0009\u000a\u000b\u000c\u000d"
1134
+ "\u001c\u001d\u001e\u001f\u0020\u0085\u00a0\u1680"
1135
+ "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a"
1136
+ "\u2028\u2029\u202f\u205f\u3000\ufeff"
1137
+ )
1138
+ _DIRECTION_IDENTITY_ERROR = (
1139
+ "DIRECTION SELECTION Option-ID and Option-Name must be "
1140
+ "non-empty single-line values"
1141
+ )
1142
+
1143
+
1144
+ def _trim_direction_identity(value: str) -> str:
1145
+ start = 0
1146
+ end = len(value)
1147
+ while start < end and value[start] in _DIRECTION_IDENTITY_WHITESPACE:
1148
+ start += 1
1149
+ while end > start and value[end - 1] in _DIRECTION_IDENTITY_WHITESPACE:
1150
+ end -= 1
1151
+ return value[start:end]
1152
+
1153
+
1154
+ def normalize_direction_selection_identity(
1155
+ option_id: str, option_name: str,
1156
+ ) -> tuple[str, str]:
1157
+ if (
1158
+ "\r" in option_id
1159
+ or "\n" in option_id
1160
+ or "\r" in option_name
1161
+ or "\n" in option_name
1162
+ ):
1163
+ raise ValueError(_DIRECTION_IDENTITY_ERROR)
1164
+ normalized_id = _trim_direction_identity(option_id)
1165
+ normalized_name = _trim_direction_identity(option_name)
1166
+ if not normalized_id or not normalized_name:
1167
+ raise ValueError(_DIRECTION_IDENTITY_ERROR)
1168
+ return normalized_id, normalized_name
1169
+
1087
1170
 
1088
1171
  def _quoted_sidecar_field(label: str, value: str) -> str:
1089
1172
  cleaned = value.strip()
@@ -1125,6 +1208,23 @@ def _serialize_plan_decision(decision: UserPlanDecision) -> str:
1125
1208
  return chunk
1126
1209
 
1127
1210
 
1211
+ def _serialize_direction_selection(selection: UserDirectionSelection) -> str:
1212
+ option_id, option_name = normalize_direction_selection_identity(
1213
+ selection.option_id, selection.option_name
1214
+ )
1215
+ if not selection.confirmed:
1216
+ raise ValueError("DIRECTION SELECTION requires Confirmed: true")
1217
+ return (
1218
+ "\n## DIRECTION SELECTION\n"
1219
+ "- Status: selected\n"
1220
+ f"- Option-ID: {option_id}\n"
1221
+ f"- Option-Name: {option_name}\n"
1222
+ "- Confirmed: true\n"
1223
+ f"{_quoted_sidecar_field('Selection-Note', selection.selection_note)}"
1224
+ f"{_quoted_sidecar_field('Constraints', selection.constraints)}"
1225
+ )
1226
+
1227
+
1128
1228
  def serialize_user_response(
1129
1229
  *,
1130
1230
  run_meta: RunMeta,
@@ -1132,18 +1232,27 @@ def serialize_user_response(
1132
1232
  created_at: str,
1133
1233
  plan_decision: UserPlanDecision | None = None,
1134
1234
  analysis_review: UserResponseAnalysisReview | None = None,
1235
+ direction_selection: UserDirectionSelection | None = None,
1135
1236
  ) -> str:
1136
1237
  """Return the canonical markdown text the HTML 'Export user
1137
1238
  response' button must produce. Used by validators to confirm that
1138
1239
  pasted sidecar files conform to the schema and that the JS
1139
1240
  serialiser stayed in sync.
1140
1241
  """
1242
+ source_data_lines = ""
1243
+ if run_meta.source_data:
1244
+ source_data_lines += f"source-data: {run_meta.source_data}\n"
1245
+ if run_meta.source_data_sha256:
1246
+ source_data_lines += (
1247
+ f"source-data-sha256: {run_meta.source_data_sha256}\n"
1248
+ )
1141
1249
  head = (
1142
1250
  "---\n"
1143
1251
  f"task-key: {run_meta.task_key}\n"
1144
1252
  f"task-type: {run_meta.task_type}\n"
1145
1253
  f"seq: {run_meta.seq}\n"
1146
1254
  f"source-report: {run_meta.source_report}\n"
1255
+ f"{source_data_lines}"
1147
1256
  "created-by: user\n"
1148
1257
  f"created-at: {created_at}\n"
1149
1258
  "---\n"
@@ -1152,6 +1261,7 @@ def serialize_user_response(
1152
1261
  )
1153
1262
  has_plan_decision = plan_decision is not None
1154
1263
  has_analysis_review = analysis_review is not None
1264
+ has_direction_selection = direction_selection is not None
1155
1265
  body_chunks: list[str] = []
1156
1266
  for e in entries:
1157
1267
  chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
@@ -1162,12 +1272,19 @@ def serialize_user_response(
1162
1272
  if e.rationale:
1163
1273
  chunk += f"- Rationale: {e.rationale.strip()}\n"
1164
1274
  body_chunks.append(chunk)
1165
- if not entries and not has_plan_decision and not has_analysis_review:
1275
+ if (
1276
+ not entries
1277
+ and not has_plan_decision
1278
+ and not has_analysis_review
1279
+ and not has_direction_selection
1280
+ ):
1166
1281
  body_chunks.append("\n_(No user responses recorded.)_\n")
1167
1282
  if plan_decision is not None:
1168
1283
  body_chunks.append(_serialize_plan_decision(plan_decision))
1169
1284
  if analysis_review is not None:
1170
1285
  body_chunks.append(_serialize_analysis_review(analysis_review))
1286
+ if direction_selection is not None:
1287
+ body_chunks.append(_serialize_direction_selection(direction_selection))
1171
1288
  return head + "".join(body_chunks)
1172
1289
 
1173
1290
 
@@ -1263,6 +1380,8 @@ def plan_approval_context(src_md_path: Path) -> PlanApprovalContext | None:
1263
1380
  recommended_option=state.recommended_option,
1264
1381
  disabled_reason=reason,
1265
1382
  blocker_ids=blocker_ids,
1383
+ show_option_selector=state.show_option_selector,
1384
+ reentry_command=state.reentry_command,
1266
1385
  )
1267
1386
 
1268
1387
 
@@ -1352,6 +1471,16 @@ def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> s
1352
1471
  """disabled 사유를 화면에서 바로 따라할 수 있는 단계별 안내로 렌더한다.
1353
1472
  핵심: 이 화면에서 답을 입력하는 것만으로는 승인이 풀리지 않으며,
1354
1473
  Export → 저장 → resume-clarification → 재렌더 라운드트립이 필요하다는 것."""
1474
+ if ctx.reentry_command:
1475
+ command = (
1476
+ f"/okstra-run task-key={run_meta.task_key} "
1477
+ "task-type=implementation-option-selection"
1478
+ )
1479
+ return (
1480
+ '<p class="approval-disabled-reason">선택 방향이 무효화되어 이 계획은 '
1481
+ "승인할 수 없습니다. 구현 방향 선택 단계에 다시 진입하세요: "
1482
+ f"<code>{html.escape(command)}</code></p>"
1483
+ )
1355
1484
  if not ctx.blocker_ids: # unreadable 사유 — 재렌더 외에 따라할 단계가 없다.
1356
1485
  return f'<p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
1357
1486
  sidecar_dir = f"runs/{run_meta.task_type}/user-responses/"
@@ -1379,6 +1508,13 @@ def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> s
1379
1508
 
1380
1509
 
1381
1510
  def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
1511
+ if ctx.reentry_command:
1512
+ return (
1513
+ '<section id="plan-approval">\n'
1514
+ " <h2>선택 방향 무효화</h2>\n"
1515
+ f" {_approval_blocked_guidance(ctx, run_meta)}\n"
1516
+ "</section>\n"
1517
+ )
1382
1518
  disabled = " disabled" if ctx.disabled_reason else ""
1383
1519
  opts: list[str] = []
1384
1520
  for name in ctx.option_names: