diff --git a/AGENTS.md b/AGENTS.md index cca3485..002aeaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,11 @@ For the full workflow narrative, conventions, and registry schemas, read input for `register_and_iterate --queue`), `ideas.md` (raw inbox swept by `$intake`, `/intake` in Claude). Mutate these only via the skills in `skills/` so commit messages stay consistent. + `dashboard.md` is the **generated** read-only view over all of it (the page + the README links): regenerate with `pyauto-brain intake --apply dashboard` + after any registry or `draft/` change you want reflected immediately — never + hand-edit it. `dashboard_refresh.yml` self-heals it on pushes to `main`, so a + missed regeneration is drift that fixes itself, not a broken page. `parked.md` holds tasks that were started or scoped but are not currently in flight (e.g. work parked in a stash, orphan worktrees); move back to `active.md` (or `planned.md` if re-scoping) when resuming. diff --git a/README.md b/README.md index d0a4c53..b51cd6b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ 📖 **Full documentation → ** — the whole PyAutoScientist organism, including how to fork and run your own. +📋 **[Task dashboard → `dashboard.md`](dashboard.md)** — every task the Mind is +holding, on one page: what to pick up now, what is in flight, and the whole +backlog. Reads on a phone; regenerated from this repo, so it is never a +second copy of the truth. + The Mind of the PyAuto organism: every piece of work in the ecosystem starts here, as a markdown file describing what you want in plain English. An AI agent (or a human) picks the file up and turns it into a tracked GitHub @@ -23,6 +28,7 @@ What lives here: | File / folder | What it is | |---------------|------------| +| [`dashboard.md`](dashboard.md) | **generated** — every task on one page, to pick from | | `ideas.md` | raw incubating ideas, no structure required | | `draft///*.md` | scoped prompts, **not started** (`feature/`, `bug/`, `docs/`, …) | | `active/.md` | **issued** prompts — an open issue, in flight | diff --git a/REFERENCE.md b/REFERENCE.md index 869fb93..61a01a9 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -134,6 +134,8 @@ over the registry without starting work: ``` PyAutoMind/ ├── README.md ← short front page +├── dashboard.md ← GENERATED task page (picks / in flight / parked / planned / backlog) +│ `pyauto-brain intake --apply dashboard`; CI self-heals it on main ├── REFERENCE.md ← this file (schemas + conventions) ├── .gitignore │ @@ -298,11 +300,42 @@ Free-form markdown. Strong conventions: Autonomy: supervised # safe | supervised | human-required Priority: normal # low | normal | high Status: draft + Blocked-by: PyAutoFit#1436 # optional; see "Declaring a gate" below ``` When present, `Type:` should match the work-type folder. The goal is light structure, not bureaucracy — prompts stay free-form prose. + **Declaring a gate — `Closes-when:` / `Blocked-by:`.** Both optional. A prompt + that waits on something external can say so in a form `lifecycle.py issues + --drafts` can grade: + + ```markdown + Closes-when: autolens_profiling#70 # this prompt is DONE when that closes + Blocked-by: PyAutoArray#431, PyAutoGalaxy#486 # READY TO START when all close + ``` + + The two readings are **opposite**, which is the whole point. Prose cannot be + graded, so a cited issue could mean either and `--drafts` had to report every + one as the same ambiguous question. With a declared key the tool reports the + action instead: a closed `Closes-when:` says *likely shipped, verify and + retire*; a closed `Blocked-by:` says *ready to start*. Prompts declaring a gate + drop out of the ambiguous advisory list. + + Notes: + - Accepts `Repo#123` shorthand (assumed `PyAutoLabs/`) or a full URL, and PRs + as well as issues. Several refs may be comma-separated. + - `Blocked-by:` clears only when **every** ref closes; a partly-satisfied gate + is reported in its own weaker band rather than as ready. + - Keys inside fenced code blocks are documentation and are ignored, so a prompt + may show the syntax without declaring a gate. + - Advisory, never a gate on the exit code: retiring a prompt writes to + `complete/` and stays a human act. + + Motivated by the 2026-08-09 `draft/` sweep, where five prompts' stated gates + had closed without anyone noticing — including one whose exit condition was met + the same day it was written. + The optional `Difficulty:` / `Autonomy:` / `Priority:` keys let both people and PyAutoBrain see, at a glance, how hard a task is, whether an agent can safely take it on, and how urgent it is. What each `Autonomy:` level *does* at every diff --git a/SPAWNED_FROM b/SPAWNED_FROM index 89b4adb..9dbe9bf 100644 --- a/SPAWNED_FROM +++ b/SPAWNED_FROM @@ -1,2 +1,2 @@ -PyAutoMind @ 0a2085dbaaa09b4d90f9d27d69dc62dd5955a1f0 +PyAutoMind @ aa10cf8731d90657b55a2065bf26bb4819da62f4 generated by scripts/spawn.py diff --git a/dashboard.md b/dashboard.md new file mode 100644 index 0000000..05b9fae --- /dev/null +++ b/dashboard.md @@ -0,0 +1,3 @@ +# PyAutoMind task dashboard + + diff --git a/scripts/lifecycle.py b/scripts/lifecycle.py index 990e48b..5424f17 100644 --- a/scripts/lifecycle.py +++ b/scripts/lifecycle.py @@ -33,8 +33,22 @@ Drift guard (mirrors repos_sync.py --check; non-zero exit on drift): * no active.md slug has a complete/ record (finished but still active) * no file lives in two states at once + * every registry `prompt:` path resolves, exactly rather than by + fallback, and into the state folder its registry implies + * no slug is listed in two registries at once + * no active/ prompt is left unclaimed by every registry Wire into /health and CI. + orphans + The focused view of `check`'s last leg: active/ prompts that no registry + entry claims. `check` grades this too — this lists only them. + + issues + The ONLINE leg (needs `gh` + network, so deliberately not part of + `check`): every registry entry's tracking issue cross-checked against + GitHub. Catches finished work still listed as pending — the class no + offline check can see. + This file is intentionally stdlib-only (no PyAuto imports) so it runs in any environment, including a bare template checkout. """ @@ -84,6 +98,514 @@ def ledger_slugs(path: Path) -> "set[str]": return slugs +# --------------------------------------------------------------------------- # +# registry integrity +# +# The registry files are the first thing a task-selection pass reads, so a wrong +# entry costs a whole session before it is noticed. `check` used to ignore them +# entirely — it never opened planned.md or parked.md and never resolved a single +# `prompt:` path, so it printed OK over a planned.md in which 8 of 12 entries +# were wrong (2026-08-08 audit). +# --------------------------------------------------------------------------- # +REGISTRY_FILES = ("active.md", "planned.md", "parked.md") + +# A field is a ZERO-INDENT `- key: value`. Nested two-space bullets are values +# of their parent key (` - SomeRepo: some-branch` under `repos:`), NOT fields — +# reading them as fields would invent keys out of branch names. +FIELD_RE = re.compile(r"^-\s*([^:\s][^:]*?):\s*(.*)$") + +# Which state folder(s) each registry's prompts may live in. parked.md takes +# BOTH: it holds tasks that were merely scoped (prompt still in draft/) and +# tasks that were started and then parked (prompt already advanced to active/). +EXPECTED_STATE = { + "active.md": {"active"}, + "planned.md": {"draft"}, + "parked.md": {"draft", "active"}, +} + + +def registry_entries(path: Path) -> "list[tuple[str, dict]]": + """[(slug, {key: value})] for each `## slug` section of a registry file. + + First occurrence of a key wins, matching how a reader scans the block.""" + entries: "list[tuple[str, dict]]" = [] + if not path.exists(): + return entries + fields: "dict[str, str]" = {} + slug = None + for line in path.read_text(errors="replace").splitlines(): + m = H2_RE.match(line) + if m: + if slug is not None: + entries.append((slug, fields)) + slug, fields = _slugify_h2(m.group(1)), {} + continue + if slug is None: + continue + f = FIELD_RE.match(line) + if f: + fields.setdefault(f.group(1).strip(), f.group(2).strip()) + if slug is not None: + entries.append((slug, fields)) + return entries + + +def resolve_prompt(root: Path, raw: str) -> "tuple[Path | None, str | None]": + """(path, state) for a registry `prompt:` value, else (None, None). + + Mirrors the fallback chain AGENTS.md documents for `$start-dev`: the literal + path, the pre-lifecycle `PyAutoMind///` and bare + `//` forms under draft/, and the bare filename in active/ + or as a complete/ record. `state` is the state folder the file ACTUALLY sits + in, which is what makes a state contradiction visible — resolving is not the + same as being in the right place.""" + rel = raw[len("PyAutoMind/"):] if raw.startswith("PyAutoMind/") else raw + stripped = rel[len("draft/"):] if rel.startswith("draft/") else rel + name = Path(rel).name + + candidates = [root / rel, root / "draft" / stripped, root / "active" / name] + complete = root / "complete" + if complete.is_dir(): + candidates += [ + f for f in sorted(complete.rglob(name)) + if (complete / "archive") not in f.parents + ] + + for cand in candidates: + if cand.is_file(): + try: + top = cand.resolve().relative_to(root.resolve()).parts[0] + except ValueError: + return cand, "outside" + return cand, top if top in ("draft", "active", "complete") else "other" + return None, None + + +def registry_problems(root: Path) -> "list[str]": + """Drift across active.md / planned.md / parked.md.""" + problems: "list[str]" = [] + seen: "dict[str, str]" = {} + + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + key = safe_name(slug) + if key in seen and seen[key] != reg: + problems.append( + f"slug listed in two registries: {slug} ({seen[key]} + {reg})" + ) + seen.setdefault(key, reg) + + raw = fields.get("prompt") + if not raw: + continue + # Entries annotate the path with a trailing parenthetical + # ("... .md (carries the phase-1 record)") — the path is the first + # token, the rest is prose for a human. + raw = raw.split()[0] + resolved, state = resolve_prompt(root, raw) + if resolved is None: + problems.append(f"{reg}: {slug}: prompt path does not resolve: {raw}") + continue + + rel = resolved.relative_to(root).as_posix() + expected = EXPECTED_STATE[reg] + if state == "complete": + problems.append( + f"{reg}: {slug}: prompt is a complete/ record (shipped but " + f"still listed): {rel}" + ) + elif state not in expected: + want = "/ or ".join(sorted(expected)) + problems.append( + f"{reg}: {slug}: prompt is in {state}/ but {reg} implies " + f"{want}/: {rel}" + ) + elif rel != raw: + problems.append( + f"{reg}: {slug}: legacy prompt path, resolves only via " + f"fallback: {raw} -> {rel}" + ) + return problems + + +# --------------------------------------------------------------------------- # +# the online leg — tracking-issue state +# +# The offline checks catch STRUCTURAL rot (bad paths, state contradictions). +# They cannot catch the class that costs most: an entry describing work that is +# finished. The 2026-08-08 audit found six such entries, including the whole +# M0-M3 release-validation chain, and not one was locally detectable — every one +# had correct upstream state (a closed issue, a merged PR, a capability live on +# main) that the Mind simply never read back. This is that read-back. +# +# Deliberately NOT part of `check`: it needs the network and `gh` credentials, +# and `check` is wired into CI where it must stay hermetic. +# --------------------------------------------------------------------------- # +# Only TRACKING refs. A task's `library-pr:`/`workspace-pr:` are merged by +# definition once it ships, so reporting those as closed would be pure noise. +ISSUE_FIELDS = ("issue", "epic") +ISSUE_URL_RE = re.compile(r"https://github\.com/([\w.-]+)/([\w.-]+)/issues/(\d+)") + + +class GhUnavailable(RuntimeError): + """`gh` is not installed. Distinct from "gh ran and said no" so the command + can report "could not run" instead of the far worse "nothing to report".""" + + +def registry_issue_refs(root: Path) -> "list[tuple[str, str, str]]": + """(registry, slug, issue_url) for every entry carrying a tracking issue. + + Entries legitimately carry prose instead of a URL ("(no issue — a + human-authorized release drive)", "NEEDS A FRESH ISSUE — ..."); those have + nothing to query and are skipped rather than reported.""" + refs = [] + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + for key in ISSUE_FIELDS: + m = ISSUE_URL_RE.search(fields.get(key, "")) + if m: + refs.append((reg, slug, m.group(0))) + return refs + + +def _gh_issue_states(urls: "list[str]") -> "dict[str, str]": + """{url: state} via the `gh` CLI. Requires gh + network; online leg only.""" + import subprocess + + states: "dict[str, str]" = {} + for url in urls: + m = ISSUE_URL_RE.match(url) + if not m: + continue + owner, repo, num = m.groups() + try: + r = subprocess.run( + ["gh", "api", f"repos/{owner}/{repo}/issues/{num}", "--jq", ".state"], + capture_output=True, text=True, + ) + except FileNotFoundError: + raise GhUnavailable + if r.returncode != 0: + tail = (r.stderr.strip().splitlines() or ["error"])[-1] + states[url] = f"unreadable: {tail}" + continue + states[url] = r.stdout.strip() + return states + + +def issue_problems(root: Path, fetch=None) -> "list[str]": + """Registry entries whose tracking issue is CLOSED — i.e. finished work + still listed as pending. + + `fetch` maps urls -> {url: state}; injectable so the logic is testable + without a network.""" + refs = registry_issue_refs(root) + if not refs: + return [] + fetch = fetch or _gh_issue_states + states = fetch([url for _, _, url in refs]) + + problems = [] + for reg, slug, url in refs: + state = states.get(url, "unknown") + if state == "closed": + problems.append( + f"{reg}: {slug}: tracking issue is CLOSED but the entry is still " + f"listed as pending: {url}" + ) + elif state != "open": + problems.append(f"{reg}: {slug}: could not read issue state ({state}): {url}") + return problems + + +def draft_issue_refs(root: Path) -> "list[tuple[str, str]]": + """(draft_path, issue_url) for draft prompts citing a GitHub issue. + + Only a handful do — drafts are pre-issue by definition — but `draft/` is + backlog no check grades, and it carries shipped work too (the 2026-08-08 + sweep found `minimum_library_version_adoption` fully delivered across all + seven repos while still sitting in draft/).""" + draft = root / "draft" + if not draft.is_dir(): + return [] + refs = [] + for f in sorted(draft.rglob("*.md")): + m = ISSUE_URL_RE.search(f.read_text(errors="replace")) + if m: + refs.append((str(f.relative_to(root)), m.group(0))) + return refs + + +def draft_issue_notes(root: Path, fetch=None) -> "list[str]": + """ADVISORY notes on drafts whose cited issue is closed. + + Deliberately weaker than `issue_problems`, and deliberately not drift. A + registry entry's `issue:` is its OWN tracking issue, so closed means done. A + draft usually cites an issue as CONTEXT — "Once #480 is fixed…", "Follow-up + to #57" — so closed can mean the draft is newly UNBLOCKED rather than + finished. Both readings are worth a human look; neither is a gate. + + For the drafts that DO state which reading applies, see `draft_gate_notes`: + an explicit `Closes-when:` / `Blocked-by:` header removes exactly this + ambiguity, and those drafts are reported there instead of here.""" + gated = {path for path, _, _ in draft_gate_refs(root)} + refs = [(p, u) for p, u in draft_issue_refs(root) if p not in gated] + if not refs: + return [] + fetch = fetch or _gh_issue_states + states = fetch([url for _, url in refs]) + return [ + f"{path}: cited issue is closed — shipped, or newly unblocked? {url}" + for path, url in refs + if states.get(url) == "closed" + ] + + +# --------------------------------------------------------------------------- # +# draft gates +# +# The 2026-08-09 draft/ sweep found five prompts whose stated gate had since +# closed, and the two readings are OPPOSITE: `test_mode_representative_outputs` +# said "EPIC CLOSES when #70 ships its recipe leg" (gate closed => the prompt is +# DONE), while `unpark_imaging_scaling_relation_slam` said "BLOCKED until +# PyAutoArray PR#431 merges" (gate closed => the prompt is READY TO START). +# Prose cannot be graded, so `--drafts` had to lump both into one "shipped, or +# newly unblocked?" note. These keys let a prompt say which it means. +# --------------------------------------------------------------------------- # +GATE_FIELDS = ("closes-when", "blocked-by") +#: `Repo#123` shorthand as well as full URLs — prompts overwhelmingly write the +#: former, and a URL-only extractor found 2 refs across the backlog where the +#: shorthand form found 8 (2026-08-09 measurement). +GATE_REF_RE = re.compile( + r"https://github\.com/([\w.-]+)/([\w.-]+)/(?:issues|pull)/(\d+)" + r"|(? "str | None": + """Normalise either GATE_REF_RE alternative to a canonical issues URL. + + `Repo#123` cannot say whether 123 is an issue or a PR, and the GitHub API + resolves an issues URL for both (a PR *is* an issue), so the issues form is + the safe canonical shape.""" + owner, repo, num, short_repo, short_num = match.groups() + if owner: + return f"https://github.com/{owner}/{repo}/issues/{num}" + if short_repo: + return f"https://github.com/{DEFAULT_GATE_OWNER}/{short_repo}/issues/{short_num}" + return None + + +def draft_gate_refs(root: Path) -> "list[tuple[str, str, str]]": + """(draft_path, gate_kind, url) for drafts carrying a gate header key. + + `gate_kind` is `closes-when` or `blocked-by` — the two opposite readings. + A key may list several refs; each becomes its own entry, because a prompt + blocked on three PRs is only unblocked when the last one lands.""" + draft = root / "draft" + if not draft.is_dir(): + return [] + refs = [] + for f in sorted(draft.rglob("*.md")): + rel = str(f.relative_to(root)) + in_fence = False + for line in f.read_text(errors="replace").splitlines(): + # Fenced blocks are documentation, not declarations. Prompts that + # *describe* these keys (this feature's own prompt does, in a + # ```markdown example) must not be read as declaring them. + if line.lstrip().startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + m = _GATE_KEY_RE.match(line) + if not m: + continue + kind = m.group(1).lower() + for ref in GATE_REF_RE.finditer(m.group(2)): + url = _gate_url(ref) + if url: + refs.append((rel, kind, url)) + return refs + + +def draft_gate_notes(root: Path, fetch=None) -> "dict[str, list[str]]": + """Drafts whose declared gate has closed, split by what that MEANS. + + Returns `{"shipped": [...], "unblocked": [...], "partial": [...], + "unreadable": [...]}`. Unlike `draft_issue_notes` these are unambiguous — + the prompt author said which reading applies — so each line states the + action rather than asking a question. + + Aggregated PER PROMPT, not per reference: a prompt blocked on three PRs is + unblocked only when the last one lands, so reporting each ref separately + would claim "ready to start" three times while it is still blocked. A + partially-satisfied `Blocked-by:` is reported in its own weaker band, which + is the real state of `ep_analytic_updates` (its WP1 gate merged; the WP3/WP4 + gates are open). + + Still advisory, and deliberately so: a satisfied `Closes-when:` is strong + evidence the work is done, but retiring a prompt writes to `complete/` and + stays a human act (the same contract `intake reconcile` keeps).""" + refs = draft_gate_refs(root) + out = {"shipped": [], "unblocked": [], "partial": [], "unreadable": []} + if not refs: + return out + fetch = fetch or _gh_issue_states + states = fetch(sorted({url for _, _, url in refs})) + + grouped: "dict[tuple[str, str], list[str]]" = {} + for path, kind, url in refs: + grouped.setdefault((path, kind), []).append(url) + + for (path, kind), urls in sorted(grouped.items()): + got = [(u, states.get(u, "unknown")) for u in urls] + bad = [f"{u} ({s})" for u, s in got if s not in ("open", "closed")] + if bad: + out["unreadable"].append( + f"{path}: could not read {len(bad)} declared gate(s): " + + ", ".join(bad)) + continue + closed = [u for u, s in got if s == "closed"] + if not closed: + continue + joined = ", ".join(closed) + if len(closed) < len(got): + still = ", ".join(u for u, s in got if s == "open") + out["partial"].append( + f"{path}: {len(closed)} of {len(got)} `{kind}:` gates closed — " + f"partly ready; still open: {still}") + elif kind == "closes-when": + out["shipped"].append( + f"{path}: every `Closes-when:` gate is CLOSED — the prompt's own " + f"exit condition is met, so this is very likely shipped: {joined}") + else: + out["unblocked"].append( + f"{path}: every `Blocked-by:` gate is CLOSED — ready to start, " + f"not blocked: {joined}") + return out + + +def cmd_issues(args) -> int: + """Cross-check every registry entry's tracking issue against GitHub.""" + try: + problems = issue_problems(ROOT) + except GhUnavailable: + print( + "lifecycle issues: cannot run — the `gh` CLI is not installed.\n" + " This leg needs GitHub; it is deliberately separate from `check`,\n" + " which stays hermetic for CI. Install gh, or run this from a\n" + " session that has it.", + file=sys.stderr, + ) + return 2 + notes = [] + gates = {"shipped": [], "unblocked": [], "partial": [], "unreadable": []} + if getattr(args, "drafts", False): + try: + gates = draft_gate_notes(ROOT) + notes = draft_issue_notes(ROOT) + except GhUnavailable: + pass # unreachable: issue_problems above would already have raised + + if problems: + print("lifecycle issues: DRIFT") + for line in problems: + print(f" - {line}") + else: + print(f"lifecycle issues: OK ({len(registry_issue_refs(ROOT))} tracking issue(s) open)") + + # Declared gates first: the prompt author said which reading applies, so + # these are actionable rather than a question. Still advisory — retiring a + # prompt writes to complete/ and stays human. + if gates["shipped"]: + print(f"\nGATE MET — {len(gates['shipped'])} draft(s) whose `Closes-when:` " + f"has closed (likely shipped; verify, then retire):") + for line in gates["shipped"]: + print(f" ! {line}") + if gates["unblocked"]: + print(f"\nUNBLOCKED — {len(gates['unblocked'])} draft(s) whose `Blocked-by:` " + f"has closed (ready to start):") + for line in gates["unblocked"]: + print(f" > {line}") + if gates["partial"]: + print(f"\npartly unblocked — {len(gates['partial'])} draft(s) with some " + f"gates closed:") + for line in gates["partial"]: + print(f" ~ {line}") + if gates["unreadable"]: + print(f"\nunreadable — {len(gates['unreadable'])} declared gate(s):") + for line in gates["unreadable"]: + print(f" ? {line}") + + # Advisory only — never affects the exit code. A draft citing a closed issue + # with no declared gate may be shipped OR newly unblocked; that is a + # judgement, not drift. Drafts that DO declare a gate are reported above + # instead, so this list is the genuinely-ambiguous remainder. + if notes: + print(f"\nadvisory — {len(notes)} undeclared draft(s) citing a closed issue:") + for line in notes: + print(f" ? {line}") + + return 1 if problems else 0 + + +def orphan_prompts(root: Path) -> "list[Path]": + """active/*.md that no registry entry claims — the mirror of registry_problems(). + + `check` validates registry -> prompt. This is prompt -> registry: a prompt + sitting in active/ that nothing lists is work whose state nobody is + tracking, which is how the M0-M3 release-validation chain shipped without a + single entry being retired. + + A prompt counts as claimed either by a registry `prompt:` path that resolves + to it, or by an entry whose slug matches its filename stem — many entries + predate the `prompt:` convention and identify their file by name alone. + """ + active_dir = root / "active" + if not active_dir.is_dir(): + return [] + + claimed: "set[Path]" = set() + slugs: "set[str]" = set() + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + slugs.add(safe_name(slug)) + raw = fields.get("prompt") + if not raw: + continue + resolved, _ = resolve_prompt(root, raw.split()[0]) + if resolved is not None: + claimed.add(resolved.resolve()) + + orphans = [] + for f in sorted(active_dir.glob("*.md")): + if f.resolve() in claimed or safe_name(f.stem) in slugs: + continue + orphans.append(f) + return orphans + + +def cmd_orphans(args) -> int: + """Report active/ prompts no registry claims. + + This condition is now part of `check` (it became a gate on 2026-08-08, once + the 8-prompt backlog the audit found was triaged to zero). The subcommand + stays as the focused view — `check` reports orphans alongside everything + else, this lists only them. + """ + orphans = orphan_prompts(ROOT) + if not orphans: + print("lifecycle orphans: none") + return 0 + print(f"lifecycle orphans: {len(orphans)} active/ prompt(s) no registry claims") + for f in orphans: + print(f" - {f.relative_to(ROOT)}") + return 1 + + def _prune_ledger_section(path: Path, slug: str) -> bool: """Drop the `## ` H2 section (heading through the line before the next H2, or EOF) from a ledger file. Returns True if a section was removed.""" @@ -395,6 +917,12 @@ def cmd_check(args) -> int: if f.name in active_names: problems.append(f"file in both active/ and complete/: {f.name}") + problems.extend(registry_problems(ROOT)) + problems.extend( + f"active/ prompt no registry entry claims: {f.relative_to(ROOT)}" + for f in orphan_prompts(ROOT) + ) + if problems: print("lifecycle check: DRIFT") for p in problems: @@ -431,6 +959,16 @@ def main() -> int: c = sub.add_parser("check", help="drift guard (non-zero exit on drift)") c.set_defaults(func=cmd_check) + iss = sub.add_parser( + "issues", help="cross-check registry tracking issues against GitHub (needs gh)" + ) + iss.add_argument("--drafts", action="store_true", + help="also flag draft/ prompts citing a closed issue (advisory)") + iss.set_defaults(func=cmd_issues) + + o = sub.add_parser("orphans", help="report active/ prompts no registry claims") + o.set_defaults(func=cmd_orphans) + args = p.parse_args() return args.func(args) diff --git a/scripts/prompt_sync.sh b/scripts/prompt_sync.sh index 4908143..21a977f 100755 --- a/scripts/prompt_sync.sh +++ b/scripts/prompt_sync.sh @@ -45,6 +45,28 @@ _prompt_sync_require_repo() { fi } +# Push the checkout's CURRENT branch — never a hardcoded `main`. +# +# Both helpers below used to end in `git push origin main` regardless of what +# was checked out. `create_issue` step 6 and `start_dev` step 7 both instruct an +# agent to call them, so a branch-scoped session (a cloud session with a +# designated branch, or any PR-based flow) that followed the documented steps +# verbatim pushed Mind straight to `main`, bypassing review entirely. +# +# Pushing HEAD leaves the single-branch laptop flow byte-identical — there the +# current branch IS `main` — while making a branch-scoped checkout do the right +# thing instead of the dangerous thing. +_prompt_sync_push_current() { + local branch + branch=$(git -C "$PROMPT_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null) + if [ -z "$branch" ] || [ "$branch" = "HEAD" ]; then + echo "prompt_sync: refusing to push from a detached HEAD in $PROMPT_REPO" >&2 + echo " check out a branch first." >&2 + return 1 + fi + git -C "$PROMPT_REPO" push -u origin "$branch" +} + # Commit and push any new untracked .md files at the repo root or under # category dirs as one "sync new task ideas" commit. Each new file is listed # individually in the commit body so the history shows which prompts arrived. @@ -69,8 +91,8 @@ prompt_sync_new_prompts() { body=$(echo "$untracked" | sed 's/^/- /') ( cd "$PROMPT_REPO" && git add -- $untracked && \ - git commit -m "$(printf 'prompt: sync new task ideas\n\n%s\n' "$body")" && \ - git push origin main ) + git commit -m "$(printf 'prompt: sync new task ideas\n\n%s\n' "$body")" ) && \ + _prompt_sync_push_current } # Stage any modifications under the repo, commit with the given subject, @@ -79,9 +101,12 @@ prompt_sync_new_prompts() { prompt_sync_push() { _prompt_sync_require_repo || return 1 local subject="${1:-prompt: sync PyAutoMind}" - ( cd "$PROMPT_REPO" && \ - git add -A && \ - if git diff --cached --quiet; then return 0; fi && \ - git commit -m "$subject" && \ - git push origin main ) + git -C "$PROMPT_REPO" add -A || return 1 + # Still a no-op when nothing is staged — the early return must come BEFORE the + # push, or a caller with no changes would push anyway. + if git -C "$PROMPT_REPO" diff --cached --quiet; then + return 0 + fi + git -C "$PROMPT_REPO" commit -m "$subject" || return 1 + _prompt_sync_push_current } diff --git a/scripts/repos_sync.py b/scripts/repos_sync.py index 926a3b5..e8a4e5d 100644 --- a/scripts/repos_sync.py +++ b/scripts/repos_sync.py @@ -30,6 +30,8 @@ * PyAutoHeart/config/repos.yaml — polled repos exist, owners match * PyAutoHands/pre_build.sh — run_workspace repos exist * PyAutoBrain/bin/ensure_workspace_labels.sh — owner/name pairs match + * the hygiene conductor — the repo sets it scans are derived from this + manifest, and no repo name has been hardcoded back into an array * the `origin` remote of every local checkout — manifest matches reality * the tenant firewall — no instance fact (satellite repo name, GitHub owner, workspace path) in Brain/Heart/Build *.py / *.sh outside the @@ -39,6 +41,8 @@ """ import argparse +import json +import os import re import subprocess import sys @@ -311,6 +315,86 @@ def check_pre_build(root, repos): ] +HYGIENE_DIR = "PyAutoBrain/agents/conductors/hygiene" +HYGIENE_SCRIPT = f"{HYGIENE_DIR}/hygiene.sh" +HYGIENE_HELPER = f"{HYGIENE_DIR}/_hygiene_repos.py" + +# The hygiene conductor scans repositories, so its repo sets must equal this +# manifest's. They used to be bash arrays, and they drifted: five libraries +# where the manifest declared six, four organs of seven. The drift was invisible +# because an unscanned repo yields no findings — the conductor reported clean and +# was believed. The tenant firewall could not catch it either; its allowlist +# PERMITTED the stale names rather than checking coverage. +# +# So this check has two legs, because either alone is escapable: +# +# A. every reader the conductor might use returns exactly the sets declared +# here. Note what this can and cannot prove: the conductor reads THIS file, +# so a manifest edit moves both sides together and can never desynchronise +# them — that is the whole point of deriving. What leg A really guards is +# the READER, and specifically the PyYAML-free fallback, which is used only +# where PyYAML is absent and would otherwise be verified nowhere. A +# fallback parser that quietly drops a repo is precisely this bug's class, +# so both readers are run and both must agree with the manifest. +# B. no repo name is written back into a *_REPOS=(...) array literal — what +# stops a future edit from "simplifying" the derivation away. +HYGIENE_ARRAY = re.compile(r"^[ \t]*[A-Za-z_]*REPOS=\(([^)]*)\)", re.M) +HYGIENE_CATEGORIES = ("library", "organ", "workspace") + + +def check_hygiene_coverage(root, repos, mind_root): + helper, script = root / HYGIENE_HELPER, root / HYGIENE_SCRIPT + if not helper.exists() or not script.exists(): + return [] # Brain not checked out in this environment + + problems = [] + for reader in ("auto", "minimal"): + result = subprocess.run( + [sys.executable, str(helper), "--json", "--parser", reader], + capture_output=True, + text=True, + env={**os.environ, "PYAUTO_MIND": str(mind_root)}, + ) + if result.returncode != 0: + problems.append( + f"{HYGIENE_HELPER} ({reader} reader): cannot read the body map " + f"(exit {result.returncode}) — the conductor would scan nothing: " + f"{result.stderr.strip()}" + ) + continue + try: + derived = json.loads(result.stdout) + except json.JSONDecodeError as exc: + problems.append( + f"{HYGIENE_HELPER} ({reader} reader): output is not JSON — {exc}" + ) + continue + for category in HYGIENE_CATEGORIES: + declared = {n for n, r in repos.items() if r["category"] == category} + seen = set(derived.get(category, [])) + for name in sorted(declared - seen): + problems.append( + f"hygiene ({reader} reader) does not scan '{name}' ({category}) " + f"— declared in the manifest but missing from the derived set" + ) + for name in sorted(seen - declared): + problems.append( + f"hygiene ({reader} reader) scans '{name}' ({category}) " + f"— not in the manifest" + ) + + for match in HYGIENE_ARRAY.finditer(script.read_text()): + hardcoded = sorted( + {tok.strip("\"'") for tok in match.group(1).split()} & set(repos) + ) + if hardcoded: + problems.append( + f"{HYGIENE_SCRIPT}: repo name(s) hardcoded in an array — " + f"{', '.join(hardcoded)}; derive them from the body map instead" + ) + return problems + + def check_labels(root, repos): script = root / "PyAutoBrain/bin/ensure_workspace_labels.sh" if not script.exists(): @@ -522,7 +606,10 @@ def write_claude_md_pointers(root, repos): "PyAutoBrain/agents/conductors/hygiene/_hygiene_config.py": {"PyAutoArray", "PyAutoCTI", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, "PyAutoBrain/agents/conductors/hygiene/_hygiene_optdeps.py": {"HowToFit", "HowToGalaxy", "HowToLens", "autocti_workspace", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, "PyAutoBrain/agents/conductors/hygiene/_hygiene_refs.py": {"PyAutoArray", "PyAutoCTI", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens", "autolens_workspace"}, - "PyAutoBrain/agents/conductors/hygiene/hygiene.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, + # hygiene.sh and _hygiene_repos.py carry NO entry on purpose: the conductor + # now derives its repo sets from the body map, so it names no instance fact + # at all. Re-adding an entry here would re-permit the drift that + # check_hygiene_coverage exists to catch. "PyAutoBrain/agents/conductors/clone/_clone.py": {"HowToFit", "PyAutoFit", "PyAutoLabs", "PyAutoLens", "autofit_assistant", "autofit_workspace", "autolens_assistant"}, "PyAutoBrain/agents/conductors/clone/clone.sh": {"HowToFit", "PyAutoFit", "autofit_workspace", "autolens_assistant"}, "PyAutoBrain/agents/conductors/community/_community.py": {"Jammy2211", "PyAutoLabs"}, @@ -532,13 +619,16 @@ def write_claude_md_pointers(root, repos): "PyAutoBrain/agents/conductors/release/nightly.sh": {"PyAutoLabs", "PyAutoLens"}, "PyAutoBrain/agents/conductors/release/rehearse.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, "PyAutoBrain/agents/conductors/release/validate.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, - "PyAutoBrain/agents/conductors/workspace/_workspace.py": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoLabs", "PyAutoReduce", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, + "PyAutoBrain/agents/conductors/workspace/_workspace.py": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoLabs", "PyAutoReduce", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace", "autoreduce_workspace"}, "PyAutoBrain/agents/faculties/memory/_memory.py": {"autolens_assistant"}, "PyAutoBrain/agents/faculties/memory/memory.sh": {"autolens_assistant"}, "PyAutoBrain/agents/faculties/review/_review.py": {"PyAutoLabs"}, "PyAutoBrain/agents/faculties/review/review.sh": {"PyAutoLabs"}, - "PyAutoBrain/agents/faculties/samplers/_samplers.py": {"PyAutoFit", "autofit_workspace_developer", "autofit_workspace_test"}, - "PyAutoBrain/agents/faculties/samplers/samplers.sh": {"PyAutoFit", "autofit_workspace_developer", "autofit_workspace_test"}, + # The two autolens tokens are the findings maturation lane's experiment and + # mature tiers — surfaces, not new files, so the entries grow rather than + # the list. + "PyAutoBrain/agents/faculties/samplers/_samplers.py": {"PyAutoFit", "autofit_workspace_developer", "autofit_workspace_test", "autolens_profiling", "autolens_workspace_developer"}, + "PyAutoBrain/agents/faculties/samplers/samplers.sh": {"PyAutoFit", "autofit_workspace_developer", "autofit_workspace_test", "autolens_profiling", "autolens_workspace_developer"}, "PyAutoBrain/agents/faculties/sizing/_sizing.py": {"PyAutoFit"}, "PyAutoBrain/docs/conf.py": {"PyAutoScientist"}, "PyAutoBrain/bin/check_skill_line_counts.sh": {"admin_jammy", "autolens_profiling"}, @@ -553,17 +643,19 @@ def write_claude_md_pointers(root, repos): "PyAutoBrain/tests/test_clean_slate.py": {"PyAutoArray", "PyAutoFit", "PyAutoGalaxy", "autolens_workspace", "euclid_assistant"}, "PyAutoBrain/tests/test_clone_conductor.py": {"autofit_assistant", "autolens_assistant"}, "PyAutoBrain/tests/test_community_conductor.py": {"Jammy2211", "PyAutoFit", "PyAutoLabs", "PyAutoLens", "admin_jammy"}, - "PyAutoBrain/tests/test_hygiene_conductor.py": {"PyAutoArray", "PyAutoFit", "PyAutoGalaxy", "autolens_workspace"}, + "PyAutoBrain/tests/test_hygiene_conductor.py": {"PyAutoArray", "PyAutoFit", "PyAutoGalaxy", "autofit_workspace", "autolens_workspace"}, "PyAutoBrain/tests/test_mind_commit_guard.py": {"/home/jammy", "PyAutoFit", "PyAutoLabs"}, "PyAutoBrain/tests/test_policy_seams.py": {"PyAutoFit", "PyAutoLens", "autolens_workspace"}, "PyAutoBrain/tests/test_review_inplace.py": {"PyAutoArray", "PyAutoLabs"}, "PyAutoBrain/tests/test_skill_install.py": {"PyAutoLabs"}, - "PyAutoBrain/tests/test_workspace_conductor.py": {"HowToGalaxy", "HowToLens", "autolens_workspace"}, + "PyAutoBrain/tests/test_workspace_conductor.py": {"HowToGalaxy", "HowToLens", "autolens_workspace", "autoreduce_workspace"}, "PyAutoHands/autohands/aggregate_results.py": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, "PyAutoHands/autohands/build_util.py": {"PyAutoNerves"}, "PyAutoHands/autohands/bump_colab_urls.sh": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoLabs", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, + "PyAutoHands/autohands/check_search_memory.py": {"PyAutoFit", "PyAutoLabs"}, "PyAutoHands/autohands/clone_seed.py": {"autofit_assistant"}, "PyAutoHands/autohands/create_analysis_issue.py": {"PyAutoLabs"}, + "PyAutoHands/autohands/env_config.py": {"PyAutoFit", "PyAutoLabs"}, "PyAutoHands/autohands/generate_autofit.py": {"autofit_workspace"}, "PyAutoHands/autohands/generate_markdown.py": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens"}, "PyAutoHands/autohands/generate_release_notes.py": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "PyAutoScientist"}, @@ -575,8 +667,12 @@ def write_claude_md_pointers(root, repos): "PyAutoHands/autohands/tag_and_merge.sh": {"PyAutoArray", "PyAutoNerves", "PyAutoFit", "PyAutoGalaxy", "PyAutoLens"}, "PyAutoHands/pre_build.sh": {"HowToFit", "HowToGalaxy", "HowToLens", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens", "admin_jammy", "autofit_workspace", "autofit_workspace_developer", "autofit_workspace_test", "autogalaxy_workspace", "autogalaxy_workspace_test", "autolens_assistant", "autolens_workspace", "autolens_workspace_developer", "autolens_workspace_test", "euclid_strong_lens_modeling_pipeline"}, "PyAutoHands/tests/test_bump_colab_urls.py": {"Jammy2211", "PyAutoFit", "PyAutoLabs", "autofit_workspace", "autogalaxy_workspace", "autolens_workspace"}, + "PyAutoHands/tests/test_check_search_memory.py": {"PyAutoFit", "autogalaxy_workspace"}, + "PyAutoHands/tests/test_env_config.py": {"PyAutoFit", "PyAutoLabs"}, "PyAutoHands/tests/test_generate_markdown.py": {"PyAutoArray", "autolens_workspace"}, + "PyAutoHands/tests/test_python_matrix_workflow.py": {"PyAutoFit"}, "PyAutoHands/tests/test_release_notes.py": {"PyAutoArray", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, + "PyAutoHands/tests/test_repro_command.py": {"PyAutoFit", "PyAutoLabs"}, "PyAutoHands/tests/test_run_all_history.py": {"HowToLens", "autogalaxy_workspace_test", "euclid_strong_lens_modeling_pipeline"}, "PyAutoHands/tests/test_slack_release_notes.py": {"PyAutoArray", "PyAutoFit", "PyAutoGalaxy", "PyAutoLabs", "PyAutoLens"}, "PyAutoHands/tests/test_workspace_config_precedence.py": {"autofit_workspace", "autofit_workspace_test", "autogalaxy_workspace", "autogalaxy_workspace_test", "autolens_workspace", "autolens_workspace_test"}, @@ -751,6 +847,7 @@ def main(): "PyAutoHeart/config/repos.yaml": check_heart(root, repos), "PyAutoHands/pre_build.sh": check_pre_build(root, repos), "ensure_workspace_labels.sh": check_labels(root, repos), + "hygiene conductor coverage": check_hygiene_coverage(root, repos, mind_root), "local checkout origins": check_origins(root, repos), "tenant firewall (organ code)": check_tenant_firewall(root, repos), "organism-map blocks (generated)": check_map_blocks(root, repos, smap), diff --git a/scripts/spawn.py b/scripts/spawn.py index 45ab56a..b5ce5f4 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -88,8 +88,11 @@ ("complete/AGENTS.md", "KEEP"), ("active/*", "DROP"), ("complete/*", "DROP"), ("docs/*", "DROP"), - # Instance root docs: - ("dashboard.md", "DROP"), ("overview.md", "DROP"), + # Instance root docs. `dashboard.md` is EMPTY rather than DROP: README.md + # ships verbatim and links it, so dropping it would hand every spawned org + # a broken front-page link. The emptied page carries the regenerate + # command, which is the whole of what a fresh Mind can truthfully say. + ("dashboard.md", "EMPTY"), ("overview.md", "DROP"), ("skills/*", "KEEP"), ("policy/*", "KEEP"), # .github is decided PER FILE by the spec's fresh-repo invariant (rule 9): # a shipped workflow must succeed on a freshly-spawned repo with no secrets @@ -108,6 +111,11 @@ # 9c — instance automation: sibling repo lists, organ-specific workflow # names, org secrets, strong-lensing vocabulary. Every one of the 13 failing # runs in the published template came from these. + # 9c also: the dashboard is rendered by PyAutoBrain, so this workflow + # checks out a sibling repo by name. A fresh org has no such sibling (and + # under owner substitution the name is the literal YOURORG placeholder), so + # every run of it there fails on checkout. + (".github/workflows/dashboard_refresh.yml", "DROP"), (".github/workflows/morning_status.yml", "DROP"), (".github/workflows/morning_health.yml", "DROP"), (".github/workflows/arxiv_papers.yml", "DROP"), @@ -172,6 +180,7 @@ # SHARES a name (e.g. Memory's `bibliography/active.md`, caught by the broad # `bibliography/*` EMPTY rule) does not silently inherit a root file's title. EMPTY_TITLES = { + "dashboard.md": "# PyAutoMind task dashboard", "active.md": "# Active Tasks", "planned.md": "# Planned", "parked.md": "# Parked tasks", diff --git a/tests/test_lifecycle_check.py b/tests/test_lifecycle_check.py new file mode 100644 index 0000000..a8c1ccc --- /dev/null +++ b/tests/test_lifecycle_check.py @@ -0,0 +1,646 @@ +"""Contract tests for the registry-integrity leg of `lifecycle.py check`. + +The registry files are the first thing a task-selection pass reads, so a wrong +entry costs a whole session before anyone notices. `check` used to ignore them +completely — it never opened planned.md or parked.md and never resolved a +`prompt:` path — so it printed OK over a registry in which half the entries +pointed at files that had moved, shipped, or never existed. + +Two things these tests deliberately do, matching `test_repos_sync_hygiene_coverage.py`: + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template (see `test_spawn_privacy.py`), so nothing here names a real + repository, task or prompt. It also keeps the tests hermetic — they assert + the check's logic, not the state of whatever happens to be checked out. +2. **Prove each leg FAILS.** A drift check that cannot fail is decoration. + Every condition below is driven with input that must trip it, and the + clean-tree case proves the checks stay quiet when nothing is wrong. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import lifecycle # noqa: E402 + + +# --------------------------------------------------------------------------- # +# fixtures +# --------------------------------------------------------------------------- # +def _tree(root: Path, *, draft=(), active=(), complete=(), registries=None): + """Build a fictional Mind tree: prompt files in state folders + registries.""" + for rel in draft: + p = root / "draft" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture prompt\n") + for name in active: + p = root / "active" / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture prompt\n") + for rel in complete: + p = root / "complete" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("# fixture record\n") + for name, body in (registries or {}).items(): + (root / name).write_text(body) + return root + + +def _entry(slug, prompt=None, extra=""): + body = f"## {slug}\n- status: planned\n" + if prompt is not None: + body += f"- prompt: {prompt}\n" + return body + extra + "\n" + + +# --------------------------------------------------------------------------- # +# the clean case — the checks must stay quiet +# --------------------------------------------------------------------------- # +def test_clean_tree_has_no_problems(tmp_path): + root = _tree( + tmp_path, + draft=["feature/flywheel/sprocket_calibration.md"], + active=["widget_alignment.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + "active.md": _entry("widget-alignment", "active/widget_alignment.md"), + }, + ) + assert lifecycle.registry_problems(root) == [] + + +def test_entry_without_a_prompt_field_is_not_a_problem(tmp_path): + """Plenty of real entries legitimately carry no `prompt:` (release drives, + trackers). Their absence must not be reported as unresolvable.""" + root = _tree(tmp_path, registries={"planned.md": _entry("no-prompt-here")}) + assert lifecycle.registry_problems(root) == [] + + +# --------------------------------------------------------------------------- # +# leg 1 — the prompt path must resolve at all +# --------------------------------------------------------------------------- # +def test_unresolvable_prompt_path_is_drift(tmp_path): + root = _tree( + tmp_path, + registries={ + "planned.md": _entry("ghost-task", "draft/bug/flywheel/never_written.md") + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "does not resolve" in problems[0] + assert "ghost-task" in problems[0] + + +# --------------------------------------------------------------------------- # +# leg 2 — resolving only via the legacy fallback is still drift +# --------------------------------------------------------------------------- # +def test_legacy_path_resolving_via_fallback_is_drift(tmp_path): + """The pre-lifecycle `PyAutoMind///` form still resolves + for $start-dev, but leaving it in the registry hides where the file is.""" + root = _tree( + tmp_path, + draft=["bug/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "PyAutoMind/bug/flywheel/sprocket_calibration.md", + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "legacy prompt path" in problems[0] + # the message must name where it actually landed, or it is not actionable + assert "draft/bug/flywheel/sprocket_calibration.md" in problems[0] + + +# --------------------------------------------------------------------------- # +# leg 3 — state contradictions +# --------------------------------------------------------------------------- # +def test_planned_entry_whose_prompt_is_in_active_is_drift(tmp_path): + """planned.md means "scoped, not started". A prompt already advanced to + active/ means the task is in flight and the registry is lying.""" + root = _tree( + tmp_path, + active=["sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "active/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "prompt is in active/" in problems[0] + + +def test_entry_whose_prompt_is_a_complete_record_is_drift(tmp_path): + """The expensive class: work that shipped but is still listed as pending.""" + root = _tree( + tmp_path, + complete=["2031/07/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "draft/bug/flywheel/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "shipped but still listed" in problems[0] + + +def test_parked_accepts_both_draft_and_active_prompts(tmp_path): + """parked.md holds tasks that were merely scoped (prompt still in draft/) + AND tasks that were started then parked (prompt already in active/). + Treating it like planned.md flags every genuinely-parked task.""" + root = _tree( + tmp_path, + draft=["feature/flywheel/scoped_then_parked.md"], + active=["started_then_parked.md"], + registries={ + "parked.md": ( + _entry( + "scoped-then-parked", + "draft/feature/flywheel/scoped_then_parked.md", + ) + + _entry("started-then-parked", "active/started_then_parked.md") + ) + }, + ) + assert lifecycle.registry_problems(root) == [] + + +# --------------------------------------------------------------------------- # +# leg 4 — a slug belongs to exactly one registry +# --------------------------------------------------------------------------- # +def test_slug_in_two_registries_is_drift(tmp_path): + root = _tree( + tmp_path, + draft=["feature/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + "parked.md": _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + ), + }, + ) + problems = lifecycle.registry_problems(root) + assert any("listed in two registries" in p for p in problems) + + +# --------------------------------------------------------------------------- # +# parser contracts — these bit during development, so they are pinned +# --------------------------------------------------------------------------- # +def test_nested_repo_bullets_are_not_read_as_fields(tmp_path): + """` - SomeRepo: some-branch` under `repos:` is a VALUE, not a field. + Reading indented bullets as fields invents keys out of branch names.""" + body = _entry( + "sprocket-calibration", + "draft/feature/flywheel/sprocket_calibration.md", + extra="- repos:\n - FlywheelRepo: feature/sprocket\n - GadgetRepo: feature/sprocket\n", + ) + (tmp_path / "planned.md").write_text(body) + entries = lifecycle.registry_entries(tmp_path / "planned.md") + assert len(entries) == 1 + _, fields = entries[0] + assert set(fields) == {"status", "prompt", "repos"} + assert "FlywheelRepo" not in fields + + +def test_trailing_parenthetical_after_the_path_is_tolerated(tmp_path): + """Entries annotate the path with prose: `... .md (carries the table)`. + The path is the first token; the annotation must not break resolution.""" + root = _tree( + tmp_path, + draft=["bug/flywheel/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", + "draft/bug/flywheel/sprocket_calibration.md (carries the phase table)", + ) + }, + ) + assert lifecycle.registry_problems(root) == [] + + +# --------------------------------------------------------------------------- # +# the online leg — tracking-issue state +# +# `fetch` is injected so these stay hermetic: no network, no `gh`, no live repo. +# --------------------------------------------------------------------------- # +GHOST_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/17" +OTHER_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/18" + + +def _states(mapping): + return lambda urls: {u: mapping.get(u, "unknown") for u in urls} + + +def test_closed_tracking_issue_on_a_pending_entry_is_drift(tmp_path): + """The class no offline check can see: the entry reads as pending, the work + is finished, and only GitHub knows.""" + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) + assert len(problems) == 1 + assert "CLOSED" in problems[0] + assert "sprocket-calibration" in problems[0] + + +def test_open_tracking_issue_is_not_drift(tmp_path): + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + assert lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "open"})) == [] + + +def test_prose_instead_of_an_issue_url_is_skipped(tmp_path): + """Real entries carry '(no issue — a human-authorized release drive)' and + 'NEEDS A FRESH ISSUE — ...'. There is nothing to query; not a finding.""" + body = ( + _entry("release-drive", extra="- issue: (no issue — a release drive)\n") + + _entry("needs-one", extra="- issue: NEEDS A FRESH ISSUE — file at start_dev\n") + ) + (tmp_path / "active.md").write_text(body) + assert lifecycle.registry_issue_refs(tmp_path) == [] + assert lifecycle.issue_problems(tmp_path, fetch=_states({})) == [] + + +def test_epic_field_is_treated_as_a_tracking_ref(tmp_path): + (tmp_path / "planned.md").write_text( + _entry("phased-task", extra=f"- epic: {GHOST_ISSUE} (the public watch point)\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) + assert len(problems) == 1 + + +def test_merged_pr_links_are_not_treated_as_tracking_refs(tmp_path): + """A shipped task's library-pr/workspace-pr are merged by definition. + Reporting those as closed would bury the real signal in noise.""" + body = _entry( + "sprocket-calibration", + extra=( + f"- issue: {GHOST_ISSUE}\n" + "- library-pr: https://github.com/FictionalOrg/FlywheelRepo/pull/99\n" + "- workspace-pr: https://github.com/FictionalOrg/GadgetRepo/pull/12\n" + ), + ) + (tmp_path / "active.md").write_text(body) + refs = lifecycle.registry_issue_refs(tmp_path) + assert [r[2] for r in refs] == [GHOST_ISSUE] + + +def test_draft_citing_a_closed_issue_is_advisory(tmp_path): + """draft/ is backlog no check grades, and it carries shipped work too. A + closed cited issue is worth a look — but NOT drift, because a draft usually + cites an issue as context ("Once #480 is fixed…"), so closed can mean newly + unblocked rather than finished.""" + d = tmp_path / "draft" / "feature" / "flywheel" + d.mkdir(parents=True) + (d / "sprocket_calibration.md").write_text(f"Once {GHOST_ISSUE} is fixed, do X.\n") + notes = lifecycle.draft_issue_notes(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) + assert len(notes) == 1 + assert "shipped, or newly unblocked?" in notes[0] + + +def test_draft_with_an_open_issue_is_silent(tmp_path): + d = tmp_path / "draft" / "feature" / "flywheel" + d.mkdir(parents=True) + (d / "sprocket_calibration.md").write_text(f"Blocked on {GHOST_ISSUE}.\n") + assert lifecycle.draft_issue_notes(tmp_path, fetch=_states({GHOST_ISSUE: "open"})) == [] + + +def test_drafts_are_not_mixed_into_registry_drift(tmp_path): + """The advisory must never leak into `issue_problems`, which is the gate.""" + d = tmp_path / "draft" / "feature" / "flywheel" + d.mkdir(parents=True) + (d / "sprocket_calibration.md").write_text(f"Once {GHOST_ISSUE} is fixed.\n") + assert lifecycle.issue_problems(tmp_path, fetch=_states({GHOST_ISSUE: "closed"})) == [] + + +def test_missing_gh_propagates_rather_than_reporting_all_clear(tmp_path): + """"gh is not installed" must never be mistaken for "no findings" — a check + that silently could not run is worse than one that fails loudly.""" + import pytest + + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {GHOST_ISSUE}\n") + ) + + def _no_gh(urls): + raise lifecycle.GhUnavailable + + with pytest.raises(lifecycle.GhUnavailable): + lifecycle.issue_problems(tmp_path, fetch=_no_gh) + + +def test_unreadable_issue_state_is_reported_not_swallowed(tmp_path): + """A deleted repo, a revoked token or a network failure must surface — a + silent 'no findings' from a check that could not run is the worst outcome.""" + (tmp_path / "planned.md").write_text( + _entry("sprocket-calibration", extra=f"- issue: {OTHER_ISSUE}\n") + ) + problems = lifecycle.issue_problems(tmp_path, fetch=_states({})) + assert len(problems) == 1 + assert "could not read issue state" in problems[0] + + +# --------------------------------------------------------------------------- # +# the default fetcher itself +# +# Everything above injects `fetch`, which is what keeps those tests hermetic — +# but it also means the real `_gh_issue_states` shim, the thing that runs on a +# machine that HAS gh, was never executed by the suite. These tests drive it +# with `subprocess.run` stubbed, so the argv, the parsing and both failure +# modes are pinned without a network or a `gh` binary. +# +# `_gh_issue_states` does `import subprocess` inside the function body, which +# rebinds the same module object from sys.modules — so patching the attribute +# on the real module reaches it. +# --------------------------------------------------------------------------- # +class _Completed: + """Stand-in for subprocess.CompletedProcess.""" + + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _stub_run(monkeypatch, handler): + """Patch subprocess.run, recording every argv the shim builds.""" + import subprocess + + calls = [] + + def fake_run(argv, **kwargs): + calls.append(argv) + return handler(argv) + + monkeypatch.setattr(subprocess, "run", fake_run) + return calls + + +def test_default_fetcher_builds_the_gh_argv_and_parses_state(monkeypatch): + """The happy path: one `gh api` call per URL, `.state` jq-extracted, and + the trailing newline gh emits stripped off.""" + calls = _stub_run(monkeypatch, lambda argv: _Completed(stdout="open\n")) + + states = lifecycle._gh_issue_states([GHOST_ISSUE]) + + assert states == {GHOST_ISSUE: "open"} + assert calls == [ + [ + "gh", + "api", + "repos/FictionalOrg/FlywheelRepo/issues/17", + "--jq", + ".state", + ] + ] + + +def test_default_fetcher_raises_gh_unavailable_when_gh_is_missing(monkeypatch): + """The one error the command turns into "could not run" rather than a + finding — so it must be the exception type, not a state string.""" + import pytest + + def _missing(argv): + raise FileNotFoundError(2, "No such file or directory: 'gh'") + + _stub_run(monkeypatch, _missing) + + with pytest.raises(lifecycle.GhUnavailable): + lifecycle._gh_issue_states([GHOST_ISSUE]) + + +def test_default_fetcher_reports_a_failed_call_as_unreadable(monkeypatch): + """gh ran and said no (404, revoked token, rate limit). That is a finding, + not a crash — and `issue_problems` grades any non-'open' state, so the + string it stores must not be mistaken for 'closed'.""" + _stub_run( + monkeypatch, + lambda argv: _Completed( + returncode=1, + stderr="gh: Not Found (HTTP 404)\n", + ), + ) + + states = lifecycle._gh_issue_states([GHOST_ISSUE]) + + assert states[GHOST_ISSUE].startswith("unreadable: ") + assert "HTTP 404" in states[GHOST_ISSUE] + assert states[GHOST_ISSUE] != "closed" + + +def test_default_fetcher_survives_a_failure_with_no_stderr(monkeypatch): + """The `or ["error"]` fallback: a non-zero exit with empty stderr must not + IndexError its way out of the whole check.""" + _stub_run(monkeypatch, lambda argv: _Completed(returncode=1, stderr=" \n")) + + assert lifecycle._gh_issue_states([GHOST_ISSUE]) == {GHOST_ISSUE: "unreadable: error"} + + +def test_default_fetcher_skips_anything_that_is_not_an_issue_url(monkeypatch): + """Guards the loop's `if not m: continue` — a malformed entry costs no + subprocess call and contributes no state, rather than querying nonsense.""" + calls = _stub_run(monkeypatch, lambda argv: _Completed(stdout="open\n")) + + states = lifecycle._gh_issue_states(["(no issue — a release drive)"]) + + assert states == {} + assert calls == [] + + +def test_default_fetcher_reads_each_url_in_a_mixed_batch(monkeypatch): + """Two URLs, different answers — the shim must key states by URL rather + than collapsing or reusing the last result.""" + + def _by_number(argv): + return _Completed(stdout="closed\n" if argv[2].endswith("/18") else "open\n") + + _stub_run(monkeypatch, _by_number) + + assert lifecycle._gh_issue_states([GHOST_ISSUE, OTHER_ISSUE]) == { + GHOST_ISSUE: "open", + OTHER_ISSUE: "closed", + } + + +# --------------------------------------------------------------------------- # +# the mirror direction — active/ prompts no registry claims +# --------------------------------------------------------------------------- # +def test_unclaimed_active_prompt_is_an_orphan(tmp_path): + root = _tree( + tmp_path, + active=["sprocket_calibration.md", "nobody_tracks_this.md"], + registries={ + "active.md": _entry( + "sprocket-calibration", "active/sprocket_calibration.md" + ) + }, + ) + orphans = [p.name for p in lifecycle.orphan_prompts(root)] + assert orphans == ["nobody_tracks_this.md"] + + +def test_slug_match_claims_a_prompt_without_a_prompt_field(tmp_path): + """Many entries predate the `prompt:` convention and identify their file by + name alone. Requiring `prompt:` would report every one of them as an orphan.""" + root = _tree( + tmp_path, + active=["sprocket_calibration.md"], + registries={"active.md": _entry("sprocket-calibration")}, + ) + assert lifecycle.orphan_prompts(root) == [] + + +def test_a_parked_entry_also_claims_its_active_prompt(tmp_path): + """Started-then-parked work keeps its prompt in active/ while it is listed + in parked.md — that prompt is tracked, not orphaned.""" + root = _tree( + tmp_path, + active=["started_then_parked.md"], + registries={ + "parked.md": _entry( + "started-then-parked", "active/started_then_parked.md" + ) + }, + ) + assert lifecycle.orphan_prompts(root) == [] + + +def test_archive_material_does_not_satisfy_a_prompt_path(tmp_path): + """complete/archive/ holds retired non-record material and is skipped + everywhere else in this module; a shelved copy must not make a missing + prompt look present.""" + root = _tree( + tmp_path, + complete=["archive/shelved/sprocket_calibration.md"], + registries={ + "planned.md": _entry( + "sprocket-calibration", "draft/bug/flywheel/sprocket_calibration.md" + ) + }, + ) + problems = lifecycle.registry_problems(root) + assert len(problems) == 1 + assert "does not resolve" in problems[0] + + +# --------------------------------------------------------------------------- # +# declared draft gates (`Closes-when:` / `Blocked-by:`) +# +# The 2026-08-09 draft/ sweep found five prompts whose stated gate had closed, +# and the two readings are OPPOSITE: a satisfied "epic closes when #N" means the +# prompt is DONE, a satisfied "blocked until #N merges" means it is READY. Prose +# cannot be graded, so `--drafts` had to report both as one ambiguous question. +# --------------------------------------------------------------------------- # +def _draft(root: Path, rel: str, body: str) -> Path: + p = root / "draft" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + return p + + +GATE_ISSUE = "https://github.com/FictionalOrg/FlywheelRepo/issues/77" +GATE_ISSUE_2 = "https://github.com/FictionalOrg/GadgetRepo/issues/88" + + +def test_closes_when_gate_closed_reads_as_shipped(tmp_path): + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nCloses-when: {GATE_ISSUE}\n") + notes = lifecycle.draft_gate_notes(tmp_path, fetch=_states({GATE_ISSUE: "closed"})) + assert len(notes["shipped"]) == 1 + assert "likely shipped" in notes["shipped"][0] + assert notes["unblocked"] == [] + + +def test_blocked_by_gate_closed_reads_as_unblocked(tmp_path): + """The opposite reading — and the whole reason the two keys are distinct.""" + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nBlocked-by: {GATE_ISSUE}\n") + notes = lifecycle.draft_gate_notes(tmp_path, fetch=_states({GATE_ISSUE: "closed"})) + assert len(notes["unblocked"]) == 1 + assert "ready to start" in notes["unblocked"][0] + assert notes["shipped"] == [] + + +def test_open_gate_is_silent(tmp_path): + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nBlocked-by: {GATE_ISSUE}\n") + notes = lifecycle.draft_gate_notes(tmp_path, fetch=_states({GATE_ISSUE: "open"})) + assert notes == {"shipped": [], "unblocked": [], "partial": [], "unreadable": []} + + +def test_repo_hash_shorthand_is_read_as_a_gate(tmp_path): + """Prompts write `Repo#123`, not URLs. A URL-only extractor found 2 refs + across the real backlog where the shorthand form found 8.""" + _draft(tmp_path, "bug/flywheel/sprocket.md", + "# Sprocket\n\nBlocked-by: FlywheelRepo#77 # the loader fix\n") + refs = lifecycle.draft_gate_refs(tmp_path) + assert len(refs) == 1 + assert refs[0][1] == "blocked-by" + assert refs[0][2].endswith("/FlywheelRepo/issues/77") + + +def test_partly_closed_gates_are_not_reported_as_ready(tmp_path): + """A prompt blocked on three PRs is unblocked when the LAST one lands. + Reporting per-reference would claim 'ready to start' while still blocked.""" + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nBlocked-by: {GATE_ISSUE}, {GATE_ISSUE_2}\n") + notes = lifecycle.draft_gate_notes( + tmp_path, fetch=_states({GATE_ISSUE: "closed", GATE_ISSUE_2: "open"})) + assert notes["unblocked"] == [] + assert len(notes["partial"]) == 1 + assert "1 of 2" in notes["partial"][0] + + +def test_all_gates_closed_reports_once_not_per_reference(tmp_path): + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nBlocked-by: {GATE_ISSUE}, {GATE_ISSUE_2}\n") + notes = lifecycle.draft_gate_notes( + tmp_path, fetch=_states({GATE_ISSUE: "closed", GATE_ISSUE_2: "closed"})) + assert len(notes["unblocked"]) == 1 + + +def test_a_fenced_example_is_documentation_not_a_declaration(tmp_path): + """The prompt that PROPOSED these keys shows them in a ```markdown block. + Reading that as a real gate would invent a finding out of documentation.""" + _draft(tmp_path, "feature/mind/gate_keys.md", + "# Propose gate keys\n\n" + "Proposal:\n\n" + "```markdown\n" + f"Closes-when: {GATE_ISSUE}\n" + "```\n\n" + "That is the idea.\n") + assert lifecycle.draft_gate_refs(tmp_path) == [] + + +def test_declared_gates_are_not_repeated_as_ambiguous_advisories(tmp_path): + """`--drafts` asks 'shipped, or newly unblocked?' precisely because prose + cannot say. A prompt that DECLARED which it means must not be asked again.""" + _draft(tmp_path, "bug/flywheel/sprocket.md", + f"# Sprocket\n\nBlocked-by: {GATE_ISSUE}\n\nContext: {GATE_ISSUE}\n") + fetch = _states({GATE_ISSUE: "closed"}) + assert lifecycle.draft_gate_notes(tmp_path, fetch=fetch)["unblocked"] + assert lifecycle.draft_issue_notes(tmp_path, fetch=fetch) == [] + + +def test_an_undeclared_draft_still_gets_the_ambiguous_advisory(tmp_path): + """The fallback must survive: most prompts carry no gate key at all.""" + _draft(tmp_path, "bug/flywheel/widget.md", f"# Widget\n\nFollow-up to {GATE_ISSUE}\n") + notes = lifecycle.draft_issue_notes(tmp_path, fetch=_states({GATE_ISSUE: "closed"})) + assert len(notes) == 1 + assert "shipped, or newly unblocked?" in notes[0] diff --git a/tests/test_prompt_sync_branch.py b/tests/test_prompt_sync_branch.py new file mode 100644 index 0000000..44d3e74 --- /dev/null +++ b/tests/test_prompt_sync_branch.py @@ -0,0 +1,184 @@ +"""`prompt_sync.sh` must push the branch it is ON, never a hardcoded `main`. + +Both helpers used to end in `git push origin main` regardless of what was +checked out. `create_issue` step 6 and `start_dev` step 7 both instruct an agent +to call them, so a branch-scoped session — a cloud session with a designated +branch, or any PR-based flow — that followed the documented steps verbatim +pushed Mind straight to `main`, bypassing review entirely. + +These tests drive the real script against throwaway git repos with real bare +remotes, because that is the only way to observe *which ref actually moved*. A +test that merely greps the source for the string would pass on a script that +still pushed the wrong branch by another route. + +Fictional fixtures only, per `test_spawn_privacy.py` — `tests/**` is KEEP-copied +verbatim into the public template, so nothing here names a real task or prompt. +""" + +import subprocess +from pathlib import Path + +SYNC = Path(__file__).resolve().parents[1] / "scripts" / "prompt_sync.sh" + + +def _git(repo, *args): + return subprocess.run(["git", "-C", str(repo), *args], + capture_output=True, text=True, check=True).stdout.strip() + + +def _repo_with_remote(tmp_path): + """A checkout on `main` with one commit, wired to a real bare remote.""" + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], + capture_output=True, check=True) + repo = tmp_path / "mind" + subprocess.run(["git", "init", "-b", "main", str(repo)], + capture_output=True, check=True) + _git(repo, "config", "user.email", "test@example.invalid") + _git(repo, "config", "user.name", "Test") + (repo / "seed.md").write_text("# seed\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "seed") + _git(repo, "remote", "add", "origin", str(remote)) + _git(repo, "push", "-u", "origin", "main") + return repo, remote + + +def _run(repo, snippet): + """Source the real script against `repo` and run `snippet`.""" + return subprocess.run( + ["bash", "-c", f'set -e; export PROMPT_REPO="{repo}"; ' + f'source "{SYNC}"; {snippet}'], + capture_output=True, text=True) + + +def _remote_branches(remote): + out = subprocess.run(["git", "-C", str(remote), "for-each-ref", + "--format=%(refname:short)", "refs/heads/"], + capture_output=True, text=True, check=True).stdout + return set(out.split()) + + +def test_push_from_a_feature_branch_does_not_touch_main(tmp_path): + """THE defect. On a branch-scoped checkout the helper must move that + branch's ref and leave `main` exactly where it was.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n\n## a-task\n") + + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert "claude/some-task" in _remote_branches(remote) + assert _git(remote, "rev-parse", "main") == main_before, ( + "main moved — the helper pushed the wrong branch") + + +def test_committed_work_actually_reaches_the_remote(tmp_path): + """The failure the old script hit MOST often, and the quietest one. + + `git push origin main` from a feature branch pushes the local `main` ref — + not the commit just made. When local `main` already matches the remote (the + normal case for a session that branched from it) the push is a no-op, exits + 0, and the committed work never leaves the machine. The caller is told the + sync succeeded. In an ephemeral cloud container that is silent loss. + + Leaking to `main` is the other half of the same bug and needs local `main` + to be ahead — see `test_a_branch_push_never_advances_main` below. This test + is the common case: the work must be ON the remote afterwards. + """ + repo, remote = _repo_with_remote(tmp_path) + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n\n## a-task\n") + + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + local_head = _git(repo, "rev-parse", "HEAD") + assert _git(remote, "rev-parse", "claude/some-task") == local_head, ( + "the commit never reached the remote") + + +def test_a_branch_push_never_advances_main(tmp_path): + """The other half: an unpushed commit sitting on local `main` must not be + published as a side effect of syncing a feature branch. + + The old script pushed it — unreviewed work onto `main`, while the branch + work it was actually asked to sync stayed local. + """ + repo, remote = _repo_with_remote(tmp_path) + (repo / "secret.md").write_text("unreviewed local work\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "local main work") + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + (repo / "active.md").write_text("# Active\n") + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert _git(remote, "rev-parse", "main") == main_before, ( + "an unpushed local main commit was published as a side effect") + assert "claude/some-task" in _remote_branches(remote) + + +def test_push_on_main_still_pushes_main(tmp_path): + """The single-branch laptop flow must be unchanged: there the current + branch IS main, so the fix is a no-op for it.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + (repo / "active.md").write_text("# Active\n") + res = _run(repo, 'prompt_sync_push "prompt: a subject"') + assert res.returncode == 0, res.stderr + + assert _git(remote, "rev-parse", "main") != main_before + assert _remote_branches(remote) == {"main"} + + +def test_push_is_still_a_no_op_when_nothing_is_staged(tmp_path): + """The documented contract. Guards a regression the branch fix can easily + introduce: if the early return moves after the push, a caller with no + changes pushes anyway.""" + repo, remote = _repo_with_remote(tmp_path) + _git(repo, "checkout", "-b", "claude/some-task") + + res = _run(repo, 'prompt_sync_push "prompt: nothing to do"') + assert res.returncode == 0, res.stderr + + assert _remote_branches(remote) == {"main"}, ( + "a no-op call created a remote branch") + + +def test_new_prompts_sweep_also_pushes_the_current_branch(tmp_path): + """The sibling helper carried the same hardcoded push.""" + repo, remote = _repo_with_remote(tmp_path) + main_before = _git(remote, "rev-parse", "main") + + _git(repo, "checkout", "-b", "claude/some-task") + draft = repo / "draft" / "feature" / "widget" + draft.mkdir(parents=True) + (draft / "a_new_idea.md").write_text("# A new idea\n") + + res = _run(repo, "prompt_sync_new_prompts") + assert res.returncode == 0, res.stderr + + assert "claude/some-task" in _remote_branches(remote) + assert _git(remote, "rev-parse", "main") == main_before + + +def test_detached_head_is_refused(tmp_path): + """`push -u origin HEAD` from a detached HEAD is meaningless — say so + rather than guessing a branch.""" + repo, _ = _repo_with_remote(tmp_path) + (repo / "active.md").write_text("# Active\n") + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "second") + _git(repo, "checkout", "--detach", "HEAD") + (repo / "active.md").write_text("# Active\n\nchanged\n") + + res = _run(repo, 'prompt_sync_push "prompt: from detached"') + assert res.returncode != 0 + assert "detached HEAD" in res.stderr diff --git a/tests/test_repos_sync_hygiene_coverage.py b/tests/test_repos_sync_hygiene_coverage.py new file mode 100644 index 0000000..9f67bd5 --- /dev/null +++ b/tests/test_repos_sync_hygiene_coverage.py @@ -0,0 +1,153 @@ +"""The hygiene conductor's repo coverage must not drift from the body map. + +The conductor once held its scanned repos as bash arrays. They drifted — five +libraries where the map declared six, four organs of seven — and nothing caught +it, because a repo that is never scanned produces no findings and so reads as a +clean bill of health. The tenant firewall could not catch it either: its entry +for the conductor ALLOWLISTED the stale names rather than checking coverage. + +`check_hygiene_coverage` is the check that closes that gap, and these are its +contract tests. Two things they deliberately do: + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template (see `test_spawn_privacy.py`), so nothing here names a real + repository. It also keeps the tests hermetic — they assert the check's logic, + not the state of whatever happens to be checked out. +2. **Prove each leg FAILS.** A drift check that cannot fail is decoration. Every + leg below is driven with input that must trip it. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import repos_sync # noqa: E402 + +HYGIENE_REL = "PyAutoBrain/agents/conductors/hygiene" + +# A fictional organism: two libraries, one organ, one workspace. +MANIFEST = { + "LibAlpha": {"category": "library"}, + "LibBeta": {"category": "library"}, + "OrganCore": {"category": "organ"}, + "wsalpha_workspace": {"category": "workspace"}, +} + +DERIVED_OK = { + "library": ["LibAlpha", "LibBeta"], + "organ": ["OrganCore"], + "workspace": ["wsalpha_workspace"], +} + +# A conductor that derives its sets — no repo name in any array literal. +CLEAN_SCRIPT = """\ +#!/usr/bin/env bash +mapfile -t LIB_REPOS < <(_body_map library) +mapfile -t ORG_REPOS < <(_body_map organ) +CODE_REPOS=("${LIB_REPOS[@]}" "${ORG_REPOS[@]}") +""" + + +def _stub_helper(derived, exit_code=0): + """A stand-in for _hygiene_repos.py that prints whatever we hand it.""" + return ( + "import json, sys\n" + f"if {exit_code}:\n" + f" sys.exit({exit_code})\n" + f"print(json.dumps({derived!r}))\n" + ) + + +def _tree(tmp_path, *, derived=None, script=CLEAN_SCRIPT, exit_code=0, helper=True): + hygiene = tmp_path / HYGIENE_REL + hygiene.mkdir(parents=True) + (hygiene / "hygiene.sh").write_text(script) + if helper: + (hygiene / "_hygiene_repos.py").write_text( + _stub_helper(DERIVED_OK if derived is None else derived, exit_code) + ) + mind = tmp_path / "PyAutoMind" + mind.mkdir() + return tmp_path, mind + + +def _check(tmp_path, **kwargs): + root, mind = _tree(tmp_path, **kwargs) + return repos_sync.check_hygiene_coverage(root, MANIFEST, mind) + + +def test_a_conductor_deriving_the_declared_sets_is_clean(tmp_path): + assert _check(tmp_path) == [] + + +def test_a_repo_missing_from_the_derived_set_is_drift(tmp_path): + # The original bug: a declared library the conductor never scans. + thinned = {**DERIVED_OK, "library": ["LibAlpha"]} + + problems = _check(tmp_path, derived=thinned) + + assert problems + assert any("does not scan 'LibBeta'" in p for p in problems) + + +def test_a_repo_the_manifest_does_not_declare_is_drift(tmp_path): + padded = {**DERIVED_OK, "organ": ["OrganCore", "OrganGhost"]} + + problems = _check(tmp_path, derived=padded) + + assert any("scans 'OrganGhost'" in p for p in problems) + + +def test_both_readers_are_exercised_so_the_pyyaml_free_path_cannot_rot(tmp_path): + # The fallback runs only where PyYAML is absent, so nothing else verifies it. + # Each reader is invoked separately and each is reported by name. + problems = _check(tmp_path, derived={**DERIVED_OK, "library": []}) + + assert any("(auto reader)" in p for p in problems) + assert any("(minimal reader)" in p for p in problems) + + +def test_a_hardcoded_repo_name_in_an_array_is_drift(tmp_path): + # Leg B: what stops a future edit from "simplifying" the derivation away. + script = 'LIB_REPOS=(LibAlpha LibBeta)\nORG_REPOS=(OrganCore)\n' + + problems = _check(tmp_path, script=script) + + assert any("hardcoded in an array" in p and "LibAlpha" in p for p in problems) + + +def test_an_array_built_from_other_arrays_is_not_drift(tmp_path): + # Composition is how the derived sets are assembled; it must stay legal. + script = 'SCAN_REPOS=("${LIB_REPOS[@]}" "${WS_REPOS[@]}")\n' + + assert _check(tmp_path, script=script) == [] + + +def test_a_helper_that_cannot_read_the_body_map_is_drift(tmp_path): + # Exiting non-zero means the conductor would scan nothing at all — the + # loudest possible version of this bug, and it must not pass silently. + problems = _check(tmp_path, exit_code=3) + + assert problems + assert any("cannot read the body map" in p for p in problems) + + +def test_the_check_skips_when_the_brain_is_not_checked_out(tmp_path): + # Partial/web checkouts are normal; missing organs are skipped, never failed. + mind = tmp_path / "PyAutoMind" + mind.mkdir() + + assert repos_sync.check_hygiene_coverage(tmp_path, MANIFEST, mind) == [] + + +def test_the_real_conductor_matches_the_real_body_map(): + """The live tree, if it is checked out here.""" + mind_root = Path(__file__).resolve().parents[1] + root = mind_root.parent + if not (root / HYGIENE_REL / "hygiene.sh").exists(): + return # Brain not present in this environment + _, repos = repos_sync.load_manifest(mind_root) + + assert repos_sync.check_hygiene_coverage(root, repos, mind_root) == [] diff --git a/tests/test_spawn_template_contract.py b/tests/test_spawn_template_contract.py index 201bcd9..d403801 100644 --- a/tests/test_spawn_template_contract.py +++ b/tests/test_spawn_template_contract.py @@ -92,6 +92,7 @@ def _real(name): GITHUB_FILES = { ".github/workflows/lifecycle_drift.yml": _real("lifecycle_drift.yml"), ".github/workflows/spawn_drift.yml": _real("spawn_drift.yml"), + ".github/workflows/dashboard_refresh.yml": _real("dashboard_refresh.yml"), ".github/workflows/morning_status.yml": ( "name: digest\non:\n schedule:\n - cron: \"0 6 * * *\"\n" "jobs:\n d:\n runs-on: ubuntu-latest\n steps:\n" @@ -113,6 +114,9 @@ def _real(name): DROPPED_GITHUB = [ ".github/workflows/spawn_drift.yml", # rule 9b, revised to DROP in #125 + # rule 9c: checks out PyAutoLabs/PyAutoBrain for the dashboard renderer, + # which a freshly-spawned org does not have. + ".github/workflows/dashboard_refresh.yml", ".github/workflows/morning_status.yml", ".github/workflows/morning_health.yml", ".github/workflows/arxiv_papers.yml",