From 0b0e59ee267d81a1fa78b072503891b14dfe3e12 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 14 Oct 2026 23:51:34 +0530 Subject: [PATCH 1/5] UTS: add a shared spec clone and its sync script to the skill creator New scripts/ensure_spec_clone.py is the only script that touches the network or changes the spec clone, and only on approval. It locates the clone (an explicit path, else the clone the skill is installed from, else ABLY_SPEC_CLONE, with UTS_SPEC_CLONE read as a deprecated alias, else ~/.ably/specification), reports its git state and staleness offline, and on request fetches origin main or fast-forwards main with `git merge --ff-only --no-overwrite-ignore`, only when main is checked out, clean, has no operation in progress and is strictly behind. Every other state is reported and left alone; it never pulls, resets, stashes, checks out or switches branches. Like a default git fetch, the fetch updates origin/main and changes no local branch. The sync runs only when the clone in use is the shared clone (ABLY_SPEC_CLONE, else ~/.ably/specification), however it was found, comparing directories with symlinks resolved and the filesystem's case rules. Any other clone is never fetched or changed, whatever ABLY_SPEC_SYNC (ask, auto, off) says: --fetch and --update report NOT_SYNCED_NAMED_CLONE, and syncEligible and notSyncedWarning carry a static warning to keep it up to date. A short lock in the clone's git directory serialises concurrent syncs; a lock that can't be written (a sandbox) is FETCH_FAILED with the clone used as it is, and a stale lock that can't be removed ends in LOCKED. The last fetch is the latest of the origin/main reflog, a FETCH_HEAD naming main from this clone's origin, and the clone itself. The user's core.sshCommand is kept, with BatchMode added. spec_clone_info.py and spec_names.py resolve the clone by the same precedence. A clone whose checkout lacks the guide or the skill (a revision that predates them) is SPEC_CLONE_INCOMPLETE, with a message saying how to proceed. --- .../scripts/ensure_spec_clone.py | 659 ++++++++++++++++++ .../scripts/spec_clone_info.py | 134 ++-- .../scripts/spec_names.py | 37 +- 3 files changed, 749 insertions(+), 81 deletions(-) create mode 100755 uts/skills/uts-to-lang-skill-creator/scripts/ensure_spec_clone.py diff --git a/uts/skills/uts-to-lang-skill-creator/scripts/ensure_spec_clone.py b/uts/skills/uts-to-lang-skill-creator/scripts/ensure_spec_clone.py new file mode 100755 index 000000000..70a238691 --- /dev/null +++ b/uts/skills/uts-to-lang-skill-creator/scripts/ensure_spec_clone.py @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +"""Locate the shared ably/specification clone and, on approval, bring it up to date (the spec-clone sync). + +Usage: ensure_spec_clone.py [--spec-clone PATH] [--status | --fetch | --update | --bootstrap [--path PATH]] + [--require RELPATH ...] [--timeout SECONDS] + +The clone is the first candidate that exists of: --spec-clone PATH (a path +inside a clone is walked up to its root); the clone this script is installed +in (the skill creator's own clone); ABLY_SPEC_CLONE (UTS_SPEC_CLONE is read as +a deprecated alias); the shared default ~/.ably/specification. An explicit path +that isn't in a clone is NOT_A_SPEC_CLONE: it is never replaced by another +candidate. An ABLY_SPEC_CLONE path, or the default, that doesn't exist is +SPEC_CLONE_NOT_FOUND, with `bootstrapPath`. + +The sync runs only when the clone in use is the shared clone (ABLY_SPEC_CLONE, +else ~/.ably/specification), however it was found: a path the user names, or +the clone this script is installed in, gets it only when it is that same +directory. Any other clone is never fetched or changed, whatever ABLY_SPEC_SYNC +says: --fetch and --update report NOT_SYNCED_NAMED_CLONE, and +`notSyncedWarning` asks the user to keep it up to date. + +Actions (one of): + --status (the default) offline: the clone's git state, how stale it is, + and what rules out a fast-forward (`fastForwardBlockedBy`) + --fetch git fetch origin main into refs/remotes/origin/main only: no + branch and no file changes + --update fetch, then `git merge --ff-only --no-overwrite-ignore` of + origin/main, only when main is checked out, has no tracked + changes, no merge, rebase, cherry-pick, revert or bisect is in + progress, and main is strictly behind origin/main; any other + state is reported and left unchanged + --bootstrap clone https://github.com/ably/specification.git to --path, else + ABLY_SPEC_CLONE, else ~/.ably/specification; refuses if the path + exists +--require RELPATH (repeatable) adds files a usable clone must have, besides the +UTS docs, the helper specs and specifications/. --timeout bounds each network +command (default 120 s for a fetch, 600 s for a clone). + +ABLY_SPEC_SYNC (ask, the default; auto; off) is the user's standing answer to +the sync question, reported as `syncPolicy`; with `off`, --fetch and --update +touch no network (SYNC_OFF). Only clone, fetch, merge --ff-only and read-only +git commands are run; never pull, reset, checkout, switch, stash, clean, +rebase, push or --force, and nothing is deleted except this script's own lock +(`ably-spec-sync.lock` in the clone's git directory, held only around the fetch +and the fast-forward). The fetch updates refs/remotes/origin/main as a default +`git fetch` does (it follows origin even after a force-push there, and the +previous value stays in that ref's reflog); no local branch is touched. + +Prints one JSON object: `ok`, `action` (status, fetched, fast-forwarded, +up-to-date, not-updated, cloned), `code`, `message`, `specClone`, `kind` +(explicit, skill-clone, env, shared), `foundBy`, `isSharedClone`, `branch`, +`detached`, `head`, `from`, `to`, `upstream` {ref, sha, ahead, behind, +lastFetch, lastFetchDays}, `onOriginMain`, `trackedChanges`, `untrackedCount`, +`dirtyFiles`, `operationInProgress`, `originUrl`, `originIsAblySpecification`, +`fastForwardBlockedBy`, `syncEligible`, `notSyncedWarning`, `changed`, +`headAgeDays`, `staleWarning`, +`stalenessLine`, `syncPolicy`, `missing`, `bootstrapPath`, `partialLeftovers` +and `warnings`. Exit 0 when the clone is usable, whatever the sync outcome; 1 +when it isn't (SPEC_CLONE_NOT_FOUND, NOT_A_SPEC_CLONE, SPEC_CLONE_INCOMPLETE, +SPEC_CLONE_ENV_CONFLICT, BOOTSTRAP_PATH_EXISTS, BOOTSTRAP_FAILED, GIT_ERROR, +INTERNAL_ERROR); 2 on a usage error. Python 3.8+, standard library only. + +Rules: the skill creator's references/orient.md 13.9; guide 9.4. +""" +import datetime, json, os, pathlib, re, socket, subprocess, sys, time + +URL = "https://github.com/ably/specification.git" +MARKER = "specifications/features.md" +REQUIRED = ["uts/README.md", "uts/docs/writing-derived-tests.md", "uts/docs/writing-test-specs.md", + "uts/docs/integration-testing.md", "uts/docs/proxy.md", "uts/rest/unit/helpers/mock_http.md", + "uts/realtime/unit/helpers/mock_websocket.md", "uts/realtime/unit/helpers/mock_vcdiff.md", + "uts/objects/helpers/standard_test_pool.md", "specifications"] +CREATOR_DIR = ("uts", "skills", "uts-to-lang-skill-creator") +GROUPS = { + "skill": ["uts/skills/uts-to-lang-skill-creator"], + "guide": ["uts/docs/writing-uts-spec-translator-skills.md"], + "utsDocs": ["uts/README.md", "uts/docs/writing-derived-tests.md", "uts/docs/writing-test-specs.md", + "uts/docs/integration-testing.md", "uts/docs/proxy.md"], + "helperSpecs": ["uts/rest/unit/helpers", "uts/realtime/unit/helpers", "uts/objects/helpers"], + "corpus": ["uts/rest", "uts/realtime", "uts/objects"], +} +OPERATIONS = {"MERGE_HEAD": "merge", "CHERRY_PICK_HEAD": "cherry-pick", "REVERT_HEAD": "revert", + "BISECT_LOG": "bisect", "rebase-merge": "rebase", "rebase-apply": "rebase or am"} +ORIGIN_MAIN = "refs/remotes/origin/main" +POLICIES = ("ask", "auto", "off") +STALE_DAYS = 30 +LOCK_NAME = "ably-spec-sync.lock" +LOCK_WAIT = 30 +LOCK_STALE = 600 +# Per-command settings, never written to the clone's config: no submodules, no automatic gc or maintenance, no stash. +SAFE = ["-c", "submodule.recurse=false", "-c", "gc.auto=0", "-c", "maintenance.auto=false", + "-c", "merge.autoStash=false"] +REMOTE = re.compile(r"^(?:(?:https?|git|ssh)://(?:[^@/]+@)?(?P

