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
|
@@ -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
|
|
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
|
|
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=
|
|
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"
|
|
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"
|
|
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
|
|
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:
|