okstra 0.159.0 → 0.160.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 (73) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +1 -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/context-loader.md +5 -2
  19. package/runtime/prompts/lead/convergence.md +3 -1
  20. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  21. package/runtime/prompts/lead/team-contract.md +2 -1
  22. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  23. package/runtime/prompts/profiles/_common-contract.md +3 -1
  24. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  25. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  26. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  27. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  28. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  29. package/runtime/python/okstra_ctl/convergence.py +168 -11
  30. package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
  31. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  32. package/runtime/python/okstra_ctl/error_report.py +56 -0
  33. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  34. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  35. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  36. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  37. package/runtime/python/okstra_ctl/paths.py +38 -0
  38. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  39. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  40. package/runtime/python/okstra_ctl/recap.py +63 -0
  41. package/runtime/python/okstra_ctl/render_final_report.py +7 -22
  42. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  43. package/runtime/python/okstra_ctl/report_views.py +7 -3
  44. package/runtime/python/okstra_ctl/run.py +41 -2
  45. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  46. package/runtime/python/okstra_ctl/user_response.py +25 -10
  47. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  48. package/runtime/python/okstra_ctl/wizard.py +64 -10
  49. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  50. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  51. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  52. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  53. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  54. package/runtime/schemas/final-report-v2.0.schema.json +51 -1
  55. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  56. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  57. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  58. package/runtime/skills/okstra-run/SKILL.md +28 -10
  59. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  60. package/runtime/templates/reports/final-report.template.md +4 -0
  61. package/runtime/templates/reports/html/i18n/en.json +5 -1
  62. package/runtime/templates/reports/html/i18n/ko.json +5 -1
  63. package/runtime/templates/reports/html/macros/forms.html +15 -0
  64. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  65. package/runtime/templates/reports/i18n/en.json +2 -0
  66. package/runtime/validators/validate-run.py +267 -208
  67. package/runtime/validators/validate_session_conformance.py +102 -32
  68. package/src/cli-registry.mjs +34 -0
  69. package/src/commands/execute/incremental-scope.mjs +10 -0
  70. package/src/commands/execute/worker-audit-check.mjs +35 -0
  71. package/src/commands/inspect/error-issue.mjs +27 -0
  72. package/src/commands/inspect/profile-show.mjs +29 -0
  73. package/src/commands/inspect/run-audit.mjs +26 -0
@@ -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
 
@@ -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}")
@@ -45,6 +45,7 @@ from .clarification_items import (
45
45
  clarification_response_with_sidecars,
46
46
  scan_approval_gate,
47
47
  )
48
+ from .error_report import prior_run_error_digest
48
49
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
49
50
  from .material import (
50
51
  build_analysis_material,
@@ -857,6 +858,10 @@ class _ResolvedAssets:
857
858
  lead_contract: Path
858
859
  run_validator: Path
859
860
  brief_validator: Path
861
+ # None when this task-type's host orchestration carries no gates; see
862
+ # prompts/host-orchestration/README.md. Absence is a normal state, not a
863
+ # broken install, so it is exempt from the _INSTALL_HINT contract above.
864
+ host_rules_file: Path | None
860
865
 
861
866
 
862
867
  def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _ResolvedAssets:
@@ -892,6 +897,9 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
892
897
  raise PrepareError(
893
898
  f"required okstra template or lead contract missing: {required}.{_INSTALL_HINT}"
894
899
  )
900
+ host_rules_file = (
901
+ workspace_root / "prompts" / "host-orchestration" / f"{inp.task_type}.md"
902
+ )
895
903
  return _ResolvedAssets(
896
904
  profile_file=profile_file,
897
905
  prompt_template=prompt_template,
@@ -900,6 +908,7 @@ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _Resolv
900
908
  lead_contract=lead_contract,
901
909
  run_validator=run_validator,
902
910
  brief_validator=brief_validator,
911
+ host_rules_file=host_rules_file if host_rules_file.is_file() else None,
903
912
  )
904
913
 
905
914
 
@@ -1766,8 +1775,24 @@ def _write_verification_target_artifact(
1766
1775
  ctx["VERIFICATION_TARGET"] = target_path.read_text(encoding="utf-8")
1767
1776
 
1768
1777
 
1778
+ def _write_prior_run_error_digest(ctx: dict, instruction_set: Path) -> None:
1779
+ """Stage the earlier runs' actionable errors — only when there are any.
1780
+
1781
+ No file at all when the task recorded none, rather than one saying "none":
1782
+ a staged file the lead must open to learn it is empty costs a read every
1783
+ run and teaches the lead to stop opening that path.
1784
+ """
1785
+ digest = prior_run_error_digest(Path(ctx["TASK_ROOT"]))
1786
+ if digest:
1787
+ (instruction_set / "prior-run-errors.md").write_text(digest, encoding="utf-8")
1788
+
1789
+
1769
1790
  def _write_instruction_set_sources(
1770
- inp: PrepareInputs, ctx: dict, profile_content: str, review_material: str
1791
+ inp: PrepareInputs,
1792
+ ctx: dict,
1793
+ profile_content: str,
1794
+ review_material: str,
1795
+ host_rules_file: Path | None,
1771
1796
  ) -> Path:
1772
1797
  """instruction-set 디렉터리에 profile/material/brief/clarification/directive 와
1773
1798
  reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
@@ -1775,6 +1800,7 @@ def _write_instruction_set_sources(
1775
1800
  instruction_set.mkdir(parents=True, exist_ok=True)
1776
1801
  _write_analysis_evidence_artifact(ctx, instruction_set)
1777
1802
  _write_verification_target_artifact(inp, ctx, instruction_set)
1803
+ _write_prior_run_error_digest(ctx, instruction_set)
1778
1804
  profile_rendered = profile_content
1779
1805
  if inp.task_type == "implementation":
1780
1806
  profile_rendered += "\n\n{{DESIGN_PREP_CONTEXT}}\n\n{{FIX_RUN_CONTEXT}}"
@@ -1814,6 +1840,19 @@ def _write_instruction_set_sources(
1814
1840
  )
1815
1841
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
1816
1842
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
1843
+ # A rule that lives only in the conversation is one compaction away from
1844
+ # gone, with no signal that it went. On disk it survives, and the session
1845
+ # conformance check can ask afterwards whether it was read. The token is
1846
+ # set unconditionally because render_template_with_ctx fail-fasts on a
1847
+ # token the launch template references but ctx lacks.
1848
+ host_rules_relative = ""
1849
+ if host_rules_file is not None:
1850
+ staged_host_rules = instruction_set / "host-orchestration-rules.md"
1851
+ shutil.copyfile(host_rules_file, staged_host_rules)
1852
+ host_rules_relative = relative_to_project_root(
1853
+ staged_host_rules, Path(ctx["PROJECT_ROOT"])
1854
+ )
1855
+ ctx["HOST_ORCHESTRATION_RULES_RELATIVE_PATH"] = host_rules_relative
1817
1856
  if inp.clarification_response_path:
1818
1857
  (instruction_set / "clarification-response.md").write_text(
1819
1858
  clarification_response_with_sidecars(Path(inp.clarification_response_path)),
@@ -2556,7 +2595,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2556
2595
 
2557
2596
  # ---- write instruction-set scaffolding + lead prompt ----
2558
2597
  instruction_set = _write_instruction_set_sources(
2559
- inp, ctx, profile_content, review_material
2598
+ inp, ctx, profile_content, review_material, assets.host_rules_file
2560
2599
  )
2561
2600
  prompt_text = _render_lead_prompt_and_snapshot(
2562
2601
  inp, ctx, instruction_set, final_report_template, prompt_template