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.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/project-structure-overview.md +1 -1
- package/package.json +3 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +8 -0
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-report-translate.py +191 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +1 -1
- package/runtime/prompts/lead/report-writer.md +16 -15
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/convergence.py +15 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_finalize.py +18 -0
- package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
- package/runtime/python/okstra_ctl/report_html/render.py +44 -2
- package/runtime/python/okstra_ctl/report_translation.py +469 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/templates/reports/html/base.template.html +12 -12
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +16 -16
- package/runtime/templates/reports/html/macros/visualizations.html +2 -2
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
- package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
- package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +59 -31
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- 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
|
-
|
|
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
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
single
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
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:
|
|
275
|
-
parsed cleanly (or is the legitimate table-less
|
|
276
|
-
``blockers`` is therefore authoritative. A non-None
|
|
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(
|
|
284
|
-
"""Scan
|
|
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(
|
|
385
|
+
return scan_clarification_blockers(report_path, APPROVAL_BLOCKS)
|
|
287
386
|
|
|
288
387
|
|
|
289
|
-
def scan_open_user_input(
|
|
290
|
-
"""Scan
|
|
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(
|
|
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
|
-
"""
|
|
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 일 때는 답변이 실린
|
|
498
|
-
가리킨다 — `attached_user_responses_section` 이 implementation
|
|
499
|
-
이미 쓰는 "원문은 경로로, 답변만 첨부" 규칙과 같다.
|
|
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
|
-
|
|
520
|
-
if
|
|
670
|
+
carried = _carry_section_1(source, text)
|
|
671
|
+
if carried is None:
|
|
521
672
|
return text
|
|
522
|
-
heading =
|
|
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
|
|
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":
|
|
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",
|
|
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
|
-
|
|
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(*
|
|
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(*
|
|
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
|
|
35
|
-
"run from a checkout that contains
|
|
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:
|