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
@@ -299,17 +299,39 @@ def _top_level_bullet_heading(line: str) -> str:
299
299
  return label
300
300
 
301
301
 
302
+ # Report headings carry a section number and the renderer's scroll anchor
303
+ # (`## 1. Clarification Items <a id="1-clarification-items"></a>`), while the
304
+ # section names above are written bare. Keying a heading by both spellings is
305
+ # what lets a lookup for `Clarification Items` find it — without the alias the
306
+ # Carry-In Extract rendered `No matching source sections were available` for
307
+ # every carry-in ever staged. No section name starts with a digit, so the
308
+ # stripped alias cannot collide with a name meant to be matched literally.
309
+ _HEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
310
+ _HEADING_ANCHOR_RE = re.compile(r'\s*<a id="[^"]*"></a>\s*$')
311
+
312
+
313
+ def _heading_alias(heading: str) -> str:
314
+ return _HEADING_NUMBER_RE.sub("", _HEADING_ANCHOR_RE.sub("", heading)).strip()
315
+
316
+
302
317
  def _section_map(text: str) -> dict[str, str]:
303
318
  result: dict[str, list[str]] = {}
319
+ aliases: dict[str, str] = {}
304
320
  current = ""
305
321
  for line in _strip_frontmatter(text).splitlines():
306
322
  if line.startswith("## "):
307
323
  current = line[3:].strip()
308
324
  result.setdefault(current, [])
325
+ alias = _heading_alias(current)
326
+ if alias and alias != current:
327
+ aliases.setdefault(alias, current)
309
328
  continue
310
329
  if current:
311
330
  result[current].append(line)
312
- return {key: "\n".join(lines).strip() for key, lines in result.items()}
331
+ sections = {key: "\n".join(lines).strip() for key, lines in result.items()}
332
+ for alias, heading in aliases.items():
333
+ sections.setdefault(alias, sections[heading])
334
+ return sections
313
335
 
314
336
 
315
337
  def _strip_frontmatter(text: str) -> str:
@@ -1,32 +1,42 @@
1
- """Parse the ``## 1. Clarification Items`` table from a final-report markdown.
2
-
3
- The unified §1 table (introduced when §4.5.9 / §5.1 / §5.2 collapsed into a
4
- single section) is the canonical home for every clarification an
5
- implementation-planning run owes the user decisions, file attachments,
6
- single data points. Each row carries a ``Blocks`` column whose value picks
7
- one of ``{approval, next-phase, none}``. Rows with ``Blocks=approval`` are
8
- the approval gate: they MUST resolve before the user flips the frontmatter
1
+ """Read a final-report's clarification rows, whichever schema wrote them.
2
+
3
+ A clarification is what a run owes the user a decision, a file attachment,
4
+ a single data point. Each row carries a ``Blocks`` value out of
5
+ ``{approval, next-phase, none}``. Rows with ``Blocks=approval`` are the
6
+ approval gate: they MUST resolve before the user flips the frontmatter
9
7
  ``approved`` field to ``true`` and starts the next ``implementation`` run.
10
8
 
11
- This module exposes one read function for that gate
12
- (``scan_approval_gate``) so both ``_validate_approved_plan``
13
- (pre-implementation run-prep) and the wizard share the same parsing logic.
9
+ The two schemas store those rows in different places, and that is why the
10
+ read functions take a report **path**, not its text:
11
+
12
+ * schema-v1 — the ``## 1. Clarification Items`` markdown table (introduced
13
+ when §4.5.9 / §5.1 / §5.2 collapsed into a single section).
14
+ * schema-v2 — ``clarificationItems[]`` in the ``.data.json`` sibling. Its AI
15
+ handoff markdown serialises them as a JSON block under
16
+ ``## Clarification and User Decisions``, so the §1 table walk finds nothing
17
+ there by construction.
14
18
 
