okstra 0.183.2 → 0.185.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +385 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +10 -8
  10. package/docs/cli.md +9 -5
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +28 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +2 -2
  18. package/docs/task-process/common-flow.md +2 -3
  19. package/docs/task-process/error-analysis.md +3 -4
  20. package/docs/task-process/final-verification.md +2 -3
  21. package/docs/task-process/implementation-planning.md +3 -4
  22. package/docs/task-process/implementation.md +2 -3
  23. package/docs/task-process/release-handoff.md +3 -4
  24. package/docs/task-process/requirements-discovery.md +3 -4
  25. package/package.json +1 -1
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/prompts/launch.template.md +8 -7
  28. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  29. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  30. package/runtime/prompts/lead/report-writer.md +4 -4
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  32. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  33. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  34. package/runtime/prompts/profiles/implementation-planning.md +11 -12
  35. package/runtime/prompts/wizard/prompts.ko.json +9 -10
  36. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/conformance.py +37 -1
  40. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  41. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  42. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  43. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  44. package/runtime/python/okstra_ctl/render.py +4 -0
  45. package/runtime/python/okstra_ctl/user_response.py +147 -37
  46. package/runtime/python/okstra_ctl/wizard.py +52 -73
  47. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  48. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  49. package/runtime/skills/okstra-chat/SKILL.md +104 -0
  50. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  51. package/runtime/skills/okstra-run/SKILL.md +4 -4
  52. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  53. package/runtime/validators/validate-run.py +254 -81
  54. package/runtime/validators/validate_session_conformance.py +24 -5
@@ -954,12 +954,15 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
954
954
  # `§x.y` is a full-reading-copy heading and is not a record coordinate.
955
955
  # `path.ext:line` is a source pointer the record does not define.
956
956
  _SECTION_REF_RE = re.compile(r"§[\d.]+|[A-Z]{1,4}-\d+|[\w./-]+\.\w+:\d+")
957
+ _PATH_LINE_RE = re.compile(r"[\w./-]+\.\w+:\d+")
957
958
  _ID_TOKEN_RE = re.compile(r"^[A-Z]{1,4}-\d+$")
959
+ _PLAN_ITEM_ID_RE = re.compile(r"^P-")
958
960
  _ROW_DEFINITION_KEYS = (
959
961
  "statement",
960
962
  "summary",
961
963
  "item",
962
964
  "title",
965
+ "subject",
963
966
  "check",
964
967
  "action",
965
968
  "evidence",
@@ -1992,6 +1995,138 @@ def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
1992
1995
  ]
1993
1996
 
1994
1997
 
