G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,724 @@
#!/usr/bin/env python3
"""Assemble and dry-run a clean Outcome/Alliance OS release patch.
The source worktree is read-only except for the declared patch artifact. A
clean HEAD tree is materialized under the system temporary directory, related
whole files are overlaid, mixed documents are reconstructed from an exact hunk
map, and api.gen.ts is regenerated with the repository's official npm script.
The candidate uses canonical LF hunks, must apply to both a clean checkout and
an isolated canonical index, and replaces the prior artifact only after every
guard passes.
"""
from __future__ import annotations
import argparse
import difflib
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MAP = REPO_ROOT / "docs/ops/outcome-os-release-hunk-map-2026-08-07.json"
HUNK_HEADER = re.compile(
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"
)
class AssemblyError(RuntimeError):
"""Release assembly failed closed."""
@dataclass(frozen=True)
class Hunk:
header: str
raw: str
old_start: int
old_count: int
new_start: int
new_count: int
removed: tuple[str, ...]
added: tuple[str, ...]
@property
def sha256(self) -> str:
return hashlib.sha256(self.raw.encode("utf-8")).hexdigest()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--map", type=Path, default=DEFAULT_MAP)
parser.add_argument("--json", action="store_true", help="emit JSON result")
return parser.parse_args()
def run(
argv: list[str],
*,
cwd: Path = REPO_ROOT,
env: dict[str, str] | None = None,
allow_failure: bool = False,
) -> subprocess.CompletedProcess[bytes]:
result = subprocess.run(
argv,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode and not allow_failure:
stdout = result.stdout.decode("utf-8", errors="replace")[-2000:]
stderr = result.stderr.decode("utf-8", errors="replace")[-4000:]
raise AssemblyError(
f"command failed ({result.returncode}): {' '.join(argv)}\n"
f"stdout:\n{stdout}\nstderr:\n{stderr}"
)
return result
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise AssemblyError(f"cannot read JSON {path}: {exc}") from exc
if not isinstance(value, dict):
raise AssemblyError(f"JSON root must be an object: {path}")
return value
def canonical_path(value: Any) -> str:
if not isinstance(value, str) or not value or "\\" in value:
raise AssemblyError(f"path is not canonical: {value!r}")
candidate = PurePosixPath(value)
if candidate.is_absolute() or ".." in candidate.parts or str(candidate) != value:
raise AssemblyError(f"path escapes repository: {value!r}")
return value
def current_head() -> str:
return run(["git", "rev-parse", "HEAD"]).stdout.decode("ascii").strip()
def git_dirty_paths() -> set[str]:
payload = run(
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"]
).stdout
records = payload.split(b"\0")
paths: set[str] = set()
index = 0
while index < len(records):
record = records[index]
index += 1
if not record:
continue
text = record.decode("utf-8", errors="surrogateescape")
if len(text) < 4 or text[2] != " ":
raise AssemblyError(f"unexpected git status record: {text!r}")
status = text[:2]
path = text[3:].replace("\\", "/")
paths.add(path)
if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
if index >= len(records) or not records[index]:
raise AssemblyError(f"rename/copy lacks source path: {text!r}")
index += 1
return paths
def file_digest(path: Path) -> str | None:
if not path.exists():
return None
if not path.is_file():
raise AssemblyError(f"dirty path is not a regular file: {path}")
return hashlib.sha256(path.read_bytes()).hexdigest()
def snapshot_worktree() -> dict[str, str | None]:
return {
path: file_digest(REPO_ROOT / path)
for path in sorted(git_dirty_paths())
}
def verify_worktree_unchanged(
before: dict[str, str | None], output_path: str
) -> None:
after = snapshot_worktree()
before_without_output = {k: v for k, v in before.items() if k != output_path}
after_without_output = {k: v for k, v in after.items() if k != output_path}
if before_without_output != after_without_output:
before_keys = set(before_without_output)
after_keys = set(after_without_output)
added = sorted(after_keys - before_keys)
removed = sorted(before_keys - after_keys)
changed = sorted(
key
for key in before_keys & after_keys
if before_without_output[key] != after_without_output[key]
)
raise AssemblyError(
"source worktree changed during assembly: "
f"added={added}, removed={removed}, changed={changed}"
)
def parse_hunks(path: str) -> list[Hunk]:
raw = run(
[
"git",
"-c",
"color.ui=false",
"diff",
"--unified=0",
"--no-ext-diff",
"--",
path,
]
).stdout.decode("utf-8")
starts = [match.start() for match in re.finditer(r"^@@ ", raw, re.MULTILINE)]
hunks: list[Hunk] = []
for index, start in enumerate(starts):
end = starts[index + 1] if index + 1 < len(starts) else len(raw)
chunk = raw[start:end]
lines = chunk.splitlines()
if not lines:
raise AssemblyError(f"empty diff hunk: {path}")
header = lines[0]
match = HUNK_HEADER.match(header)
if match is None:
raise AssemblyError(f"unparseable hunk header for {path}: {header}")
old_start = int(match.group(1))
old_count = int(match.group(2) or "1")
new_start = int(match.group(3))
new_count = int(match.group(4) or "1")
removed: list[str] = []
added: list[str] = []
for line in lines[1:]:
if line.startswith("-"):
removed.append(line[1:])
elif line.startswith("+"):
added.append(line[1:])
elif line == "\\ No newline at end of file":
raise AssemblyError(f"no-final-newline hunk is unsupported: {path} {header}")
else:
raise AssemblyError(
f"unexpected zero-context hunk line for {path} {header}: {line!r}"
)
if len(removed) != old_count or len(added) != new_count:
raise AssemblyError(
f"hunk count mismatch for {path} {header}: "
f"removed={len(removed)}/{old_count}, added={len(added)}/{new_count}"
)
hunks.append(
Hunk(
header=header,
raw=chunk,
old_start=old_start,
old_count=old_count,
new_start=new_start,
new_count=new_count,
removed=tuple(removed),
added=tuple(added),
)
)
return hunks
def git_base_text(base_commit: str, path: str) -> str:
result = run(["git", "show", f"{base_commit}:{path}"])
try:
return result.stdout.decode("utf-8")
except UnicodeDecodeError as exc:
raise AssemblyError(f"mixed document is not UTF-8 text: {path}") from exc
def selected_added_lines(hunk: Hunk, mapping: dict[str, Any]) -> list[str]:
decision = mapping.get("decision")
if decision == "include":
return list(hunk.added)
if decision == "exclude":
return []
if decision != "split-added-lines":
raise AssemblyError(f"unknown hunk decision: {decision!r}")
if hunk.old_count != 0 or hunk.removed:
raise AssemblyError(
f"split-added-lines only supports pure insertions: {hunk.header}"
)
include = mapping.get("include_added_line_numbers")
exclude = mapping.get("exclude_added_line_numbers")
if not isinstance(include, list) or not isinstance(exclude, list):
raise AssemblyError(f"split hunk needs include/exclude indexes: {hunk.header}")
include_set = set(include)
exclude_set = set(exclude)
expected = set(range(1, len(hunk.added) + 1))
if include_set & exclude_set or include_set | exclude_set != expected:
raise AssemblyError(
f"split indexes must partition every added line: {hunk.header}"
)
if any(not isinstance(index, int) for index in include + exclude):
raise AssemblyError(f"split indexes must be integers: {hunk.header}")
return [line for index, line in enumerate(hunk.added, 1) if index in include_set]
def assemble_document(
base_commit: str,
document: dict[str, Any],
) -> tuple[str, dict[str, int]]:
path = canonical_path(document.get("path"))
current_hunks = parse_hunks(path)
mapped = document.get("hunks")
if not isinstance(mapped, list):
raise AssemblyError(f"document.hunks must be an array: {path}")
if len(mapped) != len(current_hunks):
raise AssemblyError(
f"hunk coverage drift for {path}: mapped={len(mapped)}, current={len(current_hunks)}"
)
base_text = git_base_text(base_commit, path)
if not base_text.endswith("\n"):
raise AssemblyError(f"mixed document must end in newline: {path}")
base_lines = base_text[:-1].split("\n")
output: list[str] = []
cursor = 0
counts = {"include": 0, "exclude": 0, "split-added-lines": 0}
for index, (hunk, mapping) in enumerate(zip(current_hunks, mapped), 1):
if not isinstance(mapping, dict):
raise AssemblyError(f"hunk mapping must be an object: {path} #{index}")
if mapping.get("header") != hunk.header:
raise AssemblyError(
f"hunk header drift for {path} #{index}: "
f"mapped={mapping.get('header')!r}, current={hunk.header!r}"
)
if mapping.get("sha256") != hunk.sha256:
raise AssemblyError(
f"hunk hash drift for {path} {hunk.header}: "
f"mapped={mapping.get('sha256')}, current={hunk.sha256}"
)
rationale = mapping.get("rationale")
if not isinstance(rationale, str) or not rationale.strip():
raise AssemblyError(f"hunk rationale is empty: {path} {hunk.header}")
decision = mapping.get("decision")
if decision not in counts:
raise AssemblyError(f"invalid decision for {path}: {decision!r}")
counts[decision] += 1
if decision == "exclude":
continue
old_index = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
if old_index < cursor:
raise AssemblyError(f"selected hunks overlap in {path}: {hunk.header}")
if hunk.old_count:
actual_removed = base_lines[old_index : old_index + hunk.old_count]
if actual_removed != list(hunk.removed):
raise AssemblyError(
f"base content does not match selected hunk in {path}: {hunk.header}"
)
output.extend(base_lines[cursor:old_index])
output.extend(selected_added_lines(hunk, mapping))
cursor = old_index + hunk.old_count
output.extend(base_lines[cursor:])
return "\n".join(output) + "\n", counts
def materialize_head(base_commit: str, destination: Path) -> None:
archive = destination.parent / f"{destination.name}.zip"
run(
[
"git",
"archive",
"--format=zip",
f"--output={archive}",
base_commit,
]
)
destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive) as payload:
payload.extractall(destination)
def related_payload_paths(
manifest: dict[str, Any], hunk_map: dict[str, Any]
) -> list[str]:
classifications = manifest.get("classifications")
if not isinstance(classifications, dict):
raise AssemblyError("manifest classifications missing")
related = classifications.get("related")
if not isinstance(related, list):
raise AssemblyError("manifest related groups missing")
payload = hunk_map.get("payload")
if not isinstance(payload, dict):
raise AssemblyError("hunk map payload config missing")
excluded_groups = set(payload.get("excluded_related_group_ids", []))
excluded_paths = {
canonical_path(path) for path in payload.get("excluded_paths", [])
}
selected: set[str] = set()
seen_group_ids: set[str] = set()
for group in related:
if not isinstance(group, dict):
raise AssemblyError("related group must be an object")
group_id = group.get("id")
if not isinstance(group_id, str) or not group_id:
raise AssemblyError("related group id missing")
seen_group_ids.add(group_id)
if group_id in excluded_groups:
continue
paths = group.get("paths")
if not isinstance(paths, list):
raise AssemblyError(f"related group paths missing: {group_id}")
selected.update(canonical_path(path) for path in paths)
unknown_groups = excluded_groups - seen_group_ids
if unknown_groups:
raise AssemblyError(f"unknown excluded related groups: {sorted(unknown_groups)}")
return sorted(selected - excluded_paths)
def copy_related_files(paths: list[str], release_tree: Path) -> None:
for path in paths:
source = REPO_ROOT / path
if not source.is_file():
raise AssemblyError(f"related payload source missing: {path}")
target = release_tree / path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def create_openapi_tool_link(temp_web: Path) -> Path:
source_modules = REPO_ROOT / "apps/web/node_modules"
source_tool = source_modules / "openapi-typescript"
source_cmd = source_modules / ".bin/openapi-typescript.cmd"
if not source_tool.is_dir() or not source_cmd.is_file():
raise AssemblyError("openapi-typescript is unavailable in apps/web/node_modules")
temp_modules = temp_web / "node_modules"
temp_bin = temp_modules / ".bin"
temp_bin.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_cmd, temp_bin / source_cmd.name)
junction = temp_modules / "openapi-typescript"
result = run(
["cmd.exe", "/d", "/c", "mklink", "/J", str(junction), str(source_tool)],
allow_failure=True,
)
if result.returncode:
detail = result.stderr.decode("utf-8", errors="replace").strip()
raise AssemblyError(f"cannot create temporary openapi-typescript junction: {detail}")
return junction
def regenerate_api(
release_tree: Path,
generation: dict[str, Any],
) -> dict[str, Any]:
path = canonical_path(generation.get("path"))
temp_web = release_tree / "apps/web"
junction = create_openapi_tool_link(temp_web)
env = os.environ.copy()
env["PYTHONUTF8"] = "1"
try:
generate_command = generation.get("generate_command")
check_command = generation.get("check_command")
if generate_command != "npm.cmd run generate:api-types":
raise AssemblyError("generated file command is not the canonical npm generator")
if check_command != "npm.cmd run check:api-types":
raise AssemblyError("generated file check is not canonical")
run(["npm.cmd", "run", "generate:api-types"], cwd=temp_web, env=env)
generated = release_tree / path
if not generated.is_file():
raise AssemblyError(f"API generator did not create {path}")
first_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
run(["npm.cmd", "run", "check:api-types"], cwd=temp_web, env=env)
second_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
if first_hash != second_hash:
raise AssemblyError("check:api-types changed generated output")
text = generated.read_text(encoding="utf-8")
missing = [
marker
for marker in generation.get("required_markers", [])
if marker not in text
]
forbidden = [
marker
for marker in generation.get("forbidden_markers", [])
if marker in text
]
if missing:
raise AssemblyError(f"regenerated API is missing required markers: {missing}")
if forbidden:
raise AssemblyError(f"regenerated API contains unrelated markers: {forbidden}")
return {
"path": path,
"sha256": first_hash,
"bytes": len(generated.read_bytes()),
"required_markers": len(generation.get("required_markers", [])),
"forbidden_markers_absent": len(generation.get("forbidden_markers", [])),
"official_generate": True,
"official_check": True,
}
finally:
if junction.exists():
os.rmdir(junction)
def text_lines(path: Path) -> list[str]:
payload = path.read_bytes()
if b"\0" in payload:
raise AssemblyError(f"binary release payload is unsupported: {path}")
try:
text = payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise AssemblyError(f"release payload is not UTF-8: {path}") from exc
# ``git archive`` may materialize text according to checkout attributes and
# the source worktree may use a different EOL policy. A release patch must
# compare against the canonical Git blobs, not one machine's checkout EOL.
# Normalizing before ``difflib`` keeps the patch applicable both to the
# canonical index and to CRLF/LF clean worktrees handled by ``git apply``.
canonical = text.replace("\r\n", "\n").replace("\r", "\n")
return canonical.splitlines(keepends=True)
def build_patch(base_tree: Path, release_tree: Path, paths: list[str]) -> tuple[str, int]:
chunks: list[str] = []
changed_files = 0
for path in sorted(set(paths)):
base_path = base_tree / path
release_path = release_tree / path
if not release_path.is_file():
raise AssemblyError(f"assembled release file missing: {path}")
old_lines = text_lines(base_path) if base_path.is_file() else []
new_lines = text_lines(release_path)
if old_lines == new_lines:
raise AssemblyError(f"declared release payload has no diff from base: {path}")
changed_files += 1
chunks.append(f"diff --git a/{path} b/{path}\n")
if not base_path.exists():
chunks.append("new file mode 100644\n")
from_file = "/dev/null"
else:
from_file = f"a/{path}"
chunks.extend(
difflib.unified_diff(
old_lines,
new_lines,
fromfile=from_file,
tofile=f"b/{path}",
n=3,
lineterm="\n",
)
)
return "".join(chunks), changed_files
def validate_mixed_coverage(
manifest: dict[str, Any], hunk_map: dict[str, Any]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
classifications = manifest.get("classifications", {})
mixed = classifications.get("mixed") if isinstance(classifications, dict) else None
if not isinstance(mixed, list):
raise AssemblyError("manifest mixed classification missing")
manifest_paths: set[str] = set()
for entry in mixed:
if not isinstance(entry, dict) or entry.get("disposition") != "hunk-review-required":
raise AssemblyError("every manifest mixed entry must remain hunk-review-required")
paths = entry.get("paths")
if not isinstance(paths, list):
raise AssemblyError("manifest mixed paths missing")
manifest_paths.update(canonical_path(path) for path in paths)
generated = hunk_map.get("generated_files")
documents = hunk_map.get("documents")
if not isinstance(generated, list) or not isinstance(documents, list):
raise AssemblyError("hunk map generated_files/documents must be arrays")
mapped_paths = {
canonical_path(item.get("path"))
for item in [*generated, *documents]
if isinstance(item, dict)
}
if manifest_paths != mapped_paths:
raise AssemblyError(
f"mixed path coverage drift: manifest={sorted(manifest_paths)}, "
f"map={sorted(mapped_paths)}"
)
return generated, documents
def main() -> int:
args = parse_args()
hunk_map_path = args.map if args.map.is_absolute() else REPO_ROOT / args.map
before: dict[str, str | None] = {}
output_path = ""
prior_output: bytes | None = None
prior_output_existed = False
output_replaced = False
try:
hunk_map = load_json(hunk_map_path)
if hunk_map.get("schema_version") != "1.0":
raise AssemblyError("hunk map schema_version must be 1.0")
base_commit = hunk_map.get("base_commit")
if not isinstance(base_commit, str) or current_head() != base_commit:
raise AssemblyError(
f"HEAD drift: expected {base_commit}, current {current_head()}"
)
manifest_path = REPO_ROOT / canonical_path(hunk_map.get("manifest"))
manifest = load_json(manifest_path)
generated, documents = validate_mixed_coverage(manifest, hunk_map)
if len(generated) != 1:
raise AssemblyError("exactly one generated mixed file is expected")
output_path = canonical_path(hunk_map.get("output_patch"))
destination = REPO_ROOT / output_path
prior_output_existed = destination.is_file()
if prior_output_existed:
prior_output = destination.read_bytes()
before = snapshot_worktree()
document_outputs: dict[str, str] = {}
document_counts: dict[str, dict[str, int]] = {}
for document in documents:
if not isinstance(document, dict):
raise AssemblyError("document mapping must be an object")
path = canonical_path(document.get("path"))
assembled, counts = assemble_document(base_commit, document)
document_outputs[path] = assembled
document_counts[path] = counts
payload_paths = related_payload_paths(manifest, hunk_map)
mixed_paths = [
canonical_path(item.get("path"))
for item in [*generated, *documents]
]
release_paths = sorted(set(payload_paths) | set(mixed_paths))
with tempfile.TemporaryDirectory(prefix="vignette-outcome-release-") as temp_name:
temp_root = Path(temp_name)
base_tree = temp_root / "base"
release_tree = temp_root / "release"
materialize_head(base_commit, base_tree)
materialize_head(base_commit, release_tree)
copy_related_files(payload_paths, release_tree)
for path, content in document_outputs.items():
target = release_tree / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8", newline="\n")
api_report = regenerate_api(release_tree, generated[0])
patch_text, changed_files = build_patch(
base_tree, release_tree, release_paths
)
if not patch_text:
raise AssemblyError("assembled patch is empty")
candidate_patch = temp_root / "release-candidate.patch"
candidate_patch.write_text(patch_text, encoding="utf-8", newline="\n")
apply_check = run(
[
"git",
"apply",
"--check",
"--whitespace=nowarn",
str(candidate_patch),
],
cwd=base_tree,
)
if apply_check.returncode != 0:
raise AssemblyError("git apply --check failed")
# Prove the same patch against a canonical clean index as well.
# This catches checkout-only patches whose CRLF hunk context works
# on Windows but fails in CI/Linux or with ``git apply --cached``.
canonical_index = temp_root / "canonical.index"
index_env = os.environ.copy()
index_env["GIT_INDEX_FILE"] = str(canonical_index)
run(["git", "read-tree", base_commit], env=index_env)
cached_apply_check = run(
[
"git",
"apply",
"--cached",
"--check",
"--whitespace=nowarn",
str(candidate_patch),
],
env=index_env,
)
if cached_apply_check.returncode != 0:
raise AssemblyError("canonical-index git apply --cached --check failed")
# Do not replace the last verified artifact until every validation
# and the source snapshot guard passes. A concurrent edit must
# leave the previous patch intact instead of publishing a partial
# or stale candidate.
verify_worktree_unchanged(before, output_path)
destination.parent.mkdir(parents=True, exist_ok=True)
# ``tempfile`` may live on C: while the repository lives on D:.
# Windows cannot atomically replace across volumes, so first copy
# the fully verified candidate beside the destination and only
# then perform the same-volume atomic swap.
publish_candidate = destination.with_name(
f".{destination.name}.{os.getpid()}.tmp"
)
try:
shutil.copyfile(candidate_patch, publish_candidate)
os.replace(publish_candidate, destination)
finally:
if publish_candidate.exists():
publish_candidate.unlink()
output_replaced = True
verify_worktree_unchanged(before, output_path)
patch_bytes = (REPO_ROOT / output_path).read_bytes()
if b"\r\n" in patch_bytes or b"\r" in patch_bytes:
raise AssemblyError("release patch must use canonical LF-only line endings")
report = {
"ok": True,
"base_commit": base_commit,
"output_patch": output_path,
"patch_sha256": hashlib.sha256(patch_bytes).hexdigest(),
"patch_bytes": len(patch_bytes),
"patch_files": changed_files,
"api_generation": api_report,
"document_hunks": document_counts,
"git_apply_check": "passed-on-clean-temporary-head",
"git_apply_cached_check": "passed-on-canonical-clean-index",
"patch_line_endings": "lf-only",
"artifact_publish": "atomic-replace-after-all-guards",
"source_worktree": "unchanged-except-output-patch",
}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
else:
print(
"PASS outcome-os release patch: "
f"files={changed_files}, bytes={len(patch_bytes)}, "
f"sha256={report['patch_sha256']}; git apply --check passed"
)
return 0
except (AssemblyError, OSError, zipfile.BadZipFile) as exc:
if output_replaced and output_path:
destination = REPO_ROOT / output_path
if prior_output_existed and prior_output is not None:
destination.write_bytes(prior_output)
elif destination.exists():
destination.unlink()
if before and output_path:
try:
verify_worktree_unchanged(before, output_path)
except AssemblyError as mutation_error:
exc = AssemblyError(f"{exc}; {mutation_error}")
report = {"ok": False, "error": str(exc)}
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
else:
print(f"FAIL outcome-os release patch: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())