diff --git a/dashboard.html b/dashboard.html index 0c88158f..766469d7 100644 --- a/dashboard.html +++ b/dashboard.html @@ -185,7 +185,7 @@

PyAutoMindDashboard

Intent. Priority. Flow.

Every task the Mind is holding. Tap a task's 📋 and its /start_dev command is on your clipboard — paste it into a Claude Code chat to route Claude straight to that task. Recent is the same work by date — what has been happening rather than what to do next.

- +

Last updated 2026-08-25. This page is generated from active/, draft/ and the registry files, so it is only as current as they are. dashboard_refresh.yml re-renders it on every push to main — that heals a stale page, but not a stale prompt: a task that shipped without its prompt advancing to complete/ keeps rendering here as pickable backlog. Reconciling those is the refresh below.

latent-nan-guard-honest-run — planned 2026-07-22

Backlog markdown version

-

140 filed prompts, not started — sorted most-pickable first (priority, then size). 25 of them belong to an epic and are listed only under Epics below.

+

141 filed prompts, not started — sorted most-pickable first (priority, then size). 25 of them belong to an epic and are listed only under Epics below.

feature — 27 @@ -308,7 +308,7 @@

Backlog

interferometer/start_here.py OOM in nightly release-validation integrate leg🐛 bugautolens

-maintenance — 25 +maintenance — 26

autocti_workspace has no Navigator Check, so its CI can never roll…🧹 maintenancecimediumsupervisedhigh

Untrack the generated FITS test artifacts in autoarray🧹 maintenancelibrariessmallsupervisedmedium

smoke_install.sh's stale jax<0.7 pin — CI is on the right jax…🧹 maintenancecilowsupervisedmedium

@@ -333,6 +333,7 @@

Backlog

dataset/imaging/jwst_lw is untracked because the gitignore was never extended for it🧹 maintenanceautolens_profilingsmallsupervisedlow

cosmos_web_ring stores boolean masks as float64, wasting ~3.4 MB of the repo's…🧹 maintenanceautolens_workspacesmallsupervisedlow

+

@@ -398,6 +399,12 @@

Backlog The 50 newest things to happen to the work in hand, newest first — issued, parked, filed. Every other section on this page is laid out by state, which is exactly why none of them can answer “what has been happening?”. Shipped work is not here: it is read from complete/index.md, and a thousand records deep it would crowd out everything anyone can still act on. Showing the newest 10; … opens the next 10.

+ + + + + + @@ -451,7 +458,7 @@