1998
+ def _option_probe_texts(options: list[Any]) -> list[str]:
1999
+ texts: list[str] = []
2000
+ for option in options:
2001
+ if not isinstance(option, Mapping):
2002
+ continue
2003
+ texts.extend(
2004
+ str(option.get(key) or "")
2005
+ for key in ("answer", "rationale", "addedWork", "directionChange")
2006
+ )
2007
+ return texts
2008
+
2009
+
2010
+ def _row_probe_texts(row: Mapping[str, Any]) -> list[str]:
2011
+ return [
2012
+ str(row.get("statement") or ""),
2013
+ str(row.get("expected_form") or ""),
2014
+ *_option_probe_texts(list(row.get("options") or [])),
2015
+ ]
2016
+
2017
+
2018
+ def _path_line_refs(*texts: str) -> list[str]:
2019
+ found: list[str] = []
2020
+ seen: set[str] = set()
2021
+ for text in texts:
2022
+ for match in _PATH_LINE_RE.findall(text or ""):
2023
+ if match not in seen:
2024
+ seen.add(match)
2025
+ found.append(match)
2026
+ return found
2027
+
2028
+
2029
+ def _why_asked(row: Mapping[str, Any]) -> str:
2030
+ approval = row.get("approval_context") or {}
2031
+ if not isinstance(approval, Mapping):
2032
+ return "not stated in the report"
2033
+ unblock = str(approval.get("unblockCondition") or "").strip()
2034
+ if unblock:
2035
+ return unblock
2036
+ classification = str(approval.get("classification") or "").strip()
2037
+ return classification or "not stated in the report"
2038
+
2039
+
2040
+ def _linked_plan_items(
2041
+ record: dict[str, Any] | None, clarification_id: str
2042
+ ) -> list[dict[str, str]]:
2043
+ if record is None:
2044
+ return []
2045
+ linked: list[dict[str, str]] = []
2046
+ seen: set[str] = set()
2047
+
2048
+ def walk(node: object) -> None:
2049
+ if isinstance(node, dict):
2050
+ row_id = node.get("id")
2051
+ refs = node.get("clarificationRefs") or []
2052
+ if (
2053
+ isinstance(row_id, str)
2054
+ and _PLAN_ITEM_ID_RE.match(row_id)
2055
+ and isinstance(refs, list)
2056
+ and clarification_id in refs
2057
+ and row_id not in seen
2058
+ ):
2059
+ seen.add(row_id)
2060
+ linked.append({
2061
+ "id": row_id,
2062
+ "definition": _row_definition(node) or "not stated in the report",
2063
+ })
2064
+ for value in node.values():
2065
+ walk(value)
2066
+ elif isinstance(node, list):
2067
+ for item in node:
2068
+ walk(item)
2069
+
2070
+ walk(record)
2071
+ return linked
2072
+
2073
+
2074
+ def _format_ref_list(label: str, items: list[str]) -> list[str]:
2075
+ if not items:
2076
+ return [f"{label}: none"]
2077
+ return [f"{label}:", *(f"- {item}" for item in items)]
2078
+
2079
+
2080
+ def _format_open_row_view(
2081
+ row: Mapping[str, Any],
2082
+ record: dict[str, Any] | None,
2083
+ markdown_text: str,
2084
+ response: UserResponseEntry | None,
2085
+ ) -> list[str]:
2086
+ item = row["item"]
2087
+ probe = _row_probe_texts(row)
2088
+ refs = sorted(set(_SECTION_REF_RE.findall(" ".join(probe))))
2089
+ resolved = (
2090
+ resolve_refs_from_record(record, refs)
2091
+ if record is not None
2092
+ else resolve_refs(markdown_text, refs)
2093
+ )
2094
+ lines = [
2095
+ "",
2096
+ f"[{item.row_id}]",
2097
+ f"Kind: {item.kind}",
2098
+ f"Blocks: {item.blocks}",
2099
+ f"Report status: {item.status}",
2100
+ f"Question: {row['statement']}",
2101
+ f"Expected form: {row['expected_form']}",
2102
+ f"Current response: {response.value if response else 'none'}",
2103
+ f"Current disposition: {response.disposition if response else 'none'}",
2104
+ f"Why asked: {_why_asked(row)}",
2105
+ "Options:",
2106
+ ]
2107
+ approval = row.get("approval_context") or {}
2108
+ if isinstance(approval, Mapping) and approval:
2109
+ lines.extend([
2110
+ f"Approval classification: {approval.get('classification', '')}",
2111
+ f"Approval unblock condition: {approval.get('unblockCondition', '')}",
2112
+ f"Approval recommended disposition: {approval.get('recommendedDisposition', '')}",
2113
+ ])
2114
+ for index, option in enumerate(row["options"], start=1):
2115
+ lines.extend(_option_view(option, index))
2116
+ linked = _linked_plan_items(record, item.row_id)
2117
+ lines.extend(_format_ref_list(
2118
+ "Linked plan items",
2119
+ [f"{plan['id']}: {plan['definition']}" for plan in linked],
2120
+ ))
2121
+ lines.extend(_format_ref_list("Cited artifacts", _path_line_refs(*probe)))
2122
+ lines.append("Context:")
2123
+ lines.extend(
2124
+ f"- {ref['ref']}: {ref['definition'] or 'not stated in the report'}"
2125
+ for ref in resolved
2126
+ )
2127
+ return lines
2128
+
2129
+
1995
2130
  def format_show_view(report_path: Path, project_root: Path) -> str:
