mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
7c10303184
## What Adds `--only <skill[,skill,...]>` to `scripts/regen-codex-hashes.sh` and threads it through `scripts/regen-all.sh` (`--skills` arg / `REGEN_SKILLS` env), so an agent can regenerate codex hashes for **only the skills it touched**. ## Why From `.agents/retro/2026-05-31-bead-crank-postmortem.md` §5 (#1-2): the codex hash regen rewrites every drifted manifest entry, sweeping ~8-9 pre-existing drifts into every skill PR and forcing a manual `jq`-splice. Scoping the loop kills that toil. ## Changes - **`regen-codex-hashes.sh`**: `--only` parses a skill set; the per-skill loop `continue`s when a skill isn't in the set. `--check` honors the same scope. Default (no flag) = all skills, unchanged. `SKILLS_ROOT` is now env-overridable (enables hermetic testing). - **`regen-all.sh`**: `--skills a,b` / `REGEN_SKILLS=a,b` threads `--only` into the codex-hash step in both regen and `--check` modes; trailing NOTE instructs passing scope when only some skills changed. - **`tests/scripts/regen-codex-hashes-only.bats`**: hermetic — `--only foo` regenerates only foo and leaves a simulated pre-existing-drift sibling untouched; no-`--only` regenerates all; `--check` scoping; comma-list; arg-required. ## Verification - `bats tests/scripts/regen-codex-hashes-only.bats` — 5/5 pass - `shellcheck -S warning` clean on both scripts - `git diff origin/main --stat` scope-only (2 scripts + 1 bats) - The 9 manifest drifts on `main` (deps, post-mortem, provenance, red-team, release, scenario, ship-loop, skill-builder, trace) are **pre-existing**; this PR touches zero `skills-codex/` files. Closes-scenario: ag-fbe9#regen-scoping Bounded-context: BC2-Validation Evidence: scripts/regen-codex-hashes.sh
160 lines
5.3 KiB
Bash
Executable File
160 lines
5.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Regenerate generated_hash values in skills-codex manifest and markers.
|
|
# Run after any change to skills-codex/ files to fix artifact metadata drift.
|
|
#
|
|
# Usage:
|
|
# scripts/regen-codex-hashes.sh # update all drifted hashes
|
|
# scripts/regen-codex-hashes.sh --check # dry-run: report drift without fixing
|
|
# scripts/regen-codex-hashes.sh --only foo,bar # only touch skills foo and bar
|
|
#
|
|
# --only scopes the per-skill loop to the named skills (comma- and/or
|
|
# repeat-separated). Skills outside the set are skipped entirely, so a PR that
|
|
# changes one skill no longer sweeps unrelated pre-existing hash drift into its
|
|
# diff. Combine with --check to scope the drift report the same way.
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
SKILLS_ROOT="${SKILLS_ROOT:-$REPO_ROOT/skills-codex}"
|
|
CHECK_ONLY=false
|
|
ONLY_SKILLS=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--check) CHECK_ONLY=true ;;
|
|
--only)
|
|
shift
|
|
[[ $# -gt 0 ]] || { echo "--only requires a skill list" >&2; exit 2; }
|
|
ONLY_SKILLS="${ONLY_SKILLS:+$ONLY_SKILLS,}$1"
|
|
;;
|
|
--only=*) ONLY_SKILLS="${ONLY_SKILLS:+$ONLY_SKILLS,}${1#--only=}" ;;
|
|
-h|--help)
|
|
echo "Usage: scripts/regen-codex-hashes.sh [--check] [--only <skill[,skill,...]>]"
|
|
echo " --check Report drift without fixing"
|
|
echo " --only <skill,...> Limit to the named skills (scope a single-skill PR)"
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown arg: $1" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
[[ -d "$SKILLS_ROOT" ]] || {
|
|
echo "skills-codex root not found: $SKILLS_ROOT" >&2
|
|
exit 1
|
|
}
|
|
|
|
export SKILLS_ROOT CHECK_ONLY ONLY_SKILLS
|
|
python3 - <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
skills_root = pathlib.Path(os.environ["SKILLS_ROOT"]).resolve()
|
|
check_only = os.environ.get("CHECK_ONLY") == "true"
|
|
scope = {s for s in os.environ.get("ONLY_SKILLS", "").split(",") if s.strip()}
|
|
manifest_path = skills_root / ".agentops-manifest.json"
|
|
marker_name = ".agentops-generated.json"
|
|
|
|
if not manifest_path.exists():
|
|
print(f"Codex artifact manifest missing: {manifest_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
entries = manifest.get("skills", [])
|
|
entry_by_name = {entry.get("name"): entry for entry in entries if entry.get("name")}
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def sha256_file(path: pathlib.Path) -> str:
|
|
return sha256_bytes(path.read_bytes())
|
|
|
|
|
|
def hash_tree(root: pathlib.Path) -> str:
|
|
rows = []
|
|
for path in sorted(p for p in root.rglob("*") if p.is_file()):
|
|
if path.name in {".agentops-manifest.json", marker_name, ".DS_Store"}:
|
|
continue
|
|
if "__pycache__" in path.parts:
|
|
continue
|
|
if path.suffix == ".pyc":
|
|
continue
|
|
rel = path.relative_to(root).as_posix()
|
|
rows.append(f"{rel}\t{sha256_file(path)}\n")
|
|
return sha256_bytes("".join(rows).encode("utf-8"))
|
|
|
|
|
|
repo_root = skills_root.parent
|
|
source_root = repo_root / "skills"
|
|
|
|
updated = []
|
|
for skill_dir in sorted(p for p in skills_root.iterdir() if p.is_dir()):
|
|
if not (skill_dir / "SKILL.md").exists():
|
|
continue
|
|
|
|
name = skill_dir.name
|
|
if scope and name not in scope:
|
|
continue
|
|
new_hash = hash_tree(skill_dir)
|
|
|
|
# Source-side hash: tree-hash skills/<name>/ if it exists. A codex skill
|
|
# without a source twin (rare; pure-codex skill) keeps source_hash empty.
|
|
source_dir = source_root / name
|
|
new_source_hash = hash_tree(source_dir) if source_dir.is_dir() and (source_dir / "SKILL.md").exists() else ""
|
|
|
|
changed = False
|
|
|
|
# Check/update manifest entry (both generated_hash AND source_hash)
|
|
if name in entry_by_name:
|
|
entry = entry_by_name[name]
|
|
if entry.get("generated_hash") != new_hash:
|
|
changed = True
|
|
if not check_only:
|
|
entry["generated_hash"] = new_hash
|
|
if new_source_hash and entry.get("source_hash") != new_source_hash:
|
|
changed = True
|
|
if not check_only:
|
|
entry["source_hash"] = new_source_hash
|
|
|
|
# Check/update marker (both generated_hash AND source_hash)
|
|
marker_path = skill_dir / marker_name
|
|
if marker_path.exists():
|
|
marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
marker_changed = False
|
|
if marker.get("generated_hash") != new_hash:
|
|
marker_changed = True
|
|
if not check_only:
|
|
marker["generated_hash"] = new_hash
|
|
if new_source_hash and marker.get("source_hash") != new_source_hash:
|
|
marker_changed = True
|
|
if not check_only:
|
|
marker["source_hash"] = new_source_hash
|
|
if marker_changed:
|
|
changed = True
|
|
if not check_only:
|
|
marker_path.write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
|
|
|
|
if changed:
|
|
updated.append(name)
|
|
|
|
if not check_only:
|
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
|
|
if updated:
|
|
verb = "Drifted" if check_only else "Updated"
|
|
print(f"{verb} hashes for {len(updated)} skill(s): {', '.join(updated)}")
|
|
if check_only:
|
|
sys.exit(1)
|
|
else:
|
|
print("All hashes up to date.")
|
|
PY
|