Backlog pynufft removal: unswept downstream residue (1 hard break + stale…

- + @@ -691,12 +698,6 @@

Backlog Nightly release has been blocked 8 nights running — triage the streak

- - - - - -
2026-08-25filedrepos_sync.py installs .claude/ into every repo without knowing…
2026-08-24 filed wiki-currency's --check-version gate rots on every library main merge

Epics markdown version

diff --git a/dashboard.md b/dashboard.md index 38bc730c..d249ea6b 100644 --- a/dashboard.md +++ b/dashboard.md @@ -45,7 +45,7 @@ anything you could not verify. | [In flight](#in-flight) (`active/`) | 1 | | [Parked](#parked) (`parked.md`) | 3 | | [Planned](#planned) (`planned.md`) | 5 | -| [Backlog](#backlog) (`draft/`) | 140 | +| [Backlog](#backlog) (`draft/`) | 141 | ## Start here @@ -261,7 +261,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned. ## Backlog -**140** filed prompts, not started. Each section is sorted most-pickable first (priority, then size). **25** of them belong to an epic and are listed only under [Epics](#epics) below. +**141** filed prompts, not started. Each section is sorted most-pickable first (priority, then size). **25** of them belong to an epic and are listed only under [Epics](#epics) below.
feature — 27 @@ -666,7 +666,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-maintenance — 25 +maintenance — 26
📋 autocti_workspace has no Navigator Check, so its CI can never roll… — ci · medium · supervised · high @@ -860,6 +860,14 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
+
📋 repos_sync.py installs .claude/ into every repo without knowing whether the target lints… — pyautomind · small · supervised · low + +``` +/start_dev draft/maintenance/pyautomind/repos_sync_target_repo_lint_awareness.md +``` + +
+
📋 Regenerate setup_notebook-drifted notebooks in autogalaxy/autofit/HowToFit workspaces — workspaces · small · supervised · low ``` @@ -1234,6 +1242,7 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | Date | Event | Task | |------|-------|------| +| 2026-08-25 | filed | repos_sync.py installs .claude/ into every repo without knowing… | | 2026-08-24 | filed | wiki-currency's --check-version gate rots on every library main merge | | 2026-08-24 | filed | interferometer/jax_grad/gradient.py: eager and jitted likelihoods… | | 2026-08-24 | filed | euclid: CRLF has reached the HPC submit scripts AGENTS.md warns about | @@ -1243,12 +1252,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-24 | filed | Organ repo spellings split across two normalised keys | | 2026-08-24 | filed | Heart's local smoke runner cannot run any CTI workspace — no autocti… | | 2026-08-23 | filed | pynufft removal: unswept downstream residue (1 hard break + stale… | -| 2026-08-23 | filed | Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI… |
… 10 more (40 left) | Date | Event | Task | |------|-------|------| +| 2026-08-23 | filed | Phase 3: stop installing pynufft in Hands/Heart CI and PyAutoCTI… | | 2026-08-23 | filed | Brain board follow-ups: what real mornings surface | | 2026-08-22 | filed | smoke_install.sh's stale jax<0.7 pin — CI is on the right jax… | | 2026-08-22 | filed | Untrack the generated FITS test artifacts in autoarray | @@ -1258,12 +1267,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-22 | filed | Defer the eager scipy.sparse import in derivative_util (~0.10 s of… | | 2026-08-21 | filed | Rectangular mesh split: Bilinear (fast CPU default) vs RTU… | | 2026-08-20 | filed | Numba CPU likelihood phase 2: kernel-CDF numba fast path (the 49-88%… | -| 2026-08-20 | filed | Numba CPU likelihood phase 1: batched MGE convolution +… |
… 10 more (30 left) | Date | Event | Task | |------|-------|------| +| 2026-08-20 | filed | Numba CPU likelihood phase 1: batched MGE convolution +… | | 2026-08-19 | filed | status.sh --repos sources a file that no longer exists | | 2026-08-19 | filed | jax 0.11 breaks beta/gamma message log_partition under jit… | | 2026-08-19 | filed | autolens_workspace_test jax_likelihood pins: 4 scripts fail smoke on… | @@ -1273,12 +1282,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-19 | filed | Release board: local run_logs enrichment | | 2026-08-19 | filed | RTD organism docs currency: Nerves page, organ-count drift, hands.md… | | 2026-08-19 | filed | Deduplicate repos_sync.py's check/write pairs | -| 2026-08-19 | filed | Bug in autocti_workspace: the dataset_1d results/database example… |
… 10 more (20 left) | Date | Event | Task | |------|-------|------| +| 2026-08-19 | filed | Bug in autocti_workspace: the dataset_1d results/database example… | | 2026-08-18 | parked | single-source-density-design | | 2026-08-18 | parked | prior-message-collapse-design | | 2026-08-18 | filed | @PyAutoFit TransformedMessage.logpdf/pdf omit the transform… | @@ -1288,12 +1297,12 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-14 | filed | Three jax_likelihood pins are stale by ~1.24e-4 and fail the smoke… | | 2026-08-09 | found | isothermal-ell-sph-oversampling-at-the-cusp | | 2026-08-08 | parked | pyautoreduce-slacs1430-acs-comparison | -| 2026-08-08 | filed | Regenerate autolens_workspace markdown/ so the MGE pages show… |
… 10 more (10 left) | Date | Event | Task | |------|-------|------| +| 2026-08-08 | filed | Regenerate autolens_workspace markdown/ so the MGE pages show… | | 2026-08-07 | filed | Regenerate setup_notebook-drifted notebooks in… | | 2026-08-06 | filed | Triage: Convolver "No blurring_image provided" warning in canonical… | | 2026-08-06 | filed | Rewrite PyAutoCTI docs/api — 55 of 89 autosummary entries are dead | @@ -1303,7 +1312,6 @@ The 50 newest things to happen to the work in hand, newest first — issued, par | 2026-08-04 | filed | cosmos_web_ring stores boolean masks as float64, wasting ~3.4 MB of… | | 2026-08-04 | filed | autolens_workspace_developer: broad stale-API rot (56 symbols, no CI) | | 2026-08-04 | filed | Nightly release has been blocked 8 nights running — triage the streak | -| 2026-08-04 | filed | HowToLens ch4 tutorial 3: mask overlay is never actually drawn |
diff --git a/draft/maintenance/pyautomind/repos_sync_target_repo_lint_awareness.md b/draft/maintenance/pyautomind/repos_sync_target_repo_lint_awareness.md new file mode 100644 index 00000000..c7621065 --- /dev/null +++ b/draft/maintenance/pyautomind/repos_sync_target_repo_lint_awareness.md @@ -0,0 +1,67 @@ +# repos_sync.py installs .claude/ into every repo without knowing whether the target lints its own structure + +Type: maintenance +Target: pyautomind +Repos: +- PyAutoMind +Difficulty: small +Autonomy: supervised +Priority: low +Status: formalised +Filed: 2026-08-25 + +`scripts/repos_sync.py --write` writes generated files into *every* checked-out +repo in `repos.yaml`: + +- `write_session_hooks()` — `.claude/hooks/session-start.sh` (verbatim copy of + `policy/session_start_hook.sh`, chmod 755) plus a `.claude/settings.json` + that registers it. +- `write_claude_md_pointers()` — the canonical `CLAUDE.md` `@AGENTS.md` pointer. + +Neither writer knows anything about the target repo beyond "is it checked out, +and does it have an `AGENTS.md`". In particular, **it does not know whether the +target repo enforces its own structure lint** — a check that asserts which +top-level paths are allowed to exist. Dropping `.claude/` into such a repo +breaks that repo's CI, in a way that looks like the repo's fault rather than +the sync script's. + +## Why this is worth tracking + +PyAutoMemory was the only repo in the body map carrying a structure lint, so it +was the only casualty and the break was fixed there. The *class* of break is +still open: the next repo that grows a structure lint gets silently broken the +next time anyone runs `repos_sync.py --write`, with no signal from the sync +script that it just wrote a path the target considers illegal. + +This is latent, not currently failing. It is filed as insurance against a +recurrence, not as an outstanding regression. + +## Shape of the fix + +Options, roughly in increasing order of effort: + +1. **Declare it in `repos.yaml`.** Give each repo an optional + `structure_lint:` key naming the lint (or just a boolean). The writers skip — + or warn loudly about — repos that declare one, so the human is told to extend + the lint's allowlist before the sync lands. +2. **Detect it.** Have `repos_sync.py` look for the repo's own structure check + (a known script path / CI job name) and refuse to write `.claude/` into a + repo whose lint does not already permit it. +3. **Post-write verification.** After `--write`, run each touched repo's own + fast checks and report any that the sync just broke. Most thorough, most + expensive, and hardest to keep fast. + +(1) is probably the right size: it makes the coupling explicit in the body map, +which is where repo identity already lives, and it costs one key per repo. + +Whichever is chosen, the drift-check side (`check_session_hooks()`, +`check_claude_md_pointers()`) has to agree with the write side, or a +lint-exempt repo will show as permanent drift. + +## Definition of done + +- A repo that lints its own top-level structure is either skipped by the + `.claude/` writers or flagged before the write, not broken by it. +- The corresponding `check_*` functions do not report the exempted repo as + drift. +- `python3 scripts/repos_sync.py` (check mode) is clean across the workspace. diff --git a/scripts/repos_sync.py b/scripts/repos_sync.py index efd8048c..f26b0c23 100644 --- a/scripts/repos_sync.py +++ b/scripts/repos_sync.py @@ -34,6 +34,12 @@ copies must be byte-identical to the canonical file, so `--check` fails on any edit made to a copy. +`.claude/` and `CLAUDE.md` are the only top-level entries --write creates in a +target repo, and a repo may lint its own layout. Before writing either one, +--write reads that repo's own allowlist (see "Structure-lint agreement" below) +and skips a repo that has not allowlisted the entry, rather than breaking that +repo's CI with a path it rejects. + --check (always run) verifies, against the manifest: * PyAutoHeart/config/repos.yaml — polled repos exist, owners match @@ -47,11 +53,15 @@ declared config surfaces (FIREWALL_ALLOWLIST below) * the SessionStart hook — present, executable, byte-identical to policy/session_start_hook.sh and registered in .claude/settings.json + * target-repo layout lints — every checked-out repo that lints its own + top-level entries allowlists the `.claude/` and `CLAUDE.md` that --write + installs (a repo that does not is skipped by --write and named here) Exit code 0 = no drift; 1 = drift found (each mismatch printed). """ import argparse +import ast import json import os import re @@ -589,6 +599,8 @@ def check_claude_md_pointers(root, repos): continue # not checked out in this environment if not (repo_dir / "AGENTS.md").exists(): continue # AGENTS-less repos are reported separately, not drift + if structure_lint_forbids(repo_dir, "CLAUDE.md"): + continue # --write skips it; check_structure_lints reports it claude = repo_dir / "CLAUDE.md" if not claude.exists(): problems.append(f"'{name}': has AGENTS.md but no CLAUDE.md pointer") @@ -622,6 +634,12 @@ def write_claude_md_pointers(root, repos): if not (repo_dir / "AGENTS.md").exists(): print(f"skipped (no AGENTS.md): {repo_dir / 'CLAUDE.md'}") continue + if structure_lint_forbids(repo_dir, "CLAUDE.md"): + print( + "SKIPPED (repo's layout lint disallows it): " + f"{repo_dir / 'CLAUDE.md'}" + ) + continue claude = repo_dir / "CLAUDE.md" if claude.exists() and claude_md_is_pointer(claude.read_text()): print(f"unchanged: {claude}") @@ -631,6 +649,159 @@ def write_claude_md_pointers(root, repos): print(f"{verb}: {claude}") +# -------------------------------------------------------------------------- +# Structure-lint agreement +# -------------------------------------------------------------------------- +# +# `--write` creates exactly two top-level entries in a target repo: the +# `.claude/` tooling folder and the `CLAUDE.md` pointer. A repo may lint its +# own layout — an allowlist of the top-level entries it accepts — and such a +# repo has no way to know this script is about to write into it. Installing +# `.claude/` into a repo whose lint has not allowlisted it breaks that repo's +# CI, and the breakage reads as the repo's fault rather than as this script's. +# +# So: before writing, ask the target's own lint whether it accepts what is +# about to be created. The lint stays the single authority — its allowlist is +# READ (never executed, never copied here), so a repo that adds `.claude` to +# its allowlist is covered again on the next run with no change on this side. +# Repos with no lint are the common case and are untouched by any of this. + +# Where a repo is expected to keep its layout lint. A repo that keeps one +# somewhere else is not covered — a real, bounded gap: extend this tuple when a +# repo lints its layout from a different path. Detection is by convention +# because the alternative (a per-repo key in repos.yaml) would put POLICY in the +# body map, which is identity-only by contract. +STRUCTURE_LINT_CANDIDATES = ("scripts/validate_structure.py",) + +# The top-level entries --write creates, each flagged with whether it is a +# directory — which picks the allowlist that governs it. +GENERATED_TOP_LEVEL = ((".claude", True), ("CLAUDE.md", False)) + +# The module-level names a layout lint uses for its two allowlists. Matched +# exactly: accepting near-miss spellings would turn "no allowlist found" +# (reported) into "wrong allowlist read" (silent). +ALLOWLIST_NAMES = {True: "ALLOWED_TOP_DIRS", False: "ALLOWED_TOP_FILES"} + + +def find_structure_lint(repo_dir): + """The repo's own layout lint, or None if it keeps none.""" + for rel in STRUCTURE_LINT_CANDIDATES: + path = repo_dir / rel + if path.is_file(): + return path + return None + + +def string_set_literal(node): + """The strings in a set/list/tuple literal, or None if the node is not one + — or holds anything but plain strings. A computed allowlist cannot be read + without running the lint, and this never runs the lint.""" + if not isinstance(node, (ast.Set, ast.List, ast.Tuple)): + return None + names = set() + for element in node.elts: + if not isinstance(element, ast.Constant) or not isinstance( + element.value, str + ): + return None + names.add(element.value) + return names + + +def structure_lint_allowlists(path): + """Read `{is_dir: allowed names}` out of a layout lint without running it. + + A missing entry means that allowlist could not be read (absent, computed, + or the file does not parse) — never that it is empty, and never that it is + permissive. check_structure_lints reports the difference. + """ + try: + tree = ast.parse(path.read_text()) + except (OSError, SyntaxError): + return {} + wanted = {name: is_dir for is_dir, name in ALLOWLIST_NAMES.items()} + found = {} + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id in wanted: + names = string_set_literal(node.value) + if names is not None: + found[wanted[target.id]] = names + return found + + +def structure_lint_verdict(repo_dir): + """`(lint_path, forbidden, unreadable)` for one checked-out repo. + + `forbidden` names the generated top-level entries this repo's lint would + reject — the writers skip those. `unreadable` names allowlists that exist + in principle but could not be read; those do NOT block the write, because + "cannot tell" is not "forbids", and refusing on a guess would strand the + common case. They are surfaced for a human instead. + """ + lint = find_structure_lint(repo_dir) + if lint is None: + return None, [], [] + allowlists = structure_lint_allowlists(lint) + forbidden, unreadable = [], [] + for entry, is_dir in GENERATED_TOP_LEVEL: + allowed = allowlists.get(is_dir) + if allowed is None: + unreadable.append(ALLOWLIST_NAMES[is_dir]) + elif entry not in allowed: + forbidden.append(entry) + return lint, forbidden, sorted(set(unreadable)) + + +def structure_lint_forbids(repo_dir, entry): + """True when this repo's own layout lint would reject `entry`.""" + return entry in structure_lint_verdict(repo_dir)[1] + + +def check_structure_lints(root, repos): + """Report repos whose own layout lint disagrees with what --write creates. + + Not drift in the generated-copy sense — nothing here has rotted. It is the + coupling this script would otherwise break blind: the writers skip these + repos, and this names them, so the skip gets resolved (extend the + allowlist) rather than going unnoticed. + """ + problems = [] + for name in repos: + repo_dir = root / name + if not repo_dir.is_dir(): + continue # not checked out in this environment + lint, forbidden, unreadable = structure_lint_verdict(repo_dir) + if lint is None: + continue # no layout lint: nothing to disagree with + rel = lint.relative_to(repo_dir) + for entry in forbidden: + # Already on disk is the worse case, and the one that actually + # happened: a --write from before this guard existed left the entry + # behind, so the repo's own lint is failing right now. Skipping the + # next write does not undo that — say so, rather than reporting it + # as a write this run declined to make. + if (repo_dir / entry).exists(): + problems.append( + f"'{name}': {rel} does not allow '{entry}', which is " + "already installed — that lint is failing now; allowlist " + f"'{entry}' or remove it from the repo" + ) + else: + problems.append( + f"'{name}': {rel} does not allow '{entry}' — --write skips " + f"the repo; add '{entry}' to that lint's allowlist" + ) + for allowlist in unreadable: + problems.append( + f"'{name}': {rel} has no readable {allowlist} — cannot tell " + "whether it accepts the generated .claude/ and CLAUDE.md" + ) + return problems + + # -------------------------------------------------------------------------- # Tenant firewall # -------------------------------------------------------------------------- @@ -911,6 +1082,8 @@ def check_session_hooks(root, repos, hook_text): repo_dir = root / name if not repo_dir.is_dir(): continue # not checked out in this environment + if structure_lint_forbids(repo_dir, ".claude"): + continue # --write skips it; check_structure_lints reports it hook = repo_dir / SESSION_HOOK_REL if not hook.exists(): problems.append(f"'{name}': no {SESSION_HOOK_REL}") @@ -939,6 +1112,12 @@ def write_session_hooks(root, repos, hook_text): repo_dir = root / name if not repo_dir.is_dir(): continue + if structure_lint_forbids(repo_dir, ".claude"): + print( + "SKIPPED (repo's layout lint disallows .claude/): " + f"{repo_dir / SESSION_HOOK_REL}" + ) + continue hook = repo_dir / SESSION_HOOK_REL hook.parent.mkdir(parents=True, exist_ok=True) if hook.exists() and hook.read_text() == hook_text: @@ -1035,6 +1214,7 @@ def main(): "CLAUDE.md → AGENTS.md pointers": lambda: check_claude_md_pointers(root, repos), SESSION_HOOKS: lambda: check_session_hooks(root, repos, hook_text), + "target-repo layout lints": lambda: check_structure_lints(root, repos), } if args.only: unknown = [label for label in args.only if label not in checks] diff --git a/tests/test_repos_sync_structure_lint.py b/tests/test_repos_sync_structure_lint.py new file mode 100644 index 00000000..eca10958 --- /dev/null +++ b/tests/test_repos_sync_structure_lint.py @@ -0,0 +1,176 @@ +"""--write must not install `.claude/` into a repo whose own lint rejects it. + +`repos_sync.py --write` creates exactly two top-level entries in every +checked-out repo — `.claude/` and the `CLAUDE.md` pointer — and knows nothing +about the target beyond "checked out, has an AGENTS.md". A repo that lints its +own layout has no way to know the write is coming, so the write breaks that +repo's CI and the breakage reads as the repo's fault. The guard asks the +target's own allowlist first; these tests pin that it actually refuses. + +Conventions this file follows (see `test_repos_sync_hygiene_coverage.py`): + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template, so nothing here names a real repository, and the assertions + are about the guard's logic rather than whatever happens to be checked out. +2. **Prove each leg FAILS.** Every failure mode below is driven with input that + must trip it — a check that cannot fail is decoration. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import repos_sync # noqa: E402 + +HOOK_TEXT = "#!/usr/bin/env bash\necho canonical\n" +REPOS = {"OrganOne": {"category": "organ"}} + +PERMISSIVE = """\ +ALLOWED_TOP_DIRS = {".claude", ".git", "scripts"} +ALLOWED_TOP_FILES = {"AGENTS.md", "CLAUDE.md", "README.md"} +""" +FORBIDS_BOTH = """\ +ALLOWED_TOP_DIRS = {".git", "scripts"} +ALLOWED_TOP_FILES = {"AGENTS.md", "README.md"} +""" + + +def make_repo(root, name="OrganOne", *, lint=None): + """A checked-out repo, optionally carrying its own layout lint.""" + repo = root / name + repo.mkdir(parents=True) + (repo / "AGENTS.md").write_text("# guidance\n") + if lint is not None: + path = repo / repos_sync.STRUCTURE_LINT_CANDIDATES[0] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(lint) + return repo + + +# --- reading the allowlist ------------------------------------------------ + + +def test_repo_without_a_lint_is_unconstrained(tmp_path): + repo = make_repo(tmp_path) + assert repos_sync.structure_lint_verdict(repo) == (None, [], []) + assert not repos_sync.structure_lint_forbids(repo, ".claude") + + +def test_permissive_lint_forbids_nothing(tmp_path): + repo = make_repo(tmp_path, lint=PERMISSIVE) + lint, forbidden, unreadable = repos_sync.structure_lint_verdict(repo) + assert lint is not None + assert (forbidden, unreadable) == ([], []) + + +def test_lint_omitting_both_entries_forbids_both(tmp_path): + repo = make_repo(tmp_path, lint=FORBIDS_BOTH) + assert repos_sync.structure_lint_verdict(repo)[1] == [".claude", "CLAUDE.md"] + + +def test_the_two_entries_are_governed_by_separate_allowlists(tmp_path): + """A dirs-only allowlist must not vouch for the CLAUDE.md file.""" + repo = make_repo( + tmp_path, + lint='ALLOWED_TOP_DIRS = {".claude"}\nALLOWED_TOP_FILES = {"README.md"}\n', + ) + assert repos_sync.structure_lint_verdict(repo)[1] == ["CLAUDE.md"] + + +def test_list_and_tuple_allowlists_are_read_too(tmp_path): + repo = make_repo( + tmp_path, + lint='ALLOWED_TOP_DIRS = [".claude"]\nALLOWED_TOP_FILES = ("CLAUDE.md",)\n', + ) + assert repos_sync.structure_lint_verdict(repo)[1] == [] + + +def test_computed_allowlist_is_unreadable_not_permissive(tmp_path): + """A lint we cannot read without running it must never read as an all-clear.""" + repo = make_repo( + tmp_path, + lint='BASE = {".git"}\nALLOWED_TOP_DIRS = BASE | {".claude"}\n' + 'ALLOWED_TOP_FILES = {"CLAUDE.md"}\n', + ) + _, forbidden, unreadable = repos_sync.structure_lint_verdict(repo) + assert unreadable == ["ALLOWED_TOP_DIRS"] + assert forbidden == [] # "cannot tell" is not "forbids" — it must not block + + +def test_unparseable_lint_is_unreadable_not_permissive(tmp_path): + repo = make_repo(tmp_path, lint="ALLOWED_TOP_DIRS = {\n") + _, forbidden, unreadable = repos_sync.structure_lint_verdict(repo) + assert unreadable == ["ALLOWED_TOP_DIRS", "ALLOWED_TOP_FILES"] + assert forbidden == [] + + +# --- the writers refuse --------------------------------------------------- + + +def test_write_installs_into_a_repo_whose_lint_allows_it(tmp_path): + repo = make_repo(tmp_path, lint=PERMISSIVE) + repos_sync.write_session_hooks(tmp_path, REPOS, HOOK_TEXT) + repos_sync.write_claude_md_pointers(tmp_path, REPOS) + assert (repo / repos_sync.SESSION_HOOK_REL).exists() + assert (repo / repos_sync.SESSION_SETTINGS_REL).exists() + assert (repo / "CLAUDE.md").exists() + + +def test_write_refuses_a_repo_whose_lint_disallows_the_entries(tmp_path): + repo = make_repo(tmp_path, lint=FORBIDS_BOTH) + repos_sync.write_session_hooks(tmp_path, REPOS, HOOK_TEXT) + repos_sync.write_claude_md_pointers(tmp_path, REPOS) + assert not (repo / ".claude").exists() + assert not (repo / "CLAUDE.md").exists() + + +# --- the checks agree with the writers ------------------------------------ + + +def test_skipped_repo_is_not_also_reported_as_generated_drift(tmp_path): + """The write side and the drift side must agree, or a deliberately + unwritten repo reads as permanent drift on every run.""" + make_repo(tmp_path, lint=FORBIDS_BOTH) + assert repos_sync.check_session_hooks(tmp_path, REPOS, HOOK_TEXT) == [] + assert repos_sync.check_claude_md_pointers(tmp_path, REPOS) == [] + + +def test_skipped_repo_is_reported_by_its_own_check(tmp_path): + make_repo(tmp_path, lint=FORBIDS_BOTH) + problems = repos_sync.check_structure_lints(tmp_path, REPOS) + assert len(problems) == 2 + assert any(".claude" in p for p in problems) + assert any("CLAUDE.md" in p for p in problems) + assert all("OrganOne" in p for p in problems) + + +def test_unreadable_allowlist_is_reported(tmp_path): + make_repo(tmp_path, lint="ALLOWED_TOP_DIRS = {\n") + problems = repos_sync.check_structure_lints(tmp_path, REPOS) + assert len(problems) == 2 + assert all("cannot tell" in p for p in problems) + + +def test_permissive_and_lintless_repos_are_silent(tmp_path): + make_repo(tmp_path, "OrganOne", lint=PERMISSIVE) + make_repo(tmp_path, "LibTwo") + repos = {"OrganOne": {"category": "organ"}, "LibTwo": {"category": "library"}} + assert repos_sync.check_structure_lints(tmp_path, repos) == [] + + +def test_repo_that_is_not_checked_out_is_skipped(tmp_path): + assert repos_sync.check_structure_lints(tmp_path, REPOS) == [] + + +def test_forbidden_entry_already_on_disk_is_reported_as_currently_failing(tmp_path): + """The case that actually happened: a --write from before the guard existed + left `.claude/` behind, so the repo's lint is red now. Skipping the next + write does not undo that, and the report must not imply it did.""" + repo = make_repo(tmp_path, lint=FORBIDS_BOTH) + (repo / ".claude").mkdir() + (repo / "CLAUDE.md").write_text(repos_sync.CLAUDE_MD_POINTER) + problems = repos_sync.check_structure_lints(tmp_path, REPOS) + assert len(problems) == 2 + assert all("already installed" in p for p in problems) + assert all("skips" not in p for p in problems)