okstra 0.159.0 → 0.161.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 (80) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +2 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/okstra-claude-exec.sh +4 -1
  14. package/runtime/prompts/host-orchestration/README.md +18 -0
  15. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  16. package/runtime/prompts/launch.template.md +10 -1
  17. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  18. package/runtime/prompts/lead/adapters/cmux.md +67 -0
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/team-contract.md +2 -1
  23. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  24. package/runtime/prompts/profiles/_common-contract.md +3 -1
  25. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  26. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  27. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  28. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  29. package/runtime/python/okstra_ctl/cmux.py +531 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +76 -7
  33. package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
  34. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  35. package/runtime/python/okstra_ctl/error_report.py +56 -0
  36. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  37. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  38. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  39. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  40. package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
  41. package/runtime/python/okstra_ctl/paths.py +38 -0
  42. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  43. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  44. package/runtime/python/okstra_ctl/recap.py +63 -0
  45. package/runtime/python/okstra_ctl/render.py +7 -2
  46. package/runtime/python/okstra_ctl/render_final_report.py +7 -22
  47. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  48. package/runtime/python/okstra_ctl/report_views.py +7 -3
  49. package/runtime/python/okstra_ctl/run.py +54 -3
  50. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  51. package/runtime/python/okstra_ctl/team.py +50 -11
  52. package/runtime/python/okstra_ctl/user_response.py +25 -10
  53. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  54. package/runtime/python/okstra_ctl/wizard.py +64 -10
  55. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  56. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  57. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  58. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  59. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  60. package/runtime/schemas/final-report-v2.0.schema.json +51 -1
  61. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  62. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  63. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  64. package/runtime/skills/okstra-run/SKILL.md +28 -10
  65. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  66. package/runtime/templates/reports/final-report.template.md +4 -0
  67. package/runtime/templates/reports/html/i18n/en.json +5 -1
  68. package/runtime/templates/reports/html/i18n/ko.json +5 -1
  69. package/runtime/templates/reports/html/macros/forms.html +15 -0
  70. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  71. package/runtime/templates/reports/i18n/en.json +2 -0
  72. package/runtime/validators/validate-run.py +267 -208
  73. package/runtime/validators/validate-workflow.sh +6 -0
  74. package/runtime/validators/validate_session_conformance.py +135 -31
  75. package/src/cli-registry.mjs +34 -0
  76. package/src/commands/execute/incremental-scope.mjs +10 -0
  77. package/src/commands/execute/worker-audit-check.mjs +35 -0
  78. package/src/commands/inspect/error-issue.mjs +27 -0
  79. package/src/commands/inspect/profile-show.mjs +29 -0
  80. package/src/commands/inspect/run-audit.mjs +26 -0
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import os
17
17
  import re
18
+ from collections.abc import Callable, Sequence
18
19
  from dataclasses import dataclass, replace
19
20
  from pathlib import Path
20
21
  from typing import Optional
@@ -41,9 +42,46 @@ __all__ = [
41
42
  "task_runs_dir",
42
43
  "container_paths",
43
44
  "okstra_home",
45
+ "find_asset_root",
44
46
  ]
45
47
 
46
48
 
49
+ def find_asset_root(
50
+ relative: Sequence[str],
51
+ *,
52
+ start: Optional[Path] = None,
53
+ is_present: Callable[[Path], bool] = Path.is_file,
54
+ ) -> Optional[Path]:
55
+ """Return the runtime root that carries ``relative``, or None.
56
+
57
+ okstra's assets ship in three layouts that put the same tree at a different
58
+ depth relative to this package: a repo checkout has `scripts/okstra_ctl/`,
59
+ the built runtime has `runtime/python/okstra_ctl/`, and an install has
60
+ `~/.okstra/lib/python/okstra_ctl/` with the asset trees two levels further
61
+ up at `~/.okstra/`. Probing for the asset therefore beats counting parents,
62
+ which is right for exactly one of the three.
63
+
64
+ Pass ``is_present=Path.is_dir`` when ``relative`` names a directory.
65
+
66
+ This reads `OKSTRA_HOME` straight from the environment rather than calling
67
+ `okstra_home()`, and the difference is load-bearing: `okstra_home()` falls
68
+ back to `~/.okstra` when the variable is unset, which would let an
69
+ installed copy shadow the checkout a developer is running from. An unset
70
+ variable — or one whose tree lacks the asset — falls through to the walk.
71
+ """
72
+ override = os.environ.get("OKSTRA_HOME")
73
+ if override:
74
+ root = Path(override)
75
+ if is_present(root.joinpath(*relative)):
76
+ return root
77
+
78
+ here = Path(start or __file__).resolve()
79
+ for parent in [here, *here.parents]:
80
+ if is_present(parent.joinpath(*relative)):
81
+ return parent
82
+ return None
83
+
84
+
47
85
  _STAGED_TASK_TYPES = ("implementation", "final-verification")
