diff --git a/.github/workflows/board_tests.yml b/.github/workflows/board_tests.yml index 6cd20f0..dab39ba 100644 --- a/.github/workflows/board_tests.yml +++ b/.github/workflows/board_tests.yml @@ -14,6 +14,22 @@ jobs: with: repository: PyAutoLabs/PyAutoBrain path: PyAutoBrain + # Coordinated presentation changes test the companion Brain branch. + # Main remains the fallback when this is a Scientist-only change. + - name: Prefer matching Brain branch on pull requests + if: github.event_name == 'pull_request' + working-directory: PyAutoBrain + env: + SCIENTIST_TASK_BRANCH: ${{ github.head_ref }} + run: | + if git ls-remote --exit-code --heads origin "refs/heads/$SCIENTIST_TASK_BRANCH" > /dev/null; then + git fetch --depth 1 origin "refs/heads/$SCIENTIST_TASK_BRANCH" + git checkout -q FETCH_HEAD + else + status=$? + if [ "$status" -ne 2 ]; then exit "$status"; fi + echo "No companion Brain branch; testing against main" + fi # Canonical repository identity input; relationships remain in Scientist. - uses: actions/checkout@v4 with: @@ -25,5 +41,6 @@ jobs: python-version: '3.12' - run: python -m pip install pytest pyyaml - run: python scripts/organism_board.py --repos PyAutoMind/repos.yaml --check-projects + - run: python scripts/organism_board.py --instance examples/starter/instance.json --repos examples/starter/repos.yaml --check-instance - run: python -m pytest tests -q - run: node --test tests/overview.cjs diff --git a/AGENTS.md b/AGENTS.md index 81a23b3..5c81725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,14 @@ feeds without replacing open disclosures or prompt controls. https://pyautoscientist.readthedocs.io are built from **`PyAutoBrain/docs/`**; edit the docs there. +## Independent instance preview + +`STARTER.md` documents the opt-in `--instance` profile. Keep instance names, +repository identities, project paths and connected-organ locations in configuration. +An empty profile contacts no feeds and imports no live project records. Repo +identities continue to come from Mind. Bootstrap, activation and deployment are +separate phases; do not describe this preview as a full installation. + ## Future intent (not now — Phase 3, demand-gated) Migrating the RTD docs source *into* this repo — so PyAutoScientist becomes the diff --git a/README.md b/README.md index bcadd8e..ef6c15d 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ The Cortex keeps scientific experiments on a separate track from software develo The software developed by the organism lives across the [PyAutoLabs](https://github.com/PyAutoLabs) organisation. See the [PyAutoLabs front door](https://pyautolabs.github.io) for the full repository map. +## Independent starter preview + +[STARTER.md](STARTER.md) provides an empty, independently named local dashboard +with explicit optional organ connections. It is the first starter slice; full +bootstrap, organ activation and independent deployment remain in development. + ## Project History In March 2026, following more than a decade of exclusively human-led software development, PyAutoLabs transitioned to a fully natural-language, agentic-AI development ecosystem called `PyAutoScientist`. The software it now develops grew out of that decade of human-led work: [PyAutoLens](https://github.com/PyAutoLabs/PyAutoLens) (strong gravitational lensing), [PyAutoGalaxy](https://github.com/PyAutoLabs/PyAutoGalaxy) (galaxy structure and morphology), [PyAutoFit](https://github.com/PyAutoLabs/PyAutoFit) (Bayesian model fitting), [PyAutoArray](https://github.com/PyAutoLabs/PyAutoArray) (scientific data structures) and [PyAutoCTI](https://github.com/PyAutoLabs/PyAutoCTI) (CCD charge-transfer calibration), together with their workspaces and tutorials. diff --git a/STARTER.md b/STARTER.md new file mode 100644 index 0000000..8fc837b --- /dev/null +++ b/STARTER.md @@ -0,0 +1,95 @@ +# Start a Scientist dashboard with your own identity + +This is the first slice of the independent starter: an empty dashboard with a +name, its own repository identity and explicit organ connections. It needs +Python, PyYAML and a pinned PyAutoBrain checkout for shared presentation. It +requires no science libraries, assistant repository or organ service. + +This local preview does not initialize a working Mind, install an agent harness, +provision repositories, activate organs or publish a site. Those remain later +starter steps. Existing projects keep their own source and records. + +## Try the empty preview + +From this repository, with Brain beside it (or `PYAUTO_BRAIN` set to its checkout): + +```bash +python -m pip install pyyaml +python scripts/organism_board.py --instance examples/starter/instance.json \ + --repos examples/starter/repos.yaml --check-instance +python scripts/organism_board.py --instance examples/starter/instance.json \ + --repos examples/starter/repos.yaml --html > starter.html +``` + +Open `starter.html` locally. The fictitious **Mira Scientist** has no projects or +connected organs. Rendering and opening this empty page fetch no organ feeds. +The example GitHub identity and `example.invalid` address are placeholders, +not repositories or a deployed service. This example body map is a fixture, +not a complete Mind installation. + +## Configure your instance + +Copy the example configuration to a directory you own, outside the framework +checkout. Set `name`, `repo` and `pages_url`. `repo` is a stable ID in your +canonical Mind `repos.yaml`; its `github` field supplies the repository home. +`pages_url` is the explicit HTTPS directory where you intend to publish this +Scientist, including any custom path. Naming is independent of framework organ +keys and repository names. + +The required `projects` field names a file relative to the profile directory. +Start with the empty project file. Supplying `--instance` never falls back to +this repository's live project configuration. Use `--projects` only when you +intentionally want to override the profile's file. Keep this configuration and +your records when updating the framework; this change neither migrates nor +rewrites them. A supported upgrade procedure remains future work. + +Omitting `--instance` retains the existing PyAutoLabs-compatible dashboard path. +The current Actions publishing workflow still uses that path; this local preview +does not configure independent-instance deployment. + +## Connect an existing project + +Give your coding assistant this request in the local checkout: + +> Read STARTER.md, PROJECTS.md and my instance profile. Help connect one existing +> project. First inspect its current source and any existing Scientist records. +> Ask me for its purpose, canonical repository identity and intended visibility +> if these are not available. Reuse my Mind body map and explicit project/resource +> relationships; do not copy source material or assume an assistant exists. +> Explain the proposed configuration changes before applying them. Validate the +> profile and relationships offline, then show me a local dashboard preview. +> Do not create repositories, publish, change access or run science as part of +> connecting the project. Suggest optional organs only when they serve a concrete +> need; setup and activation remain separate steps. + +Add canonical repository identities in your Mind body map, then configure +resources and projects as described in [PROJECTS.md](PROJECTS.md). Projects and +resources are private by default; only explicit `public: true` entries appear in +the publication snapshot. Do not enter private URLs into public connections. +A project may have no assistant and may share dependencies with other projects. + +## Connect an already available organ + +`organs` maps Brain's stable organ keys to explicit canonical identities and +published board directories. For example, after your own Mind board exists: + +```json +"organs": { + "mind": { + "repo": "research-plans", + "pages_url": "https://research.example/boards/plans/" + } +} +``` + +`research-plans` must be an entry in your body map with its actual GitHub home. +The board's `state.json` must use `organ: "mind"` and the repository name from +that GitHub identity. Connection order follows Brain's shared registry; only +selected organs are collected, displayed and linked in the footer. A connected +organ with a missing or invalid feed remains visible as unavailable. An omitted +organ is unconfigured, not failed. Empty `organs: {}` is supported. + +Connections are an explicit choice of public feed locations. Adding a connection +reads that feed during rendering and browser refresh; it never installs or runs +an organ. Grey available-to-add capabilities, resumable AI-led activation and +independent deployment are subsequent epic phases. diff --git a/examples/starter/instance.json b/examples/starter/instance.json new file mode 100644 index 0000000..f2bb494 --- /dev/null +++ b/examples/starter/instance.json @@ -0,0 +1,8 @@ +{ + "schema_version": 1, + "name": "Mira Scientist", + "repo": "mira", + "pages_url": "https://example.invalid/research/mira/", + "projects": "projects.json", + "organs": {} +} diff --git a/examples/starter/projects.json b/examples/starter/projects.json new file mode 100644 index 0000000..ea3849d --- /dev/null +++ b/examples/starter/projects.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "resources": {}, + "projects": {} +} diff --git a/examples/starter/repos.yaml b/examples/starter/repos.yaml new file mode 100644 index 0000000..ef6c624 --- /dev/null +++ b/examples/starter/repos.yaml @@ -0,0 +1,5 @@ +# Fictitious canonical identity input for the offline preview only. +# Use your own Mind body map when connecting real projects. +repos: + mira: + github: ExampleResearch/MiraScientist diff --git a/scripts/instance_profile.py b/scripts/instance_profile.py new file mode 100644 index 0000000..a0a291d --- /dev/null +++ b/scripts/instance_profile.py @@ -0,0 +1,81 @@ +"""Opt-in Scientist instance configuration, resolved against the Mind body map. + +Loading is read-only and offline. Only explicitly selected organs enter the +public snapshot; local configuration paths never enter it. +""" + +import json +import re +from pathlib import Path +from urllib.parse import urlsplit + +import yaml + + +def _text(value, label): + if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value): + raise ValueError(f"{label} must be nonempty single-line text") + return value + + +def _pages(value): + value = _text(value, "pages_url") + url = urlsplit(value) + if (url.scheme != "https" or not url.hostname or url.username or url.password + or url.query or url.fragment or "\\" in value or any(c.isspace() for c in value)): + raise ValueError("pages_url must be an HTTPS directory URL without credentials, query or fragment") + return value.rstrip("/") + "/" + + +def _identity(key, repos): + _text(key, "repo identity") + identity = repos.get(key) + github = identity.get("github") if isinstance(identity, dict) else None + if not isinstance(github, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", github): + raise ValueError(f"repo {key}: missing or invalid canonical GitHub identity") + return github + + +def load(path, repos_path, theme): + """Return (public resolved profile, local project configuration path).""" + path = Path(path) + config = json.loads(path.read_text(encoding="utf-8")) + required = {"schema_version", "name", "repo", "pages_url", "projects", "organs"} + if not isinstance(config, dict) or set(config) != required: + raise ValueError("instance profile must declare schema_version, name, repo, pages_url, projects and organs") + if type(config["schema_version"]) is not int or config["schema_version"] != 1: + raise ValueError("unsupported instance schema_version") + body = yaml.safe_load(Path(repos_path).read_text(encoding="utf-8")) + repos = body.get("repos") if isinstance(body, dict) else None + if not isinstance(repos, dict): + raise ValueError("canonical identity map must contain repos") + name = _text(config["name"], "instance name") + github = _identity(config["repo"], repos) + pages_url = _pages(config["pages_url"]) + relative = Path(_text(config["projects"], "projects")) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("projects must be a relative path within the instance configuration directory") + projects = path.parent / relative + if not projects.resolve().is_relative_to(path.parent.resolve()): + raise ValueError("projects must stay within the instance configuration directory") + selected = config["organs"] + family = theme.board_links(pages_url, current="organism") + if not family: + raise ValueError("Brain board registry unavailable") + if not isinstance(selected, dict) or set(selected) - set(family): + raise ValueError("organs must map canonical Brain organ keys to explicit connections") + boards = [] + for key in family: + if key not in selected: + continue + connection = selected[key] + if not isinstance(connection, dict) or set(connection) != {"repo", "pages_url"}: + raise ValueError(f"organ {key}: declare only repo and pages_url") + organ_github = _identity(connection["repo"], repos) + boards.append({"key": key, "name": theme.organ(key)["organ"], + "role": theme.organ(key)["function"], + "repo": organ_github.split("/")[1], + "url": _pages(connection["pages_url"])}) + return {"schema_version": 1, "name": name, "repo": github.split("/")[1], + "source_url": "https://github.com/" + github, "pages_url": pages_url, + "boards": boards}, projects diff --git a/scripts/organism_board.py b/scripts/organism_board.py index 197078f..d3faf4a 100644 --- a/scripts/organism_board.py +++ b/scripts/organism_board.py @@ -38,16 +38,13 @@ # the whole family at once — organism_board.yml checks PyAutoBrain out beside # this repo, and a local run finds the sibling checkout the same way the other # PyAuto tools resolve each other. -CHECKIN_PROMPT = ( - "Use this chat as an ongoing entry point to PyAutoLabs. Read PyAutoScientist/AGENTS.md " - "and PyAutoScientist/REPORTING.md. Use the board skill to inspect relevant operational evidence. Load other organs and " - "project context as the request requires.\n\n" +COMMON_CHECKIN_PROMPT = ( "When I give no particular direction, provide a concise overview of the organism’s " - "work across all organs over the last 24 hours, plus its current position: significant progress, blockers, decisions needing my attention and " + "work across connected organs over the last 24 hours, plus its current position: significant progress, blockers, decisions needing my attention and " "useful next steps. Check evidence freshness and coverage, linking to the owning boards " "rather than reproducing every queue.\n\n" "For a summary over a given time period, use that period instead of the last 24 hours. " - "State the start, end and timezone. Check dated records in every organ, distinguish " + "State the start, end and timezone. Check dated records in every connected organ, distinguish " "completed outcomes from work still in progress and decisions awaiting me, and link " "the evidence. Deduplicate work spanning organs. A dashboard refresh is not a completed " "event; current snapshots alone cannot establish a historical summary. Report coverage " @@ -73,6 +70,26 @@ "background follow-up." ) +CHECKIN_PROMPT = ( + "Use this chat as an ongoing entry point to PyAutoLabs. Read PyAutoScientist/AGENTS.md " + "and PyAutoScientist/REPORTING.md. Use the board skill to inspect relevant operational evidence. Load other organs and " + "project context as the request requires.\n\n" + + COMMON_CHECKIN_PROMPT +) + + +def instance_prompt(instance): + return (f"Use this chat as an ongoing entry point to {instance['name']}. " + "Read this Scientist checkout’s AGENTS.md, STARTER.md and REPORTING.md, " + "then its explicit instance profile and project relationships. " + "Consult only connected organs and relevant project-owned sources. " + "Do not assume optional organs, an agent harness or remote access are installed. " + "Help connect existing projects using their canonical repository identities; " + "ask about purpose, visibility and needed capabilities before changing setup. " + "Keep missing evidence distinct from unconfigured organs.\n\n" + + COMMON_CHECKIN_PROMPT) + + BOARD_KEY = "organism" # this board's entry in the Brain's palette table @@ -138,12 +155,13 @@ def _get(url: str) -> str: return resp.read().decode("utf-8", errors="replace") -def collect(owner: str | None = None) -> dict: - owner = owner if owner is not None else _owner() - if not owner: +def collect(owner: str | None = None, instance: dict | None = None) -> dict: + owner = owner if owner is not None else (_owner() if instance is None else None) + if instance is None and not owner: raise ValueError("Cannot resolve the repository owner") - rows = boards(owner) + rows = boards(owner) if instance is None else instance["boards"] # theme() resolves the matching Brain checkout and its validator. + theme() from _state import validate_state def read(row): @@ -160,8 +178,11 @@ def read(row): with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: collected = list(executor.map(read, rows)) - return {"generated": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "owner": owner, "boards": collected} + snapshot = {"generated": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "owner": owner, "boards": collected} + if instance is not None: + snapshot["instance"] = {k: v for k, v in instance.items() if k != "boards"} + return snapshot # --- pure helpers --------------------------------------------------------------- @@ -183,6 +204,10 @@ def heart_word(snapshot: dict) -> str: def route_hint(snapshot: dict) -> str: """Where to work next, keyed off the Heart's verdict.""" + if "instance" in snapshot: + if not snapshot.get("boards"): + return "No organs connected. Connect a project, then add only the organs it needs." + return "Inspect connected organ evidence before choosing the next action; missing evidence is not a passing result." word = heart_word(snapshot) if word in ("RED", "YELLOW"): return ("The Heart is " + word + @@ -196,7 +221,9 @@ def route_hint(snapshot: dict) -> str: def _render_md(snapshot: dict) -> str: - lines = ["# PyAutoScientist Dashboard", "", + from project_resources import markdown_text + name = snapshot.get("instance", {}).get("name", "PyAutoScientist") + lines = [f"# {markdown_text(name)} Dashboard", "", f"_{route_hint(snapshot)}_", "", "| Dashboard | Says | |", "|---|---|---|"] for b in snapshot.get("boards") or []: @@ -254,37 +281,46 @@ def _row_id(name: str) -> str: def _render_html(snapshot: dict) -> str: t = theme() owner = snapshot.get("owner") + instance = snapshot.get("instance") + name = instance["name"] if instance is not None else "PyAutoScientist" + source = instance["source_url"] if instance is not None else (f"https://github.com/{owner}/PyAutoScientist" if owner else None) + prompt = CHECKIN_PROMPT if instance is None else instance_prompt(instance) panel = t.orchestration_panel( - "scientist", "", "", CHECKIN_PROMPT, organ="scientist", - work_links=([{"label": "Scientist repository", "href": f"https://github.com/{owner}/PyAutoScientist"}] if owner else []), + "scientist", "", "", prompt, organ="scientist", + work_links=([{"label": (name if instance is not None else "Scientist") + " repository", "href": source}] if source else []), refreshed_at=(snapshot.get("generated") if snapshot.get("boards") and all(row.get("headline") is not None for row in snapshot["boards"]) else None), - refresh_url=(f"https://github.com/{owner}/PyAutoScientist/actions/workflows/organism_board.yml" if owner else None)) + refresh_url=(source + "/actions/workflows/organism_board.yml" if source and instance is None else None)) from project_resources import render_html as render_projects projects_html = render_projects(snapshot["project_resources"]) if "project_resources" in snapshot else "" - footer = t.boards_footer(t.board_links(f"https://{owner.lower()}.github.io", BOARD_KEY), BOARD_KEY) if owner else "" + links = ({row["key"]: row["url"] for row in snapshot["boards"]} if instance is not None + else t.board_links(f"https://{owner.lower()}.github.io", BOARD_KEY) if owner else {}) + footer = t.boards_footer(links, BOARD_KEY) rows = [] for row in snapshot.get("boards") or []: key = row.get("key", row["name"].lower()) - name = _html.escape(row["name"]) + organ_name = _html.escape(row["name"]) ident = _row_id(row["name"]) # Server fallback is explicitly a publication snapshot, not a live check. head = _html.escape(row.get("headline") or "Evidence unavailable") url = _html.escape(row["url"], quote=True) rows.append(f"""
-{name}Snapshot +{organ_name}Snapshot
Published status
{head}
-
Published {_html.escape(str(snapshot.get('generated') or 'unknown'))}Open {name} →
""") +
Published {_html.escape(str(snapshot.get('generated') or 'unknown'))}Open {organ_name} →
""") data = json.dumps(snapshot, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") script = (HOME / "scripts" / "overview.js").read_text(encoding="utf-8") + banner = (t.hero(BOARD_KEY, "Dashboard", display_name=name) if instance is not None + else t.hero(BOARD_KEY, "Dashboard")) # Native per-organ disclosures keep the overview visible; do not put the # whole grid inside section_layout's extra collapsed H2 group. return f""" -PyAutoScientist Dashboard -{t.hero(BOARD_KEY, "Dashboard")}{panel} +{_html.escape(name)} Dashboard +{banner}{panel} {projects_html}

Dashboards

+{'

No organs connected. Add a connection when your project needs it.

' if instance is not None and not rows else ''}
{''.join(rows)}
{footer} @@ -299,7 +335,9 @@ def badge_endpoint(snapshot: dict) -> dict: mind = next((b for b in snapshot.get("boards") or [] if b["name"] == "Mind"), {}) backlog = (mind.get("counts") or {}).get("Backlog") msg = word if backlog is None else f"{word} · {backlog} tasks queued" - return {"schemaVersion": 1, "label": "organism", "message": msg, "color": color} + if "instance" in snapshot and not snapshot.get("boards"): + msg, color = "No organs connected", "lightgrey" + return {"schemaVersion": 1, "label": snapshot.get("instance", {}).get("name", "organism"), "message": msg, "color": color} def render(snapshot: dict, fmt: str = "md") -> str: @@ -327,10 +365,12 @@ def main(argv: list[str] | None = None) -> int: g.add_argument("--html", action="store_true") g.add_argument("--badge", action="store_true") g.add_argument("--json", action="store_true") - ap.add_argument("--projects", type=Path, default=HOME / "config" / "projects.json", + ap.add_argument("--instance", type=Path, help="opt-in instance profile; selects only explicitly connected organs") + ap.add_argument("--projects", type=Path, help="instance-owned project/resource configuration") ap.add_argument("--repos", type=Path, help="canonical Mind repos.yaml (required outside a configured workspace)") ap.add_argument("--check-projects", action="store_true", help="validate relationships offline; do not collect feeds") + ap.add_argument("--check-instance", action="store_true", help="validate the profile and project relationships offline") ns = ap.parse_args(argv) from project_resources import load repos_path = ns.repos @@ -344,13 +384,20 @@ def main(argv: list[str] | None = None) -> int: from _repo_paths import repo_path repos_path = repo_path(_workspace_root(), "PyAutoMind", required=True) / "repos.yaml" try: - projects = load(ns.projects, repos_path) + instance = None + projects_path = HOME / "config" / "projects.json" + if ns.instance: + from instance_profile import load as load_instance + instance, projects_path = load_instance(ns.instance, repos_path, theme()) + elif ns.check_instance: + ap.error("--check-instance requires --instance") + projects = load(ns.projects or projects_path, repos_path) except (OSError, ValueError, yaml.YAMLError) as exc: ap.error(f"project configuration unavailable or invalid: {exc}") - if ns.check_projects: + if ns.check_projects or ns.check_instance: print(f"Project configuration valid: {len(projects['projects'])} public projects") return 0 - snap = collect() + snap = collect(instance=instance) snap["project_resources"] = projects fmt = "md" for name, label in (("md", "md"), ("md_brief", "md-brief"), diff --git a/scripts/overview.js b/scripts/overview.js index 21fa5fb..bc84acb 100644 --- a/scripts/overview.js +++ b/scripts/overview.js @@ -137,13 +137,21 @@ function summaryHtml(rows) { return rows.map(row => `
${esc(row.label)}
${esc(concise(row.text))}
`).join(''); } +function cacheKey(organ, instance) { + // An independent instance must never consume another owner's basename-only + // cache. Retain that old key only for the existing unprofiled dashboard. + return instance + ? 'scientist:last-good:' + JSON.stringify([instance.source_url, organ.key, organ.repo, organ.url]) + : 'pyauto-cockpit:last-good:' + organ.repo; +} + // ---- browser startup ---- const snapshot = JSON.parse(document.getElementById('scientist-snapshot').textContent); const organStates = new Map(); for (const organ of snapshot.boards) { const identity = { organ: organ.name, repo: organ.repo }; let saved = null; - try { saved = JSON.parse(localStorage.getItem('pyauto-cockpit:last-good:' + organ.repo)); } catch (_) {} + try { saved = JSON.parse(localStorage.getItem(cacheKey(organ, snapshot.instance))); } catch (_) {} const lastGood = saved && !validate(saved.feed, identity) ? saved.feed : organ.feed && !validate(organ.feed, identity) ? organ.feed : null; organStates.set(organ.key, { lastGood }); @@ -182,7 +190,7 @@ async function refresh() { const feed = await response.json(); if (validate(feed, { organ: organ.name, repo: organ.repo })) throw new Error('Invalid evidence'); st.feed = feed; st.lastGood = feed; st.error = null; - try { localStorage.setItem('pyauto-cockpit:last-good:' + organ.repo, JSON.stringify({ feed, at: new Date().toISOString() })); } catch (_) {} + try { localStorage.setItem(cacheKey(organ, snapshot.instance), JSON.stringify({ feed, at: new Date().toISOString() })); } catch (_) {} } catch (_) { st.feed = null; st.error = 'Evidence unavailable'; } finally { clearTimeout(timeout); paint(organ); } })); diff --git a/scripts/project_resources.py b/scripts/project_resources.py index 162b2d5..ea7fa60 100644 --- a/scripts/project_resources.py +++ b/scripts/project_resources.py @@ -169,9 +169,13 @@ def display_groups(project: dict) -> list[dict]: return list(groups.values()) +def markdown_text(value: str) -> str: + """Escape instance-owned labels for plain text in published Markdown.""" + return re.sub(r"([\\\[\]*_`#|])", r"\\\1", html.escape(value)).replace("\n", " ") + + def render_markdown(snapshot: dict) -> str: - def text(value): - return re.sub(r"([\\\[\]*_`#|])", r"\\\1", html.escape(value)).replace("\n", " ") + text = markdown_text lines = ["## Projects", "", "Connected resources; inventory is not a quality or readiness verdict."] for project in snapshot.get("projects", []): diff --git a/tests/overview.cjs b/tests/overview.cjs index a67d6f6..87696d3 100644 --- a/tests/overview.cjs +++ b/tests/overview.cjs @@ -45,3 +45,23 @@ test('feed identity, impossible dates and malformed action metadata are rejected assert.ok(run('validate(d)',{d:feed({updated:'2026-02-30T12:00:00Z'})})); assert.ok(run('validate(d)',{d:feed({items:[{severity:'info',text:'text',actions:[{id:'x',kind:'link',label:'x',target:'javascript:alert(1)'}]}]})})); }); + +test('independent profiles do not load another instance or host cache with the same repo name', () => { + const connected = {key:'mind',name:'Mind',repo:'ResearchPlans',url:'https://research.example/boards/plans/'}; + const instance = {source_url:'https://github.com/Research/Mira'}; + const otherFeed = feed({repo:'ResearchPlans',pages_url:'https://other.example/plans/',headline:'OTHER INSTANCE PRIVATE TASK'}); + const oldKey = 'pyauto-cockpit:last-good:ResearchPlans'; + const ownKey = run('cacheKey(connected, instance)', {connected,instance}); + const otherHostKey = run('cacheKey({...connected,url:"https://other.example/plans/"},instance)'); + const otherInstanceKey = run('cacheKey(connected,{source_url:"https://github.com/Other/Mira"})'); + assert.notEqual(ownKey,otherHostKey); + assert.notEqual(ownKey,otherInstanceKey); + const saved = new Map([oldKey,otherHostKey,otherInstanceKey].map(k=>[k,JSON.stringify({feed:otherFeed})])); + const read = []; + const boot = vm.createContext({URL,Date,document:{getElementById:()=>({textContent:JSON.stringify({instance,boards:[connected]})})}, + localStorage:{getItem:k=>{read.push(k);return saved.get(k) || null;}}}); + vm.runInContext(script.split('function paint(organ)')[0],boot); + assert.equal(vm.runInContext('organStates.get("mind").lastGood',boot),null); + assert.deepEqual(read,[ownKey]); + assert.equal(run('cacheKey(connected,null)'),oldKey); +}); diff --git a/tests/test_instance_profile.py b/tests/test_instance_profile.py new file mode 100644 index 0000000..e9f89c6 --- /dev/null +++ b/tests/test_instance_profile.py @@ -0,0 +1,142 @@ +"""Independent starter isolation and connected-feed identity, with no network.""" +import copy +import json +import sys +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +import instance_profile as profiles +import organism_board as ob + + +@pytest.fixture +def configured(tmp_path): + config = {'schema_version': 1, 'name': 'Mira & ', 'repo': 'scientist', + 'pages_url': 'https://research.example/mira', 'projects': 'projects.json', + 'organs': {}} + body = {'repos': {'scientist': {'github': 'ExampleResearch/Mira'}, + 'plans': {'github': 'ExampleResearch/ResearchPlans'}}} + (tmp_path / 'projects.json').write_text(json.dumps({'schema_version': 1, 'resources': {}, 'projects': {}})) + path, repos = tmp_path / 'instance.json', tmp_path / 'repos.yaml' + repos.write_text(yaml.safe_dump(body)) + def load(updated=None): + path.write_text(json.dumps(updated or config)) + return profiles.load(path, repos, ob.theme()) + return config, load, path, repos + + +def test_empty_instance_is_independent_and_makes_no_requests(configured, monkeypatch, capsys): + config, load, path, repos = configured + load() + monkeypatch.setattr(ob, '_get', lambda *_: pytest.fail('empty starter contacted a feed')) + monkeypatch.setattr(ob, '_owner', lambda: pytest.fail('explicit profile read framework remote')) + before = {p: p.read_bytes() for p in path.parent.iterdir()} + assert ob.main(['--instance', str(path), '--repos', str(repos), '--html']) == 0 + page = capsys.readouterr().out + assert 'Mira & <Research>' in page + assert 'Mira & <Research>' in page + assert 'https://github.com/ExampleResearch/Mira' in page + assert 'No public projects configured.' in page + assert 'No organs connected.' in page + assert 'class="boards"' not in page + assert 'class="organ-card"' not in page + assert 'PyAutoLabs' not in page + assert 'PyAutoFit' not in page + assert 'Use the health skill.' not in page + assert all(p.read_bytes() == data for p, data in before.items()) + + +def test_selected_connection_uses_canonical_repo_and_custom_host(configured, monkeypatch): + config, load, _, _ = configured + config['organs']['mind'] = {'repo': 'plans', 'pages_url': 'https://research.example/boards/plans'} + profile, _ = load() + urls = [] + feed = {'schema_version': 1, 'organ': 'mind', 'repo': 'ResearchPlans', 'status': 'green', + 'headline': 'No tasks yet', 'updated': '2026-10-11T12:00:00Z', + 'pages_url': 'https://research.example/boards/plans/', 'items': []} + def get(url): + urls.append(url) + return json.dumps(feed) + monkeypatch.setattr(ob, '_get', get) + snap = ob.collect(instance=profile) + assert urls == ['https://research.example/boards/plans/state.json'] + assert len(snap['boards']) == 1 + assert snap['boards'][0]['error'] is None + assert snap['boards'][0]['repo'] == 'ResearchPlans' + page = ob.render(snap, 'html') + assert 'Mira & <Research>' in page + assert 'data-organ="mind"' in page + assert 'https://research.example/boards/plans/' in page + assert 'id="board-heart"' not in page + assert 'pyautolabs.github.io' not in page + assert snap['instance']['pages_url'] == 'https://research.example/mira/' + assert 'projects' not in snap['instance'] + assert ob.render(snap, 'md').startswith('# Mira & <Research> Dashboard') + snap['instance']['name'] = '[Mira](https://example.com/)' + assert ob.render(snap, 'md').startswith(r'# \[Mira\](https://example.com/) Dashboard') + feed['repo'] = 'PyAutoMind' + bad = ob.collect(instance=profile) + assert bad['boards'][0]['error'] == 'Evidence unavailable' + assert bad['boards'][0]['feed'] is None + assert 'data-organ="mind"' in ob.render(bad, 'html') + assert profile['boards'][0].get('error') is None # collection never mutates config + + +def test_offline_check_never_collects(configured, monkeypatch, capsys): + _, load, path, repos = configured + load() + monkeypatch.setattr(ob, 'collect', lambda **_: pytest.fail('offline validation collected feeds')) + assert ob.main(['--instance', str(path), '--repos', str(repos), '--check-instance']) == 0 + assert '0 public projects' in capsys.readouterr().out + + +@pytest.mark.parametrize('field,value', [ + ('schema_version', True), ('name', ''), ('name', 'two\nlines'), ('repo', 'missing'), + ('pages_url', 'javascript:alert(1)'), ('pages_url', 'https://user:pass@example.com/'), + ('pages_url', 'https://example.com/?private=yes'), ('pages_url', 'https://example.com/#anchor'), + ('projects', '../projects.json'), ('projects', '/private/projects.json'), + ('organs', {'unknown': {}}), ('organs', {'organism': {}}), + ('organs', {'mind': {'repo': 'missing', 'pages_url': 'https://example.com/'}}), + ('organs', {'mind': {'repo': 'plans'}}), ('organs', []), +]) +def test_invalid_profile_fails_before_collection(configured, field, value): + config, load, _, _ = configured + config = copy.deepcopy(config) + config[field] = value + with pytest.raises(ValueError): + load(config) + + +def test_profile_requires_its_own_projects_file(configured, monkeypatch): + _, load, path, repos = configured + load() + (path.parent / 'projects.json').unlink() + monkeypatch.setattr(ob, 'collect', lambda **_: pytest.fail('invalid profile collected feeds')) + with pytest.raises(SystemExit) as exc: + ob.main(['--instance', str(path), '--repos', str(repos), '--json']) + assert exc.value.code == 2 + + +def test_connect_project_without_assistant_reuses_body_map(configured, monkeypatch, capsys): + _, load, path, repos = configured + load() + body = yaml.safe_load(repos.read_text()) + body['repos']['ocean-code'] = {'github': 'ExampleResearch/OceanModels'} + repos.write_text(yaml.safe_dump(body)) + projects = {'schema_version': 1, + 'resources': {'code': {'name': 'Ocean models', 'repo': 'ocean-code', 'public': True}}, + 'projects': {'ocean': {'name': 'Ocean study', 'kind': 'scientific_project', + 'public': True, 'resources': {'software': ['code']}}}} + (path.parent / 'projects.json').write_text(json.dumps(projects)) + monkeypatch.setattr(ob, '_get', lambda *_: pytest.fail('project-only instance collected an organ')) + assert ob.main(['--instance', str(path), '--repos', str(repos), '--json']) == 0 + snap = json.loads(capsys.readouterr().out) + assert snap['boards'] == [] + project = snap['project_resources']['projects'][0] + assert project['name'] == 'Ocean study' + assert project['groups'][0]['resources'][0]['links'][0]['url'] == 'https://github.com/ExampleResearch/OceanModels' + assert all(g['relation'] != 'assistants' for g in project['groups']) + assert 'Ocean study' in ob.render(snap, 'html')