1996
2131
  context = _validate_owned_report_context(
1997
2132
  report_path, expected_project_root=project_root
@@ -1999,6 +2134,10 @@ def format_show_view(report_path: Path, project_root: Path) -> str:
1999
2134
  rows, record = _all_report_rows(context.report_path)
2000
2135
  state = _existing_sidecar_state(context.sidecar_path)
2001
2136
  current = {entry.response_id: entry for entry in state.entries}
2137
+ markdown_text = (
2138
+ "" if record is not None
2139
+ else context.markdown_path.read_text(encoding="utf-8")
2140
+ )
2002
2141
  lines = [
2003
2142
  "USER RESPONSE REPORT",
2004
2143
  f"Report: {context.report_path}",
@@ -2015,51 +2154,22 @@ def format_show_view(report_path: Path, project_root: Path) -> str:
2015
2154
  if candidates:
2016
2155
  lines.append("Plan option candidates:")
2017
2156
  for index, candidate in enumerate(candidates, start=1):
2157
+ current_pick = (
2158
+ state.plan_decision is not None
2159
+ and state.plan_decision.implementation_option == candidate
2160
+ )
2018
2161
  lines.extend([
2019
2162
  f"Plan option {index}: {candidate}",
2020
2163
  f" Recommended: {'yes' if candidate == recommended_name else 'no'}",
2021
- " Current decision: "
2022
- f"{'yes' if state.plan_decision and state.plan_decision.implementation_option == candidate else 'no'}",
2164
+ f" Current decision: {'yes' if current_pick else 'no'}",
2023
2165
  ])
2024
2166
  for row in rows:
2025
2167
  item = row["item"]
2026
2168
  if item.status not in {"open", "answered"} or item.row_id in current:
2027
2169
  continue
2028
- refs = sorted(set(_SECTION_REF_RE.findall(
2029
- row["statement"] + " " + row["expected_form"]
2030
- )))
2031
- resolved = (
2032
- resolve_refs_from_record(record, refs)
2033
- if record is not None
2034
- else resolve_refs(context.markdown_path.read_text(encoding="utf-8"), refs)
2035
- )
2036
- response = current.get(item.row_id)
2037
- lines.extend([
2038
- "",
2039
- f"[{item.row_id}]",
2040
- f"Kind: {item.kind}",
2041
- f"Blocks: {item.blocks}",
2042
- f"Report status: {item.status}",
2043
- f"Question: {row['statement']}",
2044
- f"Expected form: {row['expected_form']}",
2045
- f"Current response: {response.value if response else 'none'}",
2046
- f"Current disposition: {response.disposition if response else 'none'}",
2047
- "Options:",
2048
- ])
2049
- approval = row.get("approval_context") or {}
2050
- if approval:
2051
- lines.extend([
2052
- f"Approval classification: {approval.get('classification', '')}",
2053
- f"Approval unblock condition: {approval.get('unblockCondition', '')}",
2054
- f"Approval recommended disposition: {approval.get('recommendedDisposition', '')}",
2055
- ])
2056
- for index, option in enumerate(row["options"], start=1):
2057
- lines.extend(_option_view(option, index))
2058
- lines.append("Context:")
2059
- lines.extend(
2060
- f"- {ref['ref']}: {ref['definition'] or 'not stated in the report'}"
2061
- for ref in resolved
2062
- )
2170
+ lines.extend(_format_open_row_view(
2171
+ row, record, markdown_text, current.get(item.row_id),
2172
+ ))
2063
2173
  return "\n".join(lines) + "\n"
2064
2174
 
2065
2175
 
@@ -1231,17 +1231,6 @@ def _wizard_state_from_json(payload: dict[str, Any]) -> WizardState:
1231
1231
  if version != 2:
1232
1232
  state.execution_identity_version = 2
1233
1233
  _convert_v1_provider_selections(state, payload)
1234
- # v1 은 리더 확인 칸이 없었다. 변환 재개 시 다시 묻지 않는다.
1235
- if (
1236
- state.host_entry_mode == "current-session"
1237
- and _LEADER_SESSION_STEP not in state.answered
1238
- ):
1239
- state.answered.append(_LEADER_SESSION_STEP)
1240
- if (
1241
- state.host_entry_mode == "current-session"
1242
- and _LEADER_SESSION_STEP not in state.role_selection_order
1243
- ):
1244
- state.role_selection_order.insert(0, _LEADER_SESSION_STEP)
1245
1234
  return state
1246
1235
 
1247
1236
 
@@ -1281,13 +1270,8 @@ def _role_model_prompt_id(role: str, ordinal: int) -> str:
1281
1270
  return f"role-model:{role}:{ordinal}"
1282
1271
 
1283
1272
 
1284
- _LEADER_SESSION_STEP = "leader-session"
1285
-
1286
-
1287
1273
  def _is_role_selection_step(step_id: str) -> bool:
1288
- return step_id == _LEADER_SESSION_STEP or step_id.startswith(
1289
- ("role-count:", "role-model:", "role-add:")
1290
- )
1274
+ return step_id.startswith(("role-count:", "role-model:", "role-add:"))
1291
1275
 
1292
1276
 
1293
1277
  def _selected_role_count(
@@ -1320,36 +1304,6 @@ def _selectable_static_requirements(
1320
1304
  return tuple(requirements)
1321
1305
 
1322
1306
 
1323
- def _leader_session_prompt(state: WizardState) -> Prompt:
1324
- """current-session 리더 칸: 모델/effort 표시만 하고 role_models 에 쓰지 않는다."""
1325
- attestation = _host_session_context(state).current_model
1326
- model_ref = (
1327
- attestation.normalized_model_ref
1328
- or attestation.observed_model
1329
- or "current-session"
1330
- )
1331
- steps = _load_wizard_root(state.workspace_root)["steps"]
1332
- raw = steps.get("leader_session") or {}
1333
- effort_suffix = ""
1334
- if attestation.effort:
1335
- effort_template = raw.get("effort_suffix", " · effort {effort}")
1336
- effort_suffix = effort_template.format(effort=attestation.effort)
1337
- prompt = _p(
1338
- state.workspace_root,
1339
- "leader_session",
1340
- model_ref=model_ref,
1341
- effort_suffix=effort_suffix,
1342
- )
1343
- continue_label = prompt["options"].get("continue", "계속")
1344
- return Prompt(
1345
- step=_LEADER_SESSION_STEP,
1346
- kind="pick",
1347
- label=prompt["label"],
1348
- options=[_opt("continue", continue_label)],
1349
- echo_template=prompt["echo_template"],
1350
- )
1351
-
1352
-
1353
1307
  def _count_prompt(
1354
1308
  state: WizardState,
1355
1309
  requirement: RoleRequirement,
@@ -1777,13 +1731,6 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
1777
1731
  if not _role_selection_enabled(state) or not _identity_ready(state):
1778
1732
  return None
1779
1733
  state.use_defaults = False
1780
- # current-session: 리더는 읽기 전용 확인 칸만 먼저 보여 준다.
1781
- if (
1782
- state.host_entry_mode == "current-session"
1783
- and _LEADER_SESSION_STEP not in state.answered
1784
- and _LEADER_SESSION_STEP not in state.role_selection_order
1785
- ):
1786
- return _leader_session_prompt(state)
1787
1734
  profile = _load_role_profile_for_state(state)
1788
1735
  for requirement in profile.roles:
1789
1736
  # 필수 수량: min < max 이고 min > 0 일 때만.
@@ -1884,11 +1831,6 @@ def _validate_submitted_role_model(
1884
1831
 
1885
1832
 
1886
1833
  def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
1887
- if prompt.step == _LEADER_SESSION_STEP:
1888
- if value != "continue":
1889
- raise WizardError("leader session step accepts only 'continue'")
1890
- # role_models["leader"] 에 쓰지 않는다 — 현재 세션 모델을 그대로 쓴다.
1891
- return "leader-session: continue"
1892
1834
  if prompt.step.startswith("role-count:"):
1893
1835
  role = prompt.step.split(":", 1)[1]
1894
1836
  profile = _load_role_profile_for_state(state)
@@ -2041,9 +1983,11 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
2041
1983
  raise WizardError(f"unknown wizard step_id: {step_id!r}")
2042
1984
  label_template = raw.get("label", "")
2043
1985
  fv_label_template = raw.get("label_final_verification", "")
1986
+ unlinked_template = raw.get("label_unlinked", "")
2044
1987
  try:
2045
1988
  label = label_template.format(**vars)
2046
1989
  label_final_verification = fv_label_template.format(**vars)
1990
+ label_unlinked = unlinked_template.format(**vars)
2047
1991
  except KeyError as exc:
2048
1992
  missing = exc.args[0] if exc.args else "<unknown>"
2049
1993
  raise WizardError(
@@ -2052,6 +1996,7 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
2052
1996
  return {
2053
1997
  "label": label,
2054
1998
  "label_final_verification": label_final_verification,
1999
+ "label_unlinked": label_unlinked,
2055
2000
  "echo_template": raw.get("echo_template", ""),
2056
2001
  "options": raw.get("options", {}),
2057
2002
  "options_final_verification": raw.get("options_final_verification", {}),
@@ -2788,6 +2733,8 @@ def _build_task_type(state: WizardState) -> Prompt:
2788
2733
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
2789
2734
  rerun_suffix = t["options"].get("_RERUN_SUFFIX", "")
2790
2735
  next_suffix = t["options"].get("_NEXT_SUFFIX", "")
2736
+ approve_suffix = t["options"].get("_APPROVE_SUFFIX", recommended_suffix)
2737
+ blocked_rerun_suffix = t["options"].get("_BLOCKED_RERUN_SUFFIX", rerun_suffix)
2791
2738
  description_by_type = dict(TASK_TYPES)
2792
2739
  options: list[Option] = []
2793
2740
 
@@ -2813,10 +2760,21 @@ def _build_task_type(state: WizardState) -> Prompt:
2813
2760
  # `ready` 가 아니면 추천은 비고, 아래 `currentPhase` 재실행 옵션이 남는다.
2814
2761
  # 실패한 run 의 포인터가 `{"phase": "", "status": "blocked"}` 라는 점에서
2815
2762
  # 그것이 맞는 제안이다 — 그 옵션은 포인터가 아니라 `currentPhase` 에서 온다.
2763
+ # 계획 승인 대기는 `awaitingApproval` 로 표시한다. 구현이 추천이지만 먼저
2764
+ # 승인을 받아야 하므로 접미사로 구분한다. 열린 C-NNN 때문에 blocked 면
2765
+ # 재실행은 답이 기록된 뒤에만 고르라고 접미사로 말한다.
2816
2766
  recommended = (revision_requested or state.task_type
2817
2767
  or next_phase.autofill_task_type({"workflow": workflow}))
2818
2768
  if not recommended and not workflow:
2819
2769
  recommended = TASK_TYPE_VALUES[0]
2770
+ pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
2771
+ if workflow.get("awaitingApproval") is True and recommended == "implementation":
2772
+ recommended_suffix = approve_suffix
2773
+ if (
2774
+ pointer["status"] == next_phase.STATUS_BLOCKED
2775
+ and (workflow.get("currentPhase") or "") == "implementation-planning"
2776
+ ):
2777
+ rerun_suffix = blocked_rerun_suffix
2820
2778
  add(recommended, recommended_suffix)
2821
2779
  add(workflow.get("currentPhase") or "", rerun_suffix)
2822
2780
  add(_phase_after(recommended), next_suffix)
@@ -4191,14 +4149,15 @@ def _reverify_scope_preview(state: WizardState) -> Optional[dict]:
4191
4149
 
4192
4150
 
4193
4151
  def _reverify_scope_pick_required(state: WizardState) -> bool:
4194
- """좁힐 여지가 있는 재실행에서만 범위를 묻는다.
4152
+ """좁힐 여지가 있거나, unlinked id 의 stage 번호를 받아야 할 때 묻는다.
4195
4153
 
4196
- 링크가 끊겨 full 이미 확정된 재실행에서 물으면 어떤 답도 판정을 바꾸지
4197
- 못한다 고를 없는 선택지를 내미는 화면이 하나 느는 것뿐이다. 그 경우
4198
- 확인 블록의 `reverify-scope: full 예상` 줄이 이유까지 같이 알려준다.
4154
+ unlinked full 확정이 아니다. SHA 바뀌었거나 입력을 읽을 없어
4155
+ `wouldForceFull` 경우만 질문이 의미 없다.
4199
4156
  """
4200
4157
  preview = _reverify_scope_preview(state)
4201
- return preview is not None and not preview["wouldForceFull"]
4158
+ return preview is not None and (
4159
+ not preview["wouldForceFull"] or bool(preview["unlinkedIds"])
4160
+ )
4202
4161
 
4203
4162
 
4204
4163
  def _reverify_scope_step_pending(state: WizardState) -> bool:
@@ -4234,13 +4193,20 @@ def _prior_stage_numbers(state: WizardState) -> set[int]:
4234
4193
  def _build_reverify_scope_pick(state: WizardState) -> Prompt:
4235
4194
  t = _p(state.workspace_root, "reverify_scope_pick")
4236
4195
  opts = t["options"]
4196
+ preview = _reverify_scope_preview(state) or {}
4197
+ unlinked = bool(preview.get("unlinkedIds"))
4198
+ options = []
4199
+ if not unlinked:
4200
+ options.append(_opt("auto", opts["auto"]))
4201
+ options.append(_opt("full", opts["full"]))
4202
+ options.append(_opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]))
4203
+ else:
4204
+ options.append(_opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]))
4205
+ options.append(_opt("full", opts["full"]))
4206
+ label = t["label_unlinked"] if unlinked else t["label"]
4237
4207
  return Prompt(
4238
- step=S_REVERIFY_SCOPE_PICK, kind="pick", label=t["label"],
4239
- options=[
4240
- _opt("auto", opts["auto"]),
4241
- _opt("full", opts["full"]),
4242
- _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
4243
- ],
4208
+ step=S_REVERIFY_SCOPE_PICK, kind="pick", label=label,
4209
+ options=options,
4244
4210
  echo_template=t["echo_template"])
4245
4211
 
4246
4212
 
@@ -4255,6 +4221,13 @@ def _submit_reverify_scope_pick(state: WizardState, value: str) -> Optional[str]
4255
4221
  raise WizardError(
4256
4222
  f"expected 'auto' / 'full' / {PICK_TYPE_CUSTOM!r}, got: {value!r}"
4257
4223
  )
4224
+ if picked == "auto":
4225
+ preview = _reverify_scope_preview(state) or {}
4226
+ unlinked = preview.get("unlinkedIds") or []
4227
+ if unlinked:
4228
+ raise WizardError(
4229
+ t["errors"]["unlinked_auto"].format(ids=", ".join(unlinked))
4230
+ )
4258
4231
  state.reverify_scope = picked
4259
4232
  state.reverify_scope_pending_text = False
4260
4233
  return t["echo_suffixes"][picked]
@@ -4271,6 +4244,12 @@ def _submit_reverify_scope_stages(state: WizardState, value: str) -> Optional[st
4271
4244
  t = _p(state.workspace_root, "reverify_scope_stages")
4272
4245
  tokens = [token.strip() for token in value.split(",") if token.strip()]
4273
4246
  if not tokens:
4247
+ preview = _reverify_scope_preview(state) or {}
4248
+ unlinked = preview.get("unlinkedIds") or []
4249
+ if unlinked:
4250
+ raise WizardError(
4251
+ t["errors"]["unlinked_empty"].format(ids=", ".join(unlinked))
4252
+ )
4274
4253
  state.reverify_scope = "auto"
4275
4254
  state.reverify_scope_pending_text = False
4276
4255
  return t["echo_suffixes"]["auto"]
@@ -6341,13 +6320,13 @@ def _reverify_scope_line(state: WizardState) -> Optional[str]:
6341
6320
  if state.reverify_scope and state.reverify_scope != "auto":
6342
6321
  return _msg(state.workspace_root, "confirmation",
6343
6322
  "reverify_scope_user_stages", stages=state.reverify_scope)
6344
- if not preview["wouldForceFull"]:
6345
- return _msg(state.workspace_root, "confirmation",
6346
- "reverify_scope_incremental")
6347
6323
  if preview["unlinkedIds"]:
6348
6324
  return _msg(state.workspace_root, "confirmation",
6349
6325
  "reverify_scope_unlinked",
6350
6326
  ids=", ".join(preview["unlinkedIds"]))
6327
+ if not preview["wouldForceFull"]:
6328
+ return _msg(state.workspace_root, "confirmation",
6329
+ "reverify_scope_incremental")
6351
6330
  return _msg(state.workspace_root, "confirmation", "reverify_scope_full",
6352
6331
  reason=preview["reason"])
6353
6332
 
@@ -9145,6 +9145,12 @@
9145
9145
  "properties": {
9146
9146
  "setAside": {"type": "array", "items": {"type": "object", "required": ["id", "reason"], "additionalProperties": false, "properties": {"id": {"type": "string", "minLength": 1}, "reason": {"type": "string", "enum": ["record", "observed", "deferred"]}}}},
9147
9147
  "stageLedger": {"type": "object", "additionalProperties": {"type": "string", "enum": ["done", "active", "ready", "blocked"]}},
9148
+ "dispatchQueue": {
9149
+ "type": "array",
9150
+ "description": "Plan-item ids sent to verifiers this round. In-scope plus plan-wide; deferred and observed stages are omitted.",
9151
+ "items": {"type": "string", "minLength": 1},
9152
+ "uniqueItems": true
9153
+ },
9148
9154
  "uniformVerifiers": {
9149
9155
  "type": "array",
9150
9156
  "description": "Verifiers whose every vote this round was one verdict. Advisory: a unanimous round is legitimate, but the gate reads as a three-way cross-check unless this sits beside it.",
@@ -9172,6 +9178,10 @@
9172
9178
  }
9173
9179
  }
9174
9180
  },
9181
+ "gating": {
9182
+ "type": "boolean",
9183
+ "description": "If false, plan-body verification is advisory: majority-disagree does not block approval, the self-fix loop does not run, and only one automatic round is allowed. Legal only when designPreparation.mode is no-design-inputs and the Stage Map has exactly one row. Prepare emits true; plan-items prepare/seed flip it after those facts exist."
9184
+ },
9175
9185
  "roundCount": {
9176
9186
  "type": "integer",
9177
9187
  "minimum": 0
@@ -9247,6 +9257,8 @@
9247
9257
  "properties": {
9248
9258
  "block": {"type": "string", "enum": ["execution", "record"]},
9249
9259
  "stageScope": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "integer", "minimum": 1}},
9260
+ "contentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of subject+payload+block+stageScope for the current extract."},
9261
+ "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "contentHash at the last apply-verdicts. Matching hashes keep a pre-self-fix verdict."},
9250
9262
  "id": {
9251
9263
  "type": "string",
9252
9264
  "minLength": 1
@@ -9140,6 +9140,12 @@
9140
9140
  "properties": {
9141
9141
  "setAside": {"type": "array", "items": {"type": "object", "required": ["id", "reason"], "additionalProperties": false, "properties": {"id": {"type": "string", "minLength": 1}, "reason": {"type": "string", "enum": ["record", "observed", "deferred"]}}}},
9142
9142
  "stageLedger": {"type": "object", "additionalProperties": {"type": "string", "enum": ["done", "active", "ready", "blocked"]}},
9143
+ "dispatchQueue": {
9144
+ "type": "array",
9145
+ "description": "Plan-item ids sent to verifiers this round. In-scope plus plan-wide; deferred and observed stages are omitted.",
9146
+ "items": {"type": "string", "minLength": 1},
9147
+ "uniqueItems": true
9148
+ },
9143
9149
  "uniformVerifiers": {
9144
9150
  "type": "array",
9145
9151
  "description": "Verifiers whose every vote this round was one verdict. Advisory: a unanimous round is legitimate, but the gate reads as a three-way cross-check unless this sits beside it.",
@@ -9167,6 +9173,10 @@
9167
9173
  }
9168
9174
  }
9169
9175
  },
9176
+ "gating": {
9177
+ "type": "boolean",
9178
+ "description": "If false, plan-body verification is advisory: majority-disagree does not block approval, the self-fix loop does not run, and only one automatic round is allowed. Legal only when designPreparation.mode is no-design-inputs and the Stage Map has exactly one row. Prepare emits true; plan-items prepare/seed flip it after those facts exist."
9179
+ },
9170
9180
  "roundCount": {
9171
9181
  "type": "integer",
9172
9182
  "minimum": 0
@@ -9242,6 +9252,8 @@
9242
9252
  "properties": {
9243
9253
  "block": {"type": "string", "enum": ["execution", "record"]},
9244
9254
  "stageScope": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "integer", "minimum": 1}},
9255
+ "contentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of subject+payload+block+stageScope for the current extract."},
9256
+ "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "contentHash at the last apply-verdicts. Matching hashes keep a pre-self-fix verdict."},
9245
9257
  "id": {
9246
9258
  "type": "string",
9247
9259
  "minLength": 1
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: okstra-chat
3
+ description: Use when the user wants to create or join a global okstra chat room, send a message to everyone or to one participant, read unread arrivals, or reopen the inbox. Trigger words include "okstra chat", "okstra-chat", "chat room", "join the room", "send a chat message".
4
+ ---
5
+
6
+ # okstra-chat
7
+
8
+ Cross-session rooms in the global okstra home. Not a project task artifact.
9
+ Do not write JSON. Call `okstra chat` and read its fixed text.
10
+
11
+ Rooms are independent of tasks and runs. A participant is this host session.
12
+ The display name is typed at join. Do not invent a default name.
13
+
14
+ ## When to use
15
+
16
+ - The user wants a room that a Claude lead and a Grok lead can both join.
17
+ - The user wants to send a message, see unread arrivals, or reopen the inbox.
18
+
19
+ ## Step 0: CLI
20
+
21
+ Run as a separate Bash tool call with literal leading token:
22
+
23
+ ```bash
24
+ okstra chat --help
25
+ ```
26
+
27
+ If `okstra` is not on PATH, tell the user:
28
+
29
+ `okstra not installed — run npx okstra@latest install once, then retry this skill.`
30
+
31
+ Do not use `npx` from this skill.
32
+
33
+ ## Step 1: Create or join
34
+
35
+ Ask one question: create a room, or join an existing room.
36
+
37
+ If the host has a native picker, use it. Otherwise print a numbered list.
38
+
39
+ ### Create
40
+
41
+ 1. Ask for the room name as free input.
42
+ 2. Run `okstra chat create --room <room>`.
43
+ 3. Ask for the display name as free input. Do not suggest a generated name.
44
+ 4. Run `okstra chat join --room <room> --name <display>`.
45
+
46
+ ### Join
47
+
48
+ 1. Run `okstra chat rooms`.
49
+ 2. If the output is `no rooms`, say so and offer create.
50
+ 3. Otherwise pick a room from that list (picker, or numbered list if the picker limit is exceeded).
51
+ 4. Ask for the display name as free input.
52
+ 5. Run `okstra chat join --room <room> --name <display>`.
53
+
54
+ `--name` is required. An empty name, `all`, or a name already in the room fails.
55
+
56
+ ## Step 2: Unread
57
+
58
+ ```bash
59
+ okstra chat unread --room <room> --as <display>
60
+ ```
61
+
62
+ Show the rows. Each row is `id:time:recipient:body`.
63
+
64
+ ## Step 3: Next action
65
+
66
+ Ask: send, inbox, log, ack, or done.
67
+
68
+ ### Send
69
+
70
+ 1. Run `okstra chat members --room <room>`.
71
+ 2. Pick the recipient from `all` plus those names. Recipient is required.
72
+ 3. Ask for the body as free input.
73
+ 4. Run `okstra chat send --room <room> --as <display> --to <all|name> --body <text>`.
74
+
75
+ ### Inbox
76
+
77
+ ```bash
78
+ okstra chat inbox --room <room> --as <display>
79
+ ```
80
+
81
+ This is every arrival to `@you` or `all`, including messages already acked.
82
+
83
+ ### Log
84
+
85
+ ```bash
86
+ okstra chat log --room <room> --as <display>
87
+ ```
88
+
89
+ The whole room, including messages not addressed to you.
90
+
91
+ ### Ack
92
+
93
+ After unread, mark the last id the user has read:
94
+
95
+ ```bash
96
+ okstra chat ack --room <room> --as <display> --through <id>
97
+ ```
98
+
99
+ ## Rules
100
+
101
+ - Call only `okstra chat`. Do not open files under the chat store.
102
+ - Do not treat chat rows as evidence for a finding, verdict, or assignment.
103
+ - Workers may run the same commands with `--name` and `--as`. Joining is optional.
104
+ - Do not generate a display name from the provider, model, or execution label.
@@ -95,12 +95,13 @@ It has already promoted legacy values.
95
95
  The status response always includes one of:
96
96
 
97
97
  1. **Resume current run** — if `latestResumeCommandPath` exists, display that path.
98
- 2. **Restart current phase** — task can be re-run with the same `task-key` and current `taskType`.
99
- Branches 3–5 are decided by `workflow.nextRecommendedPhase.status` alone one status, one branch, no other field consulted:
98
+ 2. **Ask the user to approve** — if `workflow.awaitingApproval` is true. Tell the user to approve the plan (`okstra-run` with `--task-type implementation`, which asks `approve_plan_confirm`, or `--approve`). Quote `nextRecommendedPhase.rationale`. A `ready` pointer to `implementation` here means implementation is next after approval, not that it may launch as if already approved. Do not re-run `implementation-planning`.
99
+ 3. **Restart current phase** — only when `awaitingApproval` is false and the pointer is not `ready`. The task can be re-run with the same `task-key` and current `taskType`.
100
+ Branches 4–6 are decided by `workflow.nextRecommendedPhase.status` when `awaitingApproval` is false — one status, one branch:
100
101
 
101
- 3. **Start next phase** — `status` is `ready`. Propose `nextRecommendedPhase.phase` as the next run's `--task-type` and quote its `rationale` as the reason. This is the only status under which a named phase may be launched, so it is the only branch that proposes a run. A `ready` pointer to `release-handoff` already implies an `accepted` final-verification verdict (the report validator refuses that routing target otherwise), so do not re-gate it here.
102
- 4. **Need more information** — `status` is `pending` (the last run did not settle where this task goes next) or `blocked` (it did settle, and the answer is that something outside the run has to change first). Neither proposes a run, and a leftover `phase` name does not change that — `prepare` keeps the name when it lowers a pointer to `pending`, so read `status`, not the emptiness of `phase`. Show the `rationale`, and for `blocked` state what it names as the obstacle. The way forward is a fresh brief, a clarification response, or a re-run of the current phase not a next-phase launch.
103
- 5. **Task complete (terminal)** — `status` is `terminal`: the task lifecycle ends here. This is **not** a "next phase" — do not propose a new okstra run. Surface the latest report and ask the user whether any follow-up task should be opened separately.
102
+ 4. **Start next phase** — `status` is `ready` and `awaitingApproval` is false. Propose `nextRecommendedPhase.phase` as the next run's `--task-type` and quote its `rationale` as the reason. This is the only status under which a named phase may be launched without a prior approval ask, so it is the only branch that proposes a run. A `ready` pointer to `release-handoff` already implies an `accepted` final-verification verdict (the report validator refuses that routing target otherwise), so do not re-gate it here.
103
+ 5. **Need more information** — `status` is `pending` (the last run did not settle where this task goes next) or `blocked` (it did settle, and the answer is that something outside the run has to change first). Neither proposes a run, and a leftover `phase` name does not change that — `prepare` keeps the name when it lowers a pointer to `pending`, so read `status`, not the emptiness of `phase`. Show the `rationale`, and for `blocked` state what it names as the obstacle. After `implementation-planning`, the first action is `okstra-user-response` on the named `C-NNN` ids; do not re-run planning until those answers exist, and do not start implementation.
104
+ 6. **Task complete (terminal)** — `status` is `terminal`: the task lifecycle ends here. This is **not** a "next phase" — do not propose a new okstra run. Surface the latest report and ask the user whether any follow-up task should be opened separately.
104
105
 
105
106
  ### status.4 — Update workStatus (write)
106
107