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.
Files changed (34) hide show
  1. package/docs/architecture.md +3 -3
  2. package/docs/for-ai/skills/okstra-run.md +1 -1
  3. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  4. package/docs/project-structure-overview.md +2 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/okstra-lead-contract.md +18 -5
  8. package/runtime/prompts/lead/plan-body-verification.md +5 -5
  9. package/runtime/prompts/lead/report-writer.md +1 -1
  10. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +4 -5
  12. package/runtime/python/okstra_ctl/approval_decisions.py +1 -1
  13. package/runtime/python/okstra_ctl/clarification_items.py +141 -28
  14. package/runtime/python/okstra_ctl/design_surfaces.py +103 -18
  15. package/runtime/python/okstra_ctl/implementation_direction.py +8 -9
  16. package/runtime/python/okstra_ctl/next_phase.py +24 -22
  17. package/runtime/python/okstra_ctl/render_final_report.py +4 -8
  18. package/runtime/python/okstra_ctl/report_assembly.py +5 -1
  19. package/runtime/python/okstra_ctl/run.py +34 -3
  20. package/runtime/python/okstra_ctl/user_response.py +2 -1
  21. package/runtime/python/okstra_ctl/worker_prompt_body.py +4 -15
  22. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -5
  23. package/runtime/skills/okstra-run/SKILL.md +1 -1
  24. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  25. package/runtime/templates/reports/html/assets/base.css +19 -9
  26. package/runtime/templates/reports/html/assets/base.js +21 -0
  27. package/runtime/templates/reports/html/base.template.html +5 -2
  28. package/runtime/templates/reports/html/i18n/en.json +1 -0
  29. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  30. package/runtime/templates/reports/html/macros/forms.html +8 -3
  31. package/runtime/templates/reports/report.js +33 -0
  32. package/runtime/validators/validate-run.py +138 -47
  33. package/runtime/validators/validate_analysis_report.py +7 -6
  34. 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
- normalised = _LINE_RANGE_SUFFIX.sub("", _normalise(path))
204
- path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
205
- matched = []
206
- for stage_number, stage in stages.items():
207
- files = [
208
- _normalise(step.get("files"))
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 kind, evidence in _matching_rule_evidence(
247
- step=None,
248
- field="files",
249
- value=path,
250
- ):
251
- grouped.setdefault((stage_number, kind), []).append(evidence)
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
- for row in data.get("clarificationItems") or ():
313
- if (
314
- isinstance(row, Mapping)
315
- and row.get("blocks") == "next-phase"
316
- and row.get("status") not in {"resolved", "obsolete"}
317
- ):
318
- raise DirectionSelectionError(
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 APPROVAL_BLOCKS, UNRESOLVED_STATUSES
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
- rows = report_data.get("clarificationItems")
223
- if not isinstance(rows, list):
224
- return []
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
- 들어온다. 차단은 `blocked-by-disagreement` / `aborted-non-result` 와
249
- `Status` 가 open/answered 인 `Blocks=approval` 행뿐이다.
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 in _BLOCKING_PLAN_GATES:
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
- row.get("id")
560
- for row in data.get("clarificationItems", [])
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) and row.get("status") in {"open", "answered"}
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 data_gate in BLOCKING_PLAN_BODY_GATES:
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 data_gate in BLOCKING_PLAN_BODY_GATES:
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 행이 아직 open/answered 면 승인을 무효화한다.
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 {"open", "answered"}
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 map above only supplies display capitalization for the three providers
20
- that predate it; every other worker id (`grok`, `kimi`, a user-installed
21
- adapter) takes the id itself. Both branches are identity deltas the equality
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 _ANALYSIS_WORKER_LABELS.get(worker_id, f"{worker_id} worker")
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 a provider outside its display map (`grok`, `kimi`) is covered as it
163
- comes. Restating the map here is what forked the roster: the enumeration
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 an `implementation-planning` run, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. If awaiting approval, the next sentence to the user is to approve the plan (`okstra-run` → `implementation`, or `--approve`). Do not start another planning run. If the pointer is `blocked`, name the rationale and send the user to `okstra-user-response`; do not re-run planning until those answers exist.
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. The final picker option is `Enter directly`, where the user may provide a report path or task key.
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 and finish with `Enter directly`.
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 28%, transparent);
141
- background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
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
- box-shadow: 0 2px 8px color-mix(in srgb, CanvasText 18%, transparent);
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 { background: color-mix(in srgb, CanvasText 12%, Canvas); }
149
- .back-to-top:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
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 0 .4rem;
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
- <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
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",
@@ -159,6 +159,7 @@
159
159
  "count-answered-questions": "답한 질문 {count}건",
160
160
  "your-answer-to-id": "{id}에 대한 답변",
161
161
  "choose-one": "하나를 선택하세요",
162
+ "other-answer": "기타 (직접 입력)",
162
163
  "recommended": "권장",
163
164
  "scope-impact": "범위",
164
165
  "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 row.kind == 'decision' and approval_context and options %}
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 row.userInput | default('') == option.answer %} selected{% endif %}>{{ option.answer }}</option>{% endfor %}
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 %}