okstra 0.106.1 → 0.107.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.
Files changed (29) hide show
  1. package/docs/for-ai/skills/okstra-brief.md +6 -3
  2. package/docs/for-ai/skills/okstra-container-build.md +1 -1
  3. package/docs/for-ai/skills/okstra-inspect.md +5 -5
  4. package/docs/for-ai/skills/okstra-manager.md +9 -3
  5. package/docs/for-ai/skills/okstra-run.md +18 -2
  6. package/docs/for-ai/skills/okstra-schedule.md +11 -7
  7. package/docs/for-ai/skills/okstra-setup.md +2 -2
  8. package/docs/kr/architecture.md +1 -1
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/report-writer-worker.md +1 -1
  12. package/runtime/bin/okstra-antigravity-exec.sh +24 -3
  13. package/runtime/bin/okstra-claude-exec.sh +30 -3
  14. package/runtime/bin/okstra-codex-exec.sh +27 -4
  15. package/runtime/prompts/coding-preflight/clean-code.md +2 -0
  16. package/runtime/prompts/coding-preflight/overview.md +2 -0
  17. package/runtime/prompts/coding-preflight/scripts/preedit-check.sh +2 -2
  18. package/runtime/prompts/lead/convergence.md +9 -5
  19. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  20. package/runtime/prompts/lead/report-writer.md +12 -8
  21. package/runtime/prompts/profiles/implementation-planning.md +6 -6
  22. package/runtime/schemas/final-report-v1.0.schema.json +27 -9
  23. package/runtime/skills/okstra-container-build/SKILL.md +1 -1
  24. package/runtime/templates/reports/final-report.template.md +14 -4
  25. package/runtime/templates/reports/i18n/en.json +6 -1
  26. package/runtime/templates/reports/i18n/ko.json +6 -1
  27. package/runtime/validators/validate-brief.py +37 -0
  28. package/runtime/validators/validate-implementation-plan-stages.py +55 -12
  29. package/runtime/validators/validate-run.py +354 -0
@@ -861,6 +861,57 @@ def _task_root_from_run_dir(run_dir: Path) -> Path:
861
861
  return run_dir.parent.parent
862
862
 
863
863
 
864
+ def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
865
+ """H4-c — at planning time, every stage that DECLARES `Conformance tests:`
866
+ must already carry a matching entry in the shared task-level
867
+ `qa/conformance-manifest.json`. The profile mandates writing the script +
868
+ manifest entry as part of emitting that line
869
+ (implementation-planning.md:83/85); without this check a declaration that
870
+ was never materialized only surfaces much later at the `implementation`
871
+ entry gate. Matches by stageKey suffix (`-stage-<N>`) like
872
+ `_scope_manifest_entries`; skips stages that took a `Conformance exemption:`.
873
+ """
874
+ data_path = report_path.with_suffix(".data.json")
875
+ if not data_path.is_file():
876
+ return
877
+ try:
878
+ data = json.loads(data_path.read_text(encoding="utf-8"))
879
+ except (OSError, json.JSONDecodeError):
880
+ return
881
+ ip = data.get("implementationPlanning")
882
+ if not isinstance(ip, dict):
883
+ return
884
+ declaring_stages = [
885
+ s.get("stage")
886
+ for s in (ip.get("stages") or [])
887
+ if isinstance(s, dict) and str(s.get("conformanceTests") or "").strip()
888
+ ]
889
+ if not declaring_stages:
890
+ return
891
+ task_root = _task_root_from_run_dir(report_path.parent.parent)
892
+ manifest_path = task_root / "qa" / "conformance-manifest.json"
893
+ entries = []
894
+ if manifest_path.is_file():
895
+ try:
896
+ entries = json.loads(manifest_path.read_text()).get("entries") or []
897
+ except (OSError, json.JSONDecodeError):
898
+ entries = []
899
+ for stage_number in declaring_stages:
900
+ suffix = f"-stage-{stage_number}"
901
+ has_entry = any(
902
+ isinstance(e, dict) and str(e.get("stageKey") or "").endswith(suffix)
903
+ for e in entries
904
+ )
905
+ if not has_entry:
906
+ failures.append(
907
+ f"final-report data.json: stage {stage_number} declares "
908
+ "`Conformance tests:` but no matching entry exists in "
909
+ f"{manifest_path} (stageKey ending `{suffix}`). Emitting the "
910
+ "declaration MUST also write the manifest entry + script "
911
+ "(implementation-planning.md §Conformance)."
912
+ )
913
+
914
+
864
915
  def _validate_conformance(report_path: Path, failures: list[str],
865
916
  surface_patterns: object = None) -> None:
