From 55e9fd42269dc2002ed3d7c4e8e0c4d8b315eeb0 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 28 Aug 2026 18:44:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(clone):=20add=20a=20`sync`=20mode=20?= =?UTF-8?q?=E2=80=94=20replay=20the=20reference's=20generic=20diff=20onto?= =?UTF-8?q?=20the=20born=20siblings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Birth copies the reference once. Nothing re-synced afterwards, so the four copies of skills/start-new-project.md and wiki/project/* grew four distinct hashes and the autofit_assistant copy diverged by ~343 lines. The conductor listed those files as _SHARED_GENERIC but had only a birth mode. `clone sync` closes that loop for the generic set, and ONLY that set: - it takes the REFERENCE's own diff over a commit range, restricted to that reference's `generic` patterns (reusing REFERENCE_PROFILES/match_any, not a second list), rewrites the names in it for each sibling, and applies it with GNU patch; - it is not an overwrite. A hunk the sibling has adapted away is REJECTED and reported by number — on --apply it lands as a .rej for a human. A sibling's domain adaptation outranks the reference's prose, and a conflict is a human decision, never a silent resolution; - per file it reports applied / created / already-applied / rejected / absent / skipped / unsupported, so the report says what a human must look at; - dry run is the default; --apply writes; exit 1 means something was rejected. "Since the sibling's last sync" is read from the sibling's own history via a `Clone-sync: @` commit trailer — no state file, and the pointer travels with the commit that consumed the patch. A first sync needs an explicit --since rather than a guessed range. The substitutions add the UPPERCASE rule birth omits, which is why a sibling born from autolens_assistant still names $AUTOLENS_ASSISTANT in its generated project scaffold; synced lines are right even where the old ones are not. tests/test_clone_sync.py: 9 hermetic tests over a temp workspace of three fake assistants, one of which has adapted the prose the reference changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GZceRRca9F5ojAtjpRp3me --- AGENTS.md | 2 +- agents/conductors/clone/AGENTS.md | 45 ++++- agents/conductors/clone/_clone.py | 309 +++++++++++++++++++++++++++++- bin/pyauto-brain | 6 +- tests/test_clone_sync.py | 295 ++++++++++++++++++++++++++++ 5 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 tests/test_clone_sync.py diff --git a/AGENTS.md b/AGENTS.md index 63ba2ae..80984d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,7 @@ the `/` slash commands. | `eyes` | The perceptive function — the organism's sense of its own appearance: survey/review a visualization workspace's figure surface, critiques route to intake/start_dev — EyesSurvey/EyesReviewSurface (never renders, never edits) | `bin/pyauto-brain eyes` | | `profiling` | The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision | `bin/pyauto-brain profiling` | | `hygiene` | The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision | `bin/pyauto-brain hygiene` | -| `clone` | The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build | `bin/pyauto-brain clone` | +| `clone` | The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build; 'clone sync' patches the reference's generic-file diff onto the born siblings (dry run by default; rejected hunks are a human decision) | `bin/pyauto-brain clone` | | `build` | Coordinate execution: consult the vitals faculty, then delegate to PyAutoHands | `bin/pyauto-brain build` | | `release` | Release door → the Build Agent release mode (single gate); 'release rehearse'/'release validate' drive release validation; 'release nightly' is the scheduled-nightly driver | `bin/pyauto-brain release` | | `health` | The organism's clinician: run the health loop with a human, dispatch by dispatch, toward green | `bin/pyauto-brain health` | diff --git a/agents/conductors/clone/AGENTS.md b/agents/conductors/clone/AGENTS.md index f362808..2a8fc25 100644 --- a/agents/conductors/clone/AGENTS.md +++ b/agents/conductors/clone/AGENTS.md @@ -17,6 +17,9 @@ bin/pyauto-brain clone --workspace [--reference ] [--json # give birth (the mode is the human's answer to the clone-mode question): bin/pyauto-brain clone --workspace --apply --mode lightweight-seed # ... add --no-push to build the seed tree in scratch without creating a repo +# keep the already-born siblings from drifting (dry run by default): +bin/pyauto-brain clone sync [--reference ] [--target ]... \ + [--since ] [--until ] [--apply] [--json] ``` What analyze does: domain analysis (library public API via `ast`, workspace @@ -39,6 +42,45 @@ instead of `main`, so paired PRs can both be green before the ordered merge (PyAutoBrain#186); new cells inherit this from the reference's `clone-boundary.yml`. +## `sync` — the second mode (drift, not birth) + +Birth copies the reference **once**. Nothing re-synced afterwards, so the four +copies of `skills/start-new-project.md` and `wiki/project/*` grew four distinct +hashes and the `autofit_assistant` copy diverged by ~343 lines. `sync` closes +that loop for the files the boundary already calls **generic** — and only +those; a domain file the reference changed never crosses. + +**It is not an overwrite.** It takes the *reference's own diff* over a commit +range, restricted to that reference's `generic` pattern set, rewrites the names +in it for the target (the same substitutions birth uses, **plus** the UPPERCASE +env-var rule birth omits — which is why `autocti_assistant` still says +`$AUTOLENS_ASSISTANT`), and applies it with GNU `patch`. Per file, per sibling +it reports one of: + +| result | meaning | +|--------|---------| +| `applied` / `created` | the hunks fit; on `--apply` the file is written | +| `already-applied` | the sibling already carries the change | +| `rejected` | one or more hunks no longer fit — **listed by number**; on `--apply` they land as a `.rej` file | +| `absent` | the reference changed a file this sibling does not have | +| `skipped` | the reference *added* a file the sibling already has — compare by hand | +| `unsupported` | a rename/delete in the reference — do it by hand | + +Apply is **per hunk**, the way `patch` has always worked: what fits lands, what +does not is written out for a human. A sibling's domain adaptation outranks the +reference's prose, and a conflict is never resolved silently — that judgement +is the human's, which is the whole reason this mode is a patch and not a copy. + +**Dry run is the default.** `--apply` writes. Exit `1` means at least one file +had rejected hunks (in either mode), so a caller can gate on it. + +**"Since the sibling's last sync"** is read from the sibling's *own* history: +a sync commit carries the trailer `Clone-sync: @`, and the next +run diffs the reference from there. No state file, and the pointer travels with +the commit that consumed the patch. The first sync of a sibling therefore needs +an explicit `--since `; without one the run reports that and exits 1 +rather than guessing a range. + Hard rules (from DESIGN.md): never writes repos/files/GitHub state itself (birth is Build's — this agent hands over a plan); never copies domain content across domains; never modifies the reference; never embeds PyAutoMemory content @@ -46,6 +88,7 @@ in a public assistant; a newborn is born private and is not flipped public or announced before its Heart validation legs pass (`PyAutoHeart/docs/newborn_validation.md`). -Exit codes: `0` decision · `4` inputs unresolvable (incl. an unclassified +Exit codes: `0` decision / clean sync · `1` a sync completed with rejected +hunks · `4` inputs unresolvable (incl. an unclassified boundary, or Build's birth failing) · `5` bad usage (`--apply` without `--mode lightweight-seed` — the v2 modes are refused here). diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index 5deabed..299ef3c 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -19,7 +19,17 @@ The clone-mode question is MANDATORY and never defaulted: exact-clone | differentiated-sibling | lightweight-seed -Stdlib-only. Exit codes: 0 decision · 4 inputs unresolvable · 5 bad usage. +`sync` is the SECOND mode, and it is not a birth: it keeps assistants that were +already born from drifting apart on the files the boundary calls generic. It is +NOT a blind overwrite — it takes the REFERENCE's own diff over a commit range, +restricted to the `_SHARED_GENERIC` set, rewrites the names in it for each +sibling, and applies it as a patch. A hunk that no longer fits the sibling is +REJECTED and reported, never resolved silently: a sibling's domain adaptations +outrank the reference's text, and only a human decides what a conflict means. +Dry run by default; `--apply` writes (and leaves `.rej` files for the human). + +Stdlib-only (GNU `patch` for the apply). Exit codes: 0 decision / clean sync · +1 sync completed with rejected hunks · 4 inputs unresolvable · 5 bad usage. """ from __future__ import annotations @@ -28,8 +38,11 @@ import ast import fnmatch import json +import re +import shutil import subprocess import sys +import tempfile from pathlib import Path # Workspace root via the one shared resolver (agents/_pyauto_root.py, mirrored @@ -467,7 +480,301 @@ def apply_seed(args, decision): fail(4, "Build's clone_seed failed — see its output") +# --------------------------------------------------------------------------- +# sync — keep the born siblings from drifting on the generic files +# +# Birth copies the reference once; nothing re-synced afterwards, so the four +# copies of `skills/start-new-project.md` and `wiki/project/*` grew four +# distinct hashes. This mode replays the REFERENCE's own diff over a commit +# range onto each sibling, restricted to that reference's generic set, with the +# same name substitutions birth uses. It applies with GNU `patch`, so a hunk +# whose context the sibling has adapted away is REJECTED and listed rather than +# forced: the sibling's domain adaptation outranks the reference's prose, and +# only a human decides what a conflict means. +# +# "Since the sibling's last sync" is read from the sibling's own git history: a +# sync commit carries the trailer `Clone-sync: @`. No new state +# file, and the pointer travels with the commit that consumed the patch. +# --------------------------------------------------------------------------- + +SYNC_TRAILER = "Clone-sync" + + +def substitute(text, subs): + """Apply name-substitution rules to `text`. + + Mirrors PyAutoHands `clone_seed.substitute` — the birth-side implementation + of the same contract — so a synced line reads exactly as a born one would. + A rule is `(old, new)` or `(old, new, "word")`; the latter requires `old` to + start at a word boundary, because the two-letter skill prefixes (`al_`, + `af_`) otherwise match inside `total_draws` / `external_shear`. + """ + for rule in subs: + old, new = rule[0], rule[1] + if len(rule) > 2 and rule[2] == "word": + text = re.sub(rf"(? sibling rename rules, most specific first. + + Birth omits the UPPERCASE rule, so a newborn inherits the reference's + `$_ASSISTANT` env-var name in its generated project scaffold — + still visible in a sibling born before this. Sync carries the rule, so + newly synced lines are right even where the old ones are not; sync only + ever touches the lines in the patch, so it does not retro-fix them. + """ + ref_pkg, ref_lib = reference_library(reference_name) + tgt_pkg, tgt_lib = reference_library(target_name) + return [ + (reference_name, target_name), + (f"{ref_pkg[0]}{ref_pkg[4]}_", f"{tgt_pkg[0]}{tgt_pkg[4]}_", "word"), + (ref_lib, tgt_lib), + (ref_pkg.upper(), tgt_pkg.upper()), + (ref_pkg, tgt_pkg), + ] + + +def git(repo, *args, check=False): + out = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, + ) + if check and out.returncode != 0: + fail(4, f"git {' '.join(args)} failed in {repo}: {out.stderr.strip()}") + return out + + +def discover_targets(reference_name): + """Sibling assistants checked out beside the reference.""" + return sorted( + child.name + for child in PYAUTO_ROOT.iterdir() + if child.is_dir() + and child.name.endswith("_assistant") + and child.name != reference_name + and (child / ".git").exists() + ) + + +def last_sync_rev(target_root, reference_name): + """The reference sha recorded by this sibling's most recent sync commit.""" + out = git(target_root, "log", "-n", "1", + f"--grep=^{SYNC_TRAILER}: {reference_name}@", "--format=%B") + match = re.search( + rf"^{SYNC_TRAILER}: {re.escape(reference_name)}@(\S+)", + out.stdout, re.MULTILINE, + ) + return match.group(1) if match else None + + +def changed_generic_files(reference_root, profile, since, until): + """(path, status) for every generic file the reference changed in range.""" + out = git(reference_root, "diff", "--name-status", f"{since}..{until}", + check=True) + rows = [] + for line in out.stdout.splitlines(): + parts = line.split("\t") + if len(parts) < 2: + continue + status, path = parts[0], parts[-1] + if match_any(path, profile["generic"]): + rows.append((path, status[0])) + return sorted(rows) + + +def substituted_patch(reference_root, since, until, path, subs): + """The reference's diff for one path, renamed for the target. + + Only the CONTENT lines are substituted (` `, `+`, `-`), never the `---` / + `+++` / `diff --git` headers or the `@@` ranges: the generic paths are the + same in every sibling, and rewriting a header would send the hunk to a file + that does not exist. + """ + out = git(reference_root, "diff", f"{since}..{until}", "--", path, check=True) + lines = [] + for line in out.stdout.splitlines(keepends=True): + if line.startswith(("diff --git", "index ", "--- ", "+++ ", "@@", + "new file mode", "deleted file mode", + "old mode", "new mode", "similarity index", + "rename from", "rename to")): + lines.append(line) + else: + lines.append(substitute(line, subs)) + return "".join(lines) + + +def apply_patch(target_root, patch_text, dry_run): + """Run GNU patch; return (status, detail). + + status is one of: applied · created · already-applied · rejected · error. + """ + if shutil.which("patch") is None: + fail(4, "GNU `patch` not found on PATH — sync applies patches with it") + handle, name = tempfile.mkstemp(prefix="clone_sync_", suffix=".patch") + Path(name).write_text(patch_text) + cmd = ["patch", "-p1", "--forward", "--fuzz=3", + "--no-backup-if-mismatch", "-i", name] + if dry_run: + cmd.insert(1, "--dry-run") + out = subprocess.run(cmd, cwd=str(target_root), capture_output=True, text=True) + Path(name).unlink(missing_ok=True) + text = out.stdout + out.stderr + failed = re.findall(r"Hunk #(\d+) FAILED", text) + if failed: + return "rejected", f"hunks {', '.join('#' + h for h in failed)} rejected" + if "Reversed (or previously applied) patch detected" in text: + return "already-applied", "sibling already carries this change" + if out.returncode != 0: + return "error", text.strip().splitlines()[-1] if text.strip() else "patch failed" + return "applied", "" + + +def run_sync(args): + reference_root = repo_root(args.reference) + profile = reference_profile(args.reference) + until = args.until + targets = args.target or discover_targets(args.reference) + if not targets: + fail(4, f"no sibling assistants found beside {args.reference}") + + report = { + "reference": f"{args.reference} @ {head_sha(reference_root)}", + "until": until, + "dry_run": not args.apply, + "targets": {}, + } + rejected_any = False + + for name in targets: + target_root = repo_root(name) + since = args.since or last_sync_rev(target_root, args.reference) + if since is None: + report["targets"][name] = { + "since": None, + "error": ( + f"no --since given and no `{SYNC_TRAILER}: {args.reference}@` " + "trailer in this sibling's history — pass --since for " + "the first sync" + ), + "files": [], + } + rejected_any = True + continue + + subs = sync_substitutions(args.reference, name) + files = [] + for path, status in changed_generic_files(reference_root, profile, since, until): + if status in ("R", "D"): + files.append({"path": path, "result": "unsupported", + "detail": f"reference {status} (rename/delete) — do it by hand"}) + rejected_any = True + continue + exists = (target_root / path).exists() + if status == "M" and not exists: + files.append({"path": path, "result": "absent", + "detail": "file not present in this sibling"}) + continue + if status == "A" and exists: + files.append({"path": path, "result": "skipped", + "detail": "reference ADDED this file and the sibling " + "already has one — compare the two by hand"}) + continue + patch_text = substituted_patch(reference_root, since, until, path, subs) + if not patch_text.strip(): + files.append({"path": path, "result": "unchanged", "detail": ""}) + continue + result, detail = apply_patch(target_root, patch_text, dry_run=not args.apply) + if status == "A" and result == "applied": + result = "created" + if result in ("rejected", "error"): + rejected_any = True + files.append({"path": path, "result": result, "detail": detail}) + + report["targets"][name] = {"since": since, "files": files} + + report["next_action"] = ( + "review the report; re-run with --apply to write (rejected hunks land as " + "`.rej` files a human resolves), and put " + f"`{SYNC_TRAILER}: {args.reference}@{head_sha(reference_root)}` in the " + "sibling's sync commit so the next run knows where it got to" + if not args.apply else + "resolve any `.rej` files by hand, delete them, then commit with " + f"`{SYNC_TRAILER}: {args.reference}@{head_sha(reference_root)}` in the message" + ) + return report, rejected_any + + +_SYNC_GLYPH = { + "applied": "OK ", "created": "NEW", "unchanged": "-- ", + "already-applied": "== ", "absent": "?? ", "skipped": "?? ", + "rejected": "XX ", + "error": "XX ", "unsupported": "XX ", +} + + +def print_sync(report): + mode = "dry run — writes nothing" if report["dry_run"] else "APPLY — writes" + print(f"== CloneSync ({mode}) ==") + print(f"Reference: {report['reference']}") + print(f"Until: {report['until']}") + for name, block in report["targets"].items(): + print(f"\n{name} (since {block['since'] or '?'})") + if block.get("error"): + print(f" XX {block['error']}") + continue + if not block["files"]: + print(" -- nothing generic changed in range") + continue + for row in block["files"]: + detail = f" — {row['detail']}" if row["detail"] else "" + print(f" {_SYNC_GLYPH[row['result']]} {row['path']:<48s} " + f"{row['result']}{detail}") + counts = {} + for row in block["files"]: + counts[row["result"]] = counts.get(row["result"], 0) + 1 + print(" summary: " + " · ".join(f"{v} {k}" for k, v in sorted(counts.items()))) + print(f"\nNext action: {report['next_action']}") + + +def sync_main(argv): + parser = argparse.ArgumentParser( + prog="pyauto-brain clone sync", + description="Re-apply the reference assistant's generic-file changes to " + "its born siblings as a reviewable patch (dry run by default).", + ) + parser.add_argument("--reference", default="autolens_assistant", + help="the reference assistant the diff comes from " + "(default: autolens_assistant)") + parser.add_argument("--target", action="append", default=None, + help="sibling to sync (repeatable; default: every " + "*_assistant checked out beside the reference)") + parser.add_argument("--since", default=None, + help="reference rev to diff from (default: the sha in " + f"each sibling's last `{SYNC_TRAILER}:` commit trailer)") + parser.add_argument("--until", default="HEAD", + help="reference rev to diff to (default: HEAD)") + parser.add_argument("--apply", action="store_true", + help="write the patches (default: dry run)") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + report, rejected = run_sync(args) + if args.json: + print(json.dumps(report, indent=2)) + else: + print_sync(report) + sys.exit(1 if rejected else 0) + + def main(): + # `sync` is a mode, not a library: it takes no library/workspace pair, so it + # gets its own parser rather than optional-ing out every analyze argument. + if len(sys.argv) > 1 and sys.argv[1] == "sync": + sync_main(sys.argv[2:]) + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("library", help="source library repo, e.g. PyAutoFit") parser.add_argument("--workspace", required=True, help="the library's workspace repo") diff --git a/bin/pyauto-brain b/bin/pyauto-brain index e9db454..b822c6c 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -19,7 +19,9 @@ # pyauto-brain eyes [args] (conductor) the Eyes: survey/review a visualization workspace's figure surface — EyesSurvey/EyesReviewSurface (writes nothing) # pyauto-brain profiling [args] (conductor) campaign/ingest/triage plans over the profiling workspace # pyauto-brain hygiene [args] (conductor) code-quality upkeep: perf/tidy/noise/deps/docs — HygieneDecision (modes staged) -# pyauto-brain clone [args] (conductor) v0: analyze a library+workspace against the reference assistant — CloneDecision (writes nothing) +# pyauto-brain clone [args] (conductor) analyze a library+workspace against the reference assistant — CloneDecision (writes nothing) +# (clone sync: re-apply the reference's generic-file diff to its born +# siblings as a reviewable patch; dry run unless --apply) # pyauto-brain build [args] (conductor) coordinate execution: consult vitals, run Build # pyauto-brain release [args] (conductor) release door → Build Agent release mode (gate + pre_build) # (release rehearse: Stage-2 TestPyPI rehearsal; @@ -83,7 +85,7 @@ declare -A AGENT_DESC=( [eyes]="The perceptive function — the organism's sense of its own appearance: survey/review a visualization workspace's figure surface, critiques route to intake/start_dev — EyesSurvey/EyesReviewSurface (never renders, never edits)" [profiling]="The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision" [hygiene]="The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision" - [clone]="The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build" + [clone]="The Mitosis Agent: partition the reference assistant, analyze the domain, emit the CloneDecision; --apply --mode lightweight-seed delegates the seed birth to Build; 'clone sync' patches the reference's generic-file diff onto the born siblings (dry run by default; rejected hunks are a human decision)" [build]="Coordinate execution: consult the vitals faculty, then delegate to PyAutoHands" [release]="Release door → the Build Agent release mode (single gate); 'release rehearse'/'release validate' drive release validation; 'release nightly' is the scheduled-nightly driver" [health]="The organism's clinician: run the health loop with a human, dispatch by dispatch, toward green" diff --git a/tests/test_clone_sync.py b/tests/test_clone_sync.py new file mode 100644 index 0000000..5e9c480 --- /dev/null +++ b/tests/test_clone_sync.py @@ -0,0 +1,295 @@ +"""Contract tests for the Clone Agent's `sync` mode. + +Hermetic: builds a throwaway workspace of three fake assistants (plus the +library directories `reference_library()` resolves names against) with real +`git init` repos, and never touches the live checkouts or the network. + +What the mode promises, and therefore what is asserted here: + +1. it is **not** a blind overwrite — a sibling that adapted the reference's + prose keeps its own text, and the hunks that no longer fit are REPORTED as + rejected rather than forced; +2. the reference's names are rewritten for each sibling, so a synced line reads + as a born one would (`autolens` -> `autocti`, `al_` -> `ac_`, and the + UPPERCASE env-var form birth forgets); +3. a dry run writes nothing; +4. "since the sibling's last sync" is read from the sibling's own history via + the `Clone-sync: @` commit trailer. +""" + +import argparse +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +CLONE = ( + Path(__file__).resolve().parents[1] + / "agents" / "conductors" / "clone" / "_clone.py" +) + +REFERENCE = "autolens_assistant" + +README_V1 = """# wiki/project/ + +A running journal for *this clone* of autolens_assistant. + +- profile.md — who is working on this clone. +- Dated entries — YYYY-MM-DD-.md, one per meaningful session. + +## File naming + +YYYY-MM-DD-.md, five words at most in the slug. + +## How to read this folder + +Skim the recent entries first, then grep for dataset names. +""" + +README_V2 = README_V1.replace( + "- profile.md — who is working on this clone.", + "- profile.md — who is working on this clone.\n" + "- state.md — the head pointer, REWRITTEN each session, never appended.", +).replace( + "Skim the recent entries first, then grep for dataset names.", + "Read state.md first, then skim the recent entries, then grep for dataset names.", +) + +SKILL_V1 = """# Start New Project + +Point $AUTOLENS_ASSISTANT at a local autolens_assistant clone. +Promotion upstream is deliberate, via al_ingest_paper from the assistant clone. +""" + +SKILL_V2 = SKILL_V1.replace( + "# Start New Project\n", + "# Start New Project\n\n## Session start — do this first, every session\n\n" + "Read wiki/project/profile.md, wiki/project/state.md, then the newest entry.\n", +) + +STATE_TEMPLATE = """--- +title: Project state +--- + +# Project state + +## Where we are now +## In flight +""" + + +def _load(): + spec = importlib.util.spec_from_file_location("_clone_sync_under_test", CLONE) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _git(repo, *args): + subprocess.run( + ["git", "-C", str(repo), + "-c", "user.email=t@example.invalid", "-c", "user.name=t", + "-c", "commit.gpgsign=false", *args], + check=True, capture_output=True, text=True, + ) + + +def _write(root, rel, text): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def _commit(repo, message): + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message) + out = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ) + return out.stdout.strip() + + +def _init(root): + root.mkdir(parents=True, exist_ok=True) + _git(root.parent, "init", "-q", "-b", "main", root.name) + return root + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + """A miniature PyAutoLabs: three assistants + the libraries they name.""" + clone = _load() + monkeypatch.setattr(clone, "PYAUTO_ROOT", tmp_path) + + # `reference_library()` resolves autolens -> PyAutoLens by directory name. + for lib in ("PyAutoLens", "PyAutoCTI", "PyAutoGalaxy"): + (tmp_path / lib).mkdir() + + ref = _init(tmp_path / REFERENCE) + _write(ref, "wiki/project/README.md", README_V1) + _write(ref, "skills/start-new-project.md", SKILL_V1) + base = _commit(ref, "reference v1") + + # Both siblings are born from v1 with the birth substitutions applied. + cti = _init(tmp_path / "autocti_assistant") + _write(cti, "wiki/project/README.md", + README_V1.replace("autolens", "autocti")) + # ... but this one never received the skill file: the reference's change to + # it must report `absent`, not explode. + _commit(cti, "born") + + gal = _init(tmp_path / "autogalaxy_assistant") + # This sibling ADAPTED the exact prose the reference is about to change, so + # its hunks must be rejected rather than forced. + _write(gal, "wiki/project/README.md", """# wiki/project/ + +A running journal for *this clone* of autogalaxy_assistant. + +- profile.md — the morphology-fitting user's background and goals. +- Dated entries — one per session; see the galaxy-decomposition examples. + +## File naming + +Dated slugs, kept short. + +## How to read this folder + +Grep for the galaxy name first; the entries are decomposition-ordered. +""") + _write(gal, "skills/start-new-project.md", + SKILL_V1.replace("AUTOLENS", "AUTOGALAXY") + .replace("autolens", "autogalaxy") + .replace("al_", "ag_")) + _commit(gal, "born") + + # The reference moves on: two edits and one new file, all generic. + _write(ref, "wiki/project/README.md", README_V2) + _write(ref, "skills/start-new-project.md", SKILL_V2) + _write(ref, "wiki/project/_state_template.md", STATE_TEMPLATE) + head = _commit(ref, "reference v2: state.md") + + return argparse.Namespace( + clone=clone, root=tmp_path, ref=ref, cti=cti, gal=gal, + base=base, head=head, + ) + + +def _args(**kw): + defaults = dict(reference=REFERENCE, target=None, since=None, + until="HEAD", apply=False, json=False) + defaults.update(kw) + return argparse.Namespace(**defaults) + + +def _results(report, target): + return {row["path"]: row for row in report["targets"][target]["files"]} + + +def test_discovers_the_siblings_and_not_the_reference(workspace): + found = workspace.clone.discover_targets(REFERENCE) + assert found == ["autocti_assistant", "autogalaxy_assistant"] + + +def test_dry_run_reports_and_writes_nothing(workspace): + before = (workspace.cti / "wiki/project/README.md").read_text() + report, rejected = workspace.clone.run_sync( + _args(since=workspace.base, target=["autocti_assistant"]) + ) + rows = _results(report, "autocti_assistant") + + assert report["dry_run"] is True + assert rows["wiki/project/README.md"]["result"] == "applied" + assert rows["wiki/project/_state_template.md"]["result"] == "created" + # The sibling never had the skill file; that is a report, not a crash. + assert rows["skills/start-new-project.md"]["result"] == "absent" + assert rejected is False + assert (workspace.cti / "wiki/project/README.md").read_text() == before + assert not (workspace.cti / "wiki/project/_state_template.md").exists() + + +def test_apply_writes_with_the_names_rewritten(workspace): + report, rejected = workspace.clone.run_sync( + _args(since=workspace.base, target=["autocti_assistant"], apply=True) + ) + assert rejected is False + text = (workspace.cti / "wiki/project/README.md").read_text() + assert "state.md — the head pointer" in text + assert "Read state.md first" in text + # Substitution: nothing lensing-shaped may leak into the CTI sibling. + assert "autolens" not in text + assert (workspace.cti / "wiki/project/_state_template.md").exists() + + +def test_divergent_sibling_keeps_its_prose_and_reports_rejects(workspace): + """Apply is per hunk, the way `patch` has always worked: what fits lands, + what does not is written out as a `.rej` for a human. The sibling's own + adapted prose is never overwritten to make the reference's text fit.""" + report, rejected = workspace.clone.run_sync( + _args(since=workspace.base, target=["autogalaxy_assistant"], apply=True) + ) + rows = _results(report, "autogalaxy_assistant") + + assert rejected is True + assert rows["wiki/project/README.md"]["result"] == "rejected" + assert "hunks" in rows["wiki/project/README.md"]["detail"] + text = (workspace.gal / "wiki/project/README.md").read_text() + # The adaptation the reference's hunk collided with survives verbatim... + assert "Grep for the galaxy name first" in text + assert "Read state.md first" not in text + # ... and the conflict is left on disk for a human, not resolved silently. + assert (workspace.gal / "wiki/project/README.md.rej").exists() + + +def test_substitution_rewrites_the_uppercase_env_var(workspace): + """Birth omits the UPPERCASE rule; sync must not repeat that.""" + subs = workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + line = "point `$AUTOLENS_ASSISTANT` at a local autolens_assistant clone (al_setup)" + out = workspace.clone.substitute(line, subs) + assert "$AUTOCTI_ASSISTANT" in out + assert "autocti_assistant" in out + assert "ac_setup" in out + assert "AUTOLENS" not in out + + +def test_word_anchor_does_not_rewrite_mid_word(workspace): + subs = workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + assert workspace.clone.substitute("total_draws external_shear", subs) == ( + "total_draws external_shear" + ) + + +def test_since_falls_back_to_the_commit_trailer(workspace): + assert workspace.clone.last_sync_rev(workspace.cti, REFERENCE) is None + + _write(workspace.cti, "NOTES.md", "a sync landed here\n") + _commit( + workspace.cti, + f"chore: sync generic files from the reference\n\n" + f"Clone-sync: {REFERENCE}@{workspace.base}", + ) + assert workspace.clone.last_sync_rev(workspace.cti, REFERENCE) == workspace.base + + # With no --since the run resolves the range from that trailer. + report, _ = workspace.clone.run_sync(_args(target=["autocti_assistant"])) + assert report["targets"]["autocti_assistant"]["since"] == workspace.base + + +def test_first_sync_without_since_is_reported_not_guessed(workspace): + report, rejected = workspace.clone.run_sync(_args(target=["autocti_assistant"])) + block = report["targets"]["autocti_assistant"] + assert block["since"] is None + assert "--since" in block["error"] + assert rejected is True + + +def test_only_generic_files_are_synced(workspace): + """A domain file the reference changed must never cross the boundary.""" + _write(workspace.ref, "wiki/core/lensing.md", "domain content\n") + _commit(workspace.ref, "reference: a domain page") + report, _ = workspace.clone.run_sync( + _args(since=workspace.base, target=["autocti_assistant"]) + ) + assert "wiki/core/lensing.md" not in _results(report, "autocti_assistant") From d2b1af9450c2e6cf74eadfd2ce7910e9cfac7c98 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Fri, 28 Aug 2026 21:02:59 -0400 Subject: [PATCH 2/2] clone sync: domain + env-var substitution rules (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Birth and sync each built their own rename list, and birth's was missing two rules. Both gaps were still visible in a sibling cell: its generated project scaffold pointed at the REFERENCE's `$..._ASSISTANT` variable, and its profile template still headed its first section with the reference's domain noun ("Lensing background", in a CTI cell). Neither is reachable by the identity rules — the uppercase form is not the package, and a domain noun is not a package at all. - `name_substitutions()` is now the ONE table, used by `apply_seed` (which serialises it for PyAutoHands' `clone_seed`) and by `sync_substitutions`. A file that lands in a sibling reads the same whichever route brought it. - It carries the UPPERCASE package rule (birth omitted it) and, new, `DOMAIN_NOUNS`: the science's own noun and its longer phrase, per package. - `DOMAIN_ALIASES` handles the qualified compounds first, because "strong-lensing" -> "strong-CTI" is a phrase in no science; the qualifier belongs to the reference's domain, so the compound resolves to the target's bare noun. Word-anchored throughout, so `microlensing` survives and `lensing-fluent` keeps its own tail. - A target package absent from `DOMAIN_NOUNS` gets NO domain rule and a printed warning at birth. A science's own noun is never guessed. Sync only ever touches the lines in the patch, so these rules correct newly synced lines; they do not retro-fix a line an earlier birth got wrong — one of the new tests pins exactly that, so nobody expects a silent rewrite. tests/test_clone_sync.py: 9 -> 16 (domain-noun rename, the compound rule, the word anchor, the never-guess rule, birth/sync table identity, an end-to-end sync that lands both closed, and the no-retro-fix pin). Checks: `pytest tests/ -q` 634 passed (627 before); tenant firewall OK. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GZceRRca9F5ojAtjpRp3me --- agents/conductors/clone/AGENTS.md | 24 +++++- agents/conductors/clone/_clone.py | 129 ++++++++++++++++++++++++------ tests/test_clone_sync.py | 118 ++++++++++++++++++++++++++- 3 files changed, 241 insertions(+), 30 deletions(-) diff --git a/agents/conductors/clone/AGENTS.md b/agents/conductors/clone/AGENTS.md index 2a8fc25..b41f4c7 100644 --- a/agents/conductors/clone/AGENTS.md +++ b/agents/conductors/clone/AGENTS.md @@ -52,10 +52,9 @@ those; a domain file the reference changed never crosses. **It is not an overwrite.** It takes the *reference's own diff* over a commit range, restricted to that reference's `generic` pattern set, rewrites the names -in it for the target (the same substitutions birth uses, **plus** the UPPERCASE -env-var rule birth omits — which is why `autocti_assistant` still says -`$AUTOLENS_ASSISTANT`), and applies it with GNU `patch`. Per file, per sibling -it reports one of: +in it for the target (`name_substitutions` — the one rename table birth and +sync share), and applies it with GNU `patch`. Per file, per sibling it reports +one of: | result | meaning | |--------|---------| @@ -66,6 +65,23 @@ it reports one of: | `skipped` | the reference *added* a file the sibling already has — compare by hand | | `unsupported` | a rename/delete in the reference — do it by hand | +### The rename table (`name_substitutions`) + +One table, used by **both** birth and sync, so a generic file reads the same +whichever route brought it into a sibling. Most specific first: the assistant +name, the `al_` → `ac_` skill prefix (word-anchored), the library repo name, +the **UPPERCASE** package form, the lowercase package, then the **domain noun** +(`DOMAIN_NOUNS`: lensing / galaxy / CTI / model-fitting, each with its longer +phrase, word-anchored so `microlensing` survives). + +The last two rules were missing at the first births, and both are still visible +in `autocti_assistant` (PyAutoBrain#315): its generated project scaffold points +at `$AUTOLENS_ASSISTANT`, and its profile template heads its first section +"Lensing background". Neither is reachable by the identity rules — `AUTOLENS` +is not `autolens`, and "lensing" is not a package at all. A target whose +package is not in `DOMAIN_NOUNS` gets **no** domain rule and a printed warning; +a science's own noun is never guessed. + Apply is **per hunk**, the way `patch` has always worked: what fits lands, what does not is written out for a human. A sibling's domain adaptation outranks the reference's prose, and a conflict is never resolved silently — that judgement diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index 299ef3c..ae263d5 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -428,6 +428,94 @@ def repo_owner(repo_root): return url.rstrip("/").split("/")[-2].split(":")[-1] +# --------------------------------------------------------------------------- +# The name/domain rename table — shared by BIRTH (apply_seed -> clone_seed) and +# SYNC (sync_substitutions). One table, so a file that lands in a sibling by +# either route reads the same. +# +# Two rules were missing when the first siblings were born, and both were still +# visible in a sibling cell (PyAutoBrain#315): the UPPERCASE package form, so a +# generated project scaffold still pointed at the REFERENCE's `$..._ASSISTANT` +# variable; and the science's own noun, so a profile template still headed its +# first section with the reference's domain. Neither is a name the identity +# rules can reach — the uppercase form is not the package, and a domain noun is +# not a package at all. +# --------------------------------------------------------------------------- + +# The domain noun each assistant's GENERIC prose uses for its own science, as +# (short noun, full phrase). Keyed by library package. A package absent from +# the table gets NO domain rule and a printed warning: leaving the reference's +# noun visible is recoverable, inventing a science's name for it is not. +DOMAIN_NOUNS = { + "autolens": ("lensing", "gravitational lensing"), + "autogalaxy": ("galaxy", "galaxy morphology"), + "autocti": ("CTI", "charge transfer inefficiency"), + "autofit": ("model-fitting", "statistical model-fitting"), +} + + +# Qualified compounds of the reference's own noun. They are rewritten to the +# TARGET's bare noun *before* the bare rule runs, because "strong-lensing" -> +# "strong-CTI" is a phrase in no science: the qualifier belongs to the +# reference's domain, not the target's. (Trailing compounds — `lensing-fluent` +# -> `CTI-fluent` — the bare rule already handles.) +DOMAIN_ALIASES = { + "autolens": ("strong-lensing", "weak-lensing", "strong lensing", "weak lensing"), +} + + +def capitalised(phrase): + """`lensing` -> `Lensing`, `CTI` -> `CTI` (an acronym is left alone).""" + return phrase if phrase[:1].isupper() else phrase[:1].upper() + phrase[1:] + + +def domain_substitutions(ref_pkg, tgt_pkg): + """Rewrite the reference's domain noun for the target's science. + + Longest first — the full phrase, then the qualified compounds of + `DOMAIN_ALIASES`, then the bare noun — each in its lowercase and + sentence-case form, because the noun shows up mid-sentence and as a + heading. Word-anchored, so `microlensing` is not rewritten. Returns [] + when either side's noun is unknown — never a guess. + """ + ref, tgt = DOMAIN_NOUNS.get(ref_pkg), DOMAIN_NOUNS.get(tgt_pkg) + if not ref or not tgt: + return [] + pairs = [(ref[1], tgt[1])] # the full phrase + pairs += [ # qualified compounds + (alias, tgt[0]) + for alias in sorted(DOMAIN_ALIASES.get(ref_pkg, ()), key=len, reverse=True) + ] + pairs.append((ref[0], tgt[0])) # the bare noun, last + rules = [] + for old, new in pairs: + if old == new: + continue + rules.append((old, new, "word")) + if capitalised(old) != old: + rules.append((capitalised(old), capitalised(new), "word")) + return rules + + +def name_substitutions(reference_name, target_name, + ref_pkg, ref_lib, tgt_pkg, tgt_lib): + """The reference -> sibling rename rules, most specific first.""" + return [ + # repo identity first (most specific): the full assistant name, + # e.g. autofit_assistant -> ic50_assistant + (reference_name, target_name), + # skill prefix (al_ -> af_): package initials, e.g. autolens -> al, + # autofit -> af. Word-anchored: unanchored, this two-letter rule also + # rewrites the `al_` inside `total_draws`, `external_shear` and + # `radial_minimum` (it did, in a sibling clone — PyAutoBrain#150). + (f"{ref_pkg[0]}{ref_pkg[4]}_", f"{tgt_pkg[0]}{tgt_pkg[4]}_", "word"), + (ref_lib, tgt_lib), # PyAutoLens -> PyAutoFit + (ref_pkg.upper(), tgt_pkg.upper()), # $AUTOLENS_ASSISTANT -> $AUTOFIT_… + (ref_pkg, tgt_pkg), # autolens -> autofit + *domain_substitutions(ref_pkg, tgt_pkg), + ] + + def apply_seed(args, decision): """v1: emit the generation plan and hand execution to Build (clone_seed).""" import tempfile @@ -448,23 +536,21 @@ def apply_seed(args, decision): "owner": args.owner or repo_owner(reference_root), "reference_path": str(reference_root), "substitutions": [ - # repo identity first (most specific): the full assistant name, - # e.g. autofit_assistant -> ic50_assistant - [args.reference, target], - # skill prefix (al_ -> af_): package initials, e.g. - # autolens -> al, autofit -> af. Word-anchored: unanchored, this - # two-letter rule also rewrites the `al_` inside `total_draws`, - # `external_shear` and `radial_minimum` (it did, in a sibling - # assistant clone — PyAutoBrain#150). - [f"{ref_pkg[0]}{ref_pkg[4]}_", f"{target_pkg[0]}{target_pkg[4]}_", "word"], - [ref_lib, args.library], # PyAutoLens -> PyAutoFit - [ref_pkg, target_pkg], # autolens -> autofit + list(rule) for rule in name_substitutions( + args.reference, target, ref_pkg, ref_lib, target_pkg, args.library + ) ], "generic": sets["generic"], "mixed": sets["mixed"], "domain": sets["domain"], "scaffold_dirs": profile["scaffold_dirs"], } + if not DOMAIN_NOUNS.get(target_pkg): + print(f"\n!! no domain noun known for '{target_pkg}' — the copied generic " + f"prose will keep the reference's ('{DOMAIN_NOUNS.get(ref_pkg, ('its own',))[0]}').\n" + f" Add it to DOMAIN_NOUNS in _clone.py, or fix the newborn's " + f"prose by hand. Not guessed here.") + plan_path = Path(tempfile.mkstemp(prefix="clone_plan_", suffix=".json")[1]) plan_path.write_text(json.dumps(plan, indent=2)) @@ -519,23 +605,18 @@ def substitute(text, subs): def sync_substitutions(reference_name, target_name): - """The reference -> sibling rename rules, most specific first. + """The reference -> sibling rename rules for a sync, from the shared table. - Birth omits the UPPERCASE rule, so a newborn inherits the reference's - `$_ASSISTANT` env-var name in its generated project scaffold — - still visible in a sibling born before this. Sync carries the rule, so - newly synced lines are right even where the old ones are not; sync only - ever touches the lines in the patch, so it does not retro-fix them. + Same rules birth uses (`name_substitutions`), resolved from the two + assistant names. Sync only ever touches the lines in the patch, so the + UPPERCASE and domain-noun rules correct newly synced lines; they do not + retro-fix a line an earlier birth got wrong until that line is patched. """ ref_pkg, ref_lib = reference_library(reference_name) tgt_pkg, tgt_lib = reference_library(target_name) - return [ - (reference_name, target_name), - (f"{ref_pkg[0]}{ref_pkg[4]}_", f"{tgt_pkg[0]}{tgt_pkg[4]}_", "word"), - (ref_lib, tgt_lib), - (ref_pkg.upper(), tgt_pkg.upper()), - (ref_pkg, tgt_pkg), - ] + return name_substitutions( + reference_name, target_name, ref_pkg, ref_lib, tgt_pkg, tgt_lib + ) def git(repo, *args, check=False): diff --git a/tests/test_clone_sync.py b/tests/test_clone_sync.py index 5e9c480..9ddb62e 100644 --- a/tests/test_clone_sync.py +++ b/tests/test_clone_sync.py @@ -10,8 +10,9 @@ prose keeps its own text, and the hunks that no longer fit are REPORTED as rejected rather than forced; 2. the reference's names are rewritten for each sibling, so a synced line reads - as a born one would (`autolens` -> `autocti`, `al_` -> `ac_`, and the - UPPERCASE env-var form birth forgets); + as a born one would (`autolens` -> `autocti`, `al_` -> `ac_`, the UPPERCASE + env-var form and the domain noun `lensing` -> `CTI` — the two rules the + first births omitted, PyAutoBrain#315); 3. a dry run writes nothing; 4. "since the sibling's last sync" is read from the sibling's own history via the `Clone-sync: @` commit trailer. @@ -69,6 +70,19 @@ "Read wiki/project/profile.md, wiki/project/state.md, then the newest entry.\n", ) +PROFILE_V1 = """# Profile template + +## Lensing background + +One or two sentences on the user's prior exposure to gravitational lensing. +Set $AUTOLENS_ASSISTANT before you start; microlensing work is out of scope. +""" + +PROFILE_V2 = PROFILE_V1.replace( + "## Lensing background\n", + "## Lensing background\n\n_unrecorded until the first session._\n", +) + STATE_TEMPLATE = """--- title: Project state --- @@ -131,12 +145,21 @@ def workspace(tmp_path, monkeypatch): ref = _init(tmp_path / REFERENCE) _write(ref, "wiki/project/README.md", README_V1) _write(ref, "skills/start-new-project.md", SKILL_V1) + _write(ref, "wiki/project/_profile_template.md", PROFILE_V1) base = _commit(ref, "reference v1") # Both siblings are born from v1 with the birth substitutions applied. cti = _init(tmp_path / "autocti_assistant") _write(cti, "wiki/project/README.md", README_V1.replace("autolens", "autocti")) + # Born with the corrected rules (UPPERCASE + domain noun), i.e. what a + # birth produces after PyAutoBrain#315 — so a later reference hunk, whose + # context is substituted the same way, still fits. + _write(cti, "wiki/project/_profile_template.md", + PROFILE_V1.replace("AUTOLENS", "AUTOCTI") + .replace("Lensing", "CTI") + .replace("gravitational lensing", + "charge transfer inefficiency")) # ... but this one never received the skill file: the reference's change to # it must report `absent`, not explode. _commit(cti, "born") @@ -163,12 +186,17 @@ def workspace(tmp_path, monkeypatch): SKILL_V1.replace("AUTOLENS", "AUTOGALAXY") .replace("autolens", "autogalaxy") .replace("al_", "ag_")) + # ... and this copy was born BEFORE the rules were fixed: its heading and + # env var still name the reference's domain, exactly as the live + # autocti_assistant's did (PyAutoBrain#315). + _write(gal, "wiki/project/_profile_template.md", PROFILE_V1) _commit(gal, "born") # The reference moves on: two edits and one new file, all generic. _write(ref, "wiki/project/README.md", README_V2) _write(ref, "skills/start-new-project.md", SKILL_V2) _write(ref, "wiki/project/_state_template.md", STATE_TEMPLATE) + _write(ref, "wiki/project/_profile_template.md", PROFILE_V2) head = _commit(ref, "reference v2: state.md") return argparse.Namespace( @@ -293,3 +321,89 @@ def test_only_generic_files_are_synced(workspace): _args(since=workspace.base, target=["autocti_assistant"]) ) assert "wiki/core/lensing.md" not in _results(report, "autocti_assistant") + + +def test_substitution_rewrites_the_domain_noun(workspace): + """The science's own noun is a rename too: a generic file copied from the + lensing reference must not head its profile template "Lensing background" + in a CTI cell (PyAutoBrain#315).""" + subs = workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + out = workspace.clone.substitute( + "## Lensing background\n\nprior exposure to gravitational lensing.\n", subs + ) + assert out == "## CTI background\n\nprior exposure to charge transfer inefficiency.\n" + + gal = workspace.clone.sync_substitutions(REFERENCE, "autogalaxy_assistant") + assert workspace.clone.substitute("## Lensing background", gal) == ( + "## Galaxy background" + ) + + +def test_qualified_compounds_do_not_become_nonsense(workspace): + """`strong-lensing` -> `strong-CTI` is a phrase in no science: the + qualifier belongs to the reference's domain, so the compound resolves to + the target's bare noun. A TRAILING compound keeps its own tail.""" + subs = workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + assert workspace.clone.substitute("strong-lensing systematics", subs) == ( + "CTI systematics" + ) + assert workspace.clone.substitute("a weak lensing survey", subs) == ( + "a CTI survey" + ) + assert workspace.clone.substitute("lensing-fluent", subs) == "CTI-fluent" + + +def test_domain_noun_is_word_anchored(workspace): + """`microlensing` is one word, not a domain noun to rewrite.""" + subs = workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + assert workspace.clone.substitute("microlensing", subs) == "microlensing" + + +def test_domain_noun_is_never_guessed_for_an_unknown_science(workspace): + """A birth into a science the table does not know gets NO domain rule — + a visible `lensing` is recoverable, an invented noun is not.""" + assert workspace.clone.domain_substitutions("autolens", "ic50") == [] + + +def test_synced_hunk_lands_with_both_birth_gaps_closed(workspace): + """End to end: the reference edits its profile template, and the hunk that + lands in the CTI sibling carries the corrected heading and env var.""" + report, rejected = workspace.clone.run_sync( + _args(since=workspace.base, target=["autocti_assistant"], apply=True) + ) + rows = _results(report, "autocti_assistant") + assert rows["wiki/project/_profile_template.md"]["result"] == "applied" + text = (workspace.cti / "wiki/project/_profile_template.md").read_text() + assert "## CTI background" in text + assert "_unrecorded until the first session._" in text + assert "$AUTOCTI_ASSISTANT" in text + assert "Lensing" not in text + assert "lensing" not in text.replace("microlensing", "") + + +def test_birth_and_sync_share_one_rename_table(workspace): + """Birth used to omit the UPPERCASE and domain rules; both routes must now + produce the identical substitution set.""" + born = workspace.clone.name_substitutions( + REFERENCE, "autocti_assistant", + "autolens", "PyAutoLens", "autocti", "PyAutoCTI", + ) + assert born == workspace.clone.sync_substitutions(REFERENCE, "autocti_assistant") + assert ("AUTOLENS", "AUTOCTI") in born + assert ("lensing", "CTI", "word") in born + + +def test_sync_does_not_retro_fix_a_stale_born_copy(workspace): + """A sibling born before the rules were fixed still says "Lensing" and + `$AUTOLENS_ASSISTANT`. Sync only ever touches the lines in the patch: the + new line lands (`patch --fuzz` tolerates the drifted context), and the + stale heading and env var survive untouched. Closing those needs the + reference to change those very lines, or a hand fix — never a silent + rewrite by the sync.""" + workspace.clone.run_sync( + _args(since=workspace.base, target=["autogalaxy_assistant"], apply=True) + ) + text = (workspace.gal / "wiki/project/_profile_template.md").read_text() + assert "_unrecorded until the first session._" in text # the hunk landed + assert "## Lensing background" in text # ... and only it + assert "$AUTOLENS_ASSISTANT" in text