okstra 0.59.0 → 0.60.1
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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/clarification_items.py +22 -0
- package/runtime/python/okstra_ctl/render_final_report.py +40 -0
- package/runtime/python/okstra_ctl/run.py +12 -1
- package/runtime/python/okstra_ctl/wizard.py +76 -17
- package/runtime/schemas/final-report-v1.0.schema.json +4 -1
- package/runtime/templates/reports/final-report.template.md +4 -3
- package/runtime/validators/validate-implementation-plan-stages.py +0 -3
- package/runtime/validators/validate-report-views.py +12 -1
- package/runtime/validators/validate-run.py +10 -1
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -218,3 +218,25 @@ def unresolved_approval_blockers(report_text: str) -> Optional[list[Clarificatio
|
|
|
218
218
|
it for it in items
|
|
219
219
|
if it.blocks == "approval" and it.status in UNRESOLVED_STATUSES
|
|
220
220
|
]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
|
|
224
|
+
# "§1 헤딩은 있는데 형태가 어긋나 파싱에 실패" 한 상태다. trailing 부분을 보지
|
|
225
|
+
# 않으므로 앵커 변형·수동 편집·미래 렌더 변경 어디서든 헤딩의 존재만 잡는다.
|
|
226
|
+
_LOOSE_SECTION_1_RE = re.compile(r"^##\s+1\.\s+Clarification Items\b", re.MULTILINE)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def section_1_present_but_unparsed(report_text: str) -> bool:
|
|
230
|
+
"""§1 헤딩이 느슨 탐지엔 잡히지만 엄격 SECTION_HEADING_PATTERN 에는 매칭하지
|
|
231
|
+
못하는 경우 True — 헤딩 형태가 어긋나(앵커·포맷 drift) §1 슬라이스 자체가
|
|
232
|
+
실패하는 상태다.
|
|
233
|
+
|
|
234
|
+
이때 ``_section_1_slice`` 가 None 을 반환해 parse 가 통째로 None 이 되고 승인
|
|
235
|
+
게이트가 "schema 없음 → soft-pass" 로 조용히 열린다. §1 앵커 버그가 정확히 이
|
|
236
|
+
메커니즘으로 터졌다. 헤딩 자체가 없는 legacy 리포트(둘 다 불매칭)와, 엄격
|
|
237
|
+
매칭에 성공하는 정상 헤딩(테이블이 없는 emptyState placeholder 포함)은 False —
|
|
238
|
+
placeholder 는 헤딩이 멀쩡하므로 fail-closed 로 오인하지 않는다. 정규식만 넓혀 온
|
|
239
|
+
과거 수정과 달리, 이 판별은 "헤딩 형태 drift" 자체를 차단해 재발 클래스를 닫는다."""
|
|
240
|
+
if SECTION_HEADING_PATTERN.search(report_text):
|
|
241
|
+
return False
|
|
242
|
+
return bool(_LOOSE_SECTION_1_RE.search(report_text))
|
|
@@ -50,6 +50,7 @@ from jinja2 import ChainableUndefined, Environment, FileSystemLoader
|
|
|
50
50
|
|
|
51
51
|
from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
|
+
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
|
|
@@ -351,6 +352,44 @@ def _enforce_schema(data: dict) -> None:
|
|
|
351
352
|
)
|
|
352
353
|
|
|
353
354
|
|
|
355
|
+
# 일반 alias('opus'/'sonnet'/'haiku')가 런타임에 해소되는, okstra 가 아는 최신
|
|
356
|
+
# 구체버전 — final-report '표시 전용'. 실행 인자(execution value)는 바꾸지 않으며
|
|
357
|
+
# (여전히 'opus' 가 `claude --model` 로 전달됨), 이 매핑은 보고서에서 "어느
|
|
358
|
+
# 구체버전 계열로 돌았는지" 가독성만 준다. CLI 가 실제 고른 버전과 다를 수
|
|
359
|
+
# 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
|
|
360
|
+
_DISPLAY_CONCRETE_CLAUDE = {
|
|
361
|
+
"opus": "claude-opus-4-7",
|
|
362
|
+
"sonnet": "claude-sonnet-4-6",
|
|
363
|
+
"haiku": "claude-haiku-4-5",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _model_detail(display: Any) -> str:
|
|
368
|
+
"""모델 display alias(예: 'opus')를 'opus (claude-opus-4-7)' 형태로 상세화한다.
|
|
369
|
+
|
|
370
|
+
lead·report-writer 헤더 모델은 항상 claude provider 다. 구체 alias('opus-4-7'
|
|
371
|
+
등)는 claude 매핑의 실행 ID 를, 일반 alias('opus' 등)는 표시 전용
|
|
372
|
+
`_DISPLAY_CONCRETE_CLAUDE` 의 권장 구체버전을 병기한다. 어느 쪽에도 없는 값
|
|
373
|
+
(이미 실행 ID 이거나 'default' 등)은 원본을 그대로 돌려준다. 바깥 백틱은
|
|
374
|
+
템플릿이 감싸므로 여기서는 넣지 않는다."""
|
|
375
|
+
text = str(display or "").strip()
|
|
376
|
+
if not text:
|
|
377
|
+
return text
|
|
378
|
+
try:
|
|
379
|
+
meta = resolve_model_metadata(
|
|
380
|
+
provider="claude", raw_value=text,
|
|
381
|
+
default_display=text, default_execution="",
|
|
382
|
+
)
|
|
383
|
+
except UnknownModelError:
|
|
384
|
+
meta = None
|
|
385
|
+
if meta and meta.execution and meta.execution != text:
|
|
386
|
+
return f"{text} ({meta.execution})"
|
|
387
|
+
concrete = _DISPLAY_CONCRETE_CLAUDE.get(text.lower())
|
|
388
|
+
if concrete:
|
|
389
|
+
return f"{text} ({concrete})"
|
|
390
|
+
return text
|
|
391
|
+
|
|
392
|
+
|
|
354
393
|
def _build_environment(template_dir: Path) -> Environment:
|
|
355
394
|
# ChainableUndefined lets optional fields (e.g.
|
|
356
395
|
# ``clarificationCarryIn``, ``ticketCoverage.omit``) silently evaluate
|
|
@@ -370,6 +409,7 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
370
409
|
env.filters["format_duration_ms"] = _format_duration_ms
|
|
371
410
|
env.filters["yaml_scalar"] = _yaml_scalar
|
|
372
411
|
env.filters["yaml_inline_list"] = _yaml_inline_list
|
|
412
|
+
env.filters["model_detail"] = _model_detail
|
|
373
413
|
return env
|
|
374
414
|
|
|
375
415
|
|
|
@@ -28,7 +28,10 @@ from pathlib import Path
|
|
|
28
28
|
|
|
29
29
|
from okstra_project import project_json_path, upsert_project_json
|
|
30
30
|
from .analysis_packet import build_analysis_packet
|
|
31
|
-
from .clarification_items import
|
|
31
|
+
from .clarification_items import (
|
|
32
|
+
section_1_present_but_unparsed,
|
|
33
|
+
unresolved_approval_blockers,
|
|
34
|
+
)
|
|
32
35
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
33
36
|
from .material import (
|
|
34
37
|
build_analysis_material,
|
|
@@ -341,6 +344,14 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
341
344
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
342
345
|
# Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
|
|
343
346
|
blockers = unresolved_approval_blockers(body)
|
|
347
|
+
if blockers is None and section_1_present_but_unparsed(body):
|
|
348
|
+
raise PrepareError(
|
|
349
|
+
f"approved plan has a `## 1. Clarification Items` heading but its table "
|
|
350
|
+
f"could not be parsed (heading/anchor/format drift): {path}\n"
|
|
351
|
+
" the approval gate cannot confirm there are no unresolved "
|
|
352
|
+
"`Blocks=approval` rows, so it refuses to soft-pass. Re-render the report "
|
|
353
|
+
"with scripts/okstra-render-final-report.py so §1 matches the schema, then retry."
|
|
354
|
+
)
|
|
344
355
|
if blockers:
|
|
345
356
|
lines = [
|
|
346
357
|
f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
|
|
@@ -31,7 +31,10 @@ from okstra_ctl.models import (
|
|
|
31
31
|
UnknownModelError,
|
|
32
32
|
resolve_model_metadata,
|
|
33
33
|
)
|
|
34
|
-
from okstra_ctl.clarification_items import
|
|
34
|
+
from okstra_ctl.clarification_items import (
|
|
35
|
+
section_1_present_but_unparsed,
|
|
36
|
+
unresolved_approval_blockers,
|
|
37
|
+
)
|
|
35
38
|
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
36
39
|
from okstra_ctl.run import (
|
|
37
40
|
APPROVED_FRONTMATTER_PATTERN,
|
|
@@ -455,6 +458,13 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
|
455
458
|
# frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
|
|
456
459
|
# 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
|
|
457
460
|
blockers = unresolved_approval_blockers(body)
|
|
461
|
+
if blockers is None and section_1_present_but_unparsed(body):
|
|
462
|
+
raise WizardError(
|
|
463
|
+
f"approved plan has a `## 1. Clarification Items` heading but its table "
|
|
464
|
+
f"could not be parsed (heading/anchor/format drift): {p}\n"
|
|
465
|
+
" the approval gate cannot confirm there are no unresolved "
|
|
466
|
+
"`Blocks=approval` rows — re-render the report so §1 matches the schema."
|
|
467
|
+
)
|
|
458
468
|
if blockers:
|
|
459
469
|
lines = [
|
|
460
470
|
f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
|
|
@@ -716,7 +726,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
716
726
|
latest_key = latest.get("taskKey") or ""
|
|
717
727
|
latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
|
|
718
728
|
options: list[Option] = []
|
|
719
|
-
for entry in tasks[:
|
|
729
|
+
for entry in tasks[:16]:
|
|
720
730
|
key = entry.get("taskKey") or ""
|
|
721
731
|
ttype = entry.get("taskType") or ""
|
|
722
732
|
phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
|
|
@@ -985,11 +995,31 @@ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
|
|
|
985
995
|
return f"task-id: {state.task_id}"
|
|
986
996
|
|
|
987
997
|
|
|
998
|
+
def _existing_task_next_phase(state: WizardState) -> str:
|
|
999
|
+
"""새 task 로 시작했더라도 입력한 task-key 가 이미 존재하면(=사실상 이어가기)
|
|
1000
|
+
그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
|
|
1001
|
+
|
|
1002
|
+
사용자가 picker 에서 기존 task-key 를 고르지 않고 new-task 흐름으로 같은
|
|
1003
|
+
task-group/task-id 를 다시 입력한 경우에도 직전 phase 의 추천이 끊기지 않게
|
|
1004
|
+
하는 안전장치."""
|
|
1005
|
+
if not (state.project_id and state.task_group and state.task_id):
|
|
1006
|
+
return ""
|
|
1007
|
+
key = f"{state.project_id}:{state.task_group}:{state.task_id}"
|
|
1008
|
+
root = find_task_root(Path(state.project_root), key)
|
|
1009
|
+
if root is None:
|
|
1010
|
+
return ""
|
|
1011
|
+
workflow = (read_task_manifest(root) or {}).get("workflow") or {}
|
|
1012
|
+
nxt = workflow.get("nextRecommendedPhase") or ""
|
|
1013
|
+
return nxt if isinstance(nxt, str) else ""
|
|
1014
|
+
|
|
1015
|
+
|
|
988
1016
|
def _build_task_type(state: WizardState) -> Prompt:
|
|
989
1017
|
t = _p(state.workspace_root, "task_type")
|
|
990
1018
|
recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
|
|
991
1019
|
options: list[Option] = []
|
|
992
1020
|
recommended = state.task_type if not state.is_new_task else ""
|
|
1021
|
+
if not recommended and state.is_new_task:
|
|
1022
|
+
recommended = _existing_task_next_phase(state)
|
|
993
1023
|
seen: list[str] = []
|
|
994
1024
|
if recommended and recommended in TASK_TYPE_VALUES:
|
|
995
1025
|
d = dict(TASK_TYPES)[recommended]
|
|
@@ -1340,8 +1370,11 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
1340
1370
|
runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1341
1371
|
if not runs_base.is_dir():
|
|
1342
1372
|
return ""
|
|
1373
|
+
# run 산출물 구조는 runs/<task-type>/reports/final-report-*.md (1단계).
|
|
1374
|
+
# 과거 `*/*/reports/...` (2단계) glob 은 실제 구조와 어긋나 항상 빈 결과여서
|
|
1375
|
+
# clarification 단계가 직전 final-report 를 추천하지 못했다.
|
|
1343
1376
|
candidates = [
|
|
1344
|
-
p for p in runs_base.glob("
|
|
1377
|
+
p for p in runs_base.glob("*/reports/final-report-*.md")
|
|
1345
1378
|
if p.is_file()
|
|
1346
1379
|
]
|
|
1347
1380
|
if not candidates:
|
|
@@ -1424,11 +1457,14 @@ def _pick_snippet(value: str, style: str) -> str:
|
|
|
1424
1457
|
def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
|
|
1425
1458
|
suggestion = spec.suggest(state)
|
|
1426
1459
|
t = _p(state.workspace_root, spec.prompt_key)
|
|
1427
|
-
|
|
1460
|
+
# 추천(이전 directive·siblings·최근 리포트·프로젝트 기본)을 가장 먼저 노출하고,
|
|
1461
|
+
# '건너뛰기'는 중간, '직접 입력'은 항상 마지막에 둔다 (run-prompt 추천 규칙).
|
|
1462
|
+
options: list[Option] = []
|
|
1428
1463
|
if suggestion:
|
|
1429
1464
|
snippet = _pick_snippet(suggestion, spec.snippet_style)
|
|
1430
1465
|
options.append(_opt(spec.recommend_token, t["labels"][spec.label_key].format(snippet=snippet)))
|
|
1431
1466
|
setattr(state, spec.cache_attr, suggestion)
|
|
1467
|
+
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1432
1468
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1433
1469
|
return Prompt(
|
|
1434
1470
|
step=spec.step, kind="pick",
|
|
@@ -1592,9 +1628,13 @@ def _critic_provider_label(provider: str, t: dict) -> str:
|
|
|
1592
1628
|
def _build_critic_pick(state: WizardState) -> Prompt:
|
|
1593
1629
|
t = _p(state.workspace_root, "critic_pick")
|
|
1594
1630
|
off_label = t["options"].get("off", "off")
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1631
|
+
# 추천(claude critic)을 가장 먼저, 'off'(critic 미사용)를 마지막에 둔다
|
|
1632
|
+
# (run-prompt 추천 규칙: 추천이 항상 첫 옵션).
|
|
1633
|
+
options = [
|
|
1634
|
+
_opt(provider, _critic_provider_label(provider, t))
|
|
1635
|
+
for provider in _critic_provider_choices()
|
|
1636
|
+
]
|
|
1637
|
+
options.append(_opt("off", off_label))
|
|
1598
1638
|
return Prompt(
|
|
1599
1639
|
step=S_CRITIC_PICK, kind="pick",
|
|
1600
1640
|
label=t["label"],
|
|
@@ -1671,7 +1711,10 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
|
|
|
1671
1711
|
if value not in ("defaults", "customize"):
|
|
1672
1712
|
raise WizardError(f"expected 'defaults' or 'customize', got: {value!r}")
|
|
1673
1713
|
state.use_defaults = value == "defaults"
|
|
1674
|
-
|
|
1714
|
+
mode = ("defaults (recommended models as-is)"
|
|
1715
|
+
if state.use_defaults
|
|
1716
|
+
else "customize (manual model pick)")
|
|
1717
|
+
return f"model-mode: {mode}"
|
|
1675
1718
|
|
|
1676
1719
|
|
|
1677
1720
|
def _build_workers_override(state: WizardState) -> Prompt:
|
|
@@ -1719,7 +1762,13 @@ def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
|
|
|
1719
1762
|
|
|
1720
1763
|
|
|
1721
1764
|
def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
|
|
1722
|
-
|
|
1765
|
+
# "default" picks the role's recommended model — leaving it here yields
|
|
1766
|
+
# the SAME result as the 'Use defaults' branch. Spell that out on the
|
|
1767
|
+
# label so default ↔ customize never reads as "no difference".
|
|
1768
|
+
opts = [
|
|
1769
|
+
_opt(o, "default (recommended model)" if o == "default" else o)
|
|
1770
|
+
for o in options
|
|
1771
|
+
]
|
|
1723
1772
|
return Prompt(step=step, kind="pick", label=label,
|
|
1724
1773
|
options=opts, echo_template=echo)
|
|
1725
1774
|
|
|
@@ -2170,14 +2219,16 @@ STEPS: list[Step] = [
|
|
|
2170
2219
|
and S_REPORT_WRITER_MODEL not in s.answered),
|
|
2171
2220
|
build=_build_report_writer_model, submit=_submit_report_writer_model,
|
|
2172
2221
|
owns=("report_writer_model",)),
|
|
2222
|
+
# directive(이번 run 의 추가 지시)는 기본값/커스터마이즈와 무관하게 항상
|
|
2223
|
+
# 묻는다 — 매 run 마다 줄 수 있는 입력이므로 'Use defaults' 분기 뒤에 숨기지
|
|
2224
|
+
# 않는다. (use_defaults is not None: defaults_or_custom 답 이후에만 등장)
|
|
2173
2225
|
Step(S_DIRECTIVE_PICK,
|
|
2174
|
-
applies=lambda s: (s.
|
|
2175
|
-
and
|
|
2226
|
+
applies=lambda s: (S_DIRECTIVE_PICK not in s.answered
|
|
2227
|
+
and s.use_defaults is not None),
|
|
2176
2228
|
build=_build_directive_pick, submit=_submit_directive_pick,
|
|
2177
2229
|
owns=("directive", "directive_pending_text")),
|
|
2178
2230
|
Step(S_DIRECTIVE,
|
|
2179
|
-
applies=lambda s: (s.
|
|
2180
|
-
and s.directive_pending_text
|
|
2231
|
+
applies=lambda s: (s.directive_pending_text
|
|
2181
2232
|
and S_DIRECTIVE not in s.answered),
|
|
2182
2233
|
build=_build_directive, submit=_submit_directive,
|
|
2183
2234
|
owns=("directive", "directive_pending_text")),
|
|
@@ -2192,14 +2243,19 @@ STEPS: list[Step] = [
|
|
|
2192
2243
|
and S_RELATED_TASKS not in s.answered),
|
|
2193
2244
|
build=_build_related_tasks, submit=_submit_related_tasks,
|
|
2194
2245
|
owns=("related_tasks_raw", "related_tasks_pending_text")),
|
|
2246
|
+
# clarification(직전 phase final-report 입력)은 customize 전용이 아니다.
|
|
2247
|
+
# 이어가기에 필수인 직전 final-report 가 존재하면 use_defaults 와 무관하게
|
|
2248
|
+
# 입력 기회를 노출한다. (과거: use_defaults 게이트 뒤에 숨어 "Use defaults"
|
|
2249
|
+
# 를 고르면 직전 리포트가 통째로 누락됐다.)
|
|
2195
2250
|
Step(S_CLARIFICATION_PICK,
|
|
2196
|
-
applies=lambda s: (s.
|
|
2197
|
-
and
|
|
2251
|
+
applies=lambda s: (S_CLARIFICATION_PICK not in s.answered
|
|
2252
|
+
and s.use_defaults is not None
|
|
2253
|
+
and (s.use_defaults is False
|
|
2254
|
+
or bool(_suggest_latest_final_report(s)))),
|
|
2198
2255
|
build=_build_clarification_pick, submit=_submit_clarification_pick,
|
|
2199
2256
|
owns=("clarification_response_path", "clarification_pending_text")),
|
|
2200
2257
|
Step(S_CLARIFICATION,
|
|
2201
|
-
applies=lambda s: (s.
|
|
2202
|
-
and s.clarification_pending_text
|
|
2258
|
+
applies=lambda s: (s.clarification_pending_text
|
|
2203
2259
|
and S_CLARIFICATION not in s.answered),
|
|
2204
2260
|
build=_build_clarification, submit=_submit_clarification,
|
|
2205
2261
|
owns=("clarification_response_path", "clarification_pending_text")),
|
|
@@ -2469,6 +2525,7 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
2469
2525
|
lines: list[str] = [header]
|
|
2470
2526
|
lines.append(f" task-type : {state.task_type}")
|
|
2471
2527
|
lines.append(f" task-key : {state.task_group}/{state.task_id}")
|
|
2528
|
+
lines.append(f" brief : {state.brief_path or '(none)'}")
|
|
2472
2529
|
if state.reuse_worktree:
|
|
2473
2530
|
lines.append(" base-ref : (reusing existing worktree)")
|
|
2474
2531
|
else:
|
|
@@ -2491,6 +2548,8 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
2491
2548
|
if state.report_writer_model:
|
|
2492
2549
|
lines.append(f" report-writer : {state.report_writer_model}")
|
|
2493
2550
|
lines.append(f" directive : {state.directive or '(none)'}")
|
|
2551
|
+
if state.related_tasks_raw:
|
|
2552
|
+
lines.append(f" related-tasks : {state.related_tasks_raw}")
|
|
2494
2553
|
if state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification"):
|
|
2495
2554
|
lines.append(f" critic : {state.critic or '(off)'}")
|
|
2496
2555
|
if state.task_type in _STAGE_SCOPED_TASK_TYPES:
|
|
@@ -536,7 +536,10 @@
|
|
|
536
536
|
"type": "array",
|
|
537
537
|
"items": { "$ref": "#/$defs/DiffFileRow" }
|
|
538
538
|
}
|
|
539
|
-
}
|
|
539
|
+
},
|
|
540
|
+
"$comment": "rawStat(git diff --stat 원문)이 비어있지 않으면(=변경 있음) files 표는 최소 1행이어야 한다. 변경을 stat 으로는 보고하면서 files 표를 비워 미선언-surface conformance 게이트를 우회하는 fail-open(SUSP-1)을 차단. 0파일 변경은 rawStat 이 빈 문자열이라 허용된다.",
|
|
541
|
+
"if": { "required": ["rawStat"], "properties": { "rawStat": { "minLength": 1 } } },
|
|
542
|
+
"then": { "properties": { "files": { "minItems": 1 } } }
|
|
540
543
|
},
|
|
541
544
|
"outOfPlanEdits": {
|
|
542
545
|
"type": "array",
|
|
@@ -26,10 +26,11 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
|
|
|
26
26
|
- Created at: {{ header.createdAt }}
|
|
27
27
|
- Task Key: {{ header.taskKey }}
|
|
28
28
|
- Task Type: {{ header.taskType }}
|
|
29
|
-
- Report Owner: `{{ header.reportOwner }}`
|
|
29
|
+
- Report Owner: `{{ header.reportOwner }}` (model: `{{ header.leadModel | model_detail }}`)
|
|
30
30
|
- Report Author: `{{ header.reportAuthor }}`
|
|
31
|
-
- Lead Model: `{{ header.leadModel }}`
|
|
32
|
-
-
|
|
31
|
+
- Lead Model: `{{ header.leadModel | model_detail }}`
|
|
32
|
+
{% for row in followUpTasks if row.origin == 'phase-continuation' %}{% if loop.first %}- Next Step: run `/okstra-run`, select task `{{ header.taskKey }}`, choose task-type `{{ row.suggestedTaskType }}`
|
|
33
|
+
{% endif %}{% endfor %}- Okstra Version: `{{ header.okstraVersion }}`
|
|
33
34
|
|
|
34
35
|
## Verdict Card
|
|
35
36
|
|
|
@@ -13,9 +13,6 @@ from pathlib import Path
|
|
|
13
13
|
from typing import List, Tuple
|
|
14
14
|
|
|
15
15
|
STAGE_MAP_HEADING = re.compile(r"^##\s+5\.5\s+Stage\s+Map\b", re.M)
|
|
16
|
-
STAGE_SECTION = re.compile(
|
|
17
|
-
r"^##\s+5\.5\.(\d+)\s+Stage\s+\1\s*:\s*(.+)$", re.M
|
|
18
|
-
)
|
|
19
16
|
REQUIRED_SUBSECTIONS = (
|
|
20
17
|
"Carry-In",
|
|
21
18
|
"Stepwise Execution Order",
|
|
@@ -29,7 +29,10 @@ SCRIPTS_DIR = REPO_ROOT / "scripts"
|
|
|
29
29
|
if str(SCRIPTS_DIR) not in sys.path:
|
|
30
30
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
31
31
|
|
|
32
|
-
from okstra_ctl.clarification_items import
|
|
32
|
+
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
33
|
+
parse_clarification_items,
|
|
34
|
+
section_1_present_but_unparsed,
|
|
35
|
+
)
|
|
33
36
|
from okstra_ctl.report_views import ( # noqa: E402
|
|
34
37
|
extract_html_digest,
|
|
35
38
|
source_digest,
|
|
@@ -80,6 +83,14 @@ def validate(report_path: Path) -> list[str]:
|
|
|
80
83
|
|
|
81
84
|
md = report_path.read_text(encoding="utf-8")
|
|
82
85
|
html_path = report_path.with_name(report_path.stem + ".html")
|
|
86
|
+
# §1 헤딩이 있는데 파싱이 실패하면 md_ids 가 빈 []이 되어 "clarification 없음
|
|
87
|
+
# → skip" 으로 흘러 HTML form parity 게이트가 조용히 열린다. fail-closed.
|
|
88
|
+
if section_1_present_but_unparsed(md):
|
|
89
|
+
return [
|
|
90
|
+
"final-report has a `## 1. Clarification Items` heading but its table "
|
|
91
|
+
"could not be parsed (heading/anchor/format drift) — cannot verify HTML "
|
|
92
|
+
"form parity. Re-render the report so §1 matches the schema."
|
|
93
|
+
]
|
|
83
94
|
md_ids = _md_response_ids(md)
|
|
84
95
|
|
|
85
96
|
# (1) sibling artifact exists — conditional on §1 clarification rows.
|
|
@@ -1125,13 +1125,22 @@ def _extract_final_verdict_token(content: str) -> str | None:
|
|
|
1125
1125
|
return match.group("value")
|
|
1126
1126
|
|
|
1127
1127
|
|
|
1128
|
+
# 렌더러는 ID 정의 셀(`<a id="r-001"></a>R-001`)에도 스크롤 앵커를 넣는다.
|
|
1129
|
+
# 셀 정규화 때 그 빈 앵커를 벗겨야 ID 컬럼이 bare 토큰으로 읽힌다
|
|
1130
|
+
# (clarification_items._CELL_ANCHOR_RE 와 동형).
|
|
1131
|
+
_CELL_ANCHOR_RE = re.compile(r'<a id="[^"]*"></a>')
|
|
1132
|
+
|
|
1133
|
+
|
|
1128
1134
|
def _split_markdown_row(line: str) -> list[str]:
|
|
1129
1135
|
stripped = line.strip()
|
|
1130
1136
|
if stripped.startswith("|"):
|
|
1131
1137
|
stripped = stripped[1:]
|
|
1132
1138
|
if stripped.endswith("|"):
|
|
1133
1139
|
stripped = stripped[:-1]
|
|
1134
|
-
return [
|
|
1140
|
+
return [
|
|
1141
|
+
_CELL_ANCHOR_RE.sub("", cell).strip().strip("`").strip()
|
|
1142
|
+
for cell in stripped.split("|")
|
|
1143
|
+
]
|
|
1135
1144
|
|
|
1136
1145
|
|
|
1137
1146
|
def _is_markdown_separator(line: str) -> bool:
|