866
917
  """Tier 3 conformance 게이트(implementation / final-verification).
@@ -1483,6 +1534,11 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1483
1534
  elif task_type == "implementation-planning":
1484
1535
  _validate_implementation_planning_cross_project(data, failures)
1485
1536
  _validate_implementation_planning_decision_drafts(data, failures)
1537
+ _validate_plan_body_gate_recompute(data, failures)
1538
+ _validate_plan_item_extraction_completeness(data, failures)
1539
+ _validate_plan_item_subject_substance(data, failures)
1540
+ _validate_plan_body_clarification_matching(data, failures)
1541
+ _validate_requirement_coverage_covered_by(data, failures)
1486
1542
 
1487
1543
 
1488
1544
  # path:line (foo.service.ts:268), report ID (C-001 / F-013 / G-crit-002 / RC-1),
@@ -1584,6 +1640,302 @@ def _validate_implementation_planning_decision_drafts(data: dict, failures: list
1584
1640
  )
1585
1641
 
1586
1642
 
1643
+ # Plan-body gate outcomes ranked by how favorable each is to approval.
1644
+ # A higher rank claims a healthier verification result. The recompute check
1645
+ # below fails only when the *declared* gate outranks what the recorded
1646
+ # per-worker verdicts support — i.e. the lead claimed a better outcome than
1647
+ # the votes justify. A lead writing a conservatively *worse* gate is allowed,
1648
+ # so genuine edge cases in this recompute never manufacture false failures.
1649
+ _PLAN_GATE_RANK = {
1650
+ "aborted-non-result": 0,
1651
+ "blocked-by-disagreement": 0,
1652
+ "passed-with-dissent": 1,
1653
+ "passed": 2,
1654
+ }
1655
+
1656
+ # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
1657
+ # needed), because the defect is concrete, safety-critical, and adversarially
1658
+ # verifiable: `a` = cited path/symbol mismatch, `d` = rollback violates
1659
+ # commit/dependency order. `b`/`c`/`e` still need a majority — `b` in particular
1660
+ # is prone to planning-vs-implementation environment false positives.
1661
+ _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
1662
+
1663
+
1664
+ def _classify_plan_item_gate(item: dict) -> str:
1665
+ """Recompute one plan item's gate class from its per-worker verdicts,
1666
+ per `prompts/lead/convergence.md` "Round protocol". Returns one of
1667
+ ``majority-disagree`` / ``has-dissent`` / ``full-consensus`` /
1668
+ ``all-non-result``. Collapses ``partial-consensus`` and
1669
+ ``dissent-isolated`` into ``has-dissent`` because they resolve to the
1670
+ same gate value; only the majority-disagree boundary changes the gate.
1671
+ """
1672
+ tokens = [
1673
+ (
1674
+ str(v.get("verdict") or "").strip().upper(),
1675
+ str(v.get("breakageKind") or "").strip().lower(),
1676
+ )
1677
+ for v in (item.get("verdicts") or [])
1678
+ if isinstance(v, dict)
1679
+ ]
1680
+ non_error = [(vd, bk) for (vd, bk) in tokens if vd and vd != "VERIFICATION-ERROR"]
1681
+ if not non_error:
1682
+ return "all-non-result"
1683
+ disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
1684
+ agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
1685
+ disagree_kinds = {bk for (_vd, bk) in disagree if bk}
1686
+ if not disagree:
1687
+ return "full-consensus"
1688
+ # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
1689
+ # safety-critical, adversarially-verifiable defect is enough to block, even
1690
+ # in a two-worker roster — a lone correct dissent must not be outvoted here.
1691
+ # `a`/`d` for any item; `f` only for P-Req items (requirement coverage).
1692
+ is_req = str(item.get("id") or "").upper().startswith("P-REQ")
1693
+ if disagree_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in disagree_kinds):
1694
+ return "majority-disagree"
1695
+ # Otherwise a genuine majority is required — and a majority needs at least
1696
+ # two participating votes, so a lone surviving DISAGREE (its peer returned a
1697
+ # non-result) does NOT block. That fixes the paradox where a worker failure
1698
+ # made the gate stricter than a healthy roster would.
1699
+ if len(non_error) >= 2 and len(disagree) > len(agree):
1700
+ return "majority-disagree"
1701
+ return "has-dissent"
1702
+
1703
+
1704
+ def _recompute_plan_body_gate(pbv: dict) -> str | None:
1705
+ """Recompute the whole §5.5.9 gate value from ``planItems[].verdicts``.
1706
+ Returns a value in ``PLAN_VERIFY_GATE_VALUES`` or ``None`` when there are
1707
+ no plan items to judge (disabled / empty round)."""
1708
+ classes = [
1709
+ _classify_plan_item_gate(it)
1710
+ for it in (pbv.get("planItems") or [])
1711
+ if isinstance(it, dict)
1712
+ ]
1713
+ if not classes:
1714
+ return None
1715
+ if all(c == "all-non-result" for c in classes):
1716
+ return "aborted-non-result"
1717
+ if any(c == "majority-disagree" for c in classes):
1718
+ return "blocked-by-disagreement"
1719
+ if any(c == "has-dissent" for c in classes):
1720
+ return "passed-with-dissent"
1721
+ return "passed"
1722
+
1723
+
1724
+ def _validate_plan_body_gate_recompute(data: dict, failures: list[str]) -> None:
1725
+ """H1 — the declared `Gate result` must not claim a healthier outcome than
1726
+ the recorded per-worker verdicts support. Closes the forgery hole where a
1727
+ lead writes `gateResult: passed` while workers actually voted DISAGREE:
1728
+ the verdicts live in `planItems[].verdicts`, so the gate is recomputable
1729
+ and no longer depends on the lead's honesty alone.
1730
+ """
1731
+ ip = data.get("implementationPlanning")
1732
+ if not isinstance(ip, dict):
1733
+ return
1734
+ pbv = ip.get("planBodyVerification")
1735
+ if not isinstance(pbv, dict):
1736
+ return
1737
+ declared = str(pbv.get("gateResult") or "").strip().lower()
1738
+ recomputed = _recompute_plan_body_gate(pbv)
1739
+ if recomputed is None or declared not in _PLAN_GATE_RANK:
1740
+ return
1741
+ if _PLAN_GATE_RANK[declared] > _PLAN_GATE_RANK[recomputed]:
1742
+ failures.append(
1743
+ "final-report data.json: implementationPlanning.planBodyVerification "
1744
+ f"`gateResult` is `{declared}` but the recorded planItems[].verdicts "
1745
+ f"only support `{recomputed}` (a majority DISAGREE, a DISAGREE(f) on "
1746
+ "a P-Req item, or all-non-result dispatches were recorded). The gate "
1747
+ "value must honestly aggregate the worker votes — do not upgrade it "
1748
+ "to unblock the run (convergence.md Round protocol)."
1749
+ )
1750
+
1751
+
1752
+ # Each plan-body deliverable array and the P-* verdict-item prefix it must be
1753
+ # extracted into for §5.5.9 cross-verification (convergence.md Plan-item
1754
+ # extraction). A weak item dropped from planItems escapes the gate entirely.
1755
+ _PLAN_ITEM_SOURCES = (
1756
+ ("optionCandidates", "P-Opt", "Option Candidates (§4.5.1)"),
1757
+ ("stepwiseExecution", "P-Step", "Stepwise Execution Order (§4.5.4)"),
1758
+ ("dependencyMigrationRisk", "P-Dep", "Dependency / Migration Risk (§4.5.5)"),
1759
+ ("validationChecklist", "P-Val", "Validation Checklist (§4.5.6)"),
1760
+ ("rollbackStrategy", "P-Rb", "Rollback Strategy (§4.5.7)"),
1761
+ ("requirementCoverage", "P-Req", "Requirement Coverage (§4.5.8)"),
1762
+ )
1763
+
1764
+
1765
+ def _validate_plan_item_extraction_completeness(data: dict, failures: list[str]) -> None:
1766
+ """H2 — when a verification round ran, every plan-body deliverable item must
1767
+ appear as a matching `P-*` verdict item. Closes the omission hole where the
1768
+ lead silently drops a weak item (e.g. an out-of-order rollback) from
1769
+ planItems so no worker ever verifies it, yet the gate still reads passed.
1770
+ Requires only that no category is *under-extracted*; over-extraction is fine.
1771
+ """
1772
+ ip = data.get("implementationPlanning")
1773
+ if not isinstance(ip, dict):
1774
+ return
1775
+ pbv = ip.get("planBodyVerification")
1776
+ if not isinstance(pbv, dict):
1777
+ return
1778
+ round_count = pbv.get("roundCount")
1779
+ if not isinstance(round_count, int) or round_count < 1:
1780
+ return
1781
+ extracted_ids = [
1782
+ str(it.get("id") or "").upper()
1783
+ for it in (pbv.get("planItems") or [])
1784
+ if isinstance(it, dict)
1785
+ ]
1786
+ for field, prefix, label in _PLAN_ITEM_SOURCES:
1787
+ source_count = len([r for r in (ip.get(field) or []) if isinstance(r, dict)])
1788
+ if source_count == 0:
1789
+ continue
1790
+ extracted = len(
1791
+ [i for i in extracted_ids if i.startswith(prefix.upper() + "-")]
1792
+ )
1793
+ if extracted < source_count:
1794
+ failures.append(
1795
+ f"final-report data.json: {label} has {source_count} item(s) but "
1796
+ f"planBodyVerification.planItems carries only {extracted} "
1797
+ f"`{prefix}-*` verdict item(s). Every plan-body item MUST be "
1798
+ "extracted for cross-verification — a dropped item escapes the "
1799
+ "gate (convergence.md Plan-item extraction)."
1800
+ )
1801
+
1802
+
1803
+ # A `subject` this short or shaped like a bare `P-Opt-1` id is a placeholder,
1804
+ # not the plain-language "what this item is" label §5.5.9 renders as a heading.
1805
+ _MIN_SUBJECT_LEN = 3
1806
+ _BARE_PLAN_ITEM_ID_RE = re.compile(r"^P-(?:Opt|Step|Dep|Val|Rb|Req)-\d+$", re.IGNORECASE)
1807
+
1808
+
1809
+ def _validate_plan_item_subject_substance(data: dict, failures: list[str]) -> None:
1810
+ """H2 follow-up — `planItems[].subject` must be a real label, not a
1811
+ placeholder. The schema only enforces non-empty, so `"x"` or a copied
1812
+ `P-Opt-1` id would otherwise slip through and defeat the whole point of the
1813
+ subject (letting a reader see *what* each AGREE/DISAGREE is about).
1814
+ """
1815
+ ip = data.get("implementationPlanning")
1816
+ if not isinstance(ip, dict):
1817
+ return
1818
+ pbv = ip.get("planBodyVerification")
1819
+ if not isinstance(pbv, dict):
1820
+ return
1821
+ for item in pbv.get("planItems") or []:
1822
+ if not isinstance(item, dict):
1823
+ continue
1824
+ item_id = str(item.get("id") or "").strip()
1825
+ subject = str(item.get("subject") or "").strip()
1826
+ if (
1827
+ len(subject) < _MIN_SUBJECT_LEN
1828
+ or subject == item_id
1829
+ or _BARE_PLAN_ITEM_ID_RE.match(subject)
1830
+ ):
1831
+ failures.append(
1832
+ f"final-report data.json: plan item `{item_id or '<unknown>'}` has a "
1833
+ f"placeholder subject `{subject}`. Give a plain-language label of "
1834
+ "what the item is (e.g. 'Option A: upload v2 를 신규 모듈로 분리') so "
1835
+ "the §5.5.9 reader knows what each verdict is about "
1836
+ "(convergence.md Plan-item extraction)."
1837
+ )
1838
+
1839
+
1840
+ def _validate_plan_body_clarification_matching(data: dict, failures: list[str]) -> None:
1841
+ """H5 — every plan item that the recorded verdicts make `majority-disagree`
1842
+ must point (via `clarificationId`) at an existing `blocks: approval`
1843
+ clarification row. Closes the hole where a majority-disagree item blocks the
1844
+ gate but no §1 Clarification row is raised, so the user never sees why
1845
+ approval is withheld (convergence.md self-review step 7).
1846
+ """
1847
+ ip = data.get("implementationPlanning")
1848
+ if not isinstance(ip, dict):
1849
+ return
1850
+ pbv = ip.get("planBodyVerification")
1851
+ if not isinstance(pbv, dict):
1852
+ return
1853
+ round_count = pbv.get("roundCount")
1854
+ if not isinstance(round_count, int) or round_count < 1:
1855
+ return
1856
+ clar_rows = [r for r in (data.get("clarificationItems") or []) if isinstance(r, dict)]
1857
+ all_ids = {r.get("id") for r in clar_rows if r.get("id")}
1858
+ approval_ids = {r.get("id") for r in clar_rows if r.get("blocks") == "approval" and r.get("id")}
1859
+ for item in pbv.get("planItems") or []:
1860
+ if not isinstance(item, dict):
1861
+ continue
1862
+ if _classify_plan_item_gate(item) != "majority-disagree":
1863
+ continue
1864
+ item_id = item.get("id") or "<unknown>"
1865
+ cid = item.get("clarificationId")
1866
+ if not cid:
1867
+ failures.append(
1868
+ f"final-report data.json: plan item `{item_id}` is majority-disagree "
1869
+ "but carries no `clarificationId`. A blocking disagreement MUST "
1870
+ "surface as a `## 1. Clarification Items` row (blocks=approval) so "
1871
+ "the user sees the blocker (convergence.md self-review step 7)."
1872
+ )
1873
+ elif cid not in approval_ids:
1874
+ reason = (
1875
+ "references a non-existent §1 row"
1876
+ if cid not in all_ids
1877
+ else "references a §1 row whose `blocks` is not `approval`"
1878
+ )
1879
+ failures.append(
1880
+ f"final-report data.json: plan item `{item_id}` (majority-disagree) "
1881
+ f"has clarificationId `{cid}` which {reason}. Every majority-disagree "
1882
+ "item MUST map 1:1 to a `blocks: approval` Clarification row."
1883
+ )
1884
+
1885
+
1886
+ _COVERED_BY_ANCHOR_RE = re.compile(r"option|stage|step", re.IGNORECASE)
1887
+ _COVERED_BY_STAGE_REF_RE = re.compile(r"stage\s*(\d+)", re.IGNORECASE)
1888
+ _COVERED_BY_VAGUE = {"recommended option", "the recommended option", "recommended"}
1889
+
1890
+
1891
+ def _validate_requirement_coverage_covered_by(data: dict, failures: list[str]) -> None:
1892
+ """H3 (partial) — a `covered` requirement row's `coveredBy` must name a
1893
+ concrete plan element that actually exists, not the spec-forbidden bare
1894
+ "recommended option" nor a phantom stage. Whether the cited step truly
1895
+ *satisfies* the requirement stays a worker DISAGREE(f) judgment; this closes
1896
+ the coarser hole where `coveredBy` points at nothing real.
1897
+ """
1898
+ ip = data.get("implementationPlanning")
1899
+ if not isinstance(ip, dict):
1900
+ return
1901
+ rows = [r for r in (ip.get("requirementCoverage") or []) if isinstance(r, dict)]
1902
+ if not rows:
1903
+ return
1904
+ stage_numbers = {
1905
+ s.get("stage") for s in (ip.get("stages") or []) if isinstance(s, dict)
1906
+ }
1907
+ for row in rows:
1908
+ if row.get("status") != "covered":
1909
+ continue
1910
+ rid = row.get("id") or "<row>"
1911
+ covered = str(row.get("coveredBy") or "").strip()
1912
+ if covered.lower() in _COVERED_BY_VAGUE:
1913
+ failures.append(
1914
+ f"final-report data.json: requirementCoverage `{rid}` is `covered` "
1915
+ f"but coveredBy is just `{covered}`. Name the specific Option "
1916
+ "Candidate and Stage/Step that satisfies it, not 'recommended "
1917
+ "option' (profile Requirement Coverage)."
1918
+ )
1919
+ continue
1920
+ if not _COVERED_BY_ANCHOR_RE.search(covered):
1921
+ failures.append(
1922
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1923
+ f"`{covered}` names no Option / Stage / Step. A `covered` row must "
1924
+ "cite the concrete plan element that satisfies the requirement."
1925
+ )
1926
+ continue
1927
+ phantom = [
1928
+ n for m in _COVERED_BY_STAGE_REF_RE.finditer(covered)
1929
+ if stage_numbers and (n := int(m.group(1))) not in stage_numbers
1930
+ ]
1931
+ if phantom:
1932
+ failures.append(
1933
+ f"final-report data.json: requirementCoverage `{rid}` coveredBy "
1934
+ f"cites Stage {phantom[0]} which does not exist in the Stage Map. "
1935
+ "A coverage row must point at a real stage."
1936
+ )
1937
+
1938
+
1587
1939
  def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
1588
1940
  """Enforce verdict ↔ blocker/condition/routing consistency on the
1589
1941
  final-verification data.json (SSOT). The schema guarantees field SHAPE;
@@ -2424,6 +2776,8 @@ def main() -> int:
2424
2776
  except (OSError, json.JSONDecodeError):
2425
2777
  _sp = None
2426
2778
  _validate_conformance(report_path, failures, surface_patterns=_sp)
2779
+ if task_type == "implementation-planning":
2780
+ _validate_planning_conformance_declared(report_path, failures)
2427
2781
  if task_type == "improvement-discovery":
2428
2782
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
2429
2783
  brief_path = (