okstra 0.158.1 → 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 (84) 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/agents/workers/report-writer-worker.md +1 -1
  14. package/runtime/bin/okstra-claude-exec.sh +4 -1
  15. package/runtime/prompts/host-orchestration/README.md +18 -0
  16. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  17. package/runtime/prompts/launch.template.md +10 -1
  18. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  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/report-writer.md +1 -1
  23. package/runtime/prompts/lead/team-contract.md +2 -1
  24. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  25. package/runtime/prompts/profiles/_common-contract.md +3 -1
  26. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  27. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  28. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  29. package/runtime/python/okstra_ctl/clarification_items.py +9 -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 +4 -2
  33. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  34. package/runtime/python/okstra_ctl/error_report.py +56 -0
  35. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  36. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  37. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  38. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  39. package/runtime/python/okstra_ctl/paths.py +38 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  41. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  42. package/runtime/python/okstra_ctl/recap.py +63 -0
  43. package/runtime/python/okstra_ctl/render_final_report.py +11 -62
  44. package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
  45. package/runtime/python/okstra_ctl/report_html/render.py +9 -8
  46. package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
  47. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
  48. package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
  49. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  50. package/runtime/python/okstra_ctl/report_views.py +7 -3
  51. package/runtime/python/okstra_ctl/run.py +41 -2
  52. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  53. package/runtime/python/okstra_ctl/usage_cells.py +47 -0
  54. package/runtime/python/okstra_ctl/user_response.py +25 -10
  55. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  56. package/runtime/python/okstra_ctl/wizard.py +64 -10
  57. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  58. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  59. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  60. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  61. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +56 -2
  63. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  64. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  66. package/runtime/skills/okstra-run/SKILL.md +28 -10
  67. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  68. package/runtime/templates/reports/final-report.template.md +4 -0
  69. package/runtime/templates/reports/html/assets/base.css +14 -1
  70. package/runtime/templates/reports/html/base.template.html +42 -0
  71. package/runtime/templates/reports/html/i18n/en.json +30 -1
  72. package/runtime/templates/reports/html/i18n/ko.json +30 -1
  73. package/runtime/templates/reports/html/macros/forms.html +15 -0
  74. package/runtime/templates/reports/html/macros/visualizations.html +3 -2
  75. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  76. package/runtime/templates/reports/i18n/en.json +2 -0
  77. package/runtime/validators/validate-run.py +331 -208
  78. package/runtime/validators/validate_session_conformance.py +102 -32
  79. package/src/cli-registry.mjs +34 -0
  80. package/src/commands/execute/incremental-scope.mjs +10 -0
  81. package/src/commands/execute/worker-audit-check.mjs +35 -0
  82. package/src/commands/inspect/error-issue.mjs +27 -0
  83. package/src/commands/inspect/profile-show.mjs +29 -0
  84. package/src/commands/inspect/run-audit.mjs +26 -0