48
86
  # 발견용: bash `find -name 'final-report-*.md'` 와 같은 범위 — seq 이전 세대의
49
87
  # 타임스탬프 파일명도 잡아야 옛 번들에서 조용히 실패하지 않는다.
@@ -1,4 +1,9 @@
1
- """CLI adapter for deterministic implementation-planning item extraction."""
1
+ """CLI adapter for deterministic implementation-planning item extraction.
2
+
3
+ `extract` / `validate` own the `P-*` queue; `collect-verdicts` / `apply-verdicts`
4
+ own the round's votes on that queue. Both halves exist so the round's fidelity
5
+ does not depend on a parser the lead re-writes each time.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
9
  import argparse
@@ -10,6 +15,7 @@ from typing import Any
10
15
 
11
16
  from .convergence_store import write_json_atomic
12
17
  from .plan_items import PlanItemContractError, extract_plan_items
18
+ from .verdict_blocks import VerdictBlock, VerdictBlockError, parse_verdict_blocks
13
19
 
14
20
 
15
21
  def _load_json_object(path: Path) -> dict[str, Any]:
@@ -49,6 +55,21 @@ def _parser() -> argparse.ArgumentParser:
49
55
  validate = commands.add_parser("validate")
50
56
  validate.add_argument("--data", type=Path, required=True)
51
57
  validate.add_argument("--items", type=Path, required=True)
58
+ collect = commands.add_parser(
59
+ "collect-verdicts",
60
+ help="read this round's worker responses into a verdicts envelope",
61
+ )
62
+ collect.add_argument("--result", action="append", default=[], required=True,
63
+ metavar="<worker-id>=<path>",
64
+ help="one worker's plan-verify result file (repeatable)")
65
+ collect.add_argument("--items", type=Path, required=True)
66
+ collect.add_argument("--output", type=Path, required=True)
67
+ apply_verdicts = commands.add_parser(
68
+ "apply-verdicts",
69
+ help="overwrite planBodyVerification.planItems[].verdicts in data.json",
70
+ )
71
+ apply_verdicts.add_argument("--data", type=Path, required=True)
72
+ apply_verdicts.add_argument("--verdicts", type=Path, required=True)
52
73
  return parser
53
74
 
54
75
 
@@ -66,11 +87,154 @@ def _validate(args: argparse.Namespace) -> dict[str, Any]:
66
87
  return {"ok": True, "operation": "validate", "path": str(args.items)}
67
88
 
68
89
 
90
+ def _split_result_arg(raw: str) -> tuple[str, Path]:
91
+ worker, separator, path = raw.partition("=")
92
+ if not separator or not worker.strip() or not path.strip():
93
+ raise PlanItemContractError(
94
+ f"--result must be <worker-id>=<path>, got: {raw}"
95
+ )
96
+ return worker.strip(), Path(path.strip())
97
+
98
+
99
+ def _assigned_item_ids(items_path: Path) -> list[str]:
100
+ envelope = _load_json_object(items_path)
101
+ items = envelope.get("items")
102
+ if not isinstance(items, list):
103
+ raise PlanItemContractError(f"items envelope has no `items` array: {items_path}")
104
+ ids: list[str] = []
105
+ for item in items:
106
+ item_id = item.get("id") if isinstance(item, Mapping) else None
107
+ if not isinstance(item_id, str) or not item_id:
108
+ raise PlanItemContractError(f"every item needs an `id`: {items_path}")
109
+ ids.append(item_id)
110
+ return ids
111
+
112
+
113
+ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
114
+ """One `planItems[].verdicts[]` row. Optional fields stay absent when empty
115
+ so the recorded table shows what the worker actually said."""
116
+ row: dict[str, Any] = {"worker": worker, "verdict": block.verdict}
117
+ for key, value in (
118
+ ("breakageKind", block.breakage_kind),
119
+ ("fixability", block.fixability),
120
+ ("note", block.note),
121
+ ("priorDissent", block.prior_dissent),
122
+ ):
123
+ if value:
124
+ row[key] = value
125
+ return row
126
+
127
+
128
+ def _worker_blocks(
129
+ raw_results: list[str], assigned: set[str]
130
+ ) -> list[tuple[str, dict[str, VerdictBlock]]]:
131
+ """Each worker's parsed response, refusing any queue mismatch.
132
+
133
+ A missing vote and an invented item are both silent in a hand-written
134
+ parser; each is a round scored on a table that does not match the queue.
135
+ """
136
+ collected: list[tuple[str, dict[str, VerdictBlock]]] = []
137
+ for raw in raw_results:
138
+ worker, path = _split_result_arg(raw)
139
+ try:
140
+ blocks = parse_verdict_blocks(path.read_text(encoding="utf-8"))
141
+ except (OSError, UnicodeError) as exc:
142
+ raise PlanItemContractError(f"cannot read result {path}: {exc}") from exc
143
+ answered = set(blocks)
144
+ missing = sorted(assigned - answered)
145
+ if missing:
146
+ raise PlanItemContractError(
147
+ f"worker `{worker}` was assigned {len(assigned)} items but "
148
+ f"returned no verdict for {missing} — an unanswered item cannot "
149
+ f"be scored, and dropping it silently is what makes a round look "
150
+ f"complete when it is not"
151
+ )
152
+ unknown = sorted(answered - assigned)
153
+ if unknown:
154
+ raise PlanItemContractError(
155
+ f"worker `{worker}` returned verdicts for {unknown}, which are "
156
+ f"not in the persisted plan-item queue"
157
+ )
158
+ collected.append((worker, blocks))
159
+ return collected
160
+
161
+
162
+ def _collect_verdicts(args: argparse.Namespace) -> dict[str, Any]:
163
+ assigned = _assigned_item_ids(args.items)
164
+ collected = _worker_blocks(args.result, set(assigned))
165
+ envelope = {
166
+ "schemaVersion": "1.0",
167
+ "taskType": "implementation-planning",
168
+ "planItems": [
169
+ {
170
+ "id": item_id,
171
+ "verdicts": [
172
+ _verdict_row(worker, blocks[item_id])
173
+ for worker, blocks in collected
174
+ ],
175
+ }
176
+ for item_id in assigned
177
+ ],
178
+ }
179
+ write_json_atomic(args.output, envelope)
180
+ return {"ok": True, "operation": "collect-verdicts", "path": str(args.output)}
181
+
182
+
183
+ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, Any]]:
184
+ verification = _planning(data).get("planBodyVerification")
185
+ if not isinstance(verification, Mapping):
186
+ raise PlanItemContractError(
187
+ f"implementationPlanning.planBodyVerification must be an object: {data_path}"
188
+ )
189
+ items = verification.get("planItems")
190
+ if not isinstance(items, list):
191
+ raise PlanItemContractError(
192
+ f"planBodyVerification.planItems must be an array: {data_path}"
193
+ )
194
+ return items
195
+
196
+
197
+ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
198
+ data = _load_json_object(args.data)
199
+ incoming = _load_json_object(args.verdicts).get("planItems")
200
+ if not isinstance(incoming, list):
201
+ raise PlanItemContractError("verdicts envelope has no `planItems` array")
202
+ rows = {
203
+ item["id"]: item.get("verdicts", [])
204
+ for item in incoming
205
+ if isinstance(item, Mapping) and isinstance(item.get("id"), str)
206
+ }
207
+ recorded = _plan_body_items(data, args.data)
208
+ known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
209
+ missing = sorted(item_id for item_id in rows if item_id not in known)
210
+ if missing:
211
+ raise PlanItemContractError(
212
+ f"the report's planBodyVerification has no row for {missing} — the "
213
+ f"gate is re-derived from that table, so a verdict with nowhere to "
214
+ f"land would be scored as if it were never cast"
215
+ )
216
+ for item in recorded:
217
+ if isinstance(item, Mapping) and item.get("id") in rows:
218
+ # Overwrite, never merge: the contract records one round at a time,
219
+ # and a merged table lets a previous round's votes keep voting.
220
+ item["verdicts"] = rows[item["id"]]
221
+ write_json_atomic(args.data, data)
222
+ return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
223
+
224
+
225
+ _HANDLERS = {
226
+ "extract": _extract,
227
+ "validate": _validate,
228
+ "collect-verdicts": _collect_verdicts,
229
+ "apply-verdicts": _apply_verdicts,
230
+ }
231
+
232
+
69
233
  def main(argv: list[str] | None = None) -> int:
70
234
  args = _parser().parse_args(argv)
71
235
  try:
72
- result = _extract(args) if args.command == "extract" else _validate(args)
73
- except (PlanItemContractError, OSError, ValueError) as exc:
236
+ result = _HANDLERS[args.command](args)
237
+ except (PlanItemContractError, VerdictBlockError, OSError, ValueError) as exc:
74
238
  print(f"plan-items: {exc}", file=sys.stderr)
75
239
  return 2
76
240
  print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,134 @@
1
+ """Read-only flattened view of a phase profile (`okstra profile show`).
2
+
3
+ A profile is assembled from three places — the top-level body, its
4
+ `{{INCLUDE:}}` targets, and the lazy-read sidecars the body's own table names —
5
+ so grepping the top-level file and finding nothing does not mean the rule is
6
+ absent. This prints the whole thing, so one grep answers "does this task-type
7
+ cover X".
8
+
9
+ Read-only is the point, not a nicety: `render-bundle` would answer the same
10
+ question, but it writes a manifest and registers the run in `recent.jsonl`.
11
+ That side effect is exactly why it cannot be used to look something up.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from .paths import find_asset_root
22
+ from .run import PrepareError, _expand_profile_includes
23
+
24
+ _PROFILES_REL = ("prompts", "profiles")
25
+
26
+ # The sidecar table spells its targets as inline-code repo-relative paths, e.g.
27
+ # `prompts/profiles/_implementation-executor.md`. Reading the list from the
28
+ # profile body rather than hard-coding it here is deliberate: a list in code
29
+ # goes stale the moment a profile adds a sidecar, and does so silently.
30
+ _SIDECAR_RE = re.compile(r"`(prompts/profiles/_[\w-]+\.md)`")
31
+
32
+
33
+ def workspace_root(start: Path | None = None) -> Path:
34
+ """Locate the runtime root that carries the `prompts/profiles` tree."""
35
+ root = find_asset_root(_PROFILES_REL, start=start, is_present=Path.is_dir)
36
+ if root is not None:
37
+ return root
38
+
39
+ raise PrepareError(
40
+ "could not locate prompts/profiles. Set OKSTRA_HOME or run from a "
41
+ "checkout that contains prompts/profiles/."
42
+ )
43
+
44
+
45
+ def profile_path(root: Path, task_type: str) -> Path:
46
+ path = root.joinpath(*_PROFILES_REL, f"{task_type}.md")
47
+ if not path.is_file():
48
+ raise PrepareError(f"unknown task-type: {task_type} (no {path})")
49
+ return path
50
+
51
+
52
+ def sidecar_bodies(root: Path, profile_text: str) -> list[str]:
53
+ """Every lazy-read sidecar reachable from the profile body, breadth-first.
54
+
55
+ The walk runs to a fixpoint because sidecars name sidecars of their own:
56
+ `_implementation-executor.md` points at the coding-conventions preflight,
57
+ the diff-review sweep, and the completion self-check. Stopping after one
58
+ hop would rebuild the very false negative this command exists to prevent,
59
+ one level down.
60
+
61
+ Each body is expanded rather than read raw, so nested `{{INCLUDE:}}`
62
+ directives resolve and the maintainer-only HTML comments drop out — the
63
+ same treatment the lead's rendered profile gets.
64
+ """
65
+ bodies: list[str] = []
66
+ seen: set[str] = set()
67
+ pending = list(dict.fromkeys(_SIDECAR_RE.findall(profile_text)))
68
+ while pending:
69
+ relative = pending.pop(0)
70
+ if relative in seen:
71
+ continue
72
+ seen.add(relative)
73
+ path = root / relative
74
+ if not path.is_file():
75
+ continue
76
+ body = _expand_profile_includes(path)
77
+ bodies.append(f"\n\n<!-- lazy-read sidecar: {relative} -->\n\n{body}")
78
+ pending.extend(ref for ref in _SIDECAR_RE.findall(body) if ref not in seen)
79
+ return bodies
80
+
81
+
82
+ def render(root: Path, task_type: str, *, resolved: bool) -> str:
83
+ path = profile_path(root, task_type)
84
+ if not resolved:
85
+ return path.read_text(encoding="utf-8")
86
+ expanded = _expand_profile_includes(path)
87
+ return expanded + "".join(sidecar_bodies(root, expanded))
88
+
89
+
90
+ def _write_stdout(text: str) -> None:
91
+ """Write the profile, tolerating a reader that stops early.
92
+
93
+ The command's whole purpose is to be piped into `grep` or `head`, and both
94
+ close the pipe as soon as they have enough. Without this, that normal usage
95
+ ends in a BrokenPipeError traceback — and the interpreter raises a second
96
+ one when it flushes stdout at shutdown, which is why the fd is redirected
97
+ to devnull rather than merely swallowing the first exception.
98
+ """
99
+ try:
100
+ sys.stdout.write(text)
101
+ sys.stdout.flush()
102
+ except BrokenPipeError:
103
+ devnull = os.open(os.devnull, os.O_WRONLY)
104
+ try:
105
+ os.dup2(devnull, sys.stdout.fileno())
106
+ finally:
107
+ os.close(devnull)
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ parser = argparse.ArgumentParser(
112
+ prog="okstra profile show",
113
+ description="Print a phase profile, optionally fully resolved (read-only).",
114
+ )
115
+ parser.add_argument("command", choices=("show",))
116
+ parser.add_argument("task_type")
117
+ parser.add_argument(
118
+ "--resolved",
119
+ action="store_true",
120
+ help="expand {{INCLUDE:}} targets and append the lazy-read sidecars",
121
+ )
122
+ args = parser.parse_args(argv)
123
+
124
+ try:
125
+ text = render(workspace_root(), args.task_type, resolved=args.resolved)
126
+ except PrepareError as exc:
127
+ print(f"profile show: {exc}", file=sys.stderr)
128
+ return 2
129
+ _write_stdout(text)
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main(sys.argv[1:]))
@@ -11,7 +11,9 @@ import json
11
11
  import sys