15
- Gate semantics are fail-closed: when the §1 schema cannot be read with
16
- confidence (heading missing/drifted, table header unrecognized, or any
17
- body row whose metadata cell fails to parse), the scan reports an
18
- ``unreadable_reason`` and callers must refuse approval instead of
19
+ Every gate goes through ``scan_approval_gate`` / ``scan_open_user_input`` so
20
+ run-prep (``_validate_approved_plan``), the wizard, and the user-response CLI
21
+ cannot disagree about what is still open.
22
+
23
+ Gate semantics are fail-closed: when the rows cannot be read with confidence
24
+ (§1 heading missing/drifted, table header unrecognized, a body row whose
25
+ metadata cell fails to parse, or a v2 row missing id/blocks/status), the scan
26
+ reports an ``unreadable_reason`` and callers must refuse approval instead of
19
27
  soft-passing. ``parse_clarification_items`` keeps the lenient
20
- None-on-absence contract for the HTML-view renderers, which only need
21
- best-effort row extraction.
28
+ None-on-absence contract for the schema-v1 HTML-view renderers, which only
29
+ need best-effort row extraction.
22
30
  """
23
31
  from __future__ import annotations
24
32
 
33
+ import json
25
34
  import re
26
35
  from dataclasses import dataclass
27
36
  from pathlib import Path
28
37
  from typing import Optional
29
38
 
39
+ from okstra_ctl.final_report_paths import final_report_data_path
30
40
  from okstra_ctl.md_table import is_separator_row, split_pipe_row, to_cell_text
31
41
 
32
42
 
@@ -208,12 +218,100 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
208
218
  return _walk_section_1_table(section).items
209
219
 
210
220
 
211
- def parse_clarification_rows(report_text: str) -> list[dict]:
212
- """§1 행마다 메타(ClarificationItem) + 원문 Statement / Expected form 셀.
221
+ # schema-v2 clarification 을 §1 마크다운 테이블이 아니라 data.json 의
222
+ # `clarificationItems[]` 들고, AI 핸드오프 Markdown 그것을
223
+ # `## Clarification and User Decisions` 아래 JSON 블록으로 직렬화한다. §1 테이블
224
+ # 워크는 거기서 아무것도 못 찾으므로, 게이트가 v1 파서만 쓰면 열린 항목이 있는
225
+ # v2 리포트를 "읽을 수 없음"으로 떨어뜨린다. data.json 은 두 렌더러가 이미 읽는
226
+ # SSOT 다 — clarification 상태도 여기서 읽는다.
227
+ SCHEMA_VERSION_V2 = "2.0"
228
+
213
229
 