@@ -0,0 +1,110 @@
1
+ """What the run cost, per agent — the Phase 7 usage cells as reader-facing text.
2
+
3
+ `executionStatus` is the only block that holds a duration per agent, so the
4
+ table is built from it rather than from `tokenUsage.workerDetails`, which
5
+ repeats the same token figures without saying how long each agent ran.
6
+
7
+ The formatting happens here rather than in the template because a null cell has
8
+ to read as "not measured" everywhere it appears, and `usage_cells` is where both
9
+ report views agree on how that looks.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from ..usage_cells import format_duration_ms, format_int, format_usd
14
+
15
+
16
+ def _number(value: object) -> int | float | None:
17
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
18
+ return None
19
+ return value
20
+
21
+
22
+ def _totals_row(row: object) -> dict[str, str]:
23
+ values = row if isinstance(row, dict) else {}
24
+ return {
25
+ "rawTokens": format_int(values.get("totalTokens")),
26
+ "billableTokens": format_int(values.get("billableTokens")),
27
+ "cost": format_usd(values.get("costUsd")),
28
+ }
29
+
30
+
31
+ def _agent_row(row: dict) -> dict[str, str]:
32
+ """One agent's identity and what it spent.
33
+
34
+ The CLI cells stay empty rather than `--` when the agent made no CLI call:
35
+ they render as a second line inside the token and cost cells, and a `--`
36
+ there would read as a missing measurement instead of an absent charge.
37
+ """
38
+ cli_tokens = _number(row.get("cliTotalTokens")) or 0
39
+ cli_cost = _number(row.get("cliCostUsd")) or 0
40
+ return {
41
+ "agent": str(row.get("agent") or ""),
42
+ "role": str(row.get("role") or ""),
43
+ "model": str(row.get("model") or ""),
44
+ "status": str(row.get("status") or ""),
45
+ "rawTokens": format_int(row.get("totalTokens")),
46
+ "billableTokens": format_int(row.get("billableTokens")),
47
+ "cost": format_usd(row.get("costUsd")),
48
+ "duration": format_duration_ms(row.get("durationMs")),
49
+ "cliTokens": format_int(cli_tokens) if cli_tokens else "",
50
+ "cliCost": format_usd(cli_cost) if cli_cost else "",
51
+ }
52
+
53
+
54
+ def _sum(rows: list[dict], key: str) -> int | float:
55
+ return sum(_number(row.get(key)) or 0 for row in rows)
56
+
57
+
58
+ def _unaccounted(rows: list[dict], grand: dict) -> dict[str, str] | None:
59
+ """The part of the total that no row above carries, when there is one.
60
+
61
+ Two documented paths leave a gap. Sessions that match no worker are summed
62
+ into `unattributedWorkerUsage`, and where two report rows share one
63
+ team-state aggregate only the first is attributed, leaving the second's
64
+ cells null. Both land in the total, so without this row the column adds up
65
+ to less than the figure beneath it and neither number can be trusted.
66
+ """
67
+ grand_tokens = _number(grand.get("totalTokens"))
68
+ if grand_tokens is None:
69
+ return None
70
+ gap = grand_tokens - _sum(rows, "totalTokens")
71
+ if gap <= 0:
72
+ return None
73
+ billable_gap = (_number(grand.get("billableTokens")) or 0) - _sum(rows, "billableTokens")
74
+ cost_gap = (_number(grand.get("costUsd")) or 0) - _sum(rows, "costUsd")
75
+ return {
76
+ "rawTokens": format_int(gap),
77
+ "billableTokens": format_int(max(0, billable_gap)),
78
+ "cost": format_usd(max(0.0, cost_gap)),
79
+ }
80
+
81
+
82
+ _MEASURED_KEYS = ("totalTokens", "billableTokens", "costUsd", "durationMs")
83
+
84
+
85
+ def run_usage(data: dict) -> dict[str, object] | None:
86
+ """The run-cost table, or nothing when the run has no measured figure.
87
+
88
+ Phase 7 fills these cells before the HTML is rendered, so an all-null table
89
+ means the collector found no session to read. A grid of `--` states nothing
90
+ the reader can act on, so the section stays out of the document entirely.
91
+ """
92
+ rows = [row for row in (data.get("executionStatus") or []) if isinstance(row, dict)]
93
+ usage = data.get("tokenUsage") or {}
94
+ totals = {name: _totals_row(usage.get(name)) for name in ("lead", "worker", "grand")}
95
+ measured = any(
96
+ _number(row.get(key)) is not None for row in rows for key in _MEASURED_KEYS
97
+ ) or any(
98
+ _number((usage.get(name) or {}).get(key)) is not None
99
+ for name in ("lead", "worker", "grand")
100
+ for key in _MEASURED_KEYS
101
+ )
102
+ if not measured:
103
+ return None
104
+ cli_cost = _number((usage.get("cli") or {}).get("costUsd")) or 0
105
+ return {
106
+ "rows": [_agent_row(row) for row in rows],
107
+ "unaccounted": _unaccounted(rows, usage.get("grand") or {}),
108
+ **totals,
109
+ "cliCost": format_usd(cli_cost) if cli_cost else "",
110
+ }
@@ -1,32 +1,85 @@
1
1
  """Human-first error-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