12
12
  from pathlib import Path
13
13
 
14
+ from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
14
15
  from okstra_ctl.ids import slugify_task_segment
16
+ from okstra_ctl.incremental_scope import preview_link_availability_for_report
15
17
  from okstra_ctl.paths import task_timeline_file
16
18
  from okstra_project import read_task_key
17
19
  from okstra_ctl.run_context import dir_flock
@@ -33,6 +35,63 @@ def _load_timeline(task_root: Path) -> list[dict]:
33
35
 
34
36
 
35
37
 
38
+ _STRUCTURAL_CHANGE_NOTE = (
39
+ "An answer that overturns the selected option, restructures the Stage Map, "
40
+ 'or changes the recommended approach requires --full-reason "<what changes '
41
+ 'and how>". The back-trace resolves stages; it cannot judge whether the '
42
+ "plan's shape survived, so that call is the lead's and must be declared."
43
+ )
44
+
45
+
46
+ def _latest_planning_report(runs: list[dict], project_root: Path) -> Path | None:
47
+ """The most recent `implementation-planning` report still on disk."""
48
+ for run in reversed(runs):
49
+ if not isinstance(run, dict):
50
+ continue
51
+ if run.get("taskType") != "implementation-planning":
52
+ continue
53
+ relative = str(run.get("reportPath") or "")
54
+ if not relative:
55
+ continue
56
+ report = project_root / relative
57
+ if report.is_file():
58
+ return report
59
+ return None
60
+
61
+
62
+ def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
63
+ """What the next clarification re-run needs, assembled from disk.
64
+
65
+ Every field is derivable before the run starts, and each one used to live
66
+ somewhere else: the flag value in the lead prompt (read only *after* the
67
+ run begins), the answered ids in sidecars, the re-verification mode nowhere
68
+ at all until `incremental-scope --preview`. Scattering them is why the user
69
+ had to ask for each one instead of being told.
70
+
71
+ ``None`` when nothing is waiting to be carried — no planning report, or no
72
+ answered clarification beside it.
73
+ """
74
+ report = _latest_planning_report(runs, project_root)
75
+ if report is None:
76
+ return None
77
+ answered = sorted(sidecar_answers(report))
78
+ if not answered:
79
+ return None
80
+ return {
81
+ "sourceReport": project_rel(report, project_root),
82
+ "answeredClarifications": answered,
83
+ "answeredClarificationsCsv": ",".join(answered),
84
+ "sidecars": [
85
+ project_rel(sidecar, project_root)
86
+ for sidecar in user_response_sidecars(report)
87
+ ],
88
+ "reverifyPreview": preview_link_availability_for_report(
89
+ report, set(answered)
90
+ ),
91
+ "structuralChangeNote": _STRUCTURAL_CHANGE_NOTE,
92
+ }
93
+
94
+
36
95
  def assemble_recap(task_root: Path, project_root: Path) -> dict:
