← Files Auto Preference LearnerARCHIVED FILE
skills/auto-preference-learner/scripts/reconcile_agents.py
34.8 KB · Oct 2, 2026 · 00:29 UTC
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import difflib
import os
import re
import subprocess
from pathlib import Path
from typing import Any
from _common import (
END_MARKER,
START_MARKER,
codex_home,
load_json,
normalize_text,
sha256_bytes,
stable_id,
write_json_output,
)
HEADING = "## Learned working preferences"
MAX_AGENTS_BYTES = 32 * 1024
UTF8_BOM = b"\xef\xbb\xbf"
VALID_ACTIONS = {"add", "merge", "narrow", "replace", "remove", "no-op", "needs_evidence"}
MUTATING_ACTIONS = {"add", "merge", "narrow", "replace", "remove"}
class ReconcileError(ValueError):
pass
def read_agents(path: Path) -> tuple[bytes, str]:
if path.is_symlink():
raise ReconcileError(f"refusing symbolic-link AGENTS.md target: {path}")
if not path.exists():
return b"", ""
raw = path.read_bytes()
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError as error:
raise ReconcileError(f"{path} is not valid UTF-8") from error
return raw, text
def encode_agents(text: str, original_raw: bytes) -> bytes:
encoded = text.encode("utf-8")
return UTF8_BOM + encoded if original_raw.startswith(UTF8_BOM) else encoded
def newline_for(text: str) -> str:
return "\r\n" if "\r\n" in text else "\n"
def parse_rules(text: str) -> tuple[list[str], tuple[int, int] | None]:
start_count = text.count(START_MARKER)
end_count = text.count(END_MARKER)
if start_count == 0 and end_count == 0:
return [], None
if start_count != 1 or end_count != 1:
raise ReconcileError("managed block markers must occur exactly once")
start = text.find(START_MARKER)
end = text.find(END_MARKER)
if end < start:
raise ReconcileError("managed block end marker precedes start marker")
end_after = end + len(END_MARKER)
body = text[start + len(START_MARKER) : end]
rules: list[str] = []
current: list[str] | None = None
for line in body.splitlines():
stripped = line.strip()
if not stripped or stripped == HEADING:
continue
if stripped.startswith("- "):
if current:
rules.append(" ".join(current).strip())
current = [stripped[2:].strip()]
elif current is not None:
current.append(stripped)
if current:
rules.append(" ".join(current).strip())
return rules, (start, end_after)
def managed_block(rules: list[str], newline: str) -> str:
lines = [START_MARKER, HEADING, ""]
lines.extend(f"- {rule.strip()}" for rule in rules if rule.strip())
lines.append(END_MARKER)
return newline.join(lines)
def render_text(original: str, rules: list[str], bounds: tuple[int, int] | None) -> str:
newline = newline_for(original)
block = managed_block(rules, newline)
if bounds is not None:
return original[: bounds[0]] + block + original[bounds[1] :]
if not rules:
return original
if not original:
return block + newline
separator = newline if original.endswith(("\n", "\r")) else newline * 2
if original.endswith(newline):
separator = newline
return original + separator + block + newline
def locate_rule(rules: list[str], expected: str) -> int:
needle = normalize_text(expected)
for index, rule in enumerate(rules):
if normalize_text(rule) == needle:
return index
raise ReconcileError(f"managed rule not found: {expected}")
def normalized_effective_lines(text: str) -> set[str]:
result: set[str] = set()
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("<!--") or line.startswith("#"):
continue
line = re.sub(r"^(?:[-+*]|\d+[.)])\s+", "", line).strip()
normalized = normalize_text(line)
if normalized:
result.add(normalized)
return result
def apply_decisions(original: str, decisions: list[dict[str, Any]]) -> tuple[str, list[str]]:
rules, bounds = parse_rules(original)
notes: list[str] = []
for decision in decisions:
action = decision.get("action")
if action not in VALID_ACTIONS:
raise ReconcileError(f"invalid action: {action}")
if action in {"no-op", "needs_evidence"}:
continue
instruction = str(decision.get("instruction") or "").strip()
text_fields = [instruction, str(decision.get("existing_instruction") or "")]
text_fields.extend(str(value) for value in decision.get("existing_instructions") or [])
if any(marker in value for value in text_fields for marker in (START_MARKER, END_MARKER)):
raise ReconcileError("decision text must not contain managed block markers")
if action == "add":
if not instruction:
raise ReconcileError("add requires instruction")
if normalize_text(instruction) in {normalize_text(rule) for rule in rules}:
notes.append(f"{decision['decision_id']}: exact duplicate became no-op")
else:
rules.append(instruction)
elif action == "remove":
existing = str(decision.get("existing_instruction") or "").strip()
if not existing:
raise ReconcileError("remove requires existing_instruction")
rules.pop(locate_rule(rules, existing))
elif action in {"narrow", "replace"}:
existing = str(decision.get("existing_instruction") or "").strip()
if not existing or not instruction:
raise ReconcileError(f"{action} requires existing_instruction and instruction")
index = locate_rule(rules, existing)
equivalent_exists = any(
other_index != index and normalize_text(rule) == normalize_text(instruction)
for other_index, rule in enumerate(rules)
)
if equivalent_exists:
rules.pop(index)
notes.append(f"{decision['decision_id']}: replacement consolidated into equivalent existing rule")
else:
rules[index] = instruction
elif action == "merge":
existing_values = decision.get("existing_instructions")
if not isinstance(existing_values, list) or not existing_values or not instruction:
raise ReconcileError("merge requires existing_instructions and instruction")
indices = sorted({locate_rule(rules, str(value)) for value in existing_values}, reverse=True)
insert_at = min(indices)
for index in indices:
rules.pop(index)
if normalize_text(instruction) in {normalize_text(rule) for rule in rules}:
notes.append(f"{decision['decision_id']}: merge consolidated into equivalent existing rule")
else:
rules.insert(min(insert_at, len(rules)), instruction)
updated = render_text(original, rules, bounds)
parse_rules(updated)
if len(updated.encode("utf-8")) > MAX_AGENTS_BYTES and len(updated.encode("utf-8")) > len(original.encode("utf-8")):
raise ReconcileError("AGENTS.md would exceed 32 KiB; merge, narrow, replace, or remove rules instead of growing it")
return updated, notes
def ensure_decision_ids(decisions: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in decisions:
decision = dict(raw)
identifier = str(decision.get("decision_id") or "").strip()
generated = not identifier or identifier.isdecimal()
if generated:
semantic_text = str(
decision.get("instruction")
or decision.get("existing_instruction")
or "|".join(map(str, decision.get("existing_instructions") or []))
or decision.get("semantic_scope")
or decision.get("explanation")
or ""
)
identifier = stable_id(
str(decision.get("action")),
str(decision.get("target")),
str(decision.get("project_root")),
semantic_text,
prefix="D-",
)
base_identifier = identifier
occurrence = 2
while identifier in seen:
identifier = f"{base_identifier}-{occurrence}"
occurrence += 1
if identifier in seen:
raise ReconcileError(f"duplicate decision_id: {identifier}")
decision["decision_id"] = identifier
seen.add(identifier)
result.append(decision)
return result
def assign_selection_numbers(decisions: list[dict[str, Any]]) -> None:
"""Assign compact, plan-local numbers only to user-reviewable changes."""
next_number = 1
for decision in decisions:
decision.pop("selection_number", None)
if decision.get("action") in MUTATING_ACTIONS:
decision["selection_number"] = next_number
next_number += 1
def decision_fingerprint(decision: dict[str, Any], instruction: str | None = None) -> str:
target = str(decision.get("target") or "")
scope = "global" if target == "global" else path_key(Path(str(decision.get("project_root") or ".")).expanduser())
text = instruction
if text is None:
text = str(decision.get("instruction") or decision.get("existing_instruction") or "")
return stable_id(target, scope, normalize_text(text), prefix="C-")
def evidence_ref_scopes(batch: dict[str, Any]) -> dict[str, set[str]]:
scopes = {str(value): set() for value in batch.get("record_ids", []) if str(value).strip()}
def add_ref(value: Any, project_root: Any) -> None:
ref = str(value or "").strip()
if not ref:
return
roots = scopes.setdefault(ref, set())
if isinstance(project_root, str) and project_root.strip():
roots.add(path_key(Path(project_root)))
def add_record(record: dict[str, Any]) -> None:
record_id = str(record.get("record_id") or "").strip()
session_id = str(record.get("session_id") or "").strip()
turn_id = str(record.get("turn_id") or "").strip()
project_root = record.get("project_root")
add_ref(record_id, project_root)
if session_id and turn_id:
add_ref(f"{session_id}:{turn_id}", project_root)
add_ref(f"{session_id}/{turn_id}", project_root)
source = record.get("source") if isinstance(record.get("source"), dict) else {}
for key in ("user_event_refs", "final_event_refs"):
for value in source.get(key, []):
add_ref(value, project_root)
for record in batch.get("records", []):
if isinstance(record, dict):
add_record(record)
for episode in batch.get("feedback_episode_candidates", []):
if not isinstance(episode, dict):
continue
for record in episode.get("context_records", []):
if isinstance(record, dict):
add_record(record)
return scopes
def evidence_ref_record_ids(batch: dict[str, Any]) -> dict[str, set[str]]:
records_by_ref: dict[str, set[str]] = {}
def add_ref(value: Any, record_id: str) -> None:
ref = str(value or "").strip()
if ref and record_id:
records_by_ref.setdefault(ref, set()).add(record_id)
def add_record(record: dict[str, Any]) -> None:
record_id = str(record.get("record_id") or "").strip()
if not record_id:
return
add_ref(record_id, record_id)
session_id = str(record.get("session_id") or "").strip()
turn_id = str(record.get("turn_id") or "").strip()
if session_id and turn_id:
add_ref(f"{session_id}:{turn_id}", record_id)
add_ref(f"{session_id}/{turn_id}", record_id)
source = record.get("source") if isinstance(record.get("source"), dict) else {}
for key in ("user_event_refs", "final_event_refs"):
for value in source.get(key, []):
add_ref(value, record_id)
for record in batch.get("records", []):
if isinstance(record, dict):
add_record(record)
for episode in batch.get("feedback_episode_candidates", []):
if not isinstance(episode, dict):
continue
for record in episode.get("context_records", []):
if isinstance(record, dict):
add_record(record)
for value in batch.get("record_ids", []):
record_id = str(value).strip()
add_ref(record_id, record_id)
return records_by_ref
def apply_state_gates(
decisions: list[dict[str, Any]],
state: dict[str, Any],
mode: str,
ref_scopes: dict[str, set[str]],
explicit_project_roots: set[str] | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
tombstones = state.get("tombstones", {})
rejected = state.get("rejected_candidates", {})
result: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for original in decisions:
decision = dict(original)
action = decision.get("action")
identifier = decision["decision_id"]
if action not in VALID_ACTIONS:
errors.append({"decision_id": identifier, "error": f"invalid action: {action}"})
result.append(decision)
continue
if action in {"add", "merge", "narrow", "replace"}:
fingerprint = decision_fingerprint(decision)
if fingerprint in tombstones and not decision.get("restore_tombstone"):
decision["action"] = "no-op"
decision["explanation"] = "blocked by a scoped tombstone; explicit restore is required"
elif fingerprint in rejected and not decision.get("override_prior_rejection"):
decision["action"] = "no-op"
decision["explanation"] = "equivalent scoped candidate was previously rejected"
else:
decision_evidence = {str(value) for value in decision.get("evidence_refs", [])}
target_scope = str(decision.get("target") or "")
project_scope = path_key(Path(str(decision.get("project_root")))) if target_scope == "project" and decision.get("project_root") else None
def same_scope(entry: Any) -> bool:
if not isinstance(entry, dict) or entry.get("target") != target_scope:
return False
if target_scope == "global":
return True
value = entry.get("project_root")
return isinstance(value, str) and project_scope == path_key(Path(value))
tombstone_match = next(
(
key
for key, entry in tombstones.items()
if same_scope(entry) and decision_evidence.intersection(str(value) for value in entry.get("evidence_refs", []))
),
None,
)
rejection_match = next(
(
key
for key, entry in rejected.items()
if same_scope(entry) and decision_evidence.intersection(str(value) for value in entry.get("evidence_refs", []))
),
None,
)
if tombstone_match and not decision.get("restore_tombstone"):
decision["action"] = "no-op"
decision["explanation"] = "feedback evidence was already withdrawn; explicit restore is required"
elif rejection_match and not decision.get("override_prior_rejection"):
decision["action"] = "no-op"
decision["explanation"] = "feedback evidence already produced a rejected candidate"
action = decision.get("action")
if action in MUTATING_ACTIONS:
evidence_refs = decision.get("evidence_refs")
if not isinstance(evidence_refs, list) or not evidence_refs:
errors.append({"decision_id": identifier, "error": "mutating decision requires at least one evidence_ref"})
else:
unknown_refs = sorted({str(value) for value in evidence_refs if str(value) not in ref_scopes})
if unknown_refs:
errors.append({"decision_id": identifier, "error": f"untrusted evidence_refs: {unknown_refs}"})
if decision.get("target") == "project" and decision.get("project_root"):
target_scope = path_key(Path(str(decision["project_root"])))
wrong_scope = []
if target_scope not in (explicit_project_roots or set()):
wrong_scope = sorted(
{
str(value)
for value in evidence_refs
if not ref_scopes.get(str(value)) or target_scope not in ref_scopes[str(value)]
}
)
if wrong_scope:
errors.append({"decision_id": identifier, "error": f"evidence_refs belong to another project: {wrong_scope}"})
if mode == "auto" and (decision.get("confidence") != "high" or decision.get("risk_of_overconstraint") != "low"):
errors.append(
{
"decision_id": identifier,
"error": "Auto mutation requires confidence=high and risk_of_overconstraint=low",
}
)
if mode == "auto" and decision.get("target") == "global":
evidence_projects = {
scope
for value in (evidence_refs if isinstance(evidence_refs, list) else [])
for scope in ref_scopes.get(str(value), set())
}
if len(evidence_projects) < 2:
errors.append(
{
"decision_id": identifier,
"error": "Auto global mutation requires evidence from two project roots",
}
)
result.append(decision)
return result, errors
def coverage_errors(
decisions: list[dict[str, Any]],
batch: dict[str, Any],
ref_scopes: dict[str, set[str]],
required_record_ids: set[str] | None = None,
) -> list[dict[str, str]]:
errors: list[dict[str, str]] = []
covered_records: set[str] = set()
record_ids = (
required_record_ids
if required_record_ids is not None
else {str(value) for value in batch.get("record_ids", []) if str(value).strip()}
)
records_by_ref = evidence_ref_record_ids(batch)
for decision in decisions:
identifier = str(decision.get("decision_id") or "")
evidence_refs = decision.get("evidence_refs")
if not isinstance(evidence_refs, list) or not evidence_refs:
errors.append({"decision_id": identifier, "error": "every decision requires at least one evidence_ref"})
continue
refs = {str(value) for value in evidence_refs}
unknown_refs = sorted(refs - set(ref_scopes))
if unknown_refs:
errors.append({"decision_id": identifier, "error": f"untrusted evidence_refs: {unknown_refs}"})
for ref in refs:
covered_records.update(records_by_ref.get(ref, set()))
if decision.get("action") not in MUTATING_ACTIONS:
evidence_projects = {scope for ref in refs for scope in ref_scopes.get(ref, set())}
if len(evidence_projects) > 1:
errors.append(
{
"decision_id": identifier,
"error": "non-mutating decision evidence spans multiple projects",
}
)
if decision.get("target") == "project" and decision.get("project_root"):
target_scope = path_key(Path(str(decision["project_root"])))
wrong_scope = sorted(ref for ref in refs if not ref_scopes.get(ref) or target_scope not in ref_scopes[ref])
if wrong_scope:
errors.append(
{
"decision_id": identifier,
"error": f"evidence_refs belong to another project: {wrong_scope}",
}
)
if decision.get("action") == "no-op" and not str(decision.get("explanation") or "").strip():
errors.append({"decision_id": identifier, "error": "no-op decision requires a specific explanation"})
uncovered = sorted(record_ids - covered_records)
if uncovered:
errors.append(
{
"decision_id": "batch-coverage",
"error": f"batch records lack explicit decision coverage: {uncovered}",
}
)
return errors
def edited_acceptance_errors(decisions: list[dict[str, Any]], state: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
superseding = [item for item in decisions if str(item.get("supersedes_decision_id") or "").strip()]
if not superseding:
return False, []
errors: list[dict[str, str]] = []
if len(superseding) != len(decisions):
errors.append({"decision_id": "edited-acceptance", "error": "an edited acceptance plan cannot mix superseding and ordinary decisions"})
return False, errors
pending = state.get("pending_decisions", {}) if isinstance(state.get("pending_decisions"), dict) else {}
seen: set[str] = set()
for decision in decisions:
identifier = str(decision.get("decision_id") or "")
supersedes = str(decision.get("supersedes_decision_id") or "").strip()
prior_entry = pending.get(supersedes)
prior = prior_entry.get("decision") if isinstance(prior_entry, dict) else None
if supersedes in seen:
errors.append({"decision_id": identifier, "error": f"superseded pending decision is reused: {supersedes}"})
seen.add(supersedes)
if not isinstance(prior, dict):
errors.append({"decision_id": identifier, "error": f"superseded decision is not pending: {supersedes}"})
continue
if decision.get("target") != prior.get("target"):
errors.append({"decision_id": identifier, "error": "edited decision target differs from the pending decision"})
if decision.get("target") == "project" and path_key(Path(str(decision.get("project_root") or "."))) != path_key(
Path(str(prior.get("project_root") or "."))
):
errors.append({"decision_id": identifier, "error": "edited decision project differs from the pending decision"})
if not set(map(str, decision.get("evidence_refs", []))).issubset(set(map(str, prior.get("evidence_refs", [])))):
errors.append({"decision_id": identifier, "error": "edited decision introduces evidence not present in the pending decision"})
return not errors, errors
def path_key(path: Path) -> str:
return os.path.normcase(str(path.resolve()))
def target_for(decision: dict[str, Any], allowed_roots: dict[str, Path], home: Path) -> tuple[Path, str, Path | None]:
target = decision.get("target")
if target == "global":
if decision.get("project_root") is not None:
raise ReconcileError("global decision must not contain project_root")
return home / "AGENTS.md", "global", None
if target != "project":
raise ReconcileError(f"invalid target: {target}")
root_value = decision.get("project_root")
if not isinstance(root_value, str) or not root_value.strip():
raise ReconcileError("project decision requires project_root")
requested = Path(root_value).expanduser().resolve()
root = allowed_roots.get(path_key(requested))
if root is None:
raise ReconcileError(f"project root was not derived from trusted session metadata: {requested}")
return root / "AGENTS.md", "project", root
def agents_dirty(root: Path | None) -> bool:
if root is None:
return False
result = subprocess.run(
["git", "-C", str(root), "status", "--porcelain", "--", "AGENTS.md"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
return result.returncode == 0 and bool(result.stdout.strip())
def active_override(target: Path) -> str | None:
override = target.with_name("AGENTS.override.md")
try:
if override.exists() and override.read_text(encoding="utf-8-sig").strip():
return str(override)
except (OSError, UnicodeDecodeError):
return str(override)
return None
def unified_diff(path: Path, old: str, new: str) -> str:
before = old.splitlines(keepends=True)
after = new.splitlines(keepends=True)
return "".join(difflib.unified_diff(before, after, fromfile=f"a/{path.name}", tofile=f"b/{path.name}", n=0))
def reconcile(decisions_value: Any, batch: dict[str, Any], home: Path, mode: str, state: dict[str, Any] | None = None) -> dict[str, Any]:
batch_quota = batch.get("quota_gate")
if batch_quota is not None and batch_quota.get("allowed") is not True:
raise ReconcileError("batch quota gate is blocked or unknown")
if isinstance(decisions_value, dict) and "decisions" not in decisions_value:
raise ReconcileError("decision object must contain a decisions array")
aggregation_state: dict[str, Any] | None = None
if isinstance(decisions_value, dict) and decisions_value.get("review_protocol") == "project-then-global-v1":
pending_candidates = decisions_value.get("pending_global_candidates")
if not isinstance(pending_candidates, dict):
raise ReconcileError("completed global aggregation requires pending_global_candidates")
for identifier, candidate in pending_candidates.items():
if not isinstance(candidate, dict) or str(candidate.get("candidate_id") or "") != str(identifier):
raise ReconcileError("pending global candidate keys must match candidate_id values")
if not str(candidate.get("instruction") or "").strip() or not str(candidate.get("semantic_scope") or "").strip():
raise ReconcileError(f"pending global candidate {identifier} is incomplete")
if not isinstance(candidate.get("sources"), list) or not candidate["sources"]:
raise ReconcileError(f"pending global candidate {identifier} has no sources")
aggregation_state = pending_candidates
raw_decisions = decisions_value.get("decisions") if isinstance(decisions_value, dict) else decisions_value
if not isinstance(raw_decisions, list):
raise ReconcileError("decisions must be a JSON array or an object containing decisions")
if batch.get("record_ids") and not raw_decisions:
raise ReconcileError("a non-empty batch requires at least one explicit decision, including no-op")
decisions = ensure_decision_ids(raw_decisions)
ref_scopes = evidence_ref_scopes(batch)
explicit_project_roots = {path_key(Path(value)) for value in batch.get("explicit_project_roots", [])}
current_state = state or {}
decisions, gate_errors = apply_state_gates(decisions, current_state, mode, ref_scopes, explicit_project_roots)
edited_acceptance, edit_errors = edited_acceptance_errors(decisions, current_state)
records_by_ref = evidence_ref_record_ids(batch)
edited_record_ids = {
record_id
for decision in decisions
for ref in map(str, decision.get("evidence_refs", []))
for record_id in records_by_ref.get(ref, set())
}
batch_record_ids = {str(value) for value in batch.get("record_ids", []) if str(value).strip()}
plan_record_ids = sorted(edited_record_ids & batch_record_ids) if edited_acceptance else list(batch.get("record_ids", []))
allowed_roots = {path_key(Path(value)): Path(value).expanduser().resolve() for value in batch.get("allowed_project_roots", [])}
grouped: dict[str, dict[str, Any]] = {}
decision_errors: list[dict[str, str]] = [
*gate_errors,
*edit_errors,
*coverage_errors(decisions, batch, ref_scopes, set(plan_record_ids)),
]
for decision in decisions:
try:
path, scope, root = target_for(decision, allowed_roots, home)
except ReconcileError as error:
decision_errors.append({"decision_id": decision["decision_id"], "error": str(error)})
continue
if decision.get("action") in {"no-op", "needs_evidence"}:
continue
key = path_key(path)
grouped.setdefault(key, {"path": path, "scope": scope, "project_root": root, "decisions": []})["decisions"].append(decision)
targets: list[dict[str, Any]] = []
for group in grouped.values():
path: Path = group["path"]
try:
raw, current = read_agents(path)
existing_rules, _ = parse_rules(current)
normalized_existing = {
*{normalize_text(rule) for rule in existing_rules},
*normalized_effective_lines(current),
}
if group["scope"] == "project":
global_path = home / "AGENTS.md"
if global_path.exists():
try:
global_text = global_path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError) as error:
raise ReconcileError(f"cannot inspect effective global AGENTS.md: {error}") from error
normalized_existing.update(normalized_effective_lines(global_text))
for decision in group["decisions"]:
if decision.get("action") == "add":
normalized_instruction = normalize_text(str(decision.get("instruction") or ""))
if normalized_instruction in normalized_existing:
decision["action"] = "no-op"
decision["explanation"] = "existing or earlier batch rule already exactly covers this instruction"
else:
normalized_existing.add(normalized_instruction)
active_decisions = [decision for decision in group["decisions"] if decision.get("action") in MUTATING_ACTIONS]
if not active_decisions:
continue
updated, notes = apply_decisions(current, active_decisions)
except (OSError, ReconcileError) as error:
decision_errors.extend({"decision_id": item["decision_id"], "error": str(error)} for item in group["decisions"])
continue
targets.append(
{
"path": str(path.absolute()),
"scope": group["scope"],
"project_root": str(group["project_root"]) if group["project_root"] else None,
"existed": path.exists(),
"base_sha256": sha256_bytes(raw),
"new_sha256": sha256_bytes(encode_agents(updated, raw)),
"diff": unified_diff(path, current, updated),
"changed": current != updated,
"decision_ids": [decision["decision_id"] for decision in active_decisions],
"notes": notes,
"agents_md_dirty": agents_dirty(group["project_root"]) if mode == "suggest" else None,
"inactive_due_to_override": active_override(path),
}
)
assign_selection_numbers(decisions)
plan = {
"version": 1,
"mode": mode,
"codex_home": str(home),
"decisions": decisions,
"record_ids": plan_record_ids,
"edited_acceptance": edited_acceptance,
"trusted_evidence_refs": sorted(ref_scopes),
"evidence_ref_scopes": {ref: sorted(scopes) for ref, scopes in ref_scopes.items()},
"state_guard": {
"tombstones": sorted((state or {}).get("tombstones", {})),
"rejected_candidates": sorted((state or {}).get("rejected_candidates", {})),
"constraint_revision": int((state or {}).get("constraint_revision", 0)),
"scope_revisions": dict((state or {}).get("scope_revisions", {})),
},
"allowed_project_roots": [str(path) for path in allowed_roots.values()],
"explicit_project_roots": sorted(explicit_project_roots),
"targets": targets,
"errors": decision_errors,
}
if aggregation_state is not None:
plan["review_protocol"] = "project-then-global-v1"
plan["pending_global_candidates"] = aggregation_state
return plan
def main() -> int:
parser = argparse.ArgumentParser(description="Reconcile structured memory decisions against managed AGENTS.md blocks and produce a reviewable plan.")
parser.add_argument("--decisions", required=True)
parser.add_argument("--batch", required=True)
parser.add_argument("--codex-home")
parser.add_argument("--state")
parser.add_argument("--mode", choices=("suggest", "auto", "confirmed-suggest"), default="suggest")
parser.add_argument("--model")
parser.add_argument("--reasoning-effort", choices=("low", "medium", "high"), default="high")
parser.add_argument("--quota-result")
parser.add_argument("--output")
args = parser.parse_args()
decisions = load_json(Path(args.decisions).expanduser().resolve(), [])
batch = load_json(Path(args.batch).expanduser().resolve(), {})
state = load_json(Path(args.state).expanduser().resolve(), {}) if args.state else {}
quota = load_json(Path(args.quota_result).expanduser().resolve(), {}) if args.quota_result else None
if quota is not None and quota.get("allowed") is not True:
parser.error("quota result is blocked or unknown; refusing to reconcile")
if quota is not None and args.reasoning_effort != "high":
parser.error("Scheduled runs require --reasoning-effort high")
if batch.get("record_ids") and (
not isinstance(decisions, dict)
or decisions.get("review_protocol") != "project-then-global-v1"
):
parser.error("run aggregate_global_candidates.py prepare and finalize before reconciliation")
try:
result = reconcile(decisions, batch, codex_home(args.codex_home), args.mode, state)
except ReconcileError as error:
parser.error(str(error))
result["run_metadata"] = {
"model": args.model,
"reasoning_effort": args.reasoning_effort,
"quota": quota,
}
write_json_output(result, args.output)
return 0 if not result["errors"] else 2
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: a4e78247e27069bfc90e24740f334daec551ac545539bf1adc50f80b169184ae