+ import re
5
+
4
6
  from ..common import evidence_index
5
7
  from ..models import HumanReportView, VisualEdge, VisualNode
6
8
  from ..visualizations import cause_graph_figure
7
9
 
8
10
 
9
- def _cause_figure(error: dict):
10
- symptom = VisualNode(
11
- "symptom", "Observed failure", "effect", "risk", error["observableFailure"], note="Observed failure"
11
+ _LEAD_CLAUSE = re.compile(r"\s[—–-]\s|(?<=[.。!?])\s")
12
+ _MAX_LABEL_CHARS = 44
13
+
14
+
15
+ def _short_label(statement: str) -> str:
16
+ """The candidate's name, taken from the opening clause of its statement.
17
+
18
+ `causeCandidates` carries no title field: `statement` is the full claim,
19
+ routinely several sentences long. Passed through whole it filled the
20
+ figure's name cell with prose and ran the node text out of the drawing, so
21
+ the figure takes the lead clause and leaves the claim to the cards below.
22
+ """
23
+ head = _LEAD_CLAUSE.split(statement.strip(), maxsplit=1)[0].strip()
24
+ if len(head) <= _MAX_LABEL_CHARS:
25
+ return head
26
+ return head[:_MAX_LABEL_CHARS].rstrip() + "…"
27
+
28
+
29
+ def _cause_node(row: dict, leading_cause_id: str) -> VisualNode:
30
+ # The figure names its nodes and nothing more. Both the failure sentence
31
+ # and each candidate's disproof already have a section of their own, and
32
+ # repeating them here printed every one of them twice.
33
+ leading = row["id"] == leading_cause_id
34
+ return VisualNode(
35
+ row["id"],
36
+ _short_label(row["statement"]),
37
+ "candidate",
38
+ "leading" if leading else row["confidence"],
39
+ "",
40
+ note="Leading cause" if leading else f'Confidence {row["confidence"]}',
12
41
  )
13
- causes = tuple(
14
- VisualNode(
15
- row["id"],
16
- row["statement"],
17
- "candidate",
18
- row["confidence"],
19
- row["disproveWith"],
20
- note=f'Confidence {row["confidence"]}',
21
- )
22
- for row in error.get("causeCandidates", [])
42
+
43
+
44
+ def _cause_edges(rows: list[dict], symptom_id: str) -> tuple[VisualEdge, ...]:
45
+ """The chain between candidates, then the symptom each chain ends at.
46
+
47
+ Candidates are not always competing guesses for one spot. A propagation
48
+ chain — the extraction breaks, the error is swallowed, the empty result is
49
+ stamped a success — needs every link to hold for the symptom to appear, and
50
+ `downstreamOf` is where the diagnosis says so. Drawing every candidate
51
+ straight at the symptom instead claimed they were alternatives.
52
+
53
+ Only a candidate nothing else is downstream of reaches the symptom: an
54
+ upstream link would otherwise be drawn as its own explanation of the
55
+ symptom as well as a step on the way there.
56
+ """
57
+ known = {row["id"] for row in rows}
58
+ upstream = {
59
+ row["id"]: [step for step in row.get("downstreamOf", []) if step in known]
60
+ for row in rows
61
+ }
62
+ has_downstream = {step for steps in upstream.values() for step in steps}
63
+ chain = tuple(
64
+ VisualEdge(step, row["id"], "then", "chain")
65
+ for row in rows
66
+ for step in upstream[row["id"]]
23
67
  )
24
- edges = tuple(
25
- VisualEdge(row.id, symptom.id, "may cause", "hypothesis") for row in causes
68
+ return chain + tuple(
69
+ VisualEdge(row["id"], symptom_id, "may cause", "hypothesis")
70
+ for row in rows
71
+ if row["id"] not in has_downstream
26
72
  )
73
+
74
+
75
+ def _cause_figure(error: dict):
76
+ symptom = VisualNode("symptom", "Observed failure", "effect", "risk", "", note="Symptom")
77
+ rows = error.get("causeCandidates", [])
78
+ leading_cause_id = (error.get("routing") or {}).get("leadingCauseId") or ""
79
+ causes = tuple(_cause_node(row, leading_cause_id) for row in rows)
27
80
  return cause_graph_figure(
28
81
  nodes=(symptom, *causes),
29
- edges=edges,
82
+ edges=_cause_edges(rows, symptom.id),
30
83
  title="Cause hypotheses and observed symptom",
31
84
  )
32
85
 
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import html
5
+ import unicodedata
5
6
  from dataclasses import replace
6
7
  from typing import Iterable, Sequence
7
8
 
@@ -11,6 +12,11 @@ from .models import FigureModel, VisualEdge, VisualNode
11
12
  _ROWS_PER_COLUMN = 8
12
13
  _COLUMN_WIDTH = 260
13
14
  _ROW_HEIGHT = 100
15
+ _NODE_WIDTH = 180
16
+ _NODE_MIN_HEIGHT = 50
17
+ _LABEL_PADDING = 12
18
+ _LINE_HEIGHT = 16
19
+ _MAX_LABEL_LINES = 3
14
20
 
15
21
 
16
22
  def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
@@ -141,10 +147,103 @@ _ARROW_MARKER = (
141
147
  )
142
148
 
143
149
 
150
+ def _drawable_text(value: str) -> str:
151
+ """Strip the markdown code fences a drawing cannot render.
152
+
153
+ Labels carry backticks around identifiers because every other text surface
154
+ turns them into `<code>`. Drawn literally they are stray characters inside
155
+ the box, and they consume width the label needs.
156
+ """
157
+ return value.replace("`", "")
158
+
159
+
160
+ def _text_width(text: str) -> float:
161
+ """Approximate the advance width of the 13px label font.
162
+
163
+ An SVG carries no font metrics, so wrapping has to estimate. East-Asian
164
+ characters occupy a full em; the rest average a little over half of one.
165
+ """
166
+ return sum(13.0 if unicodedata.east_asian_width(c) in ("W", "F") else 6.8 for c in text)
167
+
168
+
169
+ def _split_oversized_word(word: str, limit: float) -> list[str]:
170
+ """Cut a word wider than the box into box-width pieces.
171
+
172
+ Korean and Japanese labels arrive as long unbroken runs, and a path or an
173
+ identifier can be wider than the box on its own.
174
+ """
175
+ pieces: list[str] = []
176
+ current = ""
177
+ for char in word:
178
+ if current and _text_width(current + char) > limit:
179
+ pieces.append(current)
180
+ current = char
181
+ else:
182
+ current += char
183
+ return pieces + [current] if current else pieces
184
+
185
+
186
+ def _ellipsize(line: str, limit: float) -> str:
187
+ while line and _text_width(line + "…") > limit:
188
+ line = line[:-1]
189
+ return line + "…"
190
+
191
+
192
+ def _wrap_label(label: str) -> list[str]:
193
+ """Break a label into the lines that fit inside one node box.
194
+
195
+ The label used to be drawn as a single line whatever its length, so a node
196
+ carrying a sentence painted it straight out of the box and off the canvas.
197
+ Text past the last line is cut here rather than drawn, because the
198
+ figure's fallback table prints the label in full.
199
+ """
200
+ limit = _NODE_WIDTH - 2 * _LABEL_PADDING
201
+ lines: list[str] = []
202
+ current = ""
203
+ for word in label.split():
204
+ pieces = _split_oversized_word(word, limit) if _text_width(word) > limit else [word]
205
+ for piece in pieces:
206
+ candidate = f"{current} {piece}".strip()
207
+ if current and _text_width(candidate) > limit:
208
+ lines.append(current)
209
+ current = piece
210
+ else:
211
+ current = candidate
212
+ if current:
213
+ lines.append(current)
214
+ if len(lines) > _MAX_LABEL_LINES:
215
+ lines = lines[:_MAX_LABEL_LINES]
216
+ lines[-1] = _ellipsize(lines[-1], limit)
217
+ return lines or [""]
218
+
219
+
220
+ def _node_group(node: VisualNode, x: int, y: int, lines: Sequence[str], box_height: int) -> str:
221
+ baseline = y + (box_height - len(lines) * _LINE_HEIGHT) // 2 + _LINE_HEIGHT - 4
222
+ spans = "".join(
223
+ f'<tspan x="{x + _LABEL_PADDING}" y="{baseline + index * _LINE_HEIGHT}">'
224
+ f"{html.escape(line)}</tspan>"
225
+ for index, line in enumerate(lines)
226
+ )
227
+ status = html.escape(node.status)
228
+ detail = _drawable_text(node.detail)
229
+ tooltip = f"{_drawable_text(node.label)}: {node.status}." + (f" {detail}" if detail else "")
230
+ return (
231
+ f'<g data-node-id="{html.escape(node.id)}" class="node node-{status}">'
232
+ f"<title>{html.escape(tooltip)}</title>"
233
+ f'<rect x="{x}" y="{y}" width="{_NODE_WIDTH}" height="{box_height}" rx="8"/>'
234
+ f"<text>{spans}</text></g>"
235
+ )
236
+
237
+
144
238
  def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
145
239
  positions = _node_positions(nodes, edges)
146
- width = max((x for x, _ in positions.values()), default=50) + 230
147
- height = max((y for _, y in positions.values()), default=55) + 90
240
+ wrapped = {node.id: _wrap_label(_drawable_text(node.label)) for node in nodes}
241
+ line_count = max((len(lines) for lines in wrapped.values()), default=1)
242
+ # One height for every box: a figure whose rows are all the same depth
243
+ # keeps the arrow between two columns horizontal.
244
+ box_height = max(_NODE_MIN_HEIGHT, line_count * _LINE_HEIGHT + 2 * _LABEL_PADDING)
245
+ width = max((x for x, _ in positions.values()), default=50) + _NODE_WIDTH + 50
246
+ height = max((y for _, y in positions.values()), default=55) + box_height + 40
148
247
  parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
149
248
  parts.append(_ARROW_MARKER)
150
249
  for edge in edges:
@@ -154,8 +253,11 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
154
253
  x2, y2 = positions[edge.target]
155
254
  # Leave the source box on its right edge and arrive on the target's
156
255
  # left, so a left-to-right layering reads as one direction of travel.
157
- start_x, end_x = (x1 + 180, x2) if x2 > x1 else ((x1, x2 + 180) if x2 < x1 else (x1 + 90, x2 + 90))
158
- start_y, end_y = y1 + 25, y2 + 25
256
+ forward = (x1 + _NODE_WIDTH, x2)
257
+ backward = (x1, x2 + _NODE_WIDTH)
258
+ same_column = (x1 + _NODE_WIDTH // 2, x2 + _NODE_WIDTH // 2)
259
+ start_x, end_x = forward if x2 > x1 else (backward if x2 < x1 else same_column)
260
+ start_y, end_y = y1 + box_height // 2, y2 + box_height // 2
159
261
  span = abs(x2 - x1) // _COLUMN_WIDTH
160
262
  if span > 1:
161
263
  # An edge that skips a column would otherwise be drawn straight
@@ -177,16 +279,7 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
177
279
  )
178
280
  for node in nodes:
179
281
  x, y = positions[node.id]
180
- node_id = html.escape(node.id)
181
- label = html.escape(node.label)
182
- status = html.escape(node.status)
183
- detail = html.escape(node.detail)
184
- parts.append(
185
- f'<g data-node-id="{node_id}" class="node node-{status}">'
186
- f"<title>{label}: {status}. {detail}</title>"
187
- f'<rect x="{x}" y="{y}" width="180" height="50" rx="8"/>'
188
- f'<text x="{x + 12}" y="{y + 30}">{label}</text></g>'
189
- )
282
+ parts.append(_node_group(node, x, y, wrapped[node.id], box_height))
190
283
  parts.append("</svg>")
191
284
  return "".join(parts)
192
285
 
@@ -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