okstra 0.186.1 → 0.186.3
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/docs/architecture.md +3 -3
- package/docs/for-ai/skills/okstra-run.md +1 -1
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +2 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +18 -5
- package/runtime/prompts/lead/plan-body-verification.md +5 -5
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +4 -5
- package/runtime/python/okstra_ctl/approval_decisions.py +1 -1
- package/runtime/python/okstra_ctl/clarification_items.py +141 -28
- package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
- package/runtime/python/okstra_ctl/implementation_direction.py +8 -9
- package/runtime/python/okstra_ctl/next_phase.py +24 -22
- package/runtime/python/okstra_ctl/render_final_report.py +4 -8
- package/runtime/python/okstra_ctl/report_assembly.py +5 -1
- package/runtime/python/okstra_ctl/run.py +34 -3
- package/runtime/python/okstra_ctl/user_response.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +4 -2
- package/runtime/templates/reports/html/assets/base.css +19 -9
- package/runtime/templates/reports/html/assets/base.js +21 -0
- package/runtime/templates/reports/html/base.template.html +5 -2
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/reports/html/macros/forms.html +8 -3
- package/runtime/templates/reports/report.js +33 -0
- package/runtime/validators/validate-run.py +138 -47
- package/runtime/validators/validate_analysis_report.py +7 -6
- package/runtime/validators/validate_session_conformance.py +24 -3
|
@@ -181,6 +181,84 @@ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
|
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
_LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
|
|
184
|
+
_PAREN_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _split_declared_paths(raw: str) -> list[str]:
|
|
188
|
+
"""쉼표·줄바꿈으로 이어진 경로를 파일 단위로 나눈다. `{a,b}` 안 쉼표는 유지.
|
|
189
|
+
|
|
190
|
+
`fileStructure.path` 는 원래 열 수 있는 경로 하나인데, 계획이 여러 파일을
|
|
191
|
+
한 칸에 이어 쓰면 추출기가 그 문자열 전체를 한 경로로 보고 스테이지 매핑에
|
|
192
|
+
실패한다.
|
|
193
|
+
"""
|
|
194
|
+
parts: list[str] = []
|
|
195
|
+
buf: list[str] = []
|
|
196
|
+
depth = 0
|
|
197
|
+
for char in raw:
|
|
198
|
+
if char == "{":
|
|
199
|
+
depth += 1
|
|
200
|
+
buf.append(char)
|
|
201
|
+
elif char == "}":
|
|
202
|
+
depth = max(0, depth - 1)
|
|
203
|
+
buf.append(char)
|
|
204
|
+
elif char in ",\n" and depth == 0:
|
|
205
|
+
token = "".join(buf).strip()
|
|
206
|
+
if token:
|
|
207
|
+
parts.append(token)
|
|
208
|
+
buf = []
|
|
209
|
+
else:
|
|
210
|
+
buf.append(char)
|
|
211
|
+
token = "".join(buf).strip()
|
|
212
|
+
if token:
|
|
213
|
+
parts.append(token)
|
|
214
|
+
return parts
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _step_files_cells(step: Mapping[str, Any]) -> list[str]:
|
|
218
|
+
raw = step.get("files")
|
|
219
|
+
if isinstance(raw, list):
|
|
220
|
+
return [_normalise(item) for item in raw if item]
|
|
221
|
+
text = _normalise(raw)
|
|
222
|
+
return [text] if text else []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _files_cell_tokens(cell: str) -> list[str]:
|
|
226
|
+
tokens: list[str] = []
|
|
227
|
+
for part in _split_declared_paths(cell) or [cell]:
|
|
228
|
+
token = _LINE_RANGE_SUFFIX.sub("", part)
|
|
229
|
+
token = _PAREN_SUFFIX.sub("", token).strip()
|
|
230
|
+
if token:
|
|
231
|
+
tokens.append(token)
|
|
232
|
+
return tokens
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cell_covers_path(cell: str, path: str) -> bool:
|
|
236
|
+
path_pattern = rf"(?<![\w./@-]){re.escape(path)}(?![\w./@-])"
|
|
237
|
+
if re.search(path_pattern, cell) is not None:
|
|
238
|
+
return True
|
|
239
|
+
for token in _files_cell_tokens(cell):
|
|
240
|
+
if token == path:
|
|
241
|
+
return True
|
|
242
|
+
if "*" in token and fnmatch.fnmatch(path, token):
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _stages_touching_path(
|
|
248
|
+
path: str,
|
|
249
|
+
stages: Mapping[int, Mapping[str, Any]],
|
|
250
|
+
) -> list[int]:
|
|
251
|
+
hits: list[int] = []
|
|
252
|
+
for stage_number, stage in stages.items():
|
|
253
|
+
cells = [
|
|
254
|
+
cell
|
|
255
|
+
for step in stage.get("stepwiseExecution") or []
|
|
256
|
+
if isinstance(step, Mapping)
|
|
257
|
+
for cell in _step_files_cells(step)
|
|
258
|
+
]
|
|
259
|
+
if any(_cell_covers_path(cell, path) for cell in cells):
|
|
260
|
+
hits.append(stage_number)
|
|
261
|
+
return hits
|
|
184
262
|
|
|
185
263
|
|
|
186
264
|
def _stages_for_selected_path(
|
|
@@ -200,21 +278,27 @@ def _stages_for_selected_path(
|
|
|
200
278
|
Zero stages is still an error: the recommended option declares a file that
|
|
201
279
|
no step creates or edits.
|
|
202
280
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
for step in stage.get("stepwiseExecution") or []
|
|
210
|
-
if isinstance(step, Mapping)
|
|
211
|
-
]
|
|
212
|
-
if any(re.search(path_pattern, cell) is not None for cell in files):
|
|
213
|
-
matched.append(stage_number)
|
|
214
|
-
if not matched:
|
|
281
|
+
declared = [
|
|
282
|
+
_LINE_RANGE_SUFFIX.sub("", _normalise(part))
|
|
283
|
+
for part in (_split_declared_paths(path) or [path])
|
|
284
|
+
]
|
|
285
|
+
declared = [part for part in declared if part]
|
|
286
|
+
if not declared:
|
|
215
287
|
raise DesignSurfaceError(
|
|
216
288
|
f"selected option path {path!r} is not mapped to a stage"
|
|
217
289
|
)
|
|
290
|
+
matched: list[int] = []
|
|
291
|
+
seen: set[int] = set()
|
|
292
|
+
for part in declared:
|
|
293
|
+
hits = _stages_touching_path(part, stages)
|
|
294
|
+
if not hits:
|
|
295
|
+
raise DesignSurfaceError(
|
|
296
|
+
f"selected option path {part!r} is not mapped to a stage"
|
|
297
|
+
)
|
|
298
|
+
for stage_number in hits:
|
|
299
|
+
if stage_number not in seen:
|
|
300
|
+
seen.add(stage_number)
|
|
301
|
+
matched.append(stage_number)
|
|
218
302
|
return matched
|
|
219
303
|
|
|
220
304
|
|
|
@@ -243,12 +327,13 @@ def detect_design_surfaces(
|
|
|
243
327
|
continue
|
|
244
328
|
path = str(row.get("path") or "")
|
|
245
329
|
for stage_number in _stages_for_selected_path(path, stages):
|
|
246
|
-
for
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
330
|
+
for declared in _split_declared_paths(path) or [path]:
|
|
331
|
+
for kind, evidence in _matching_rule_evidence(
|
|
332
|
+
step=None,
|
|
333
|
+
field="files",
|
|
334
|
+
value=declared,
|
|
335
|
+
):
|
|
336
|
+
grouped.setdefault((stage_number, kind), []).append(evidence)
|
|
252
337
|
rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
|
|
253
338
|
return [
|
|
254
339
|
DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
|
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Any, Mapping
|
|
15
15
|
|
|
16
|
+
from .clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
|
|
16
17
|
from .final_report_paths import final_report_data_path
|
|
17
18
|
from .exact_coverage import ExactCoverageError, calculate_plan_exact_coverage
|
|
18
19
|
from .final_report_schema import (
|
|
@@ -309,15 +310,13 @@ def _selected_option(
|
|
|
309
310
|
def _validate_no_blockers(data: Mapping[str, Any], option: Mapping[str, Any]) -> None:
|
|
310
311
|
if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
|
|
311
312
|
raise DirectionSelectionError("selected candidate has a safety blocker")
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
"selection report has an unresolved next-phase blocker"
|
|
320
|
-
)
|
|
313
|
+
blockers = progress_blocking_ids(
|
|
314
|
+
data.get("clarificationItems"), USER_INPUT_BLOCKS
|
|
315
|
+
)
|
|
316
|
+
if blockers:
|
|
317
|
+
raise DirectionSelectionError(
|
|
318
|
+
"selection report has an unresolved next-phase blocker"
|
|
319
|
+
)
|
|
321
320
|
|
|
322
321
|
|
|
323
322
|
def _direction_payload(option: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -9,7 +9,10 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import Any, Mapping
|
|
11
11
|
|
|
12
|
-
from okstra_ctl.clarification_items import
|
|
12
|
+
from okstra_ctl.clarification_items import (
|
|
13
|
+
APPROVAL_BLOCKS,
|
|
14
|
+
progress_blocking_ids,
|
|
15
|
+
)
|
|
13
16
|
|
|
14
17
|
STATUS_READY = "ready"
|
|
15
18
|
STATUS_PENDING = "pending"
|
|
@@ -219,24 +222,9 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
|
|
|
219
222
|
|
|
220
223
|
|
|
221
224
|
def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
ids: list[str] = []
|
|
226
|
-
for row in rows:
|
|
227
|
-
if not isinstance(row, Mapping):
|
|
228
|
-
continue
|
|
229
|
-
blocks = str(row.get("blocks") or "").strip().lower()
|
|
230
|
-
status = str(row.get("status") or "").strip().lower()
|
|
231
|
-
row_id = row.get("id")
|
|
232
|
-
if (
|
|
233
|
-
blocks in APPROVAL_BLOCKS
|
|
234
|
-
and status in UNRESOLVED_STATUSES
|
|
235
|
-
and isinstance(row_id, str)
|
|
236
|
-
and row_id
|
|
237
|
-
):
|
|
238
|
-
ids.append(row_id)
|
|
239
|
-
return ids
|
|
225
|
+
return progress_blocking_ids(
|
|
226
|
+
report_data.get("clarificationItems"), APPROVAL_BLOCKS
|
|
227
|
+
)
|
|
240
228
|
|
|
241
229
|
|
|
242
230
|
def _planning_approval_block_reason(
|
|
@@ -245,8 +233,10 @@ def _planning_approval_block_reason(
|
|
|
245
233
|
"""plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
|
|
246
234
|
|
|
247
235
|
자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
|
|
248
|
-
들어온다. 차단은 `
|
|
249
|
-
|
|
236
|
+
들어온다. 차단은 `aborted-non-result` 와, 사용자가 아직 진행 처분을
|
|
237
|
+
고르지 않은 `Blocks=approval` 행이다. `blocked-by-disagreement` 는 그
|
|
238
|
+
행들이 전부 `accept-risk` / `select` / `answer` 이면 증거가 된 뒤라
|
|
239
|
+
포인터를 막지 않는다.
|
|
250
240
|
"""
|
|
251
241
|
ids = _unresolved_approval_ids(report_data)
|
|
252
242
|
if ids:
|
|
@@ -261,11 +251,23 @@ def _planning_approval_block_reason(
|
|
|
261
251
|
gate = ""
|
|
262
252
|
if isinstance(verification, Mapping):
|
|
263
253
|
gate = str(verification.get("gateResult") or "").strip().lower()
|
|
264
|
-
if gate
|
|
254
|
+
if gate == "aborted-non-result":
|
|
265
255
|
return (
|
|
266
256
|
f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
|
|
267
257
|
"구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
|
|
268
258
|
)
|
|
259
|
+
if gate == "blocked-by-disagreement":
|
|
260
|
+
approval_rows = [
|
|
261
|
+
row
|
|
262
|
+
for row in (report_data.get("clarificationItems") or [])
|
|
263
|
+
if isinstance(row, Mapping)
|
|
264
|
+
and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
|
|
265
|
+
]
|
|
266
|
+
if not approval_rows:
|
|
267
|
+
return (
|
|
268
|
+
f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
|
|
269
|
+
"구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
|
|
270
|
+
)
|
|
269
271
|
return ""
|
|
270
272
|
|
|
271
273
|
|
|
@@ -44,6 +44,7 @@ from typing import Any
|
|
|
44
44
|
import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
|
|
45
45
|
from jinja2 import ChainableUndefined, Environment, FileSystemLoader
|
|
46
46
|
|
|
47
|
+
from okstra_ctl.clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
|
|
47
48
|
from okstra_ctl.final_report_schema import (
|
|
48
49
|
SchemaError,
|
|
49
50
|
load_schema_for_data,
|
|
@@ -555,14 +556,9 @@ def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
|
|
|
555
556
|
)
|
|
556
557
|
context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
|
|
557
558
|
context["aiTaskTemplate"] = _markdown_task_template(task_type)
|
|
558
|
-
context["aiBlockingIds"] =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if isinstance(row, dict)
|
|
562
|
-
and row.get("status") in {"open", "answered"}
|
|
563
|
-
and row.get("blocks") in {"approval", "next-phase"}
|
|
564
|
-
and isinstance(row.get("id"), str)
|
|
565
|
-
]
|
|
559
|
+
context["aiBlockingIds"] = progress_blocking_ids(
|
|
560
|
+
data.get("clarificationItems", []), USER_INPUT_BLOCKS
|
|
561
|
+
)
|
|
566
562
|
sections = ReportSections(data, schema or {})
|
|
567
563
|
context["md"] = sections.section
|
|
568
564
|
context["md_rest"] = sections.rest
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Callable, Mapping, Sequence
|
|
10
10
|
|
|
11
11
|
from .agent_activity import agent_activity_rows
|
|
12
|
+
from .clarification_items import clarification_disposition, row_blocks_progress
|
|
12
13
|
from .final_report_schema import load_schema_version, validate
|
|
13
14
|
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
|
|
14
15
|
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
@@ -330,7 +331,10 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
330
331
|
data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
|
|
331
332
|
clarifications = data.get("clarificationItems") or []
|
|
332
333
|
blocked = any(
|
|
333
|
-
isinstance(row, Mapping)
|
|
334
|
+
isinstance(row, Mapping)
|
|
335
|
+
and row_blocks_progress(
|
|
336
|
+
str(row.get("status") or ""), clarification_disposition(row)
|
|
337
|
+
)
|
|
334
338
|
for row in clarifications
|
|
335
339
|
)
|
|
336
340
|
frontmatter = {
|
|
@@ -49,8 +49,10 @@ from .analysis_inputs import (
|
|
|
49
49
|
)
|
|
50
50
|
from .stage_fix_carry import derive_stage_fix_carry
|
|
51
51
|
from .clarification_items import (
|
|
52
|
+
APPROVAL_BLOCKS,
|
|
52
53
|
attached_user_responses_section,
|
|
53
54
|
clarification_response_with_sidecars,
|
|
55
|
+
progress_blocking_ids,
|
|
54
56
|
scan_approval_gate,
|
|
55
57
|
)
|
|
56
58
|
from .error_report import prior_run_error_digest
|
|
@@ -283,6 +285,29 @@ def _data_json_gate_result(data: dict) -> str:
|
|
|
283
285
|
return str(verification.get("gateResult") or "").strip().lower()
|
|
284
286
|
|
|
285
287
|
|
|
288
|
+
def _blocking_gate_survives_user_decision(data: dict, gate: str) -> bool:
|
|
289
|
+
"""사용자가 진행 처분을 골라도 이 게이트 값이 승인을 막는가.
|
|
290
|
+
|
|
291
|
+
`aborted-non-result` 는 투표가 없어 사용자 판단의 대상이 아니다.
|
|
292
|
+
`blocked-by-disagreement` 는 승인 행이 있고 그 행이 전부 진행 처분이면
|
|
293
|
+
DISAGREE 를 증거로 남긴 채 막지 않는다. 승인 행이 없으면 판단 기록이
|
|
294
|
+
없으므로 막는다.
|
|
295
|
+
"""
|
|
296
|
+
if gate != "blocked-by-disagreement":
|
|
297
|
+
return True
|
|
298
|
+
rows = data.get("clarificationItems")
|
|
299
|
+
if not isinstance(rows, list):
|
|
300
|
+
return True
|
|
301
|
+
has_approval_row = any(
|
|
302
|
+
isinstance(row, dict)
|
|
303
|
+
and str(row.get("blocks") or "").strip().lower() in APPROVAL_BLOCKS
|
|
304
|
+
for row in rows
|
|
305
|
+
)
|
|
306
|
+
return (not has_approval_row) or bool(
|
|
307
|
+
progress_blocking_ids(rows, APPROVAL_BLOCKS)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
286
311
|
def _record_approved_flag(path: Path) -> bool | None:
|
|
287
312
|
"""정본 `frontmatter.approved`. 정본이 없으면(schema-v1) None."""
|
|
288
313
|
loaded = _load_final_report_data_if_present(path)
|
|
@@ -317,7 +342,10 @@ def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> No
|
|
|
317
342
|
if loaded is not None:
|
|
318
343
|
data_path, data = loaded
|
|
319
344
|
data_gate = _data_json_gate_result(data)
|
|
320
|
-
if
|
|
345
|
+
if (
|
|
346
|
+
data_gate in BLOCKING_PLAN_BODY_GATES
|
|
347
|
+
and _blocking_gate_survives_user_decision(data, data_gate)
|
|
348
|
+
):
|
|
321
349
|
raise PrepareError(
|
|
322
350
|
f"{action} rejected because approved plan data.json Gate result is "
|
|
323
351
|
f"`{data_gate}`: {data_path}\n"
|
|
@@ -421,7 +449,10 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
|
|
|
421
449
|
return False
|
|
422
450
|
data_path, data = loaded
|
|
423
451
|
data_gate = _data_json_gate_result(data)
|
|
424
|
-
if
|
|
452
|
+
if (
|
|
453
|
+
data_gate in BLOCKING_PLAN_BODY_GATES
|
|
454
|
+
and _blocking_gate_survives_user_decision(data, data_gate)
|
|
455
|
+
):
|
|
425
456
|
raise PrepareError(
|
|
426
457
|
f"--approve rejected because approved plan data.json Gate result is "
|
|
427
458
|
f"`{data_gate}`: {data_path}"
|
|
@@ -555,7 +586,7 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
555
586
|
_reject_blocking_plan_body_gate(p, "", action="approved plan validation")
|
|
556
587
|
_validate_approved_plan_conformance(p)
|
|
557
588
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
558
|
-
# Blocks=approval 행이 아직
|
|
589
|
+
# Blocks=approval 행이 아직 진행 처분 없이 열려 있으면 승인을 무효화한다.
|
|
559
590
|
scan = scan_approval_gate(p)
|
|
560
591
|
if scan.unreadable_reason:
|
|
561
592
|
raise PrepareError(
|
|
@@ -57,6 +57,7 @@ from okstra_ctl.paths import resolve_under_root
|
|
|
57
57
|
from okstra_ctl.run_context import dir_flock
|
|
58
58
|
from okstra_ctl.clarification_items import (
|
|
59
59
|
ClarificationItem,
|
|
60
|
+
UNRESOLVED_STATUSES,
|
|
60
61
|
read_clarification_rows,
|
|
61
62
|
sidecar_answers,
|
|
62
63
|
_section_1_slice,
|
|
@@ -885,7 +886,7 @@ def _open_blocker_rows(report_path: Path) -> list[dict[str, Any]]:
|
|
|
885
886
|
row
|
|
886
887
|
for row in rows
|
|
887
888
|
if row["item"].blocks in {"approval", "next-phase"}
|
|
888
|
-
and row["item"].status in
|
|
889
|
+
and row["item"].status in UNRESOLVED_STATUSES
|
|
889
890
|
and row["item"].row_id not in answered
|
|
890
891
|
]
|
|
891
892
|
|
|
@@ -6,25 +6,14 @@ from typing import Any, Mapping, Sequence
|
|
|
6
6
|
from .worker_prompt_policy import PromptPlan
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
_ANALYSIS_WORKER_LABELS = {
|
|
10
|
-
"claude": "Claude worker",
|
|
11
|
-
"codex": "Codex worker",
|
|
12
|
-
"antigravity": "Antigravity worker",
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
9
|
def analysis_worker_label(worker_id: str) -> str:
|
|
17
10
|
"""The role label this worker's prompt body is titled with.
|
|
18
11
|
|
|
19
|
-
The
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
group must normalize away, so `worker_prompt_contract` erases exactly what
|
|
23
|
-
this function returns rather than restating the map — an enumeration that
|
|
24
|
-
covered only the map's keys let `# grok worker Dispatch` through and failed
|
|
25
|
-
every roster carrying grok or kimi before publication.
|
|
12
|
+
The label is `{worker_id} worker` for every id. A three-provider display
|
|
13
|
+
map made grok/kimi a second branch, and any consumer that restated the map
|
|
14
|
+
treated those ids as unnamed.
|
|
26
15
|
"""
|
|
27
|
-
return
|
|
16
|
+
return f"{worker_id} worker"
|
|
28
17
|
|
|
29
18
|
|
|
30
19
|
def analysis_prompt_body(
|
|
@@ -159,11 +159,8 @@ def _worker_label_pattern(worker_ids: Iterable[str]) -> re.Pattern[str] | None:
|
|
|
159
159
|
"""Match the role label the body renderer titled each compared worker with.
|
|
160
160
|
|
|
161
161
|
Built from `analysis_worker_label`, the same function that writes the label,
|
|
162
|
-
so
|
|
163
|
-
|
|
164
|
-
named only Claude / Codex / Antigravity, `# grok worker Dispatch` survived
|
|
165
|
-
normalization, and every run rostering grok or kimi failed the equality group
|
|
166
|
-
before publication with no prompt defect to fix.
|
|
162
|
+
so every worker id in the comparison group is covered. Restating a
|
|
163
|
+
three-provider list here is what forked the roster.
|
|
167
164
|
"""
|
|
168
165
|
labels = sorted(
|
|
169
166
|
{
|
|
@@ -390,4 +390,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
|
|
|
390
390
|
|
|
391
391
|
- Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
|
|
392
392
|
- Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
|
|
393
|
-
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. After
|
|
393
|
+
- After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. Awaiting approval → `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
|
|
@@ -59,6 +59,8 @@ Read the absolute path in the fixed `Relay contract` line. In that file, take th
|
|
|
59
59
|
- When `native-single` is available and the option count fits `nativeLimits` (unique labels, within min/max): call `interactions.native-single.function` once with one question and every option as `{label, description}` in original order. Do not print a numbered list in chat while the native tool is available. Claude Code's function is `AskUserQuestion`, Grok's is `ask_user_question`, Codex's is `request_user_input` — copy the relay field; do not substitute one name for another.
|
|
60
60
|
- Otherwise render a 1-based numbered Markdown list and wait for the next message. Do not drop options to force the native tool.
|
|
61
61
|
|
|
62
|
+
Pass only the choices this step already owns — the `list-view` rows, the report `options[]`, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
|
|
63
|
+
|
|
62
64
|
Never invent a picker function. Never ask the user to type a number when the native tool is available.
|
|
63
65
|
|
|
64
66
|
## Step 1: Select a task from the fixed list view
|
|
@@ -69,7 +71,7 @@ okstra user-response list-view --home <resolved-home> --project <projectId> --li
|
|
|
69
71
|
|
|
70
72
|
The view gives `Task key`, `Task type`, `Report`, open-item counts, and readability status. If the count is zero, answer `No task has open clarification items.` and stop. Do not continue with an unreadable entry.
|
|
71
73
|
|
|
72
|
-
Present up to three task choices through the host picker.
|
|
74
|
+
Present up to three task choices through the host picker. A host free-text row or unmatched next message is the report path or task key.
|
|
73
75
|
|
|
74
76
|
## Step 2: Read the fixed report view
|
|
75
77
|
|
|
@@ -79,7 +81,7 @@ okstra user-response show-view --report <reportPath> --project-root <projectRoot
|
|
|
79
81
|
|
|
80
82
|
The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
|
|
81
83
|
|
|
82
|
-
Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order
|
|
84
|
+
Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order.
|
|
83
85
|
|
|
84
86
|
Contract 3.0 options also expose `reach` and `scopeEffects`. Contract 3.0 approval-blocking rows expose `approvalContext`.
|
|
85
87
|
|
|
@@ -133,27 +133,34 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
133
133
|
display: flex;
|
|
134
134
|
flex-direction: column;
|
|
135
135
|
align-items: flex-end;
|
|
136
|
+
gap: .4rem;
|
|
136
137
|
}
|
|
137
|
-
.back-to-top {
|
|
138
|
+
.back-to-top-actions { display: flex; gap: .4rem; }
|
|
139
|
+
.back-to-top,
|
|
140
|
+
.back-to-top-toggle {
|
|
138
141
|
padding: .55rem .9rem;
|
|
139
142
|
border-radius: 8px;
|
|
140
|
-
border: 1px solid color-mix(in srgb, CanvasText
|
|
141
|
-
background:
|
|
143
|
+
border: 1px solid color-mix(in srgb, CanvasText 40%, transparent);
|
|
144
|
+
background: Canvas;
|
|
142
145
|
color: CanvasText;
|
|
143
146
|
text-decoration: none;
|
|
144
147
|
font: inherit;
|
|
145
148
|
font-size: .9rem;
|
|
146
|
-
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
box-shadow: 0 2px 10px color-mix(in srgb, CanvasText 28%, transparent);
|
|
147
152
|
}
|
|
148
|
-
.back-to-top:hover
|
|
149
|
-
.back-to-top:
|
|
153
|
+
.back-to-top:hover,
|
|
154
|
+
.back-to-top-toggle:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
|
|
155
|
+
.back-to-top:focus-visible,
|
|
156
|
+
.back-to-top-toggle:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
150
157
|
.back-to-top-index {
|
|
151
158
|
display: none;
|
|
152
159
|
box-sizing: border-box;
|
|
153
160
|
width: min(22rem, calc(100vw - 2.4rem));
|
|
154
161
|
max-height: min(70vh, 32rem);
|
|
155
162
|
overflow: auto;
|
|
156
|
-
margin: 0
|
|
163
|
+
margin: 0;
|
|
157
164
|
padding: .8rem 1rem;
|
|
158
165
|
border-radius: 12px;
|
|
159
166
|
border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
|
|
@@ -163,10 +170,13 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
|
|
|
163
170
|
.back-to-top-index ol { margin: 0; padding-left: 1.2rem; }
|
|
164
171
|
.back-to-top-index li { margin: .25em 0; }
|
|
165
172
|
.back-to-top-index a { color: inherit; }
|
|
173
|
+
.back-to-top-wrap.is-open .back-to-top-index { display: block; }
|
|
166
174
|
@media (hover: hover) {
|
|
167
|
-
.back-to-top-wrap:hover .back-to-top-index
|
|
168
|
-
.back-to-top-wrap:focus-within .back-to-top-index { display: block; }
|
|
175
|
+
.back-to-top-wrap:hover .back-to-top-index { display: block; }
|
|
169
176
|
}
|
|
177
|
+
.clarification-option { cursor: pointer; border-radius: 10px; padding: .6rem .7rem; }
|
|
178
|
+
.clarification-option.is-selected { background: color-mix(in srgb, Highlight 12%, Canvas); }
|
|
179
|
+
.clarification-item.is-closed .clarification-option { cursor: default; }
|
|
170
180
|
/* A seven-column table needs 42em of floor, more than a phone can give. */
|
|
171
181
|
@media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
|
|
172
182
|
@media print { .skip-link, .back-to-top-wrap, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
|
|
@@ -2,4 +2,25 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
document.documentElement.classList.add("js-enabled");
|
|
5
|
+
|
|
6
|
+
// 목차는 hover 만으로는 안 열린다. 클릭으로 연다.
|
|
7
|
+
var wrap = document.querySelector(".back-to-top-wrap");
|
|
8
|
+
var toggle = wrap && wrap.querySelector(".back-to-top-toggle");
|
|
9
|
+
if (!wrap || !toggle) return;
|
|
10
|
+
|
|
11
|
+
function setOpen(open) {
|
|
12
|
+
wrap.classList.toggle("is-open", open);
|
|
13
|
+
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toggle.addEventListener("click", function (event) {
|
|
17
|
+
event.stopPropagation();
|
|
18
|
+
setOpen(!wrap.classList.contains("is-open"));
|
|
19
|
+
});
|
|
20
|
+
document.addEventListener("click", function (event) {
|
|
21
|
+
if (!wrap.contains(event.target)) setOpen(false);
|
|
22
|
+
});
|
|
23
|
+
document.addEventListener("keydown", function (event) {
|
|
24
|
+
if (event.key === "Escape") setOpen(false);
|
|
25
|
+
});
|
|
5
26
|
})();
|
|
@@ -115,8 +115,11 @@
|
|
|
115
115
|
<pre id="user-response-output" aria-live="polite"></pre>
|
|
116
116
|
</footer>{% endif %}
|
|
117
117
|
<div class="back-to-top-wrap">
|
|
118
|
-
<nav class="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
|
|
119
|
-
<
|
|
118
|
+
<nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
|
|
119
|
+
<div class="back-to-top-actions">
|
|
120
|
+
<button type="button" class="back-to-top-toggle" aria-expanded="false" aria-controls="back-to-top-index">{{ t('base.contents') }}</button>
|
|
121
|
+
<a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
|
|
122
|
+
</div>
|
|
120
123
|
</div>
|
|
121
124
|
<script id="run-meta" type="application/json">{{ {
|
|
122
125
|
"task-key": runMeta.task_key,
|
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
"count-answered-questions": "{count} answered questions",
|
|
160
160
|
"your-answer-to-id": "Your answer to {id}",
|
|
161
161
|
"choose-one": "Choose one",
|
|
162
|
+
"other-answer": "Other (type your own)",
|
|
162
163
|
"recommended": "Recommended",
|
|
163
164
|
"scope-impact": "Scope",
|
|
164
165
|
"added-work": "Added work",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
{% if options %}
|
|
79
79
|
<ol class="clarification-options">
|
|
80
80
|
{% for option in options %}
|
|
81
|
-
<li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}">
|
|
81
|
+
<li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}" data-option-value="{{ option.answer }}">
|
|
82
82
|
<p class="clarification-option-answer">{{ option.answer }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
|
|
83
83
|
{% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
|
|
84
84
|
<p class="clarification-option-rationale">{{ option.rationale }}</p>
|
|
@@ -92,11 +92,16 @@
|
|
|
92
92
|
</ol>
|
|
93
93
|
{% endif %}
|
|
94
94
|
<label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
|
|
95
|
-
{% if
|
|
95
|
+
{% if options %}
|
|
96
|
+
{% set pick = namespace(has_other=false, current=row.userInput | default('')) %}
|
|
97
|
+
{% for option in options %}{% if option.answer == '__other__' %}{% set pick.has_other = true %}{% endif %}{% endfor %}
|
|
98
|
+
{% set is_custom = pick.current and pick.current not in (options | map(attribute='answer') | list) %}
|
|
96
99
|
<select id="response-{{ row.id }}" data-response-id="{{ row.id }}"{% if is_closed %} disabled{% endif %}>
|
|
97
100
|
<option value="">{{ t('macros.forms.choose-one') }}</option>
|
|
98
|
-
{% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if
|
|
101
|
+
{% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if pick.current == option.answer %} selected{% endif %}>{{ option.answer }}</option>{% endfor %}
|
|
102
|
+
{% if not pick.has_other %}<option value="__other__"{% if is_custom %} selected{% endif %}>{{ t('macros.forms.other-answer') }}</option>{% endif %}
|
|
99
103
|
</select>
|
|
104
|
+
{% if not pick.has_other %}<textarea data-other-for="{{ row.id }}" rows="2"{% if is_closed %} disabled{% endif %}{% if not is_custom %} hidden{% endif %}>{% if is_custom %}{{ pick.current }}{% endif %}</textarea>{% endif %}
|
|
100
105
|
{% else %}
|
|
101
106
|
<textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4"{% if is_closed %} disabled{% endif %}>{{ row.userInput | default('') }}</textarea>
|
|
102
107
|
{% endif %}
|