diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcc8e68abb..74063c5cfef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -398,6 +398,17 @@ from or pin to; the template keeps serving the latest `main` to new forks. ### Fixed +- **`aeon-update` syncs again on Claude Code 2.1.301.** The skill described its compare, + 3-way classify and apply steps as shell snippets (helper functions, per-file loops, redirects + into files), and Claude Code's permission layer refuses each of those shapes. The model then + wrote its own classify script and ran it with `bash`, which is refused too, so miroshark-aeon's + runs ended green with nothing synced. `scripts/aeon_update.py` now does that work in two + allowed commands: `plan` (compare, partition, 3-way classify, conflict diffs) and `apply` + (write, pack-registry check, eyebrow lock scan or fail-safe, glyphs, catalogs, + validate-config, pending conflicts, state file). It diffs upstream's two trees, so the + 300-file compare cap no longer truncates a large sync. The skill's remaining steps are single + commands. Tests: `scripts/tests/test_aeon_update.py`. + - **Dashboard schedules respect your time zone:** the picker carries the weekday across midnight and handles half-hour and 45-minute zones (India, Nepal), and `PATCH /api/skills` refuses a malformed cron with the reason, using the same rules as `validate-config.js`, instead of saving diff --git a/eyebrowlock.json b/eyebrowlock.json index 67098135a00..b7bbcb345b8 100644 --- a/eyebrowlock.json +++ b/eyebrowlock.json @@ -2632,7 +2632,7 @@ "files": [ { "path": "SKILL.md", - "hash": "adfa693922e114982369ec459f17bbd9f81565878556bed0d90803bc3b7cccfc" + "hash": "12a46d15241393c9e8e086dba3f1bb1abc5aaa6c4406e9c8160024f3a495f1d1" } ], "findings": [ @@ -2641,12 +2641,12 @@ "severity": "high", "owasp": "ASK-06", "file": "SKILL.md", - "line": 100, + "line": 105, "snippet": "output/** .mcp.json .env* aeon.db", "explanation": "references sensitive credential or secret paths" } ], - "contentHash": "sha256-8ccbfe3a2e361876314154777eb3ed4b4a7a515a3c57126d2aac11eeacd9f28f", + "contentHash": "sha256-fead703d79cbd78a27855b2ad567ffac6af3ace93470055bc9e32d45c26458a5", "discoveredFrom": "skills/aeon-update/SKILL.md" }, { diff --git a/scripts/aeon_update.py b/scripts/aeon_update.py new file mode 100755 index 00000000000..40fc382f075 --- /dev/null +++ b/scripts/aeon_update.py @@ -0,0 +1,840 @@ +#!/usr/bin/env python3 +""" +aeon_update - the mechanical half of the aeon-update skill (S4-S8). + +The skill used to spell these steps out as shell snippets: helper functions, +per-file loops, redirects into /tmp files. Claude Code's permission layer +refuses every one of those shapes in a skill run, so the model wrote its own +script and ran it with `bash`, which is refused as well. Two runs on +miroshark-aeon (2026-10-21) ended green with nothing synced. This script does +the whole compare/classify/apply pass in one allowed command +(`Bash(python3:*)` is in the write tier), so every disposition is decided by +code, not by the model's reading of the steps. + + python3 scripts/aeon_update.py plan --upstream OWNER/REPO --baseline SHA --head SHA + S4-S6. Diffs upstream's BASELINE and HEAD trees, partitions the changed + paths, 3-way classifies each OWNED file and stages every blob the apply + step needs under $WORK. Mutates nothing in the repo, so `report` mode + stops here. Prints a JSON summary; the full plan is $WORK/plan.json. + + python3 scripts/aeon_update.py apply + S7-S8, on the checked-out aeon-update/* branch. Writes the CLEAN files, + carries new skills' glyphs, regenerates the catalogs, runs the + pack-registry and eyebrow-lock checks (reverting what would go red), + runs validate-config, recomputes pending_conflicts and writes + memory/topics/aeon-update-state.json. Prints a JSON result; the full + result is $WORK/result.json. Stages nothing except deletions. + +$WORK is $AEON_UPDATE_WORK, else ${RUNNER_TEMP:-/tmp}/aeon-update. +Exit codes: 0 done (read "status"), 2 usage or environment error, +3 baseline is not an ancestor of HEAD (plan only). + +Test hooks: AEON_UPDATE_UPSTREAM_GIT= reads upstream from a local git +repo instead of `gh api`; AEON_UPDATE_EYEBROW_BIN= picks the +eyebrow binary (none = unavailable, exercises the fail-safe). +""" +import argparse +import concurrent.futures +import difflib +import hashlib +import json +import os +import platform +import re +import shutil +import stat +import subprocess +import sys +import tarfile +import urllib.parse +from datetime import datetime, timezone +from pathlib import Path + +STATE_FILE = "memory/topics/aeon-update-state.json" +PACK_UNIT = ( + "catalog/skill-packs.json", + "docs/community-skill-packs.md", + "scripts/validate-skill-packs.mjs", + "scripts/tests/test_validate_skill_packs.sh", + ".github/workflows/ci-skill-packs.yml", +) +OPERATOR_FILES = { + "aeon.yml", "STRATEGY.md", ".mcp.json", "aeon.db", "skills.lock", + "eyebrowlock.json", ".github/dependabot.yml", +} +OPERATOR_DIRS = ("soul/", "memory/", "output/", "apps/dashboard/outputs/") +CLEAN = ("CLEAN-ADD", "CLEAN-UPDATE", "CLEAN-MERGE", "CLEAN-DELETE") +CATALOG_GENERATORS = ( + ["bin/generate-skills-json"], + ["bin/generate-packs-json"], + ["bin/generate-skill-icons"], +) + + +def die(msg, code=2): + print(json.dumps({"status": "AEON_UPDATE_ERROR", "error": msg})) + sys.exit(code) + + +def run(cmd, cwd=None, input=None, check=False, env=None): + p = subprocess.run(cmd, check=False, cwd=cwd, input=input, capture_output=True, env=env) + if check and p.returncode != 0: + raise RuntimeError(f"{' '.join(cmd)}: {p.stderr.decode(errors='replace').strip()[-300:]}") + return p + + +def blob_sha(data): + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def work_dir(): + base = os.environ.get("AEON_UPDATE_WORK") or os.path.join(os.environ.get("RUNNER_TEMP") or "/tmp", "aeon-update") + return Path(base) + + +def today(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +# --- upstream backends -------------------------------------------------------- + +class GhUpstream: + """Reads upstream through `gh api` (auth from GITHUB_TOKEN / GH_TOKEN).""" + + def __init__(self, repo): + self.repo = repo + + def _api(self, path, raw=False): + cmd = ["gh", "api", f"repos/{self.repo}/{path}"] + if raw: + cmd[2:2] = ["-H", "Accept: application/vnd.github.raw"] + p = run(cmd) + if p.returncode != 0: + err = (p.stderr + p.stdout).decode(errors="replace") + if "HTTP 404" in err or '"status":"404"' in err.replace(" ", ""): + return None + raise RuntimeError(f"gh api {path}: {err.strip()[-300:]}") + return p.stdout if raw else json.loads(p.stdout or b"null") + + def compare(self, base, head): + c = self._api(f"compare/{base}...{head}") + if c is None: + return None + return { + "status": c.get("status"), + "ahead": c.get("ahead_by", 0), + "behind": c.get("behind_by", 0), + "commits": [ + {"sha": x["sha"], "msg": x["commit"]["message"].split("\n")[0], "date": x["commit"]["author"]["date"]} + for x in c.get("commits") or [] + ], + } + + def tree(self, sha): + t = self._api(f"git/trees/{sha}?recursive=1") + if t is None: + raise RuntimeError(f"tree {sha} not found") + if t.get("truncated"): + raise RuntimeError(f"tree {sha} is truncated (repo too large for one recursive tree call)") + return {e["path"]: (e["mode"], e["sha"]) for e in t["tree"] if e["type"] in ("blob", "commit")} + + def blob(self, sha): + data = self._api(f"git/blobs/{sha}", raw=True) + if data is None: + raise RuntimeError(f"blob {sha} not found") + return data + + def path_commits(self, path, head): + rows = self._api(f"commits?sha={head}&path={urllib.parse.quote(path)}&per_page=30") or [] + return [r["sha"] for r in rows] + + +class GitUpstream: + """Reads upstream from a local clone (tests, or an operator dry run).""" + + def __init__(self, path): + self.path = path + + def _git(self, *args, check=True): + return run(["git", *args], cwd=self.path, check=check) + + def compare(self, base, head): + if self._git("cat-file", "-e", f"{base}^{{commit}}", check=False).returncode != 0: + return None + if base == head: + return {"status": "identical", "ahead": 0, "behind": 0, "commits": []} + if self._git("merge-base", "--is-ancestor", base, head, check=False).returncode != 0: + return {"status": "diverged", "ahead": 0, "behind": 1, "commits": []} + log = self._git("log", "--reverse", "--format=%H%x09%aI%x09%s", f"{base}..{head}").stdout.decode() + commits = [] + for line in log.splitlines(): + sha, date, msg = line.split("\t", 2) + commits.append({"sha": sha, "msg": msg, "date": date}) + return {"status": "ahead", "ahead": len(commits), "behind": 0, "commits": commits} + + def tree(self, sha): + out = self._git("ls-tree", "-r", "-z", sha).stdout.decode() + tree = {} + for rec in out.split("\0"): + if not rec: + continue + meta, path = rec.split("\t", 1) + mode, _typ, obj = meta.split(" ") + tree[path] = (mode, obj) + return tree + + def blob(self, sha): + return self._git("cat-file", "blob", sha).stdout + + def path_commits(self, path, head): + return self._git("log", "--format=%H", "-n", "30", head, "--", path).stdout.decode().split() + + +def upstream_backend(repo): + local = os.environ.get("AEON_UPDATE_UPSTREAM_GIT") + return GitUpstream(local) if local else GhUpstream(repo) + + +# --- local tree --------------------------------------------------------------- + +def local_entry(path): + """(mode, sha) of the working-tree copy, or None when absent.""" + if os.path.islink(path): + return ("120000", blob_sha(os.readlink(path).encode())) + if os.path.isdir(path): + return ("040000", "dir") + if not os.path.isfile(path): + return None + data = Path(path).read_bytes() + mode = "100755" if os.stat(path).st_mode & stat.S_IXUSR else "100644" + return (mode, blob_sha(data)) + + +def same(a, b): + """Two entries are the same file when content and the exec bit agree.""" + if a is None or b is None: + return a is b + return a[1] == b[1] and (a[0] == "100755") == (b[0] == "100755") + + +def operator_owned(p): + if p in OPERATOR_FILES: + return True + if "/" not in p and p.startswith(".env"): + return True + if p.startswith(OPERATOR_DIRS): + return True + if p.startswith(".claude/") and not p.startswith(".claude/skills/aeon/"): + return True + if p.startswith("catalog/") and p.count("/") == 1 and p.endswith(".json") and p != "catalog/skill-packs.json": + return True + return False + + +def skill_of(p): + m = re.match(r"skills/([^/]+)/", p) + return m.group(1) if m else None + + +def enabled_skills(): + try: + text = Path("aeon.yml").read_text() + except OSError: + return set() + return set(re.findall(r"^ ([A-Za-z0-9_-]+): *\{[^}]*enabled: true", text, re.MULTILINE)) + + +def write_blob(work, sha, data): + d = work / "blobs" + d.mkdir(parents=True, exist_ok=True) + (d / sha).write_bytes(data) + + +def read_blob(work, sha): + return (work / "blobs" / sha).read_bytes() + + +def safe_name(p): + return p.replace("/", "__") + + +def text_diff(a, b, path): + if b"\0" in a[:8000] or b"\0" in b[:8000]: + return f"(binary file {path} changed)\n" + return "".join(difflib.unified_diff( + a.decode(errors="replace").splitlines(True), b.decode(errors="replace").splitlines(True), + f"a/{path}", f"b/{path}", n=2, + )) + + +def load_state(): + try: + return json.loads(Path(STATE_FILE).read_text()) + except (OSError, ValueError): + return {} + + +# --- plan (S4-S6) ------------------------------------------------------------- + +def classify(path, base, head, local, enabled, head_tree): + """Disposition for one OWNED path from its BASELINE, HEAD and local entries.""" + if base is not None and head is not None and same(base, head): + return "SKIP", "unchanged-upstream" + for e in (base, head): + if e is not None and e[0] == "160000": + return ("SKIP", "already-synced") if same(local, head) else ("CONFLICT", "submodule") + if local is not None and local[0] == "040000": + return "CONFLICT", "local-directory" + if head is None: + if local is None: + return "SKIP", "already-removed" + if not same(local, base): + return "CONFLICT", "removed-upstream-local-edited" + slug = skill_of(path) + if slug and slug in enabled and f"skills/{slug}/SKILL.md" not in head_tree: + return "CONFLICT", "enabled-skill-removed-upstream" + return "CLEAN-DELETE", "" + if base is None: + if local is None: + return "CLEAN-ADD", "" + if same(local, head): + return "SKIP", "already-synced" + return "CONFLICT", "local-collision" + if same(local, head): + return "SKIP", "already-synced" + if local is None: + return "CONFLICT", "deleted-locally" + if same(local, base): + return "CLEAN-UPDATE", "" + return "MERGE", "" + + +def three_way(work, path, base_data, head_data, idx): + d = work / "merge" / str(idx) + d.mkdir(parents=True, exist_ok=True) + (d / "base").write_bytes(base_data) + (d / "head").write_bytes(head_data) + shutil.copyfile(path, d / "local") + p = run(["git", "merge-file", "-p", "--diff3", str(d / "local"), str(d / "base"), str(d / "head")]) + if p.returncode == 0: + (d / "merged").write_bytes(p.stdout) + return True, str(d / "merged") + return False, None + + +def packs_subset(local_bytes, head_bytes): + try: + lp = {x.get("repo") for x in json.loads(local_bytes).get("packs", [])} + hp = {x.get("repo") for x in json.loads(head_bytes).get("packs", [])} + except (ValueError, AttributeError): + return False + return lp <= hp + + +def cmd_plan(args): + work = work_dir() + if work.exists(): + shutil.rmtree(work) + work.mkdir(parents=True) + up = upstream_backend(args.upstream) + try: + cmp = up.compare(args.baseline, args.head) + except RuntimeError as e: + die(str(e)) + if cmp is None or cmp["status"] in ("diverged", "behind"): + print(json.dumps({"status": "AEON_UPDATE_BASELINE_UNREACHABLE", "baseline": args.baseline, "head": args.head, + "compare_status": cmp and cmp["status"]})) + sys.exit(3) + if cmp["status"] == "identical": + # Nothing to apply, but a prior conflict may have been resolved by hand since. + try: + head_tree = up.tree(args.head) + except RuntimeError as e: + die(str(e)) + prior = load_state().get("pending_conflicts") or [] + print(json.dumps({"status": "AEON_UPDATE_IN_SYNC", "baseline": args.baseline, "head": args.head, + "pending_conflicts": compute_pending([], prior, head_tree, enabled_skills())}, indent=1)) + return + try: + base_tree = up.tree(args.baseline) + head_tree = up.tree(args.head) + except RuntimeError as e: + die(str(e)) + + changed = {p for p in set(base_tree) | set(head_tree) if base_tree.get(p) != head_tree.get(p)} + if changed & set(PACK_UNIT): + changed |= set(PACK_UNIT) + changed.add("catalog/skill-packs.json") + enabled = enabled_skills() + + entries, fetch = [], set() + for path in sorted(changed): + base, head = base_tree.get(path), head_tree.get(path) + local = local_entry(path) + e = {"path": path, "base": base, "head": head, "local": local, "reason": ""} + if operator_owned(path): + e["disposition"] = "OPERATOR" if base != head else "SKIP" + else: + e["disposition"], e["reason"] = classify(path, base, head, local, enabled, head_tree) + if e["disposition"] == "SKIP": + continue + entries.append(e) + for side in ("base", "head"): + if e[side] and e[side][0] != "160000": + fetch.add(e[side][1]) + if "catalog/skill-icons.json" in head_tree: + fetch.add(head_tree["catalog/skill-icons.json"][1]) + if "eyebrowlock.json" in head_tree: + fetch.add(head_tree["eyebrowlock.json"][1]) + if "eyebrowlock.json" in base_tree: + fetch.add(base_tree["eyebrowlock.json"][1]) + + unreadable = {} + + def get(sha): + try: + write_blob(work, sha, up.blob(sha)) + except RuntimeError as e: + unreadable[sha] = str(e) + + with concurrent.futures.ThreadPoolExecutor(8) as pool: + list(pool.map(get, sorted(fetch))) + + for i, e in enumerate(entries): + need = [e[s][1] for s in ("base", "head") if e[s] and e[s][0] != "160000"] + if any(sha in unreadable for sha in need): + e["disposition"], e["reason"] = "UNREADABLE", "; ".join(unreadable[s] for s in need if s in unreadable) + continue + if e["disposition"] == "MERGE": + path, base_data, head_data = e["path"], read_blob(work, e["base"][1]), read_blob(work, e["head"][1]) + if path == "catalog/skill-packs.json" and packs_subset(Path(path).read_bytes(), head_data): + e["disposition"], e["reason"] = "CLEAN-UPDATE", "stale-copy" + continue + ok, merged = three_way(work, path, base_data, head_data, i) + if ok: + e["disposition"], e["merged"] = "CLEAN-MERGE", merged + else: + e["disposition"], e["reason"] = "CONFLICT", "operator-customized" + + unit = [e for e in entries if e["path"] in PACK_UNIT] + if any(e["disposition"] == "CONFLICT" for e in unit): + for e in unit: + if e["disposition"] in CLEAN: + e["disposition"], e["reason"] = "CONFLICT", "skill-packs-unit" + + in_range = {c["sha"] for c in cmp["commits"]} + diffs = work / "diffs" + diffs.mkdir() + surfaced = [e for e in entries if e["disposition"] in ("CONFLICT", "OPERATOR")] + + def describe(e): + try: + e["upstream_commits"] = [s[:7] for s in up.path_commits(e["path"], args.head) if s in in_range] + except RuntimeError: + e["upstream_commits"] = [] + a = read_blob(work, e["base"][1]) if e["base"] and e["base"][0] != "160000" else b"" + b = read_blob(work, e["head"][1]) if e["head"] and e["head"][0] != "160000" else b"" + e["diff"] = str(diffs / (safe_name(e["path"]) + ".diff")) + Path(e["diff"]).write_text(text_diff(a, b, e["path"])) + + with concurrent.futures.ThreadPoolExecutor(8) as pool: + list(pool.map(describe, surfaced)) + + plan = { + "upstream": args.upstream, "baseline": args.baseline, "head": args.head, + "ahead": cmp["ahead"], "commits": cmp["commits"], "entries": entries, + "head_icons": head_tree.get("catalog/skill-icons.json"), + "head_lock": head_tree.get("eyebrowlock.json"), + "base_lock": base_tree.get("eyebrowlock.json"), + "head_tree": head_tree, + } + (work / "plan.json").write_text(json.dumps(plan, indent=2)) + prior = load_state().get("pending_conflicts") or [] + pending = compute_pending(entries, prior, head_tree, enabled_skills()) + print(json.dumps(summary(plan, entries, pending, work), indent=1)) + + +def summary(plan, entries, pending, work, extra=None): + by = {} + for e in entries: + by.setdefault(e["disposition"], []).append(e["path"]) + out = { + "status": "AEON_UPDATE_PLANNED", + "upstream": plan["upstream"], + "range": f"{plan['baseline'][:7]}..{plan['head'][:7]}", + "ahead": plan["ahead"], + "counts": {k: len(v) for k, v in sorted(by.items())}, + "clean": {k: by[k] for k in CLEAN if k in by}, + "conflicts": [ + {"path": e["path"], "reason": e["reason"], "upstream_commits": e.get("upstream_commits", []), "diff": e.get("diff")} + for e in entries if e["disposition"] == "CONFLICT" + ], + "operator": [{"path": e["path"], "diff": e.get("diff")} for e in entries if e["disposition"] == "OPERATOR"], + "unreadable": [{"path": e["path"], "error": e["reason"]} for e in entries if e["disposition"] == "UNREADABLE"], + "pending_conflicts": pending, + "commits": [f"{c['sha'][:7]} {c['msg']}" for c in plan["commits"]], + "work": str(work), + } + if extra: + out.update(extra) + return out + + +def compute_pending(entries, prior, head_tree, enabled): + """S8: this run's conflicts plus prior entries that are still divergent.""" + now = {e["path"]: e for e in entries} + out = [ + {"path": e["path"], "reason": e["reason"], "upstream_commits": e.get("upstream_commits", [])} + for e in entries if e["disposition"] == "CONFLICT" + ] + seen = {x["path"] for x in out} + for p in prior: + path = p.get("path") + if not path or path in seen: + continue + e = now.get(path) + if e and e["disposition"] in CLEAN: + continue + if p.get("reason") == "enabled-skill-removed-upstream": + slug = skill_of(path) + if slug in enabled and path not in head_tree: + out.append(p) + seen.add(path) + continue + if not same(local_entry(path), head_tree.get(path)): + out.append(p) + seen.add(path) + return out + + +# --- apply (S7-S8) ------------------------------------------------------------ + +def parses(path, data): + try: + if path.endswith(".json"): + json.loads(data) + elif path.endswith((".yml", ".yaml")): + try: + import yaml + except ImportError: + return True + list(yaml.safe_load_all(data)) + except Exception: # noqa: BLE001 - any parse error means "do not ship it" + return False + return True + + +class Tree: + """Applies entries and remembers each original so any of them can be reverted.""" + + def __init__(self, work): + self.work = work + self.saved = {} + + def _save(self, path): + if path in self.saved: + return + if os.path.islink(path): + self.saved[path] = ("link", os.readlink(path)) + elif os.path.isfile(path): + self.saved[path] = ("file", Path(path).read_bytes(), os.stat(path).st_mode) + else: + self.saved[path] = ("absent",) + + def put(self, path, data, mode): + self._save(path) + Path(path).parent.mkdir(parents=True, exist_ok=True) + if os.path.islink(path) or os.path.exists(path): + os.remove(path) + if mode == "120000": + os.symlink(data.decode(), path) + return + Path(path).write_bytes(data) + if mode == "100755": + os.chmod(path, os.stat(path).st_mode | 0o111) + + def delete(self, path): + self._save(path) + tracked = run(["git", "ls-files", "--error-unmatch", "--", path]).returncode == 0 + if tracked: + run(["git", "rm", "-q", "--", path], check=True) + elif os.path.lexists(path): + os.remove(path) + + def revert(self, path): + s = self.saved.pop(path, None) + if s is None: + return + run(["git", "reset", "-q", "--", path]) + if os.path.islink(path) or os.path.exists(path): + os.remove(path) + if s[0] == "link": + os.symlink(s[1], path) + elif s[0] == "file": + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_bytes(s[1]) + os.chmod(path, stat.S_IMODE(s[2])) + + +def apply_entry(tree, work, e): + path, d = e["path"], e["disposition"] + if d == "CLEAN-DELETE": + tree.delete(path) + return None + if d == "CLEAN-MERGE": + data = Path(e["merged"]).read_bytes() + mode = e["head"][0] if e["base"][0] != e["head"][0] else e["local"][0] + else: + data, mode = read_blob(work, e["head"][1]), e["head"][0] + if mode != "120000" and not parses(path, data): + return "parse-failed" + tree.put(path, data, mode) + return None + + +def run_tool(cmd): + """Run a repo tool if this instance has it; None when the file is absent.""" + if not os.path.exists(cmd[1] if cmd[0] == "node" else cmd[0]): + return None + p = run(cmd) + return {"cmd": " ".join(cmd), "rc": p.returncode, "out": (p.stdout + p.stderr).decode(errors="replace")[-1500:]} + + +def regen_catalogs(): + results = [run_tool(c) for c in CATALOG_GENERATORS] + [run_tool(["node", "scripts/gen-agents-md.js"])] + return [r for r in results if r and r["rc"] != 0] + + +def go_json(data): + """eyebrow writes Go-style JSON: 2-space indent, <, > and & escaped.""" + out = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + return out.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") + + +def carry_glyphs(work, plan, applied): + """A new skill's glyph lives in the hand-authored catalog/skill-icons.json, which + is regenerated rather than synced; copy upstream's entry for each new skill.""" + new = sorted({skill_of(e["path"]) for e in applied + if e["disposition"] == "CLEAN-ADD" and e["path"].endswith("/SKILL.md") and skill_of(e["path"])}) + if not new or not plan.get("head_icons") or not os.path.isfile("catalog/skill-icons.json"): + return [] + try: + up_icons = json.loads(read_blob(work, plan["head_icons"][1])).get("icons", {}) + local = json.loads(Path("catalog/skill-icons.json").read_text()) + except (OSError, ValueError): + return [] + added = [s for s in new if s in up_icons and s not in local.get("icons", {})] + for s in added: + local.setdefault("icons", {})[s] = up_icons[s] + if added: + Path("catalog/skill-icons.json").write_text(json.dumps(local, indent=2, ensure_ascii=False) + "\n") + return added + + +def eyebrow_binary(work): + forced = os.environ.get("AEON_UPDATE_EYEBROW_BIN") + if forced: + return None if forced == "none" else forced + found = shutil.which("eyebrow") + if found: + return found + wf = Path(".github/workflows/ci-skill-integrity.yml") + text = wf.read_text() if wf.exists() else "" + # The step's `version:` input is the binary CI really runs; the `# vX` comment on + # the uses: line can drift ahead of it after a Dependabot bump. + m = re.search(r"alexverify/eyebrow/action@[^\n]*\n(?:[^\n]*\n){0,6}?\s*version:\s*(v\d+\.\d+\.\d+)", text) + m = m or re.search(r"alexverify/eyebrow/action@[0-9a-f]+\s*#\s*(v\d+\.\d+\.\d+)", text) + ver = m.group(1) if m else "v0.5.6" + arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine().lower()) + osname = platform.system().lower() + if not arch or osname not in ("linux", "darwin"): + return None + tb = f"eyebrow_{ver[1:]}_{osname}_{arch}.tar.gz" + d = work / "eb" + d.mkdir(exist_ok=True) + p = run(["gh", "release", "download", ver, "-R", "alexverify/eyebrow", "-p", tb, "-p", "checksums.txt", "-D", str(d), "--clobber"]) + if p.returncode != 0: + return None + sums = {} + for line in (d / "checksums.txt").read_text().splitlines(): + parts = line.split() + if len(parts) == 2: + sums[parts[1].lstrip("*")] = parts[0] + if sums.get(tb) != hashlib.sha256((d / tb).read_bytes()).hexdigest(): + return None + with tarfile.open(d / tb) as t: + member = next((m for m in t.getmembers() if m.isfile() and os.path.basename(m.name) == "eyebrow"), None) + if member is None: + return None + src = t.extractfile(member) + (d / "eyebrow").write_bytes(src.read()) + os.chmod(d / "eyebrow", 0o755) + return str(d / "eyebrow") + + +def eyebrow_scan(work): + eb = eyebrow_binary(work) + if not eb: + return False, "eyebrow binary unavailable" + # A local file hasher: no secrets, no network, so give it a scrubbed env. + env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "")} + p = run([eb, "scan", "--path", ".", "--lockfile", "eyebrowlock.json"], env=env) + if p.returncode != 0: + return False, (p.stdout + p.stderr).decode(errors="replace")[-500:] + return True, "scanned" + + +def lock_entry(lock, slug): + want = f"skills/{slug}/SKILL.md" + return [a for a in lock.get("artifacts", []) if a.get("discoveredFrom") == want] + + +def eyebrow_failsafe(work, plan, tree, entries): + """Without a fresh scan, never ship a skill against a stale lock entry: carry + upstream's entry when the skill matches HEAD exactly, else hold the skill.""" + try: + local_lock = json.loads(Path("eyebrowlock.json").read_text()) + except (OSError, ValueError): + local_lock = {"artifacts": []} + up_lock = json.loads(read_blob(work, plan["head_lock"][1])) if plan.get("head_lock") else {"artifacts": []} + base_lock = json.loads(read_blob(work, plan["base_lock"][1])) if plan.get("base_lock") else {"artifacts": []} + slugs = {} + for e in entries: + if e["disposition"] in ("CLEAN-ADD", "CLEAN-UPDATE", "CLEAN-MERGE") and e["path"].endswith("/SKILL.md"): + slug = skill_of(e["path"]) + if slug and e["path"] == f"skills/{slug}/SKILL.md": + slugs[slug] = e + carried, held = [], [] + for slug, e in sorted(slugs.items()): + mine = lock_entry(local_lock, slug) + new = e["disposition"] == "CLEAN-ADD" or not mine + findings = any(a.get("findings") for a in mine) + caps = [a.get("capabilities") for a in lock_entry(base_lock, slug)] != [a.get("capabilities") for a in lock_entry(up_lock, slug)] + if not (new or findings or caps): + continue + up_tree = {p: v for p, v in plan["head_tree"].items() if p.startswith(f"skills/{slug}/")} + local_paths = set() + for root, _dirs, files in os.walk(f"skills/{slug}"): + local_paths.update(os.path.join(root, f) for f in files) + identical = set(up_tree) == local_paths and all(same(local_entry(p), tuple(v)) for p, v in up_tree.items()) + theirs = lock_entry(up_lock, slug) + if identical and len(theirs) == 1: + want = f"skills/{slug}/SKILL.md" + arts = local_lock.setdefault("artifacts", []) + if mine: + local_lock["artifacts"] = [theirs[0] if a.get("discoveredFrom") == want else a for a in arts] + else: + arts.extend(theirs) + carried.append(slug) + continue + for x in entries: + if skill_of(x["path"]) == slug and x["disposition"] in CLEAN: + tree.revert(x["path"]) + x["disposition"], x["reason"] = "CONFLICT", "needs-eyebrowlock-scan" + held.append(slug) + if carried: + Path("eyebrowlock.json").write_text(go_json(local_lock)) + return carried, held + + +def cmd_apply(_args): + work = work_dir() + try: + plan = json.loads((work / "plan.json").read_text()) + except (OSError, ValueError): + die(f"no plan at {work}/plan.json: run `plan` first") + branch = run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.decode().strip() + if not branch.startswith("aeon-update/"): + die(f"on branch {branch!r}: check out the aeon-update/sync-* branch first (S7)") + entries = plan["entries"] + for e in entries: + for k in ("base", "head", "local"): + if e.get(k): + e[k] = tuple(e[k]) + tree = Tree(work) + for e in entries: + if e["disposition"] not in CLEAN: + continue + if not same(local_entry(e["path"]), e["local"]): + e["disposition"], e["reason"] = "CONFLICT", "changed-since-plan" + continue + err = apply_entry(tree, work, e) + if err: + e["disposition"], e["reason"] = "CONFLICT", err + + notes = {} + unit = [e for e in entries if e["path"] in PACK_UNIT] + if any(e["disposition"] in CLEAN for e in unit): + r = run_tool(["node", "scripts/validate-skill-packs.mjs"]) + if r and r["rc"] != 0: + for e in unit: + if e["disposition"] in CLEAN: + tree.revert(e["path"]) + e["disposition"], e["reason"] = "CONFLICT", "skill-packs-parity" + notes["skill_packs_validator"] = r["out"] + + # Lock first (it only reads skills/), then glyphs + catalogs from the final tree, + # so a held skill never leaves a glyph or catalog row behind. + if any(e["path"].startswith("skills/") for e in entries if e["disposition"] in CLEAN): + ok, why = eyebrow_scan(work) + notes["eyebrow"] = why + if not ok: + notes["eyebrow_carried"], notes["eyebrow_held"] = eyebrow_failsafe(work, plan, tree, entries) + applied = [e for e in entries if e["disposition"] in CLEAN] + if any(e["path"].startswith("skills/") for e in applied): + notes["glyphs_carried"] = carry_glyphs(work, plan, applied) + notes["catalog_errors"] = regen_catalogs() + + head_tree = {p: tuple(v) for p, v in plan["head_tree"].items()} + prior = load_state().get("pending_conflicts") or [] + pending = compute_pending(entries, prior, head_tree, enabled_skills()) + + vc = run_tool(["node", "scripts/validate-config.js", "aeon.yml"]) + if vc and vc["rc"] != 0: + notes["validate_config"] = vc["out"] + + if not applied: + status = "AEON_UPDATE_MANUAL_ONLY" + else: + status = "AEON_UPDATE_APPLIED" + state = load_state() + count = lambda d: sum(1 for e in applied if e["disposition"] == d) + state.update({ + "baseline_sha": plan["head"], + "upstream": plan["upstream"], + "last_run": today(), + "last_pr": None, + "applied": {"added": count("CLEAN-ADD"), "updated": count("CLEAN-UPDATE"), + "merged": count("CLEAN-MERGE"), "deleted": count("CLEAN-DELETE")}, + "pending_conflicts": pending, + }) + Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True) + Path(STATE_FILE).write_text(json.dumps(state, indent=2) + "\n") + result = summary(plan, entries, pending, work, {"status": status, **{k: v for k, v in notes.items() if v}}) + (work / "result.json").write_text(json.dumps(result, indent=2)) + print(json.dumps(result, indent=1)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("plan") + p.add_argument("--upstream", required=True) + p.add_argument("--baseline", required=True) + p.add_argument("--head", required=True) + sub.add_parser("apply") + args = ap.parse_args() + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", getattr(args, "upstream", "a/b")): + die("--upstream must be owner/name") + for k in ("baseline", "head"): + if hasattr(args, k) and not re.fullmatch(r"[0-9a-fA-F]{7,40}", getattr(args, k)): + die(f"--{k} must be a commit sha") + top = run(["git", "rev-parse", "--show-toplevel"]).stdout.decode().strip() + if not top: + die("not inside a git repo") + os.chdir(top) + {"plan": cmd_plan, "apply": cmd_apply}[args.cmd](args) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/ci-lanes.txt b/scripts/tests/ci-lanes.txt index 81818c3c85c..fd58cd0b421 100644 --- a/scripts/tests/ci-lanes.txt +++ b/scripts/tests/ci-lanes.txt @@ -102,3 +102,4 @@ 3 | vuln-scanner Riva candidate validation tests | bash scripts/tests/test_riva_candidates.sh 6 | vuln-scanner Riva kernel contract tests | bash scripts/tests/test_riva_kernel_contract.sh 3 | anatomy skill integration tests | bash scripts/tests/test_anatomy_integration.sh +1 | aeon-update plan/apply tests | python3 scripts/tests/test_aeon_update.py diff --git a/scripts/tests/test_aeon_update.py b/scripts/tests/test_aeon_update.py new file mode 100644 index 00000000000..74774008de8 --- /dev/null +++ b/scripts/tests/test_aeon_update.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""aeon_update.py plans and applies an upstream sync without the model scripting it. + +Builds a real upstream repo (BASELINE + HEAD commits) and an instance cloned +from BASELINE with operator edits, then runs `plan` and `apply` against the +local upstream (AEON_UPDATE_UPSTREAM_GIT) with no eyebrow binary, so the +lock fail-safe path runs. +""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "aeon_update.py" +LINES = "".join(f"line {i}\n" for i in range(1, 11)) + + +def lock(*entries): + return {"version": 1, "artifacts": [ + {"discoveredFrom": f"skills/{slug}/SKILL.md", "name": slug, "capabilities": caps, "findings": findings} + for slug, caps, findings in entries + ]} + + +class AeonUpdateTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + self.env = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid", + "AEON_UPDATE_WORK": str(self.root / "work"), + "AEON_UPDATE_EYEBROW_BIN": "none", + } + self.up = self.root / "upstream" + self.inst = self.root / "instance" + self.env["AEON_UPDATE_UPSTREAM_GIT"] = str(self.up) + + def tearDown(self): + self.tmp.cleanup() + + def git(self, cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, env=self.env, text=True, capture_output=True, check=True).stdout.strip() + + def write(self, base, files): + for path, body in files.items(): + p = base / path + if body is None: + if p.exists(): + p.unlink() + continue + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body if isinstance(body, str) else json.dumps(body, indent=2) + "\n") + + def tool(self, *args): + p = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.inst, env=self.env, text=True, capture_output=True) + out = json.loads(p.stdout) if p.stdout.strip() else None + return p.returncode, out, p.stderr + + def build(self): + base_files = { + "scripts/a.sh": "a1\n", + "scripts/b.sh": LINES, + "scripts/c.sh": LINES, + "scripts/old.sh": "old\n", + "scripts/edited-old.sh": "old\n", + "scripts/resolved.sh": "r1\n", + "scripts/still.sh": "s1\n", + "docs/x.md": "moved\n", + "skills/retired/SKILL.md": "retired\n", + "skills/custom/SKILL.md": LINES, + "aeon.yml": "skills:\n retired: { enabled: true }\n", + "catalog/skill-packs.json": {"packs": [{"repo": "a/one"}]}, + "catalog/skill-icons.json": {"version": 1, "icons": {"custom": ""}}, + "eyebrowlock.json": lock(("custom", {}, [{"line": 3}])), + } + self.git(self.root, "init", "-q", "-b", "main", str(self.up)) + self.write(self.up, base_files) + os.chmod(self.up / "scripts/a.sh", 0o755) + self.git(self.up, "add", "-A") + self.git(self.up, "commit", "-qm", "base") + self.base = self.git(self.up, "rev-parse", "HEAD") + + self.git(self.root, "clone", "-q", str(self.up), str(self.inst)) + self.write(self.inst, { + "scripts/b.sh": LINES.replace("line 2\n", "line 2 operator\n"), + "scripts/c.sh": LINES.replace("line 3\n", "line 3 operator\n"), + "scripts/edited-old.sh": "old operator\n", + "scripts/collide.sh": "mine\n", + "scripts/still.sh": "s1 operator\n", + "skills/custom/SKILL.md": LINES.replace("line 1\n", "line 1 operator\n"), + "aeon.yml": "skills:\n retired: { enabled: true }\n mine: { enabled: true }\n", + "memory/topics/aeon-update-state.json": { + "baseline_sha": self.base, "upstream": "acme/aeon", "keep": "me", + "pending_conflicts": [ + {"path": "scripts/resolved.sh", "reason": "operator-customized"}, + {"path": "scripts/still.sh", "reason": "operator-customized"}, + ], + }, + }) + self.git(self.inst, "add", "-A") + self.git(self.inst, "commit", "-qm", "operator edits") + + head_files = { + "scripts/a.sh": "a2\n", + "scripts/b.sh": LINES.replace("line 9\n", "line 9 upstream\n"), + "scripts/c.sh": LINES.replace("line 3\n", "line 3 upstream\n"), + "scripts/old.sh": None, + "scripts/edited-old.sh": None, + "scripts/resolved.sh": "r1\n", + "scripts/still.sh": "s2\n", + "scripts/new.sh": "new\n", + "scripts/collide.sh": "theirs\n", + "docs/x.md": None, + "docs/y.md": "moved\n", + "skills/retired/SKILL.md": None, + "skills/custom/SKILL.md": LINES.replace("line 9\n", "line 9 upstream\n"), + "skills/fresh/SKILL.md": "fresh \n", + "skills/fresh/helper.md": "helper\n", + "conf/bad.json": "{not json\n", + "aeon.yml": "skills:\n retired: { enabled: false }\n fresh: { enabled: false }\n", + "catalog/skill-packs.json": {"packs": [{"repo": "a/one"}, {"repo": "b/two"}]}, + "catalog/skill-icons.json": {"version": 1, "icons": {"custom": "", "fresh": ""}}, + "eyebrowlock.json": lock(("custom", {}, [{"line": 3}]), ("fresh", {"network": ["x.dev"]}, [])), + } + self.write(self.up, head_files) + os.chmod(self.up / "scripts/new.sh", 0o755) + self.git(self.up, "add", "-A") + self.git(self.up, "commit", "-qm", "upstream work") + self.git(self.up, "commit", "-q", "--allow-empty", "-m", "second") + self.head = self.git(self.up, "rev-parse", "HEAD") + # the instance's frozen pack registry predates the baseline copy + self.write(self.inst, {"catalog/skill-packs.json": {"packs": []}}) + self.git(self.inst, "commit", "-qam", "frozen registry") + + def test_plan_then_apply(self): + self.build() + rc, plan, err = self.tool("plan", "--upstream", "acme/aeon", "--baseline", self.base, "--head", self.head) + self.assertEqual(rc, 0, err) + self.assertEqual(plan["status"], "AEON_UPDATE_PLANNED") + self.assertEqual(plan["ahead"], 2) + self.assertEqual(self.git(self.inst, "status", "--porcelain"), "", "plan must not touch the repo") + clean = plan["clean"] + self.assertEqual(sorted(clean["CLEAN-ADD"]), ["conf/bad.json", "docs/y.md", "scripts/new.sh", + "skills/fresh/SKILL.md", "skills/fresh/helper.md"]) + self.assertEqual(sorted(clean["CLEAN-UPDATE"]), ["catalog/skill-packs.json", "scripts/a.sh"]) + self.assertEqual(sorted(clean["CLEAN-MERGE"]), ["scripts/b.sh", "skills/custom/SKILL.md"]) + self.assertEqual(sorted(clean["CLEAN-DELETE"]), ["docs/x.md", "scripts/old.sh"]) + reasons = {c["path"]: c["reason"] for c in plan["conflicts"]} + self.assertEqual(reasons, { + "scripts/c.sh": "operator-customized", + "scripts/edited-old.sh": "removed-upstream-local-edited", + "scripts/collide.sh": "local-collision", + "scripts/still.sh": "operator-customized", + "skills/retired/SKILL.md": "enabled-skill-removed-upstream", + }) + c = next(x for x in plan["conflicts"] if x["path"] == "scripts/c.sh") + self.assertIn("+line 3 upstream", Path(c["diff"]).read_text()) + self.assertEqual(len(c["upstream_commits"]), 1) + self.assertIn("aeon.yml", [o["path"] for o in plan["operator"]]) + self.assertNotIn("eyebrowlock.json", json.dumps(plan["clean"])) + + rc, out, err = self.tool("apply") + self.assertEqual(rc, 2, "apply must refuse to run on main") + self.git(self.inst, "checkout", "-qb", f"aeon-update/sync-{self.head[:7]}") + rc, res, err = self.tool("apply") + self.assertEqual(rc, 0, err) + self.assertEqual(res["status"], "AEON_UPDATE_APPLIED") + f = lambda p: (self.inst / p).read_text() # noqa: E731 + self.assertEqual(f("scripts/a.sh"), "a2\n") + self.assertTrue(os.access(self.inst / "scripts/a.sh", os.X_OK)) + self.assertTrue(os.access(self.inst / "scripts/new.sh", os.X_OK)) + self.assertIn("line 2 operator", f("scripts/b.sh")) + self.assertIn("line 9 upstream", f("scripts/b.sh")) + self.assertEqual(f("scripts/c.sh"), LINES.replace("line 3\n", "line 3 operator\n")) + self.assertFalse((self.inst / "scripts/old.sh").exists()) + self.assertFalse((self.inst / "docs/x.md").exists()) + self.assertEqual(f("docs/y.md"), "moved\n") + self.assertTrue((self.inst / "skills/retired/SKILL.md").exists()) + self.assertEqual(f("aeon.yml"), "skills:\n retired: { enabled: true }\n mine: { enabled: true }\n") + self.assertFalse((self.inst / "conf/bad.json").exists()) + self.assertEqual(json.loads(f("catalog/skill-packs.json"))["packs"][1]["repo"], "b/two") + + # eyebrow unavailable: the untouched new skill carries upstream's lock entry and + # glyph; the operator-customized skill with findings is held back + self.assertEqual(res["eyebrow_carried"], ["fresh"]) + self.assertEqual(res["eyebrow_held"], ["custom"]) + self.assertEqual(f("skills/custom/SKILL.md"), LINES.replace("line 1\n", "line 1 operator\n")) + lk = f("eyebrowlock.json") + self.assertIn('"x.dev"', lk) + self.assertEqual(json.loads(lk)["artifacts"][1]["discoveredFrom"], "skills/fresh/SKILL.md") + self.assertEqual(json.loads(f("catalog/skill-icons.json"))["icons"]["fresh"], "") + self.assertEqual(res["glyphs_carried"], ["fresh"]) + + reasons = {c["path"]: c["reason"] for c in res["conflicts"]} + self.assertEqual(reasons["skills/custom/SKILL.md"], "needs-eyebrowlock-scan") + self.assertEqual(reasons["conf/bad.json"], "parse-failed") + state = json.loads(f("memory/topics/aeon-update-state.json")) + self.assertEqual(state["baseline_sha"], self.head) + self.assertEqual(state["keep"], "me") + self.assertIsNone(state["last_pr"]) + self.assertEqual(state["applied"], {"added": 4, "updated": 2, "merged": 1, "deleted": 2}) + pending = {p["path"] for p in state["pending_conflicts"]} + self.assertIn("scripts/still.sh", pending) + self.assertNotIn("scripts/resolved.sh", pending) + self.assertIn("skills/custom/SKILL.md", pending) + self.assertIn("skills/retired/SKILL.md", pending) + self.assertIn("D scripts/old.sh", self.git(self.inst, "status", "--porcelain")) + + def test_in_sync_and_unreachable(self): + self.build() + rc, out, _ = self.tool("plan", "--upstream", "acme/aeon", "--baseline", self.head, "--head", self.head) + self.assertEqual((rc, out["status"]), (0, "AEON_UPDATE_IN_SYNC")) + # resolved.sh now matches HEAD, still.sh does not + self.assertEqual([p["path"] for p in out["pending_conflicts"]], ["scripts/still.sh"]) + rc, out, _ = self.tool("plan", "--upstream", "acme/aeon", "--baseline", "deadbeef", "--head", self.head) + self.assertEqual((rc, out["status"]), (3, "AEON_UPDATE_BASELINE_UNREACHABLE")) + rc, out, _ = self.tool("plan", "--upstream", "acme/aeon", "--baseline", "x;y", "--head", self.head) + self.assertEqual(rc, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/aeon-update/SKILL.md b/skills/aeon-update/SKILL.md index dec68da4e0d..70e0f5640e6 100644 --- a/skills/aeon-update/SKILL.md +++ b/skills/aeon-update/SKILL.md @@ -31,11 +31,14 @@ Today is ${today}. This is the fleet's **downstream updater** - the counterpart ## Steps +Every command below is **one Bash call**: run it, read the value it prints, and type that value into the next command (shown as ``, ``, ...). Never assign shell variables (`X=$(...)`), chain with `&&`/`;`, redirect into a file, or write a script and run it with `bash`: the permission layer refuses all of those in this run. Create or change files with Write/Edit. + ### S0. Bootstrap + load state -```bash -mkdir -p memory/topics -[ -f memory/topics/aeon-update-state.json ] || echo '{"baseline_sha":null,"upstream":null,"last_run":null,"last_pr":null,"pending_conflicts":[]}' > memory/topics/aeon-update-state.json +If `memory/topics/aeon-update-state.json` does not exist, create it with Write: + +```json +{"baseline_sha": null, "upstream": null, "last_run": null, "last_pr": null, "pending_conflicts": []} ``` Read `memory/MEMORY.md` for context and scan the last ~3 days of `memory/logs/` - drop anything already reported so a repeat run isn't re-sent. Read the state file: @@ -51,49 +54,51 @@ Read `memory/MEMORY.md` for context and scan the last ~3 days of `memory/logs/` ### S2. Resolve the upstream source ```bash -SELF=$(gh repo view --json nameWithOwner -q .nameWithOwner) -UPSTREAM="${REPO_OVERRIDE:-$(gh api "repos/${SELF}" --jq '.parent.full_name // empty')}" -[ -z "$UPSTREAM" ] && UPSTREAM="aeonfun/aeon" +gh repo view --json nameWithOwner -q .nameWithOwner # -> SELF +gh api "repos/" --jq '.parent.full_name // empty' # -> UPSTREAM (skip when REPO_OVERRIDE is set) ``` +`UPSTREAM` = `REPO_OVERRIDE`, else the parent, else `aeonfun/aeon` when the parent is empty. + If `UPSTREAM` == `SELF`, this instance **is** canon - there is nothing upstream to pull. Write status `AEON_UPDATE_IS_UPSTREAM` to `memory/logs/${today}.md`, send **no** notification, and stop. ```bash -UP_DEFAULT=$(gh api "repos/${UPSTREAM}" --jq '.default_branch') -HEAD_SHA=$(gh api "repos/${UPSTREAM}/commits/${UP_DEFAULT}" --jq '.sha') +gh api "repos/" --jq '.default_branch' # -> UP_DEFAULT +gh api "repos//commits/" --jq '.sha' # -> HEAD_SHA (HEAD7 = first 7 chars) ``` ### S3. Establish / reset the baseline -- **`reset=fork-point`** → `BASELINE=$(gh api "repos/${UPSTREAM}/compare/${HEAD_SHA}...$(git rev-parse HEAD)" --jq '.merge_base_commit.sha')`. `reset=` → `BASELINE=`. Persist immediately to state, then continue. +- **`reset=fork-point`** → run `git rev-parse HEAD` (-> LOCAL_HEAD), then `gh api "repos//compare/..." --jq '.merge_base_commit.sha'` -> `BASELINE`. `reset=` → `BASELINE=`. Persist it to the state file with Edit, then continue. - **First run (`BASELINE` null and no `reset`):** a fresh instance already carries all of canon from fork time, so there is no delta to apply - just **anchor the watermark**. Set `baseline_sha = HEAD_SHA`, write state, log `AEON_UPDATE_BASELINE_SET`, send a one-line notify (`baseline initialized at ; future runs sync from here`), and stop. (To backfill everything since the fork point instead, re-run with `reset=fork-point`.) -- **`BASELINE` == `HEAD_SHA`:** in sync. Re-verify `PENDING` (S8) in case a prior conflict is now resolved, update state, log `AEON_UPDATE_IN_SYNC`, notify nothing, stop. +- **`BASELINE` == `HEAD_SHA`:** in sync. Run the S4 `plan` command anyway: it returns `AEON_UPDATE_IN_SYNC` with `pending_conflicts` re-verified against HEAD. If that list differs from `PENDING` (a prior conflict was resolved), write it into the state file with Edit. Log `AEON_UPDATE_IN_SYNC`, notify nothing, stop. - **A sync PR is still open (sync mode):** the state file on `main` still holds the old baseline until that PR merges, so a second PR from it would carry the same commits again and two branches would each move the watermark. Check before doing any work, including before persisting a `reset=`: ```bash - OPEN_PR=$(gh pr list --repo "$SELF" --state open --base "$UP_DEFAULT" --limit 100 --json number,headRefName,url \ - --jq '[.[] | select(.headRefName | startswith("aeon-update/sync-"))] | sort_by(.number) | last // empty | "\(.number) \(.headRefName) \(.url)"') + gh pr list --repo "" --state open --base "" --limit 100 --json number,headRefName,url --jq '[.[] | select(.headRefName | startswith("aeon-update/sync-"))] | sort_by(.number) | last // empty | "\(.number) \(.headRefName) \(.url)"' ``` - If `OPEN_PR` is set: write nothing (no state file, no branch, no PR) and log `AEON_UPDATE_PR_OPEN` with the PR and its range. If its branch already ends in `HEAD_SHA`'s first 7 chars, stay silent; if upstream has moved past it, send one line (`PR #N still open; upstream is M commits past it, merge or close it and the next run syncs the rest`, `--mute-key "aeon-update:${HEAD7}"`). Stop. `report` mode skips this check and notes the open PR in its report. + If it prints a PR (`OPEN_PR`): write nothing (no state file, no branch, no PR) and log `AEON_UPDATE_PR_OPEN` with the PR and its range. If its branch already ends in `HEAD_SHA`'s first 7 chars, stay silent; if upstream has moved past it, send one line (`PR #N still open; upstream is M commits past it, merge or close it and the next run syncs the rest`, `--mute-key "aeon-update:${HEAD7}"`). Stop. `report` mode skips this check and notes the open PR in its report. + +### S4-S6. Plan: compare, partition, 3-way classify -### S4. Compare baseline → HEAD +All of S4-S8's file work is done by `scripts/aeon_update.py`, in single commands. **Do not re-implement it in the shell**: helper functions, loops, redirects into files and `bash