okstra 0.149.0 → 0.151.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 (57) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +2 -2
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +3 -2
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +8 -0
  7. package/runtime/agents/workers/translator-worker.md +67 -0
  8. package/runtime/bin/okstra-render-final-report.py +0 -11
  9. package/runtime/bin/okstra-report-translate.py +191 -0
  10. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  12. package/runtime/prompts/lead/plan-body-verification.md +1 -1
  13. package/runtime/prompts/lead/report-writer.md +16 -15
  14. package/runtime/prompts/lead/team-contract.md +2 -2
  15. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  16. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  17. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  18. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  19. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  20. package/runtime/python/okstra_ctl/convergence.py +15 -1
  21. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  22. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  23. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  24. package/runtime/python/okstra_ctl/i18n.py +12 -7
  25. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  26. package/runtime/python/okstra_ctl/report_finalize.py +18 -0
  27. package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
  28. package/runtime/python/okstra_ctl/report_html/render.py +44 -2
  29. package/runtime/python/okstra_ctl/report_translation.py +469 -0
  30. package/runtime/python/okstra_ctl/report_views.py +23 -9
  31. package/runtime/python/okstra_ctl/run.py +1 -1
  32. package/runtime/python/okstra_ctl/user_response.py +11 -6
  33. package/runtime/python/okstra_ctl/wizard.py +100 -25
  34. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  35. package/runtime/templates/reports/html/base.template.html +12 -12
  36. package/runtime/templates/reports/html/i18n/en.json +395 -0
  37. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  38. package/runtime/templates/reports/html/macros/forms.html +16 -16
  39. package/runtime/templates/reports/html/macros/visualizations.html +2 -2
  40. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
  41. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  42. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
  43. package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
  44. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
  45. package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
  46. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
  47. package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
  48. package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
  49. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
  50. package/runtime/templates/reports/report.js +8 -5
  51. package/runtime/validators/validate-report-views.py +1 -1
  52. package/runtime/validators/validate-run.py +59 -31
  53. package/src/cli-registry.mjs +11 -0
  54. package/src/commands/inspect/worker-liveness.mjs +9 -7
  55. package/src/commands/report/translate.mjs +31 -0
  56. package/src/lib/helper-scripts.mjs +1 -0
  57. package/runtime/templates/reports/i18n/ko.json +0 -273
@@ -454,7 +454,7 @@ def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
454
454
  return "\n".join(lines)
455
455
 
456
456
 
457
- def inject_index_into_file(md_path: Path, *, report_language: str = "en") -> int:
457
+ def inject_index_into_file(md_path: Path) -> int:
458
458
  """Apply the legacy top-of-report index to an existing Markdown report.
459
459
 
460
460
  This remains for schema-v1 and quick compatibility artifacts. Schema-v2
@@ -463,12 +463,8 @@ def inject_index_into_file(md_path: Path, *, report_language: str = "en") -> int
463
463
  """
464
464
  if not md_path.is_file():
465
465
  raise FinalReportRenderError(f"report markdown not found: {md_path}")
466
- if report_language not in SUPPORTED_LANGS:
467
- raise FinalReportRenderError(
468
- f"report_language must be one of {SUPPORTED_LANGS}, got {report_language!r}"
469
- )
470
466
  try:
471
- dictionary = load_dictionary(report_language)
467
+ dictionary = load_dictionary(MARKDOWN_LANG)
472
468
  except I18nError as exc:
473
469
  raise FinalReportRenderError(str(exc)) from exc
474
470
  injected = _inject_index_and_anchors(md_path.read_text(encoding="utf-8"), dictionary)
@@ -630,12 +626,20 @@ def _ai_markdown_context(data: dict) -> dict:
630
626
  return context
631
627
 
632
628
 