37
96
  runs = _load_timeline(task_root)
38
97
  transitions = []
@@ -62,6 +121,10 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
62
121
  "runCount": len(runs),
63
122
  "transitions": transitions,
64
123
  "latestPhaseStates": latest_states,
124
+ # `null` when nothing is waiting to be carried — the key is always
125
+ # present so a consumer can tell "no re-run pending" from "this recap
126
+ # predates the block".
127
+ "rerunReadiness": rerun_readiness(project_root, runs),
65
128
  }
66
129
 
67
130
 
@@ -31,7 +31,8 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
31
31
  from . import fix_cycles
32
32
  from .analysis_inputs import ANALYSIS_TASK_TYPES
33
33
  from .paths import okstra_home
34
- from .lead_runtime import lead_runtime_info
34
+ from .dispatch_state import BACKEND_CMUX_PANE
35
+ from .lead_runtime import lead_runtime_info, with_cmux_dispatch
35
36
  from .models import UnknownProviderError, provider_ids, provider_spec
36
37
  from .runner_resolution import native_provider_for_host
37
38
  from .path_hints import compact_active_run_context, hydrate_run_context
@@ -80,7 +81,10 @@ def _lead_runtime(ctx: dict) -> str:
80
81
 
81
82
 
82
83
  def _lead_info(ctx: dict):