[^/:]+)(?::\d+)?/|(?:[^@/]+@)?(?P

[^/:]+):)" + r"(?P[^/]+)/(?P[^/]+?)(?:\.git)?/*$", re.I) +GITHUB = {"github.com", "www.github.com"} +USAGE = ("usage: ensure_spec_clone.py [--spec-clone PATH] [--status | --fetch | --update | --bootstrap [--path PATH]] " + "[--require RELPATH ...] [--timeout SECONDS]") + + +class GitError(RuntimeError): + pass + + +def git(clone, *args, timeout=60, network=False): + """Run git in clone; return stdout. Raises GitError (with git's stderr) on failure or timeout.""" + env = dict(os.environ) + if network: + env["GIT_TERMINAL_PROMPT"] = "0" + env.setdefault("GCM_INTERACTIVE", "never") + if "GIT_SSH_COMMAND" not in env: # keep the user's core.sshCommand; only add BatchMode + ssh = try_git(clone, "config", *([] if clone else ["--global"]), "--get", "core.sshCommand") + env["GIT_SSH_COMMAND"] = (ssh.strip() if ssh and ssh.strip() else "ssh") + " -o BatchMode=yes" + cmd = ["git"] + (["-C", str(clone)] if clone else []) + list(args) + verb = next((a for i, a in enumerate(args) if a != "-c" and (i == 0 or args[i - 1] != "-c")), "") + try: + out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env, + timeout=timeout) + except subprocess.TimeoutExpired: + raise GitError(f"git {verb} timed out after {timeout} s") + except OSError as exc: + raise GitError(f"git couldn't run: {exc}") + if out.returncode != 0: + raise GitError(out.stderr.strip() or f"git {' '.join(args)} exited {out.returncode}") + return out.stdout + + +def try_git(clone, *args): + try: + return git(clone, *args) + except GitError: + return None + + +def default_path(): + return pathlib.Path.home() / ".ably" / "specification" + + +def policy(): + value = os.environ.get("ABLY_SPEC_SYNC", "").strip().lower() + return value if value in POLICIES else "ask" + + +def clone_root(path): + """The ably/specification clone containing path (path itself or a parent), or None.""" + return next((p for p in [path, *path.parents] if (p / MARKER).is_file()), None) + + +def own_clone(): + """The clone this script is installed in: /uts/skills/uts-to-lang-skill-creator/scripts/. Else None.""" + here = pathlib.Path(__file__).resolve().parent + parents = list(here.parents) + if here.name == "scripts" and len(parents) > 3 and tuple(parents[i].name for i in (2, 1, 0)) == CREATOR_DIR: + return parents[3] if (parents[3] / MARKER).is_file() else None + return None + + +def env_clone(warnings): + """(path, how) from ABLY_SPEC_CLONE or the deprecated UTS_SPEC_CLONE; raises ValueError if both differ.""" + new, old = os.environ.get("ABLY_SPEC_CLONE", "").strip(), os.environ.get("UTS_SPEC_CLONE", "").strip() + if old: + warnings.append("UTS_SPEC_CLONE is deprecated: rename it to ABLY_SPEC_CLONE") + if new and old and expand(new) != expand(old): + raise ValueError(f"ABLY_SPEC_CLONE ({new}) and the deprecated UTS_SPEC_CLONE ({old}) name different " + "clones: unset UTS_SPEC_CLONE, or make them agree") + if new: + return new, "ABLY_SPEC_CLONE" + if old: + return old, "UTS_SPEC_CLONE (deprecated)" + return None, None + + +def expand(raw): + return pathlib.Path(os.path.expanduser(str(raw))).resolve() + + +def shared_target(): + """Where the shared clone is: ABLY_SPEC_CLONE (or its alias), else the default.""" + raw = os.environ.get("ABLY_SPEC_CLONE", "").strip() or os.environ.get("UTS_SPEC_CLONE", "").strip() + return expand(raw) if raw else default_path().resolve() + + +def same_path(a, b): + """Whether two paths are the same directory (symlinks resolved; case-insensitive where the filesystem is).""" + try: + return os.path.samefile(str(a), str(b)) + except OSError: + return os.path.normcase(str(pathlib.Path(a).resolve())) == os.path.normcase(str(pathlib.Path(b).resolve())) + + +def shared_root(): + target = shared_target() + return clone_root(target) or target + + +def not_synced_warning(loc): + """The static warning for a clone that isn't the shared clone (None for the shared clone).""" + if loc.get("isSharedClone") or not loc.get("specClone"): + return None + who = "the spec clone you named" if loc.get("kind") == "explicit" else "the clone this skill is installed from" + return f"Using {who} ({loc['specClone']}). It isn't synced; make sure its UTS specs are up to date." + + +def leftovers(target): + parent = target.parent + return sorted(str(p) for p in parent.glob(target.name + ".partial-*")) if parent.is_dir() else [] + + +def locate(explicit=None, require=()): + """Resolve the clone by precedence. Returns a dict: ok, code, message, specClone, kind, foundBy, missing, ...""" + warnings = [] + + def candidates(): + if explicit is not None and str(explicit).strip(): + yield "explicit", "argument", explicit + own = own_clone() + if own is not None: + yield "skill-clone", "skill location", str(own) + env, env_how = env_clone(warnings) # a conflict matters only once the candidates above are exhausted + if env: + yield "env", env_how, env + yield "shared", "shared default", str(default_path()) + + found = candidates() + while True: + try: + kind, how, raw = next(found) + except ValueError as exc: + return {"ok": False, "code": "SPEC_CLONE_ENV_CONFLICT", "message": str(exc), "warnings": warnings} + path = expand(raw) + if kind in ("env", "shared") and not path.exists(): + return {"ok": False, "code": "SPEC_CLONE_NOT_FOUND", "kind": kind, "foundBy": how, + "bootstrapPath": str(path), "partialLeftovers": leftovers(path), "warnings": warnings, + "message": f"No ably/specification clone at {path} (from {how}). After STOP-16, offer to clone " + f"it there (ensure_spec_clone.py --bootstrap; network), or ask for the path of an " + "existing clone (STOP-1)."} + root = clone_root(path) + if root is None: + return {"ok": False, "code": "NOT_A_SPEC_CLONE", "kind": kind, "foundBy": how, "warnings": warnings, + "missing": [MARKER], "message": f"{path} (from {how}) is not an ably/specification clone: no " + f"{MARKER} in it or a parent directory"} + missing = [rel for rel in list(REQUIRED) + list(require) if not (root / rel).exists()] + shared = same_path(root, shared_root()) + loc = dict( + ok=not missing, code="SPEC_CLONE_INCOMPLETE" if missing else "FOUND", specClone=str(root), kind=kind, + foundBy=how, isSharedClone=shared, syncEligible=shared, missing=missing, warnings=warnings) + loc["notSyncedWarning"] = not_synced_warning(loc) + return loc + + +def last_fetch(clone): + """Seconds since the epoch of the latest of: an update of origin/main (its reflog), a fetch of main (FETCH_HEAD's + mtime, which a fetch that changes nothing also renews) and the clone itself (HEAD's "clone:" reflog entry).""" + times = [] + out = try_git(clone, "reflog", "show", "-n1", "--date=unix", "--format=%gd", ORIGIN_MAIN, "--") + times += [int(t) for t in re.findall(r"@\{(\d+)\}", out or "")] + path = try_git(clone, "rev-parse", "--git-path", "FETCH_HEAD") + if path: + p = pathlib.Path(path.strip()) + p = p if p.is_absolute() else pathlib.Path(clone) / p + url = (try_git(clone, "remote", "get-url", "origin") or "").strip().rstrip("/") # as FETCH_HEAD writes it + url = url[:-len(".git")] if url.endswith(".git") else url + try: + if url and f"branch 'main' of {url}\n" in p.read_text(encoding="utf-8", errors="replace") + "\n": + times.append(int(p.stat().st_mtime)) + except OSError: + pass + out = try_git(clone, "reflog", "show", "--date=unix", "--format=%gd %gs", "HEAD", "--") + times += [int(m.group(1)) for m in re.finditer(r"@\{(\d+)\} clone:", out or "")] + return max(times) if times else None + + +def days(ts, now): + return None if ts is None else max(0, int((now - ts) // 86400)) + + +def iso(ts): + return None if ts is None else datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ") + + +def origin_is_ably_spec(url): + m = REMOTE.match((url or "").strip()) + if not m or m.group("owner").lower() != "ably" or m.group("repo").lower() != "specification": + return False + host = (m.group("h1") or m.group("h2") or "").lower() + if host in GITHUB: + return True + if "." in host: + return False + try: # an SSH host alias: `ssh -G` prints the resolved config and never connects + out = subprocess.run(["ssh", "-G", host], capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=5) + hm = re.search(r"(?m)^hostname\s+(\S+)", out.stdout) + return bool(hm) and hm.group(1).lower() in GITHUB + except (OSError, subprocess.SubprocessError): + return False + + +def redact(url): + return re.sub(r"^([a-z+]+://)[^@/]+@", r"\1***@", url or "", flags=re.I) or None + + +def status(clone): + """The clone's git state, offline. Raises GitError if the clone isn't a usable git checkout.""" + now = time.time() + head = git(clone, "rev-parse", "HEAD").strip() + branch = try_git(clone, "symbolic-ref", "-q", "--short", "HEAD") + branch = branch.strip() if branch else None + ops = [] + for name, what in OPERATIONS.items(): + p = try_git(clone, "rev-parse", "--git-path", name) + if p: + p = pathlib.Path(p.strip()) + if (p if p.is_absolute() else pathlib.Path(clone) / p).exists(): + ops.append(what) + porcelain = git(clone, "--no-optional-locks", "status", "--porcelain", "--untracked-files=normal").splitlines() + tracked = [line[3:] for line in porcelain if not line.startswith("??")] + untracked = sum(1 for line in porcelain if line.startswith("??")) + dirty = git(clone, "--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "--", "uts", + "specifications").splitlines() + url = try_git(clone, "config", "--get", "remote.origin.url") + url = url.strip() if url else None + up_sha = try_git(clone, "rev-parse", "-q", "--verify", ORIGIN_MAIN + "^{commit}") + up_sha = up_sha.strip() if up_sha else None + ahead = behind = None + on_main = None + if up_sha: + counts = git(clone, "rev-list", "--left-right", "--count", f"HEAD...{ORIGIN_MAIN}").split() + ahead, behind = int(counts[0]), int(counts[1]) + on_main = ahead == 0 + fetched = last_fetch(clone) if up_sha else None + head_ts = try_git(clone, "log", "-1", "--format=%ct", "HEAD") + head_age = days(int(head_ts.strip()), now) if head_ts and head_ts.strip().isdigit() else None + ably = origin_is_ably_spec(url) + blocked = [] + if not ably: + blocked.append("ORIGIN_NOT_ABLY_SPEC") + if branch is None: + blocked.append("DETACHED") + elif branch != "main": + blocked.append("NOT_ON_MAIN") + if ops: + blocked.append("OP_IN_PROGRESS") + if tracked: + blocked.append("DIRTY") + if branch == "main" and ahead: + blocked.append("DIVERGED" if behind else "AHEAD") + fetch_days = days(fetched, now) + stale = None + if fetched is None: + stale = "the spec clone's last fetch is unknown (never fetched): it may be far behind origin/main" + elif fetch_days > STALE_DAYS: + stale = f"the spec clone was last fetched {fetch_days} days ago: it may be well behind origin/main" + line = (f"HEAD committed {head_age if head_age is not None else '?'} days ago; " + + ("origin/main: unknown (never fetched)" if fetched is None else + f"origin/main as last fetched {fetch_days} days ago was {behind} commit(s) ahead; the real gap may be " + "larger")) + return { + "head": head, "branch": branch, "detached": branch is None, + "upstream": {"ref": ORIGIN_MAIN, "sha": up_sha, "ahead": ahead, "behind": behind, "lastFetch": iso(fetched), + "lastFetchDays": fetch_days}, + "onOriginMain": on_main, "trackedChanges": tracked, "untrackedCount": untracked, + "dirtyFiles": [line[3:] for line in dirty], "operationInProgress": ops, "originUrl": redact(url), + "originIsAblySpecification": ably, "fastForwardBlockedBy": blocked, "headAgeDays": head_age, + "staleWarning": stale, "stalenessLine": line, + } + + +def upstream_has(clone, paths): + """Whether origin/main as last fetched has every path (offline), or None if it isn't known.""" + if try_git(clone, "rev-parse", "-q", "--verify", ORIGIN_MAIN) is None: + return None + return all(try_git(clone, "cat-file", "-e", f"{ORIGIN_MAIN}:{p}") is not None for p in paths) + + +def incomplete_message(loc, st, clone): + where = f"branch {st['branch']}" if st.get("branch") else "a detached HEAD" + msg = (f"{loc['specClone']} ({loc['kind']}, found by {loc['foundBy']}) is an ably/specification clone, but its " + f"checkout ({where} @ {st['head'][:8]}) lacks {len(loc['missing'])} file(s) this skill needs, such as " + f"{loc['missing'][0]}: that revision predates them. ") + has = upstream_has(clone, loc["missing"]) + if has: + return msg + ("origin/main as last fetched has them: the spec-clone sync (fast-forward) fixes this." + if loc.get("isSharedClone") else "origin/main as last fetched has them: this clone isn't " + "synced, so update it yourself, or use the shared clone.") + yours = "" if loc.get("isSharedClone") else "; this clone isn't synced, so that fetch is yours" + known = (f"origin/main as last fetched doesn't have them either (a fetch may show whether it has since{yours}). " + if has is False else "origin/main was never fetched, so whether it has them is unknown. ") + return msg + known + ("Meanwhile, use a clone or worktree of a branch that has them: pass --spec-clone , or " + "install the skill from that clone (STOP-1).") + + +def changed(clone, old, new): + out = {} + corpus_skip = set() + for name, paths in GROUPS.items(): + files = [f for f in git(clone, "diff", "--name-only", old, new, "--", *paths).splitlines() if f] + if name == "helperSpecs": + corpus_skip = set(files) + if name == "corpus": + files = [f for f in files if f not in corpus_skip] + out[name] = len(files) if name not in ("skill", "guide") else bool(files) + return out + + +def lock_path(clone): + common = pathlib.Path(git(clone, "rev-parse", "--git-common-dir").strip()) + return (common if common.is_absolute() else pathlib.Path(clone) / common) / LOCK_NAME + + +def acquire(path): + """Take the sync lock; return None when held, else the holder's details (LOCKED), or {"error": …} when the git + directory can't be written (a sandbox).""" + deadline = time.time() + LOCK_WAIT + while True: + try: + fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"pid": os.getpid(), "host": socket.gethostname(), "started": time.time()}, f) + except OSError: + release(path) # never leave an empty lock behind + raise + return None + except FileExistsError: + try: + holder = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + holder = {} + if stale_lock(path, holder): + try: + path.unlink() + continue + except OSError: + pass + if time.time() >= deadline: + if isinstance(holder.get("started"), (int, float)): + holder["started"] = iso(holder["started"]) + holder["lock"] = str(path) + return holder + time.sleep(1) + except OSError as exc: + return {"error": f"can't write the lock {path} ({exc.strerror or exc})"} + + +def stale_lock(path, holder): + """Older than LOCK_STALE, on this host, and its process is gone (POSIX only; anything else is asked).""" + try: + age = time.time() - path.stat().st_mtime + except OSError: + return False + if age < LOCK_STALE or os.name == "nt" or holder.get("host") != socket.gethostname(): + return False + try: + os.kill(int(holder.get("pid")), 0) + except ProcessLookupError: + return True + except (OSError, TypeError, ValueError): + return False + return False + + +def release(path): + try: + path.unlink() + except OSError: + pass + + +def sync(clone, action, timeout): + """--fetch or --update under the lock. Returns (action, code, message, from, to).""" + st = status(clone) + if not st["originIsAblySpecification"]: + return ("not-updated", "ORIGIN_NOT_ABLY_SPEC", + f"origin is {st['originUrl'] or 'not set'}, not github.com/ably/specification: not fetched", None, None) + lock = lock_path(clone) + holder = acquire(lock) + if holder is not None and "error" in holder: + return ("not-updated", "FETCH_FAILED", f"not updated: {holder['error']}: the clone's git directory isn't " + "writable here (a sandbox?); the clone is used as it is", None, None) + if holder is not None: + return ("not-updated", "LOCKED", "not updated: another sync of this clone is in progress (pid " + f"{holder.get('pid')}, host {holder.get('host')}, started {holder.get('started')}); if no other run " + f"is syncing, the lock {holder['lock']} is stale: the user may remove it", None, None) + try: + try: + git(clone, *SAFE, "fetch", "--no-tags", "--no-recurse-submodules", "origin", + f"+refs/heads/main:{ORIGIN_MAIN}", timeout=timeout, network=True) + except GitError as exc: + first = str(exc).strip().splitlines()[0] if str(exc).strip() else "unknown error" + return ("not-updated", "FETCH_FAILED", f"not updated: fetch failed ({first}); offline, a sandbox " + "denial, or an auth prompt suppressed: the clone is used as it is", None, None) + if action == "fetch": + return ("fetched", "FETCHED", "fetched origin/main; no branch or file changed", None, None) + st = status(clone) + blocked = [b for b in st["fastForwardBlockedBy"] if b != "ORIGIN_NOT_ABLY_SPEC"] + up = st["upstream"] + where = f"on branch {st['branch']}" if st["branch"] else "detached" + counts = f"{up['ahead']} ahead / {up['behind']} behind origin/main" + if "DETACHED" in blocked or "NOT_ON_MAIN" in blocked: + return ("not-updated", blocked[0], f"{where} @ {st['head'][:8]}, {counts}: not updated (the sync never " + "switches branches)", None, None) + if "OP_IN_PROGRESS" in blocked: + return ("not-updated", "OP_IN_PROGRESS", f"a {', '.join(st['operationInProgress'])} is in progress: " + "not updated", None, None) + if "DIRTY" in blocked: + return ("not-updated", "DIRTY", f"{len(st['trackedChanges'])} tracked file(s) changed (such as " + f"{st['trackedChanges'][0]}): not updated", None, None) + if "DIVERGED" in blocked or "AHEAD" in blocked: + code = "DIVERGED" if "DIVERGED" in blocked else "AHEAD" + return ("not-updated", code, f"main is {counts}: local commits are left alone; not updated" + + ("; HEAD isn't on GitHub's main" if code == "AHEAD" else ""), None, None) + if not up["behind"]: + return ("up-to-date", "UP_TO_DATE", "up to date (fetched now)", st["head"], st["head"]) + old = st["head"] + try: + git(clone, *SAFE, "merge", "--ff-only", "--no-overwrite-ignore", "-q", ORIGIN_MAIN, timeout=timeout) + except GitError as exc: + return ("not-updated", "FF_REFUSED", f"fast-forward refused, nothing changed: {exc}", None, None) + new = git(clone, "rev-parse", "HEAD").strip() + return ("fast-forwarded", "FAST_FORWARDED", f"fast-forwarded {old[:8]}..{new[:8]} ({up['behind']} " + "commit(s))", old, new) + finally: + release(lock) + + +def bootstrap(target, timeout): + """Clone into a temporary sibling, then rename it into place. Returns (ok, code, message).""" + if target.exists(): + return False, "BOOTSTRAP_PATH_EXISTS", (f"{target} already exists: pass it as the spec clone, or choose " + "another path; nothing is overwritten") + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.parent / f"{target.name}.partial-{os.getpid()}" + try: + git(None, "clone", "-c", "core.autocrlf=false", "--no-recurse-submodules", "-q", URL, str(tmp), + timeout=timeout, network=True) + except GitError as exc: + left = f"; remove the partial clone {tmp}" if tmp.exists() else "" + first = str(exc).strip().splitlines()[0] if str(exc).strip() else "unknown error" + return False, "BOOTSTRAP_FAILED", f"clone failed ({first}){left}" + if target.exists(): + return False, "BOOTSTRAP_FAILED", f"{target} appeared while cloning; the new clone is at {tmp}" + os.rename(str(tmp), str(target)) + return True, "CLONED", f"cloned {URL} to {target}" + + +def usage_error(message): + print(f"{USAGE}\nensure_spec_clone.py: error: {message}", file=sys.stderr) + return 2 + + +def parse(argv): + opts = {"action": None, "spec": None, "path": None, "require": [], "timeout": None} + it = iter(argv) + for a in it: + if a in ("--status", "--fetch", "--update", "--bootstrap"): + if opts["action"]: + raise ValueError("give one action") + opts["action"] = a[2:] + elif a in ("--spec-clone", "--path", "--require", "--timeout"): + value = next(it, "") + if not value or value.startswith("-"): + raise ValueError(f"{a} needs a value") + if a == "--require": + opts["require"].append(value) + elif a == "--timeout": + try: + opts["timeout"] = int(value) + except ValueError: + raise ValueError(f"--timeout needs a number of seconds, got {value}") + if opts["timeout"] <= 0: + raise ValueError("--timeout must be positive") + else: + opts[a[2:].replace("-clone", "")] = value + else: + raise ValueError(f"unknown argument {a}") + opts["action"] = opts["action"] or "status" + if opts["path"] and opts["action"] != "bootstrap": + raise ValueError("--path goes with --bootstrap") + if opts["spec"] and opts["action"] == "bootstrap": + raise ValueError("--bootstrap clones to --path; --spec-clone names an existing clone") + return opts + + +def emit(result): + print(json.dumps(result, indent=2)) + return 0 if result.get("ok") else 1 + + +def main(argv): + if "-h" in argv[1:] or "--help" in argv[1:]: + print(__doc__.strip()) + return 0 + try: + opts = parse(argv[1:]) + except ValueError as exc: + return usage_error(str(exc)) + action = opts["action"] + base = {"ok": False, "action": "status", "from": None, "to": None, "changed": None, "syncPolicy": policy(), + "warnings": []} + if os.environ.get("ABLY_SPEC_SYNC", "").strip() and os.environ["ABLY_SPEC_SYNC"].strip().lower() not in POLICIES: + base["warnings"].append(f"ABLY_SPEC_SYNC={os.environ['ABLY_SPEC_SYNC']} isn't ask, auto or off: read as ask") + if action == "bootstrap": + loc = {"warnings": []} + try: + env, _ = env_clone(loc["warnings"]) + except ValueError as exc: + return emit({**base, "code": "SPEC_CLONE_ENV_CONFLICT", "message": str(exc)}) + target = expand(opts["path"] or env or default_path()) + base.update(bootstrapPath=str(target), partialLeftovers=leftovers(target)) + base["warnings"] += loc["warnings"] + ok, code, message = bootstrap(target, opts["timeout"] or 600) + if not ok: + return emit({**base, "action": "not-updated", "code": code, "message": message}) + base["action"] = "cloned" + if target != shared_target() or own_clone() is not None: + opts["spec"] = str(target) + loc = locate(opts["spec"], opts["require"]) + base["warnings"] += loc.pop("warnings", []) + result = {**base, **loc} + if "specClone" not in loc: + return emit(result) + clone = loc["specClone"] + try: + if action in ("fetch", "update"): + if not loc.get("syncEligible"): + result.update(action="not-updated", code="NOT_SYNCED_NAMED_CLONE", + message=f"not synced: {loc['notSyncedWarning']} No network is used.") + elif base["syncPolicy"] == "off": + result.update(action="not-updated", code="SYNC_OFF", + message="not updated: ABLY_SPEC_SYNC=off, so no network is used") + else: + act, code, message, old, new = sync(clone, action, opts["timeout"] or 120) + result.update(action=act, code=code, message=message, **{"from": old, "to": new}) + if old and new and old != new: + result["changed"] = changed(clone, old, new) + again = locate(opts["spec"] or clone, opts["require"]) # keep the original kind and foundBy + loc.update(ok=again["ok"], missing=again.get("missing", [])) + result.update(ok=loc["ok"], missing=loc["missing"]) + result.update(status(clone)) + if action == "bootstrap": + result.update(code="CLONED" if loc["ok"] else loc["code"], message=f"cloned {URL} to {clone}") + if not result["ok"]: + result["code"] = "SPEC_CLONE_INCOMPLETE" + result["message"] = (result.get("message", "") + ". " if action != "status" else "") + \ + incomplete_message(loc, result, clone) + elif action == "status": + result.update(code="STATUS", message="status only; no network used") + except GitError as exc: + result.update(ok=False, code="GIT_ERROR", message=str(exc)) + return emit(result) + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except Exception as exc: # never crash: report as JSON + print(json.dumps({"ok": False, "code": "INTERNAL_ERROR", "message": f"{type(exc).__name__}: {exc}"}, indent=2)) + sys.exit(1) diff --git a/uts/skills/uts-to-lang-skill-creator/scripts/spec_clone_info.py b/uts/skills/uts-to-lang-skill-creator/scripts/spec_clone_info.py index 9e395e9b5..f74f63ff7 100755 --- a/uts/skills/uts-to-lang-skill-creator/scripts/spec_clone_info.py +++ b/uts/skills/uts-to-lang-skill-creator/scripts/spec_clone_info.py @@ -3,26 +3,45 @@ Usage: spec_clone_info.py [SPEC_CLONE | --spec-clone PATH] -Discovery order: the path given (walked up to the clone root from a path inside -a clone), else UTS_SPEC_CLONE, else the clone this skill is installed from. A -path given (or UTS_SPEC_CLONE) that isn't in a clone is NOT_A_SPEC_CLONE: it is -never replaced by another candidate. - -Prints one JSON object: `ok`, `specClone`, `foundBy`, `sha`, `dirty`, -`dirtyFiles`, `guideLastChange`, `skillDir`, `skillRealpath`, `skillVersion`, -`skillMatchesClone`, `paths` and `warnings` (a list, possibly empty). Exit 0 on -success; 1 with {"ok": false} and a `code` (SPEC_CLONE_NOT_FOUND, -NOT_A_SPEC_CLONE, GIT_ERROR, INTERNAL_ERROR); 2 on a usage error. Read-only; -never touches the network. - -Rules: SKILL.md 2.6. +Precedence (ensure_spec_clone.py's): the path given (walked up to the clone +root from a path inside a clone), else the clone this skill is installed from, +else ABLY_SPEC_CLONE (UTS_SPEC_CLONE is read as a deprecated alias, with a +warning), else the shared default ~/.ably/specification. A path given that +isn't in a clone is NOT_A_SPEC_CLONE: it is never replaced by another +candidate. An ABLY_SPEC_CLONE path, or the default, that doesn't exist is +SPEC_CLONE_NOT_FOUND with `bootstrapPath`. A clone whose checkout lacks the +guide or this skill (a revision that predates them) is SPEC_CLONE_INCOMPLETE, +with `missing` and a message saying how to proceed. Only the shared clone is +synced (`syncEligible`); any other clone gets `notSyncedWarning`. + +Prints one JSON object: `ok`, `specClone`, `kind`, `foundBy`, +`isSharedClone`, `syncEligible`, `notSyncedWarning`, `sha`, `branch`, +`detached`, `dirty`, `dirtyFiles`, `trackedChanges`, `operationInProgress`, +`upstream` {ref, sha, ahead, behind, lastFetch, lastFetchDays}, +`onOriginMain`, `originUrl`, +`originIsAblySpecification`, `fastForwardBlockedBy`, `headAgeDays`, +`staleWarning`, `stalenessLine`, `syncPolicy`, `guideLastChange`, `skillDir`, +`skillRealpath`, `skillVersion`, `skillMatchesClone`, `paths` and `warnings` +(a list, possibly empty). Exit 0 on success; 1 with {"ok": false} and a `code` +(SPEC_CLONE_NOT_FOUND with `bootstrapPath`, NOT_A_SPEC_CLONE, +SPEC_CLONE_INCOMPLETE, SPEC_CLONE_ENV_CONFLICT, GIT_ERROR, INTERNAL_ERROR); 2 +on a usage error. Read-only; never touches the network (the sync is +ensure_spec_clone.py's). + +Rules: SKILL.md 2.6; references/orient.md 13.1, 13.9. """ -import filecmp, json, os, pathlib, subprocess, sys +import filecmp, importlib.util, json, pathlib, sys + +HERE = pathlib.Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("ensure_spec_clone", HERE / "ensure_spec_clone.py") +esc = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(esc) SKILL_NAME = "uts-to-lang-skill-creator" SKILL_IN_CLONE = pathlib.Path("uts", "skills", SKILL_NAME) REQUIRED = { "guide": "uts/docs/writing-uts-spec-translator-skills.md", + "skillCreator": (SKILL_IN_CLONE / "SKILL.md").as_posix(), "utsReadme": "uts/README.md", "writingDerivedTests": "uts/docs/writing-derived-tests.md", "writingTestSpecs": "uts/docs/writing-test-specs.md", @@ -41,17 +60,6 @@ def fail(code, message, **extra): return 1 -def git(clone, *args): - out = subprocess.run(["git", "-C", str(clone), *args], capture_output=True, text=True, encoding="utf-8") - if out.returncode != 0: - raise RuntimeError(out.stderr.strip() or f"git {' '.join(args)} failed") - return out.stdout - - -def is_spec_clone(path): - return all((path / rel).exists() for rel in REQUIRED.values()) - - def skill_files(root): return sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file() and "__pycache__" not in p.parts) @@ -96,38 +104,27 @@ def main(argv): return usage_error("give the spec clone once (SPEC_CLONE or --spec-clone PATH)") skill_dir = pathlib.Path(__file__).absolute().parent.parent skill_real = skill_dir.resolve() - candidates = [] - if given and given[0].strip(): - candidates.append(("argument", given[0])) - if os.environ.get("UTS_SPEC_CLONE"): - candidates.append(("UTS_SPEC_CLONE", os.environ["UTS_SPEC_CLONE"])) - candidates.append(("skill location", str(skill_real.parent.parent.parent))) - - clone, source = None, None - for source, raw in candidates: - path = pathlib.Path(os.path.expanduser(raw)).resolve() - # accept a path inside a clone (e.g. /uts/objects): walk up to the root - found = next((p for p in [path, *path.parents] if is_spec_clone(p)), None) - if found is not None: - clone = found - break - if source != "skill location": - missing = [rel for rel in REQUIRED.values() if not (path / rel).exists()] - return fail("NOT_A_SPEC_CLONE", f"{path} (from {source}) is not an ably/specification clone", - missing=missing) + loc = esc.locate(given[0] if given else None, list(REQUIRED.values())) + warnings = loc.pop("warnings", []) + clone = pathlib.Path(loc["specClone"]) if loc.get("specClone") else None if clone is None: - return fail("SPEC_CLONE_NOT_FOUND", - "No spec clone given and the skill is not installed from one. " - "Ask the user for the path of their local ably/specification clone (STOP-1).") - + return fail(loc["code"], loc["message"], **{k: v for k, v in loc.items() if k not in ("ok", "code", "message")}, + warnings=warnings) try: - sha = git(clone, "rev-parse", "HEAD").strip() - status = git(clone, "--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "--", "uts", - "specifications").splitlines() - last = git(clone, "log", "-1", "--format=%H %cd", "--", - "uts/docs/writing-uts-spec-translator-skills.md", SKILL_IN_CLONE.as_posix()).strip() - except (RuntimeError, OSError) as exc: - return fail("GIT_ERROR", str(exc), specClone=str(clone)) + st = esc.status(clone) + last = esc.git(clone, "log", "-1", "--format=%H %cd", "--", + REQUIRED["guide"], SKILL_IN_CLONE.as_posix()).strip() + except (esc.GitError, OSError) as exc: + return fail("GIT_ERROR", str(exc), specClone=str(clone), kind=loc.get("kind"), foundBy=loc.get("foundBy"), + warnings=warnings) + if not loc["ok"]: + return fail("SPEC_CLONE_INCOMPLETE", esc.incomplete_message(loc, st, clone), specClone=str(clone), + kind=loc["kind"], foundBy=loc["foundBy"], isSharedClone=loc.get("isSharedClone"), + syncEligible=loc.get("syncEligible"), notSyncedWarning=loc.get("notSyncedWarning"), + missing=loc["missing"], sha=st["head"], branch=st["branch"], detached=st["detached"], + upstream=st["upstream"], fastForwardBlockedBy=st["fastForwardBlockedBy"], + staleWarning=st["staleWarning"], stalenessLine=st["stalenessLine"], syncPolicy=esc.policy(), + warnings=warnings) clone_skill = clone / SKILL_IN_CLONE matches = None @@ -142,10 +139,27 @@ def main(argv): result = { "ok": True, "specClone": str(clone), - "foundBy": source, - "sha": sha, - "dirty": bool(status), - "dirtyFiles": [line[3:] for line in status], + "kind": loc["kind"], + "foundBy": loc["foundBy"], + "isSharedClone": loc["isSharedClone"], + "syncEligible": loc["syncEligible"], + "notSyncedWarning": loc["notSyncedWarning"], + "sha": st["head"], + "branch": st["branch"], + "detached": st["detached"], + "dirty": bool(st["dirtyFiles"]), + "dirtyFiles": st["dirtyFiles"], + "trackedChanges": st["trackedChanges"], + "operationInProgress": st["operationInProgress"], + "upstream": st["upstream"], + "onOriginMain": st["onOriginMain"], + "originUrl": st["originUrl"], + "originIsAblySpecification": st["originIsAblySpecification"], + "fastForwardBlockedBy": st["fastForwardBlockedBy"], + "headAgeDays": st["headAgeDays"], + "staleWarning": st["staleWarning"], + "stalenessLine": st["stalenessLine"], + "syncPolicy": esc.policy(), "guideLastChange": { "sha": last.split(" ", 1)[0] if last else None, "date": last.split(" ", 1)[1] if " " in last else None, @@ -155,7 +169,7 @@ def main(argv): "skillVersion": skill_version(skill_real), "skillMatchesClone": matches, "paths": {key: str(clone / rel) for key, rel in REQUIRED.items()}, - "warnings": [], + "warnings": warnings, } if matches is False: result["warnings"].append( diff --git a/uts/skills/uts-to-lang-skill-creator/scripts/spec_names.py b/uts/skills/uts-to-lang-skill-creator/scripts/spec_names.py index 547ecfd56..f6a1c937b 100755 --- a/uts/skills/uts-to-lang-skill-creator/scripts/spec_names.py +++ b/uts/skills/uts-to-lang-skill-creator/scripts/spec_names.py @@ -18,10 +18,13 @@ Rules: references/orient.md 13.8; LiveObjects categories: references/liveobjects-support.md 12.1. """ -import json, os, pathlib, re, subprocess, sys +import importlib.util, json, pathlib, re, subprocess, sys HERE = pathlib.Path(__file__).resolve().parent DATA = HERE.parent / "assets" / "capability-names.json" +_esc_spec = importlib.util.spec_from_file_location("ensure_spec_clone", HERE / "ensure_spec_clone.py") +esc = importlib.util.module_from_spec(_esc_spec) +_esc_spec.loader.exec_module(esc) TYPE_LINE = re.compile(r"^ (class|interface|enum)\s+((?:PublicAPI::)?[A-Za-z_]\w*)(?:<[^>]*>)?(?:\s+extends\s+[\w, ]+)?:?\s*" r"(?://\s*(.*))?$") MEMBER_LINE = re.compile(r"^ ([a-zA-Z_]\w*)\s*(\(|:)") @@ -61,7 +64,7 @@ class DataFileError(ValueError): class SpecCloneError(ValueError): - """An explicit spec clone path (--spec-clone or UTS_SPEC_CLONE) isn't an ably/specification clone. Never falls back.""" + """An explicit spec clone path (--spec-clone or ABLY_SPEC_CLONE) isn't an ably/specification clone. Never falls back.""" def _strip(value): @@ -122,27 +125,19 @@ def load_data(): return load_json(DATA, DATA_SHAPE) -def _clone_root(path): - """The spec clone containing path (path itself or a parent, e.g. /uts/objects), or None.""" - path = pathlib.Path(path).expanduser().resolve() - return next((p for p in [path, *path.parents] if (p / "specifications" / "features.md").is_file()), None) - - def locate(spec_clone=None): - """The spec clone: the argument, else UTS_SPEC_CLONE, else the clone this skill lives in. + """The spec clone, by ensure_spec_clone.py's precedence: the argument (walked up to its root), else the clone this + skill lives in, else ABLY_SPEC_CLONE (or the deprecated UTS_SPEC_CLONE), else ~/.ably/specification. - A path inside a clone is walked up to its root. An explicit path (the argument, or UTS_SPEC_CLONE when there is - no argument) that isn't in a clone raises SpecCloneError: it is never replaced by another candidate. + An explicit path (the argument, or ABLY_SPEC_CLONE) that isn't in a clone raises SpecCloneError: it is never + replaced by another candidate. None when no clone exists. """ - for how, cand in (("--spec-clone", spec_clone), ("UTS_SPEC_CLONE", os.environ.get("UTS_SPEC_CLONE"))): - if cand and str(cand).strip(): - root = _clone_root(cand) - if root is None: - raise SpecCloneError( - f"{cand} (from {how}) isn't an ably/specification clone: no specifications/features.md in it or " - "a parent directory") - return root - return _clone_root(HERE.parents[3]) + found = esc.locate(spec_clone) + if found.get("specClone"): + return pathlib.Path(found["specClone"]) + if found["code"] in ("NOT_A_SPEC_CLONE", "SPEC_CLONE_ENV_CONFLICT"): + raise SpecCloneError(found["message"]) + return None def revision(clone): @@ -303,7 +298,7 @@ def load(spec_clone=None): result = {"source": "spec", "specClone": str(clone) if clone else None, "warnings": [], "data": str(DATA)} try: if clone is None: - raise ValueError("no spec clone found (pass --spec-clone, or set UTS_SPEC_CLONE)") + raise ValueError("no spec clone found (pass --spec-clone, or set ABLY_SPEC_CLONE)") result["revision"] = revision(clone) features, hf = parse_idl(clone / "specifications" / "features.md") objects, ho = parse_idl(clone / "specifications" / "objects-features.md") From b7f1c919f5840cea069cd18b9d34fcc19faca66b Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 14 Oct 2026 23:51:34 +0530 Subject: [PATCH 2/5] UTS: fix the skill creator's inspector and Orient misreports Inspector: - X-fetch-main also catches docs or specs fetched with `gh api` or the GitHub contents API, counting as unpinned only fetches that read the default branch. - Harness self-tests are also found by location: untagged test files under the harness root that use a type the harness declares, reported as candidates to confirm by reading. - The X-* checks read only the skill's own files (SKILL.md, references/, scripts/), not the harness README. - CI evidence follows the scripts the workflows call and build tools named without a file (make with -C/-f, rake, cake or dotnet cake), matched only in commands and as options at the start of a word, and says which files it read. - New B-usage-guard check: a usage line and a stop when the argument is missing. New R-spec-sync check flags a generated skill with a missing or stale copy of ensure_spec_clone.py. Orient stays offline: it runs the eligibility check even without a clone, shows the clone's kind, branch and staleness (a WARNING after 30 days without a fetch, noting when the sync is off), reports leftover partial clones, and reports in specSync what the sync should offer after STOP-16. A clone that isn't the shared clone gets the static not-synced warning first and no sync offer. When the recorded spec SHA isn't in the clone, the State summary says so instead of dropping the Changes line, and S2 recommends the full gap audit, since diff-driven can't diff. --- .../scripts/inspect_existing_skill.py | 188 +++++++++++++++--- .../scripts/orient.py | 141 +++++++++++-- 2 files changed, 282 insertions(+), 47 deletions(-) diff --git a/uts/skills/uts-to-lang-skill-creator/scripts/inspect_existing_skill.py b/uts/skills/uts-to-lang-skill-creator/scripts/inspect_existing_skill.py index 31fa8c221..4e85facbb 100755 --- a/uts/skills/uts-to-lang-skill-creator/scripts/inspect_existing_skill.py +++ b/uts/skills/uts-to-lang-skill-creator/scripts/inspect_existing_skill.py @@ -5,15 +5,16 @@ A read-only first pass for STOP-13 and the gap audit. It finds uts-to-* skills in the agent skill directories (detect_liveobjects.SKILL_ROOTS) and any other -SKILL.md whose directory or `name:` starts with uts-to-, and runs regex checks -keyed to acceptance-checklist items: a "pass" is a pattern hit, not a review. +SKILL.md whose directory or `name:` starts with uts-to-, and runs checks keyed +to acceptance-checklist items, mostly regex (R-spec-sync compares a copied +script by hash): a "pass" is a pattern hit, not a review. `--records ` reads the working records from instead of /generation/ (for the named only, when one is given). It never runs the skill's scripts and never writes. Prints one JSON object: `ok`, `repo`, `head`, `skills` (per skill: installs, origin, records, `capabilityProfile`, language, frontmatter, files, mapping, -harness, checks, summary), `namedSkill`, `workingRecords`, `brokenInstalls`, +harness (with `selfTestCandidates` and `ciSources`), checks, summary), `namedSkill`, `workingRecords`, `brokenInstalls`, `orphanRecords`, `otherRepoSkills`, `repoLanguages`, `harnessCandidates`, `userLevelInstalls`, `derivedTests`, `note` and `found`. Exit 0 on success; 1 with {"ok": false} on an error (INSPECT_ERROR); 2 on a usage error. @@ -21,13 +22,14 @@ Rules: references/orient.md 13.3 (origin, S4), 13.8 (the recorded capability profile); references/upgrade-existing-skill.md 11.2 (the gap audit). """ -import collections, filecmp, importlib.util, json, os, pathlib, re, subprocess, sys +import collections, filecmp, hashlib, importlib.util, json, os, pathlib, re, subprocess, sys _spec = importlib.util.spec_from_file_location("detect_liveobjects", pathlib.Path(__file__).resolve().parent / "detect_liveobjects.py") lo = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(lo) +HERE = pathlib.Path(__file__).resolve().parent SKILL_ROOTS = lo.SKILL_ROOTS # one list for every script SELF = "uts-to-lang-skill-creator" NON_PORTABLE = ["argument-hint", "model", "disable-model-invocation", "user-invocable", "when_to_use", "context", @@ -70,10 +72,25 @@ "relies on $ARGUMENTS (Claude Code only)"), ("X-claude-path", "SKILL.md: usage guard ... own files referred to relative to the skill directory ...", r"\.claude/skills/", "hard-codes a .claude/skills/ path (breaks under Codex)"), - ("X-fetch-main", "Required reading incl. features specs, from the same clone", - r"(?i)(raw\.githubusercontent\.com/ably/specification|github\.com/ably/specification/(blob|tree)/main|fetch first)", - "fetches docs or specs from GitHub main instead of the local clone"), + ("X-fetch-main", "Required reading incl. features specs, from the same clone, synced first", + r"(?i)(raw\.githubusercontent\.com/ably/specification|github\.com/ably/specification/(blob|tree)/main|" + r"gh\s+api\b[^\n]*ably/specification|api\.github\.com/repos/ably/specification|" + r"repos/ably/specification/contents|fetch first)", + "fetches docs or specs from GitHub instead of the local clone"), ] +UNPINNED = re.compile(r"(?i)gh\s+api|api\.github\.com|/contents|/(blob|tree)/main|specification/(refs/heads/)?main/") +# A usage guard (guide 3.3 MUST): a usage line, and a stop when the argument is missing. +USAGE_LINE = re.compile(r"(?i)\busage:|usage line") +USAGE_STOP = re.compile(r"(?i)(\bno\b|missing|empty|blank|without)[^\n]{0,80}\bstop|\bstop[^\n]{0,60}" + r"(no|missing|empty|without)\b[^\n]{0,20}(argument|module|path)") +TEST_NAME = re.compile(r"(?i)(tests?|spec)\.[a-z]+$|^test_|_test\.[a-z]+$") +TYPE_DECL = re.compile(r"\b(?:class|struct|interface|protocol|object|enum|record|type)\s+([A-Z][A-Za-z0-9_]{3,})") +CI_SCRIPT = re.compile(r"(?:^|[\s\"'=(])(\.{0,2}/?[\w./-]+\.(?:sh|bash|ps1|cake|py|rb|js|mjs|mk|gradle|kts|bat|cmd))\b") +# Build tools named without a file: the entrypoint each reads by default (`make -C dir` / `-f file` respected). +CI_TOOL = re.compile(r"(?:^|[\s;&|(`\"'])(make|gmake|rake|cake|dotnet\s+cake)(?=[\s\"';&|)`]|$)([^\n;&|)]*)") +CI_PROSE = re.compile(r"^\s*(#|-?\s*name:)") # comments and step names: "make sure ..." is not a command +TOOL_FILES = {"make": ["GNUmakefile", "makefile", "Makefile"], "gmake": ["GNUmakefile", "makefile", "Makefile"], + "rake": ["Rakefile", "rakefile", "Rakefile.rb"], "cake": ["build.cake"]} def git_lines(repo, *args): @@ -119,6 +136,11 @@ def unquote(value): return value, False +def digest(path): + """sha256 of a text file with line endings normalised (a Windows copy compares equal).""" + return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() + + def check(checks, cid, item, ok, evidence, warn=False): status = "pass" if ok else ("warn" if warn else "fail") checks.append({"id": cid, "item": item, "status": status, "evidence": evidence}) @@ -204,7 +226,8 @@ def read_records(rec_dir): text = dr.read_text(encoding="utf-8", errors="replace") ours = bool(re.match(r"#\s*Design record:\s*uts-to-", text)) and "uts-to-lang-skill-creator version" in text status = re.search(r"(?im)^-?\s*Run status:\s*(.+)$", text) - spec = re.search(r"(?im)^-?\s*Spec clone:\s*([0-9a-f]{7,40})", text) + # "Spec clone: (…)", or "Spec clone: ; on …" (13.9) + spec = re.search(r"(?im)^-?\s*Spec clone:\s*(?:[^;\n]*;\s*)?([0-9a-f]{7,40})\b", text) repo_sha = re.search(r"(?im)^-?\s*Repo:\s*([0-9a-f]{7,40})", text) caps = re.search(r"(?im)^-?\s*Capabilities \(D-31\):\s*(.+)$", text) profile = None @@ -368,16 +391,24 @@ def inspect_skill(repo, real, installs, records_dir=None): hit = re.search(pattern, body_no_urls) check(checks, cid, item, bool(hit), meaning + (" found" if hit else " not found") + " (outside URLs)", warn=cid in WARN_ONLY) + has_usage, has_stop = bool(USAGE_LINE.search(body)), bool(USAGE_STOP.search(body)) + check(checks, "B-usage-guard", "SKILL.md: usage guard that doesn't rely on `$ARGUMENTS` ...", has_usage and has_stop, + ("a usage line " + ("found" if has_usage else "not found") + "; a stop when the argument is missing " + + ("found" if has_stop else "not found")), warn=has_usage != has_stop) + # X-* checks read only the skill's own files (guide 13): SKILL.md, references/ and scripts/. defect_sources = {"SKILL.md": body} - for ref in sorted((d / "references").glob("*.md")) if (d / "references").is_dir() else []: - defect_sources[f"references/{ref.name}"] = ref.read_text(encoding="utf-8", errors="replace") + for sub in ("references", "scripts"): + for ref in sorted(x for x in (d / sub).rglob("*") if x.is_file() and "__pycache__" not in x.parts + and x.suffix in (".md", ".py", ".sh", ".js", ".ts", ".rb", ".json")) \ + if (d / sub).is_dir() else []: + defect_sources[ref.relative_to(d).as_posix()] = ref.read_text(encoding="utf-8", errors="replace") lines = text.count("\n") + 1 check(checks, "B-length", "SKILL.md: ... follows the 3.4 outline", lines <= 500, f"{lines} lines (agent-skill guidance: keep SKILL.md under 500; move detail to references)", warn=True) files = {"SKILL.md": True} for rel in ("uts-package-mapping.json", "scripts/resolve_uts.py", "scripts/audit_translation.py", - "scripts/scan_constructs.py"): + "scripts/scan_constructs.py", "scripts/ensure_spec_clone.py"): files[rel] = (d / rel).is_file() check(checks, "L-mapping", "`uts-package-mapping.json`: ...", files["uts-package-mapping.json"], "present" if files["uts-package-mapping.json"] else "missing") @@ -456,6 +487,11 @@ def inspect_skill(repo, real, installs, records_dir=None): readme.read_text(encoding="utf-8", errors="replace"))), "tiers": {t: {"tests": (v or {}).get("tests") if isinstance(v, dict) else None} for t, v in tiers.items()}, } + if harness.get("root"): + listed = git_lines(repo, "ls-files", "-co", "--exclude-standard", "-z") or [] + by_name = smoke_by_name(listed, [str(harness["root"]).strip("/")]) + harness_info.update(smokeOrSelfTestFiles=by_name, selfTestCandidates=self_test_candidates( + repo, listed, [str(harness["root"]).strip("/")], by_name)) check(checks, "H-readme-known-gaps", "Harness README with a Known gaps section (MUST)", harness_info["knownGaps"], f"README {harness.get('readme')}: Known gaps " + ("found" if harness_info["knownGaps"] else "not found")) @@ -468,19 +504,35 @@ def inspect_skill(repo, real, installs, records_dir=None): + (", ".join(f"{r['path']} (Known gaps {'found' if r['knownGaps'] else 'not found'})" for r in readmes) or "none found"), warn=not readmes) if harness_info: - for r in ([harness_info["readme"]] if harness_info.get("readmeExists") else []) + \ - [x["path"] for x in harness_info.get("readmes", [])]: - defect_sources[r] = (repo / r).read_text(encoding="utf-8", errors="replace") + by_name, cands = harness_info.get("smokeOrSelfTestFiles"), harness_info.get("selfTestCandidates") check(checks, "H-smoke-self-tests", "Harness smoke tests per tier and helper self-tests ... (MUST)", - bool(harness_info.get("smokeOrSelfTestFiles")), - f"smoke/self-test files: {harness_info.get('smokeOrSelfTestFiles') or 'none found by name'}; " - f"CI lines: {harness_info.get('ciLines') or 'none'}") + bool(by_name), + f"smoke/self-test files: {by_name or 'none found by name'}" + + (f"; {len(cands)} candidate(s) by location, read to confirm: {cands}" if cands else "") + + f"; CI lines ({harness_info.get('ciSources') or 'workflow files only'}): " + f"{harness_info.get('ciLines') or 'none'}", warn=not by_name and bool(cands)) for cid, item, pattern, meaning in BODY_DEFECTS: - per_file = {k: len(re.findall(pattern, v)) for k, v in defect_sources.items()} - per_file = {k: v for k, v in per_file.items() if v} + per_file, unpinned = {}, 0 + for k, v in defect_sources.items(): + hits = list(re.finditer(pattern, v)) + if hits: + per_file[k] = len(hits) + if cid == "X-fetch-main": # an API fetch without ?ref=, or a URL on main, reads the default branch + for m in hits: + end = v.find("\n", m.end()) + line = v[v.rfind("\n", 0, m.start()) + 1:end if end != -1 else len(v)] + unpinned += bool(UNPINNED.search(v[m.start():m.end() + 20])) and "ref=" not in line check(checks, cid, item, not per_file, - "none" if not per_file else f"{meaning}: " + ", ".join(f"{k} ×{v}" for k, v in per_file.items())) - + "none" if not per_file else f"{meaning}: " + ", ".join(f"{k} ×{v}" for k, v in per_file.items()) + + (f" ({unpinned} unpinned: no ref=)" if unpinned else "")) + + ours, theirs = HERE / "ensure_spec_clone.py", d / "scripts" / "ensure_spec_clone.py" + synced = theirs.is_file() and digest(theirs) == digest(ours) + sync_state = ("matches the skill creator's copy" if synced else + "differs from the skill creator's copy: a change item (refresh it, D-32)" if theirs.is_file() else + "missing (guide 9.4 SHOULD, D-32)") + check(checks, "R-spec-sync", "Required reading incl. features specs, from the same clone, synced first", synced, + f"scripts/ensure_spec_clone.py {sync_state}", warn=True) codex = [i for i in installs if i["path"].startswith(".agents/")] claude = [i for i in installs if i["path"].startswith(".claude/")] for other in find_copies(repo, real, installs): @@ -613,16 +665,92 @@ def find_harness(repo): text = (repo / cand).read_text(encoding="utf-8", errors="replace") readmes.append({"path": cand, "knownGaps": bool(re.search(r"(?im)^#+\s*known gaps", text))}) break - smoke = [f for f in listed if SMOKE_NAME.search(f.rsplit("/", 1)[-1]) - and pathlib.PurePosixPath(f).suffix in SOURCE_EXT][:20] - ci = [] - for f in listed: - if f.startswith(".github/workflows/") and f.endswith((".yml", ".yaml")): - for n, line in enumerate((repo / f).read_text(encoding="utf-8", errors="replace").splitlines(), 1): - if re.search(r"(?i)\buts\b|smoke|self-?test", line): - ci.append(f"{f}:{n}: {line.strip()[:100]}") + smoke = smoke_by_name(listed) + roots = [r["path"].rsplit("/", 1)[0] if "/" in r["path"] else "." for r in readmes] + if not roots and dirs: + roots = [os.path.commonpath(list(dirs)).replace(os.sep, "/")] + ci, sources = ci_evidence(repo, listed) return {"fromMapping": False, "candidateDirs": dict(dirs.most_common(10)), "readmes": readmes, - "smokeOrSelfTestFiles": smoke, "ciLines": ci[:20]} + "smokeOrSelfTestFiles": smoke, "selfTestCandidates": self_test_candidates(repo, listed, roots, smoke), + "ciLines": ci[:20], "ciSources": sources} + + +def smoke_by_name(listed, roots=None): + """Source files named smoke or self-test (under roots, if given).""" + return [f for f in listed if SMOKE_NAME.search(f.rsplit("/", 1)[-1]) and pathlib.PurePosixPath(f).suffix in + SOURCE_EXT and (roots is None or any(under(f, r) for r in roots))][:20] + + +def under(path, root): + return root in ("", ".") or path == root or path.startswith(root.rstrip("/") + "/") + + +def has_tag(text): + return any(TAG.match(line) for line in text.splitlines()) + + +def self_test_candidates(repo, listed, roots, by_name): + """Untagged test files under the harness roots that use a type the harness declares: self-test candidates found + by location, not by name (read them to confirm).""" + files = [f for f in listed if pathlib.PurePosixPath(f).suffix in SOURCE_EXT and any(under(f, r) for r in roots) + and not any(p in EXCLUDED_DIRS for p in f.split("/")[:-1])] + texts = {} + for f in files: + try: + texts[f] = (repo / f).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + texts = {f: t for f, t in texts.items() if not has_tag(t)} + tests = [f for f in texts if TEST_NAME.search(f.rsplit("/", 1)[-1]) and f not in by_name] + declared = {f: set(TYPE_DECL.findall(t)) for f, t in texts.items() if f not in tests} + harness_types = set().union(*declared.values()) if declared else set() + found = [] + for f in tests: + own = set(TYPE_DECL.findall(texts[f])) + names = harness_types - own + if names and re.search(r"\b(" + "|".join(sorted(map(re.escape, names))) + r")\b", texts[f]): + found.append(f) + return found[:20] + + +def ci_evidence(repo, listed): + """UTS, smoke and self-test lines in the CI workflows and in the scripts they call (followed to depth 3).""" + tracked = set(listed) + queue = [f for f in listed if f.startswith(".github/workflows/") and f.endswith((".yml", ".yaml"))] + seen, scripts, ci = set(queue), [], [] + depth = {f: 0 for f in queue} + while queue: + f = queue.pop(0) + try: + text = (repo / f).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for n, line in enumerate(text.splitlines(), 1): + if re.search(r"(?i)\buts\b|smoke|self-?test", line): + ci.append(f"{f}:{n}: {line.strip()[:100]}") + if depth[f] >= 3: + continue + base = f.rsplit("/", 1)[0] if "/" in f else "" + refs = CI_SCRIPT.findall(text) + commands = "\n".join(line for line in text.splitlines() if not CI_PROSE.match(line)) + for tool, opts in CI_TOOL.findall(commands): + tool = tool.split()[-1] + where = re.search(r"(?:^|(?<=\s))(?:-C\s*|--directory[= ])([\w./-]+)", opts) # an option, not a target + given = re.search(r"(?:^|(?<=\s))(?:-f\s*|--(?:make)?file[= ])([\w./-]+)", opts) + folder = where.group(1).rstrip("/") + "/" if where else "" + refs += [folder + given.group(1)] if given else [folder + name for name in TOOL_FILES[tool]] + for ref in refs: + ref = ref[2:] if ref.startswith("./") else ref + for cand in (os.path.normpath(ref).replace(os.sep, "/"), + os.path.normpath(f"{base}/{ref}").replace(os.sep, "/") if base else None): + if cand and cand in tracked and cand not in seen: + seen.add(cand) + depth[cand] = depth[f] + 1 + scripts.append(cand) + queue.append(cand) + return ci, ("workflow files only" if not scripts else + f"workflow files and {len(scripts)} script(s) they call: {', '.join(scripts[:8])}" + + (", …" if len(scripts) > 8 else "")) def find_copies(repo, real, installs): diff --git a/uts/skills/uts-to-lang-skill-creator/scripts/orient.py b/uts/skills/uts-to-lang-skill-creator/scripts/orient.py index 1149c6faf..df79b941f 100755 --- a/uts/skills/uts-to-lang-skill-creator/scripts/orient.py +++ b/uts/skills/uts-to-lang-skill-creator/scripts/orient.py @@ -12,17 +12,23 @@ wins, with re-entry as an overlay) and compares the current capability profile with the recorded one. +Runs the eligibility check even when no spec clone is found, so STOP-16 is +settled before any offer to clone one. Never syncs the clone: `specSync` says +what Step 0's sync (ensure_spec_clone.py, after STOP-16) should offer; only the +shared clone is synced, and any other clone gets a static warning. + Prints one JSON object: `ok`, `repo`, `eligibility`, `warnings`, `state` (class, re-entry, reasons, recommended option, options), `stateSummaryText` (the State summary shown at STOP-13), `stop1`, `stop16`, `stop17`, -`capabilityDelta` and the facts behind them. `--full` adds `raw`: the outputs +`specSync`, `capabilityDelta` and the facts behind them. `--full` adds `raw`: the outputs of spec_clone_info, inspect_existing_skill and the two detectors (the eligibility output is always under `eligibility`). Exit 0 whenever it classifies; 1 with {"ok": false} on NOT_A_SPEC_CLONE, DATA_FILE_ERROR or ORIENT_ERROR; 2 on a usage error. Read-only; never touches the network. Rules: references/orient.md 13.1 (what runs), 13.3 (state classes), 13.4 (the -State summary), 13.6 (re-entry), 13.8 (capability delta, STOP-17). +State summary), 13.6 (re-entry), 13.8 (capability delta, STOP-17), 13.9 (the +spec-clone sync). """ import json, pathlib, re, subprocess, sys @@ -116,6 +122,7 @@ def skill_view(s, repo): ("candidates: " + ", ".join(r["path"] for r in harness.get("readmes", [])) if harness.get("readmes") else "none found")), "smokeOrSelfTestFiles": len(smoke) if isinstance(smoke, list) else None, + "selfTestCandidates": len(harness.get("selfTestCandidates") or []), "tiersLackingSmoke": tiers_lacking_smoke(tiers, smoke), "named": bool(s.get("named")), } @@ -243,12 +250,18 @@ def classify(elig, spec, insp, skills, named_only=False): return {"class": "INELIGIBLE", "reentry": False, "reasons": [elig.get("message")], "recommended": None, "options": []}, stop1 if not spec or not spec.get("ok"): - stop1.append(f"spec clone: {(spec or {}).get('code', 'unknown error')}: ask for the clone's path") - else: - if spec.get("skillMatchesClone") is False: - stop1.append("the installed skill differs from the clone's copy: refresh the install or confirm") - if spec.get("foundBy") != "argument": - stop1.append(f"confirm the spec clone found by {spec.get('foundBy')}: {spec.get('specClone')}") + code = (spec or {}).get("code", "unknown error") + if code == "SPEC_CLONE_NOT_FOUND": + stop1.append(f"spec clone not found: after STOP-16, offer to clone it to {spec.get('bootstrapPath')} " + "(with STOP-6, 13.9), or ask for the path of an existing clone" + + (f"; partial clone(s) left by an interrupted bootstrap, for the user to remove: " + f"{', '.join(spec['partialLeftovers'])}" if spec.get("partialLeftovers") else "")) + elif code == "SPEC_CLONE_INCOMPLETE": + stop1.append(f"spec clone too old for this skill: {spec.get('message')}") + else: + stop1.append(f"spec clone: {code}: ask for the clone's path") + elif spec.get("skillMatchesClone") is False: + stop1.append("the installed skill differs from the clone's copy: refresh the install or confirm") broken, orphans = insp.get("brokenInstalls", []), insp.get("orphanRecords", []) mismatch = [s for s in skills if s["languageMatchesRepo"] is False] finished_orphans = [o for o in orphans if o["runStatus"] == "finished"] @@ -330,8 +343,58 @@ def eligibility_line(elig): return f"Eligibility {elig.get('decision')}: {what}{repo} ({reason}){names}; STOP-16" +def clone_label(spec): + """How the State summary names the clone's kind (13.4).""" + shared = spec.get("isSharedClone") + return {"shared": "shared clone", "env": f"shared clone ({spec.get('foundBy')})", + "explicit": "named clone" + (", the shared clone" if shared else ""), + "skill-clone": "skill clone" + (", the shared clone" if shared else " (not the shared clone)")}.get( + spec.get("kind"), spec.get("kind") or "?") + + +def sync_plan(spec): + """What Step 0's spec-clone sync offers after STOP-16 (13.9), from the offline status. Never runs it.""" + if not spec: + return None + script = "python3 /scripts/ensure_spec_clone.py" + if spec.get("code") == "SPEC_CLONE_NOT_FOUND" and spec.get("bootstrapPath"): + path = spec["bootstrapPath"] + return {"step": "bootstrap", "ask": "STOP-1 with STOP-6, after STOP-16", "bootstrapPath": path, + "partialLeftovers": spec.get("partialLeftovers") or [], + "command": f"{script} --bootstrap", + "options": ["give the path of an existing clone", + f"clone ably/specification to {path} (network: about 30 MiB from github.com)", "stop"], + "summary": "bootstrap offered after STOP-16"} + if not spec.get("specClone") or spec.get("code") not in (None, "SPEC_CLONE_INCOMPLETE"): + return {"step": "none", "summary": "none until the clone is settled at STOP-1"} + if not spec.get("syncEligible"): # only the shared clone is synced, however it was found + return {"step": "none", "synced": False, "warning": spec.get("notSyncedWarning"), + "summary": "not synced (not the shared clone)"} + policy, blocked = spec.get("syncPolicy") or "ask", spec.get("fastForwardBlockedBy") or [] + arg = f" --spec-clone {spec['specClone']}" if spec.get("kind") == "explicit" else "" + if policy == "off": + return {"step": "none", "policy": "off", "summary": "not updated (ABLY_SPEC_SYNC=off)"} + if "ORIGIN_NOT_ABLY_SPEC" in blocked: + return {"step": "ask", "policy": policy, "blockedBy": blocked, "ask": "STOP-6, after STOP-16", + "options": ["use it as it is", "stop"], "command": None, + "summary": "origin isn't ably/specification: no fetch; ask"} + if blocked: + return {"step": "ask" if policy == "ask" else "none", "policy": policy, "blockedBy": blocked, + "ask": "STOP-6, after STOP-16" if policy == "ask" else None, "command": f"{script} --fetch{arg}", + "options": ["use it as it is", "fetch only (network: updates origin/main; no branch or file changes)", + "stop"] if policy == "ask" else None, + "summary": f"no fast-forward ({', '.join(blocked)}): " + + ("ask: use as is, fetch only, or stop" if policy == "ask" else "used as is (auto)")} + return {"step": "ask" if policy == "ask" else "update", "policy": policy, "blockedBy": [], + "ask": "STOP-6, after STOP-16" if policy == "ask" else None, "command": f"{script} --update{arg}", + "options": ["yes, this run: fetch and fast-forward main", "no, use it as it is", + "always: the user sets ABLY_SPEC_SYNC=auto", "never ask: the user sets ABLY_SPEC_SYNC=off"] + if policy == "ask" else None, + "summary": "ask at STOP-6 after STOP-16" if policy == "ask" else "fetch and fast-forward (auto)"} + + def summary_text(repo_name, head, dirty, elig, spec, insp, skills, lo, changes, state, caps=None, delta=None, - added=(), lowered=(), stop17=None, others=None): + added=(), lowered=(), stop17=None, others=None, sync=None): if state["class"] == "INELIGIBLE": return state["reasons"][0] dt = insp.get("derivedTests", {}) @@ -340,6 +403,12 @@ def summary_text(repo_name, head, dirty, elig, spec, insp, skills, lo, changes, sources = {src.get("namesSource") for src in (lo, caps) if src and src.get("namesSource")} fallback = bool(sources) and sources != {"spec"} lines = [] + if (spec or {}).get("notSyncedWarning"): + lines.append(f"WARNING SPEC CLONE NOT SYNCED: {spec['notSyncedWarning']}") + elif (spec or {}).get("staleWarning"): + lines.append(f"WARNING STALE SPEC CLONE: {spec['staleWarning']}; " + + ("the sync is off (ABLY_SPEC_SYNC=off)" if spec.get("syncPolicy") == "off" else + "offer the sync at STOP-6 (13.9)")) if fallback: lines.append("WARNING SPEC NAMES FALLBACK: the spec clone's IDL wasn't parsed; the LiveObjects and capability " "verdicts below are unreliable; settle it at STOP-1 first") @@ -347,15 +416,24 @@ def summary_text(repo_name, head, dirty, elig, spec, insp, skills, lo, changes, f"Repo {repo_name} @ {(head or '?')[:8]} ({'dirty' if dirty else 'clean'}); languages: {langs}", eligibility_line(elig), (f"Spec clone {spec.get('specClone')} @ {(spec.get('sha') or '?')[:8]} " - f"({'dirty' if spec.get('dirty') else 'clean'}); found by {spec.get('foundBy')}; " + f"({'dirty' if spec.get('dirty') else 'clean'}); {clone_label(spec)}; " + f"{'branch ' + spec['branch'] if spec.get('branch') else 'detached'}; found by {spec.get('foundBy')}; " f"creator {spec.get('skillVersion')} (matches clone: " f"{ {True: 'yes', False: 'no'}.get(spec.get('skillMatchesClone'), 'unknown')})" if (spec or {}).get("ok") else f"Spec clone {spec['specClone']} (not usable: {spec.get('code')}: {spec.get('message')}); settle it at " "STOP-1" if (spec or {}).get("specClone") else - f"Spec clone NOT FOUND ({(spec or {}).get('code', 'unknown error')}): ask for its path at STOP-1"), - "Model (state the session's model; Opus-class required)", + f"Spec clone {'NOT FOUND' if (spec or {}).get('code') == 'SPEC_CLONE_NOT_FOUND' else 'NOT USABLE'} " + f"({(spec or {}).get('code', 'unknown error')}): " + + (f"no clone at {spec['bootstrapPath']}; after STOP-16, offer to clone it there (STOP-1 with STOP-6, 13.9) " + "or ask for its path" if (spec or {}).get("bootstrapPath") else + ((spec or {}).get("message") or "") + "; settle it at STOP-1")), ] + if (spec or {}).get("stalenessLine"): + lines.append(f" {spec['stalenessLine']}; " + + (f"sync: {(sync or {}).get('summary', 'none')}" if spec.get("syncEligible") else + "not synced (not the shared clone)")) + lines.append("Model (state the session's model; Opus-class required)") if sources: rev = ((caps or lo or {}).get("specRevision") or {}).get("sha") read = (caps or lo or {}).get("specClone") @@ -408,6 +486,8 @@ def summary_text(repo_name, head, dirty, elig, spec, insp, skills, lo, changes, f" objects notes: {s['objectsNotes']}") lack = s["tiersLackingSmoke"] lines.append(f"Harness {s['harness']}; smoke/self-test files: {s['smokeOrSelfTestFiles']}" + + (f" by name; {s['selfTestCandidates']} candidate(s) by location (read to confirm)" + if s.get("selfTestCandidates") else "") + (f"; offered tiers lacking them: {lack if isinstance(lack, str) else ', '.join(lack)}" + (" (some files don't name a tier)" if lack == "unknown" else "") if lack else "")) if s["records"]: @@ -425,7 +505,15 @@ def summary_text(repo_name, head, dirty, elig, spec, insp, skills, lo, changes, problems += [f"{d}: another uts-to-* skill, not classified (--skill-dir)" for d in others or []] if problems: lines.append("Problems " + "; ".join(problems)) - if changes and changes.get("anchorPresent"): + if changes and not changes.get("anchorPresent"): + if changes.get("anchorSpec"): # a recorded SHA this clone lacks; None when the records name no SHA + fetch = ("fetch it (STOP-6, 13.9)" if (spec or {}).get("syncEligible") else + "the user fetches it (this clone isn't synced)") + lines.append(f"Changes recorded spec {changes['anchorSpec'][:8]} isn't in this clone: {fetch}, or " + "run a full gap audit") + else: + lines.append("Changes the records name no spec SHA: run a full gap audit") + elif changes: lines.append(f"Changes since recorded spec {changes['anchorSpec'][:8]}: guide {changes['guide']}, UTS docs " f"{changes['utsDocs']}, helper specs {changes['helperSpecs']}, corpus {changes['corpus']} files; " f"checklist changed: {'yes' if changes['checklistChanged'] else 'no'}" @@ -498,7 +586,7 @@ def main(argv): print(json.dumps({"ok": False, "code": "NOT_A_SPEC_CLONE", "message": spec.get("message"), "missing": spec.get("missing"), "fix": "pass the path of a local ably/specification clone (or a directory inside " - "one) with --spec-clone, or correct UTS_SPEC_CLONE, then run again"}, indent=2)) + "one) with --spec-clone, or correct ABLY_SPEC_CLONE, then run again"}, indent=2)) return 1 # Set on success, and on GIT_ERROR (a spec copy that isn't a git checkout): never drop an explicit clone. clone = (spec or {}).get("specClone") @@ -573,13 +661,32 @@ def main(argv): changes.get(k) for k in ("guide", "utsDocs", "helperSpecs", "corpus", "repo")): state["reasons"].append("nothing changed since the recorded run") state["recommended"] = "Stop" + if state["class"] == "S2" and not state["reentry"] and changes and not changes.get("anchorPresent"): + if changes.get("anchorSpec"): + fetch = "fetch it (STOP-6)" if (spec or {}).get("syncEligible") else \ + "the user fetches it (this clone isn't synced)" + state["reasons"].append(f"recorded spec {changes['anchorSpec'][:8]} isn't in this clone: diff-driven " + f"can't diff; {fetch} and rerun, or run the full gap audit") + else: + state["reasons"].append("the records name no spec SHA: diff-driven can't diff; run the full gap audit") + state["recommended"] = "Upgrade/Fix, full gap audit (section 11)" + sync = sync_plan(spec) + if (spec or {}).get("notSyncedWarning"): + warnings.append(f"spec clone: {spec['notSyncedWarning']}") + if (spec or {}).get("staleWarning"): + warnings.append(f"spec clone: {spec['staleWarning']}") + warnings += [f"spec clone: {w}" for w in (spec or {}).get("warnings", [])] head = (git(repo, "rev-parse", "HEAD") or "").strip() or None dirty = bool((git(repo, "--no-optional-locks", "status", "--porcelain") or "").strip()) stop16 = None if not elig or elig.get("decision") == "accept" else (elig.get("reason") or elig.get("message")) result.update( head=head, dirty=dirty, stop16=stop16, - stop1=stop1, spec={k: (spec or {}).get(k) for k in ("ok", "code", "specClone", "foundBy", "sha", "dirty", - "skillVersion", "skillMatchesClone")}, + stop1=stop1, spec={k: (spec or {}).get(k) for k in ( + "ok", "code", "specClone", "kind", "foundBy", "isSharedClone", "syncEligible", "notSyncedWarning", + "sha", "branch", "detached", "dirty", + "upstream", "onOriginMain", "fastForwardBlockedBy", "staleWarning", "syncPolicy", "bootstrapPath", + "partialLeftovers", + "missing", "skillVersion", "skillMatchesClone")}, specSync=sync, repoLanguages=insp.get("repoLanguages"), skills=skills, brokenInstalls=insp.get("brokenInstalls"), orphanRecords=insp.get("orphanRecords"), otherRepoSkills=insp.get("otherRepoSkills"), unclassifiedSkills=others, @@ -601,7 +708,7 @@ def main(argv): "summary": (lo or {}).get("summary")}), changesSinceRecorded=changes, state=state) result["stateSummaryText"] = summary_text(repo.name, head, dirty, elig, spec, insp, skills, lo, changes, state, - caps, delta, added, lowered, stop17, others) + caps, delta, added, lowered, stop17, others, sync) if "--full" in flags: result["raw"] = {"spec": spec, "inspect": insp, "liveObjects": lo, "capabilities": caps} print(json.dumps(result, indent=2, ensure_ascii=False)) From addbdba0f6ae075a2f719f8d9cfee232e1096551 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 14 Oct 2026 23:51:34 +0530 Subject: [PATCH 3/5] UTS: document the skill creator's spec-clone sync New references/orient.md 13.9 holds the rules: which clone (an explicit path, the clone the skill is installed from, ABLY_SPEC_CLONE, ~/.ably/specification), the bootstrap offer, the one question per run through STOP-6 (or the user's ABLY_SPEC_SYNC, with how auto and off handle a missing recorded spec SHA), the fast-forward-only update rule, its outcomes, staleness, what is recorded, and concurrency. It states that only the shared clone is synced; any other clone gets a static warning and is the user's to keep current. 13.4 lists every Spec clone and Changes line variant and every exit-1 code. SKILL.md gains the sync as a Step 0 item between STOP-16 and STOP-1, an exception for it in 2.2, STOP-6 from Phase 0, and a shorter 2.6. New decision D-32 ships a copy of ensure_spec_clone.py in generated skills: Phase 5 copies it and writes the step 0 row, Phase 6 path 11 exercises the sync paths, and Upgrade/Fix pre-fills it. The glossary, failure modes, templates and Phase 4 reference follow. --- uts/skills/uts-to-lang-skill-creator/SKILL.md | 40 +++--- .../assets/templates/acceptance-checklist.md | 2 +- .../assets/templates/design-record.md | 3 +- .../assets/templates/final-report.md | 3 +- .../assets/templates/repo-profile.md | 2 +- .../assets/templates/skill-gap-audit.md | 3 +- .../references/failure-modes.md | 7 ++ .../references/glossary.md | 2 + .../references/orient.md | 118 +++++++++++++++--- .../references/phase-4-design-record.md | 3 +- .../references/phase-5-generate-skill.md | 8 +- .../references/phase-6-validate-skill.md | 3 +- .../references/upgrade-diff-driven.md | 2 + .../references/upgrade-existing-skill.md | 2 +- 14 files changed, 151 insertions(+), 47 deletions(-) diff --git a/uts/skills/uts-to-lang-skill-creator/SKILL.md b/uts/skills/uts-to-lang-skill-creator/SKILL.md index 146dda483..3257df547 100644 --- a/uts/skills/uts-to-lang-skill-creator/SKILL.md +++ b/uts/skills/uts-to-lang-skill-creator/SKILL.md @@ -1,8 +1,8 @@ --- name: uts-to-lang-skill-creator -description: "Builds or upgrades a uts-to-LANG translator skill, with the UTS test harness it runs on, for one Ably Pub/Sub SDK repository on this skill's whitelist (C#, Python, Go, JavaScript, Ruby, ...). Use it whenever asked to create a uts-to-* skill; to audit, fix, upgrade or refresh one after the guide, UTS docs, corpus or SDK changed; to add LiveObjects (uts/objects) support; or to set up UTS test infrastructure (SDK test hooks, MockHttpClient, MockWebSocket, fake clock, sandbox and proxy helpers, harness smoke tests and self-tests). First checks the repo is on the whitelist, then detects what it has (uts-to-* skills and their origin, harness, UTS-tagged tests, REST and Realtime capabilities and scope, LiveObjects API) and recommends Create or Upgrade/Fix, then runs a procedure with user stop points. Needs a local ably/specification clone. Not for Chat, Spaces, AI Transport, CLI or other non-Pub/Sub repos, for translating specs once a uts-to-* skill exists (use that skill), or for writing UTS specs." +description: "Builds or upgrades a uts-to-LANG translator skill, with the UTS test harness it runs on, for one Ably Pub/Sub SDK repository on this skill's whitelist (C#, Python, Go, JavaScript, Ruby, ...). Use it whenever asked to create a uts-to-* skill; to audit, fix, upgrade or refresh one after the guide, UTS docs, corpus or SDK changed; to add LiveObjects (uts/objects) support; or to set up UTS test infrastructure (SDK test hooks, MockHttpClient, MockWebSocket, fake clock, sandbox and proxy helpers, harness smoke tests and self-tests). First checks the repo is on the whitelist, then detects what it has (uts-to-* skills and their origin, harness, UTS-tagged tests, REST and Realtime capabilities and scope, LiveObjects API) and recommends Create or Upgrade/Fix, then runs a procedure with user stop points. Needs a local (synced) ably/specification clone. Not for Chat, Spaces, AI Transport, CLI or other non-Pub/Sub repos, for translating specs once a uts-to-* skill exists (use that skill), or for writing UTS specs." license: Apache-2.0 -compatibility: "Claude Code or Codex, on an Opus-class model. Needs shell and file access, git, Python 3.8+, a local clone of ably/specification, and the target SDK's build toolchain." +compatibility: "Claude Code or Codex, on an Opus-class model. Needs shell and file access, git, Python 3.8+, a local clone of ably/specification (cloned or updated on approval), and the target SDK's build toolchain." allowed-tools: Read Grep Glob metadata: team: "engineering" @@ -30,12 +30,13 @@ The harness and the skill are one deliverable (guide [section 2](../../docs/writ ## Step 0: Orient -Do this first, in every run, before any phase. It is read-only: no build, no test, no network, no file written. Run `python3 /scripts/orient.py [--spec-clone ]` ([13.1](references/orient.md#131-run-it)), then settle its stops in this order; [references/orient.md](references/orient.md) (section 13) has the rules: +Do this first, in every run, before any phase. `orient.py` is read-only: no build, no test, no network, no file written. Run `python3 /scripts/orient.py [--spec-clone ]` ([13.1](references/orient.md#131-run-it)), then settle its stops in this order; [references/orient.md](references/orient.md) (section 13) has the rules: 1. **STOP-16**, if the repo isn't plainly eligible ([13.2](references/orient.md#132-repository-eligibility-stop-16)). On a reject, show the script's message exactly and end the run; there is no override. -2. **STOP-1**, if needed: the model tier (2.8) and anything in `stop1`, such as the spec clone (2.6). -3. **STOP-17**, when `stop17` is set: the capability profile and scope ([13.8](references/orient.md#138-capabilities-and-scope-stop-17-d-31)). Record D-31. -4. **STOP-13**, always: the State summary and the recommended mode ([13.4](references/orient.md#134-the-state-summary-and-stop-13)). Record D-27, then route per [13.5](references/orient.md#135-routing). +2. **The spec-clone sync** ([13.9](references/orient.md#139-sync-the-spec-clone)): Step 0's only network use, for the shared clone only, after STOP-6 (not asked if `ABLY_SPEC_SYNC` is `auto` or `off`); rerun `orient.py` if HEAD moved. +3. **STOP-1**, if needed: the model tier (2.8) and anything in `stop1`, such as the spec clone (2.6). +4. **STOP-17**, when `stop17` is set: the capability profile and scope ([13.8](references/orient.md#138-capabilities-and-scope-stop-17-d-31)). Record D-31. +5. **STOP-13**, always: the State summary and the recommended mode ([13.4](references/orient.md#134-the-state-summary-and-stop-13)). Record D-27, then route per [13.5](references/orient.md#135-routing). ## 2. Ground rules @@ -63,7 +64,7 @@ and, only as a last resort, the reference implementations. | Change CI workflows, build scripts that CI runs, or dependency manifests (including adding a test dependency) | **Only with explicit approval** ([STOP-5](#23-stop-points)). Otherwise write the change as a suggestion in the Final report | | Edit project or solution files needed to register new test files or projects (`.sln`, `.csproj`, `.projitems`, an Xcode project) | Only with explicit approval ([STOP-5](#23-stop-points)), recorded in the Design record (D-20). If the user declines, give them the exact entries to add, wait until they confirm, and list them in the Final report | | Stage, commit, push, branch, or open a PR | **No, unless the user asks** | -| Edit anything in the spec clone | No. Report spec errors and doc gaps in the Final report with a draft fix | +| Edit anything in the spec clone | No, except the shared clone's sync (13.9). Report spec errors and doc gaps in the Final report with a draft fix | | Delete or rewrite existing tests or existing UTS-derived tests | No, unless the user asks. Report their state in the Repo profile | | Edit, replace or delete files of an existing `uts-to-*` skill or UTS harness that this run didn't create | Only for the items the user chose at STOP-15 (D-29) or the harness rows approved at STOP-3; the objects files that the STOP-14 decision (D-28) changes; when regenerating, replacements the user approves ([11.5](references/upgrade-existing-skill.md#115-step-u4-run-the-phases-scoped)); the provenance stamp in its frontmatter (D-30). Never delete without asking | | Run the full test suite, or any integration or proxy test (network, sandbox, proxy binary download) | Ask first ([STOP-6](#23-stop-points)) | @@ -75,12 +76,12 @@ At a stop point, present what you found and the options, then wait for the user' | ID | Phase | When | What you present | |---|---|---|---| -| **STOP-1** | 0 (Orient) | A required input is missing, the session's model isn't Opus-class ([2.8](#28-model-tier-and-sub-agents)), or `spec_clone_info.py` fails or reports `skillMatchesClone: false` ([2.6](#26-pin-the-spec-clone)) | The missing input (or the model found) and why it's needed | +| **STOP-1** | 0 (Orient) | A required input is missing, the session's model isn't Opus-class ([2.8](#28-model-tier-and-sub-agents)), or no usable spec clone is found (offer to clone it, 13.9), or `skillMatchesClone: false` ([2.6](#26-pin-the-spec-clone)) | The missing input (or the model found) and why it's needed | | **STOP-2** | 1 (step 1d) | The Repo profile is written | The profile, for confirmation or correction | | **STOP-3** | 2 (end) | The harness design is written | The gap table, the design and three options: (a) build the missing harness now, (b) plan it only, (c) scope the skill to the tiers possible today | | **STOP-4** | 3 onwards | Before any SDK production-code change | The hook or visibility proposal ([4.6](references/phase-2-design-harness.md#46-step-2f-propose-the-sdk-test-hooks)) | | **STOP-5** | 3 onwards | Before any CI, build-script, dependency-manifest or project/solution-file change (including creating a test project or adding one to a solution) | The diff you propose and why | -| **STOP-6** | 1 onwards | Before any network access (network tests, proxy releases, a reference implementation on GitHub or fetching its clone, [2.1](#21-your-inputs)), or running a full suite | The command, the expected duration, what it touches; for reference reads, whether the approval covers the whole run | +| **STOP-6** | 0 onwards | Before any network access (the shared clone's sync, 13.9, unless `ABLY_SPEC_SYNC` is `auto` or `off`; network tests, proxy releases, a reference implementation on GitHub or fetching its clone, [2.1](#21-your-inputs)), or running a full suite | The command, the expected duration, what it touches; for reference reads and the sync, whether the approval covers the whole run | | **STOP-7** | 3 (end) | The harness is built | The build results (smoke tests and self-tests per tier, CI wiring, updated tier feasibility), before any skill file is written | | **STOP-8** | 4 (end) | The Design record is written | The Design record, for confirmation | | **STOP-9** | 5 | Pseudocode constructs, helper symbols or spec shapes you can't map ([7.4](references/phase-5-generate-skill.md#74-step-5d-fill-the-construct-catalogue)); batch everything one scan finds into one stop | Each construct, where it occurs, a proposed rendering | @@ -90,7 +91,7 @@ At a stop point, present what you found and the options, then wait for the user' | **STOP-13** | 0 (Orient), every run | Always ([13.4](references/orient.md#134-the-state-summary-and-stop-13)) | The State summary, the state class (S0–S4, re-entry) and the options for it, with the recommended one: Create; Upgrade/Fix (diff-driven or full gap audit); regenerate from scratch; resume or restart; for S4, pick a skill, create a new skill or repair the installs first; stop | | **STOP-14** | 1 (step 1e), every run | Always, in every mode ([12.2](references/liveobjects-support.md#122-stop-14-recommend-then-ask)) | The `detect_liveobjects.py` evidence (names per category, with files and counts), its recommendation and yours; options: full objects support (translate-only if incomplete), placeholder, none now | | **STOP-15** | 1 (step 1f), Upgrade/Fix | The gap audit, or the list of detected changes (diff-driven), is written ([11.4](references/upgrade-existing-skill.md#114-step-u3-stop-15-choose-per-item), [section 10](references/upgrade-diff-driven.md#10-upgradefix-diff-driven)) | Every non-conforming item or detected change, with the proposed change and the options add, skip or defer (a skipped or deferred MUST row leaves the skill not conforming); or regenerate from scratch instead | -| **STOP-16** | 0 (Orient), first stop after the spec clone resolves | `check_repo_eligibility.py` rejects the repo, or can't decide ([13.2](references/orient.md#132-repository-eligibility-stop-16)) | Reject: the script's exact message (not on the whitelist; or, for `NO_CLIENT_DEFINITION`, no client defined in this checkout); the run ends, with no override. Ask: the remotes found and the question (confirm a fork, name the GitHub repository, check the path) | +| **STOP-16** | 0 (Orient), first stop, before the spec-clone sync | `check_repo_eligibility.py` rejects the repo, or can't decide ([13.2](references/orient.md#132-repository-eligibility-stop-16)) | Reject: the script's exact message (not on the whitelist; or, for `NO_CLIENT_DEFINITION`, no client defined in this checkout); the run ends, with no override. Ask: the remotes found and the question (confirm a fork, name the GitHub repository, check the path) | | **STOP-17** | 0 (Orient), with STOP-13 | Orient sets `stop17`: the capability profile needs confirming ([13.8](references/orient.md#138-capabilities-and-scope-stop-17-d-31) lists the triggers) | The profile (REST, Realtime and its sub-areas, with evidence), the suggested scope (modules; `unsupported` modules; capability-inapplicable tests) and any delta; options: accept, correct with evidence and rerun, stop. With doors, also the side question: the core constructors (sanctioned internal-access route), the server door, the device door, or both as a parameter; the scope follows the side | ### 2.4 Never guess @@ -108,11 +109,7 @@ Every choice you make that isn't forced by the guide or the UTS docs goes into t ### 2.6 Pin the spec clone -Orient runs `scripts/spec_clone_info.py` with the path the user gave, if any. It locates the clone, checks that the guide, the UTS docs and the helper specs exist, and reads the clone's git state; [13.1](references/orient.md#131-run-it) gives the discovery order, the outcomes and the stop or confirmation each needs. - -Record the SHA, whether the clone is clean (and, if not, which files under `uts/` or `specifications/` are modified or untracked), the guide's and this skill's last-change commit (`guideLastChange`) and `skillVersion`. Read every spec-repo file at the absolute paths the script reports (`paths`), or under `specClone`, and from this clone for the whole run. A spec you will translate that is itself modified or untracked is handled as in guide [9.1](../../docs/writing-uts-spec-translator-skills.md#91-record-what-you-translated-from-must) (stop and ask; if the user proceeds, stamp its blob hash). Compare with `origin/main` only if the user agrees to a `git fetch`; never pull or check out on their behalf. If the clone changes during the run, stop and ask whether to restart from the new SHA. - -Untracked local notes in the clone (for example files matching `uts/UTS_*.md`) are not part of the corpus. Don't read them as specs. +Step 0 syncs the shared clone (no other clone is synced), then Orient pins it: [13.9](references/orient.md#139-sync-the-spec-clone) gives the precedence, the update rule and the outcomes. Record what the sync and `spec_clone_info.py` report: the SHA, the branch, the sync result, the dirty files under `uts/` and `specifications/`, `guideLastChange` and `skillVersion`. Read every spec-repo file at the paths it reports (`paths`), from this clone, for the whole run. A spec you will translate that is modified or untracked is handled as in guide [9.1](../../docs/writing-uts-spec-translator-skills.md#91-record-what-you-translated-from-must). Never fetch, pull or check out outside 13.9. If HEAD moves during the run, stop and ask whether to restart from the new SHA. Untracked notes in the clone (such as `uts/UTS_*.md`) aren't specs. ### 2.7 Working style @@ -148,7 +145,7 @@ Before starting a phase, read its reference file in full: this table is a map, n | 1. Understand the repo | A confirmed Repo profile: how the SDK is built, tested and mocked today; the LiveObjects decision; in Upgrade/Fix, the gap audit or the change list | [phase-1](references/phase-1-understand-repo.md) (and sections 10, 11, 12) | `repo-profile.md`; in Upgrade/Fix `skill-gap-audit.md` | STOP-2, STOP-14 (STOP-15 in Upgrade/Fix) | | 2. Design the harness | Reuse, wrap, extend or build each capability; hooks; placement; tier feasibility | [phase-2](references/phase-2-design-harness.md) | `uts-infra-design.md` | STOP-3 | | 3. Build and verify the harness | The approved harness, with green per-tier smoke tests and self-tests, wired into CI | [phase-3](references/phase-3-build-harness.md) | harness code and README; build log | STOP-7 | -| 4. Skill design decisions | D-01 to D-26 decided; D-27 to D-31 confirmed | [phase-4](references/phase-4-design-record.md) | `design-record.md` | STOP-8 | +| 4. Skill design decisions | D-01 to D-26 and D-32 decided; D-27 to D-31 confirmed | [phase-4](references/phase-4-design-record.md) | `design-record.md` | STOP-8 | | 5. Generate the skill files | Mapping, resolver, audit, construct catalogue, module notes, `SKILL.md` | [phase-5](references/phase-5-generate-skill.md) | the `uts-to-` skill | (STOP-9 if constructs are unmapped) | | 6. Validate the skill | Resolver, audit mutations, pilots, other paths, lint, examples | [phase-6](references/phase-6-validate-skill.md) | pilot tests; results | (STOP-10 at a bound) | | 7. Final report and handover | Report and acceptance checklist | [section 9](#9-phase-7-final-report-and-handover) | `final-report.md` with the acceptance checklist | — | @@ -166,12 +163,12 @@ Before starting a phase, read its reference file in full: this table is a map, n The user starts a run in the target SDK repo with one sentence, for example: ``` -/uts-to-lang-skill-creator ~/src/specification realtime unit first (Claude Code; $uts-to-lang-skill-creator in Codex) +/uts-to-lang-skill-creator realtime unit first (Claude Code; $uts-to-lang-skill-creator in Codex) Audit our uts-to-python skill against the guide and upgrade it; add LiveObjects support. Read /uts/skills/uts-to-lang-skill-creator/SKILL.md and follow it to create a uts-to- skill for this repo. (skill not installed) ``` -`` is the user's local clone of `ably/specification`. Take it, and any goals ("objects unit tier first", "rest and realtime only"), from the user's words, and record the goals as inputs. Installation is described in [`uts/README.md`](../../README.md#installing-the-skill-creator). +`` is a local clone of `ably/specification`: by default the shared clone ([13.9](references/orient.md#139-sync-the-spec-clone)), so naming one is optional; a clone the user names isn't synced unless it is the shared clone, and otherwise Orient warns them to keep it up to date. Take any path the user names (`--spec-clone `, or a path in their words), and any goals ("objects unit tier first", "rest and realtime only"), from the user's words, and record the goals as inputs. Installation is described in [`uts/README.md`](../../README.md#installing-the-skill-creator). ### 1.3 Required inputs @@ -179,7 +176,7 @@ Read /uts/skills/uts-to-lang-skill-creator/SKILL.md and follow it to |---|---|---| | Target SDK repo path | the current working directory | Ask. Don't assume the working directory is the SDK repo: Orient checks that it is an eligible Ably Pub/Sub SDK repository with SDK sources and tests (STOP-16) | | Language (and the skill name it implies) | C# → `uts-to-csharp`; Python → `uts-to-python` | Infer from the repo in Phase 1, then confirm | -| Local spec clone path | `~/src/specification` (the directory that contains `uts/` and `specifications/`) | Located by `scripts/spec_clone_info.py` ([2.6](#26-pin-the-spec-clone)) and confirmed with the user; otherwise stop and ask ([STOP-1](#23-stop-points)). Never fetch the spec or the UTS docs from GitHub instead | +| Local spec clone path | `~/.ably/specification` (the directory that contains `uts/` and `specifications/`) | Optional. Located by `scripts/spec_clone_info.py` (precedence: argument, this skill's clone, `ABLY_SPEC_CLONE`, `~/.ably/specification`), or cloned there on approval ([13.9](references/orient.md#139-sync-the-spec-clone)). Only the shared clone is synced. Never fetch the spec or the UTS docs file by file from GitHub | | User goals: modules and tiers wanted first | "realtime unit, then rest unit; objects later" | Ask. Default to every module (`rest`, `realtime`, `objects`) and every tier the SDK can support, prioritised by the user | | Model tier | The session's model | See [2.8](#28-model-tier-and-sub-agents) (STOP-1 if it isn't Opus-class) | | Local clones of ably-cocoa and ably-java (now `ably-pubsub-java`) (optional) | Clones of either repo, read at their current HEAD (ideally a recent `main`) | Not needed to start. Ask only if you reach the last-resort reference reads ([2.1](#21-your-inputs)); without clones, those reads go to GitHub after STOP-6 | @@ -187,7 +184,7 @@ Read /uts/skills/uts-to-lang-skill-creator/SKILL.md and follow it to ### 1.4 Outputs 1. **The harness** for this language, in the target repo: the shared test library implementing the helper specs, wait and assertion helpers, sandbox and proxy helpers, module helpers, the per-tier smoke tests and helper self-tests (guide [2.7](../../docs/writing-uts-spec-translator-skills.md#27-harness-smoke-tests-and-self-tests-must); permanent and run in CI), a harness README with its Known gaps and, with explicit approval, SDK test hooks. Built in Phase 3, unless the user scopes it down at STOP-3. -2. **The skill** (in Upgrade/Fix, the existing skill, upgraded in place), at `.claude/skills/uts-to-/`, linked from `.agents/skills/uts-to-/` for Codex, unless D-01 says otherwise: `SKILL.md`, `uts-package-mapping.json`, `scripts/resolve_uts.py`, `scripts/audit_translation.py`, `references/-mapping.md` and, if D-19 ships it, `scripts/scan_constructs.py` (guide [3.1](../../docs/writing-uts-spec-translator-skills.md#31-layout)). +2. **The skill** (in Upgrade/Fix, the existing skill, upgraded in place), at `.claude/skills/uts-to-/`, linked from `.agents/skills/uts-to-/` for Codex, unless D-01 says otherwise: `SKILL.md`, `uts-package-mapping.json`, `scripts/resolve_uts.py`, `scripts/audit_translation.py`, `references/-mapping.md`, `scripts/scan_constructs.py` if D-19 ships it, and `scripts/ensure_spec_clone.py` per D-32 (guide [3.1](../../docs/writing-uts-spec-translator-skills.md#31-layout)). 3. **Pilot-translated tests**, one spec per available tier (in Upgrade/Fix, per tier whose rules changed, 11.6), produced by the new skill in Phase 6. 4. **Working records** (*procedure recommendation*): Markdown files in the working-records directory (default `.claude/skills/uts-to-/generation/`; D-24, confirmed in Phase 4). Copy each template from `assets/templates/` there under the same file name, then fill it in: - [repo-profile](assets/templates/repo-profile.md) and [design-record](assets/templates/design-record.md) (which holds the decision log), at the start of Phase 1; @@ -252,12 +249,13 @@ Each phase's reference (sections 3 to 8) is linked from [Phases at a glance](#ph ## Scripts -All are read-only Python 3, with no network access; they print to stdout and run from anywhere as `python3 /scripts/.py`. `--help` prints a script's docstring (purpose, usage, output and exit codes) and exits 0; a usage error prints the usage to stderr and exits 2. Each row's linked section has the rules. +All but `ensure_spec_clone.py` are read-only Python 3, with no network access; they print to stdout and run from anywhere as `python3 /scripts/.py`. `--help` prints a script's docstring (purpose, usage, output and exit codes) and exits 0; a usage error prints the usage to stderr and exits 2. Each row's linked section has the rules. | Script | Use | |---|---| | `orient.py [--spec-clone P] [--skill-dir P] [--records P] [--include-submodules] [--full]` | Step 0: runs `spec_clone_info.py`, the eligibility check, the inspector and both detectors, classifies the repo, recommends a mode ([13.1](references/orient.md#131-run-it)) | | `spec_clone_info.py [SPEC_CLONE \| --spec-clone P]` | Locate, validate and pin the spec clone; `warnings` is a list, possibly empty ([13.1](references/orient.md#131-run-it)) | +| `ensure_spec_clone.py [--spec-clone P] [--status \| --fetch \| --update \| --bootstrap]` | Step 0's sync of the shared clone (no other clone): the only script that uses the network or changes the spec clone, and only on approval ([13.9](references/orient.md#139-sync-the-spec-clone)); copied into the generated skill (D-32) | | `check_repo_eligibility.py [--spec-clone P]` | Is the repository on the whitelist? ([13.2](references/orient.md#132-repository-eligibility-stop-16)) | | `detect_capabilities.py [--spec-clone P] [--include-submodules] [--capability-override rest=full,realtime=absent]` | Capability profile, door sides, suggested scope ([13.8](references/orient.md#138-capabilities-and-scope-stop-17-d-31)) | | `spec_names.py [--spec-clone P]` | The detectors' names, from the spec clone's IDL, and the capability-inapplicable tests ([13.8](references/orient.md#138-capabilities-and-scope-stop-17-d-31)) | diff --git a/uts/skills/uts-to-lang-skill-creator/assets/templates/acceptance-checklist.md b/uts/skills/uts-to-lang-skill-creator/assets/templates/acceptance-checklist.md index 6e65d8957..2da4d83f9 100644 --- a/uts/skills/uts-to-lang-skill-creator/assets/templates/acceptance-checklist.md +++ b/uts/skills/uts-to-lang-skill-creator/assets/templates/acceptance-checklist.md @@ -24,7 +24,7 @@ | Module notes for every diverging module; `objects` decided from evidence of the LiveObjects public API (full notes if exposed, even if incomplete; a placeholder or an entry with every tier marked not ready otherwise) | 7.5; D-23; 12.1–12.3; D-28 (decided at STOP-14) | 7.5 acceptance; 8.5 paths 3–4 | | | Scripts run on every developer platform (path handling, UTF-8, LF) | 7.1–7.3; D-02 | 8.1 (path parts, `~`), 8.6 (platforms) | | | **Workflow** | | | | -| Required reading incl. features specs, from the same clone | 7.6 (step 0) | 8.3 pilot | | +| Required reading incl. features specs, from the same clone, synced first (SHOULD; D-32) | 7.6 (step 0) | 8.3 pilot; 8.5 path 11 | | | Steps 0 and A–F: the four questions and the harness preflight (stop on red) | 7.6 | 8.3 pilot (green preflight first); 8.5 paths 8–9 | | | Steps 1–7 with batching; a reference test per tier; review in both modes | 7.6; 8.8 | 8.3 pilot; 8.8 | | | Unmapped constructs stop and ask; corpus scanner in `scripts/` (SHOULD) | 7.4; 7.6; D-19 | 8.3 pilot; 8.5 (re-sync) | | diff --git a/uts/skills/uts-to-lang-skill-creator/assets/templates/design-record.md b/uts/skills/uts-to-lang-skill-creator/assets/templates/design-record.md index 812cbddd4..845472147 100644 --- a/uts/skills/uts-to-lang-skill-creator/assets/templates/design-record.md +++ b/uts/skills/uts-to-lang-skill-creator/assets/templates/design-record.md @@ -1,7 +1,7 @@ # Design record: uts-to- ## Inputs at generation time -- Spec clone: (clean | dirty: ); guide/skill: ; uts-to-lang-skill-creator version +- Spec clone: ; on (clean | dirty: ); on origin/main: ; sync: .. | up to date | not updated: , behind as of last fetch | not synced (not the shared clone)>; guide/skill: ; uts-to-lang-skill-creator version - Repo: - Repo profile confirmed: ; harness option at STOP-3: (a | b | c); harness confirmed: - Harness README: @@ -47,6 +47,7 @@ | D-29 | Gap-audit choices: add / skip / defer per GA- or CH- item; preserve list (STOP-15; n/a in create mode) | | | | | | D-30 | Provenance stamp (metadata.generated-by, metadata.records) and Run status | | | | | | D-31 | Capability profile and skill scope (STOP-13 or STOP-17) | | | | | +| D-32 | Spec-clone sync in the generated skill (ensure_spec_clone.py copied; ABLY_SPEC_SYNC) | | | | | ### D-03 mapping | Module | unit | integration | proxy | notes | hand-maintained? | diff --git a/uts/skills/uts-to-lang-skill-creator/assets/templates/final-report.md b/uts/skills/uts-to-lang-skill-creator/assets/templates/final-report.md index c2ac1dd1d..cd304ab24 100644 --- a/uts/skills/uts-to-lang-skill-creator/assets/templates/final-report.md +++ b/uts/skills/uts-to-lang-skill-creator/assets/templates/final-report.md @@ -1,6 +1,7 @@ # Final report: uts-to- for -- Spec clone (clean | dirty: ); repo ; date +- Spec clone: ; on (clean | dirty: ); on origin/main: ; sync: .. | up to date | not updated: , behind as of last fetch | not synced (not the shared clone)>; repo ; date +- Spec SHA not on origin/main (a branch run): - Model: (Opus-class: yes | no, and why); sub-agents: , or none - Scope: ; out of scope and why - Conforms to the guide: yes | no (MUST rows ✗: …) diff --git a/uts/skills/uts-to-lang-skill-creator/assets/templates/repo-profile.md b/uts/skills/uts-to-lang-skill-creator/assets/templates/repo-profile.md index 11fce9759..2ecbfb3da 100644 --- a/uts/skills/uts-to-lang-skill-creator/assets/templates/repo-profile.md +++ b/uts/skills/uts-to-lang-skill-creator/assets/templates/repo-profile.md @@ -4,7 +4,7 @@ (paste orient.py's stateSummaryText, completed with the model, the capability profile confirmed at STOP-13 or STOP-17 (D-31), and the option chosen at STOP-13) - Repo: , HEAD (clean | dirty: ) -- Spec clone: , HEAD (clean | dirty: ); guide/skill last changed in ; skill version +- Spec clone: ; on (clean | dirty: ); on origin/main: ; sync: .. | up to date | not updated: , behind as of last fetch | not synced (not the shared clone)>; guide/skill last changed in ; skill version - Date: ; confirmed by the user on (STOP-2) - User goals: diff --git a/uts/skills/uts-to-lang-skill-creator/assets/templates/skill-gap-audit.md b/uts/skills/uts-to-lang-skill-creator/assets/templates/skill-gap-audit.md index ea52bab38..5c56c54d0 100644 --- a/uts/skills/uts-to-lang-skill-creator/assets/templates/skill-gap-audit.md +++ b/uts/skills/uts-to-lang-skill-creator/assets/templates/skill-gap-audit.md @@ -1,7 +1,8 @@ # Gap audit: uts-to- in - Existing skill: (installs: <.claude/skills/…, .agents/skills/… (symlink | copy)>); frontmatter version -- Repo (clean | dirty: ); spec clone (clean | dirty: ) +- Repo (clean | dirty: ) +- Spec clone: ; on (clean | dirty: ); on origin/main: ; sync: .. | up to date | not updated: , behind as of last fetch | not synced (not the shared clone)> - State class (Orient): S2 | S3 | S4; mode at STOP-13: upgrade: diff-driven | upgrade: full audit | upgrade: regenerate — chosen by on - inspect_existing_skill.py run: ; its checks are regex hints, every row below is confirmed by reading - Capabilities: recorded D-31 vs Orient now ; delta: diff --git a/uts/skills/uts-to-lang-skill-creator/references/failure-modes.md b/uts/skills/uts-to-lang-skill-creator/references/failure-modes.md index 4ae3e8084..e948e8411 100644 --- a/uts/skills/uts-to-lang-skill-creator/references/failure-modes.md +++ b/uts/skills/uts-to-lang-skill-creator/references/failure-modes.md @@ -39,6 +39,13 @@ All from the guide and the UTS docs; follow the link for the detail. | Orient offers to resume a run that finished | The Design record's Run status wasn't set to finished at Phase 7 | Restart, or set the Run status after checking the Final report | [13.6](orient.md#136-re-entry-resume-or-restart) | | A REST-only SDK reported with realtime, or a full SDK reported REST-only | Realtime names only in comments, tests or docs; renamed clients the detector doesn't know; a client only reachable through a factory in an excluded directory or submodule | Read the evidence; correct the profile at STOP-17 with evidence; add a new client name to `aliases` in `assets/capability-names.json` (not the code) | [13.8](orient.md#138-capabilities-and-scope-stop-17-d-31) | | Orient's Spec names line says FALLBACK, or a renamed spec type isn't found | The spec clone's IDL couldn't be parsed (a missing heading, a moved file, an old clone), so the detectors used the data file's minimal, possibly stale names | Fix or update the spec clone at STOP-1 and rerun; if the IDL's format changed, report the parser gap. Don't rely on the LiveObjects or capability verdicts until it says `spec` | [13.4](orient.md#134-the-state-summary-and-stop-13), [13.8](orient.md#138-capabilities-and-scope-stop-17-d-31) | +| The spec-clone sync says `FETCH_FAILED` although the machine is online | A sandbox denies writes under the clone's `.git` or network access; or a credential or SSH prompt was suppressed | Use the clone as it is for this run; the user allows the directory and network (Claude Code `/add-dir` or `permissions.additionalDirectories`; Codex a writable root and network), or fixes the remote's auth, then reruns | [13.9](orient.md#139-sync-the-spec-clone) | +| The sync says `NOT_SYNCED_NAMED_CLONE`, or Orient shows SPEC CLONE NOT SYNCED | The clone in use isn't the shared clone (a path the user named, or a skill installed from another clone): only the shared clone is synced | Expected. The user keeps that clone up to date; to have it synced, use the shared clone instead (no path, a path into it, or `ABLY_SPEC_CLONE` pointing at it) | [13.9](orient.md#139-sync-the-spec-clone) | +| The sync says `LOCKED` | Another run is syncing the same clone, or a crashed run left its lock | Use the clone as it is; if no other run is syncing, the user removes the reported lock file | [13.9](orient.md#139-sync-the-spec-clone) | +| The sync says `NOT_ON_MAIN`, and nobody meant to leave the shared clone on a branch | The clone was switched to test a spec change and left there | Use it as it is, or stop while the user switches it back; the sync never switches. To test branches, use a worktree of the shared clone instead | [13.9](orient.md#139-sync-the-spec-clone) | +| The spec clone's HEAD moved mid-run | This run's sync, or another run's fast-forward of the shared clone | Stop and ask whether to restart from the new SHA; never stamp the old SHA on files read after the move | [13.9](orient.md#139-sync-the-spec-clone), [2.6](../SKILL.md#26-pin-the-spec-clone) | +| Orient says the recorded spec SHA isn't in this clone | The records came from another clone, or from a branch whose commits were since deleted | In the shared clone, fetch the SHA after STOP-6 and rerun Orient; if it is still missing, run the full gap audit. In any other clone, never fetch: the user fetches it, or run the full gap audit | [13.9](orient.md#139-sync-the-spec-clone) | +| `SPEC_CLONE_INCOMPLETE` | The clone's checkout predates the guide or this skill (for example a `main` from before they were merged) | Follow the message: for the shared clone, sync it if `origin/main` has them; otherwise name a clone or worktree of a branch that has them | [13.9](orient.md#139-sync-the-spec-clone) | | Sides reported for an SDK without doors, or a door's side wrong | A factory or door type whose name or path the data file doesn't know, or a door type under an unexpected path | Read the Side lines' evidence; correct the side at STOP-17; add the door type or path to `doorFactories.sides` in `assets/capability-names.json` | [13.8](orient.md#138-capabilities-and-scope-stop-17-d-31) | | A harness defect reported as SDK-blocked | The objects smoke test fails, and it is blamed on the SDK without checking | SDK-blocked needs every self-test green and evidence naming an SDK not-implemented symbol; otherwise it is a harness stop | [12.5](liveobjects-support.md#125-an-sdk-blocked-objects-tier) | | `objects` translated with no notes in a later run | No mapping entry, so the skill's step B offered `--create` | Give `objects` an entry even when out of scope: every tier unready, with the reason | [12.2](liveobjects-support.md#122-stop-14-recommend-then-ask) | diff --git a/uts/skills/uts-to-lang-skill-creator/references/glossary.md b/uts/skills/uts-to-lang-skill-creator/references/glossary.md index 1cffff2d3..0c6e9e6c9 100644 --- a/uts/skills/uts-to-lang-skill-creator/references/glossary.md +++ b/uts/skills/uts-to-lang-skill-creator/references/glossary.md @@ -31,6 +31,8 @@ Part of the `uts-to-lang-skill-creator` skill: read [SKILL.md](../SKILL.md) firs | **Delegating spec/test** | One that says to run another module's tests against this client | | **Re-sync** | Re-auditing a module against a newer spec SHA and regenerating what changed | | **Spec SHA** | The commit of the local spec clone that a test, or the skill, was derived from | +| **Shared spec clone** | The one local clone of `ably/specification` that every run and every SDK repo reads by default: `ABLY_SPEC_CLONE`, else `~/.ably/specification`; a path the user names, or the clone this skill is installed from, comes first, and is the shared clone only if it is that same directory ([13.9](orient.md#139-sync-the-spec-clone)) | +| **Spec-clone sync** | Step 0's only network use (including fetching a recorded spec SHA missing from the clone), for the shared clone only (any other clone gets a static warning and is never fetched): with approval (or the user's `ABLY_SPEC_SYNC=auto`), fetch `origin main` and fast-forward a clean, checked-out `main` that is strictly behind; anything else is reported and left alone. `ensure_spec_clone.py` does it ([13.9](orient.md#139-sync-the-spec-clone)) | | **Phase N / generated-skill Phase 1–2** | "Phase N" (0–7) is a phase of this procedure; Phase 0 is Orient, also called Step 0 (the generated skill's own step 0 is named with its context: "guide 4 step 0" or "generated-skill step 0"). "Generated-skill Phase 1" (selection, steps 0 and A–F) and "generated-skill Phase 2" (per spec, steps 1–7) are the generated `uts-to-` skill's workflow (guide [4](../../../docs/writing-uts-spec-translator-skills.md#4-the-workflow-the-skill-must-implement)). `writing-derived-tests.md` Phase 1/2 is always named with that document | | **Working records** | Repo profile, Harness design (with the gap table), Design record, Final report; in Upgrade/Fix, also the gap audit | | **Stop point** | A STOP-n point where you present what you found and the options, wait for the user's answer and record it ([2.3](../SKILL.md#23-stop-points)) | diff --git a/uts/skills/uts-to-lang-skill-creator/references/orient.md b/uts/skills/uts-to-lang-skill-creator/references/orient.md index 4854a085b..fef212f8c 100644 --- a/uts/skills/uts-to-lang-skill-creator/references/orient.md +++ b/uts/skills/uts-to-lang-skill-creator/references/orient.md @@ -10,7 +10,7 @@ Part of the `uts-to-lang-skill-creator` skill: read [SKILL.md](../SKILL.md) firs - which mode to run in (**Create** or **Upgrade/Fix**); - for Upgrade/Fix, which existing skill to work on. -Orient is read-only and quick. It builds nothing, runs no tests, touches no network and writes no file. The working records are created only after STOP-13, because their location depends on the answer (D-24). +Orient is read-only and quick. It builds nothing, runs no tests, touches no network and writes no file. The spec-clone sync after STOP-16 (13.9) is separate. The working records are created only after STOP-13, because their location depends on the answer (D-24). Contents: @@ -22,34 +22,37 @@ Contents: - [13.6 Re-entry: resume or restart](#136-re-entry-resume-or-restart) - [13.7 Provenance (D-30)](#137-provenance-d-30) - [13.8 Capabilities and scope (STOP-17, D-31)](#138-capabilities-and-scope-stop-17-d-31) +- [13.9 Sync the spec clone](#139-sync-the-spec-clone) ## 13.1 Run it Run `python3 /scripts/orient.py [--spec-clone ]`. Pass the spec clone the user named, if any. Add `--skill-dir ` when the user names the skill, `--records ` when they say where its working records are (with `--skill-dir`, it applies to the named skill only), `--include-submodules` to scan submodule sources too, and `--full` for the raw script outputs. The script runs this skill's read-only scripts in order and prints one JSON object: -1. `spec_clone_info.py`: resolves the spec clone every other script reads, and settles STOP-1 ([2.6](../SKILL.md#26-pin-the-spec-clone)). -2. `check_repo_eligibility.py`: on a reject, nothing else runs. +1. `spec_clone_info.py`: resolves the spec clone every other script reads, offline ([2.6](../SKILL.md#26-pin-the-spec-clone)). +2. `check_repo_eligibility.py`, with or without a clone: on a reject, nothing else runs. 3. `inspect_existing_skill.py`: skills, their origin and records, harness, UTS-tagged tests. 4. `detect_liveobjects.py`: a one-line LiveObjects verdict. 5. `detect_capabilities.py`: the REST and Realtime capability profile, the sides, and the suggested scope (13.8), with eligibility's `capabilityOverride` if the whitelist gives one. -The clone `spec_clone_info.py` resolved (the argument, walked up to the clone root, else `UTS_SPEC_CLONE`, else the clone this skill is symlinked from) is passed to the eligibility check and to both detectors, which read their names from it (13.8); the State summary's Spec clone line shows that path. An explicit path (the argument or `UTS_SPEC_CLONE`) that isn't in a clone stops Orient with `NOT_A_SPEC_CLONE`: it is never replaced by another clone. A malformed data file in `assets/` (`DATA_FILE_ERROR`, naming the file and the key) stops Orient with `ok: false`: fix the file before anything else ([Maintaining this skill](../SKILL.md#maintaining-this-skill)). +The clone `spec_clone_info.py` resolved (the argument, walked up to the clone root, else the clone this skill is installed from, else `ABLY_SPEC_CLONE`, else the shared default `~/.ably/specification`; 13.9) is passed to the eligibility check and to both detectors, which read their names from it (13.8); the State summary's Spec clone line shows that path. An explicit path (the argument or `ABLY_SPEC_CLONE`) that isn't in a clone stops Orient with `NOT_A_SPEC_CLONE`: it is never replaced by another clone. When no clone is found, Orient still runs the eligibility check, so STOP-16 is settled before any offer to clone one. A malformed data file in `assets/` (`DATA_FILE_ERROR`, naming the file and the key) stops Orient with `ok: false`: fix the file before anything else ([Maintaining this skill](../SKILL.md#maintaining-this-skill)). -`spec_clone_info.py [ | --spec-clone ]` checks that the guide, the UTS docs and the helper specs exist, and runs `git rev-parse HEAD`, `git status --porcelain -- uts specifications` and `git log -1 --format='%H %cd'` on the guide and this skill ([2.6](../SKILL.md#26-pin-the-spec-clone) says what to record). Its outcomes: +`spec_clone_info.py [ | --spec-clone ]` checks that the guide, this skill, the UTS docs and the helper specs exist, reads the clone's git state offline (`ensure_spec_clone.py`'s status: branch, tracked changes, the last fetch of `origin/main`), and runs `git log -1 --format='%H %cd'` on the guide and this skill ([2.6](../SKILL.md#26-pin-the-spec-clone) says what to record). Its outcomes: -- Any `ok: false` (for example `SPEC_CLONE_NOT_FOUND`, `NOT_A_SPEC_CLONE`, `GIT_ERROR`): STOP-1; ask for the clone's path. -- `foundBy` other than `argument`: tell the user which clone was found and how, and confirm it before Phase 1. +- `SPEC_CLONE_NOT_FOUND` (nothing at `ABLY_SPEC_CLONE`, or at the default): after STOP-16, the STOP-1 bootstrap offer (13.9). +- `SPEC_CLONE_INCOMPLETE` (a revision that predates the guide or this skill): STOP-1, with the script's message (13.9). +- Any other `ok: false` (`NOT_A_SPEC_CLONE`, `SPEC_CLONE_ENV_CONFLICT`, `GIT_ERROR`): STOP-1; ask for the clone's path. +- `foundBy` other than `argument`: shown in the State summary (with the clone's kind) and confirmed with the STOP-13 answer. - `skillMatchesClone: false` (an installed copy that differs from this clone's copy of the skill, also reported in `warnings`): STOP-1; ask the user to refresh the install, or to confirm which version to follow. -- `skillMatchesClone: null` (the clone has no copy of this skill, for example an older checkout): tell the user and confirm before Phase 1. The JSON has `state` (class, re-entry, reasons, recommended option, options) and `stateSummaryText`, the same under Claude Code and Codex. The facts behind them are in the other fields. If Orient stopped with `NOT_A_SPEC_CLONE`, settle the clone's path (STOP-1) and rerun Orient first. Then settle the stops in this order, in as few messages as possible: 1. **STOP-16** (13.2), if the repo isn't plainly eligible. -2. **STOP-1** for the model (2.8) and for anything listed in `stop1`, such as the spec clone's path or a stale install of this skill. -3. **STOP-17** (13.8), when `stop17` is set: the capability profile needs confirming. -4. **STOP-13** (13.4). +2. **The spec-clone sync** (13.9), as `specSync` describes; rerun Orient if it moved HEAD or created the clone. +3. **STOP-1** for the model (2.8) and for anything listed in `stop1`, such as the spec clone's path or a stale install of this skill. +4. **STOP-17** (13.8), when `stop17` is set: the capability profile needs confirming. +5. **STOP-13** (13.4). If the user's request conflicts with the state (for example "create a skill" when one exists), say so at STOP-13. @@ -115,11 +118,15 @@ A version number alone proves nothing. Show `stateSummaryText`, completed with the session's model. Its layout: ``` +WARNING SPEC CLONE NOT SYNCED: Using (). It isn't synced; make sure its UTS specs are up to date. (only for a clone that isn't the shared clone, 13.9) +WARNING STALE SPEC CLONE: ; offer the sync at STOP-6 (13.9) | the sync is off (ABLY_SPEC_SYNC=off) (only for the shared clone, when the last fetch is over 30 days old, or unknown) WARNING SPEC NAMES FALLBACK: the spec clone's IDL wasn't parsed; the LiveObjects and capability verdicts below are unreliable; settle it at STOP-1 first (only on fallback) Repo @ (clean|dirty); languages: Eligibility accept: / via ( name)[; canonical ][; planned rename ][; capability override rest , realtime ] | ask: [ /] (); STOP-16 -Spec clone @ (clean|dirty); found by ; creator (matches clone: ) +Spec clone @ (clean|dirty); ; branch | detached; found by ; creator (matches clone: ) + HEAD committed days ago; origin/main as last fetched days ago was commit(s) ahead; the real gap may be larger; sync: | not synced (not the shared clone) + | NOT FOUND (SPEC_CLONE_NOT_FOUND): no clone at ; after STOP-16, offer to clone it there … | NOT USABLE (): ; settle it at STOP-1 | (not usable: : ) Model (Opus-class: yes|no) Spec names from the spec clone's IDL @ | from the spec clone's IDL (revision unknown: not a git checkout) | FALLBACK, don't rely on the LiveObjects or capability verdicts: Capabilities rest (); realtime (; connection …, channels …, presence …); [; sides: (rest yes|no, realtime yes|no), …][; source: override (whitelist)] @@ -131,12 +138,13 @@ Scope : (objects per Capability changed since the recorded run: new>, …[; added: ][; lower than recorded: ] (only if it changed) Skill [installs: …] name , version , origin (one per skill) inspector hints: fail, warn,

pass (regex, not a review); mapping: ; objects notes: -Harness ; smoke/self-test files: [; offered tiers lacking them: | unknown (some files don't name a tier)] +Harness ; smoke/self-test files: [ by name; candidate(s) by location (read to confirm)][; offered tiers lacking them: | unknown (some files don't name a tier)] Records

: run ; recorded spec , repo (only if the skill has records) Skills none[; other repo skills: …] (instead of the Skill lines, when there is no skill) Harness (with "Skills none") Problems (only if any) Changes since recorded spec : guide , UTS docs , helper specs , corpus files; checklist changed: [; repo files] + | recorded spec isn't in this clone: fetch it (STOP-6, 13.9) | the user fetches it (this clone isn't synced), or run a full gap audit | the records name no spec SHA: run a full gap audit UTS tests tags in files (); ; header SHAs …; deviations.md: … LiveObjects (): (decided at STOP-14) User-level @@ -146,6 +154,8 @@ Recommended