214
- ``parse_clarification_items`` 같은 lenient 계약: §1 부재 또는 헤더
215
- 미인식이면 ``[]``. §1 테이블 셀 추출의 단일 참조점 — 다른 모듈이 §1
216
- 레이아웃을 다시 훑지 않도록 이 함수로 통일한다.
230
+ def _read_report_text(report_path: Path) -> str:
231
+ return report_path.read_text(encoding="utf-8", errors="replace")
232
+
233
+
234
+ def _v2_report_data(report_path: Path) -> Optional[dict]:
235
+ """``report_path`` 의 schema-v2 data 사이드카, 아니면 ``None``.
236
+
237
+ 파일이 없거나 JSON 이 깨졌거나 v2 가 아니면 ``None`` — 호출자는 v1
238
+ 마크다운 경로로 폴백하고, 진짜 v2 인데 사이드카가 깨진 경우는 그 폴백이
239
+ "§1 없음" fail-closed 로 잡는다.
240
+ """
241
+ data_path = final_report_data_path(report_path)
242
+ if not data_path.is_file():
243
+ return None
244
+ try:
245
+ data = json.loads(data_path.read_text(encoding="utf-8"))
246
+ except (OSError, ValueError):
247
+ return None
248
+ if not isinstance(data, dict) or data.get("schemaVersion") != SCHEMA_VERSION_V2:
249
+ return None
250
+ return data
251
+
252
+
253
+ def _v2_row(entry: dict) -> Optional[dict]:
254
+ """``clarificationItems[]`` 한 행을 §1 행과 같은 shape 으로. 필수 필드가
255
+ 빠졌으면 ``None`` — 호출자가 fail-closed 로 셀 수 있게 한다."""
256
+ row_id = entry.get("id")
257
+ raw_blocks = entry.get("blocks")
258
+ raw_status = entry.get("status")
259
+ if not (isinstance(row_id, str) and row_id):
260
+ return None
261
+ if not (isinstance(raw_blocks, str) and raw_blocks):
262
+ return None
263
+ if not (isinstance(raw_status, str) and raw_status):
264
+ return None
265
+ kind = entry.get("kind")
266
+ item = ClarificationItem(
267
+ row_id=row_id,
268
+ kind=kind.lower() if isinstance(kind, str) else "",
269
+ blocks=raw_blocks.lower(),
270
+ status=raw_status.lower(),
271
+ raw_blocks=raw_blocks,
272
+ raw_status=raw_status,
273
+ )
274
+ return {
275
+ "item": item,
276
+ "statement": str(entry.get("statement") or ""),
277
+ "expected_form": str(entry.get("expectedForm") or ""),
278
+ }
279
+
280
+
281
+ def _v2_clarification_rows(report_path: Path) -> Optional[list[dict]]:
282
+ """schema-v2 clarification 행들, 이 리포트가 v2 가 아니면 ``None``.
283
+ 필수 필드가 빠진 행은 건너뛰는 lenient 계약(§1 파서와 동일)."""
284
+ data = _v2_report_data(report_path)
285
+ if data is None:
286
+ return None
287
+ entries = data.get("clarificationItems")
288
+ if not isinstance(entries, list):
289
+ return []
290
+ rows = [_v2_row(e) for e in entries if isinstance(e, dict)]
291
+ return [row for row in rows if row is not None]
292
+
293
+
294
+ def read_clarification_rows(report_path: Path) -> list[dict]:
295
+ """리포트 한 건의 clarification 행 — schema-v2 는 data.json 사이드카에서,
296
+ schema-v1 은 §1 테이블에서. 스키마 버전을 아는 유일한 읽기 지점이다.
297
+
298
+ ``parse_section_1_rows`` 와 같은 lenient 계약: 어느 쪽에서도 행을 못 찾으면
299
+ ``[]``.
300
+ """
301
+ v2_rows = _v2_clarification_rows(report_path)
302
+ if v2_rows is not None:
303
+ return v2_rows
304
+ return parse_section_1_rows(_read_report_text(report_path))
305
+
306
+
307
+ def parse_section_1_rows(report_text: str) -> list[dict]:
308
+ """§1 테이블 한 행마다 메타(ClarificationItem) + 원문 Statement / Expected
309
+ form 셀.
310
+
311
+ 입력은 §1 을 담은 마크다운 본문이다 — 리포트 전문일 수도 있고, 다음 run 에
312
+ 첨부되는 carry-in 본문(``clarification_response_with_sidecars``)일 수도
313
+ 있다. §1 셀 추출의 단일 참조점 — 다른 모듈이 §1 레이아웃을 다시 훑지 않도록
314
+ 이 함수로 통일한다.
217
315
  """
218
316
  section = _section_1_slice(report_text)
219
317
  if section is None:
@@ -269,33 +367,46 @@ USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
269
367
 
270
368
  @dataclass(frozen=True)
271
369
  class ClarificationScan:
272
- """Fail-closed read of §1 for rows blocking on one `Blocks` value set.
370
+ """Fail-closed read of the clarification rows blocking on one `Blocks`
371
+ value set.
273
372
 
