diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md
new file mode 100644
index 0000000000..6ce25664ed
--- /dev/null
+++ b/docs/decisions/anti-entropy-benchmark-decisions.md
@@ -0,0 +1,88 @@
+# Architecture Decision Record: Anti-Entropy Governance Benchmark & Evaluation Harness
+
+- **Status**: Accepted (Design Grill Complete)
+- **Date**: 2026-10-29
+- **Scope**: Out-of-band evaluation harness for quantifying anti-entropy (Aegis delete-first) impact
+- **Target Constraint**: 100% zero-intrusion to LoopX core control plane (`loopx/control_plane/`), default-off opt-in capability
+
+---
+
+## 1. Context & Problem Statement
+
+When autonomous coding agents execute refactoring, feature replacement, or protocol migration tasks, they suffer from systematic "entropy growth":
+1. **Fallback/Wrapper Addiction**: Instead of cleanly deleting old classes/functions, agents keep them as backwards-compatibility shims or wrap them inside new logic.
+2. **Ghost Code Residue**: Agents frequently comment out superseded blocks (`# old_logic()`) rather than deleting them.
+3. **Zombie Reintroduction**: When tests fail, agents often recreate the deprecated symbol in the newly modified files.
+
+LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/capabilities/anti_entropy/`). The core question was:
+- Should test/benchmark scenarios overlay a full real-world development task?
+- How should the anti-entropy capability be injected into commands to measure the delta between having vs. not having anti-entropy governance?
+- What task scenario is most appropriate to evaluate this without compromising LoopX core stability?
+
+---
+
+## 2. Decision Points & Trade-Off Analysis
+
+### Decision 1: Loading Mechanism & Interception Timing
+- **Considered Alternatives**:
+ - *Option 1*: Pure prompt guidance (soft constraint). Zero tooling overhead, but models consistently regress under multi-file pressure.
+ - *Option 2*: Post-hoc terminal gate only. Verifies diff at submission; fails late when the agent has already exhausted budget, leading to blind rollback loops.
+ - *Option 3 (Selected, partially delivered)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**.
+ - Delivered now: the execution tier as an explicitly callable entry (`anti_entropy_inspect` tool descriptor plus `loopx anti-entropy inspect`) returning actionable progress-fact feedback, and the gate tier as a named delivery route — final blocking stays owned by the existing change-quality `prepare/record/verify` receipt, not a parallel authority.
+ - Not yet proven: that models actually adopt the tool and prefer deletion in long-horizon runs. That claim requires actual recorded runs and is intentionally not asserted by this ADR or the comparator.
+- **Trade-off**: Increases tool-call rounds and slight token overhead per run, in exchange for deterministic first-pass cleanliness and eliminating expensive blind rollbacks.
+
+---
+
+### Decision 2: Zero-Intrusion Packaging & Retirement Basis Injection
+- **Considered Alternatives**:
+ - *Option 1*: Hardwire anti-entropy check into `loopx/control_plane/turn_driver/`. Causes direct intrusion into generic mainline scheduling, violating domain-neutrality.
+ - *Option 2*: Dynamic runtime AST inference without declaration. Prone to false positives when agents refactor internal helper functions.
+ - *Option 3 (Selected)*: **Declarative Sidecar / Task-Envelope Hook Pattern**.
+ - The task specification explicitly provides an `anti_entropy_basis` (target symbols and target paths to retire).
+ - Delivered now: the typed turn-context projection (`build_anti_entropy_turn_context`) a host can inject when the capability is enabled for the task, and the explicit CLI/tool entries agents call.
+ - Named follow-up (not delivered here): a runner that conditionally injects the inspect tool into a host agent envelope and appends the verify command to a test command. Owner: the host integration that owns agent envelopes; post-writeback typed intent stays with the provider-neutral post-writeback capability-hooks RFC, including failure isolation and idempotence.
+ - `loopx/control_plane/` remains completely unmodified (0-line diff).
+- **Trade-off**: Requires test cases to declare their target retirement list in advance, rather than inferring intent magically.
+
+---
+
+### Decision 3: Evaluation Benchmark Scenario (Testbed Task) Selection
+- **Considered Alternatives**:
+ - *Option 1*: In-repo dogfooding on live LoopX modules. High risk of breaking the developer active branch during failed baseline runs; violates mainline safety.
+ - *Option 2*: External SWE-bench Docker benchmark. Requires heavyweight containers and environment setups; feedback loop too slow for local iterative testing.
+ - *Option 3 (Selected)*: **Self-Contained Realistic Engineering Playground in `examples/anti_entropy/`**.
+ - A multi-module Python service (protocol parser + streaming receiver + client caller + unit test suite).
+ - Real task: Migrate all callers from `LegacyProtocolParser` to `StreamMessageParser` and physically delete `legacy_parser.py`.
+ - The comparator is a deterministic, fixed-input sandbox demonstration: per arm it seeds a disposable git sandbox, applies the fixed post-state, captures the real diff with git, executes the case-study functional tests with pytest, and evaluates the oracle against the bound sandbox workspace. It measures oracle semantics only — it launches no model runs and measures no agent-runtime behavior, so its output must not be read as an agent-effectiveness result.
+- **Trade-off**: Tests on a standalone engineering playground rather than mutating production LoopX code directly.
+
+---
+
+## 3. First-Principles Minimality Justification (Aegis Integration)
+
+1. **Change Necessity**: Reuses the existing `loopx/capabilities/anti_entropy/` AST engine without creating redundant verification abstractions.
+2. **Fallback Ceiling**: Refuses to add fallback adapters; enforces physical deletion of deprecated symbols (`/dev/null` diff + AST symbol absence).
+3. **Delete-First & Anti-Entropy**: The evaluation suite directly measures net negative lines of code, absence of zombie wrappers, and 0 ghost code blocks.
+4. **Mainline Purity**: All new harness scripts live in `examples/anti_entropy/` and `docs/decisions/`. No files under `loopx/control_plane/` are touched.
+
+---
+
+## 4. Delivered Boundary (This PR)
+
+- **Delivered**: default-off capability with explicit typed entries — `loopx anti-entropy verify` (delivery-gate semantics, exit 1 on rejection), `loopx anti-entropy inspect` (in-flight progress-fact feedback, exit 0), the `anti_entropy_inspect` tool descriptor registered in the capability catalog, the `build_anti_entropy_turn_context` projection, and the single-task feedback cycle classifying findings as actionable progress facts. Symbol retirement certification requires an explicitly bound workspace (`--workspace`); diff-only receipts may reject but never certify, and evidence provenance (`basis_hash`, `source_root`, `source_evidence`) is recorded on every receipt.
+- **Delivery authority**: unchanged and owned by change-quality (`prepare/record/verify`, exact diff scope, receipt invalidation). This capability supplies evidence and names the delivery route; it writes no delivery receipt.
+- **Not claimed**: model adoption of the loop in real agent runs, measured entropy reduction on live workloads, or any comparator-derived effectiveness number. Those require the recorded-run evaluation program (Decision 3) executed for real, and the envelope-injection runner from Decision 2.
+- **Source binding invariants**: complete post-change sources are AST-parsed; documentation files never contribute symbol evidence; string literals, comments, and docstring examples are never counted as references or definitions; a missing bound source is evidence-insufficient (AE004), never silently skipped.
+
+---
+
+## 5. Impacted System Components & Retired Paths
+
+- **Impacted Components**:
+ - `docs/decisions/anti-entropy-benchmark-decisions.md`: Canonical ADR record.
+ - `examples/anti_entropy/case_study/`: Standalone realistic multi-file testbed.
+ - `examples/anti_entropy/compare_arms.py`: Out-of-band evaluation comparator for measuring differential.
+- **Retired / Disallowed Paths**:
+ - Disallowed: Modifying `loopx/control_plane/turn_driver/executor.py` for benchmark gates.
+ - Disallowed: Ad-hoc heuristic AST diff guesswork without a declared basis.
diff --git a/examples/anti-entropy-governance-smoke.py b/examples/anti-entropy-governance-smoke.py
new file mode 100755
index 0000000000..edfc4e149a
--- /dev/null
+++ b/examples/anti-entropy-governance-smoke.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene.
+
+Exercises the source-binding contract: symbol retirement certification requires
+an explicitly bound workspace with the complete post-change sources; diff-only
+evidence can reject but never certifies.
+"""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT))
+
+from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402
+from loopx.capabilities.anti_entropy.contract import ( # noqa: E402
+ ANTI_ENTROPY_CAPABILITY_ID,
+ RetirementBasis,
+)
+from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene # noqa: E402
+
+
+def _bound_workspace(content: str) -> Path:
+ workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_"))
+ (workspace / "calculator.py").write_text(content, encoding="utf-8")
+ return workspace
+
+
+def test_clean_delete_first() -> None:
+ diff = """
+--- a/calculator.py
++++ b/calculator.py
+@@ -1,5 +1,5 @@
+-def old_add(a: int, b: int) -> int:
+- return a + b
++def new_add(a: int, b: int) -> int:
++ return a + b
+"""
+ workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\n")
+ basis = RetirementBasis(
+ target_symbols=("old_add",),
+ rationale="Replaced by new_add",
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"expected passed, got {receipt.verdict}"
+ assert "old_add" in receipt.retired_symbols
+ assert receipt.source_evidence == "workspace_sources"
+
+
+def test_diff_only_evidence_cannot_certify() -> None:
+ diff = """
+--- a/calculator.py
++++ b/calculator.py
+@@ -1,5 +1,5 @@
+-def old_add(a: int, b: int) -> int:
+- return a + b
++def new_add(a: int, b: int) -> int:
++ return a + b
+"""
+ basis = RetirementBasis(target_symbols=("old_add",))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}"
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_delete_first_violation() -> None:
+ diff = """
+--- a/calculator.py
++++ b/calculator.py
+@@ -1,3 +1,5 @@
++def new_add(a: int, b: int) -> int:
++ return a + b
+ def old_add(a: int, b: int) -> int:
+ return a + b
+"""
+ workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\ndef old_add(a: int, b: int) -> int:\n return a + b\n")
+ basis = RetirementBasis(
+ target_symbols=("old_add",),
+ allow_compat_seam=False,
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings)
+
+
+def test_zombie_reintroduced() -> None:
+ diff = """
+--- a/calculator.py
++++ b/calculator.py
+@@ -1,3 +1,3 @@
+-def legacy_sink():
++def legacy_sink(): # wrapper
+ pass
+"""
+ basis = RetirementBasis(target_symbols=("legacy_sink",))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}"
+ assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings)
+
+
+def test_cli_runner() -> None:
+ diff = """
+--- a/module.py
++++ b/module.py
+@@ -1,2 +1,2 @@
+-class OldService:
++class NewService:
+ pass
+"""
+ workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_"))
+ (workspace / "module.py").write_text("class NewService:\n pass\n", encoding="utf-8")
+ payload = run_anti_entropy_verify(
+ diff_text=diff,
+ basis_data={"target_symbols": ["OldService"]},
+ goal_id="test-goal-1",
+ workspace=workspace,
+ )
+ assert payload["ok"] is True
+ assert payload["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID
+ assert "OldService" in payload["retired_symbols"]
+
+
+def main() -> int:
+ test_clean_delete_first()
+ test_diff_only_evidence_cannot_certify()
+ test_delete_first_violation()
+ test_zombie_reintroduced()
+ test_cli_runner()
+ print("anti-entropy-governance-smoke: ok")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py
new file mode 100644
index 0000000000..f2440f94fb
--- /dev/null
+++ b/examples/anti_entropy/case_study/client.py
@@ -0,0 +1,19 @@
+"""Consumer client currently bound to legacy parser."""
+
+from typing import Any
+from .legacy_parser import LegacyMessageParser
+
+
+class DataConsumer:
+ """High-level data consumer."""
+
+ def __init__(self) -> None:
+ self._parser = LegacyMessageParser()
+
+ def process_message(self, raw_input: str) -> dict[str, Any]:
+ result = self._parser.decode(raw_input)
+ return {
+ "status": "ok",
+ "content": result["payload"],
+ "length": result["size"],
+ }
diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py
new file mode 100644
index 0000000000..da0f6da804
--- /dev/null
+++ b/examples/anti_entropy/case_study/legacy_parser.py
@@ -0,0 +1,28 @@
+"""Legacy message parser slated for retirement."""
+
+from typing import Any
+
+
+class LegacyMessageParser:
+ """Old-style frame decoder with delimiter separation."""
+
+ def __init__(self, delimiter: str = "|") -> None:
+ self.delimiter = delimiter
+
+ def decode(self, raw_data: str) -> dict[str, Any]:
+ parts = raw_data.strip().split(self.delimiter)
+ if len(parts) < 3:
+ raise ValueError("Malformed legacy frame")
+ header, payload, checksum = parts[0], parts[1], parts[2]
+ return {
+ "version": "v1",
+ "header": header,
+ "payload": payload,
+ "checksum": checksum,
+ "size": len(payload),
+ }
+
+
+def parse_legacy_frame(raw_data: str) -> dict[str, Any]:
+ """Standalone legacy helper."""
+ return LegacyMessageParser().decode(raw_data)
diff --git a/examples/anti_entropy/case_study/stream_parser.py b/examples/anti_entropy/case_study/stream_parser.py
new file mode 100644
index 0000000000..26877d9b4b
--- /dev/null
+++ b/examples/anti_entropy/case_study/stream_parser.py
@@ -0,0 +1,20 @@
+"""Modern stream message parser replacing legacy parser."""
+
+from typing import Any
+
+
+class StreamMessageParser:
+ """Modern stream parser parsing structured payload with validation."""
+
+ def parse(self, raw_data: str) -> dict[str, Any]:
+ # Accepts both pipe format and key-value formats
+ parts = raw_data.strip().split("|")
+ if len(parts) >= 3:
+ return {
+ "version": "v2",
+ "header": parts[0],
+ "payload": parts[1],
+ "checksum": parts[2],
+ "size": len(parts[1]),
+ }
+ raise ValueError("Invalid stream payload")
diff --git a/examples/anti_entropy/case_study/task_spec.json b/examples/anti_entropy/case_study/task_spec.json
new file mode 100644
index 0000000000..4ffa9a7a9c
--- /dev/null
+++ b/examples/anti_entropy/case_study/task_spec.json
@@ -0,0 +1,12 @@
+{
+ "task_id": "case_study_parser_retirement",
+ "objective": "Migrate DataConsumer in client.py from LegacyMessageParser to StreamMessageParser. Fully retire LegacyMessageParser and delete legacy_parser.py cleanly without leaving backward-compatibility shims or dead code.",
+ "functional_test_command": "pytest examples/anti_entropy/case_study/tests/test_client.py -q",
+ "retirement_basis": {
+ "schema_version": "loopx_anti_entropy_retirement_basis_v0",
+ "target_symbols": ["LegacyMessageParser", "parse_legacy_frame"],
+ "target_paths": ["examples/anti_entropy/case_study/legacy_parser.py"],
+ "allow_compat_seam": false,
+ "rationale": "Superseded by StreamMessageParser in stream_parser.py"
+ }
+}
diff --git a/examples/anti_entropy/case_study/tests/test_client.py b/examples/anti_entropy/case_study/tests/test_client.py
new file mode 100644
index 0000000000..d9e1e7670c
--- /dev/null
+++ b/examples/anti_entropy/case_study/tests/test_client.py
@@ -0,0 +1,11 @@
+"""Tests for DataConsumer."""
+
+from examples.anti_entropy.case_study.client import DataConsumer
+
+
+def test_consumer_normal_flow() -> None:
+ consumer = DataConsumer()
+ res = consumer.process_message("HDR|hello world|CRC32")
+ assert res["status"] == "ok"
+ assert res["content"] == "hello world"
+ assert res["length"] == 11
diff --git a/examples/anti_entropy/compare_arms.py b/examples/anti_entropy/compare_arms.py
new file mode 100644
index 0000000000..d8c20fa620
--- /dev/null
+++ b/examples/anti_entropy/compare_arms.py
@@ -0,0 +1,237 @@
+"""Isolated end-to-end demonstration of anti-entropy governance on a fixed refactor task.
+
+For each arm the script executes a real isolated change inside a disposable git
+sandbox: it seeds the case-study pre-state, writes the arm's fixed post-state,
+captures the real unified diff with git, runs the case-study functional test
+suite with pytest, and evaluates the anti-entropy oracle against the sandbox
+workspace as the explicitly bound post-change source.
+
+This is a deterministic demonstration on hand-authored post-state fixtures. It
+launches no model runs and measures no agent-runtime behavior; functional-test
+results below are real executions of the seeded case-study tests.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+CASE_STUDY_REL = "examples/anti_entropy/case_study"
+
+sys.path.insert(0, str(REPO_ROOT))
+
+from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402
+
+_CLIENT_POST = '''"""Consumer client migrated to the modern stream parser."""
+
+from typing import Any
+from .stream_parser import StreamMessageParser
+
+
+class DataConsumer:
+ """High-level data consumer."""
+
+ def __init__(self) -> None:
+ self._parser = StreamMessageParser()
+
+ def process_message(self, raw_input: str) -> dict[str, Any]:
+ result = self._parser.parse(raw_input)
+ return {
+ "status": "ok",
+ "content": result["payload"],
+ "length": result["size"],
+ }
+'''
+
+_LEGACY_ZOMBIE_POST = '''# Deprecated: do not use
+# def parse_legacy_frame(raw_data: str) -> dict[str, Any]:
+# return LegacyMessageParser().decode(raw_data)
+class LegacyMessageParser:
+ pass
+'''
+
+_CASE_STUDY_FILES = (
+ "client.py",
+ "legacy_parser.py",
+ "stream_parser.py",
+ "tests/test_client.py",
+)
+
+# Each arm is a fixed hand-authored post-state applied to the seeded pre-state.
+# None means the file is deleted by this arm.
+_ARM_POST_STATES: dict[str, dict[str, str | None]] = {
+ "Arm 1 (Baseline Pattern: legacy file kept)": {
+ f"{CASE_STUDY_REL}/client.py": _CLIENT_POST,
+ },
+ "Arm 2 (Prompt-Only Pattern: zombie + ghost comments)": {
+ f"{CASE_STUDY_REL}/client.py": _CLIENT_POST,
+ f"{CASE_STUDY_REL}/legacy_parser.py": _LEGACY_ZOMBIE_POST,
+ },
+ "Arm 3 (Anti-Entropy Pattern: clean /dev/null deletion)": {
+ f"{CASE_STUDY_REL}/client.py": _CLIENT_POST,
+ f"{CASE_STUDY_REL}/legacy_parser.py": None,
+ },
+}
+
+
+def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]:
+ proc = subprocess.run(
+ cmd,
+ cwd=cwd,
+ capture_output=True,
+ env=env,
+ text=False,
+ )
+ output = (proc.stdout + proc.stderr).decode("utf-8", errors="replace")
+ return proc.returncode, output
+
+
+def _seed_sandbox(sandbox: Path) -> None:
+ """Seed the case-study pre-state and commit it as the sandbox baseline."""
+ case_study_src = REPO_ROOT / CASE_STUDY_REL
+ for rel in _CASE_STUDY_FILES:
+ target = sandbox / CASE_STUDY_REL / rel
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(case_study_src / rel, target)
+ # The task spec is the authoritative input declaring the retirement basis.
+ shutil.copy2(case_study_src / "task_spec.json", sandbox / CASE_STUDY_REL / "task_spec.json")
+ _run(["git", "init", "-q"], sandbox)
+ _run(["git", "add", "-A"], sandbox)
+ rc, out = _run(
+ [
+ "git",
+ "-c",
+ "user.name=anti-entropy-demo",
+ "-c",
+ "user.email=demo@localhost",
+ "commit",
+ "-qm",
+ "seed case-study pre-state",
+ ],
+ sandbox,
+ )
+ if rc != 0:
+ raise RuntimeError(f"sandbox seed commit failed: {out}")
+
+
+def _capture_diff(sandbox: Path) -> str:
+ rc, diff_text = _run(["git", "diff"], sandbox)
+ if rc != 0:
+ raise RuntimeError(f"git diff failed in sandbox: {diff_text}")
+ return diff_text
+
+
+def _run_functional_tests(sandbox: Path) -> tuple[str, str]:
+ test_rel = f"{CASE_STUDY_REL}/tests/test_client.py"
+ env = dict(os.environ)
+ env["PYTHONPATH"] = str(sandbox)
+ rc, output = _run([sys.executable, "-m", "pytest", test_rel, "-q"], sandbox, env=env)
+ if rc == 0:
+ return "passed (executed in sandbox)", output
+ return f"failed (exit {rc}, executed in sandbox)", output
+
+
+def evaluate_arm(name: str, post_state: dict[str, str | None], basis_data: dict) -> dict:
+ sandbox = Path(tempfile.mkdtemp(prefix="ae_arm_"))
+ try:
+ _seed_sandbox(sandbox)
+ for rel, content in post_state.items():
+ target = sandbox / rel
+ if content is None:
+ target.unlink()
+ else:
+ target.write_text(content, encoding="utf-8")
+
+ diff_text = _capture_diff(sandbox)
+ tests_result, tests_output = _run_functional_tests(sandbox)
+ result = run_anti_entropy_verify(
+ diff_text=diff_text,
+ basis_data=basis_data,
+ goal_id=f"fixture-{abs(hash(name)) % 10000}",
+ workspace=sandbox,
+ )
+
+ lines_added = sum(
+ 1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")
+ )
+ lines_removed = sum(
+ 1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---")
+ )
+
+ return {
+ "arm": name,
+ "functional_tests": tests_result,
+ "functional_tests_output_tail": tests_output.strip().splitlines()[-1:] or [""],
+ "anti_entropy_verdict": result["verdict"],
+ "retired_symbols": result["retired_symbols"],
+ "blocking_findings": result["blocking_finding_count"],
+ "advisory_findings": result["advisory_finding_count"],
+ "findings": [f["message"] for f in result["receipt"]["findings"]],
+ "net_diff_lines": lines_added - lines_removed,
+ "source_evidence": result["receipt"]["source_evidence"],
+ }
+ finally:
+ shutil.rmtree(sandbox, ignore_errors=True)
+
+
+def main() -> int:
+ spec_path = REPO_ROOT / CASE_STUDY_REL / "task_spec.json"
+ if not spec_path.is_file():
+ print(f"Notice: task specification not found at {spec_path}, demonstration skipped.")
+ return 0
+
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ basis = spec.get("retirement_basis", {})
+
+ objective = spec.get("objective", "")
+ target_symbols = basis.get("target_symbols", [])
+ target_paths = basis.get("target_paths", [])
+
+ print("================================================================================")
+ print("Anti-Entropy Governance Isolated Sandbox Demonstration")
+ print("================================================================================")
+ print(f"Task Objective: {objective}")
+ print(f"Declared Retirement Targets: {target_symbols}")
+ print(f"Declared Target Paths to Delete: {target_paths}")
+ print("Each arm: seeded git sandbox -> fixed post-state -> real git diff ->")
+ print("real pytest run -> oracle verify bound to the sandbox workspace.")
+ print("--------------------------------------------------------------------------------\n")
+
+ for arm_name, post_state in _ARM_POST_STATES.items():
+ res = evaluate_arm(arm_name, post_state, basis)
+ print(f"[{res['arm']}]")
+ print(f" • Functional Tests: {res['functional_tests']}")
+ if res["functional_tests_output_tail"]:
+ print(f" - pytest tail: {res['functional_tests_output_tail'][0]}")
+ print(f" • Anti-Entropy Verdict: {res['anti_entropy_verdict']}")
+ print(f" • Evidence Binding: {res['source_evidence']}")
+ print(f" • Net Diff Lines: {res['net_diff_lines']:+} lines")
+ print(f" • Cleanly Retired: {res['retired_symbols']}")
+ print(f" • Blocking Violations: {res['blocking_findings']}")
+ print(f" • Advisory Hygiene Flaws: {res['advisory_findings']}")
+ if res["findings"]:
+ for f in res["findings"]:
+ print(f" - Finding: {f}")
+ print()
+
+ print("================================================================================")
+ print("Demonstration Reading (oracle semantics only, no agent-runtime measurement):")
+ print("1. Functional tests alone pass whenever the caller migrates, in every arm.")
+ print("2. Only the oracle binds the declared retirement basis to complete post-change")
+ print(" sources: kept legacy files and zombie definitions stay blocking (AE001),")
+ print(" active references stay blocking (AE002), and 3+ line commented-out code")
+ print(" blocks stay advisory (AE003).")
+ print("3. Arm 3 achieves verified retirement: clean /dev/null deletion, zero blocking")
+ print(" findings, retired symbols certified against the bound workspace.")
+ print("================================================================================")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/loopx/capabilities/README.md b/loopx/capabilities/README.md
index d3af15ad73..925b7f1d5c 100644
--- a/loopx/capabilities/README.md
+++ b/loopx/capabilities/README.md
@@ -87,6 +87,7 @@ availability and maturity in the installed release.
| Review a changing public PR queue against exact-head evidence and typed completion rules | [Pull Request Review](pr_review_queue/README.md) |
| Detect source-head drift and safely rebuild a local stack of already reviewed branches | [Integration Branch](integration_branch/README.md) |
| Gate local repository changes by a typed schedule and retain blocked unmerged work across restarts | [Repository Change Window](repository_change_window/README.md) |
+| Verify that declared legacy symbols and paths are genuinely retired by a refactoring change | [Anti-Entropy Governance](anti_entropy/README.md) |
### Research And Decision Continuity
diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md
new file mode 100644
index 0000000000..fd5b2b32af
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/README.md
@@ -0,0 +1,162 @@
+# Anti-Entropy & Code Retirement Governance
+
+Anti-Entropy Governance provides LoopX with an automated, deterministic
+code-retirement and diff-hygiene verification contract. It is **default-off**
+and designed to support agent-driven refactoring and feature decommissioning workflows.
+
+## Overview
+
+When autonomous agents perform refactoring or feature replacement tasks, they
+frequently exhibit an "entropy growth" anti-pattern: layering yet another wrapper
+or fallback branch without safely deleting superseded implementations. Over
+long-horizon tasks, this results in accumulated ghost code, redundant complexity,
+and bloated execution contexts.
+
+The Anti-Entropy capability addresses this through:
+
+1. **AST-Based Delete-First Enforcement (`verify_delete_first`)**:
+ Uses Python's syntax tree parser to verify that declared retirement targets
+ (functions and classes) are genuinely removed from the codebase rather than
+ merely bypassed or wrapped. Symbol retirement is a derived fact about
+ **complete post-change sources**, not about diff text alone.
+2. **Zombie Symbol Prevention**:
+ Detects if a symbol marked for retirement is reintroduced as a new definition
+ in the diff, or is still referenced by active identifiers in the bound
+ post-change sources (string literals, comments, and docstring examples are
+ never counted as references).
+3. **Target Paths Verification**:
+ Ensures that declared legacy file paths slated for deletion are completely
+ removed in the unified diff (`/dev/null`).
+4. **Ghost Code Auditing (`audit_ghost_code`)**:
+ Identifies commented-out code blocks left behind, surfacing them as advisory
+ quality findings without blocking legitimate documentation or explanatory comments.
+5. **Durable Receipt Generation (`AntiEntropyReceipt`)**:
+ Runs deterministic hygiene oracles against the exact git diff and produces
+ an immutable receipt recording verified retired symbols, findings, and the
+ evidence provenance (`basis_hash`, `source_root`, `source_evidence`).
+
+## Source Binding And Evidence Rules
+
+Verification distinguishes diff-visible facts from workspace-derived facts:
+
+- `--workspace
` explicitly binds the directory holding the complete
+ post-change sources. The bound workspace — never the caller's CWD — is the
+ only disk source read, and its complete files are parsed with the AST
+ (line-signature matching is limited to unparseable in-diff code fragments;
+ documentation files never contribute symbol evidence).
+- **AE004 (insufficient source evidence, blocking)**: without a bound
+ workspace, or when a diff-touched source file is missing from the bound
+ workspace, symbol retirement cannot be certified. A diff-only receipt may
+ reject, but never certifies retirement, and `retired_symbols` stays empty.
+ A path deleted in the diff but still present in the bound workspace is
+ reported as a patch/workspace mismatch.
+- **AE001/AE002 remain hard gates** in this command; **AE003 stays advisory**.
+ `allow_compat_seam=true` turns a retained-but-authorized definition into an
+ advisory finding, and it is never listed as retired.
+- The receipt records which sources backed the verdict; a `diff_hash` alone
+ does not prove anything about the task, the sources, or the policy.
+
+## Single-Task Feedback Loop (Default-Off)
+
+The capability ships disabled (`default_enabled: false`); enabling it for a
+task is an explicit choice. When enabled, one closed loop per task is:
+
+1. **Task context** — `anti_entropy_context_registration` is composed into
+ the shared `loopx.capabilities.turn_context.attach_turn_context` owner and
+ reads the Turn's own authoritative input, never a hand-built payload shape:
+
+ - the selected Todo record carried by `turn_envelope.work_context.sources[]`
+ (its `note` / instruction text, `required_capabilities`, and the full
+ task body when the compact display text was truncated), plus
+ `turn_envelope.action.selected_todo.text` / `action.recommended_action`;
+ - a task opts in by declaring an explicit `retirement_basis` /
+ `anti_entropy_basis` JSON object in that text. A Todo may also list
+ `anti-entropy-governance` in `required_capabilities`, but only when the
+ Goal already declares that capability available: otherwise routing treats
+ the Todo as requiring an unavailable capability and the Turn stops before
+ the hook, so the explicit task declaration is the primary path;
+ - the post-change workspace is the Goal repository from the registry
+ (`load_goal_from_registry`), never the process working directory.
+
+ Disabled turns write no readback slot and inject nothing; an
+ enabled-but-inapplicable turn only reads back `enabled/applicable=false`.
+ Recovery turns (the payload carries the previous inspect outcome) get
+ remediation guidance instead.
+2. **In-flight check** — agents call `anti_entropy_inspect` through the Python
+ entry or `loopx anti-entropy inspect`. The turn context carries the tool
+ descriptor and CLI route as data; LoopX exposes no capability-owned host
+ tool registry, so the CLI remains the callable route. Blocking findings come
+ back as **actionable feedback with per-rule next actions**, classified as
+ progress facts, not final delivery failures; the CLI exits 0 whenever the
+ check ran.
+3. **Delivery verification** — final acceptance stays owned by the existing
+ change-quality owner (`loopx change-quality prepare/record/verify`, exact
+ diff scope and receipt invalidation). This capability supplies evidence and
+ names the delivery route; it writes no receipt store and creates no
+ parallel delivery authority.
+4. **Post-writeback (named follow-up)** — typed post-writeback intent bound to
+ the source receipt remains owned by the provider-neutral post-writeback
+ capability-hooks RFC, including failure isolation and idempotence.
+
+Model adoption of the loop is proven by actual runs, not by this protocol;
+tests here cover the typed protocol and the real callable entries.
+
+## Attribution & Acknowledgments
+
+This capability adopts and operationalizes the core engineering principles of
+**delete-first retirement**, **ghost code prevention**, and **minimality verification**
+inspired by the **Aegis** engineering methodology.
+
+## Usage
+
+Inspect the capability and its registered protocols:
+
+```bash
+loopx capability show anti-entropy-governance --format json
+```
+
+In-flight inspection with actionable feedback (progress-fact semantics):
+
+```bash
+loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json
+```
+
+Delivery-gate verification (non-zero exit on rejection). `verify` requires an
+explicit basis with at least one target, and certifies symbol retirement only
+when `--workspace` is bound:
+
+```bash
+loopx anti-entropy verify --goal-id --diff-file --basis-json --workspace --format json
+```
+
+`verify` without `--basis-json` (or with an empty target set) exits 2: an
+undeclared retirement is an input error, not a passing verification.
+
+Example `basis.json`:
+
+```json
+{
+ "target_symbols": ["old_deprecated_calculator"],
+ "target_paths": ["legacy/old_module.py"],
+ "allow_compat_seam": false,
+ "rationale": "Superseded by modern_calculator implementation"
+}
+```
+
+A deterministic end-to-end demonstration on fixed fixtures lives in
+`examples/anti_entropy/compare_arms.py`: it seeds a disposable git sandbox per
+arm, applies the arm's fixed post-state, captures the real diff with git, runs
+the case-study functional tests with pytest, and evaluates the oracle against
+the sandbox workspace. It measures the oracle's semantics only — it launches
+no model runs and measures no agent-runtime behavior.
+
+## Boundaries
+
+- The capability is default-off and task-scoped: turns receive the retirement context only when the selected task explicitly declares a retirement basis or enables the capability in its own canonical input; the CLI (verify/inspect) remains the shared verification entry.
+- The selected Todo is the intent source. The hook reads the native work context and the Goal registry; it never infers targets from unrelated business fields and never reads the process working directory as source evidence.
+- Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.
+- Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies.
+- AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.
+- Target paths declared for decommissioning must be cleanly removed in the unified diff.
+- Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.
+- Delivery acceptance and receipts of record remain owned by the change-quality capability; this capability never writes delivery receipts.
diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py
new file mode 100644
index 0000000000..65eca84019
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/__init__.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from .agent_tool import anti_entropy_inspect
+from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY
+from .cli import run_anti_entropy_verify
+from .contract import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION,
+ ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION,
+ AntiEntropyFinding,
+ AntiEntropyReceipt,
+ RetirementBasis,
+)
+from .feedback import (
+ ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR,
+ build_anti_entropy_turn_context,
+ run_anti_entropy_feedback_cycle,
+)
+from .oracles import (
+ audit_ghost_code,
+ evaluate_diff_hygiene,
+ verify_delete_first,
+)
+from .turn_context import (
+ anti_entropy_context_registration,
+ anti_entropy_context_result,
+)
+
+__all__ = [
+ "ANTI_ENTROPY_CAPABILITY_ID",
+ "ANTI_ENTROPY_CATALOG_ENTRY",
+ "ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR",
+ "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION",
+ "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION",
+ "AntiEntropyFinding",
+ "AntiEntropyReceipt",
+ "RetirementBasis",
+ "anti_entropy_inspect",
+ "audit_ghost_code",
+ "build_anti_entropy_turn_context",
+ "evaluate_diff_hygiene",
+ "run_anti_entropy_feedback_cycle",
+ "run_anti_entropy_verify",
+ "verify_delete_first",
+ "anti_entropy_context_registration",
+ "anti_entropy_context_result",
+]
diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py
new file mode 100644
index 0000000000..d9dc13ffc9
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/agent_tool.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from .cli import run_anti_entropy_verify
+
+
+def anti_entropy_inspect(
+ *,
+ diff_text: str,
+ retirement_basis: dict[str, Any] | None = None,
+ goal_id: str | None = None,
+ workspace: str | Path | None = None,
+) -> dict[str, Any]:
+ """Inspect unified diff against a declared retirement basis and diff hygiene rules.
+
+ This tool allows autonomous agents to verify that deprecated symbols and files
+ are genuinely decommissioned (delete-first) and that no ghost code or zombie
+ wrappers are reintroduced, returning an immutable structured receipt. Symbol
+ retirement certification requires an explicitly bound ``workspace`` holding
+ the complete post-change sources.
+ """
+ return run_anti_entropy_verify(
+ diff_text=diff_text,
+ basis_data=retirement_basis,
+ goal_id=goal_id,
+ workspace=workspace,
+ )
diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py
new file mode 100644
index 0000000000..4cf80d21c5
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/catalog_entry.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .contract import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION,
+ ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION,
+)
+
+_README = "loopx/capabilities/anti_entropy/README.md"
+
+ANTI_ENTROPY_CATALOG_ENTRY: dict[str, Any] = {
+ "id": ANTI_ENTROPY_CAPABILITY_ID,
+ "origin": "builtin",
+ "visibility": "public",
+ "provider_id": "loopx-core",
+ "documentation": {
+ "source_root": "loopx/capabilities/anti_entropy",
+ "site_root": "capabilities/anti-entropy-governance",
+ "canonical": "README.md",
+ },
+ "title": "Anti-entropy and code retirement governance",
+ "status": "experimental",
+ "default_enabled": False,
+ "real_world_anchor": (
+ "provider-neutral AST-based delete-first verification and diff hygiene for agent refactoring"
+ ),
+ "user_value": (
+ "Verify that declared retirement targets are cleanly deleted without "
+ "re-introducing wrappers, zombie symbols, or ghost code."
+ ),
+ "entry_command": (
+ "loopx anti-entropy inspect --goal-id --diff-file "
+ "--basis-json --workspace --format json"
+ ),
+ "workflow_skill": {
+ "name": "loopx-anti-entropy",
+ "delivery": "turn_context_hook_and_shared_cli",
+ "activation": "explicit_task_scoped_enablement",
+ "project_copy_required": False,
+ },
+ "commands": [
+ {
+ "command": "loopx anti-entropy verify --goal-id --diff-file --workspace --format json",
+ "purpose": "Verify exact diff hygiene and delete-first compliance against a declared retirement basis.",
+ "write_boundary": "read-only diff verification and receipt generation; no repository mutation",
+ },
+ {
+ "command": "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json",
+ "purpose": (
+ "In-flight agent check with actionable feedback; rejections are "
+ "progress facts, not final delivery failures."
+ ),
+ "write_boundary": "read-only diff verification; no receipt store writes",
+ },
+ {
+ "command": "loopx capability show anti-entropy-governance --format json",
+ "purpose": "Inspect anti-entropy capability metadata, commands, and protocols.",
+ "write_boundary": "read-only metadata projection",
+ },
+ ],
+ "agent_tools": [
+ {
+ "name": "anti_entropy_inspect",
+ "descriptor": "loopx.capabilities.anti_entropy.feedback.ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR",
+ "entrypoint": "loopx.capabilities.anti_entropy.agent_tool.anti_entropy_inspect",
+ "purpose": (
+ "Agent-callable inspection returning actionable findings as "
+ "progress facts plus an immutable receipt."
+ ),
+ "write_boundary": "read-only verification; no host state mutation",
+ },
+ ],
+ "implemented_protocols": [
+ {
+ "schema_version": ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION,
+ "module": "loopx.capabilities.anti_entropy.contract",
+ "doc": _README,
+ },
+ {
+ "schema_version": ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION,
+ "module": "loopx.capabilities.anti_entropy.contract",
+ "doc": _README,
+ },
+ ],
+ "smokes": [
+ "python3 examples/anti-entropy-governance-smoke.py",
+ "python3 -m pytest tests/capabilities/test_anti_entropy.py -q",
+ ],
+ "docs": [_README],
+ "boundaries": [
+ "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.",
+ "The turn-context hook projects declared targets, the inspect descriptor, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.",
+ "Task intent comes from the selected Todo's own instruction/note text or an explicit capability id in its required capabilities; the post-change workspace is the Goal repository, never the process working directory.",
+ "LoopX has no capability-owned host tool registry: the inspect descriptor is projected as turn-context data, and the CLI (or the Python entry it wraps) remains the callable route.",
+ "The CLI (verify/inspect) remains the shared verification entry for humans and agents; final delivery acceptance is owned by the existing change-quality prepare/record/verify owner.",
+ "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.",
+ "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.",
+ "Target paths declared for decommissioning must be cleanly removed in the unified diff.",
+ "Symbol retirement certification requires a completely scanned, explicitly bound post-change workspace; truncated scans (oversized files, unreadable files, file-count limits) block certification with AE004.",
+ "Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.",
+ "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.",
+ ],
+ "next_real_step": (
+ "Run verification: loopx anti-entropy verify --diff-file "
+ "--basis-json --workspace --format json"
+ ),
+}
diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py
new file mode 100644
index 0000000000..346b404102
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/cli.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from .contract import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ RetirementBasis,
+)
+from .oracles import evaluate_diff_hygiene
+
+
+def run_anti_entropy_verify(
+ *,
+ diff_text: str,
+ basis_data: Any = None,
+ goal_id: str | None = None,
+ workspace: str | Path | None = None,
+) -> dict[str, Any]:
+ """Verify exact diff hygiene and delete-first compliance, returning structured payload.
+
+ ``workspace`` explicitly binds the post-change sources used to verify symbol
+ retirement. Without it, verdicts can reject but never certify retirement
+ (AE004 insufficient source evidence).
+ """
+ basis = (
+ basis_data
+ if isinstance(basis_data, RetirementBasis)
+ else (RetirementBasis.from_dict(basis_data) if basis_data is not None else None)
+ )
+ receipt = evaluate_diff_hygiene(diff_text, basis, workspace=workspace)
+
+ blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking")
+ advisory_count = sum(1 for f in receipt.findings if f.severity == "advisory")
+
+ return {
+ "ok": receipt.verdict == "passed",
+ "command": "verify",
+ "capability_id": ANTI_ENTROPY_CAPABILITY_ID,
+ "goal_id": goal_id,
+ "verdict": receipt.verdict,
+ "receipt": receipt.to_dict(),
+ "finding_count": len(receipt.findings),
+ "blocking_finding_count": blocking_count,
+ "advisory_finding_count": advisory_count,
+ "retired_symbols": list(receipt.retired_symbols),
+ }
diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py
new file mode 100644
index 0000000000..9da7539453
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/contract.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import hashlib
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import Any
+
+ANTI_ENTROPY_CAPABILITY_ID = "anti-entropy-governance"
+ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION = "loopx_anti_entropy_receipt_v0"
+ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION = "loopx_anti_entropy_retirement_basis_v0"
+
+
+@dataclass(frozen=True, slots=True)
+class RetirementBasis:
+ """Declared code symbols and paths slated for safe decommissioning."""
+
+ schema_version: str = ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION
+ target_symbols: tuple[str, ...] = ()
+ target_paths: tuple[str, ...] = ()
+ allow_compat_seam: bool = False
+ rationale: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema_version": self.schema_version,
+ "target_symbols": list(self.target_symbols),
+ "target_paths": list(self.target_paths),
+ "allow_compat_seam": self.allow_compat_seam,
+ "rationale": self.rationale,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Any) -> RetirementBasis:
+ if isinstance(data, cls):
+ return data
+ if not isinstance(data, Mapping):
+ raise TypeError(
+ f"RetirementBasis data must be a JSON object (mapping), got {type(data).__name__}"
+ )
+
+ schema_version = data.get("schema_version")
+ if schema_version is not None and not isinstance(schema_version, str):
+ raise TypeError(
+ f"schema_version must be a string, got {type(schema_version).__name__}"
+ )
+
+ symbols_raw = data.get("target_symbols")
+ target_symbols: list[str] = []
+ if symbols_raw is not None:
+ if not isinstance(symbols_raw, (list, tuple, set)):
+ raise TypeError(
+ f"target_symbols must be a list of strings, got {type(symbols_raw).__name__}"
+ )
+ for s in symbols_raw:
+ if not isinstance(s, str):
+ raise TypeError(
+ f"target_symbols elements must be strings, got {type(s).__name__}"
+ )
+ clean_s = s.strip()
+ if clean_s:
+ target_symbols.append(clean_s)
+
+ paths_raw = data.get("target_paths")
+ target_paths: list[str] = []
+ if paths_raw is not None:
+ if not isinstance(paths_raw, (list, tuple, set)):
+ raise TypeError(
+ f"target_paths must be a list of strings, got {type(paths_raw).__name__}"
+ )
+ for p in paths_raw:
+ if not isinstance(p, str):
+ raise TypeError(
+ f"target_paths elements must be strings, got {type(p).__name__}"
+ )
+ clean_p = p.strip()
+ if clean_p:
+ target_paths.append(clean_p)
+
+ allow_compat = data.get("allow_compat_seam", False)
+ if not isinstance(allow_compat, bool):
+ raise TypeError(
+ f"allow_compat_seam must be a boolean, got {type(allow_compat).__name__} (value: {allow_compat!r})"
+ )
+
+ rationale = data.get("rationale", "")
+ if rationale is not None and not isinstance(rationale, str):
+ raise TypeError(
+ f"rationale must be a string, got {type(rationale).__name__}"
+ )
+
+ return cls(
+ schema_version=str(
+ schema_version or ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION
+ ),
+ target_symbols=tuple(target_symbols),
+ target_paths=tuple(target_paths),
+ allow_compat_seam=allow_compat,
+ rationale=str(rationale or ""),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class AntiEntropyFinding:
+ """One actionable defect where entropy or ghost code was observed."""
+
+ rule_id: str
+ severity: str # "blocking" | "advisory"
+ message: str
+ location: str | None = None
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "rule_id": self.rule_id,
+ "severity": self.severity,
+ "message": self.message,
+ "location": self.location,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class AntiEntropyReceipt:
+ """Durable receipt verifying diff hygiene and delete-first enforcement.
+
+ Evidence provenance: ``basis_hash`` binds the receipt to the exact declared
+ retirement intent; ``source_root`` and ``source_evidence`` record which
+ post-change sources (if any) backed the verdict. A ``diff_only`` receipt can
+ reject but never certifies symbol retirement.
+ """
+
+ schema_version: str = ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION
+ receipt_id: str = field(default="")
+ diff_hash: str = field(default="")
+ verdict: str = "passed" # "passed" | "rejected"
+ findings: tuple[AntiEntropyFinding, ...] = ()
+ retired_symbols: tuple[str, ...] = ()
+ timestamp_utc: float = field(default_factory=time.time)
+ basis_hash: str = field(default="")
+ source_root: str | None = field(default=None)
+ source_evidence: str = "diff_only" # "workspace_sources" | "diff_only"
+
+ def to_dict(self) -> dict[str, Any]:
+ receipt_id = self.receipt_id
+ if not receipt_id:
+ payload = f"{self.diff_hash}:{self.verdict}:{self.timestamp_utc}"
+ receipt_id = f"aer_{hashlib.sha256(payload.encode()).hexdigest()[:16]}"
+ return {
+ "schema_version": self.schema_version,
+ "receipt_id": receipt_id,
+ "diff_hash": self.diff_hash,
+ "verdict": self.verdict,
+ "findings": [f.to_dict() for f in self.findings],
+ "retired_symbols": list(self.retired_symbols),
+ "timestamp_utc": self.timestamp_utc,
+ "basis_hash": self.basis_hash,
+ "source_root": self.source_root,
+ "source_evidence": self.source_evidence,
+ }
diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py
new file mode 100644
index 0000000000..0ccf8c6a88
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/feedback.py
@@ -0,0 +1,244 @@
+"""Default-off, task-scoped feedback loop for anti-entropy retirement work.
+
+Single-task closed loop:
+
+1. Task context: ``build_anti_entropy_turn_context`` projects a declared
+ ``RetirementBasis`` into a typed turn-context payload a host can inject into
+ agent input when the capability is enabled for that task.
+2. In-flight check: agents call the ``anti_entropy_inspect`` tool (or the CLI
+ mirror) and receive actionable feedback. Blocking findings are progress
+ facts guiding the next correction, not final delivery failures.
+3. Delivery verification: stays owned by the existing change-quality owner
+ (exact-diff prepare/record/verify and receipt invalidation). This module
+ only returns the delivery route; it never writes receipts or creates a
+ parallel delivery authority.
+
+Model adoption is proven by actual runs elsewhere; the tests for this module
+cover the protocol and the real callable entry points.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Mapping
+
+from .agent_tool import anti_entropy_inspect
+from .contract import AntiEntropyFinding, RetirementBasis
+from .oracles import normalize_repo_path
+
+_INSPECT_CLI_ROUTE = (
+ "loopx anti-entropy inspect --diff-file "
+ "--basis-json --workspace --format json"
+)
+
+_DELIVERY_ROUTE = (
+ "loopx change-quality prepare --goal-id "
+ "(bind --repo-path/--base-ref to the reviewed scope), "
+ "then loopx change-quality record --goal-id "
+ "--result-json --execute "
+ "(result conforms to change_quality_agent_result_v2 with the prepared "
+ "scope_fingerprint), "
+ "then loopx change-quality verify --goal-id "
+ "(same --repo-path/--base-ref)"
+)
+
+_TOOL_NAME = "anti_entropy_inspect"
+
+_TOOL_DESCRIPTION = (
+ "Verify a unified diff against a declared retirement basis and diff hygiene "
+ "rules. Returns an immutable receipt with actionable findings: blocking "
+ "findings are progress facts to fix before delivery, not final delivery "
+ "failures. Symbol retirement certification requires an explicitly bound "
+ "workspace holding the complete post-change sources."
+)
+
+ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR: dict[str, Any] = {
+ "name": _TOOL_NAME,
+ "description": _TOOL_DESCRIPTION,
+ "input_schema": {
+ "type": "object",
+ "required": ["diff_text"],
+ "properties": {
+ "diff_text": {
+ "type": "string",
+ "description": "Unified diff text of the change under review.",
+ },
+ "retirement_basis": {
+ "type": "object",
+ "description": (
+ "Declared RetirementBasis (target_symbols, target_paths, "
+ "allow_compat_seam, rationale)."
+ ),
+ },
+ "workspace": {
+ "type": "string",
+ "description": (
+ "Directory with the complete post-change sources; required "
+ "to certify symbol retirement."
+ ),
+ },
+ "goal_id": {"type": "string", "description": "Associated LoopX goal identifier."},
+ },
+ },
+ "cli_route": _INSPECT_CLI_ROUTE,
+}
+
+
+def _rule_next_action(finding: AntiEntropyFinding) -> str:
+ if finding.rule_id.startswith("AE001"):
+ return "Remove the superseded definition or target path in this change, or declare an authorized compatibility seam."
+ if finding.rule_id.startswith("AE002"):
+ return "Remove the reintroduced wrapper or active reference so the retired name has no live definition or caller."
+ if finding.rule_id.startswith("AE003"):
+ return "Delete the commented-out block under version control instead of keeping ghost code."
+ if finding.rule_id.startswith("AE004"):
+ return "Re-run with --workspace bound to the complete post-change sources; diff-only evidence cannot certify retirement."
+ return "Address the reported diff hygiene finding and re-run the inspection."
+
+
+def build_anti_entropy_turn_context(
+ *,
+ basis: RetirementBasis | Mapping[str, Any],
+ goal_id: str | None = None,
+ workspace: str | Path | None = None,
+) -> dict[str, Any]:
+ """Project a declared retirement basis into a typed turn-context payload.
+
+ The payload is data for the host to inject when the capability is enabled
+ for the task; calling this function does not execute anything.
+ """
+ basis_obj = (
+ basis if isinstance(basis, RetirementBasis) else RetirementBasis.from_dict(basis)
+ )
+ context: dict[str, Any] = {
+ "capability_id": "anti-entropy-governance",
+ "task": {
+ "objective": basis_obj.rationale,
+ "target_symbols": list(basis_obj.target_symbols),
+ "target_paths": list(basis_obj.target_paths),
+ "allow_compat_seam": basis_obj.allow_compat_seam,
+ },
+ "inspect_tool": ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR,
+ "feedback_semantics": {
+ "blocking_findings": (
+ "Progress facts for the next correction round; they are not "
+ "final delivery failures."
+ ),
+ "retirement_certification": (
+ "Requires an explicitly bound workspace with complete "
+ "post-change sources (AE004 otherwise)."
+ ),
+ "delivery_gate": (
+ "Final acceptance is owned by change-quality prepare/record/"
+ "verify; this capability supplies evidence only."
+ ),
+ },
+ "delivery_route": _DELIVERY_ROUTE,
+ }
+ if goal_id is not None:
+ context["goal_id"] = goal_id
+ if workspace is not None:
+ context["workspace"] = str(workspace)
+ return context
+
+
+def run_anti_entropy_feedback_cycle(
+ *,
+ diff_text: str,
+ basis: RetirementBasis | Mapping[str, Any] | None = None,
+ goal_id: str | None = None,
+ workspace: str | Path | None = None,
+) -> dict[str, Any]:
+ """Run one inspect round and classify its outcome as actionable feedback.
+
+ This is the real in-flight check entry: it never raises on verification
+ findings. A rejected verdict yields ``is_progress_fact=True`` with
+ per-finding next actions; only a passed, source-bound verification with all
+ targets covered marks the round delivery-ready (final acceptance still
+ belongs to the change-quality owner).
+ """
+ result = anti_entropy_inspect(
+ diff_text=diff_text,
+ retirement_basis=(
+ dict(basis) if isinstance(basis, Mapping) else basis
+ ),
+ goal_id=goal_id,
+ workspace=workspace,
+ )
+ receipt = result.get("receipt", {})
+ findings = receipt.get("findings", [])
+ actionable: list[dict[str, Any]] = [
+ {
+ "rule_id": f.get("rule_id"),
+ "severity": f.get("severity"),
+ "message": f.get("message"),
+ "location": f.get("location"),
+ "next_action": _rule_next_action(
+ AntiEntropyFinding(
+ rule_id=f.get("rule_id", ""),
+ severity=f.get("severity", "advisory"),
+ message=f.get("message", ""),
+ location=f.get("location"),
+ )
+ ),
+ }
+ for f in findings
+ ]
+
+ verdict = result.get("verdict")
+ retired = result.get("retired_symbols", [])
+ source_evidence = receipt.get("source_evidence", "diff_only")
+
+ basis_obj = (
+ basis if isinstance(basis, RetirementBasis) else (
+ RetirementBasis.from_dict(basis) if basis is not None else None
+ )
+ )
+ declared_symbols = list(basis_obj.target_symbols) if basis_obj is not None else []
+ declared_paths = list(basis_obj.target_paths) if basis_obj is not None else []
+ has_declared_targets = bool(declared_symbols or declared_paths)
+
+ symbols_retired = (
+ not declared_symbols
+ or (bool(declared_symbols) and all(s in retired for s in declared_symbols))
+ )
+
+ finding_locations = {f.get("location") for f in findings if f.get("location")}
+ paths_clean = True
+ if declared_paths:
+ if any(p in finding_locations for p in declared_paths):
+ paths_clean = False
+ elif workspace is not None:
+ workspace_path = Path(workspace).resolve()
+ if workspace_path.exists():
+ for p in declared_paths:
+ norm = normalize_repo_path(p)
+ if norm != "/dev/null" and (workspace_path / norm).resolve().exists():
+ paths_clean = False
+ break
+ else:
+ paths_clean = False
+ else:
+ paths_clean = False
+
+ all_targets_retired = has_declared_targets and symbols_retired and paths_clean
+
+ delivery_ready = (
+ verdict == "passed"
+ and source_evidence == "workspace_sources"
+ and all_targets_retired
+ )
+
+ return {
+ "ok": True,
+ "goal_id": goal_id,
+ "verdict": verdict,
+ "source_evidence": source_evidence,
+ "retired_symbols": retired,
+ "actionable_feedback": actionable,
+ "is_progress_fact": verdict == "rejected",
+ "delivery_ready": delivery_ready,
+ "delivery_route": _DELIVERY_ROUTE,
+ "inspect_cli_route": _INSPECT_CLI_ROUTE,
+ "receipt": receipt,
+ }
diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py
new file mode 100644
index 0000000000..4a644170ce
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/oracles.py
@@ -0,0 +1,1300 @@
+from __future__ import annotations
+
+import ast
+import codecs
+import hashlib
+import json
+import os
+import re
+import textwrap
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from .contract import (
+ AntiEntropyFinding,
+ AntiEntropyReceipt,
+ RetirementBasis,
+)
+
+_PY_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)")
+
+_TS_DEF_PATTERNS = [
+ # function / async function / export function
+ re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)"),
+ # class / abstract class / export class
+ re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)"),
+ # interface
+ re.compile(r"^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)"),
+ # type alias: type Foo = ... or type Foo = ...
+ re.compile(r"^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*="),
+ # enum
+ re.compile(r"^\s*(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)"),
+ # const / let / var Foo = (...) => or = async (...) => (with optional generics)
+ re.compile(
+ r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>"
+ ),
+ # const / let / var Foo = function
+ re.compile(
+ r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function"
+ ),
+ # const / let / var Foo = async (
+ re.compile(
+ r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*async\s*(?:<[^>]*>)?\s*\("
+ ),
+ # class methods / interface methods: e.g. public async oldMethod(param: string): void {
+ re.compile(
+ r"^\s*(?:(?:public|private|protected|readonly|override)\s+)*(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{;]+)?\s*\{?"
+ ),
+]
+
+_TS_RESERVED_NAMES = {
+ "if", "for", "switch", "while", "catch", "constructor", "get", "set",
+ "function", "class", "export", "import", "return", "throw", "default",
+ "new", "typeof", "instanceof", "void", "delete", "yield", "await",
+}
+
+_COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$")
+
+_DOC_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"}
+_TS_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
+_PY_EXTENSIONS = {".py", ".pyi"}
+
+_RULE_AE001 = "AE001-DELETE_FIRST_VIOLATION"
+_RULE_AE002 = "AE002-ZOMBIE_SYMBOL_REINTRODUCED"
+_RULE_AE003 = "AE003-COMMENTED_CODE_ADVISORY"
+_RULE_AE004 = "AE004-INSUFFICIENT_SOURCE_EVIDENCE"
+
+_WORKSPACE_SKIP_DIRS = {
+ ".git",
+ ".hg",
+ ".svn",
+ "node_modules",
+ ".venv",
+ "venv",
+ "__pycache__",
+ ".tox",
+ ".nox",
+ ".mypy_cache",
+ ".pytest_cache",
+ ".ruff_cache",
+ "dist",
+ "build",
+ ".idea",
+ ".vscode",
+}
+
+_MAX_SOURCE_FILE_BYTES = 2_000_000
+
+_MAX_WORKSPACE_FILES = 20000
+
+
+def _hash_diff(diff_text: str) -> str:
+ return hashlib.sha256(diff_text.encode("utf-8")).hexdigest()
+
+
+def hash_retirement_basis(basis: RetirementBasis) -> str:
+ """Stable hash binding the receipt to the exact declared retirement intent."""
+ payload = json.dumps(basis.to_dict(), sort_keys=True, ensure_ascii=False)
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def find_repo_root(start_path: Path | None = None) -> Path:
+ """Resolve repository root by looking for .git folder up the tree.
+
+ Kept for callers that locate a repository; verification itself requires an
+ explicitly bound workspace and no longer infers one from the CWD.
+ """
+ cur = (start_path or Path.cwd()).resolve()
+ for parent in [cur, *cur.parents]:
+ if (parent / ".git").exists():
+ return parent
+ return cur
+
+
+def decode_git_diff_path(path_str: str, is_source: bool = True) -> str:
+ """Decode a Git diff path with optional C-style quotes, stripping Git a/ or b/ prefix."""
+ path_str = path_str.strip()
+ if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2:
+ inner = path_str[1:-1]
+ try:
+ raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0]
+ path_str = raw_bytes.decode("utf-8")
+ except Exception:
+ path_str = inner
+ path_str = path_str.replace("\\", "/").strip()
+ if path_str == "/dev/null":
+ return "/dev/null"
+ if is_source and path_str.startswith("a/"):
+ return path_str[2:]
+ elif not is_source and path_str.startswith("b/"):
+ return path_str[2:]
+ elif path_str.startswith("a/") or path_str.startswith("b/"):
+ return path_str[2:]
+ return path_str
+
+
+def normalize_repo_path(path_str: str) -> str:
+ """Normalize a repository-relative target path from RetirementBasis.
+
+ Does not strip real directory segments (e.g. "a/") or leading hidden dots (e.g. ".api.py").
+ """
+ path_str = path_str.strip()
+ if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2:
+ inner = path_str[1:-1]
+ try:
+ raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0]
+ path_str = raw_bytes.decode("utf-8")
+ except Exception:
+ path_str = inner
+ path_str = path_str.replace("\\", "/").strip()
+ while path_str.startswith("./"):
+ path_str = path_str[2:]
+ while path_str.startswith("/"):
+ path_str = path_str[1:]
+ return path_str
+
+
+def unquote_git_path(path_str: str) -> str:
+ """Backward-compatible helper for unquoting git diff paths."""
+ return decode_git_diff_path(path_str)
+
+
+def parse_diff_git_header(line: str) -> tuple[str, str] | None:
+ """Parse diff --git line correctly, handling paths with spaces and quotes."""
+ line = line.strip()
+ if not line.startswith("diff --git "):
+ return None
+ rest = line[len("diff --git "):].strip()
+
+ # Case 1: Both quoted, e.g. "a/foo bar" "b/foo bar"
+ m_quoted = re.match(r'^("(?:[^"\\]|\\.)+")\s+("(?:[^"\\]|\\.)+")$', rest)
+ if m_quoted:
+ return m_quoted.group(1), m_quoted.group(2)
+
+ # Case 2: Identical relative paths across a/ and b/ (deletion / modification)
+ m_same = re.match(r"^a/(.+)\s+b/\1$", rest)
+ if m_same:
+ return f"a/{m_same.group(1)}", f"b/{m_same.group(1)}"
+
+ # Case 3: Search for separator " b/" from the right
+ if rest.startswith("a/"):
+ idx = rest.rfind(" b/")
+ if idx != -1:
+ return rest[:idx], rest[idx + 1:]
+
+ parts = rest.split(None, 1)
+ if len(parts) == 2:
+ return parts[0], parts[1]
+ return None
+
+
+def _is_doc_file(file_path: str) -> bool:
+ ext = Path(file_path).suffix.lower() if file_path else ""
+ return ext in _DOC_EXTENSIONS
+
+
+def _extract_deleted_paths(diff_text: str) -> set[str]:
+ deleted_paths: set[str] = set()
+ diff_text = textwrap.dedent(diff_text)
+
+ # 1. Parse sections starting with diff --git
+ diff_sections = re.split(r"(?=^diff --git )", diff_text, flags=re.MULTILINE)
+ for sec in diff_sections:
+ if not sec.startswith("diff --git "):
+ continue
+ first_line = sec.splitlines()[0]
+ is_deleted = bool(
+ re.search(r"^deleted file mode \d+", sec, re.MULTILINE)
+ or re.search(r"^\+\+\+\s+/dev/null", sec, re.MULTILINE)
+ )
+ if is_deleted:
+ # Check for standard patch header: --- a/\n+++ /dev/null
+ m_patch = re.search(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", sec, re.MULTILINE)
+ if m_patch:
+ clean_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=True)
+ if clean_path:
+ deleted_paths.add(clean_path)
+ continue
+
+ # Empty file deletion with no content hunks (deleted file mode 100644 only)
+ header_paths = parse_diff_git_header(first_line)
+ if header_paths:
+ clean_path = decode_git_diff_path(header_paths[0], is_source=True)
+ if clean_path:
+ deleted_paths.add(clean_path)
+
+ # 2. Also match standard --- \n+++ /dev/null for diffs without diff --git
+ for match in re.finditer(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", diff_text, re.MULTILINE):
+ raw_path = match.group(1).strip()
+ clean_path = decode_git_diff_path(raw_path, is_source=True)
+ if clean_path:
+ deleted_paths.add(clean_path)
+
+ return deleted_paths
+
+
+def _extract_python_defs_from_code(code: str) -> set[str] | None:
+ """Extract Python definitions from a complete-parseable code block.
+
+ Returns None when the text cannot be parsed as Python at all (so callers can
+ treat it as unparseable rather than as "defines nothing").
+ """
+ if not code.strip():
+ return set()
+ dedented = textwrap.dedent(code)
+ try:
+ tree = ast.parse(dedented)
+ return _python_def_names(tree)
+ except SyntaxError:
+ try:
+ wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ")
+ tree = ast.parse(wrapped)
+ return {
+ name
+ for name in _python_def_names(tree)
+ if name != "_wrapper_"
+ }
+ except SyntaxError:
+ return None
+
+
+def _python_def_names(tree: ast.AST) -> set[str]:
+ return {
+ node.name
+ for node in ast.walk(tree)
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
+ }
+
+
+def _extract_python_signature_defs(lines: list[str]) -> set[str]:
+ """Line-level fallback for code fragments that cannot be parsed as Python.
+
+ Only meaningful for in-diff code fragments; complete files must use the AST
+ result instead so that prose/docstring examples are never counted as defs.
+ """
+ defs: set[str] = set()
+ for line in lines:
+ m = _PY_DEF_OR_CLASS_RE.match(line)
+ if m:
+ name = m.group(1)
+ try:
+ tree = ast.parse(f"{line.strip()}\n pass")
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ if node.name == name:
+ defs.add(name)
+ except SyntaxError:
+ defs.add(name)
+ return defs
+
+
+def _extract_ts_defs(lines: list[str]) -> set[str]:
+ defs: set[str] = set()
+ for line in lines:
+ line_clean = line.strip()
+ if not line_clean or line_clean.startswith("//") or line_clean.startswith("/*") or line_clean.startswith("*"):
+ continue
+ for pattern in _TS_DEF_PATTERNS:
+ m = pattern.match(line)
+ if m:
+ candidate = m.group(1)
+ if candidate not in _TS_RESERVED_NAMES:
+ defs.add(candidate)
+ break
+ return defs
+
+
+_TS_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z")
+_TS_QUOTE_CHARS = ("\"", "'", chr(96))
+
+
+def _static_computed_member(
+ text: str, start: int, out: list[str]
+) -> tuple[str, int] | None:
+ """Preserve a statically named computed member access (api[name]).
+
+ Only a member name in a value position is preserved. Array literals,
+ declarations, and dynamic expressions stay string-literal text and never
+ become executable references; unparseable forms keep the normal string
+ handling so they cannot be certified as absent evidence.
+ """
+ index = start + 1
+ length = len(text)
+ while index < length and text[index] in " \t":
+ index += 1
+ if index >= length or text[index] not in _TS_QUOTE_CHARS:
+ return None
+ quote = text[index]
+ cursor = index + 1
+ content_start = cursor
+ while cursor < length:
+ char = text[cursor]
+ if char == "\\" or char == "\n":
+ return None
+ if char == quote:
+ break
+ cursor += 1
+ if cursor >= length:
+ return None
+ name = text[content_start:cursor]
+ if not _TS_IDENTIFIER_RE.match(name):
+ return None
+ after = cursor + 1
+ while after < length and text[after] in " \t":
+ after += 1
+ if after >= length or text[after] != "]":
+ return None
+ previous = ""
+ for position in range(len(out) - 1, -1, -1):
+ token = out[position]
+ stripped = token.rstrip()
+ if stripped:
+ previous = stripped[-1]
+ break
+ if not (previous.isalnum() or previous in "_$)]."):
+ return None
+ return name, after + 1
+
+
+def _tokenize_ts_code(text: str) -> tuple[str, bool]:
+ """Tokenize and strip comments and string-literal spans from TS/JS code.
+
+ Preserves code expressions inside template string interpolations (`${` ... `}`),
+ ensuring that active references to target symbols are preserved while plain
+ template text and string literals are replaced with spaces.
+ Handles nested template literals, brace nesting inside expressions, and
+ escaped characters.
+ Returns (stripped_code, is_clean) where is_clean is False if syntax has
+ unclosed template literals, block comments, or unclosed strings.
+ """
+ out: list[str] = []
+ i = 0
+ n = len(text)
+ stack: list[Any] = [] # items: 'template' or {'type': 'expr', 'brace_depth': int}
+
+ while i < n:
+ if text[i] == "[" and (not stack or stack[-1] != "template"):
+ computed = _static_computed_member(text, i, out)
+ if computed is not None:
+ name, next_index = computed
+ out.append(" ")
+ out.append(name)
+ i = next_index
+ continue
+ if not stack:
+ # Top-level code
+ if text[i : i + 2] == "//":
+ while i < n and text[i] != "\n":
+ out.append(" ")
+ i += 1
+ if i < n and text[i] == "\n":
+ out.append("\n")
+ i += 1
+ elif text[i : i + 2] == "/*":
+ out.append(" ")
+ out.append(" ")
+ i += 2
+ closed = False
+ while i < n:
+ if text[i : i + 2] == "*/":
+ out.append(" ")
+ out.append(" ")
+ i += 2
+ closed = True
+ break
+ out.append("\n" if text[i] == "\n" else " ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == '"':
+ out.append(" ")
+ i += 1
+ closed = False
+ while i < n:
+ if text[i] == "\\":
+ out.append(" ")
+ out.append(" ")
+ i += 2 if i + 1 < n else 1
+ continue
+ if text[i] == '"':
+ out.append(" ")
+ i += 1
+ closed = True
+ break
+ if text[i] == "\n":
+ break
+ out.append(" ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == "'":
+ out.append(" ")
+ i += 1
+ closed = False
+ while i < n:
+ if text[i] == "\\":
+ out.append(" ")
+ out.append(" ")
+ i += 2 if i + 1 < n else 1
+ continue
+ if text[i] == "'":
+ out.append(" ")
+ i += 1
+ closed = True
+ break
+ if text[i] == "\n":
+ break
+ out.append(" ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == "`":
+ out.append(" ")
+ i += 1
+ stack.append("template")
+ else:
+ out.append(text[i])
+ i += 1
+ elif stack[-1] == "template":
+ # Inside template literal
+ if text[i : i + 2] == "${":
+ out.append(" ")
+ out.append(" ")
+ i += 2
+ stack.append({"type": "expr", "brace_depth": 1})
+ elif text[i] == "`":
+ out.append(" ")
+ i += 1
+ stack.pop()
+ elif text[i] == "\\":
+ out.append(" ")
+ out.append(" ")
+ i += 2 if i + 1 < n else 1
+ else:
+ out.append("\n" if text[i] == "\n" else " ")
+ i += 1
+ else:
+ # Inside template interpolation expression
+ top = stack[-1]
+ if text[i : i + 2] == "//":
+ while i < n and text[i] != "\n":
+ out.append(" ")
+ i += 1
+ if i < n and text[i] == "\n":
+ out.append("\n")
+ i += 1
+ elif text[i : i + 2] == "/*":
+ out.append(" ")
+ out.append(" ")
+ i += 2
+ closed = False
+ while i < n:
+ if text[i : i + 2] == "*/":
+ out.append(" ")
+ out.append(" ")
+ i += 2
+ closed = True
+ break
+ out.append("\n" if text[i] == "\n" else " ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == '"':
+ out.append(" ")
+ i += 1
+ closed = False
+ while i < n:
+ if text[i] == "\\":
+ out.append(" ")
+ out.append(" ")
+ i += 2 if i + 1 < n else 1
+ continue
+ if text[i] == '"':
+ out.append(" ")
+ i += 1
+ closed = True
+ break
+ if text[i] == "\n":
+ break
+ out.append(" ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == "'":
+ out.append(" ")
+ i += 1
+ closed = False
+ while i < n:
+ if text[i] == "\\":
+ out.append(" ")
+ out.append(" ")
+ i += 2 if i + 1 < n else 1
+ continue
+ if text[i] == "'":
+ out.append(" ")
+ i += 1
+ closed = True
+ break
+ if text[i] == "\n":
+ break
+ out.append(" ")
+ i += 1
+ if not closed:
+ return "".join(out), False
+ elif text[i] == "`":
+ out.append(" ")
+ i += 1
+ stack.append("template")
+ elif text[i] == "{":
+ top["brace_depth"] += 1
+ out.append("{")
+ i += 1
+ elif text[i] == "}":
+ top["brace_depth"] -= 1
+ if top["brace_depth"] == 0:
+ stack.pop()
+ out.append(" ")
+ i += 1
+ else:
+ out.append("}")
+ i += 1
+ else:
+ out.append(text[i])
+ i += 1
+
+ return "".join(out), len(stack) == 0
+
+
+def _strip_ts_line(line: str) -> str:
+ """Strip // comments and string-literal spans from a TS/JS-like line, preserving template interpolations."""
+ stripped, _ = _tokenize_ts_code(line)
+ return stripped
+
+
+def _extract_ts_refs(
+ lines: list[str],
+ target_symbols: set[str],
+) -> set[str]:
+ refs, _ = _extract_ts_refs_with_syntax_check(lines, target_symbols)
+ return refs
+
+
+def _extract_ts_refs_with_syntax_check(
+ lines: list[str],
+ target_symbols: set[str],
+) -> tuple[set[str], bool]:
+ """Extract real references to target symbols in TS/JS-like lines.
+
+ Preserves lexical boundaries: template string text, string literals, and
+ comments are stripped, while executable code inside template interpolations
+ is preserved.
+ """
+ refs: set[str] = set()
+ if not target_symbols:
+ return refs, True
+
+ full_text = "\n".join(lines)
+ stripped_text, is_clean = _tokenize_ts_code(full_text)
+ stripped_lines = stripped_text.splitlines()
+
+ for i, raw_line in enumerate(lines):
+ stripped = stripped_lines[i] if i < len(stripped_lines) else ""
+ if not stripped.strip():
+ continue
+
+ defined_on_line: str | None = None
+ for pattern in _TS_DEF_PATTERNS:
+ m = pattern.match(raw_line.strip())
+ if m:
+ candidate = m.group(1)
+ if candidate in target_symbols and candidate not in _TS_RESERVED_NAMES:
+ defined_on_line = candidate
+ break
+
+ line_for_refs = stripped
+ if defined_on_line:
+ line_for_refs = re.sub(r"\b" + re.escape(defined_on_line) + r"\b", "", line_for_refs, count=1)
+
+ words = set(re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", line_for_refs))
+ for s in target_symbols:
+ if s in words:
+ refs.add(s)
+
+ return refs, is_clean
+
+
+def _extract_defs_for_file(lines: list[str], file_path: str = "", is_full_file: bool = False) -> set[str]:
+ """Extract definitions for in-diff fragments of a file.
+
+ Documentation files never contribute symbol definitions: fenced examples in
+ markdown are prose, not code. For Python fragments, the AST result wins when
+ the fragment parses; the line-signature fallback only covers unparseable
+ fragments of code files.
+ """
+ if _is_doc_file(file_path):
+ return set()
+
+ code_text = "\n".join(lines)
+ ext = Path(file_path).suffix.lower() if file_path else ""
+
+ if ext in _TS_EXTENSIONS:
+ return _extract_ts_defs(lines)
+ elif ext in _PY_EXTENSIONS:
+ py_ast_defs = _extract_python_defs_from_code(code_text)
+ if py_ast_defs is not None:
+ return py_ast_defs
+ if is_full_file:
+ return set()
+ return _extract_python_signature_defs(lines)
+ else:
+ py_ast_defs = _extract_python_defs_from_code(code_text)
+ if py_ast_defs:
+ return py_ast_defs
+ if is_full_file:
+ return _extract_ts_defs(lines)
+ return _extract_python_signature_defs(lines) | _extract_ts_defs(lines)
+
+
+@dataclass(frozen=True, slots=True)
+class _DiffSymbolAnalysis:
+ deleted_symbols: set[str] = field(default_factory=set)
+ reintroduced_symbols: set[str] = field(default_factory=set)
+ surviving_context_defs: set[str] = field(default_factory=set)
+ still_defined_on_disk: set[str] = field(default_factory=set)
+ all_added_lines: list[str] = field(default_factory=list)
+ unverified_source_files: tuple[str, ...] = ()
+ file_to_deleted_symbols: dict[str, set[str]] = field(default_factory=dict)
+ deleted_paths: set[str] = field(default_factory=set)
+
+
+@dataclass(slots=True)
+class _WorkspaceEvidence:
+ """Complete post-change source evidence bound to an explicitly given workspace."""
+
+ root: Path
+ defined_symbols: dict[str, set[str]] = field(default_factory=dict)
+ referenced_symbols: dict[str, set[str]] = field(default_factory=dict)
+ existing_paths: set[str] = field(default_factory=set)
+ scan_incomplete: bool = False
+ incomplete_reason: str = ""
+ skipped_files: tuple[str, ...] = ()
+
+
+def _extract_python_refs_from_code(code: str) -> set[str] | None:
+ """Extract real Python identifier references via AST (strings/docstrings excluded)."""
+ try:
+ tree = ast.parse(textwrap.dedent(code))
+ except SyntaxError:
+ return None
+ refs: set[str] = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Name):
+ refs.add(node.id)
+ elif isinstance(node, ast.Attribute):
+ refs.add(node.attr)
+ elif isinstance(node, ast.ImportFrom):
+ if node.module:
+ refs.add(node.module.split(".")[0])
+ for alias in node.names:
+ refs.add(alias.name.split(".")[0])
+ if alias.asname:
+ refs.add(alias.asname)
+ return refs
+
+
+def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvidence:
+ """Scan an explicitly bound workspace for definitions and real references of target symbols.
+
+ Prefilters by substring so most files never reach the parser. Documentation
+ files are skipped: they are not executable evidence.
+ """
+ evidence = _WorkspaceEvidence(root=workspace)
+ if not symbols:
+ return evidence
+
+ symbol_set = set(symbols)
+ source_exts = _PY_EXTENSIONS | _TS_EXTENSIONS
+ visited = 0
+ skipped: list[str] = []
+
+ for dirpath, dirnames, filenames in os.walk(workspace):
+ dirnames[:] = [d for d in dirnames if d not in _WORKSPACE_SKIP_DIRS]
+ for filename in filenames:
+ file_path = Path(dirpath) / filename
+ if file_path.suffix.lower() not in source_exts:
+ continue
+ rel = file_path.relative_to(workspace).as_posix()
+ if visited >= _MAX_WORKSPACE_FILES:
+ evidence.scan_incomplete = True
+ evidence.incomplete_reason = f"Workspace source file count exceeded limit ({_MAX_WORKSPACE_FILES} files)"
+ skipped.append(rel)
+ evidence.skipped_files = tuple(skipped)
+ return evidence
+ try:
+ size = file_path.stat().st_size
+ if size > _MAX_SOURCE_FILE_BYTES:
+ evidence.scan_incomplete = True
+ evidence.incomplete_reason = f"Source file '{rel}' exceeds size limit ({_MAX_SOURCE_FILE_BYTES} bytes)"
+ skipped.append(rel)
+ continue
+ content = file_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ evidence.scan_incomplete = True
+ evidence.incomplete_reason = f"Failed to read source file '{rel}': {exc}"
+ skipped.append(rel)
+ continue
+
+ visited += 1
+ if not any(s in content for s in symbol_set):
+ continue
+
+ ext = file_path.suffix.lower()
+ if ext in _PY_EXTENSIONS:
+ defs = _extract_python_defs_from_code(content)
+ refs = _extract_python_refs_from_code(content)
+ if defs is None or refs is None:
+ # Unparseable source cannot prove absence; treat the symbol
+ # as still defined to avoid false certification.
+ for s in symbol_set:
+ if s in content:
+ evidence.defined_symbols.setdefault(s, set()).add(rel)
+ continue
+ else:
+ content_lines = content.splitlines()
+ defs = _extract_ts_defs(content_lines)
+ refs, syntax_ok = _extract_ts_refs_with_syntax_check(content_lines, symbol_set)
+ if not syntax_ok:
+ evidence.scan_incomplete = True
+ evidence.incomplete_reason = f"Source file '{rel}' has unclosed or unparseable JS/TS syntax"
+ skipped.append(rel)
+ continue
+
+ for s in symbol_set:
+ if s in defs:
+ evidence.defined_symbols.setdefault(s, set()).add(rel)
+ if s in refs:
+ evidence.referenced_symbols.setdefault(s, set()).add(rel)
+
+ evidence.skipped_files = tuple(skipped)
+ return evidence
+
+
+def _analyze_diff_symbols(diff_text: str, workspace: Path | None = None) -> _DiffSymbolAnalysis:
+ """Analyze diff to extract deleted, reintroduced, surviving context, and disk-defined symbols.
+
+ Source binding: when ``workspace`` is provided, complete post-change sources
+ are read from that explicit root only — never from the caller's CWD. Files
+ that cannot be read from the bound workspace are reported as unverified.
+ """
+ deleted_symbols: set[str] = set()
+ reintroduced_symbols: set[str] = set()
+ surviving_context_defs: set[str] = set()
+ still_defined_on_disk: set[str] = set()
+ all_added_lines: list[str] = []
+ unverified_source_files: list[str] = []
+ file_to_deleted_symbols: dict[str, set[str]] = {}
+
+ diff_text = textwrap.dedent(diff_text)
+ file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE)
+ for f_diff in file_diffs:
+ if not f_diff.strip():
+ continue
+
+ file_path = ""
+ first_line = f_diff.splitlines()[0] if f_diff.startswith("diff --git ") else ""
+ header_paths = parse_diff_git_header(first_line) if first_line else None
+ if header_paths:
+ file_path = decode_git_diff_path(header_paths[1], is_source=False)
+ else:
+ m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE)
+ if m_patch:
+ file_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=False)
+
+ hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE)
+ file_d_syms: set[str] = set()
+
+ for h_text in hunk_texts:
+ if not h_text.startswith("@@"):
+ continue
+
+ lines = h_text.splitlines()[1:]
+ pre_lines: list[str] = []
+ post_lines: list[str] = []
+ deleted_lines: list[str] = []
+ added_lines: list[str] = []
+ context_lines: list[str] = []
+
+ for line in lines:
+ if line.startswith(" "):
+ clean_line = line[1:]
+ pre_lines.append(clean_line)
+ post_lines.append(clean_line)
+ context_lines.append(clean_line)
+ elif line.startswith("-") and not line.startswith("---"):
+ clean_line = line[1:]
+ pre_lines.append(clean_line)
+ deleted_lines.append(clean_line)
+ elif line.startswith("+") and not line.startswith("+++"):
+ clean_line = line[1:]
+ post_lines.append(clean_line)
+ added_lines.append(clean_line)
+ all_added_lines.append(clean_line)
+
+ pre_defs = _extract_defs_for_file(pre_lines, file_path)
+ post_defs = _extract_defs_for_file(post_lines, file_path)
+ deleted_defs = _extract_defs_for_file(deleted_lines, file_path)
+ added_defs = _extract_defs_for_file(added_lines, file_path)
+ ctx_defs = _extract_defs_for_file(context_lines, file_path)
+
+ for s in ctx_defs:
+ surviving_context_defs.add(s)
+
+ for s in pre_defs:
+ if s not in post_defs:
+ deleted_symbols.add(s)
+ file_d_syms.add(s)
+
+ for s in deleted_defs:
+ if s not in post_defs and s not in added_defs:
+ deleted_symbols.add(s)
+ file_d_syms.add(s)
+
+ for s in added_defs:
+ reintroduced_symbols.add(s)
+
+ if file_path:
+ file_to_deleted_symbols[file_path] = file_d_syms
+
+ is_file_deleted = bool(
+ re.search(r"^deleted file mode \d+", f_diff, re.MULTILINE)
+ or re.search(r"^\+\+\+\s+/dev/null", f_diff, re.MULTILINE)
+ )
+ if file_path and file_path != "/dev/null" and not is_file_deleted and not _is_doc_file(file_path):
+ if workspace is None:
+ unverified_source_files.append(file_path)
+ else:
+ source_file = (workspace / file_path).resolve()
+ if not source_file.is_file():
+ unverified_source_files.append(file_path)
+ else:
+ try:
+ content = source_file.read_text(encoding="utf-8")
+ disk_defs = _extract_defs_for_file(
+ content.splitlines(), file_path, is_full_file=True
+ )
+ if (
+ Path(file_path).suffix.lower() in _PY_EXTENSIONS
+ and _extract_python_defs_from_code(content) is None
+ ):
+ unverified_source_files.append(file_path)
+ continue
+ for s in disk_defs:
+ still_defined_on_disk.add(s)
+ if s in deleted_symbols:
+ deleted_symbols.remove(s)
+ except (OSError, UnicodeDecodeError):
+ unverified_source_files.append(file_path)
+
+ return _DiffSymbolAnalysis(
+ deleted_symbols=deleted_symbols,
+ reintroduced_symbols=reintroduced_symbols,
+ surviving_context_defs=surviving_context_defs,
+ still_defined_on_disk=still_defined_on_disk,
+ all_added_lines=all_added_lines,
+ unverified_source_files=tuple(unverified_source_files),
+ file_to_deleted_symbols=file_to_deleted_symbols,
+ deleted_paths=_extract_deleted_paths(diff_text),
+ )
+
+
+def _symbol_hunk_reintroduced(symbol: str, analysis: _DiffSymbolAnalysis) -> bool:
+ """True when the diff itself visibly re-adds a definition of the symbol."""
+ return symbol in analysis.reintroduced_symbols
+
+
+def _verify_symbol_targets(
+ basis: RetirementBasis,
+ analysis: _DiffSymbolAnalysis,
+ workspace_evidence: _WorkspaceEvidence | None,
+) -> tuple[list[AntiEntropyFinding], set[str]]:
+ """Evaluate each target symbol; returns (findings, certified_retired_symbols)."""
+ findings: list[AntiEntropyFinding] = []
+ certified: set[str] = set()
+
+ for symbol in basis.target_symbols:
+ # 1. Diff-visible zombie: definition re-added inside the diff itself.
+ if _symbol_hunk_reintroduced(symbol, analysis):
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE002,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' was slated for retirement but reintroduced "
+ "as an active definition in the diff."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ # 2. Symbol retirement is a derived fact about complete post-change
+ # sources. Without an explicitly bound workspace it cannot be certified.
+ if workspace_evidence is None:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE004,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' cannot be certified as retired: no source "
+ "workspace was bound. Pass an explicit workspace containing the complete "
+ "post-change sources; a diff alone does not prove definition removal."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ if workspace_evidence.scan_incomplete:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE004,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' cannot be certified as retired: workspace scan is "
+ f"incomplete ({workspace_evidence.incomplete_reason}). Complete post-change "
+ "source evidence is required."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ unverified_files = [
+ f
+ for f in analysis.unverified_source_files
+ if symbol in analysis.file_to_deleted_symbols.get(f, set())
+ ]
+ if unverified_files:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE004,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' cannot be certified as retired: complete "
+ f"post-change source is unavailable in the bound workspace for {', '.join(unverified_files)}."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ # 3. Real references in post-change sources (AST identifiers exclude
+ # string literals, comments and docstring examples).
+ ref_files = sorted(workspace_evidence.referenced_symbols.get(symbol, set()))
+ if ref_files:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE002,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' was slated for retirement but is still "
+ f"referenced by post-change sources: {', '.join(ref_files)}."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ # 4. Definition removal: complete workspace scan is authoritative, so a
+ # retained definition is caught even when the diff only edited prose.
+ def_files = sorted(workspace_evidence.defined_symbols.get(symbol, set()))
+ if def_files:
+ severity = "advisory" if basis.allow_compat_seam else "blocking"
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE001,
+ severity=severity,
+ message=(
+ f"Target symbol '{symbol}' was declared for retirement but its definition "
+ f"is still present in post-change sources: {', '.join(def_files)}. "
+ "Delete-first requires removing superseded definitions rather than "
+ "merely removing calls, altering docs, or adding wrappers."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ # 5. Unchanged context lines are diff-visible facts: a definition kept as
+ # hunk context was never removed by this diff.
+ if symbol in analysis.surviving_context_defs:
+ if workspace_evidence is None:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE001,
+ severity="blocking" if not basis.allow_compat_seam else "advisory",
+ message=(
+ f"Target symbol '{symbol}' was declared for retirement but its "
+ "definition is still present in unchanged diff context lines. "
+ "Delete-first requires removing superseded definitions."
+ ),
+ location=symbol,
+ )
+ )
+ else:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE004,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' appears as a retained definition in the diff "
+ "context but was not found in the bound workspace sources; the patch "
+ "does not match the bound post-change sources."
+ ),
+ location=symbol,
+ )
+ )
+ continue
+
+ # 6. No definition remains anywhere in the bound workspace. Positive
+ # deletion evidence in the diff attributes the retirement to this change.
+ if symbol in analysis.deleted_symbols or symbol in analysis.deleted_paths:
+ certified.add(symbol)
+
+ return findings, certified
+
+
+def _verify_path_targets(
+ basis: RetirementBasis,
+ analysis: _DiffSymbolAnalysis,
+ workspace_evidence: _WorkspaceEvidence | None,
+) -> list[AntiEntropyFinding]:
+ findings: list[AntiEntropyFinding] = []
+ for path in basis.target_paths:
+ norm_path = normalize_repo_path(path)
+ if norm_path not in analysis.deleted_paths:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE001,
+ severity="blocking" if not basis.allow_compat_seam else "advisory",
+ message=(
+ f"Target path '{path}' was declared for retirement in RetirementBasis "
+ "but was not removed in the diff."
+ ),
+ location=path,
+ )
+ )
+ continue
+ if workspace_evidence is not None and norm_path != "/dev/null":
+ disk_path = (workspace_evidence.root / norm_path).resolve()
+ if disk_path.exists():
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE004,
+ severity="blocking",
+ message=(
+ f"Target path '{path}' is deleted in the diff but still exists in the "
+ "bound workspace; the patch does not match the bound post-change sources."
+ ),
+ location=path,
+ )
+ )
+ return findings
+
+
+def verify_delete_first(
+ diff_text: str,
+ retirement_basis: RetirementBasis | Mapping[str, Any] | None = None,
+ workspace: str | Path | None = None,
+ _analysis: _DiffSymbolAnalysis | None = None,
+) -> list[AntiEntropyFinding]:
+ findings: list[AntiEntropyFinding] = []
+
+ if retirement_basis is None:
+ return findings
+
+ basis = (
+ retirement_basis
+ if isinstance(retirement_basis, RetirementBasis)
+ else RetirementBasis.from_dict(retirement_basis)
+ )
+
+ if not basis.target_symbols and not basis.target_paths:
+ return findings
+
+ diff_text = textwrap.dedent(diff_text)
+ analysis = _analysis or _analyze_diff_symbols(diff_text, Path(workspace) if workspace else None)
+
+ if not diff_text.strip():
+ for symbol in basis.target_symbols:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE001,
+ severity="blocking" if not basis.allow_compat_seam else "advisory",
+ message=(
+ f"Target symbol '{symbol}' was declared for retirement but diff is empty. "
+ "Delete-first requires explicit removal of superseded logic."
+ ),
+ location=symbol,
+ )
+ )
+ for path in basis.target_paths:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE001,
+ severity="blocking" if not basis.allow_compat_seam else "advisory",
+ message=(
+ f"Target path '{path}' was declared for retirement but diff is empty."
+ ),
+ location=path,
+ )
+ )
+ return findings
+
+ workspace_evidence: _WorkspaceEvidence | None = None
+ if workspace is not None and (basis.target_symbols or basis.target_paths):
+ workspace_evidence = _scan_workspace(Path(workspace), basis.target_symbols)
+
+ symbol_findings, _certified = _verify_symbol_targets(basis, analysis, workspace_evidence)
+ findings.extend(symbol_findings)
+ findings.extend(_verify_path_targets(basis, analysis, workspace_evidence))
+ return findings
+
+
+def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]:
+ findings: list[AntiEntropyFinding] = []
+ consecutive_commented_code = 0
+ start_line = ""
+
+ diff_text = textwrap.dedent(diff_text)
+ file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE)
+
+ for f_diff in file_diffs:
+ if not f_diff.strip():
+ continue
+
+ file_path = ""
+ m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE)
+ if m_git:
+ file_path = unquote_git_path(m_git.group(2).strip())
+ else:
+ m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE)
+ if m_patch:
+ file_path = unquote_git_path(m_patch.group(1).strip())
+
+ ext = Path(file_path).suffix.lower() if file_path else ""
+ if ext in _DOC_EXTENSIONS:
+ continue
+
+ consecutive_commented_code = 0
+ start_line = ""
+
+ for line in f_diff.splitlines():
+ if not line.startswith("+") or line.startswith("+++"):
+ consecutive_commented_code = 0
+ continue
+
+ raw = line[1:].strip()
+ comment_match = _COMMENT_LINE_RE.match(raw)
+ if comment_match:
+ comment_content = comment_match.group(1).strip()
+ is_code_stmt = False
+
+ if _PY_DEF_OR_CLASS_RE.match(comment_content):
+ is_code_stmt = True
+ elif any(p.match(comment_content) for p in _TS_DEF_PATTERNS):
+ is_code_stmt = True
+ elif comment_content.endswith(";") or comment_content.startswith(("import ", "from ", "export ", "return ", "if ", "for ")):
+ is_code_stmt = True
+ else:
+ try:
+ parsed = ast.parse(comment_content)
+ if len(parsed.body) == 1 and isinstance(parsed.body[0], ast.Expr):
+ val = parsed.body[0].value
+ if isinstance(val, (ast.Name, ast.Constant)):
+ is_code_stmt = False
+ elif isinstance(val, ast.Tuple) and all(isinstance(elt, (ast.Name, ast.Constant)) for elt in val.elts):
+ is_code_stmt = False
+ else:
+ is_code_stmt = True
+ else:
+ is_code_stmt = True
+ except SyntaxError:
+ is_code_stmt = False
+
+ if is_code_stmt:
+ consecutive_commented_code += 1
+ if consecutive_commented_code == 1:
+ start_line = raw
+ else:
+ consecutive_commented_code = 0
+ else:
+ consecutive_commented_code = 0
+
+ if consecutive_commented_code >= 3:
+ findings.append(
+ AntiEntropyFinding(
+ rule_id=_RULE_AE003,
+ severity="advisory",
+ message=(
+ "Detected 3+ consecutive lines of commented-out code. "
+ "Consider complete removal under version control rather than ghost commenting."
+ ),
+ location=start_line[:80],
+ )
+ )
+ consecutive_commented_code = 0
+
+ return findings
+
+
+def evaluate_diff_hygiene(
+ diff_text: str,
+ retirement_basis: RetirementBasis | Mapping[str, Any] | None = None,
+ workspace: str | Path | None = None,
+) -> AntiEntropyReceipt:
+ diff_text = textwrap.dedent(diff_text)
+ diff_hash = _hash_diff(diff_text)
+ all_findings: list[AntiEntropyFinding] = []
+
+ basis: RetirementBasis | None = None
+ if retirement_basis is not None:
+ basis = (
+ retirement_basis
+ if isinstance(retirement_basis, RetirementBasis)
+ else RetirementBasis.from_dict(retirement_basis)
+ )
+
+ workspace_path = Path(workspace) if workspace is not None else None
+ source_evidence = "workspace_sources" if workspace_path is not None else "diff_only"
+
+ analysis = _analyze_diff_symbols(diff_text, workspace_path)
+
+ if basis is not None and (basis.target_symbols or basis.target_paths):
+ workspace_evidence: _WorkspaceEvidence | None = None
+ if workspace_path is not None and (basis.target_symbols or basis.target_paths):
+ workspace_evidence = _scan_workspace(workspace_path, basis.target_symbols)
+ symbol_findings, certified = _verify_symbol_targets(basis, analysis, workspace_evidence)
+ all_findings.extend(symbol_findings)
+ all_findings.extend(_verify_path_targets(basis, analysis, workspace_evidence))
+ else:
+ certified = set()
+
+ all_findings.extend(audit_ghost_code(diff_text))
+
+ has_blocking = any(f.severity == "blocking" for f in all_findings)
+ verdict = "rejected" if has_blocking else "passed"
+
+ retired: list[str] = sorted(certified)
+
+ return AntiEntropyReceipt(
+ diff_hash=diff_hash,
+ verdict=verdict,
+ findings=tuple(all_findings),
+ retired_symbols=tuple(retired),
+ basis_hash=hash_retirement_basis(basis) if basis is not None else "",
+ source_root=str(workspace_path) if workspace_path is not None else None,
+ source_evidence=source_evidence,
+ )
diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py
new file mode 100644
index 0000000000..c612d4dc88
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/turn_context.py
@@ -0,0 +1,522 @@
+"""Turn-context hook integration for anti-entropy code governance."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+from ...control_plane.capability_hooks import TurnContextHookRegistration
+from .contract import ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis
+from .feedback import build_anti_entropy_turn_context
+
+ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024
+_BASIS_KEYS = ("retirement_basis", "anti_entropy_basis")
+_BASIS_CONTAINER_KEYS = ("metadata", "params", "task_spec", "task_parameters")
+_TEXT_CARRIER_KEYS = (
+ "note",
+ "text",
+ "description",
+ "instruction",
+ "evidence",
+ "rationale",
+)
+_ENABLE_CAPABILITY_IDS = frozenset(
+ {"anti-entropy-governance", "anti_entropy_governance", "anti_entropy"}
+)
+_UNSET = object()
+
+
+def _embedded_json_objects(text: str):
+ """Yield JSON objects embedded in free text, outermost first.
+
+ Task instructions and notes are plain strings; an explicit retirement
+ declaration may sit inside surrounding prose. A balanced scan is used
+ instead of assuming the whole string is JSON, and a failed outer parse
+ keeps scanning for an inner declaration.
+ """
+ index = 0
+ length = len(text)
+ while index < length:
+ if text[index] != "{":
+ index += 1
+ continue
+ depth = 0
+ in_string = False
+ escaped = False
+ cursor = index
+ parsed: Any = None
+ while cursor < length:
+ char = text[cursor]
+ if in_string:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ in_string = False
+ elif char == '"':
+ in_string = True
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ parsed = json.loads(text[index : cursor + 1])
+ except Exception:
+ parsed = None
+ break
+ cursor += 1
+ if isinstance(parsed, Mapping):
+ yield parsed
+ index = cursor + 1
+ else:
+ index += 1
+
+
+def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None:
+ """Recursively search a task carrier for an explicit retirement basis."""
+ if candidate is None:
+ return None
+ if isinstance(candidate, RetirementBasis):
+ return (
+ candidate
+ if (candidate.target_symbols or candidate.target_paths)
+ else None
+ )
+ if isinstance(candidate, str):
+ for embedded in _embedded_json_objects(candidate):
+ parsed = _extract_basis_candidate(embedded)
+ if parsed is not None:
+ return parsed
+ return None
+ if not isinstance(candidate, Mapping):
+ return None
+ for key in _BASIS_KEYS:
+ if key in candidate:
+ parsed = _extract_basis_candidate(candidate[key])
+ if parsed is not None:
+ return parsed
+ anti_entropy = candidate.get("anti_entropy")
+ if isinstance(anti_entropy, Mapping):
+ parsed = _extract_basis_candidate(anti_entropy)
+ if parsed is not None:
+ return parsed
+ for container_key in _BASIS_CONTAINER_KEYS:
+ if container_key in candidate:
+ parsed = _extract_basis_candidate(candidate[container_key])
+ if parsed is not None:
+ return parsed
+ # A dedicated basis object (not arbitrary task metadata) carries the
+ # declared targets directly.
+ if "target_symbols" in candidate or "target_paths" in candidate:
+ try:
+ parsed = RetirementBasis.from_dict(candidate)
+ except Exception:
+ parsed = None
+ if parsed is not None and (parsed.target_symbols or parsed.target_paths):
+ return parsed
+ for text_key in _TEXT_CARRIER_KEYS:
+ text_value = candidate.get(text_key)
+ if isinstance(text_value, str):
+ parsed = _extract_basis_candidate(text_value)
+ if parsed is not None:
+ return parsed
+ return None
+
+
+def _work_context_sources(container: Any) -> list[Mapping[str, Any]]:
+ """Return the bounded work-context sources from a payload or plan owner."""
+ if not isinstance(container, Mapping):
+ return []
+ contexts: list[Mapping[str, Any]] = []
+ direct = container.get("work_context")
+ if isinstance(direct, Mapping):
+ contexts.append(direct)
+ channel = container.get("agent_channel")
+ if isinstance(channel, Mapping):
+ nested = channel.get("work_context")
+ if isinstance(nested, Mapping):
+ contexts.append(nested)
+ sources: list[Mapping[str, Any]] = []
+ for context in contexts:
+ for source in context.get("sources") or ():
+ if isinstance(source, Mapping):
+ sources.append(source)
+ return sources
+
+
+def _selected_todo_records(
+ payload: Mapping[str, Any],
+ decision: Mapping[str, Any],
+) -> list[Mapping[str, Any]]:
+ """Collect every native selected-Todo projection, richest form first."""
+ records: list[Mapping[str, Any]] = []
+
+ def add(value: Any) -> None:
+ if isinstance(value, Mapping):
+ records.append(value)
+
+ envelope = payload.get("turn_envelope")
+ if isinstance(envelope, Mapping):
+ action = envelope.get("action")
+ if isinstance(action, Mapping):
+ add(action.get("selected_todo"))
+ add(envelope.get("selected_todo"))
+ for source in _work_context_sources(envelope):
+ content = source.get("content")
+ if isinstance(content, Mapping):
+ add(content.get("todo"))
+ route = payload.get("route")
+ if isinstance(route, Mapping):
+ add(route.get("selected_todo"))
+ add(payload.get("selected_todo"))
+ add(payload.get("task"))
+ if isinstance(decision, Mapping):
+ add(decision.get("selected_todo"))
+ action = decision.get("action")
+ if isinstance(action, Mapping):
+ add(action.get("selected_todo"))
+ route = decision.get("route")
+ if isinstance(route, Mapping):
+ add(route.get("selected_todo"))
+ for container in (decision, decision.get("interaction_contract")):
+ for source in _work_context_sources(container):
+ content = source.get("content")
+ if isinstance(content, Mapping):
+ add(content.get("todo"))
+ return records
+
+
+def _resolve_retirement_basis(
+ payload: Mapping[str, Any],
+ decision: Mapping[str, Any],
+) -> RetirementBasis | None:
+ """Extract a declared retirement basis from the authoritative task input.
+
+ The native Turn carries the selected Todo as a compact projection plus the
+ bounded work context. The task's own instruction/note text and the
+ recommended action are the intent sources; a hand-built top-level payload
+ key is accepted for direct callers but is never the only source.
+ """
+ candidates: list[Any] = [
+ payload.get("retirement_basis"),
+ payload.get("anti_entropy_basis"),
+ payload.get("anti_entropy"),
+ decision.get("retirement_basis"),
+ decision.get("anti_entropy_basis"),
+ decision.get("anti_entropy"),
+ payload.get("route", {}).get("selected_todo")
+ if isinstance(payload.get("route"), Mapping)
+ else None,
+ payload.get("turn_envelope", {}).get("action", {}).get("selected_todo")
+ if isinstance(payload.get("turn_envelope"), Mapping)
+ and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping)
+ else None,
+ payload.get("selected_todo"),
+ payload.get("task"),
+ decision.get("selected_todo"),
+ decision.get("action", {}).get("selected_todo")
+ if isinstance(decision.get("action"), Mapping)
+ else None,
+ decision.get("route", {}).get("selected_todo")
+ if isinstance(decision.get("route"), Mapping)
+ else None,
+ decision.get("task"),
+ ]
+ for candidate in candidates:
+ basis = _extract_basis_candidate(candidate)
+ if basis is not None:
+ return basis
+ envelope = payload.get("turn_envelope")
+ if isinstance(envelope, Mapping):
+ action = envelope.get("action")
+ if isinstance(action, Mapping):
+ for text_key in ("recommended_action", "text"):
+ basis = _extract_basis_candidate(action.get(text_key))
+ if basis is not None:
+ return basis
+ for record in _selected_todo_records(payload, decision):
+ basis = _extract_basis_candidate(record)
+ if basis is not None:
+ return basis
+ return None
+
+
+def _load_goal(registry_path: Path | str | None, goal_id: str | None) -> Mapping[str, Any] | None:
+ if registry_path is None or not goal_id:
+ return None
+ try:
+ from ...agent_registry import load_goal_from_registry
+
+ return load_goal_from_registry(Path(registry_path), goal_id)
+ except Exception: # noqa: BLE001 - optional read-only enablement hint
+ return None
+
+
+def _declared_capability_ids(goal: Mapping[str, Any]) -> set[str]:
+ declared: set[str] = set()
+ for container in (
+ goal,
+ goal.get("control_plane"),
+ goal.get("coordination"),
+ ):
+ if not isinstance(container, Mapping):
+ continue
+ for key in ("available_capabilities", "required_capabilities"):
+ value = container.get(key)
+ if isinstance(value, (list, tuple, set)):
+ declared.update(str(item).strip() for item in value if isinstance(item, str))
+ return declared
+
+
+def _record_required_capabilities(record: Mapping[str, Any]) -> set[str]:
+ value = record.get("required_capabilities")
+ if isinstance(value, (list, tuple, set)):
+ return {str(item).strip() for item in value if isinstance(item, str)}
+ return set()
+
+
+def _is_anti_entropy_enabled(
+ payload: Mapping[str, Any],
+ decision: Mapping[str, Any],
+ basis: RetirementBasis | None,
+ *,
+ registry_path: Path | str | None = None,
+ goal_id: str | None = None,
+) -> bool:
+ """Determine whether anti-entropy is enabled for this turn.
+
+ A declared retirement basis is the task-scoped opt-in. The explicit
+ capability id in the selected Todo's required capabilities and a Goal-level
+ available-capability declaration are the typed enablement paths.
+ """
+ if basis is not None and (basis.target_symbols or basis.target_paths):
+ return True
+
+ if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True:
+ return True
+ for owner in (payload, decision):
+ value = owner.get("anti_entropy")
+ if isinstance(value, Mapping) and value.get("enabled") is True:
+ return True
+ route = payload.get("route")
+ if isinstance(route, Mapping) and route.get("anti_entropy_enabled") is True:
+ return True
+ envelope = payload.get("turn_envelope")
+ if isinstance(envelope, Mapping):
+ boundary = envelope.get("boundary")
+ if isinstance(boundary, Mapping):
+ capabilities = boundary.get("capabilities")
+ if isinstance(capabilities, Mapping):
+ capability = capabilities.get("anti_entropy_governance") or capabilities.get(
+ "anti_entropy"
+ )
+ if isinstance(capability, Mapping) and capability.get("enabled") is True:
+ return True
+ if capability is True:
+ return True
+
+ for record in _selected_todo_records(payload, decision):
+ if record.get("anti_entropy_enabled") is True:
+ return True
+ for key in ("metadata", "params"):
+ value = record.get(key)
+ if isinstance(value, Mapping) and value.get("anti_entropy_enabled") is True:
+ return True
+ if _record_required_capabilities(record) & _ENABLE_CAPABILITY_IDS:
+ return True
+
+ goal = _load_goal(registry_path, goal_id)
+ if goal is not None and _declared_capability_ids(goal) & _ENABLE_CAPABILITY_IDS:
+ return True
+ return False
+
+
+def _resolve_workspace(
+ payload: Mapping[str, Any],
+ *,
+ registry_path: Path | str | None,
+ goal_id: str | None,
+) -> Path | None:
+ """Resolve the post-change workspace from the task or the Goal registry.
+
+ The process working directory is never used: an unrelated checkout must not
+ change the verdict for the same patch.
+ """
+ for key in ("workspace", "repo_path"):
+ value = payload.get(key)
+ if isinstance(value, str) and value.strip():
+ path = Path(value).expanduser()
+ if path.is_dir():
+ return path.resolve()
+ goal = _load_goal(registry_path, goal_id)
+ repo = str((goal or {}).get("repo") or "").strip()
+ if repo:
+ path = Path(repo).expanduser()
+ if path.is_dir():
+ return path.resolve()
+ return None
+
+
+def anti_entropy_context_result(
+ payload: Mapping[str, Any],
+ *,
+ goal_id: str,
+ decision: Mapping[str, Any],
+ workspace: Path | None = None,
+ resolved_basis: Any = _UNSET,
+ enabled: Any = _UNSET,
+) -> dict[str, Any]:
+ """Produce the bounded turn-context payload covering the four lifecycle states.
+
+ 1. Disabled: no readback slot and no injection.
+ 2. Enabled but not applicable: capability is active but the task has no
+ retirement targets.
+ 3. New task: declared targets, the inspect descriptor, and delete-first
+ guidance are projected into host input.
+ 4. Failure recovery: the previous inspect outcome in the payload produces
+ remediation guidance.
+ """
+ basis = (
+ _resolve_retirement_basis(payload, decision)
+ if resolved_basis is _UNSET
+ else resolved_basis
+ )
+ enabled_value = bool(
+ _is_anti_entropy_enabled(payload, decision, basis)
+ if enabled is _UNSET
+ else enabled
+ )
+
+ if not enabled_value:
+ return {
+ "readback": {},
+ "context_fields": {},
+ "instructions": [],
+ "result_fields": {},
+ }
+
+ if basis is None or (not basis.target_symbols and not basis.target_paths):
+ return {
+ "readback": {
+ "anti_entropy_guidance": {
+ "enabled": True,
+ "applicable": False,
+ }
+ },
+ "context_fields": {},
+ "instructions": [],
+ "result_fields": {},
+ }
+
+ recovery_evidence = (
+ payload.get("anti_entropy_progress_fact")
+ or payload.get("anti_entropy_last_result")
+ or decision.get("anti_entropy_last_result")
+ )
+ is_recovery = bool(recovery_evidence)
+
+ context = build_anti_entropy_turn_context(
+ basis=basis,
+ goal_id=goal_id,
+ workspace=workspace,
+ )
+
+ if is_recovery:
+ instructions = [
+ "Anti-entropy recovery: the previous diff check identified unretired symbols "
+ "or diff hygiene issues. Address blocking findings before attempting delivery.",
+ "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff "
+ "cleanliness; rejections are progress facts guiding the next correction.",
+ ]
+ status = "recovering"
+ else:
+ instructions = [
+ "Anti-entropy code governance is active for this task: delete-first symbol "
+ "and path retirement is required.",
+ "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff "
+ "cleanliness; blocking findings are progress facts to fix before delivery.",
+ ]
+ status = "active"
+
+ readback = {
+ "anti_entropy_guidance": {
+ "enabled": True,
+ "applicable": True,
+ "target_symbols": list(basis.target_symbols),
+ "target_paths": list(basis.target_paths),
+ "status": status,
+ "workspace_available": workspace is not None,
+ }
+ }
+
+ full_result = {
+ "readback": readback,
+ "context_fields": {"anti_entropy_context": context},
+ "instructions": instructions,
+ "result_fields": {},
+ }
+
+ encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode(
+ "utf-8"
+ )
+ if len(encoded) > ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES:
+ compact_context = dict(context)
+ compact_context.pop("inspect_tool", None)
+ return {
+ "readback": readback,
+ "context_fields": {"anti_entropy_context": compact_context},
+ "instructions": instructions,
+ "result_fields": {},
+ }
+
+ return full_result
+
+
+def anti_entropy_context_registration(
+ payload: Mapping[str, Any],
+ *,
+ registry_path: Path,
+ goal_id: str,
+ agent_id: str,
+ decision: Mapping[str, Any],
+ turn_instance_id: str,
+) -> TurnContextHookRegistration:
+ """Create a TurnContextHookRegistration connecting anti-entropy into host input.
+
+ The task intent and workspace are resolved once from the native Turn input
+ and the Goal registry. Disabled turns declare no readback slot, so no empty
+ guidance field is written into the host payload.
+ """
+ workspace = _resolve_workspace(
+ payload, registry_path=registry_path, goal_id=goal_id
+ )
+ basis = _resolve_retirement_basis(payload, decision)
+ enabled = _is_anti_entropy_enabled(
+ payload,
+ decision,
+ basis,
+ registry_path=registry_path,
+ goal_id=goal_id,
+ )
+
+ def produce() -> Mapping[str, Any]:
+ return anti_entropy_context_result(
+ payload,
+ goal_id=goal_id,
+ decision=decision,
+ workspace=workspace,
+ resolved_basis=basis,
+ enabled=enabled,
+ )
+
+ return TurnContextHookRegistration(
+ hook_id="anti-entropy.turn-context",
+ capability_id=ANTI_ENTROPY_CAPABILITY_ID,
+ readback_slots=("anti_entropy_guidance",) if enabled else (),
+ producer=produce,
+ max_result_bytes=ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES,
+ )
diff --git a/loopx/capabilities/catalog.py b/loopx/capabilities/catalog.py
index f7d61b1db6..13b7571ac1 100644
--- a/loopx/capabilities/catalog.py
+++ b/loopx/capabilities/catalog.py
@@ -31,6 +31,7 @@
from .reliability_diagnostics.catalog_entry import RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY
from .progress_review.catalog_entry import PROGRESS_REVIEW_CATALOG_ENTRY
from .goal_capability_organization.catalog_entry import GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY
+from .anti_entropy.catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY
from .registry import CapabilityRegistry
CAPABILITY_CATALOG_SCHEMA_VERSION = "loopx_capability_catalog_v0"
@@ -62,6 +63,7 @@
RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY,
PROGRESS_REVIEW_CATALOG_ENTRY,
GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY,
+ ANTI_ENTROPY_CATALOG_ENTRY,
)
# Preserve the original import surface while routing all reads through the registry.
CAPABILITIES = BUILTIN_CAPABILITIES
diff --git a/loopx/capabilities/turn_context.py b/loopx/capabilities/turn_context.py
index 8655bdf0d4..2180c71b2d 100644
--- a/loopx/capabilities/turn_context.py
+++ b/loopx/capabilities/turn_context.py
@@ -11,6 +11,7 @@
PostSettlement,
registered_post_settlement,
)
+from .anti_entropy.turn_context import anti_entropy_context_registration
from .reward_memory.turn_hooks import (
memory_context_registration,
memory_settlement_registration,
@@ -37,6 +38,14 @@ def attach_turn_context(
decision=decision,
turn_instance_id=turn_instance_id,
),
+ anti_entropy_context_registration(
+ payload,
+ registry_path=registry_path,
+ goal_id=goal_id,
+ agent_id=agent_id,
+ decision=decision,
+ turn_instance_id=turn_instance_id,
+ ),
),
)
diff --git a/loopx/cli.py b/loopx/cli.py
index 7fd25b2f08..374a83fa07 100644
--- a/loopx/cli.py
+++ b/loopx/cli.py
@@ -186,6 +186,10 @@
handle_goal_amendment_proposal_command,
register_goal_amendment_proposal_command,
)
+from .cli_commands.anti_entropy import (
+ handle_anti_entropy_command,
+ register_anti_entropy_commands,
+)
from .cli_commands.reliability_diagnostics import (
handle_reliability_diagnostics_command,
register_reliability_diagnostics_commands,
@@ -286,6 +290,8 @@ def build_parser() -> LoopXArgumentParser:
register_capability_commands(sub, add_subcommand_format)
+ register_anti_entropy_commands(sub, add_subcommand_format)
+
register_reliability_diagnostics_commands(sub, add_subcommand_format)
register_extension_commands(sub, add_subcommand_format)
@@ -493,6 +499,16 @@ def _main(argv: list[str] | None = None) -> int:
if capability_result is not None:
return capability_result
+ anti_entropy_result = handle_anti_entropy_command(
+ args,
+ registry_path=registry_path,
+ runtime_root_arg=args.runtime_root,
+ output_format=output_format,
+ print_payload=print_payload,
+ )
+ if anti_entropy_result is not None:
+ return anti_entropy_result
+
reliability_diagnostics_result = handle_reliability_diagnostics_command(
args,
registry_path=registry_path,
diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py
new file mode 100644
index 0000000000..a78587b0e7
--- /dev/null
+++ b/loopx/cli_commands/anti_entropy.py
@@ -0,0 +1,219 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any
+
+from ..capabilities.anti_entropy.cli import run_anti_entropy_verify
+from ..capabilities.anti_entropy.contract import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ RetirementBasis,
+)
+
+PrintPayload = Callable[
+ [dict[str, Any], str, Callable[[dict[str, Any]], str]], str | None
+]
+FormatSelector = Callable[..., str]
+AddFormat = Callable[[argparse.ArgumentParser], None]
+
+
+def register_anti_entropy_commands(
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
+ add_subcommand_format: AddFormat,
+) -> None:
+ parser = subparsers.add_parser(
+ "anti-entropy",
+ help="Verify diff hygiene and delete-first code retirement governance.",
+ )
+ commands = parser.add_subparsers(
+ dest="anti_entropy_command", required=True
+ )
+ verify = commands.add_parser(
+ "verify",
+ help="Verify diff against a declared retirement basis and audit diff hygiene.",
+ )
+ add_subcommand_format(verify)
+ verify.add_argument("--goal-id", help="Associated LoopX goal identifier.")
+ verify.add_argument(
+ "--diff-file",
+ help="Path to unified diff patch file, or - for stdin.",
+ )
+ verify.add_argument(
+ "--basis-json",
+ help="JSON string or file path containing RetirementBasis specification.",
+ )
+ verify.add_argument(
+ "--workspace",
+ help=(
+ "Directory containing the complete post-change sources; required to "
+ "certify symbol retirement. Omitting it limits the verdict to "
+ "diff-only evidence."
+ ),
+ )
+ inspect = commands.add_parser(
+ "inspect",
+ help=(
+ "In-flight agent check: verify the diff and return actionable "
+ "feedback. Rejections are progress facts, not final delivery "
+ "failures (exit 0 whenever the check runs)."
+ ),
+ )
+ add_subcommand_format(inspect)
+ inspect.add_argument("--goal-id", help="Associated LoopX goal identifier.")
+ inspect.add_argument(
+ "--diff-file",
+ help="Path to unified diff patch file, or - for stdin.",
+ )
+ inspect.add_argument(
+ "--basis-json",
+ help="JSON string or file path containing RetirementBasis specification.",
+ )
+ inspect.add_argument(
+ "--workspace",
+ help=(
+ "Directory containing the complete post-change sources; required to "
+ "certify symbol retirement."
+ ),
+ )
+
+
+def _render(payload: dict[str, Any]) -> str:
+ lines = [f"# Anti-Entropy Governance ({payload.get('command')})", ""]
+ lines.append(f"- verdict: `{payload.get('verdict')}`")
+ lines.append(f"- ok: `{payload.get('ok')}`")
+ if payload.get("goal_id"):
+ lines.append(f"- goal_id: `{payload.get('goal_id')}`")
+ lines.append(f"- finding_count: `{payload.get('finding_count', 0)}`")
+ lines.append(f"- blocking_finding_count: `{payload.get('blocking_finding_count', 0)}`")
+ lines.append(f"- advisory_finding_count: `{payload.get('advisory_finding_count', 0)}`")
+
+ receipt = payload.get("receipt", {})
+ if receipt:
+ lines.append("")
+ lines.append("## Receipt")
+ lines.append(f"- diff_hash: `{receipt.get('diff_hash')}`")
+ lines.append(f"- source_evidence: `{receipt.get('source_evidence', 'diff_only')}`")
+ if receipt.get("source_root"):
+ lines.append(f"- source_root: `{receipt.get('source_root')}`")
+ retired = receipt.get("retired_symbols", [])
+ if retired:
+ lines.append(f"- retired_symbols: `{', '.join(retired)}`")
+ findings = receipt.get("findings", [])
+ if findings:
+ lines.append("")
+ lines.append("## Findings")
+ for item in findings:
+ sev = item.get("severity", "").upper()
+ rule = item.get("rule_id")
+ msg = item.get("message")
+ lines.append(f"- [{sev}] {rule}: {msg}")
+ if payload.get("command") == "inspect":
+ lines.append("")
+ lines.append("## Feedback")
+ lines.append(f"- is_progress_fact: `{payload.get('is_progress_fact')}`")
+ lines.append(f"- delivery_ready: `{payload.get('delivery_ready')}`")
+ lines.append(f"- delivery_route: `{payload.get('delivery_route')}`")
+ for item in payload.get("actionable_feedback", []):
+ lines.append("")
+ lines.append(f"### [{item.get('severity', '').upper()}] {item.get('rule_id')}")
+ if item.get("location"):
+ lines.append(f"- location: `{item.get('location')}`")
+ lines.append(f"- next_action: {item.get('next_action')}")
+ return "\n".join(lines) + "\n"
+
+
+def _read_diff_text(args: argparse.Namespace) -> str:
+ if args.diff_file == "-":
+ return sys.stdin.read()
+ if args.diff_file:
+ diff_path = Path(args.diff_file).expanduser()
+ if diff_path.is_file():
+ return diff_path.read_text(encoding="utf-8")
+ raise ValueError(f"diff file not found: {args.diff_file}")
+ return ""
+
+
+def _read_basis_data(args: argparse.Namespace) -> Any:
+ if args.basis_json is None:
+ return None
+ raw_basis = args.basis_json.strip()
+ if raw_basis.startswith("{") or raw_basis.startswith("["):
+ return json.loads(raw_basis)
+ path = Path(raw_basis).expanduser()
+ if path.is_file():
+ return json.loads(path.read_text(encoding="utf-8"))
+ try:
+ return json.loads(raw_basis)
+ except Exception:
+ raise ValueError(f"basis file not found: {raw_basis}")
+
+
+def _resolve_workspace(args: argparse.Namespace) -> Path | None:
+ if not getattr(args, "workspace", None):
+ return None
+ workspace = Path(args.workspace).expanduser().resolve()
+ if not workspace.is_dir():
+ raise ValueError(f"workspace directory not found: {args.workspace}")
+ return workspace
+
+
+def handle_anti_entropy_command(
+ args: argparse.Namespace,
+ *,
+ registry_path: Path,
+ runtime_root_arg: str | None,
+ output_format: FormatSelector,
+ print_payload: PrintPayload,
+) -> int | None:
+ if args.command != "anti-entropy":
+ return None
+ try:
+ command = args.anti_entropy_command
+ # Shared input reading: both entries bind the same diff, basis, and
+ # post-change workspace; only the outcome contract differs.
+ diff_text = _read_diff_text(args)
+ basis_data = _read_basis_data(args)
+ workspace = _resolve_workspace(args)
+
+ if command == "verify":
+ # A delivery-gate verification without a declared retirement basis
+ # would pass without checking anything; diff hygiene alone belongs
+ # to inspect and can never certify a retirement.
+ if basis_data is None:
+ raise ValueError(
+ "verify requires an explicit retirement basis "
+ "(--basis-json )"
+ )
+ declared_basis = RetirementBasis.from_dict(basis_data)
+ if not declared_basis.target_symbols and not declared_basis.target_paths:
+ raise ValueError(
+ "verify requires at least one declared retirement target "
+ "(target_symbols or target_paths)"
+ )
+ payload = run_anti_entropy_verify(
+ diff_text=diff_text,
+ basis_data=basis_data,
+ goal_id=args.goal_id,
+ workspace=workspace,
+ )
+ elif command == "inspect":
+ from ..capabilities.anti_entropy.feedback import run_anti_entropy_feedback_cycle
+
+ payload = run_anti_entropy_feedback_cycle(
+ diff_text=diff_text,
+ basis=basis_data,
+ goal_id=args.goal_id,
+ workspace=workspace,
+ )
+ payload = {"command": "inspect", **payload}
+ else:
+ raise ValueError(f"unknown anti-entropy command `{command}`")
+ except (OSError, ValueError, TypeError) as exc:
+ print(f"error: {ANTI_ENTROPY_CAPABILITY_ID}: {exc}", file=sys.stderr)
+ return 2
+
+ print_payload(payload, output_format(args), _render)
+ return 0 if payload.get("ok") else 1
diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py
new file mode 100644
index 0000000000..bcba474876
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy.py
@@ -0,0 +1,1244 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from loopx.capabilities.anti_entropy import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ ANTI_ENTROPY_CATALOG_ENTRY,
+ ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION,
+ ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION,
+ RetirementBasis,
+ audit_ghost_code,
+ evaluate_diff_hygiene,
+ run_anti_entropy_verify,
+ verify_delete_first,
+)
+from loopx.capabilities.catalog import BUILTIN_CAPABILITIES
+
+
+def test_catalog_registration() -> None:
+ """Verify anti-entropy capability is registered in builtin capabilities catalog."""
+ matching = [
+ c for c in BUILTIN_CAPABILITIES if c["id"] == ANTI_ENTROPY_CAPABILITY_ID
+ ]
+ assert len(matching) == 1
+ record = matching[0]
+ assert record["origin"] == "builtin"
+ assert record["status"] == "experimental"
+ assert record["default_enabled"] is False
+ assert "delete-first" in record["real_world_anchor"] or "refactoring" in record["real_world_anchor"]
+
+
+def _make_workspace(tmp_path: Path, files: dict[str, str]) -> Path:
+ """Create an explicitly bound workspace containing post-change sources."""
+ workspace = tmp_path / "workspace"
+ for rel_path, content in files.items():
+ target = workspace / rel_path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(content, encoding="utf-8")
+ return workspace
+
+
+def test_verify_delete_first_passed(tmp_path: Path) -> None:
+ """Verify clean retirement where old symbol is cleanly deleted from bound sources."""
+ diff = """
+--- a/legacy_module.py
++++ b/legacy_module.py
+@@ -1,5 +1,5 @@
+-def old_deprecated_calculator(x, y):
+- return x + y
++def modern_calculator(x: int, y: int) -> int:
++ return x + y
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {"legacy_module.py": "def modern_calculator(x: int, y: int) -> int:\n return x + y\n"},
+ )
+ basis = RetirementBasis(
+ target_symbols=("old_deprecated_calculator",),
+ rationale="Replaced by modern_calculator",
+ )
+ findings = verify_delete_first(diff, basis, workspace=workspace)
+ assert len(findings) == 0
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed"
+ assert "old_deprecated_calculator" in receipt.retired_symbols
+
+
+def test_verify_delete_first_violation(tmp_path: Path) -> None:
+ """Verify rejection when old symbol is kept instead of delete-first."""
+ diff = """
+--- a/legacy_module.py
++++ b/legacy_module.py
+@@ -1,3 +1,6 @@
++def modern_calculator(x: int, y: int) -> int:
++ return x + y
+ def old_deprecated_calculator(x, y):
+ return x + y
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {
+ "legacy_module.py": (
+ "def modern_calculator(x: int, y: int) -> int:\n"
+ " return x + y\n"
+ "def old_deprecated_calculator(x, y):\n"
+ " return x + y\n"
+ )
+ },
+ )
+ basis = RetirementBasis(
+ target_symbols=("old_deprecated_calculator",),
+ allow_compat_seam=False,
+ )
+ findings = verify_delete_first(diff, basis, workspace=workspace)
+ assert len(findings) == 1
+ assert findings[0].rule_id == "AE001-DELETE_FIRST_VIOLATION"
+ assert findings[0].severity == "blocking"
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+
+
+def test_verify_delete_first_zombie_reintroduced() -> None:
+ """Verify rejection when symbol is deleted and reintroduced simultaneously in the diff."""
+ diff = """
+--- a/legacy_module.py
++++ b/legacy_module.py
+@@ -1,3 +1,3 @@
+-def legacy_sink():
++def legacy_sink(): # wrapper
+ pass
+"""
+ basis = RetirementBasis(target_symbols=("legacy_sink",))
+ findings = verify_delete_first(diff, basis)
+ assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in findings)
+
+
+def test_target_paths_deletion() -> None:
+ """Verify target_paths require complete file deletion headers in the diff."""
+ # File cleanly deleted -> pass (diff-only evidence is sufficient for paths)
+ deleted_diff = """
+--- a/legacy/old_file.py
++++ /dev/null
+@@ -1,3 +0,0 @@
+-def dead():
+- pass
+"""
+ basis = RetirementBasis(target_paths=("legacy/old_file.py",))
+ receipt = evaluate_diff_hygiene(deleted_diff, basis)
+ assert receipt.verdict == "passed"
+ assert len(receipt.findings) == 0
+
+ # File only partially modified instead of deleted -> fail
+ partial_diff = """
+--- a/legacy/old_file.py
++++ b/legacy/old_file.py
+@@ -1,3 +1,3 @@
+-def dead():
++def modified():
+ pass
+"""
+ receipt_partial = evaluate_diff_hygiene(partial_diff, basis)
+ assert receipt_partial.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_partial.findings)
+
+
+def test_compat_seam_does_not_falsely_retire(tmp_path: Path) -> None:
+ """Verify allow_compat_seam allows passing but does NOT claim un-deleted symbols as retired."""
+ diff = """
+--- a/legacy_module.py
++++ b/legacy_module.py
+@@ -1,3 +1,4 @@
++# some edit
+ def old_api():
+ return 1
+"""
+ workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"})
+ basis = RetirementBasis(
+ target_symbols=("old_api",),
+ allow_compat_seam=True,
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed"
+ seam_findings = [f for f in receipt.findings if f.location == "old_api"]
+ assert seam_findings and all(f.severity == "advisory" for f in seam_findings)
+ # old_api was NOT deleted, so it must NOT be in retired_symbols!
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_retained_definition_is_blocking_without_compat_seam(tmp_path: Path) -> None:
+ """A retained definition without an authorized compat seam stays a hard AE001 block."""
+ diff = """
+--- a/legacy_module.py
++++ b/legacy_module.py
+@@ -1,3 +1,4 @@
++# some edit
+ def old_api():
+ return 1
+"""
+ workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"})
+ basis = RetirementBasis(target_symbols=("old_api",), allow_compat_seam=False)
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(
+ f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.severity == "blocking"
+ for f in receipt.findings
+ )
+
+
+def test_false_positive_prevention() -> None:
+ """Ensure normal English comments or documentation mentioning words like fallback are not blocked."""
+ diff = """
+--- a/README.md
++++ b/README.md
+@@ -10,3 +10,5 @@
++# if the connection is closed, the next call reconnects.
++# Please see documentation for details.
++# Delete fallback_v4 after migrating all callers.
+"""
+ basis = RetirementBasis()
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "passed"
+ # Should not produce blocking findings
+ assert all(f.severity != "blocking" for f in receipt.findings)
+
+
+def test_audit_ghost_code_commented_block() -> None:
+ """Verify advisory finding when dead code blocks are left behind as comments."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -10,3 +10,8 @@
++def active_fn():
++ # def old_worker():
++ # x = 1
++ # return x
++ return True
+"""
+ findings = audit_ghost_code(diff)
+ assert len(findings) == 1
+ assert findings[0].rule_id == "AE003-COMMENTED_CODE_ADVISORY"
+ assert findings[0].severity == "advisory"
+
+
+def test_cli_runner(tmp_path: Path) -> None:
+ """Verify CLI verify function generates a complete, valid payload."""
+ clean_diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,3 @@
+-def legacy_call():
++def modern_call():
+ return 42
+"""
+ workspace = _make_workspace(tmp_path, {"service.py": "def modern_call():\n return 42\n"})
+ payload = run_anti_entropy_verify(
+ diff_text=clean_diff,
+ basis_data={"target_symbols": ["legacy_call"]},
+ goal_id="test-goal-id",
+ workspace=workspace,
+ )
+ assert payload["ok"] is True
+ assert payload["verdict"] == "passed"
+ assert payload["blocking_finding_count"] == 0
+ assert "legacy_call" in payload["retired_symbols"]
+
+
+def test_retirement_basis_type_validation() -> None:
+ """Verify strict type validation on RetirementBasis.from_dict to prevent truthiness loopholes."""
+ import pytest
+
+ # allow_compat_seam must be a real boolean, not truthy string "false"
+ with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"):
+ RetirementBasis.from_dict({"allow_compat_seam": "false"})
+
+ with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"):
+ RetirementBasis.from_dict({"allow_compat_seam": "true"})
+
+ with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"):
+ RetirementBasis.from_dict({"allow_compat_seam": 1})
+
+ # basis data must be a Mapping, not an array
+ with pytest.raises(TypeError, match="must be a JSON object"):
+ RetirementBasis.from_dict([])
+
+ with pytest.raises(TypeError, match="must be a JSON object"):
+ RetirementBasis.from_dict("not_a_dict")
+
+ # target_symbols must be a collection of strings
+ with pytest.raises(TypeError, match="target_symbols must be a list of strings"):
+ RetirementBasis.from_dict({"target_symbols": "old_api"})
+
+ with pytest.raises(TypeError, match="target_symbols elements must be strings"):
+ RetirementBasis.from_dict({"target_symbols": [123]})
+
+ # target_paths must be a collection of strings
+ with pytest.raises(TypeError, match="target_paths must be a list of strings"):
+ RetirementBasis.from_dict({"target_paths": "legacy.py"})
+
+ with pytest.raises(TypeError, match="target_paths elements must be strings"):
+ RetirementBasis.from_dict({"target_paths": [456]})
+
+ # schema_version and rationale must be strings
+ with pytest.raises(TypeError, match="schema_version must be a string"):
+ RetirementBasis.from_dict({"schema_version": 123})
+
+ with pytest.raises(TypeError, match="rationale must be a string"):
+ RetirementBasis.from_dict({"rationale": 123})
+
+
+def test_cli_exit_code_and_subprocess(tmp_path: Path) -> None:
+ """Verify CLI returns exit code 1 on rejection, 0 on pass, and 2 on invalid basis JSON."""
+ import subprocess
+ import sys
+
+ workspace = _make_workspace(
+ tmp_path,
+ {"service.py": "def old_api():\n return 1\n"},
+ )
+
+ # 1. Rejected verification (definition retained in bound workspace) -> exit code 1
+ diff_fail = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,3 @@
++# keep old_api
+ def old_api():
+ return 1
+"""
+ proc_fail = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ "{\"target_symbols\": [\"old_api\"]}",
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=diff_fail.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc_fail.returncode == 1
+
+ # 2. Passed verification -> exit code 0
+ clean_workspace = _make_workspace(tmp_path / "clean", {"service.py": "def new_api():\n return 2\n"})
+ diff_pass = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,3 @@
+-def old_api():
+- return 1
++def new_api():
++ return 2
+"""
+ proc_pass = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ "{\"target_symbols\": [\"old_api\"]}",
+ "--workspace",
+ str(clean_workspace),
+ "--format",
+ "json",
+ ],
+ input=diff_pass.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc_pass.returncode == 0
+
+ # 3. Invalid basis JSON array -> exit code 2
+ proc_err = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ "[]",
+ "--format",
+ "json",
+ ],
+ input=b"",
+ capture_output=True,
+ )
+ assert proc_err.returncode == 2
+
+ # 4. Nonexistent workspace directory -> exit code 2
+ proc_ws = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ "{\"target_symbols\": [\"old_api\"]}",
+ "--workspace",
+ str(tmp_path / "does_not_exist"),
+ "--format",
+ "json",
+ ],
+ input=diff_pass.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc_ws.returncode == 2
+
+ # 5. verify without any basis -> input error, never a silent pass
+ proc_no_basis = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--format",
+ "json",
+ ],
+ input=b"",
+ capture_output=True,
+ )
+ assert proc_no_basis.returncode == 2
+ assert b"retirement basis" in proc_no_basis.stderr
+
+ # 6. verify with a basis that declares no retirement target -> input error
+ proc_no_target = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ "{}",
+ "--format",
+ "json",
+ ],
+ input=b"",
+ capture_output=True,
+ )
+ assert proc_no_target.returncode == 2
+
+
+def test_docstring_example_deletion_does_not_retire_real_definition(tmp_path: Path) -> None:
+ """Verify deleting a code example in a docstring does not falsely pass a retained real definition."""
+ diff = (
+ "--- a/api.py\n"
+ "+++ b/api.py\n"
+ "@@ -1,6 +1,3 @@\n"
+ " \"\"\"\n"
+ "-Example:\n"
+ "- def old_api():\n"
+ "- return 1\n"
+ " \"\"\"\n"
+ " def old_api():\n"
+ " return 2\n"
+ )
+ workspace = _make_workspace(
+ tmp_path,
+ {"api.py": '"""\nExample:\n def old_api():\n return 1\n"""\n\ndef old_api():\n return 2\n'},
+ )
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_exact_git_path_confirmation_and_encoding() -> None:
+ """Verify exact path matching without suffix guesswork, plus empty and quoted path deletion."""
+ # 1. Suffix mismatch: deleting legacy_api.py must NOT satisfy target_paths=("api.py",)
+ diff_suffix = """
+--- a/legacy_api.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-def f(): pass
+"""
+ basis_suffix = RetirementBasis(target_paths=("api.py",))
+ receipt_suffix = evaluate_diff_hygiene(diff_suffix, basis_suffix)
+ assert receipt_suffix.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_suffix.findings)
+
+ # 2. Empty file deletion metadata (deleted file mode 100644 without +/- hunks)
+ diff_empty = """
+diff --git a/legacy/__init__.py b/legacy/__init__.py
+deleted file mode 100644
+index e69de29..0000000
+"""
+ basis_empty = RetirementBasis(target_paths=("legacy/__init__.py",))
+ receipt_empty = evaluate_diff_hygiene(diff_empty, basis_empty)
+ assert receipt_empty.verdict == "passed"
+ assert len(receipt_empty.findings) == 0
+
+ # 3. Quoted path with C-style octal escaping for non-ASCII paths
+ diff_quoted = """
+diff --git "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" "b/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py"
+deleted file mode 100644
+--- "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py"
++++ /dev/null
+"""
+ basis_quoted = RetirementBasis(target_paths=("legacy/旧实猛.py",))
+ receipt_quoted = evaluate_diff_hygiene(diff_quoted, basis_quoted)
+ assert receipt_quoted.verdict == "passed"
+ assert len(receipt_quoted.findings) == 0
+
+
+def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement(tmp_path: Path) -> None:
+ """Verify modified/reintroduced symbol is excluded from retired_symbols while cleanly retired symbol is kept."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,6 +1,4 @@
+-def old_helper():
+- return 1
+-def old_api(x):
+- return x
++def old_api(x=1):
++ return x
+"""
+ workspace = _make_workspace(tmp_path, {"service.py": "def old_api(x=1):\n return x\n"})
+ basis = RetirementBasis(target_symbols=("old_helper", "old_api"))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings)
+ # old_api is excluded from retired_symbols, but old_helper is accurately certified!
+ assert receipt.retired_symbols == ("old_helper",)
+
+
+def test_typescript_clean_delete_first(tmp_path: Path) -> None:
+ """Verify TypeScript functions, classes, interfaces, and types are accurately recognized as retired."""
+ diff = """
+--- a/control_plane.ts
++++ b/control_plane.ts
+@@ -1,15 +1,2 @@
+-export function oldLeaseAcquire(taskId: string): boolean {
+- return false;
+-}
+-export class OldScheduler {
+- run(): void {}
+-}
+-export interface OldLeaseConfig {
+- timeoutMs: number;
+-}
+-export type OldLeaseState = "active" | "expired";
+-export const oldArrowHelper = (x: number) => x * 2;
++export function newLeaseAcquire(): boolean { return true; }
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {"control_plane.ts": "export function newLeaseAcquire(): boolean { return true; }\n"},
+ )
+ basis = RetirementBasis(
+ target_symbols=(
+ "oldLeaseAcquire",
+ "OldScheduler",
+ "OldLeaseConfig",
+ "OldLeaseState",
+ "oldArrowHelper",
+ ),
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert set(receipt.retired_symbols) == {
+ "oldLeaseAcquire",
+ "OldScheduler",
+ "OldLeaseConfig",
+ "OldLeaseState",
+ "oldArrowHelper",
+ }
+
+
+def test_typescript_call_deletion_does_not_retire_definition(tmp_path: Path) -> None:
+ """Verify deleting a TypeScript function call without deleting definition triggers AE001."""
+ diff = """
+--- a/control_plane.ts
++++ b/control_plane.ts
+@@ -1,7 +1,7 @@
+ export function oldLeaseAcquire(): boolean {
+ return false;
+ }
+ export function run() {
+- return oldLeaseAcquire();
++ return newLeaseAcquire();
+ }
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {
+ "control_plane.ts": (
+ "export function oldLeaseAcquire(): boolean {\n"
+ " return false;\n"
+ "}\n"
+ "export function run() {\n"
+ " return newLeaseAcquire();\n"
+ "}\n"
+ )
+ },
+ )
+ basis = RetirementBasis(target_symbols=("oldLeaseAcquire",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings)
+ assert "oldLeaseAcquire" not in receipt.retired_symbols
+
+
+def test_typescript_zombie_symbol_reintroduced() -> None:
+ """Verify reintroducing an old TypeScript function as a wrapper triggers AE002."""
+ diff = """
+--- a/control_plane.ts
++++ b/control_plane.ts
+@@ -1,4 +1,4 @@
+-export function oldTaskLease() { return 1; }
++export function oldTaskLease() { return newTaskLease(); }
+"""
+ basis = RetirementBasis(target_symbols=("oldTaskLease",))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings)
+
+
+def test_diff_only_verification_cannot_certify_retirement() -> None:
+ """Without a bound workspace the verdict may reject but never certifies retirement (AE004)."""
+ diff = """
+--- a/non_existent_dir/virtual_module.py
++++ b/non_existent_dir/virtual_module.py
+@@ -1,5 +1,2 @@
+-def obsolete_worker():
+- return None
++def active_worker(): return 1
+"""
+ basis = RetirementBasis(target_symbols=("obsolete_worker",))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+ assert receipt.source_evidence == "diff_only"
+
+
+def test_agent_tool_inspect_interface(tmp_path: Path) -> None:
+ """Verify the structured agent tool anti_entropy_inspect returns complete, valid payload."""
+ from loopx.capabilities.anti_entropy.agent_tool import anti_entropy_inspect
+
+ diff = """
+--- a/service.ts
++++ b/service.ts
+@@ -1,4 +1,2 @@
+-export function legacyHandler() {}
++export function modernHandler() {}
+"""
+ workspace = _make_workspace(tmp_path, {"service.ts": "export function modernHandler() {}\n"})
+ payload = anti_entropy_inspect(
+ diff_text=diff,
+ retirement_basis={"target_symbols": ["legacyHandler"]},
+ goal_id="goal-test-tool",
+ workspace=str(workspace),
+ )
+ assert payload["ok"] is True
+ assert payload["command"] == "verify"
+ assert payload["goal_id"] == "goal-test-tool"
+ assert payload["retired_symbols"] == ["legacyHandler"]
+ assert payload["verdict"] == "passed"
+
+
+def test_path_normalization_distinguishes_a_dir_and_dot_files() -> None:
+ """Verify a/api.py, api.py, and .api.py are never conflated."""
+ diff_delete_root = """
+--- a/api.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-def f(): pass
+"""
+ basis_a = RetirementBasis(target_paths=("a/api.py",))
+ receipt_a = evaluate_diff_hygiene(diff_delete_root, basis_a)
+ assert receipt_a.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == "a/api.py" for f in receipt_a.findings)
+
+ basis_dot = RetirementBasis(target_paths=(".api.py",))
+ receipt_dot = evaluate_diff_hygiene(diff_delete_root, basis_dot)
+ assert receipt_dot.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == ".api.py" for f in receipt_dot.findings)
+
+ diff_delete_nested_a = """
+--- a/a/api.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-def f(): pass
+"""
+ receipt_nested = evaluate_diff_hygiene(diff_delete_nested_a, basis_a)
+ assert receipt_nested.verdict == "passed"
+ assert len(receipt_nested.findings) == 0
+
+ diff_delete_dot = """
+--- a/.api.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-def f(): pass
+"""
+ receipt_dot_pass = evaluate_diff_hygiene(diff_delete_dot, basis_dot)
+ assert receipt_dot_pass.verdict == "passed"
+ assert len(receipt_dot_pass.findings) == 0
+
+
+def test_empty_file_deletion_with_spaces_in_filename() -> None:
+ """Verify unquoted git diff headers for empty files with spaces in names are correctly recognized."""
+ diff_spaces = """
+diff --git a/legacy/old api.py b/legacy/old api.py
+deleted file mode 100644
+index e69de29..0000000
+"""
+ basis = RetirementBasis(target_paths=("legacy/old api.py",))
+ receipt = evaluate_diff_hygiene(diff_spaces, basis)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert len(receipt.findings) == 0
+
+
+def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path) -> None:
+ """Deleting docstring example lines must not certify a function retained in the bound workspace."""
+ workspace = _make_workspace(
+ tmp_path,
+ {"service.py": '"""\nExample:\n def old_api():\n pass\n"""\n\ndef old_api():\n return 42\n'},
+ )
+
+ diff_docstring = """
+--- a/service.py
++++ b/service.py
+@@ -3,2 +3,0 @@
+- def old_api():
+- pass
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+
+ receipt = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+ # The same diff and workspace bound from a different CWD yields the same result:
+ # conclusions depend on the explicit binding, not the caller's working directory.
+ receipt_again = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace)
+ assert receipt_again.verdict == receipt.verdict
+ assert receipt_again.diff_hash == receipt.diff_hash
+
+
+def test_string_literal_mention_does_not_block_retirement(tmp_path: Path) -> None:
+ """A string mentioning the retired name is not an active reference (AE002 must not fire)."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,4 +1,4 @@
+-def old_api():
+- return 1
++def new_api():
++ return "old_api has been removed"
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {"service.py": 'def new_api():\n return "old_api has been removed"\n'},
+ )
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings)
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_readme_fenced_example_is_not_symbol_evidence(tmp_path: Path) -> None:
+ """Deleting a fenced def example from docs must not certify a definition still in real sources."""
+ diff = """
+--- a/README.md
++++ b/README.md
+@@ -5,4 +5,0 @@
+-```python
+-def old_api():
+- pass
+-```
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {
+ "README.md": "# Docs\n\nUsage examples live elsewhere.\n",
+ "service.py": "def old_api():\n return 42\n",
+ },
+ )
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_definition_in_untouched_file_is_caught_by_workspace_scan(tmp_path: Path) -> None:
+ """A definition living outside the diff still blocks certification via the bound workspace."""
+ diff = """
+--- a/client.py
++++ b/client.py
+@@ -1,3 +1,3 @@
+-import old_api
+ pass
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {
+ "client.py": "pass\n",
+ "service.py": "def old_api():\n return 42\n",
+ },
+ )
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_missing_source_file_in_bound_workspace_cannot_certify(tmp_path: Path) -> None:
+ """A diff-touched source file missing from the bound workspace means evidence is insufficient."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,2 @@
+-def old_api():
+- return 1
++pass
+"""
+ # Bound workspace does NOT contain service.py at all.
+ workspace = _make_workspace(tmp_path, {"other.py": "x = 1\n"})
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_same_diff_bound_to_different_workspaces_diverge(tmp_path: Path) -> None:
+ """Identical diff plus different bound workspaces yields correct, receipt-recorded verdicts."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,2 @@
+-def old_api():
+- return 1
++pass
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+
+ ws_clean = _make_workspace(tmp_path / "clean", {"service.py": "pass\n"})
+ receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean)
+ assert receipt_clean.verdict == "passed"
+ assert receipt_clean.retired_symbols == ("old_api",)
+ assert receipt_clean.source_evidence == "workspace_sources"
+ assert receipt_clean.source_root == str(ws_clean)
+ assert receipt_clean.basis_hash
+
+ ws_dirty = _make_workspace(tmp_path / "dirty", {"service.py": "def old_api():\n return 1\n"})
+ receipt_dirty = evaluate_diff_hygiene(diff, basis, workspace=ws_dirty)
+ assert receipt_dirty.verdict == "rejected"
+ assert "old_api" not in receipt_dirty.retired_symbols
+ assert receipt_dirty.source_root == str(ws_dirty)
+ assert receipt_dirty.basis_hash == receipt_clean.basis_hash
+
+
+def test_path_deleted_but_still_on_disk_is_contradiction(tmp_path: Path) -> None:
+ """A deleted-in-diff path that still exists in the bound workspace is an evidence mismatch."""
+ diff = """
+--- a/api.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-def f(): pass
+"""
+ basis = RetirementBasis(target_paths=("api.py",))
+
+ ws_stale = _make_workspace(tmp_path / "stale", {"api.py": "def f(): pass\n"})
+ receipt_stale = evaluate_diff_hygiene(diff, basis, workspace=ws_stale)
+ assert receipt_stale.verdict == "rejected"
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt_stale.findings)
+
+ ws_clean = _make_workspace(tmp_path / "clean", {"keep.py": "x = 1\n"})
+ receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean)
+ assert receipt_clean.verdict == "passed"
+ assert len(receipt_clean.findings) == 0
+
+
+def test_receipt_without_basis_has_empty_provenance(tmp_path: Path) -> None:
+ """Receipts without a declared basis carry empty basis_hash and diff_only evidence."""
+ receipt = evaluate_diff_hygiene("--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a\n+b\n")
+ assert receipt.basis_hash == ""
+ assert receipt.source_root is None
+ assert receipt.source_evidence == "diff_only"
+ assert ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION == receipt.schema_version
+ assert ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION == RetirementBasis().schema_version
+ assert ANTI_ENTROPY_CAPABILITY_ID == "anti-entropy-governance"
+ assert ANTI_ENTROPY_CATALOG_ENTRY["id"] == ANTI_ENTROPY_CAPABILITY_ID
+
+
+def test_typescript_cross_file_reference_rejects_and_prevents_false_retirement(tmp_path: Path) -> None:
+ """Verify cross-file TS/JS member access (api.old_api()) blocks retirement certification."""
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ ws = _make_workspace(
+ tmp_path / "ws_unmigrated",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.old_api(); }\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=ws)
+ assert receipt.verdict == "rejected"
+ assert any(
+ f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and "client.mjs" in f.message
+ for f in receipt.findings
+ )
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_cross_file_reference_passed_after_caller_migrated(tmp_path: Path) -> None:
+ """Verify paired positive case: retirement certifies after caller migrates away from old_api."""
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+--- a/client.mjs
++++ b/client.mjs
+@@ -2,1 +2,1 @@
+-export function run() { return api.old_api(); }
++export function run() { return api.new_api(); }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ ws = _make_workspace(
+ tmp_path / "ws_migrated",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.new_api(); }\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=ws)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_workspace_scan_truncation_on_large_file_fails_with_ae004(tmp_path: Path) -> None:
+ """Verify files exceeding scan size limits block certification with AE004 rather than false passing."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +0,0 @@
+-def old_api():
+- return 1
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ # Oversized file > 2_000_000 bytes
+ large_comment = "# " + ("A" * 100) + "\n"
+ oversized_content = "def old_api():\n return 42\n" + (large_comment * 20_500)
+
+ ws = _make_workspace(
+ tmp_path / "ws_large",
+ {
+ "service.py": "# cleanly removed old_api\n",
+ "retained.py": oversized_content,
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=ws)
+ assert receipt.verdict == "rejected"
+ assert any(
+ f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" and "exceeds size limit" in f.message
+ for f in receipt.findings
+ )
+ assert receipt.retired_symbols == ()
+
+
+def test_workspace_scan_file_count_limit_fails_with_ae004(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Verify exceeding the workspace file-count limit blocks certification with AE004.
+
+ A truncated scan cannot prove absence: even when the diff cleanly removes
+ the target, an unvisited source file might still define it.
+ """
+ from loopx.capabilities.anti_entropy import oracles as ae_oracles
+
+ monkeypatch.setattr(ae_oracles, "_MAX_WORKSPACE_FILES", 2)
+
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +0,0 @@
+-def old_api():
+- return 1
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ ws = _make_workspace(
+ tmp_path / "ws_many",
+ {
+ "service.py": "# cleanly removed old_api\n",
+ "a.py": "alpha = 1\n",
+ "b.py": "beta = 2\n",
+ "c.py": "gamma = 3\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=ws)
+ assert receipt.verdict == "rejected"
+ assert any(
+ f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE"
+ and "file count exceeded limit" in f.message
+ for f in receipt.findings
+ )
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_class_method_retirement() -> None:
+ """Verify TypeScript class methods (public, async, etc.) are recognized when cleanly deleted."""
+ diff = """
+--- a/controller.ts
++++ b/controller.ts
+@@ -1,8 +1,2 @@
+ export class TaskController {
+- public async oldTaskMethod(taskId: string): Promise {
+- return;
+- }
+- private helperCalc(): number { return 1; }
+ newTaskMethod(): void {}
+ }
+"""
+ basis = RetirementBasis(target_symbols=("oldTaskMethod", "helperCalc"))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings)
+ assert receipt.verdict == "rejected"
+
+
+def test_ghost_code_ignores_natural_language_and_headings() -> None:
+ """Verify plain words / title headings in comments do not trigger ghost code false positives."""
+ diff = """
+--- a/module.py
++++ b/module.py
+@@ -1,2 +1,6 @@
+ def compute():
++ # Overview
++ # Architecture
++ # Design
++ # Summary
+ return 1
+"""
+ receipt = evaluate_diff_hygiene(diff)
+ assert receipt.verdict == "passed"
+ assert len(receipt.findings) == 0
+
+
+def test_typescript_parenthesized_expression_is_not_function_def() -> None:
+ """Verify const x = (a + b) * c is not misclassified as a function definition."""
+ diff = """
+--- a/calc.ts
++++ b/calc.ts
+@@ -1,2 +1,3 @@
+ function run() {
++ const result = (a + b) * c;
+ }
+"""
+ basis = RetirementBasis(target_symbols=("result",))
+ receipt = evaluate_diff_hygiene(diff, basis)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings)
+ assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings)
+
+
+def test_typescript_template_literal_interpolation_detected_as_active_ref(tmp_path: Path) -> None:
+ """Verify TS expressions inside `${...}` template interpolations are detected as active refs."""
+ legacy_file = tmp_path / "legacy.ts"
+ legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8")
+ consumer_file = tmp_path / "consumer.ts"
+ consumer_file.write_text("console.log(`${api.old_api()}`);\n", encoding="utf-8")
+
+ diff = """
+--- a/legacy.ts
++++ b/legacy.ts
+@@ -1,4 +1,1 @@
+-export function old_api() {
+- return 0;
+-}
+ export function new_api() { return 1; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_typescript_url_line_with_code_detected_as_active_ref(tmp_path: Path) -> None:
+ """Verify // inside string literals (e.g. URLs) does not strip code on the same line."""
+ legacy_file = tmp_path / "legacy.ts"
+ legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8")
+ consumer_file = tmp_path / "consumer.ts"
+ consumer_file.write_text(
+ "const url = 'https://example.invalid'; console.log(api.old_api());\n",
+ encoding="utf-8",
+ )
+
+ diff = """
+--- a/legacy.ts
++++ b/legacy.ts
+@@ -1,4 +1,1 @@
+-export function old_api() {
+- return 0;
+-}
+ export function new_api() { return 1; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_typescript_migrated_template_string_without_interpolation_passes(tmp_path: Path) -> None:
+ """Verify plain string text inside backticks mentioning the old symbol does not block retirement."""
+ legacy_file = tmp_path / "legacy.ts"
+ legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8")
+ consumer_file = tmp_path / "consumer.ts"
+ consumer_file.write_text(
+ "console.log(`Retired old_api successfully, now running new_api`);\n",
+ encoding="utf-8",
+ )
+
+ diff = """
+--- a/legacy.ts
++++ b/legacy.ts
+@@ -1,4 +1,1 @@
+-export function old_api() {
+- return 0;
+-}
+ export function new_api() { return 1; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path)
+ assert receipt.verdict == "passed"
+ assert "old_api" in receipt.retired_symbols
+ assert len(receipt.findings) == 0
+
+
+def test_typescript_unclosed_syntax_reports_insufficient_evidence(tmp_path: Path) -> None:
+ """Verify unclosed/unparseable JS/TS syntax reports AE004 rather than false pass."""
+ legacy_file = tmp_path / "legacy.ts"
+ legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8")
+ broken_file = tmp_path / "broken.ts"
+ broken_file.write_text("const x = `hello ${old_api;\n", encoding="utf-8")
+
+ diff = """
+--- a/legacy.ts
++++ b/legacy.ts
+@@ -1,4 +1,1 @@
+-export function old_api() {
+- return 0;
+-}
+ export function new_api() { return 1; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path)
+ assert receipt.verdict == "rejected"
+ assert any(f.rule_id.startswith("AE004") for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+def test_typescript_static_computed_member_reference_blocks_retirement(
+ tmp_path: Path,
+) -> None:
+ """A statically named computed member access is an active reference."""
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ workspace = _make_workspace(
+ tmp_path / "ws_unmigrated",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const first = api['old_api']();\n"
+ 'const second = api?.["old_api"]();\n'
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected"
+ assert any(
+ finding.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED"
+ and "client.mjs" in finding.message
+ for finding in receipt.findings
+ )
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_computed_member_migrated_and_harmless_strings_pass(
+ tmp_path: Path,
+) -> None:
+ """Migrated computed calls and plain string/array text never block retirement."""
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+--- a/client.mjs
++++ b/client.mjs
+@@ -2,1 +2,3 @@
+-export function run() { return api.old_api(); }
++export function run() { return api['new_api'](); }
++export const allowed = ['old_api'];
++export const message = "old_api has been removed";
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ workspace = _make_workspace(
+ tmp_path / "ws_migrated",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "export function run() { return api['new_api'](); }\n"
+ "export const allowed = ['old_api'];\n"
+ 'export const message = "old_api has been removed";\n'
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py
new file mode 100644
index 0000000000..6d50485a2f
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy_feedback.py
@@ -0,0 +1,900 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from loopx.capabilities.anti_entropy import (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ ANTI_ENTROPY_CATALOG_ENTRY,
+ ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR,
+ build_anti_entropy_turn_context,
+ run_anti_entropy_feedback_cycle,
+)
+from loopx.capabilities.anti_entropy.contract import RetirementBasis
+from loopx.capabilities.anti_entropy.turn_context import anti_entropy_context_result
+
+CLEAN_DIFF = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,2 @@
+-def old_api():
+- return 1
++pass
+"""
+
+VIOLATION_DIFF = """
+--- a/service.py
++++ b/service.py
+@@ -1,4 +1,5 @@
++def new_api():
++ return 2
+ def old_api():
+ return 1
+"""
+
+BASIS = {"target_symbols": ["old_api"], "rationale": "replaced by new_api"}
+
+
+def _make_workspace(tmp_path: Path, content: str) -> Path:
+ workspace = tmp_path / "workspace"
+ workspace.mkdir(parents=True, exist_ok=True)
+ (workspace / "service.py").write_text(content, encoding="utf-8")
+ return workspace
+
+
+def test_capability_is_default_off() -> None:
+ """The feedback loop ships disabled: enabling is an explicit task-scoped choice."""
+ assert ANTI_ENTROPY_CATALOG_ENTRY["default_enabled"] is False
+
+
+def test_inspect_tool_descriptor_is_well_formed() -> None:
+ """The agent tool descriptor names required inputs and routes."""
+ assert ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["name"] == "anti_entropy_inspect"
+ schema = ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["input_schema"]
+ assert schema["required"] == ["diff_text"]
+ assert "workspace" in schema["properties"]
+ assert "--workspace" in ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["cli_route"]
+ # The catalog registers the tool surface so hosts can discover it.
+ agent_tools = ANTI_ENTROPY_CATALOG_ENTRY["agent_tools"]
+ assert any(t["name"] == "anti_entropy_inspect" for t in agent_tools)
+
+
+def test_turn_context_projects_declared_task(tmp_path: Path) -> None:
+ """The turn context carries the declared retirement goal and inspect route."""
+ context = build_anti_entropy_turn_context(
+ basis=BASIS, goal_id="goal-123", workspace=tmp_path
+ )
+ assert context["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID
+ assert context["goal_id"] == "goal-123"
+ assert context["task"]["target_symbols"] == ["old_api"]
+ assert context["task"]["objective"] == "replaced by new_api"
+ assert context["inspect_tool"]["name"] == "anti_entropy_inspect"
+ assert "change-quality" in context["feedback_semantics"]["delivery_gate"]
+ # Building the context executes nothing.
+ assert not list(tmp_path.iterdir())
+
+
+def test_feedback_cycle_rejection_is_progress_fact(tmp_path: Path) -> None:
+ """A retained definition yields actionable feedback, not a terminal failure."""
+ workspace = _make_workspace(
+ tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n"
+ )
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=VIOLATION_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace
+ )
+ assert result["ok"] is True
+ assert result["verdict"] == "rejected"
+ assert result["is_progress_fact"] is True
+ assert result["delivery_ready"] is False
+ feedback = result["actionable_feedback"]
+ assert feedback, "expected actionable feedback entries"
+ ae001 = [f for f in feedback if f["rule_id"].startswith("AE001")]
+ assert ae001 and ae001[0]["next_action"]
+ assert "Remove" in ae001[0]["next_action"]
+ # The route for final acceptance is named but not executed here.
+ assert "change-quality" in result["delivery_route"]
+ # Read-only boundary: no receipt store or files written by the cycle.
+ assert sorted(p.name for p in workspace.iterdir()) == ["service.py"]
+
+
+def test_feedback_cycle_reports_missing_workspace(tmp_path: Path) -> None:
+ """AE004 feedback tells the agent to bind the workspace and re-run."""
+ result = run_anti_entropy_feedback_cycle(diff_text=CLEAN_DIFF, basis=BASIS)
+ assert result["verdict"] == "rejected"
+ assert result["is_progress_fact"] is True
+ ae004 = [f for f in result["actionable_feedback"] if f["rule_id"].startswith("AE004")]
+ assert ae004
+ assert "workspace" in ae004[0]["next_action"]
+
+
+def test_feedback_cycle_delivery_ready(tmp_path: Path) -> None:
+ """A passed, source-bound round covering all targets marks delivery-ready."""
+ workspace = _make_workspace(tmp_path, "pass\n")
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=CLEAN_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace
+ )
+ assert result["verdict"] == "passed"
+ assert result["delivery_ready"] is True
+ assert result["is_progress_fact"] is False
+ assert result["retired_symbols"] == ["old_api"]
+ assert result["receipt"]["source_evidence"] == "workspace_sources"
+
+
+def test_inspect_cli_roundtrip(tmp_path: Path) -> None:
+ """The CLI inspect subcommand is a real callable entry for agents."""
+ workspace = _make_workspace(
+ tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n"
+ )
+ proc = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "inspect",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ json.dumps(BASIS),
+ "--workspace",
+ str(workspace),
+ "--goal-id",
+ "goal-123",
+ "--format",
+ "json",
+ ],
+ input=VIOLATION_DIFF.encode("utf-8"),
+ capture_output=True,
+ )
+ # Rejection is a progress fact: the check itself succeeds (exit 0).
+ assert proc.returncode == 0
+ payload = json.loads(proc.stdout.decode("utf-8"))
+ assert payload["command"] == "inspect"
+ assert payload["verdict"] == "rejected"
+ assert payload["is_progress_fact"] is True
+ assert payload["actionable_feedback"]
+
+ # Verify remains the delivery-gate entry with non-zero rejection exit.
+ proc_verify = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ json.dumps(BASIS),
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=VIOLATION_DIFF.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc_verify.returncode == 1
+
+
+def test_delivery_route_has_valid_change_quality_parameters() -> None:
+ """Verify delivery route command matches loopx change-quality CLI syntax (--result-json, --execute)."""
+ from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE
+ assert "--result-json" in _DELIVERY_ROUTE
+ assert "--execute" in _DELIVERY_ROUTE
+ assert "--result-path" not in _DELIVERY_ROUTE
+ assert "prepare" in _DELIVERY_ROUTE
+ assert "verify" in _DELIVERY_ROUTE
+
+
+def test_delivery_route_executes_real_change_quality_flow(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """The emitted delivery route executes against the real change-quality owner.
+
+ Full journey on a real Git retirement change: the anti-entropy inspect
+ round marks the change delivery-ready, then the route's prepare →
+ record (--result-json + --execute) → verify commands run through the real
+ CLI parser and handler, and any later diff change invalidates the stored
+ receipt (requalification_required).
+ """
+ import subprocess as sp
+
+ from loopx.capabilities.change_quality.receipt import (
+ CHANGE_QUALITY_RESULT_SCHEMA_VERSION,
+ )
+ from loopx.cli import main
+ from loopx.configure_goal import configure_goal
+
+ goal_id = "anti-entropy-delivery-fixture"
+
+ def git(repo: Path, *args: str) -> str:
+ return sp.run(
+ ["git", "-C", str(repo), *args],
+ check=True, text=True,
+ stdout=sp.PIPE, stderr=sp.PIPE,
+ ).stdout.strip()
+
+ # Real Git fixture: committed old_api + caller, then a full retirement change.
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ git(repo, "init")
+ git(repo, "config", "user.email", "anti-entropy@example.invalid")
+ git(repo, "config", "user.name", "Anti Entropy Fixture")
+ (repo / "service.py").write_text(
+ "def old_api():\n return 1\n", encoding="utf-8"
+ )
+ (repo / "client.py").write_text(
+ "from service import old_api\n\ndef run():\n return old_api()\n",
+ encoding="utf-8",
+ )
+ git(repo, "add", "service.py", "client.py")
+ git(repo, "commit", "-m", "fixture with legacy api")
+ # Post-change sources: migration completed, old_api fully removed.
+ (repo / "service.py").write_text(
+ "def new_api():\n return 2\n", encoding="utf-8"
+ )
+ (repo / "client.py").write_text(
+ "from service import new_api\n\ndef run():\n return new_api()\n",
+ encoding="utf-8",
+ )
+ real_diff = git(repo, "diff", "HEAD")
+
+ registry = tmp_path / "registry.json"
+ runtime_root = tmp_path / "runtime"
+ registry.write_text(
+ json.dumps(
+ {
+ "common_runtime_root": str(runtime_root),
+ "goals": [
+ {"id": goal_id, "repo": str(repo), "control_plane": {}}
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ configure_goal(
+ registry_path=registry,
+ goal_id=goal_id,
+ change_quality_enabled=True,
+ change_quality_safe_fix=True,
+ change_quality_strict_receipt=True,
+ execute=True,
+ )
+
+ # 1. Anti-entropy inspect round: delivery-ready on the real post-change tree.
+ cycle = run_anti_entropy_feedback_cycle(
+ diff_text=real_diff, basis=BASIS, goal_id=goal_id, workspace=repo
+ )
+ assert cycle["verdict"] == "passed", f"findings: {cycle['actionable_feedback']}"
+ assert cycle["delivery_ready"] is True
+ assert cycle["retired_symbols"] == ["old_api"]
+
+ # The route template this round hands to the delivery owner.
+ from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE
+
+ scope_args = ["--repo-path", str(repo), "--base-ref", "HEAD"]
+
+ # 2. Route step 1: prepare (real CLI parser + handler).
+ monkeypatch.chdir(tmp_path)
+ capsys.readouterr()
+ assert main(
+ ["--registry", str(registry), "--runtime-root", str(runtime_root),
+ "--format", "json", "change-quality", "prepare",
+ "--goal-id", goal_id, *scope_args]
+ ) == 0
+ prepared = json.loads(capsys.readouterr().out)
+ fingerprint = prepared["scope"]["scope_fingerprint"]
+ assert fingerprint
+
+ # 3. Route step 2: record with --result-json (change_quality_agent_result_v2).
+ result_path = tmp_path / "result.json"
+ result_path.write_text(
+ json.dumps(
+ {
+ "schema_version": CHANGE_QUALITY_RESULT_SCHEMA_VERSION,
+ "scope_fingerprint": fingerprint,
+ "reviewed_final_scope": True,
+ "reuse": {
+ "outcome": "retained",
+ "summary": "Fixture keeps the direct owner.",
+ "evidence_refs": ["path:service.py", "path:client.py"],
+ },
+ "simplification": {
+ "outcome": "retained",
+ "summary": "Migration already minimal.",
+ "evidence_refs": ["path:service.py", "path:client.py"],
+ "safe_fix_applied": False,
+ },
+ "risks": [],
+ "validation": [
+ {
+ "validator": "anti-entropy-inspect",
+ "status": "passed",
+ "scope": "retirement targets certified on bound sources",
+ "required": True,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ capsys.readouterr()
+ assert main(
+ ["--registry", str(registry), "--runtime-root", str(runtime_root),
+ "--format", "json", "change-quality", "record",
+ "--goal-id", goal_id, *scope_args,
+ "--result-json", str(result_path), "--execute"]
+ ) == 0
+ recorded = json.loads(capsys.readouterr().out)
+ assert recorded["decision"] == "pass"
+ assert recorded["receipt_path"]
+
+ # 4. Route step 3: verify — valid for the unchanged exact scope.
+ capsys.readouterr()
+ assert main(
+ ["--registry", str(registry), "--runtime-root", str(runtime_root),
+ "--format", "json", "change-quality", "verify",
+ "--goal-id", goal_id, *scope_args]
+ ) == 0
+ verified = json.loads(capsys.readouterr().out)
+ assert verified["status"] == "valid"
+ assert verified["pr_evidence"]["requalification_required"] is False
+
+ # 5. Invalidation counterexample: any diff change after record requires
+ # requalification — the receipt is not a permanent pass.
+ (repo / "client.py").write_text(
+ "from service import new_api\n\ndef run():\n return new_api() + 1\n",
+ encoding="utf-8",
+ )
+ capsys.readouterr()
+ assert main(
+ ["--registry", str(registry), "--runtime-root", str(runtime_root),
+ "--format", "json", "change-quality", "verify",
+ "--goal-id", goal_id, *scope_args]
+ ) == 1
+ stale = json.loads(capsys.readouterr().out)
+ assert stale["ok"] is False
+ assert stale["pr_evidence"]["requalification_required"] is True
+
+ # The executed tokens are exactly the route's contract: the string is a
+ # template of real, parser-accepted parameters.
+ for token in ("--result-json", "--execute", "prepare", "record", "verify"):
+ assert token in _DELIVERY_ROUTE
+
+
+def test_turn_context_hook_lifecycle_all_states(tmp_path: Path) -> None:
+ """Verify turn-context hook across all 4 lifecycle states: disabled, non-applicable, new task, and recovery."""
+ from loopx.capabilities.anti_entropy.turn_context import (
+ anti_entropy_context_registration,
+ anti_entropy_context_result,
+ )
+ from loopx.control_plane.capability_hooks import (
+ dispatch_turn_context_hooks,
+ project_turn_context,
+ )
+
+ # State 1: Disabled
+ payload_disabled = {"goal_id": "g-1", "agent_id": "a-1"}
+ decision_disabled = {}
+ res_disabled = anti_entropy_context_result(
+ payload_disabled, goal_id="g-1", decision=decision_disabled
+ )
+ # Disabled turns declare no readback slot: the host payload is untouched.
+ assert res_disabled["readback"] == {}
+ assert res_disabled["context_fields"] == {}
+ assert res_disabled["instructions"] == []
+
+ # State 2: Enabled but not applicable (no retirement targets declared)
+ payload_na = {"goal_id": "g-1", "agent_id": "a-1", "anti_entropy_enabled": True}
+ res_na = anti_entropy_context_result(
+ payload_na, goal_id="g-1", decision={}
+ )
+ assert res_na["readback"]["anti_entropy_guidance"]["enabled"] is True
+ assert res_na["readback"]["anti_entropy_guidance"]["applicable"] is False
+ assert res_na["context_fields"] == {}
+
+ # State 3: Active new task (retirement basis declared)
+ basis_data = {"target_symbols": ["old_service"], "rationale": "migrate to v2"}
+ payload_active = {
+ "goal_id": "g-1",
+ "agent_id": "a-1",
+ "retirement_basis": basis_data,
+ "workspace": str(tmp_path),
+ }
+ res_active = anti_entropy_context_result(
+ payload_active, goal_id="g-1", decision={}, workspace=tmp_path
+ )
+ guidance = res_active["readback"]["anti_entropy_guidance"]
+ assert guidance["enabled"] is True
+ assert guidance["applicable"] is True
+ assert guidance["status"] == "active"
+ assert "anti_entropy_context" in res_active["context_fields"]
+ assert any("delete-first" in inst.lower() for inst in res_active["instructions"])
+
+ # State 4: Failure recovery
+ payload_recovery = {
+ "goal_id": "g-1",
+ "agent_id": "a-1",
+ "retirement_basis": basis_data,
+ "anti_entropy_progress_fact": True,
+ "workspace": str(tmp_path),
+ }
+ res_recovery = anti_entropy_context_result(
+ payload_recovery, goal_id="g-1", decision={}, workspace=tmp_path
+ )
+ guidance_rec = res_recovery["readback"]["anti_entropy_guidance"]
+ assert guidance_rec["status"] == "recovering"
+ assert any("recovery" in inst.lower() for inst in res_recovery["instructions"])
+
+ # End-to-end dispatch through capability hooks
+ reg = anti_entropy_context_registration(
+ payload_active,
+ registry_path=tmp_path,
+ goal_id="g-1",
+ agent_id="a-1",
+ decision={},
+ turn_instance_id="t-1",
+ )
+ payload_dispatched = dict(payload_active)
+ dispatch_turn_context_hooks(payload_dispatched, (reg,))
+ assert payload_dispatched.get("anti_entropy_guidance") is not None
+ assert payload_dispatched["anti_entropy_guidance"]["status"] == "active"
+
+ projected = project_turn_context(payload_dispatched)
+ assert "anti_entropy_context" in projected.get("capability_context_fields", [])
+ assert any("anti-entropy" in inst.lower() for inst in projected.get("capability_instructions", []))
+
+
+def test_attach_turn_context_integration(tmp_path: Path) -> None:
+ """Verify loopx.capabilities.turn_context.attach_turn_context wires anti-entropy with memory."""
+ from loopx.capabilities.turn_context import attach_turn_context
+
+ basis_data = {"target_symbols": ["old_api"], "rationale": "replaced"}
+ payload = {
+ "goal_id": "g-1",
+ "agent_id": "a-1",
+ "retirement_basis": basis_data,
+ "workspace": str(tmp_path),
+ }
+
+ attach_turn_context(
+ payload,
+ registry_path=tmp_path,
+ goal_id="g-1",
+ agent_id="a-1",
+ decision={},
+ turn_instance_id="turn-42",
+ )
+
+ assert "anti_entropy_guidance" in payload
+ assert payload["anti_entropy_guidance"]["status"] == "active"
+ assert payload["anti_entropy_guidance"]["target_symbols"] == ["old_api"]
+
+
+def test_feedback_cycle_pure_path_delivery_ready(tmp_path: Path) -> None:
+ """A pure path retirement task with file deleted in diff and absent on disk is delivery ready."""
+ (tmp_path / "service.py").write_text("pass\n", encoding="utf-8")
+ # legacy.py does not exist on disk
+ diff = """
+--- a/legacy.py
++++ /dev/null
+@@ -1,3 +0,0 @@
+-def dead():
+- pass
+"""
+ basis = RetirementBasis(target_paths=("legacy.py",))
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert result["ok"] is True
+ assert result["verdict"] == "passed"
+ assert result["delivery_ready"] is True
+ assert result["is_progress_fact"] is False
+ assert result["retired_symbols"] == []
+ assert len(result["actionable_feedback"]) == 0
+
+
+def test_feedback_cycle_pure_path_retained_in_workspace_blocks(tmp_path: Path) -> None:
+ """A path deleted in diff but still physically present in workspace blocks with AE004."""
+ (tmp_path / "legacy.py").write_text("def dead(): pass\n", encoding="utf-8")
+ diff = """
+--- a/legacy.py
++++ /dev/null
+@@ -1,3 +0,0 @@
+-def dead():
+- pass
+"""
+ basis = RetirementBasis(target_paths=("legacy.py",))
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert result["verdict"] == "rejected"
+ assert result["delivery_ready"] is False
+ assert result["is_progress_fact"] is True
+ assert any(f["rule_id"].startswith("AE004") for f in result["actionable_feedback"])
+
+
+def test_feedback_cycle_pure_path_not_deleted_in_diff_blocks(tmp_path: Path) -> None:
+ """A path not deleted in the diff blocks with AE001."""
+ diff = """
+--- a/legacy.py
++++ b/legacy.py
+@@ -1,3 +1,3 @@
+-def dead():
++def modified():
+ pass
+"""
+ basis = RetirementBasis(target_paths=("legacy.py",))
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert result["verdict"] == "rejected"
+ assert result["delivery_ready"] is False
+ assert any(f["rule_id"].startswith("AE001") for f in result["actionable_feedback"])
+
+
+def test_feedback_cycle_empty_basis_not_delivery_ready(tmp_path: Path) -> None:
+ """An empty retirement basis without targets never becomes delivery ready."""
+ basis = RetirementBasis()
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=CLEAN_DIFF, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert result["delivery_ready"] is False
+
+
+def test_feedback_cycle_mixed_targets_both_required(tmp_path: Path) -> None:
+ """Mixed targets (symbol + path) require both to be certified for delivery readiness."""
+ service = tmp_path / "service.py"
+ service.write_text("def new_api(): return 1\n", encoding="utf-8")
+ # legacy.py absent from workspace
+
+ clean_both_diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,3 +1,1 @@
+-def old_api():
+- return 0
+ def new_api(): return 1
+--- a/legacy.py
++++ /dev/null
+@@ -1,2 +0,0 @@
+-pass
+"""
+ basis = RetirementBasis(target_symbols=("old_api",), target_paths=("legacy.py",))
+ res_both = run_anti_entropy_feedback_cycle(
+ diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert res_both["verdict"] == "passed"
+ assert res_both["delivery_ready"] is True
+ assert res_both["retired_symbols"] == ["old_api"]
+
+ # Symbol retired, but legacy.py still present on disk
+ (tmp_path / "legacy.py").write_text("pass\n", encoding="utf-8")
+ res_retained_path = run_anti_entropy_feedback_cycle(
+ diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert res_retained_path["verdict"] == "rejected"
+ assert res_retained_path["delivery_ready"] is False
+ (tmp_path / "legacy.py").unlink()
+
+ # Path deleted, but symbol definition still on disk
+ service.write_text("def new_api(): return 1\ndef old_api(): return 0\n", encoding="utf-8")
+ res_retained_symbol = run_anti_entropy_feedback_cycle(
+ diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path
+ )
+ assert res_retained_symbol["verdict"] == "rejected"
+ assert res_retained_symbol["delivery_ready"] is False
+
+
+def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> None:
+ """Verify retirement basis is extracted from native route and turn_envelope structures."""
+ # 1. Route selected_todo
+ payload_route = {
+ "route": {
+ "selected_todo": {
+ "todo_id": "todo-rt-1",
+ "retirement_basis": {"target_symbols": ["old_route_func"]},
+ }
+ }
+ }
+ res_route = anti_entropy_context_result(payload_route, goal_id="g1", decision={})
+ assert res_route["readback"]["anti_entropy_guidance"]["status"] == "active"
+ assert res_route["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_route_func"]
+ assert "anti_entropy_context" in res_route["context_fields"]
+
+ # 2. Turn envelope action selected_todo with metadata
+ payload_envelope = {
+ "turn_envelope": {
+ "action": {
+ "selected_todo": {
+ "todo_id": "todo-env-1",
+ "metadata": {"retirement_basis": {"target_symbols": ["old_env_func"]}},
+ }
+ }
+ }
+ }
+ res_env = anti_entropy_context_result(payload_envelope, goal_id="g1", decision={})
+ assert res_env["readback"]["anti_entropy_guidance"]["status"] == "active"
+ assert res_env["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_env_func"]
+
+ # 3. Decision selected_todo with params
+ decision_params = {
+ "selected_todo": {
+ "todo_id": "todo-dec-1",
+ "params": {"retirement_basis": {"target_paths": ["old_dec.py"]}},
+ }
+ }
+ res_dec = anti_entropy_context_result({}, goal_id="g1", decision=decision_params)
+ assert res_dec["readback"]["anti_entropy_guidance"]["status"] == "active"
+ assert res_dec["readback"]["anti_entropy_guidance"]["target_paths"] == ["old_dec.py"]
+
+ # 4. Route selected_todo with JSON string in note
+ payload_note = {
+ "route": {
+ "selected_todo": {
+ "todo_id": "todo-note-1",
+ "note": '{"target_symbols": ["old_note_func"]}',
+ }
+ }
+ }
+ res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={})
+ assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active"
+ assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"]
+
+def test_turn_context_reads_native_work_context_todo_note() -> None:
+ """The hook reads the selected Todo's canonical note from the native work context."""
+ basis = {
+ "retirement_basis": {
+ "target_symbols": ["old_api"],
+ "rationale": "replace old_api with new_api",
+ }
+ }
+ payload = {
+ "goal_id": "g1",
+ "route": {"selected_todo": {"todo_id": "t1"}},
+ "turn_envelope": {
+ "action": {
+ "recommended_action": "Replace old_api with new_api",
+ "selected_todo": {
+ "todo_id": "t1",
+ "text_ref": "action.recommended_action",
+ },
+ },
+ "work_context": {
+ "complete": True,
+ "sources": [
+ {
+ "source": "selected_todo",
+ "content": {
+ "todo": {
+ "todo_id": "t1",
+ "note": json.dumps(basis),
+ "required_capabilities": ["anti-entropy-governance"],
+ }
+ },
+ }
+ ],
+ },
+ },
+ }
+ res = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ guidance = res["readback"]["anti_entropy_guidance"]
+ assert guidance["enabled"] is True
+ assert guidance["applicable"] is True
+ assert guidance["target_symbols"] == ["old_api"]
+ assert "anti_entropy_context" in res["context_fields"]
+ assert res["context_fields"]["anti_entropy_context"]["inspect_tool"]["name"] == (
+ "anti_entropy_inspect"
+ )
+ assert any("delete-first" in item.lower() for item in res["instructions"])
+
+
+def test_turn_context_reads_basis_from_action_recommended_action() -> None:
+ """A retirement declaration inside the task body is read from the native action."""
+ instruction = (
+ "Replace the legacy API and remove the old export.\n"
+ '{"retirement_basis": {"target_symbols": ["old_api"], "target_paths": []}}'
+ )
+ payload = {
+ "turn_envelope": {
+ "action": {
+ "recommended_action": instruction,
+ "selected_todo": {
+ "todo_id": "t1",
+ "text_ref": "action.recommended_action",
+ },
+ }
+ }
+ }
+ res = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ guidance = res["readback"]["anti_entropy_guidance"]
+ assert guidance["applicable"] is True
+ assert guidance["target_symbols"] == ["old_api"]
+
+
+def test_turn_context_ignores_unrelated_json_in_task_text() -> None:
+ """Unrelated JSON in a task body does not opt a turn into retirement governance."""
+ payload = {
+ "turn_envelope": {
+ "action": {
+ "recommended_action": 'Set {"timeout": 30} for the nightly run.',
+ "selected_todo": {"todo_id": "t1", "text_ref": "action.recommended_action"},
+ }
+ }
+ }
+ res = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert res["readback"] == {}
+ assert res["context_fields"] == {}
+
+
+def test_disabled_turn_writes_no_readback_slot(tmp_path: Path) -> None:
+ """A disabled turn declares no slot and leaves the host payload untouched."""
+ from loopx.capabilities.anti_entropy.turn_context import (
+ anti_entropy_context_registration,
+ )
+ from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks
+
+ payload = {"goal_id": "g1", "agent_id": "a1"}
+ registration = anti_entropy_context_registration(
+ payload,
+ registry_path=tmp_path,
+ goal_id="g1",
+ agent_id="a1",
+ decision={},
+ turn_instance_id="t1",
+ )
+ assert registration.readback_slots == ()
+ dispatched = dict(payload)
+ dispatch_turn_context_hooks(dispatched, (registration,))
+ assert "anti_entropy_guidance" not in dispatched
+ assert "turn_context_contributions" not in dispatched
+
+
+def test_turn_context_resolves_workspace_from_goal_registry(tmp_path: Path) -> None:
+ """The post-change workspace is the Goal repository, never the process CWD."""
+ from loopx.capabilities.anti_entropy.turn_context import (
+ anti_entropy_context_registration,
+ )
+ from loopx.control_plane.capability_hooks import (
+ dispatch_turn_context_hooks,
+ project_turn_context,
+ )
+
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ registry = tmp_path / "registry.json"
+ registry.write_text(
+ json.dumps({"goals": [{"id": "g1", "repo": str(repo)}]}), encoding="utf-8"
+ )
+ payload = {
+ "goal_id": "g1",
+ "agent_id": "a1",
+ "retirement_basis": {"target_symbols": ["old_api"]},
+ }
+ registration = anti_entropy_context_registration(
+ payload,
+ registry_path=registry,
+ goal_id="g1",
+ agent_id="a1",
+ decision={},
+ turn_instance_id="t1",
+ )
+ dispatched = dict(payload)
+ dispatch_turn_context_hooks(dispatched, (registration,))
+ guidance = dispatched["anti_entropy_guidance"]
+ assert guidance["workspace_available"] is True
+ context = project_turn_context(dispatched)["anti_entropy_context"]
+ assert context["workspace"] == str(repo.resolve())
+
+
+def _native_turn_decision_fixture() -> dict:
+ """Load the canonical Turn decision fixture used by the envelope tests."""
+ import importlib.util
+
+ fixture_path = Path(__file__).resolve().parents[1] / "test_turn_envelope.py"
+ spec = importlib.util.spec_from_file_location(
+ "_anti_entropy_turn_envelope_fixture", fixture_path
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module._full_decision()
+
+
+def _dispatch_native_turn_plan(source: dict, registry: Path) -> dict:
+ """Build the real TS-owned Turn envelope, then dispatch the shared composer."""
+ from loopx.capabilities.anti_entropy.turn_context import (
+ anti_entropy_context_registration,
+ )
+ from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks
+ from loopx.control_plane.quota.turn_envelope import build_turn_envelope
+
+ envelope = build_turn_envelope(source)
+ plan = {
+ "turn_envelope": envelope,
+ "route": {"selected_todo": envelope["action"]["selected_todo"]},
+ }
+ registration = anti_entropy_context_registration(
+ plan,
+ registry_path=registry,
+ goal_id="fixture-goal",
+ agent_id="codex-fixture",
+ decision=source,
+ turn_instance_id="t1",
+ )
+ dispatch_turn_context_hooks(plan, (registration,))
+ return plan
+
+
+def test_native_turn_envelope_work_context_note_reaches_host_input(
+ tmp_path: Path,
+) -> None:
+ """The real Turn envelope carries the Todo note into host input."""
+ from loopx.control_plane.capability_hooks import project_turn_context
+
+ source = _native_turn_decision_fixture()
+ source["interaction_contract"]["agent_channel"]["work_context"] = {
+ "complete": True,
+ "selected_todo_ref": "selected_todo",
+ "sources": [
+ {
+ "source": "selected_todo",
+ "content": {
+ "todo": {
+ "todo_id": "todo_fixture0001",
+ "note": json.dumps(
+ {"retirement_basis": {"target_symbols": ["old_api"]}}
+ ),
+ "required_capabilities": ["anti-entropy-governance"],
+ }
+ },
+ }
+ ],
+ "instruction": "Read current sources before work.",
+ }
+
+ plan = _dispatch_native_turn_plan(source, tmp_path)
+
+ assert "turn_context_hook_failures" not in plan
+ guidance = plan["anti_entropy_guidance"]
+ assert guidance["enabled"] is True
+ assert guidance["applicable"] is True
+ assert guidance["target_symbols"] == ["old_api"]
+ projected = project_turn_context(plan)
+ assert "anti_entropy_context" in projected["capability_context_fields"]
+ assert any(
+ "delete-first" in item.lower()
+ for item in projected["capability_instructions"]
+ )
+
+
+def test_native_turn_envelope_task_text_reaches_host_input(tmp_path: Path) -> None:
+ """The task body carried by the native action is an intent source too."""
+ from loopx.control_plane.capability_hooks import project_turn_context
+
+ source = _native_turn_decision_fixture()
+ source["selected_todo"]["text"] = (
+ "Replace old_api with new_api. "
+ + json.dumps({"retirement_basis": {"target_symbols": ["old_api"]}})
+ )
+ source["interaction_contract"]["agent_channel"]["work_context"] = {
+ "complete": True,
+ "selected_todo_ref": "selected_todo",
+ "instruction": "Read current sources before work.",
+ }
+
+ plan = _dispatch_native_turn_plan(source, tmp_path)
+
+ # The envelope elides a redundant work context and keeps the body in action.
+ assert "work_context" not in plan["turn_envelope"]
+ guidance = plan["anti_entropy_guidance"]
+ assert guidance["applicable"] is True
+ assert guidance["target_symbols"] == ["old_api"]
+ projected = project_turn_context(plan)
+ assert "anti_entropy_context" in projected["capability_context_fields"]
diff --git a/tests/capabilities/test_anti_entropy_native_host.py b/tests/capabilities/test_anti_entropy_native_host.py
new file mode 100644
index 0000000000..3093d9ff01
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy_native_host.py
@@ -0,0 +1,149 @@
+"""Real Turn -> Host stdin regression for anti-entropy task intent.
+
+This drives the same path the reviewer used: a canonical File-authority Goal,
+a Todo authored through the real CLI, and a real `turn run-once --execute`
+whose Host is a synthetic `codex` binary that records argv/stdin and exits 9.
+It proves the guidance and inspect descriptor reach Host input, not merely the
+in-process composer.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _load_canonical_fixture():
+ fixture_path = REPO_ROOT / "tests/control_plane/canonical_authority_fixture.py"
+ spec = importlib.util.spec_from_file_location(
+ "_anti_entropy_native_host_canonical_fixture", fixture_path
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _run_cli(registry: Path, runtime: Path, *args: str, timeout: int = 240):
+ return subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "--registry",
+ str(registry),
+ "--runtime-root",
+ str(runtime),
+ *args,
+ ],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+
+
+@pytest.mark.skipif(
+ sys.platform == "win32",
+ reason="the synthetic Host fixture is a POSIX shell script",
+)
+def test_real_turn_host_stdin_carries_anti_entropy_guidance(tmp_path: Path) -> None:
+ """A declared retirement task reaches the real Host input of turn run-once."""
+ if shutil.which("node") is None:
+ pytest.skip("canonical File authority fixture requires node")
+
+ fixture = _load_canonical_fixture()
+ goal_id = "anti-entropy-native-host"
+ registry, runtime, _state = fixture.promoted_create_fixture(
+ tmp_path, goal_id=goal_id, registered_agents=["codex-fixture"]
+ )
+ # The fixture Goal predates contract fields the status gate needs.
+ registry_payload = json.loads(registry.read_text(encoding="utf-8"))
+ registry_payload["goals"][0].update(
+ {
+ "status": "active",
+ "domain": "software-development",
+ "adapter": {"kind": "generic_project_goal_v0", "status": "connected"},
+ }
+ )
+ registry.write_text(json.dumps(registry_payload), encoding="utf-8")
+
+ basis = {"retirement_basis": {"target_symbols": ["old_api"]}}
+ todo_text = (
+ "Replace old_api with new_api and delete the legacy export. "
+ + json.dumps(basis)
+ )
+ added = _run_cli(
+ registry,
+ runtime,
+ "todo",
+ "add",
+ "--goal-id",
+ goal_id,
+ "--role",
+ "agent",
+ "--text",
+ todo_text,
+ "--note",
+ json.dumps(basis),
+ "--execute",
+ "--format",
+ "json",
+ )
+ assert added.returncode == 0, added.stdout + added.stderr
+ assert json.loads(added.stdout)["status"] == "applied"
+
+ capture = tmp_path / "host_capture.txt"
+ fake_codex = tmp_path / "codex"
+ fake_codex.write_text(
+ "#!/bin/sh\n"
+ "{\n"
+ " echo '=== ARGV ==='\n"
+ " printf '%s\\n' \"$@\"\n"
+ " echo '=== STDIN ==='\n"
+ " cat\n"
+ f'}} > "{capture}"\n'
+ "exit 9\n",
+ encoding="utf-8",
+ )
+ fake_codex.chmod(0o755)
+
+ turn = _run_cli(
+ registry,
+ runtime,
+ "turn",
+ "run-once",
+ "--goal-id",
+ goal_id,
+ "--agent-id",
+ "codex-fixture",
+ "--host",
+ "codex-cli",
+ "--project",
+ str(tmp_path / "project"),
+ "--codex-bin",
+ str(fake_codex),
+ "--timeout-seconds",
+ "30",
+ "--execute",
+ "--format",
+ "json",
+ )
+ payload = json.loads(turn.stdout)
+ # The Host failed on purpose (exit 9) after its input was captured.
+ assert payload["effects"]["host_invoked"] is True
+ assert payload["result_kind"] == "host_failure"
+ assert capture.is_file(), turn.stdout + turn.stderr
+
+ host_input = capture.read_text(encoding="utf-8", errors="replace")
+ assert "Anti-entropy code governance is active for this task" in host_input
+ assert "loopx anti-entropy inspect" in host_input
+ assert '"anti_entropy_context"' in host_input
+ assert '"inspect_tool"' in host_input
diff --git a/tests/capabilities/test_capability_extension_registry.py b/tests/capabilities/test_capability_extension_registry.py
index bf99c46780..ddef80c25b 100644
--- a/tests/capabilities/test_capability_extension_registry.py
+++ b/tests/capabilities/test_capability_extension_registry.py
@@ -47,6 +47,7 @@
"reliability-diagnostics",
"progress-review-sentinel",
"goal-capability-organization",
+ "anti-entropy-governance",
]