Files
boshu2__agentops/scripts/codex-sync.sh
boshu e4dbef98f6 Collapse AgentOps install to ao skills link
Retire the 3.x plugin curl/PowerShell installers as tombstones, delete the
plugin-cache helpers, and rewire CI/doctor/conformance onto the canonical
checkout + ao skills link path. Keep install-bd and the opt-in operator scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 17:04:55 -04:00

696 lines
28 KiB
Bash
Executable File

#!/usr/bin/env bash
# codex-sync.sh — generate parity_only Codex twins from their source skills.
#
# A parity_only twin is a SELF-CONTAINED runtime artifact derived from its
# source skill. The Codex runtime ships skills-codex/ ONLY (never skills/ source
# Codex may still consume a generated skills-codex projection for archive/
# marketplace artifacts. Live installs use `ao skills link` into runtime skill
# roots — not a plugin-cache installer.
# twin must carry its own body + references; a bare pointer to skills/<name>
# would dangle at runtime (docs/contracts/codex-skill-api.md). The generated twin is therefore:
# - SKILL.md: slim (name + description) frontmatter + the source body
# transformed runtime-native (slash-command invocations of known skills ->
# `$` prefix, ~/.claude -> ~/.codex, "Claude Code" -> "Codex");
# - references/ + scripts/: copied byte-identical (lint scans only SKILL.md);
# - prompt.md: the standard codex pointer-to-sibling-SKILL.md template,
# optionally plus catalog-declared operator-contract markers.
# Because the twin is GENERATED from source, source edits never require a hand
# mirror — re-running this (via regen-all) reproduces a correct twin, killing the
# "add/touch a skill -> chase ~5 codex gates serially" whack-a-mole (regen-all.sh
# historically only rehashed EXISTING twins; it could not author one).
#
# This generator authors the COMPLETE twin for any source skill that lacks one:
# the body files + references, the per-skill marker, and all three catalog
# surfaces (manifest .skills[], manifest .codex_override_catalog.skills[], and
# skills-codex-overrides/catalog.json .skills[]), then fixes every hash. It is
# idempotent: a source skill that already matches the generated parity form is
# left untouched; a complete but stale parity twin is refreshed from source.
#
# bespoke twins (hand-authored Codex profiles) are the opt-out: they are never
# generated or overwritten — body AND references/scripts are hand-maintained, so
# even --force skips them. Source reference/body edits do NOT auto-propagate to a
# bespoke twin (many bespoke references are deliberate Codex rewrites of source);
# refreshing one is a deliberate human edit. Auto-mirroring source over a bespoke
# twin would clobber the hand-authored copy (age-0js4). Accidental drift is the
# divergence gate's job (age-odv), not this generator's.
#
# Usage:
# scripts/codex-sync.sh # generate any missing parity twin (writes)
# scripts/codex-sync.sh --check # report missing/incomplete twins; exit 1 on drift (no writes)
# scripts/codex-sync.sh --only a,b # scope to skills a and b
# scripts/codex-sync.sh <name> # scope to a single skill
#
# Wired into scripts/regen-all.sh ahead of the codex-hash step.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHECK_ONLY=false
FORCE=false
ONLY=""
while [[ $# -gt 0 ]]; do
case "$1" in
--check) CHECK_ONLY=true; shift ;;
--force) FORCE=true; shift ;;
--only) ONLY="$2"; shift 2 ;;
-h|--help)
sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0 ;;
-*) echo "Unknown flag: $1" >&2; exit 2 ;;
*) ONLY="$1"; shift ;;
esac
done
# --force regenerates EXISTING twins (overwrite body + exact-mirror references/
# scripts from source). It must be scoped (--only / a skill name) so it cannot
# silently clobber the ~75 existing hand-tended twins in one shot.
if [[ "$FORCE" == "true" && -z "$ONLY" ]]; then
echo "Refusing --force without scope: pass --only <skill[,...]> or a skill name." >&2
echo "(--force rewrites existing twins from source; an unscoped run would clobber all of them.)" >&2
exit 2
fi
export ROOT CHECK_ONLY FORCE ONLY
python3 - <<'PY'
import hashlib
import json
import os
import pathlib
import shutil
import sys
import yaml
root = pathlib.Path(os.environ["ROOT"]).resolve()
check_only = os.environ.get("CHECK_ONLY") == "true"
force = os.environ.get("FORCE") == "true"
scope = {s.strip() for s in os.environ.get("ONLY", "").split(",") if s.strip()}
source_root = root / "skills"
codex_root = root / "skills-codex"
manifest_path = codex_root / ".agentops-manifest.json"
overrides_catalog_path = root / "skills-codex-overrides" / "catalog.json"
marker_name = ".agentops-generated.json"
if not manifest_path.exists():
print(f"FATAL: missing manifest {manifest_path}", file=sys.stderr)
sys.exit(1)
if not overrides_catalog_path.exists():
print(f"FATAL: missing overrides catalog {overrides_catalog_path}", file=sys.stderr)
sys.exit(1)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
overrides_catalog = json.loads(overrides_catalog_path.read_text(encoding="utf-8"))
manifest_skills = manifest.setdefault("skills", [])
manifest_catalog = manifest.setdefault("codex_override_catalog", {})
manifest_catalog_skills = manifest_catalog.setdefault("skills", [])
overrides_skills = overrides_catalog.setdefault("skills", [])
# bespoke = the opt-out set (never generate/overwrite). Read from both catalogs.
bespoke = {
e.get("name")
for e in (manifest_catalog_skills + overrides_skills)
if e.get("treatment") == "bespoke"
}
# excluded = the drop-the-twin set (age-focus-membrane-bookkeeper-m1wg.19). A
# spine-excluded source skill (e.g. a corpus-flywheel skill demoted to the
# experimental tier) ships NO Codex twin: the skills-codex/<name>/ dir is deleted
# and MUST NOT be regenerated. Like bespoke it is skipped entirely — never
# generated, never checked, never restained — but unlike bespoke there is no twin
# on disk at all. The catalog keeps the entry (treatment: excluded) so
# validate-codex-override-coverage.sh treats the source skill as covered-by-
# exclusion rather than "missing from Codex catalog". Read from both catalogs.
excluded = {
e.get("name")
for e in (manifest_catalog_skills + overrides_skills)
if e.get("treatment") == "excluded"
}
# Cross-runtime skills: exempt from the Claude->Codex / ~/.claude->~/.codex body
# rewrites (they legitimately document non-Codex runtimes). Single source of truth
# shared with the gates: scripts/lint/codex-cross-runtime-skills.txt.
cross_runtime_path = root / "scripts" / "lint" / "codex-cross-runtime-skills.txt"
cross_runtime = set()
if cross_runtime_path.exists():
for line in cross_runtime_path.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s and not s.startswith("#"):
cross_runtime.add(s)
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def hash_tree_with(root_dir: pathlib.Path, overlay: dict[str, bytes]) -> str:
"""Tree-hash of a skill dir, with `overlay` (relpath -> bytes) substituted
in. Mirrors regen-codex-hashes.sh hash_tree exactly (excludes manifest,
marker, .DS_Store, __pycache__, *.pyc)."""
files: dict[str, bytes] = {}
if root_dir.is_dir():
for path in root_dir.rglob("*"):
if not path.is_file():
continue
if path.name in {".agentops-manifest.json", marker_name, ".DS_Store"}:
continue
if "__pycache__" in path.parts or path.suffix == ".pyc":
continue
files[path.relative_to(root_dir).as_posix()] = path.read_bytes()
files.update(overlay)
rows = [f"{rel}\t{sha256_bytes(data)}\n" for rel, data in sorted(files.items())]
return sha256_bytes("".join(rows).encode("utf-8"))
def parse_frontmatter(skill_md: pathlib.Path) -> dict:
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---"):
return {}
parts = text.split("---", 2)
if len(parts) < 3:
return {}
return yaml.safe_load(parts[1]) or {}
def split_frontmatter(skill_md: pathlib.Path) -> str:
"""Return the markdown BODY of a SKILL.md (everything after the leading
--- ... --- frontmatter block)."""
text = skill_md.read_text(encoding="utf-8")
if not text.startswith("---"):
return text
parts = text.split("---", 2)
return parts[2].lstrip("\n") if len(parts) >= 3 else text
def transform_body(body: str, known_skills: set[str], exempt: bool = False) -> str:
"""Make a source skill body runtime-native for Codex (the lint-codex-native
contract): slash-command invocations of KNOWN skills -> `$` prefix, Claude
paths -> Codex paths, "Claude Code" -> "Codex". References are copied
byte-identical (lint scans only SKILL.md), so only the body is transformed.
exempt=True (cross-runtime skill, see scripts/lint/codex-cross-runtime-skills.txt):
apply ONLY the slash->$ rewrite (Codex execution syntax is universal) and
PRESERVE runtime names/paths verbatim — the twin legitimately documents
Claude/AGY/etc., so rewriting "Claude Code"->"Codex" or ~/.claude->~/.codex
would make it inaccurate."""
import re
# Skill(skill="known", args="...") -> $known ... for declarative skill
# invocations in source skills. Preserve args when present so inline examples
# remain actionable in Codex.
known_alt = "|".join(re.escape(skill) for skill in sorted(known_skills, key=len, reverse=True))
if known_alt:
def repl_skill_call(match: re.Match) -> str:
skill = match.group(1)
args = match.group(2)
return f"${skill}{(' ' + args) if args else ''}"
body = re.sub(
rf'Skill\(skill="({known_alt})"(?:,\s*args="([^"]*)")?\)',
repl_skill_call,
body,
)
# /<known-skill> -> $<known-skill> for slash-COMMAND invocations only — never
# a path segment. Longest names first (so /premortem wins over /pre). Exclude
# when preceded by a path char (word/./-/_/slash, e.g. ../research/, foo/plan)
# or followed by '/' (a path like /research/SKILL.md), so markdown links and
# file paths are left intact (the bug that turned ../foo/ into ..$foo/).
for skill in sorted(known_skills, key=len, reverse=True):
body = re.sub(rf"(?<![\w./_-])/{re.escape(skill)}\b(?!/)", f"${skill}", body)
body = re.sub(r"(?<![\w./_-])/skill\b(?!/)", "$skill", body)
if exempt:
return body
body = body.replace("~/.claude/", "~/.codex/").replace("~/.claude", "~/.codex")
body = body.replace(".claude/", ".codex/")
body = body.replace("Claude Code", "Codex")
return body
def render_operator_contract_block(name: str, operator_contract: dict | None) -> str:
if not operator_contract:
return ""
sections = operator_contract.get("required_sections") or []
markers = operator_contract.get("required_markers") or []
if not sections or not markers:
return ""
out: list[str] = [
"<!-- BEGIN AGENTOPS OPERATOR CONTRACT -->",
f"<!-- Generated from skills-codex-overrides/catalog.json for {name}. -->",
"",
]
marker_index = 0
remaining_markers = len(markers)
section_count = len(sections)
for section_index, section in enumerate(sections):
out.extend([str(section), ""])
sections_left = section_count - section_index
if remaining_markers == 0:
count = 0
elif sections_left == 1:
count = remaining_markers
else:
count = remaining_markers - (sections_left - 1)
if count < 1:
count = 1
for bullet_index in range(count):
out.append(f"{bullet_index + 1}. {markers[marker_index]}")
marker_index += 1
remaining_markers -= 1
if section_index < section_count - 1:
out.append("")
out.extend(["", "<!-- END AGENTOPS OPERATOR CONTRACT -->"])
return "\n".join(out)
def needs_codex_start_guard(operator_contract: dict | None) -> bool:
markers = (operator_contract or {}).get("required_markers") or []
return any("ao codex ensure-start" in str(marker) for marker in markers)
def insert_after_h1(body: str, block: str) -> str:
if not block:
return body
first_newline = body.find("\n")
if body.startswith("# ") and first_newline != -1:
title = body[: first_newline + 1]
rest = body[first_newline + 1 :].lstrip("\n")
return f"{title}\n{block.rstrip()}\n\n{rest}"
return f"{block.rstrip()}\n\n{body}"
def codex_start_guard_block() -> str:
return """## Codex Lifecycle Guard
When this skill runs in Codex hookless mode (`CODEX_THREAD_ID` is set or
`CODEX_INTERNAL_ORIGINATOR_OVERRIDE` is `Codex Desktop`), run:
```bash
ao codex ensure-start 2>/dev/null || true
```
The CLI records startup once per thread and skips duplicates automatically."""
def codex_catalog_description(name: str, source_description: str) -> str:
"""Terse but skill-specific always-loaded Codex catalog text.
The full source description remains available in prompt.md; SKILL.md
frontmatter is loaded as an activation catalog, so keep enough routing
signal to choose the skill while staying under the average token budget.
"""
import re
desc = re.sub(r"\s+[Tt]riggers?:.*", "", source_description).strip()
desc = re.sub(r"\s+", " ", desc).strip(" '\"")
if not desc:
return f"Run {name}."
max_chars = 44
if len(desc) <= max_chars:
return desc
shortened = desc[: max_chars + 1].rsplit(" ", 1)[0].strip(" ,;:-")
if len(shortened) < 18:
shortened = desc[:max_chars].rstrip(" ,;:-")
return shortened
def twin_skill_md(
name: str,
description: str,
source_body: str,
known_skills: set[str],
exempt: bool = False,
operator_contract: dict | None = None,
) -> bytes:
"""A self-contained Codex twin: slim (name + terse catalog description)
frontmatter + the source body transformed runtime-native. Self-contained
because the Codex runtime ships skills-codex/ ONLY (never skills/ source) —
a twin must carry its own body + references (docs/contracts/codex-skill-api.md)."""
fm = {"name": name, "description": description}
front = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True, width=10_000).strip()
body = transform_body(source_body, known_skills, exempt)
if needs_codex_start_guard(operator_contract) and "ao codex ensure-start" not in body:
body = insert_after_h1(body, codex_start_guard_block())
return f"---\n{front}\n---\n{body.rstrip()}\n".encode("utf-8")
def twin_prompt_md(
name: str, description: str, operator_contract: dict | None = None
) -> bytes:
prompt = (
f"# {name}\n\n"
f"{description}\n\n"
f"## Instructions\n\n"
f"Load and follow the skill instructions from the sibling `SKILL.md` file "
f"for this skill.\n"
f"Then read local files in `references/` and `scripts/` when needed.\n"
)
contract = render_operator_contract_block(name, operator_contract)
if contract:
prompt = f"{prompt.rstrip()}\n\n\n{contract}\n"
return prompt.encode("utf-8")
def upsert(entries: list, name: str, entry: dict) -> bool:
"""Insert or replace by name, keeping EXACTLY ONE row per name. Returns True
if the list changed. Replaces in place (no global re-sort) to keep the diff
minimal — matches the append-only behavior of register-new-codex-skill.sh
and avoids reordering the existing catalog on every new skill. Any later
duplicate rows for the same name are dropped: historical syncs updated one
row of a duplicated pair in place, so drift was masked or misreported
depending on which row a reader's name-keyed dict happened to keep."""
replaced_at = None
removed = False
for i in range(len(entries) - 1, -1, -1):
if entries[i].get("name") != name:
continue
if replaced_at is None:
replaced_at = i
else:
# entries[i] is an EARLIER duplicate (we scan backwards): keep the
# first position for the row, drop the later one.
entries[replaced_at : replaced_at + 1] = []
replaced_at = i
removed = True
if replaced_at is None:
entries.append(entry)
return True
if not removed and entries[replaced_at] == entry:
return False
entries[replaced_at] = entry
return True
# Discover source skills (ground truth), skip non-skill dirs and bespoke.
def mirror_reasons(src_dir: pathlib.Path, twin_dir: pathlib.Path) -> list[str]:
"""Drift reasons for a twin's mirrored content (references/scripts/fixtures/
etc.) vs source — missing, stale (content mismatch), or extra files. SKILL.md
is transformed (verified separately); prompt.md + marker are twin-only."""
twin_only = {"SKILL.md", "prompt.md", marker_name, ".agentops-manifest.json", ".DS_Store"}
def tree(root_dir: pathlib.Path) -> dict[str, bytes]:
out: dict[str, bytes] = {}
if not root_dir.is_dir():
return out
for p in root_dir.rglob("*"):
if not p.is_file() or p.name in twin_only:
continue
if "__pycache__" in p.parts or p.suffix == ".pyc":
continue
out[p.relative_to(root_dir).as_posix()] = p.read_bytes()
return out
src = tree(src_dir)
twin = tree(twin_dir)
reasons = [f"missing {r}" for r in src if r not in twin]
reasons += [f"stale {r}" for r in src if r in twin and twin[r] != src[r]]
reasons += [f"extra {r}" for r in twin if r not in src]
return reasons
def exact_mirror_source_payload(src_dir: pathlib.Path, twin_dir: pathlib.Path) -> None:
"""Exact-copy source sibling content into a parity twin, excluding SKILL.md
and twin-only bookkeeping. This keeps references/scripts/fixtures generated
from source and removes stale copied files when source deletes them."""
twin_only = {"SKILL.md", "prompt.md", marker_name, ".agentops-manifest.json", ".DS_Store"}
source_names = {
entry.name
for entry in src_dir.iterdir()
if entry.name not in {"SKILL.md", ".agentops-manifest.json", marker_name, ".DS_Store"}
}
for entry in sorted(twin_dir.iterdir()):
if entry.name in twin_only:
continue
if entry.name not in source_names:
shutil.rmtree(entry) if entry.is_dir() else entry.unlink()
for entry in sorted(src_dir.iterdir()):
if entry.name in {"SKILL.md", ".agentops-manifest.json", marker_name, ".DS_Store"}:
continue
dst_entry = twin_dir / entry.name
if dst_entry.exists():
shutil.rmtree(dst_entry) if dst_entry.is_dir() else dst_entry.unlink()
shutil.copytree(entry, dst_entry) if entry.is_dir() else shutil.copy2(entry, dst_entry)
source_skills = sorted(
p.name
for p in source_root.iterdir()
if p.is_dir()
and not p.name.startswith("_")
and (p / "SKILL.md").exists()
)
known_skills = set(source_skills)
drift = []
generated = []
# Source metadata owns the installed set. Retired source roots must not leave
# empty directories, stale generated twins, or override rows that continue to
# advertise removed skills.
retired_twin_dirs = sorted(
p for p in codex_root.iterdir()
if p.is_dir() and not p.name.startswith("_") and p.name not in known_skills
)
if check_only:
drift.extend((p.name, ["retired twin directory remains"]) for p in retired_twin_dirs)
else:
for path in retired_twin_dirs:
shutil.rmtree(path)
overrides_skills[:] = [
entry for entry in overrides_skills if entry.get("name") in known_skills
]
for name in source_skills:
if name in bespoke:
continue
if name in excluded:
# Spine-excluded: the twin was intentionally dropped; never regenerate it.
continue
if scope and name not in scope:
continue
twin_dir = codex_root / name
skill_md = twin_dir / "SKILL.md"
prompt_md = twin_dir / "prompt.md"
marker_path = twin_dir / marker_name
catalog_source_entry = next((e for e in overrides_skills if e.get("name") == name), {})
operator_contract = catalog_source_entry.get("operator_contract")
fm = parse_frontmatter(source_root / name / "SKILL.md")
source_description = str(fm.get("description", "")).strip()
codex_description = codex_catalog_description(name, source_description)
source_body = split_frontmatter(source_root / name / "SKILL.md")
desired_skill = twin_skill_md(
name,
codex_description,
source_body,
known_skills,
name in cross_runtime,
operator_contract,
)
desired_prompt = twin_prompt_md(name, source_description, operator_contract)
# A twin is "complete" iff its body files + marker exist AND it is registered
# in the gate-enforced 1:1 surface (skills-codex-overrides/catalog.json — the
# surface validate-codex-override-coverage.sh holds to source skills exactly).
# Complete parity twins are still checked against the generated shape below;
# stale generated bodies, prompts, or mirrored references are refreshed.
# Bespoke twins opt out via the catalog and are skipped before this point.
# The bloated manifest .codex_override_catalog is downstream and is NOT a
# generation trigger.
in_ocat = any(e.get("name") == name for e in overrides_skills)
if check_only:
# THE single drift gate for parity twins: the on-disk twin must EXACTLY
# match what the generator would emit — presence + registration +
# byte-identical transformed SKILL.md + generated prompt.md + mirrored
# references. Any drift forces a regen. This guarantee is what lets the
# content validators (lint-native, api-conformance, runtime-sections,
# audit-parity) skip parity twins entirely: a generated artifact is
# verified by regenerate-and-diff, not by re-checking content rules.
reasons = []
if not in_ocat:
reasons.append("unregistered in catalog.json")
if not marker_path.exists():
reasons.append("missing marker")
if not skill_md.exists() or skill_md.read_bytes() != desired_skill:
reasons.append("SKILL.md")
if not prompt_md.exists() or prompt_md.read_bytes() != desired_prompt:
reasons.append("prompt.md")
reasons += mirror_reasons(source_root / name, twin_dir)
if reasons:
drift.append((name, reasons))
continue
regen_reasons = []
if not in_ocat:
regen_reasons.append("unregistered in catalog.json")
if not marker_path.exists():
regen_reasons.append("missing marker")
if not skill_md.exists() or skill_md.read_bytes() != desired_skill:
regen_reasons.append("SKILL.md")
if not prompt_md.exists() or prompt_md.read_bytes() != desired_prompt:
regen_reasons.append("prompt.md")
regen_reasons += mirror_reasons(source_root / name, twin_dir)
if not force and not regen_reasons:
continue
# --- Author/refresh the parity twin (self-contained: body + references) ---
twin_dir.mkdir(parents=True, exist_ok=True)
if force or not skill_md.exists() or skill_md.read_bytes() != desired_skill:
skill_md.write_bytes(desired_skill)
if force or not prompt_md.exists() or prompt_md.read_bytes() != desired_prompt:
prompt_md.write_bytes(desired_prompt)
# Mirror ALL source content (references/, scripts/, fixtures/, templates/,
# agents/, any sibling files) EXCEPT SKILL.md — byte-identical, so every link
# in the body resolves and the Codex runtime artifact is fully self-contained.
# lint scans only SKILL.md, so copied content needs no transform.
exact_mirror_source_payload(source_root / name, twin_dir)
source_hash = hash_tree_with(source_root / name, {})
generated_hash = hash_tree_with(twin_dir, {})
marker_path.write_text(
json.dumps(
{
"generator": "codex-sync",
"source_skill": f"skills/{name}",
"layout": "modular",
"source_hash": source_hash,
"generated_hash": generated_hash,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
upsert(
manifest_skills,
name,
{
"name": name,
"source_skill": f"skills/{name}",
"source_hash": source_hash,
"generated_hash": generated_hash,
},
)
catalog_entry = {
"name": name,
"treatment": "parity_only",
"wave": "catalog-parity",
"reason": (
f"Auto-generated parity twin (codex-sync): skills/{name} is the source "
f"of truth; no durable Codex-specific divergence yet."
),
}
# Catalog entries are ADD-ONLY: an already-registered skill keeps its existing
# (often hand-written) reason/wave in the authoritative
# skills-codex-overrides/catalog.json. The manifest embeds that catalog for
# the shipped runtime artifact, so mirror the authoritative entry for skills
# touched by this generator.
if catalog_source_entry:
upsert(manifest_catalog_skills, name, catalog_source_entry)
elif not any(e.get("name") == name for e in manifest_catalog_skills):
manifest_catalog_skills.append(catalog_entry)
if not any(e.get("name") == name for e in overrides_skills):
overrides_skills.append(catalog_entry)
generated.append(name)
# Rebuild the runtime artifact inventory from the actual generated directories.
# Source retirement can delete a twin before this generator runs; an append-only
# manifest would otherwise retain a ghost row forever. The embedded treatment
# catalog is likewise a projection of the authoritative overrides catalog, not
# a second hand-maintained store.
desired_manifest_skills = []
existing_manifest_by_name = {
entry.get("name"): entry for entry in manifest_skills if entry.get("name")
}
package_count = sum(
1
for package_dir in codex_root.iterdir()
if package_dir.is_dir() and (package_dir / "SKILL.md").exists()
)
for twin_dir in sorted(
p
for p in codex_root.iterdir()
if p.is_dir()
and (p / "SKILL.md").exists()
):
marker_path = twin_dir / marker_name
if not marker_path.exists():
continue # the manifest validator reports the missing marker fail-closed
marker = json.loads(marker_path.read_text(encoding="utf-8"))
entry = dict(existing_manifest_by_name.get(twin_dir.name, {}))
entry.update(
name=twin_dir.name,
source_skill=marker.get("source_skill", f"skills/{twin_dir.name}"),
source_hash=marker.get("source_hash", ""),
generated_hash=marker.get("generated_hash", ""),
)
desired_manifest_skills.append(entry)
manifest_inventory_drift = manifest_skills != desired_manifest_skills
embedded_catalog_drift = manifest_catalog_skills != overrides_skills
package_count_drift = manifest.get("package_count") != package_count
if check_only:
if manifest_inventory_drift:
drift.append(("<manifest>", ["artifact inventory differs from skills-codex directories"]))
if embedded_catalog_drift:
drift.append(("<manifest-catalog>", ["embedded treatment catalog differs from authoritative overrides catalog"]))
if package_count_drift:
drift.append(("<manifest>", [f"package_count differs from {package_count} installable skill directories"]))
if drift:
print(f"codex-sync drift: {len(drift)} parity twin(s) differ from generator output:")
for n, reasons in drift:
print(f" - {n}: {', '.join(reasons)}")
print("Fix: scripts/codex-sync.sh --force --only <name> (then regen hashes).")
sys.exit(1)
print("codex-sync: all parity twins match generator output.")
sys.exit(0)
manifest_skills[:] = desired_manifest_skills
manifest_catalog_skills[:] = [dict(entry) for entry in overrides_skills]
manifest["package_count"] = package_count
if generated or manifest_inventory_drift or embedded_catalog_drift or package_count_drift:
# Recompute the embedded catalog hash (same algorithm as
# register-new-codex-skill.sh) so the manifest catalog stays self-consistent.
catalog_for_hash = json.dumps(
{k: v for k, v in manifest_catalog.items() if k != "skills"}
| {"skills": manifest_catalog_skills},
sort_keys=True,
).encode("utf-8")
manifest["codex_override_catalog_hash"] = sha256_bytes(catalog_for_hash)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
overrides_catalog_path.write_text(
json.dumps(overrides_catalog, indent=2) + "\n", encoding="utf-8"
)
if generated:
print(f"codex-sync: generated {len(generated)} twin(s): {', '.join(generated)}")
else:
print("codex-sync: refreshed manifest projections")
else:
print("codex-sync: nothing to generate (all parity twins present).")
PY