83
- return lead_runtime_info(_lead_runtime(ctx))
84
+ info = lead_runtime_info(_lead_runtime(ctx))
85
+ if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
86
+ return with_cmux_dispatch(info)
87
+ return info
84
88
 
85
89
 
86
90
  def _lead_agent(ctx: dict) -> str:
@@ -1444,6 +1448,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1444
1448
  "hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
1445
1449
  "leadRuntime": _lead_runtime(ctx),
1446
1450
  "leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
1451
+ "terminalBackend": ctx.get("TERMINAL_BACKEND", ""),
1447
1452
  "runtimeResolution": _runtime_resolution(ctx),
1448
1453
  "leadAssignment": _lead_assignment(ctx),
1449
1454
  "workerAssignments": _worker_assignments(ctx),
@@ -52,6 +52,7 @@ from okstra_ctl.final_report_schema import (
52
52
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
53
53
  from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
54
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
55
+ from okstra_ctl.paths import find_asset_root
55
56
  from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY, markdown_template_for
56
57
  from okstra_ctl.report_markdown import ReportSections
57
58
  from okstra_ctl.schema_excerpt import excerpt_cut_from_version
@@ -694,17 +695,9 @@ def find_default_template(start: Path | None = None) -> Path:
694
695
 
695
696
  Raises ``FinalReportRenderError`` if neither path is present.
696
697
  """
697
- okstra_home = os.environ.get("OKSTRA_HOME")
698
- if okstra_home:
699
- candidate = Path(okstra_home).joinpath(*DEFAULT_TEMPLATE_REL)
700
- if candidate.is_file():
701
- return candidate
702
-
703
- here = Path(start or __file__).resolve()
704
- for parent in [here, *here.parents]:
705
- candidate = parent.joinpath(*DEFAULT_TEMPLATE_REL)
706
- if candidate.is_file():
707
- return candidate
698
+ root = find_asset_root(DEFAULT_TEMPLATE_REL, start=start)
699
+ if root is not None:
700
+ return root.joinpath(*DEFAULT_TEMPLATE_REL)
708
701
 
709
702
  raise FinalReportRenderError(
710
703
  "could not locate final-report.template.md. Set OKSTRA_HOME or "
@@ -724,17 +717,9 @@ def find_default_template_for_data(
724
717
  f"unsupported final-report schemaVersion: {version}"
725
718
  ) from exc
726
719
 
727
- okstra_home = os.environ.get("OKSTRA_HOME")
728
- if okstra_home:
729
- candidate = Path(okstra_home).joinpath(*relative_path)
730
- if candidate.is_file():
731
- return candidate
732
-
733
- here = Path(start or __file__).resolve()
734
- for parent in [here, *here.parents]:
735
- candidate = parent.joinpath(*relative_path)
736
- if candidate.is_file():
737
- return candidate
720
+ root = find_asset_root(relative_path, start=start)
721
+ if root is not None:
722
+ return root.joinpath(*relative_path)
738
723
 
739
724
  raise FinalReportRenderError(
740
725
  f"could not locate {relative_path[-1]}. Set OKSTRA_HOME or run from a "
@@ -24,7 +24,9 @@ from typing import Any, Iterator, Mapping, NamedTuple
24
24
  # Values the renderer reads as text and nothing else.
25
25
  PROSE_KEYS = frozenset({
26
26
  "acceptance",
27
+ "addedWork",
27
28
  "alternativesConsidered",
29
+ "answer",
28
30
  "approach",
29
31
  "approvalDisposition",
30
32
  "approvalEvidence",
@@ -50,6 +52,7 @@ PROSE_KEYS = frozenset({
50
52
  "declinedFixRecommendations",
51
53
  "description",
52
54
  "details",
55
+ "directionChange",
53
56
  "disagreement",
54
57
  "discrepancy",
55
58
  "disproveWith",
@@ -236,6 +239,7 @@ STRUCTURAL_KEYS = frozenset({
236
239
  "runManifest",
237
240
  "runSeq",
238
241
  "scope", # 'PF-001' alongside prose
242
+ "scopeImpact", # clarification reach tokens the gate matches on
239
243
  "sections",
240
244
  "shortSha",
241
245
  "signature",
@@ -693,12 +693,16 @@ def _strip_leading_letter_label(text: str) -> str:
693
693
  return re.sub(r"^\([a-z]\)\s*", "", text)
694
694
 
695
695
 
696
- def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
696
+ def parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
697
697
  """Parse the ``Expected form`` contract format
698
698
  (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
699
699
  `_common-contract.md` §Clarification request policy) into select
700
700
  ``(value, label)`` options. Returns ``[]`` when the cell carries no
701
- ``Recommended:`` cue — the caller falls back to the statement enum."""
701
+ ``Recommended:`` cue — the caller falls back to the statement enum.
702
+
703
+ The single parser for this cell. Both the HTML view and
704
+ ``user_response.show_open_rows`` call it; a second implementation is
705
+ exactly how the two option boards drifted apart once already."""
702
706
  if not expected_form:
703
707
  return []
704
708
  expected_form = _PICK_ONE_ANNOTATION.sub("", expected_form)
@@ -791,7 +795,7 @@ def _form_control(
791
795
  # 계약(_common-contract.md §Clarification request policy)이 1순위,
792
796
  # statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
793
797
  if kind_lc == "decision":
794
- opts = _parse_expected_form_options(expected_form)
798
+ opts = parse_expected_form_options(expected_form)
795
799
  if not opts:
796
800
  opts = [
797
801
  (letter, f"({letter}) {text}")