633
- def resolve_report_language(data: dict, *, override: str | None) -> str:
634
- """우선순위: override > data.meta.reportLanguage > 'en'."""
635
- if override is not None:
636
- candidate = override
637
- else:
638
- candidate = (data.get("meta") or {}).get("reportLanguage") or "en"
629
+ # The Markdown is the AI handoff sibling of the data.json — same content, same
630
+ # audience, so it renders in the SSOT's language and nothing else. The field
631
+ # still has to be well-formed here because it decides whether Phase 7 pays for
632
+ # a translator, and a malformed one would surface at the very end of the run.
633
+ MARKDOWN_LANG = "en"
634
+
635
+
636
+ def validate_report_language(data: dict) -> str:
637
+ """Check `meta.reportLanguage` and return the Markdown's own language.
638
+
639
+ The value names the language the *human HTML* renders in; it never
640
+ selects this renderer's dictionary.
641
+ """
642
+ candidate = (data.get("meta") or {}).get("reportLanguage") or "en"
639
643
  if candidate == "auto":
640
644
  raise FinalReportRenderError(
641
645
  "reportLanguage 'auto' must be resolved by the lead before "
@@ -646,7 +650,7 @@ def resolve_report_language(data: dict, *, override: str | None) -> str:
646
650
  raise FinalReportRenderError(
647
651
  f"reportLanguage must be one of {SUPPORTED_LANGS}, got {candidate!r}"
648
652
  )
649
- return candidate
653
+ return MARKDOWN_LANG
650
654
 
651
655
 
652
656
  # Schema-optional top-level arrays, and the value a template may assume when
@@ -685,7 +689,6 @@ def render(
685
689
  data: dict,
686
690
  *,
687
691
  template_path: Path,
688
- report_language: str | None = None,
689
692
  ) -> str:
690
693
  """Render ``data`` through the Jinja2 ``template_path`` and return the
691
694
  final markdown as a string. Caller writes it to disk.
@@ -699,7 +702,7 @@ def render(
699
702
 
700
703
  _enforce_schema(data)
701
704
 
702
- lang = resolve_report_language(data, override=report_language)
705
+ lang = validate_report_language(data)
703
706
  try:
704
707
  dictionary = load_dictionary(lang)
705
708
  except I18nError as exc:
@@ -834,7 +837,6 @@ def render_to_file(
834
837
  output_path: Path,
835
838
  *,
836
839
  template_path: Path | None = None,
837
- report_language: str | None = None,
838
840
  ) -> int:
839
841
  """Read ``data_path`` (JSON), render through Jinja2, write to
840
842
  ``output_path``. Returns the number of bytes written.
@@ -859,7 +861,6 @@ def render_to_file(
859
861
  rendered = render(
860
862
  data,
861
863
  template_path=resolved_template,
862
- report_language=report_language,
863
864
  )
864
865
  except FinalReportRenderError as exc:
865
866
  raise _with_excerpt_drift_hint(exc, data_path) from exc
@@ -24,12 +24,17 @@ from .final_report_paths import final_report_data_path, final_report_markdown_pa
24
24
  from .paths import task_dir, task_manifest_file
25
25
 
26
26
 
27
+ STEP_CHECK_SOURCE = "check-source"
27
28
  STEP_TOKEN_USAGE = "token-usage"
28
29
  STEP_RENDER_VIEWS = "render-views"
29
30
  STEP_SPAWN_FOLLOWUPS = "spawn-followups"
30
31
  STEP_VALIDATE_RUN = "validate-run"
31
32
 
32
33
  STEP_ORDER = (
34
+ # First, because everything after it derives from the data.json: rendering
35
+ # a Korean SSOT into English chrome, spawning follow-ups from it, and
36
+ # validating it all succeed on a record the next phase cannot read.
37
+ STEP_CHECK_SOURCE,
33
38
  STEP_TOKEN_USAGE,
34
39
  STEP_RENDER_VIEWS,
35
40
  STEP_SPAWN_FOLLOWUPS,
@@ -205,6 +210,19 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
205
210
  """Assemble the ordered Phase 7 argv list. Order is contractual."""
206
211
  markdown_path = ctx.markdown_path
207
212
  return [
213
+ (
214
+ STEP_CHECK_SOURCE,
215
+ [
216
+ sys.executable,
217
+ str(
218
+ resolve_workspace_script(
219
+ ctx.workspace_root, "okstra-report-translate.py"
220
+ )
221
+ ),
222
+ "check-source",
223
+ str(ctx.data_path),
224
+ ],
225
+ ),
208
226
  (
209
227
  STEP_TOKEN_USAGE,
210
228
  [
@@ -97,90 +97,28 @@ def evidence_refs(refs: object, anchors: object) -> Markup:
97
97
 
98
98
  # Schema enums a reader has to decode. Each vocabulary is named so a value can
99
99
  # mean different things in different fields without one table flattening them.
100
- # A value with no entry renders as itself — a new enum member shows up raw
101
- # rather than silently reading as something it is not.
102
- ENUM_LABELS: dict[str, dict[str, str]] = {
103
- "coverage": {
104
- "covered": "A break fails a test",
105
- "partial": "Partly covered",
106
- "gap": "No test",
107
- "risk": "Passes without proving",
108
- },
109
- "effort": {"S": "Small", "M": "Medium", "L": "Large", "XL": "Very large"},
110
- "versionSource": {
111
- "manifest-range": "Manifest range",
112
- "lockfile-pinned": "Lockfile pin",
113
- "container-image": "Container image",
114
- "runtime-config": "Runtime config",
115
- "declared-doc": "Declared in docs",
116
- },
117
- "interfaceKind": {
118
- "port": "Port",
119
- "adapter": "Adapter",
120
- "service-api": "Service API",
121
- "module-export": "Module export",
122
- "event": "Event",
123
- },
124
- "toolKind": {
125
- "packageManager": "Package manager",
126
- "build": "Build",
127
- "test": "Test",
128
- "lint": "Lint",
129
- "typecheck": "Type check",
130
- "format": "Format",
131
- "ci": "CI",
132
- "container": "Container",
133
- },
134
- "lens": {"candidate-found": "Candidate found", "no-candidate": "Nothing found"},
135
- "rejectionSource": {
136
- "brief-desired-outcome": "Brief · desired outcome",
137
- "brief-out-of-scope": "Brief · out of scope",
138
- "brief-source-material": "Brief · source material",
139
- "clarification-raised": "Raised as a question",
140
- "absent-not-material": "Absent · does not change the call",
141
- },
142
- "termSource": {
143
- "glossary": "Glossary",
144
- "decision-record": "Decision record",
145
- "brief": "Brief",
146
- "clarification": "Your answer",
147
- },
148
- "handoffMode": {"whole-task": "Whole task", "stage-group": "Selected stages"},
149
- }
150
-
151
-
152
- # What each member of a vocabulary actually claims. A two-word label fits a
153
- # table cell but cannot carry a definition, and a reader meeting "Passes
154
- # without proving" for the first time has nowhere to look it up. Only
155
- # vocabularies whose members are judgements need one; a kind names itself.
156
- ENUM_HINTS: dict[str, dict[str, str]] = {
157
- "coverage": {
158
- "covered": "Break this area and a test fails.",
159
- "partial": "Tests exist, but part of the area runs through none of them.",
160
- "gap": "No test runs through this area at all.",
161
- "risk": (
162
- "Tests run and pass, but they skip or assert nothing, so passing "
163
- "is not evidence that the area works. More dangerous than "
164
- "'No test', because the suite reports success."
165
- ),
166
- },
167
- }
168
-
169
-
170
- def enum_label(value: object, vocabulary: str) -> str:
100
+ # The tables live in `templates/reports/html/i18n/<lang>.json` beside the rest
101
+ # of the page's fixed strings a label is chrome, not data, so it follows the
102
+ # reader's language rather than the SSOT's. A value with no entry renders as
103
+ # itself: a new enum member shows up raw rather than silently reading as
104
+ # something it is not.
105
+ def enum_label(value: object, vocabulary: str, chrome: dict) -> str:
171
106
  """Render a schema enum as the words it stands for."""
172
107
  raw = "" if value is None else str(value)
173
- return ENUM_LABELS.get(vocabulary, {}).get(raw, raw)
108
+ return (chrome.get("enum") or {}).get(vocabulary, {}).get(raw, raw)
174
109
 
175
110
 
176
- def enum_legend(vocabulary: str) -> list[tuple[str, str]]:
111
+ def enum_legend(vocabulary: str, chrome: dict) -> list[tuple[str, str]]:
177
112
  """Pair each label with what it claims, for a legend beside the table.
178
113
 
179
- Built from the same two tables the cells render from, so a legend cannot
180
- drift from the labels it explains.
114
+ A two-word label fits a table cell but cannot carry a definition, and a
115
+ reader meeting "Passes without proving" for the first time has nowhere to
116
+ look it up. Built from the same dictionary the cells render from, so a
117
+ legend cannot drift from the labels it explains.
181
118
  """
182
- labels = ENUM_LABELS.get(vocabulary, {})
183
- return [(labels.get(value, value), hint) for value, hint in ENUM_HINTS.get(vocabulary, {}).items()]
119
+ labels = (chrome.get("enum") or {}).get(vocabulary, {})
120
+ hints = (chrome.get("enumHint") or {}).get(vocabulary, {})
121
+ return [(labels.get(value, value), hint) for value, hint in hints.items()]
184
122
 
185
123
 
186
124
  def code_evidence(rows: object) -> Markup:
@@ -3,12 +3,16 @@ from __future__ import annotations
3
3
 
4
4
  import hashlib
5
5
  import json
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import okstra_vendor # noqa: F401 # registers vendored dependency aliases
9
10
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
10
11
 
12
+ from ..final_report_paths import translation_sidecar_path
11
13
  from ..final_report_schema import load_schema_for_data, validate
14
+ from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
15
+ from ..report_translation import overlay
12
16
  from ..report_view_artifacts import user_responses_dir_for_report
13
17
  from .common import anchor_index
14
18
  from .filters import (
@@ -64,6 +68,39 @@ def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
64
68
  }
65
69
 
66
70
 
71
+ def _localize(data: dict, data_path: Path) -> tuple[dict, str]:
72
+ """Apply the translation sidecar for this report's language, if there is one.
73
+
74
+ The data.json is the English SSOT and is never rewritten — the sidecar is
75
+ read here and overlaid onto an in-memory copy, so only this HTML document
76
+ speaks the reader's language. A pointer the sidecar left untranslated
77
+ renders in English; that keeps a half-finished translation readable, and
78
+ the counts are printed because a silently half-empty sidecar otherwise
79
+ ships looking finished.
80
+ """
81
+ lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
82
+ if lang == "en":
83
+ return data, lang
84
+ sidecar_file = translation_sidecar_path(data_path, lang)
85
+ if not sidecar_file.is_file():
86
+ sys.stdout.write(
87
+ f"note: no {lang} translation sidecar at {sidecar_file.name}; "
88
+ "rendering the English source\n"
89
+ )
90
+ return data, lang
91
+ payload = json.loads(sidecar_file.read_text(encoding="utf-8"))
92
+ strings = payload.get("strings")
93
+ if not isinstance(strings, dict):
94
+ raise HtmlRenderError(f"translation sidecar has no 'strings' object: {sidecar_file}")
95
+ localized, report = overlay(data, strings)
96
+ sys.stdout.write(
97
+ f"translated {report.applied} string(s) into {lang}"
98
+ f" ({len(report.untranslated)} left in English"
99
+ f", {len(report.unresolved)} unresolved)\n"
100
+ )
101
+ return localized, lang
102
+
103
+
67
104
  def _html_path(data_path: Path) -> Path:
68
105
  suffix = ".data.json"
69
106
  if not data_path.name.endswith(suffix):
@@ -84,6 +121,9 @@ def render_v2_html_view(
84
121
  raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
85
122
  if not markdown_path.is_file():
86
123
  raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
124
+ # Validate the SSOT, then localize — the sidecar carries presentation and
125
+ # has no say in whether the report is well-formed.
126
+ data, lang = _localize(data, data_path)
87
127
  route = resolve_html_route(run_meta.task_type)
88
128
  view = route.view_builder(data)
89
129
  root = _templates_root(templates_root)
@@ -92,9 +132,11 @@ def render_v2_html_view(
92
132
  # Binding the index here is what lets a template cite an id without
93
133
  # threading the index through every macro and call site.
94
134
  anchors = anchor_index(data)
135
+ chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
136
+ env.globals["t"] = make_jinja_global(chrome)
95
137
  env.filters["code_evidence"] = code_evidence
96
- env.filters["enum_label"] = enum_label
97
- env.filters["enum_legend"] = enum_legend
138
+ env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
139
+ env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
98
140
  env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
99
141
  env.filters["inline_code"] = lambda value: inline_code(value, anchors)
100
142
  env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)