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.
-
1In flight
3Parked
5Planned
140Backlog
+
1In flight
3Parked
5Planned
141Backlog
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.
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 | @PyAutoFitTransformedMessage.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)