274
- ``unreadable_reason`` is ``None`` only when the scan is confident: §1
275
- parsed cleanly (or is the legitimate table-less placeholder) and
276
- ``blockers`` is therefore authoritative. A non-None reason means the
277
- caller must refuse to act — never soft-pass.
373
+ ``unreadable_reason`` is ``None`` only when the scan is confident: the
374
+ rows parsed cleanly (or the report is the legitimate table-less
375
+ placeholder) and ``blockers`` is therefore authoritative. A non-None
376
+ reason means the caller must refuse to act — never soft-pass.
278
377
  """
279
378
  blockers: list[ClarificationItem]
280
379
  unreadable_reason: Optional[str]
281
380
 
282
381
 
283
- def scan_approval_gate(report_text: str) -> ClarificationScan:
284
- """Scan §1 for unresolved ``Blocks=approval`` rows (``Status`` in
382
+ def scan_approval_gate(report_path: Path) -> ClarificationScan:
383
+ """Scan for unresolved ``Blocks=approval`` rows (``Status`` in
285
384
  ``{open, answered}``), refusing to guess whenever the schema drifted."""
286
- return scan_clarification_blockers(report_text, APPROVAL_BLOCKS)
385
+ return scan_clarification_blockers(report_path, APPROVAL_BLOCKS)
287
386
 
288
387
 
289
- def scan_open_user_input(report_text: str) -> ClarificationScan:
290
- """Scan §1 for every unresolved row that still owes the user an answer
388
+ def scan_open_user_input(report_path: Path) -> ClarificationScan:
389
+ """Scan for every unresolved row that still owes the user an answer
291
390
  (``Blocks`` in ``{approval, next-phase}``)."""
292
- return scan_clarification_blockers(report_text, USER_INPUT_BLOCKS)
391
+ return scan_clarification_blockers(report_path, USER_INPUT_BLOCKS)
293
392
 
294
393
 
295
394
  def scan_clarification_blockers(
395
+ report_path: Path, blocking_values: frozenset[str]
396
+ ) -> ClarificationScan:
397
+ """Shared fail-closed clarification walk for both gates above — schema-v2
398
+ reads its rows from the data sibling, schema-v1 from the §1 table."""
399
+ v2_scan = _scan_v2_blockers(report_path, blocking_values)
400
+ if v2_scan is not None:
401
+ return v2_scan
402
+ return scan_section_1_blockers(_read_report_text(report_path), blocking_values)
403
+
404
+
405
+ def scan_section_1_blockers(
296
406
  report_text: str, blocking_values: frozenset[str]
297
407
  ) -> ClarificationScan:
298
- """Shared fail-closed §1 walk for both gates above."""
408
+ """Fail-closed §1 walk over any markdown carrying the table — a schema-v1
409
+ report, or the carry-in body a reconciliation just produced."""
299
410
  section = _section_1_slice(report_text)
300
411
  if section is None:
301
412
  if _LOOSE_SECTION_1_RE.search(report_text):
@@ -333,6 +444,37 @@ def scan_clarification_blockers(
333
444
  return ClarificationScan(blockers, None)
334
445
 
335
446
 
447
+ def _scan_v2_blockers(
448
+ report_path: Path, blocking_values: frozenset[str]
449
+ ) -> Optional[ClarificationScan]:
450
+ """schema-v2 data.json 기준 스캔, 이 리포트가 v2 가 아니면 ``None``.
451
+ 필수 필드가 빠진 행은 §1 의 unparsed row 와 같이 fail-closed 로 다룬다."""
452
+ data = _v2_report_data(report_path)
453
+ if data is None:
454
+ return None
455
+ entries = data.get("clarificationItems")
456
+ if entries is None:
457
+ return ClarificationScan([], None)
458
+ if not isinstance(entries, list):
459
+ return ClarificationScan([], (
460
+ "schema-v2 data.json `clarificationItems` is not an array — the "
461
+ "gate cannot read the clarification rows"
462
+ ))
463
+ rows = [_v2_row(e) if isinstance(e, dict) else None for e in entries]
464
+ unparsed = sum(1 for row in rows if row is None)
465
+ if unparsed:
466
+ return ClarificationScan([], (
467
+ f"schema-v2 data.json has {unparsed} `clarificationItems` row(s) "
468
+ "missing id/blocks/status"
469
+ ))
470
+ blockers = [
471
+ row["item"] for row in rows
472
+ if row["item"].blocks in blocking_values
473
+ and row["item"].status in UNRESOLVED_STATUSES
474
+ ]
475
+ return ClarificationScan(blockers, None)
476
+
477
+
336
478
  # 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
337
479
  # "§1 헤딩은 있는데 형태가 어긋나 파싱에 실패" 한 상태다. trailing 부분을 보지
338
480
  # 않으므로 앵커 변형·수동 편집·미래 렌더 변경 어디서든 헤딩의 존재만 잡는다.
@@ -494,10 +636,11 @@ def clarification_response_with_sidecars(source: Path) -> str:
494
636
  리포트는 run 이 누적될수록 커지므로 이 중복은 스스로 악화된다(실측: 283K
495
637
  리포트가 892K 의 중복 읽기를 만들어 report-writer 를 timeout 시켰다).
496
638
 
497
- 그래서 소스가 final-report 일 때는 답변이 실린 §1 잘라내고 원문은 경로로
498
- 가리킨다 — `attached_user_responses_section` 이 implementation carry-in 에서
499
- 이미 쓰는 "원문은 경로로, 답변만 첨부" 규칙과 같다. §1 이 없는 소스(사용자가
500
- 직접 답변 파일)그대로 복사한다.
639
+ 그래서 소스가 final-report 일 때는 답변이 실린 clarification 행만 잘라내고
640
+ 원문은 경로로 가리킨다 — `attached_user_responses_section` 이 implementation
641
+ carry-in 에서 이미 쓰는 "원문은 경로로, 답변만 첨부" 규칙과 같다. 행을 어디서
642
+ 읽는지는 스키마가 정한다(v1 은 §1 표, v2 data.json). clarification 을
643
+ 아예 담지 않은 소스(사용자가 직접 쓴 답변 파일)만 원문 그대로 복사한다.
501
644
  """
502
645
  text = source.read_text(encoding="utf-8")
503
646
  section = attached_user_responses_section(source)
@@ -508,6 +651,14 @@ def clarification_response_with_sidecars(source: Path) -> str:
508
651
  return body.rstrip("\n") + "\n\n---\n\n" + section
509
652
 
510
653
 
654
+ SECTION_1_HEADING = "## 1. Clarification Items"
655
+ _SECTION_1_TABLE_HEADER = (
656
+ "| Record | Statement | Expected form | User input |\n"
657
+ "|---|---|---|---|"
658
+ )
659
+ _SECTION_1_EMPTY_STATE = "- The source report recorded no clarification items."
660
+
661
+
511
662
  def _clarification_carry_body(
512
663
  source: Path, text: str, answers: dict[str, str]
513
664
  ) -> str:
@@ -516,12 +667,10 @@ def _clarification_carry_body(
516
667
  §1 이 있으면 사이드카 답변을 그 표의 `User input` 열에 병합해, 답이 표 안에
517
668
  자리하도록 한다(파일 헤더가 선언하는 "답은 User input 열에" 계약을 실제로
518
669
  참으로 만든다)."""
519
- slice_ = _section_1_slice(text)
520
- if slice_ is None:
670
+ carried = _carry_section_1(source, text)
671
+ if carried is None:
521
672
  return text
522
- heading = SECTION_HEADING_PATTERN.search(text)
523
- assert heading is not None # _section_1_slice returned a slice
524
- section_body = slice_.rstrip()
673
+ heading, section_body = carried
525
674
  if answers:
526
675
  section_body = _reconcile_user_input(section_body, answers)
527
676
  return (
@@ -531,10 +680,58 @@ def _clarification_carry_body(
531
680
  "section; the report itself is read from the path above when a phase "
532
681
  "needs it. Do not re-read the source report to find the answers — they "
533
682
  "are in the `User input` column below.\n\n"
534
- f"{heading.group(0)}\n{section_body}\n"
683
+ f"{heading}\n{section_body}\n"
535
684
  )
536
685
 
537
686
 
687
+ def _carry_section_1(source: Path, text: str) -> Optional[tuple[str, str]]:
688
+ """carry-in 본문에 실을 (헤딩, §1 본문). clarification 을 담지 않은 소스면
689
+ ``None``.
690
+
691
+ schema-v2 는 행을 data.json 에 들고 AI 마크다운에는 §1 표가 없다. §1 슬라이스
692
+ 만 보던 동안 v2 소스는 "좁힐 것이 없다" 로 판정돼 **리포트 전문이 그대로
693
+ 복사**됐다 — 이 좁히기가 막으려던 바로 그 중복이다. carry-in 은 파생 문서이고
694
+ 다운스트림(승인 게이트·프롬프트 빌더·검증 워커)이 §1 표 하나만 읽으므로, v2
695
+ 행도 같은 표로 렌더한다."""
696
+ data = _v2_report_data(source)
697
+ if data is not None:
698
+ entries = data.get("clarificationItems")
699
+ return SECTION_1_HEADING, _v2_section_1_body(
700
+ entries if isinstance(entries, list) else []
701
+ )
702
+ slice_ = _section_1_slice(text)
703
+ if slice_ is None:
704
+ return None
705
+ heading = SECTION_HEADING_PATTERN.search(text)
706
+ assert heading is not None # _section_1_slice returned a slice
707
+ return heading.group(0), slice_.rstrip()
708
+
709
+
710
+ def _v2_section_1_body(entries: list) -> str:
711
+ """schema-v2 `clarificationItems[]` 를 §1 표 본문으로.
712
+
713
+ 메타 셀은 렌더러가 쓰는 모양 그대로다 — `Status:` 는 따옴표 없이 써야
714
+ `_reconcile_user_input` 이 답을 병합하면서 상태를 resolved 로 넘길 수 있다."""
715
+ rows = [e for e in entries if isinstance(e, dict) and e.get("id")]
716
+ if not rows:
717
+ return f"\n{_SECTION_1_EMPTY_STATE}"
718
+ lines = ["", _SECTION_1_TABLE_HEADER]
719
+ for entry in rows:
720
+ meta = (
721
+ f"**{entry['id']}**"
722
+ f"<br>Ticket: `{to_cell_text(entry.get('ticketId'))}`"
723
+ f"<br>Kind: `{to_cell_text(entry.get('kind'))}`"
724
+ f"<br>Blocks: `{to_cell_text(entry.get('blocks'))}`"
725
+ f"<br>Status: {to_cell_text(entry.get('status'))}"
726
+ )
727
+ lines.append(
728
+ f"| {meta} | {to_cell_text(entry.get('statement'))} "
729
+ f"| {to_cell_text(entry.get('expectedForm'))} "
730
+ f"| {to_cell_text(entry.get('userInput'))} |"
731
+ )
732
+ return "\n".join(lines)
733
+
734
+
538
735
  # The final-report renderer writes `Status: open` / `Status: answered` unquoted
539
736
  # in the stacked meta cell; only those two are unresolved. Resolve in place so
540
737
  # the meta cell's other fields (ID, Ticket, Kind, Blocks) are left untouched.
@@ -37,6 +37,7 @@ from .dispatch_state import (
37
37
  dispatch_mode as _dispatch_mode,
38
38
  load_json_object as _load_json_object,
39
39
  missing_completion_paths as _missing_completion_paths,
40
+ LIVENESS_WRAPPER_STATUS,
40
41
  require_string as _require_string,
41
42
  resolve_project_path as _resolve_project_path,
42
43
  resolve_required_path as _resolve_required_path,
@@ -437,7 +438,7 @@ def _worker_dispatch_record(
437
438
  "promptPath": str(worker.prompt_path),
438
439
  "resultPath": str(worker.result_path),
439
440
  "workerResultPath": str(worker.worker_result_path),
440
- "livenessMode": "wrapper-status",
441
+ "livenessMode": LIVENESS_WRAPPER_STATUS,
441
442
  "statusSidecarPath": str(status_path_for_prompt(worker.prompt_path)),
442
443
  "degradedFrom": "",
443
444
  "reason": reason,
@@ -123,7 +123,21 @@ def _parser() -> argparse.ArgumentParser:
123
123
  subparsers = parser.add_subparsers(dest="operation", required=True)
124
124
 
125
125
  example = subparsers.add_parser(
126
- "example", help="print a deterministic input artifact example"
126
+ "example",
127
+ help="print a deterministic input artifact example",
128
+ description=(
129
+ "Print one deterministic valid example as JSON.\n\n"
130
+ "`groups` feeds `seed --groups` and `round-results` feeds "
131
+ "`apply-round --results`, but `critic-results` is the critic "
132
+ "worker's own result document — NOT the `apply-critic-gaps "
133
+ "--results` input. That input is the coverage batch the lead "
134
+ "assembles from those candidates plus each analyser's vote "
135
+ '({schemaVersion, taskKey, mode: "coverage", provider, '
136
+ "modelExecutionValue, dispatches[], gaps[]}); see "
137
+ 'prompts/lead/convergence.md §"Coverage critic". Feeding this '
138
+ "example straight into `apply-critic-gaps` is rejected, by design."
139
+ ),
140
+ formatter_class=argparse.RawDescriptionHelpFormatter,
127
141
  )
128
142
  example.add_argument(
129
143
  "--kind",
@@ -18,6 +18,8 @@ from .dispatch_state import (
18
18
  DispatchError,
19
19
  WorkerJob,
20
20
  dispatch_mode as _dispatch_mode,
21
+ LIVENESS_AUDIT_HEARTBEAT,
22
+ LIVENESS_WRAPPER_STATUS,
21
23
  load_json_object as _load_json_object,
22
24
  missing_completion_paths as _missing_completion_paths,
23
25
  require_string as _require_string,
@@ -52,8 +54,6 @@ from .worker_artifact_paths import audit_sidecar_rel
52
54
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
53
55
 
54
56
 
55
- LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
56
- LIVENESS_WRAPPER_STATUS = "wrapper-status"
57
57
  MAX_WORKER_ATTEMPTS = 2
58
58
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
59
59
 
@@ -34,6 +34,13 @@ BACKEND_CLI_WRAPPER = "cli-wrapper"
34
34
  BACKEND_TMUX_PANE = "tmux-pane"
35
35
  BACKEND_MIXED = "mixed"
36
36
 
37
+ # `livenessMode` picks which artifact answers "is this worker still alive": the
38
+ # in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
39
+ # sidecar. Both dispatchers write it and `worker_liveness` reads it, so the
40
+ # vocabulary lives here rather than in whichever module happened to need it.
41
+ LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
42
+ LIVENESS_WRAPPER_STATUS = "wrapper-status"
43
+
37
44
  # The worktree argument grants the wrapper `--add-dir` write access outside
38
45
  # project-root. Only the phases whose workers mutate a stage worktree get it;
39
46
  # analysis phases write their artifacts under project-root (see the contract in
@@ -214,7 +221,11 @@ def transition_worker_status(
214
221
  else:
215
222
  worker["endedAt"] = timestamp
216
223
  if model_execution_value:
217
- worker["model"] = model_execution_value
224
+ # `model` is the catalog display name the task-manifest
225
+ # declares; only the execution identifier belongs here. Writing
226
+ # both from one value destroys the display name on every worker
227
+ # whose two differ, and `validate-run` compares team-state's
228
+ # `model` against the manifest.
218
229
  worker["modelExecutionValue"] = model_execution_value
219
230
  write_json(team_state_path, payload)
220
231
  return
@@ -1,4 +1,4 @@
1
- """Path helpers for the final-report markdown/data.json pair."""
1
+ """Path helpers for the final-report markdown/data.json pair and its sidecars."""
2
2
  from __future__ import annotations
3
3
 
4
4
  from pathlib import Path
@@ -6,6 +6,12 @@ from pathlib import Path
6
6
 
7
7
  DATA_JSON_SUFFIX = ".data.json"
8
8
  MARKDOWN_SUFFIX = ".md"
9
+ TRANSLATION_SOURCE_SUFFIX = ".translation-source.json"
10
+
11
+
12
+ def _stem(data_path: Path) -> str:
13
+ name = data_path.name
14
+ return name[: -len(DATA_JSON_SUFFIX)] if name.endswith(DATA_JSON_SUFFIX) else data_path.stem
9
15
 
10
16
 
11
17
  def final_report_data_path(report_path: Path) -> Path:
@@ -22,3 +28,18 @@ def final_report_markdown_path(data_path: Path) -> Path:
22
28
  if name.endswith(DATA_JSON_SUFFIX):
23
29
  return data_path.with_name(name[: -len(DATA_JSON_SUFFIX)] + MARKDOWN_SUFFIX)
24
30
  return data_path.with_suffix(MARKDOWN_SUFFIX)
31
+
32
+
33
+ def translation_source_path(data_path: Path) -> Path:
34
+ """Return the translator's work list for a final-report data.json path."""
35
+ return data_path.with_name(_stem(data_path) + TRANSLATION_SOURCE_SUFFIX)
36
+
37
+
38
+ def translation_sidecar_path(data_path: Path, lang: str) -> Path:
39
+ """Return the filled translation sidecar the HTML renderer overlays.
40
+
41
+ One file per language beside the report it translates, so the renderer
42
+ finds it from the data.json alone and a run for another language does not
43
+ overwrite it.
44
+ """
45
+ return data_path.with_name(f"{_stem(data_path)}.i18n.{lang}.json")
@@ -13,35 +13,40 @@ from typing import Any, Callable
13
13
 
14
14
  SUPPORTED_LANGS = ("en", "ko")
15
15
  DICTIONARY_REL = ("templates", "reports", "i18n")
16
+ # The human HTML has its own fixed strings — table headings, empty states,
17
+ # enum labels — that the AI-handoff Markdown never renders. Same loader, its
18
+ # own dictionary, so a heading added to one does not have to exist in the other.
19
+ HTML_DICTIONARY_REL = ("templates", "reports", "html", "i18n")
16
20
 
17
21
 
18
22
  class I18nError(RuntimeError):
19
23
  """사전 lookup 실패 또는 사전 로드 실패."""
20
24
 
21
25
 
22
- def _i18n_dir() -> Path:
26
+ def _i18n_dir(rel: tuple[str, ...] = DICTIONARY_REL) -> Path:
23
27
  okstra_home = os.environ.get("OKSTRA_HOME")
24
28
  if okstra_home:
25
- candidate = Path(okstra_home).joinpath(*DICTIONARY_REL)
29
+ candidate = Path(okstra_home).joinpath(*rel)
26
30
  if candidate.is_dir():
27
31
  return candidate
28
32
  here = Path(__file__).resolve()
29
33
  for parent in [here, *here.parents]:
30
- candidate = parent.joinpath(*DICTIONARY_REL)
34
+ candidate = parent.joinpath(*rel)
31
35
  if candidate.is_dir():
32
36
  return candidate
37
+ joined = "/".join(rel)
33
38
  raise I18nError(
34
- "could not locate templates/reports/i18n/. Set OKSTRA_HOME or "
35
- "run from a checkout that contains templates/reports/i18n/."
39
+ f"could not locate {joined}/. Set OKSTRA_HOME or "
40
+ f"run from a checkout that contains {joined}/."
36
41
  )
37
42
 
38
43
 
39
- def load_dictionary(lang: str) -> dict[str, Any]:
44
+ def load_dictionary(lang: str, rel: tuple[str, ...] = DICTIONARY_REL) -> dict[str, Any]:
40
45
  if lang not in SUPPORTED_LANGS:
41
46
  raise I18nError(
42
47
  f"unsupported reportLanguage {lang!r}; supported: {SUPPORTED_LANGS}"
43
48
  )
44
- path = _i18n_dir() / f"{lang}.json"
49
+ path = _i18n_dir(rel) / f"{lang}.json"
45
50
  try:
46
51
  return json.loads(path.read_text(encoding="utf-8"))
47
52
  except (OSError, json.JSONDecodeError) as exc: