#!/usr/bin/env python3 """Atomically advance status and record validated artifact omissions.""" from __future__ import annotations import argparse from collections import Counter import os import sys import unicodedata from pathlib import Path from typing import Any from harness_common import ( DEFAULT_CONTRACT_PATH, DEFAULT_RULES_PATH, InputError, atomic_write_json, decode_utf8, json_text, load_json, load_rules, paths_alias, require_regular_nonsymlink, run_lock, schema_version, sha256_file, sha256_text, utc_now, validate_with_schema, ) from lint_document import lint as evaluate_lint STATUSES = ( "initialized", "evidence_ready", "planned", "drafted", "reviewed", "finalized", "verified", "hold_for_review", "failed", "incomplete", ) TERMINAL_FAILURES = {"hold_for_review", "failed", "incomplete"} TERMINAL_ERROR_FIELDS = { "stage", "code", "message", "affected_artifact", "retryable", "safe_next_action", } CHECKPOINT_JSON_SCHEMAS = { "01_sources.json": "sources.schema.json", "02_reader_contract.json": "reader-contract.schema.json", "03_evidence_map.json": "evidence-map.schema.json", "04_logic_map.json": "logic-map.schema.json", "05_term_ledger.json": "term-ledger.schema.json", "08_logic_review.json": "review.schema.json", "08_reader_review.json": "review.schema.json", "08_lint.json": "lint-report.schema.json", } STRUCTURAL_DRAFT_RULES = { "DOC-H001", "DOC-H002", "DOC-M001", "DOC-M002", "DOC-M003", "DOC-M004", } def load_runtime_contract() -> dict[str, Any]: contract = load_json(DEFAULT_CONTRACT_PATH) validate_with_schema( contract, "runtime-contract.schema.json", str(DEFAULT_CONTRACT_PATH), ) return contract def required_artifacts( contract: dict[str, Any], manifest: dict[str, Any], run_dir: Path ) -> set[str]: artifacts = contract["artifacts"] required = set(artifacts["always"]) | set(artifacts[manifest["route_hint"]]) if manifest["mode"] == "review": required.update(artifacts["review_mode"]) required.discard("final.md") return required def artifact_universe(contract: dict[str, Any]) -> set[str]: artifacts = contract["artifacts"] universe: set[str] = set() for branch in ("always", "light", "standard", "deep", "review_mode"): universe.update(artifacts[branch]) return universe def validate_run_manifest(manifest: dict[str, Any], manifest_path: Path) -> None: schema_version(manifest, manifest_path) validate_with_schema(manifest, "run.schema.json", str(manifest_path)) validate_run_history(manifest) def validate_run_history(manifest: dict[str, Any]) -> None: history = manifest.get("history") if not isinstance(history, list) or not history: raise InputError("00_run.json history는 비어 있지 않은 배열이어야 합니다.") first = history[0] if not isinstance(first, dict) or first.get("from") is not None or first.get("to") != "initialized": raise InputError("run history는 (null -> initialized)로 시작해야 합니다.") if not isinstance(first.get("reason"), str) or not first["reason"].strip(): raise InputError("run history[0].reason이 비어 있습니다.") if first.get("error") is not None: raise InputError("run history[0].error는 initialized 상태에서 null이어야 합니다.") previous = "initialized" for index, entry in enumerate(history[1:], start=1): if not isinstance(entry, dict): raise InputError(f"run history[{index}]는 객체여야 합니다.") source = entry.get("from") target = entry.get("to") reason = entry.get("reason") entry_error = entry.get("error") if source != previous: raise InputError( f"run history[{index}].from={source!r}가 이전 상태 {previous!r}와 다릅니다." ) if source not in STATUSES or target not in STATUSES: raise InputError(f"run history[{index}] 상태가 잘못되었습니다.") allowed = allowed_targets(manifest, source) if target not in allowed: raise InputError(f"run history[{index}] 전이가 허용되지 않습니다: {source} -> {target}") if not isinstance(reason, str) or not reason.strip(): raise InputError(f"run history[{index}].reason이 비어 있습니다.") if target == "verified" and ( source != "finalized" or reason != "verify_run pass" ): raise InputError("verified history는 verify_run pass 전이만 허용합니다.") if target in TERMINAL_FAILURES: if not isinstance(entry_error, dict): raise InputError( f"run history[{index}].error는 terminal 상태에서 객체여야 합니다." ) elif entry_error is not None: raise InputError( f"run history[{index}].error는 non-terminal 상태에서 null이어야 합니다." ) previous = target if previous != manifest.get("status"): raise InputError( f"run history 최종 상태 {previous!r}와 status {manifest.get('status')!r}가 다릅니다." ) if manifest.get("error") != history[-1].get("error"): raise InputError("00_run.json error는 마지막 history error snapshot과 같아야 합니다.") def terminal_error_record( current: str, target: str, reason: str, overrides: dict[str, Any] | None, ) -> dict[str, Any]: record: dict[str, Any] = { "stage": current, "code": f"TERMINAL_{target.upper()}", "message": reason, "affected_artifact": None, "retryable": False, "safe_next_action": ( "기록된 오류와 run artifact를 확인하고 blocker를 해결한 뒤 재개 여부를 판단한다." ), } if overrides is None: return record if not isinstance(overrides, dict): raise InputError("terminal error override는 객체여야 합니다.") unknown = sorted(set(overrides) - TERMINAL_ERROR_FIELDS) if unknown: raise InputError(f"알 수 없는 terminal error 필드입니다: {unknown}") record.update(overrides) return record def update_omissions( run_dir: Path, manifest: dict[str, Any], additions: list[tuple[str, str]], removals: list[str], contract: dict[str, Any], ) -> list[dict[str, str]]: universe = artifact_universe(contract) existing = manifest["omissions"] existing_names = [item["artifact"] for item in existing] if any(not isinstance(artifact, str) for artifact in removals): raise InputError("--unomit artifact는 문자열이어야 합니다.") normalized: list[dict[str, str]] = [] for item in additions: if not isinstance(item, (list, tuple)) or len(item) != 2: raise InputError("--omit에는 artifact와 reason 두 값이 필요합니다.") artifact, reason = item if not isinstance(artifact, str) or not isinstance(reason, str): raise InputError("--omit artifact와 reason은 문자열이어야 합니다.") if not reason.strip(): raise InputError(f"omission reason이 비어 있습니다: {artifact!r}") normalized.append({"artifact": artifact, "reason": reason}) removal_counts = Counter(removals) duplicate_removals = sorted( name for name, count in removal_counts.items() if count > 1 ) unknown_removals = sorted(set(removals) - universe) undeclared_removals = sorted(set(removals) - set(existing_names)) addition_names = {item["artifact"] for item in normalized} conflicting = sorted(addition_names & set(removals)) removal_problems: list[str] = [] if duplicate_removals: removal_problems.append(f"duplicate_unomit={duplicate_removals}") if unknown_removals: removal_problems.append(f"unknown_unomit={unknown_removals}") if undeclared_removals: removal_problems.append(f"not_declared={undeclared_removals}") if conflicting: removal_problems.append(f"omit_and_unomit={conflicting}") if removal_problems: raise InputError("잘못된 omission 철회: " + "; ".join(removal_problems)) removal_set = set(removals) merged = [ *[item for item in existing if item["artifact"] not in removal_set], *normalized, ] declared = [item["artifact"] for item in merged] required = required_artifacts(contract, manifest, run_dir) duplicates = sorted(name for name, count in Counter(declared).items() if count > 1) unknown = sorted(set(declared) - universe) required_omitted = sorted(set(declared) & required) present_omitted = sorted( name for name in set(declared) if os.path.lexists(run_dir / name) ) problems: list[str] = [] if duplicates: problems.append(f"duplicate={duplicates}") if unknown: problems.append(f"unknown={unknown}") if required_omitted: problems.append(f"required={required_omitted}") if present_omitted: problems.append(f"present={present_omitted}") if problems: raise InputError("잘못된 omission 선언: " + "; ".join(problems)) return merged def normal_targets(manifest: dict[str, Any]) -> dict[str, set[str]]: route = manifest.get("route_hint") mode = manifest.get("mode") if route not in {"light", "standard", "deep"}: raise InputError(f"알 수 없는 route_hint입니다: {route!r}") if mode not in {"write", "revise", "review"}: raise InputError(f"알 수 없는 mode입니다: {mode!r}") if route == "light": initialized = {"planned", "evidence_ready"} else: initialized = {"evidence_ready"} targets: dict[str, set[str]] = { "initialized": initialized, "evidence_ready": {"planned"}, "planned": {"reviewed"} if mode == "review" else {"drafted"}, "drafted": {"finalized"} if route == "light" else {"reviewed"}, "reviewed": set() if mode == "review" else {"finalized"}, "finalized": {"verified"} if mode != "review" else set(), "verified": set(), "hold_for_review": set(), "failed": set(), "incomplete": set(), } if route == "light" and mode != "review": targets["drafted"].add("reviewed") return targets def allowed_targets(manifest: dict[str, Any], source: str) -> set[str]: """Return legal next states without making failure states recoverable.""" normal = normal_targets(manifest).get(source, set()) if source in TERMINAL_FAILURES: return set() return normal | TERMINAL_FAILURES def normalized_label(value: str) -> str: return unicodedata.normalize("NFKC", " ".join(value.split())).casefold() def checkpoint_file(run_dir: Path, name: str) -> Path: return require_regular_nonsymlink(run_dir / name, name) def optional_checkpoint_file(run_dir: Path, name: str) -> Path | None: path = run_dir / name if not os.path.lexists(path): return None return require_regular_nonsymlink(path, name) def checkpoint_json(run_dir: Path, name: str) -> dict[str, Any]: path = checkpoint_file(run_dir, name) value = load_json(path) schema_name = CHECKPOINT_JSON_SCHEMAS[name] validate_with_schema(value, schema_name, str(path)) schema_version(value, path) return value def validate_utf8_checkpoint(run_dir: Path, name: str) -> Path: path = checkpoint_file(run_dir, name) try: data = path.read_bytes() except OSError as exc: raise InputError(f"{name} 파일을 읽을 수 없습니다: {exc}") from exc decode_utf8(data, name) return path def validate_draft_checkpoint(run_dir: Path) -> None: draft_path = validate_utf8_checkpoint(run_dir, "07_draft.md") report, _ = evaluate_lint( argparse.Namespace( document=str(draft_path), logic_map=str(run_dir / "04_logic_map.json"), term_ledger=str(run_dir / "05_term_ledger.json"), reader_contract=str(run_dir / "02_reader_contract.json"), baseline=None, draft_baseline=None, fail_on="error", rules=str(DEFAULT_RULES_PATH), ) ) structural = sorted( { finding["rule_id"] for finding in report["findings"] if finding["rule_id"] in STRUCTURAL_DRAFT_RULES } ) if structural: raise InputError( "07_draft.md structural lint가 실패했습니다: " + ", ".join(structural) ) if report["document"]["sha256"] != sha256_file(draft_path): raise InputError("07_draft.md가 structural lint 도중 변경되었습니다.") def validate_review_draft_immutability( run_dir: Path, manifest: dict[str, Any] ) -> None: if manifest.get("mode") != "review": return draft_inventory = manifest.get("inputs", {}).get("draft") if not isinstance(draft_inventory, dict) or not isinstance( draft_inventory.get("sha256"), str ): raise InputError("review mode의 초기 draft SHA-256 inventory가 없습니다.") draft_path = validate_utf8_checkpoint(run_dir, "07_draft.md") if sha256_file(draft_path) != draft_inventory["sha256"]: raise InputError( "review mode의 07_draft.md는 run 초기화 때 고정한 원본 draft와 " "byte-identical이어야 합니다." ) def current_artifact_hash( run_dir: Path, name: str, *, optional: bool = False ) -> str | None: path = ( optional_checkpoint_file(run_dir, name) if optional else checkpoint_file(run_dir, name) ) return sha256_file(path) if path is not None else None def validate_runtime_provenance(manifest: dict[str, Any]) -> dict[str, Any]: contract = load_runtime_contract() contract_hash = sha256_file(DEFAULT_CONTRACT_PATH) if manifest.get("contract_sha256") != contract_hash: raise InputError( "checkpoint의 runtime contract가 run 시작 시 고정한 hash와 다릅니다." ) rules = load_rules(DEFAULT_RULES_PATH) rules_hash = sha256_file(DEFAULT_RULES_PATH) if ( manifest.get("rules_version") != rules.get("rules_version") or manifest.get("rules_sha256") != rules_hash ): raise InputError( "checkpoint의 quality rules version/hash가 run 시작 시 고정한 값과 다릅니다." ) return contract def validate_source_registry( run_dir: Path, manifest: dict[str, Any], sources: dict[str, Any] ) -> None: inputs = manifest["inputs"] if sha256_file(checkpoint_file(run_dir, "01_input.md")) != inputs.get( "input_sha256" ): raise InputError("01_input.md SHA-256이 00_run.json의 고정값과 다릅니다.") if sha256_text(json_text(sources)) != inputs.get("sources_manifest_sha256"): raise InputError( "01_sources.json 내용이 00_run.json.inputs.sources_manifest_sha256와 " "다릅니다." ) if ( inputs.get("brief") != sources.get("brief") or inputs.get("draft") != sources.get("draft") or inputs.get("source_count") != len(sources.get("sources", [])) ): raise InputError("01_sources.json inventory와 00_run.json.inputs가 다릅니다.") brief = sources.get("brief") draft = sources.get("draft") if ( not isinstance(brief, dict) or inputs.get("brief_sha256") != brief.get("sha256") or inputs.get("draft_sha256") != (draft.get("sha256") if isinstance(draft, dict) else None) ): raise InputError("brief/draft SHA-256 요약이 source registry와 다릅니다.") inventories = [brief, draft, *sources.get("sources", [])] for item in inventories: if item is None: continue resolved_path = item.get("resolved_path") if not isinstance(resolved_path, str): raise InputError(f"source {item.get('id')!r}의 resolved_path가 없습니다.") source_path = require_regular_nonsymlink( Path(resolved_path), f"source {item.get('id')!r}" ) current_hash = sha256_file(source_path) if current_hash != item.get("sha256"): raise InputError( f"source {item.get('id')!r} SHA-256이 run 초기화 snapshot과 다릅니다." ) if item.get("size_bytes") != source_path.stat().st_size: raise InputError( f"source {item.get('id')!r} size가 run 초기화 snapshot과 다릅니다." ) def validate_evidence_checkpoint(run_dir: Path) -> None: sources = checkpoint_json(run_dir, "01_sources.json") evidence = checkpoint_json(run_dir, "03_evidence_map.json") inventories = [ sources.get("brief"), sources.get("draft"), *sources.get("sources", []), ] known_source_ids = { item["id"] for item in inventories if isinstance(item, dict) and isinstance(item.get("id"), str) } claim_ids: set[str] = set() premises: dict[str, list[str]] = {} for claim in evidence["claims"]: claim_id = claim["id"] if claim_id in claim_ids: raise InputError(f"03_evidence_map.json claim id가 중복됩니다: {claim_id}") claim_ids.add(claim_id) source_ids = set(claim["source_ids"]) location_pairs = [ (item["source_id"], item["locator"]) for item in claim["source_locations"] ] if len(location_pairs) != len(set(location_pairs)): raise InputError( f"03_evidence_map.json claim {claim_id}의 source locator가 중복됩니다." ) location_ids = {source_id for source_id, _ in location_pairs} unknown = (source_ids | location_ids) - known_source_ids if unknown: raise InputError( f"03_evidence_map.json claim {claim_id}가 모르는 source를 참조합니다: " f"{sorted(unknown)}" ) if source_ids != location_ids: raise InputError( f"03_evidence_map.json claim {claim_id}의 source_ids와 " "source_locations가 일치하지 않습니다." ) if claim["status"] == "derived": premises[claim_id] = list(claim.get("premise_ids", [])) for claim_id, premise_ids in premises.items(): unknown = set(premise_ids) - claim_ids if unknown or claim_id in premise_ids: raise InputError( f"03_evidence_map.json derived claim {claim_id}의 premise_ids가 " f"잘못되었습니다: {sorted(unknown | ({claim_id} & set(premise_ids)))}" ) visiting: set[str] = set() visited: set[str] = set() def visit(claim_id: str) -> None: if claim_id in visiting: raise InputError( f"03_evidence_map.json derived claim premise cycle이 있습니다: {claim_id}" ) if claim_id in visited: return visiting.add(claim_id) for premise_id in premises.get(claim_id, []): visit(premise_id) visiting.remove(claim_id) visited.add(claim_id) for claim_id in claim_ids: visit(claim_id) def validate_plan_checkpoint(run_dir: Path, manifest: dict[str, Any]) -> None: reader = checkpoint_json(run_dir, "02_reader_contract.json") logic = checkpoint_json(run_dir, "04_logic_map.json") terms = checkpoint_json(run_dir, "05_term_ledger.json") configured_term_thresholds = load_rules(DEFAULT_RULES_PATH)["thresholds"]["term"] expected_budgets = { "per_sentence": configured_term_thresholds["max_new_terms_per_sentence"], "per_paragraph": configured_term_thresholds["max_new_terms_per_paragraph"], "per_section": configured_term_thresholds["max_new_terms_per_section"], } if terms.get("budgets") != expected_budgets: raise InputError( "05_term_ledger.json budgets가 quality rules와 다릅니다: " f"recorded={terms.get('budgets')!r}, expected={expected_budgets!r}" ) if not ( reader.get("document_kind") == manifest.get("document_kind") == logic.get("document_kind") ): raise InputError( "planned checkpoint의 run/reader/logic document_kind가 일치하지 않습니다." ) requested_audience = manifest.get("audience") if isinstance(requested_audience, str) and normalized_label( requested_audience ) != normalized_label(reader.get("primary_audience", "")): raise InputError( "planned checkpoint의 요청 audience와 primary_audience가 일치하지 않습니다." ) if set(reader["assumed_known"]) != set(terms["assumed_known"]): raise InputError( "planned checkpoint의 reader/term assumed_known이 일치하지 않습니다." ) assumed = {normalized_label(item) for item in reader["assumed_known"]} must_explain = {normalized_label(item) for item in reader["must_explain"]} overlap = assumed & must_explain if overlap: raise InputError( f"planned checkpoint에서 assumed_known와 must_explain이 겹칩니다: " f"{sorted(overlap)}" ) explainable: set[str] = set() term_ids: set[str] = set() expected_term_sections: dict[str, str] = {} term_name_owners: dict[str, tuple[str, str]] = {} for term in terms["terms"]: term_id = term["id"] if term_id in term_ids: raise InputError(f"05_term_ledger.json term id가 중복됩니다: {term_id}") term_ids.add(term_id) expected_term_sections[term_id] = term["first_section"] for field_name, name in ( ("canonical", term.get("canonical")), *(("alias", alias) for alias in term.get("aliases", [])), ("english", term.get("english")), ("abbreviation", term.get("abbreviation")), ): if isinstance(name, str) and name.strip(): normalized = normalized_label(name) previous = term_name_owners.get(normalized) if previous is not None: raise InputError( f"05_term_ledger.json 용어 이름 {name!r}이 둘 이상에 " f"배정되었습니다: {previous[0]}.{previous[1]}, " f"{term_id}.{field_name}" ) term_name_owners[normalized] = (term_id, field_name) explainable.add(normalized) missing_explanations = sorted( item for item in reader["must_explain"] if normalized_label(item) not in explainable ) if missing_explanations: raise InputError( "planned checkpoint의 must_explain 항목이 term ledger에 없습니다: " f"{missing_explanations}" ) sections = logic["sections"] section_ids: set[str] = set() actual_term_sections: dict[str, list[str]] = {} for index, section in enumerate(sections): section_id = section["id"] if section_id in section_ids: raise InputError(f"04_logic_map.json section id가 중복됩니다: {section_id}") unknown_dependencies = set(section["depends_on"]) - section_ids if unknown_dependencies: raise InputError( f"logic section {section_id}가 앞선 절이 아닌 depends_on을 참조합니다: " f"{sorted(unknown_dependencies)}" ) if index > 0 and not section["depends_on"]: raise InputError(f"logic section {section_id}에는 depends_on이 필요합니다.") expected_transition = sections[index + 1]["id"] if index + 1 < len(sections) else None if section["transition_to"] != expected_transition: raise InputError( f"logic section {section_id}.transition_to는 다음 절 " f"{expected_transition!r}이어야 합니다." ) section_ids.add(section_id) for term_id in section["new_terms"]: actual_term_sections.setdefault(term_id, []).append(section_id) unknown_terms = set(actual_term_sections) - term_ids mismatched_terms = { term_id: actual_term_sections.get(term_id, []) for term_id, expected_section in expected_term_sections.items() if actual_term_sections.get(term_id, []) != [expected_section] } if unknown_terms or mismatched_terms: raise InputError( "planned checkpoint의 logic/term 연결이 맞지 않습니다: " f"unknown={sorted(unknown_terms)}, first_section={mismatched_terms}" ) evidence_path = optional_checkpoint_file(run_dir, "03_evidence_map.json") if evidence_path is not None: evidence = checkpoint_json(run_dir, "03_evidence_map.json") evidence_ids = {claim["id"] for claim in evidence["claims"]} referenced_claims = { claim_id for section in sections for claim_id in section["claim_ids"] } unknown_claims = referenced_claims - evidence_ids if unknown_claims: raise InputError( "planned checkpoint의 logic map이 모르는 claim을 참조합니다: " f"{sorted(unknown_claims)}" ) def validate_review_semantics(review: dict[str, Any], name: str) -> None: finding_ids: set[str] = set() blocking = 0 for finding in review["findings"]: finding_id = finding["id"] if finding_id in finding_ids: raise InputError(f"{name} finding id가 중복됩니다: {finding_id}") finding_ids.add(finding_id) if finding["severity"] in {"critical", "high"}: blocking += 1 verdict = review["verdict"] if verdict == "pass" and blocking: raise InputError(f"{name} verdict=pass에는 blocking finding이 있을 수 없습니다.") if verdict in {"revise", "hold_for_review"} and not blocking: raise InputError(f"{name} verdict={verdict}에는 blocking finding이 필요합니다.") def expected_review_inputs(run_dir: Path) -> dict[str, str | None]: return { "input_sha256": current_artifact_hash(run_dir, "01_input.md"), "sources_sha256": current_artifact_hash(run_dir, "01_sources.json"), "reader_contract_sha256": current_artifact_hash( run_dir, "02_reader_contract.json" ), "evidence_map_sha256": current_artifact_hash( run_dir, "03_evidence_map.json", optional=True ), "logic_map_sha256": current_artifact_hash(run_dir, "04_logic_map.json"), "term_ledger_sha256": current_artifact_hash(run_dir, "05_term_ledger.json"), } def validate_reviews_checkpoint(run_dir: Path, manifest: dict[str, Any]) -> None: draft_path = checkpoint_file(run_dir, "07_draft.md") draft_hash = sha256_file(draft_path) expected_inputs = expected_review_inputs(run_dir) allowed_verdicts = ( {"pass", "revise"} if manifest["mode"] == "review" else {"pass"} ) for name, expected_type in ( ("08_logic_review.json", "logic"), ("08_reader_review.json", "reader"), ): review = checkpoint_json(run_dir, name) validate_review_semantics(review, name) if review.get("review_type") != expected_type: raise InputError(f"{name} review_type은 {expected_type!r}이어야 합니다.") document = review.get("document", {}) recorded_path = document.get("path") if not isinstance(recorded_path, str): raise InputError(f"{name} document.path가 없습니다.") candidate = Path(recorded_path).expanduser() if not candidate.is_absolute(): candidate = run_dir / candidate if not paths_alias(candidate, draft_path) or document.get("sha256") != draft_hash: raise InputError(f"{name}이 현재 07_draft.md의 path/hash를 검토하지 않았습니다.") if review.get("inputs") != expected_inputs: raise InputError(f"{name}의 upstream artifact hash 묶음이 현재 값과 다릅니다.") if review.get("verdict") not in allowed_verdicts: raise InputError( f"{name} verdict={review.get('verdict')!r}는 mode={manifest['mode']!r}의 " "reviewed checkpoint를 통과할 수 없습니다." ) def fidelity_record_matches(record: Any, path: Path) -> bool: if not isinstance(record, dict) or not isinstance(record.get("path"), str): return False return paths_alias(Path(record["path"]), path) and record.get("sha256") == sha256_file(path) def validate_lint_checkpoint( run_dir: Path, manifest: dict[str, Any], *, publish: bool ) -> None: lint_report = checkpoint_json(run_dir, "08_lint.json") mode = manifest["mode"] expected_document = checkpoint_file( run_dir, "final.md" if publish else "07_draft.md" ) expected_verdicts = {"pass"} if publish else {"pass", "fail"} if lint_report.get("tool") != "lint_document" or lint_report.get( "verdict" ) not in expected_verdicts: raise InputError( f"08_lint.json verdict={lint_report.get('verdict')!r}가 checkpoint 조건을 " "만족하지 않습니다." ) document = lint_report.get("document", {}) recorded_path = document.get("path") if not isinstance(recorded_path, str) or not paths_alias( Path(recorded_path), expected_document ) or document.get("sha256") != sha256_file(expected_document): raise InputError("08_lint.json 대상 path/hash가 현재 문서와 다릅니다.") rules = load_rules(DEFAULT_RULES_PATH) rules_hash = sha256_file(DEFAULT_RULES_PATH) if ( lint_report.get("rules_version") != rules.get("rules_version") or lint_report.get("rules_sha256") != rules_hash or manifest.get("rules_version") != rules.get("rules_version") or manifest.get("rules_sha256") != rules_hash ): raise InputError("08_lint.json quality rules version/hash가 현재 run과 다릅니다.") for artifact_name, hash_field in ( ("04_logic_map.json", "logic_map_sha256"), ("05_term_ledger.json", "term_ledger_sha256"), ("02_reader_contract.json", "reader_contract_sha256"), ): if lint_report.get(hash_field) != current_artifact_hash(run_dir, artifact_name): raise InputError(f"08_lint.json의 {artifact_name} hash가 현재 값과 다릅니다.") fidelity = lint_report.get("fidelity", {}) draft_path = checkpoint_file(run_dir, "07_draft.md") draft_record = fidelity.get("draft_baseline") if mode in {"write", "revise"}: if not fidelity_record_matches(draft_record, draft_path): raise InputError("write/revise lint의 draft_baseline은 현재 07_draft.md여야 합니다.") if document.get("sha256") != draft_record.get("sha256"): raise InputError("final.md와 07_draft.md lint hash가 같아야 합니다.") elif draft_record is not None: raise InputError("review mode lint에는 draft_baseline을 사용할 수 없습니다.") original_draft = manifest.get("inputs", {}).get("draft") baseline_record = fidelity.get("baseline") baseline_path: Path | None = None if mode == "revise": if not isinstance(original_draft, dict): raise InputError("revise mode의 원본 draft inventory가 없습니다.") baseline_path = checkpoint_file( Path(original_draft["resolved_path"]).parent, Path(original_draft["resolved_path"]).name, ) original_hash = original_draft.get("sha256") if sha256_file(baseline_path) != original_hash: raise InputError( "revise 원본 draft가 run 초기화 때 고정한 SHA-256과 다릅니다." ) if ( not fidelity_record_matches(baseline_record, baseline_path) or baseline_record.get("sha256") != original_hash ): raise InputError("revise lint baseline은 run이 고정한 원본 draft여야 합니다.") elif mode == "write" and baseline_record is not None: raise InputError("write mode lint에는 원본 baseline을 사용할 수 없습니다.") elif mode == "review" and baseline_record is not None: if not isinstance(original_draft, dict): raise InputError("review mode의 원본 draft inventory가 없습니다.") baseline_path = checkpoint_file( Path(original_draft["resolved_path"]).parent, Path(original_draft["resolved_path"]).name, ) original_hash = original_draft.get("sha256") if sha256_file(baseline_path) != original_hash: raise InputError( "review 원본 draft가 run 초기화 때 고정한 SHA-256과 다릅니다." ) if ( not fidelity_record_matches(baseline_record, baseline_path) or baseline_record.get("sha256") != original_hash ): raise InputError("review lint baseline은 run이 고정한 원본 draft여야 합니다.") if manifest["route_hint"] == "deep" and lint_report.get("fail_on") != "warning": raise InputError("deep route lint는 fail_on=warning이어야 합니다.") replay_args = argparse.Namespace( document=str(expected_document), logic_map=str(run_dir / "04_logic_map.json"), term_ledger=str(run_dir / "05_term_ledger.json"), reader_contract=str(run_dir / "02_reader_contract.json"), baseline=str(baseline_path) if baseline_path is not None else None, draft_baseline=( str(draft_path) if mode in {"write", "revise"} else None ), fail_on=lint_report["fail_on"], rules=str(DEFAULT_RULES_PATH), ) replay_report, _ = evaluate_lint(replay_args) comparable = {key: value for key, value in lint_report.items() if key != "generated_at"} replay_comparable = { key: value for key, value in replay_report.items() if key != "generated_at" } if comparable != replay_comparable: raise InputError("08_lint.json이 현재 입력의 canonical lint 재실행 결과와 다릅니다.") def validate_omissions_complete( run_dir: Path, manifest: dict[str, Any], contract: dict[str, Any] ) -> None: required = required_artifacts(contract, manifest, run_dir) universe = artifact_universe(contract) present = { name for name in universe if ( (run_dir / name).is_file() and not (run_dir / name).is_symlink() and (run_dir / name).stat().st_size > 0 ) } declared = {item["artifact"] for item in manifest["omissions"]} undeclared = sorted((universe - required - present) - declared) if undeclared: raise InputError( "checkpoint 전에 optional artifact omission 이유를 모두 기록해야 합니다: " f"{undeclared}" ) def validate_required_final_artifacts( run_dir: Path, manifest: dict[str, Any], contract: dict[str, Any] ) -> None: required = required_artifacts(contract, manifest, run_dir) - { "00_run.json", "09_final_report.json", } for name in sorted(required): path = checkpoint_file(run_dir, name) schema_name = CHECKPOINT_JSON_SCHEMAS.get(name) if schema_name is not None: value = load_json(path) validate_with_schema(value, schema_name, str(path)) schema_version(value, path) def validate_checkpoint_transition( run_dir: Path, manifest: dict[str, Any], current: str, target: str, ) -> None: if target not in {"evidence_ready", "planned", "drafted", "reviewed", "finalized"}: return contract = validate_runtime_provenance(manifest) sources = checkpoint_json(run_dir, "01_sources.json") validate_source_registry(run_dir, manifest, sources) validate_review_draft_immutability(run_dir, manifest) needs_evidence = manifest["route_hint"] in {"standard", "deep"} has_optional_evidence = os.path.lexists(run_dir / "03_evidence_map.json") if target == "evidence_ready" or ( (needs_evidence or has_optional_evidence) and target in {"planned", "drafted", "reviewed", "finalized"} ): validate_evidence_checkpoint(run_dir) if target in {"planned", "drafted", "reviewed", "finalized"}: validate_plan_checkpoint(run_dir, manifest) if target == "drafted": validate_draft_checkpoint(run_dir) elif target == "reviewed": if manifest["mode"] != "review": validate_draft_checkpoint(run_dir) validate_reviews_checkpoint(run_dir, manifest) validate_omissions_complete(run_dir, manifest, contract) if manifest["mode"] == "review": validate_required_final_artifacts(run_dir, manifest, contract) validate_lint_checkpoint(run_dir, manifest, publish=False) elif target == "finalized": validate_draft_checkpoint(run_dir) validate_omissions_complete(run_dir, manifest, contract) validate_required_final_artifacts(run_dir, manifest, contract) if current == "reviewed": validate_reviews_checkpoint(run_dir, manifest) elif any( os.path.lexists(run_dir / name) for name in ("08_logic_review.json", "08_reader_review.json") ): raise InputError( "review artifact가 있으면 reviewed checkpoint를 거친 뒤 finalized로 전이해야 합니다." ) draft_path = checkpoint_file(run_dir, "07_draft.md") final_path = checkpoint_file(run_dir, "final.md") try: identical = draft_path.read_bytes() == final_path.read_bytes() except OSError as exc: raise InputError(f"draft/final byte 비교에 실패했습니다: {exc}") from exc if not identical: raise InputError("final.md는 현재 07_draft.md와 byte-identical이어야 합니다.") validate_lint_checkpoint(run_dir, manifest, publish=True) def transition_manifest( manifest_path: Path, target: str, *, reason: str, allow_same: bool = False, error: dict[str, Any] | None = None, ) -> dict[str, Any]: return update_manifest( manifest_path, manifest_path.parent, target=target, reason=reason, omissions=[], unomit=[], allow_same=allow_same, error=error, ) def update_manifest( manifest_path: Path, run_dir: Path, *, target: str | None, reason: str, omissions: list[tuple[str, str]], unomit: list[str], allow_same: bool = False, error: dict[str, Any] | None = None, ) -> dict[str, Any]: if target is None and not omissions and not unomit: raise InputError("--status, --omit, --unomit 중 하나 이상이 필요합니다.") if target is not None and target not in STATUSES: raise InputError(f"알 수 없는 status입니다: {target}") if error is not None and target not in TERMINAL_FAILURES: raise InputError("structured error는 terminal status 전이에만 사용할 수 있습니다.") manifest = load_json(manifest_path) validate_run_manifest(manifest, manifest_path) if omissions or unomit: contract = load_runtime_contract() merged_omissions = update_omissions( run_dir, manifest, omissions, unomit, contract ) elif target in TERMINAL_FAILURES: # A verifier must still be able to move a run with invalid business-level # omission declarations to a terminal failure state. JSON Schema remains # mandatory, while terminal failure recording must not be blocked by the # defect it is trying to report. merged_omissions = manifest["omissions"] else: contract = load_runtime_contract() merged_omissions = update_omissions(run_dir, manifest, [], [], contract) current = manifest.get("status") if current not in STATUSES: raise InputError(f"현재 status가 잘못되었습니다: {current!r}") if target == current and allow_same and not omissions and not unomit and error is None: return manifest history = manifest.get("history") if not isinstance(history, list): raise InputError("00_run.json history는 배열이어야 합니다.") if target is not None and target != current: allowed = allowed_targets(manifest, current) if target not in allowed: raise InputError(f"허용되지 않은 status 전이입니다: {current} -> {target}") elif target == current and not allow_same: raise InputError(f"동일한 status로 전이할 수 없습니다: {current}") if target is not None and ( not isinstance(reason, str) or not reason.strip() ): raise InputError("status 전이 reason이 비어 있습니다.") if target is not None and target != current: checkpoint_manifest = dict(manifest) checkpoint_manifest["omissions"] = merged_omissions validate_checkpoint_transition( run_dir, checkpoint_manifest, str(current), target, ) now = utc_now() updated = dict(manifest) updated["status"] = target if target is not None else current updated["updated_at"] = now updated["omissions"] = merged_omissions next_error = manifest.get("error") if target is not None and target != current: next_error = ( terminal_error_record(current, target, reason, error) if target in TERMINAL_FAILURES else None ) updated["error"] = next_error if target is not None and target != current: updated["history"] = [ *history, { "at": now, "from": current, "to": target, "reason": reason, "error": next_error, }, ] validate_run_manifest(updated, manifest_path) atomic_write_json(manifest_path, updated) return updated def update_run( run_dir: Path, target: str | None = None, reason: str = "manual workflow transition", *, omissions: list[tuple[str, str]] | None = None, unomit: list[str] | None = None, error: dict[str, Any] | None = None, ) -> dict[str, Any]: try: resolved = run_dir.expanduser().resolve(strict=True) except OSError as exc: raise InputError(f"run directory가 없습니다: {run_dir}") from exc if not resolved.is_dir(): raise InputError(f"run-dir은 디렉터리여야 합니다: {run_dir}") manifest_path = require_regular_nonsymlink( resolved / "00_run.json", "00_run.json" ) if target == "verified": raise InputError("verified 상태는 통과한 verify_run.py만 기록할 수 있습니다.") with run_lock(resolved): return update_manifest( manifest_path, resolved, target=target, reason=reason, omissions=omissions or [], unomit=unomit or [], error=error, ) def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description=__doc__) value.add_argument("--run-dir", required=True) value.add_argument("--status", choices=STATUSES) value.add_argument("--reason", default="manual workflow transition") value.add_argument("--error-stage") value.add_argument("--error-code") value.add_argument("--error-message") value.add_argument("--error-affected-artifact") retryability = value.add_mutually_exclusive_group() retryability.add_argument( "--error-retryable", dest="error_retryable", action="store_true", help="terminal error를 안전하게 재시도할 수 있다고 명시", ) retryability.add_argument( "--error-not-retryable", dest="error_retryable", action="store_false", help="terminal error를 자동 재시도하면 안 된다고 명시", ) value.set_defaults(error_retryable=None) value.add_argument("--error-safe-next-action") value.add_argument( "--omit", nargs=2, action="append", default=[], metavar=("ARTIFACT", "REASON"), help="생략할 정본 artifact와 구체적인 이유; 여러 번 지정 가능", ) value.add_argument( "--unomit", action="append", default=[], metavar="ARTIFACT", help="기존 omission을 철회; artifact를 만들기 전에 여러 번 지정 가능", ) return value def main(argv: list[str] | None = None) -> int: args = parser().parse_args(argv) error_values = { "stage": args.error_stage, "code": args.error_code, "message": args.error_message, "affected_artifact": args.error_affected_artifact, "retryable": args.error_retryable, "safe_next_action": args.error_safe_next_action, } error = ( {key: value for key, value in error_values.items() if value is not None} if any(value is not None for value in error_values.values()) else None ) try: manifest = update_run( Path(args.run_dir), args.status, args.reason, omissions=[tuple(item) for item in args.omit], unomit=args.unomit, error=error, ) except InputError as exc: print(f"input error: {exc}", file=sys.stderr) return 2 print(f"{manifest['run_id']}\tstatus={manifest['status']}") return 0 if __name__ == "__main__": raise SystemExit(main())