diff --git a/STARTER.md b/STARTER.md
index 2de4eb2..75c5c09 100644
--- a/STARTER.md
+++ b/STARTER.md
@@ -167,8 +167,9 @@ To resume with an AI assistant:
> report connection validation separately from readiness. Do not create or publish
> anything without its own approved setup task.
-Organ provisioning, resumable owner-specific setup, pause controls and deployment
-are later activation phases. This helper covers the validated connection boundary.
+This helper covers the validated connection boundary. The lifecycle controls below
+track incomplete setup and pause Scientist observation. Provisioning and deployment
+remain owner-specific steps.
## Discover what a project can add
@@ -186,3 +187,65 @@ Names are suggestions in instance configuration, not detected repositories.
Choose opportunities explicitly when connecting your project; see
[PROJECTS.md](PROJECTS.md#available-to-add). The empty starter stays empty.
These prompts begin a setup conversation; they do not run setup or activate organs.
+
+## Track setup and pause observation
+
+Independent profiles now distinguish configuration from evidence. The **Available
+organs** catalogue offers setup prompts; selecting text there does not change files.
+An optional `organ_setup` map records a chosen organ's canonical `repo` and
+`pages_url` while its setup is incomplete. Its card makes no feed request and is
+not a connected board link. Mind retains the actual setup task and history.
+
+Preview beginning Broca setup (repeat with `--apply` after reviewing):
+
+```sh
+python scripts/organ_lifecycle.py setup --instance /path/to/instance.json \
+ --repos /path/to/Mind/repos.yaml --organ broca --repo my-guides \
+ --pages-url https://research.example/guides/
+```
+
+When the owner setup and publication are ready, use `connect_organ.py` as above.
+A matching setup entry moves to `organs` only after its real feed validates.
+Failures preserve the profile; mismatched identities require manual review.
+Neither grey evidence nor a recorded failure prevents a valid connection: owner
+readiness is separate from connection state.
+
+To pause Scientist's observation of a connected organ:
+
+```sh
+python scripts/organ_lifecycle.py pause --instance /path/to/instance.json \
+ --repos /path/to/Mind/repos.yaml --organ broca
+```
+
+Repeat with `--apply` to save optional `paused: true` on the connection. Render and
+publish your updated Scientist dashboard for viewers to see the change; an already
+open or previously published page keeps its old configuration until reloaded from
+the updated publication. New renders and refreshed pages skip server/browser feed
+requests for paused organs, and cached evidence cannot revive them as current.
+The repository identity and board link remain available.
+
+**Pause stops only Scientist's observation.** It does not stop services, scheduled
+Actions, jobs or compute. Those remain controlled by the organ's own procedures.
+Use the same command with `resume` to preview resumption. Resume validates the saved
+feed identity before removing the pause flag; an unavailable or invalid feed leaves
+it paused. Apply and republish the Scientist page to resume its observation.
+
+Connected cards show **Connected** separately from current evidence such as
+**Needs attention** or **Unavailable**. Missing, old or grey evidence never turns a
+connection into an available-to-add capability. Setup entries and paused connections
+do not claim healthy evidence. Existing profiles without these optional fields
+retain their connections and continue fetching normally.
+
+For a fresh conversation:
+
+> Read my instance profile, canonical map and existing Mind task. Identify organs
+> still being set up, connected organs and paused observations. Preview the relevant
+> lifecycle operation before applying it. Preserve records and identities; validate
+> the published feed before connecting or resuming. Pausing observation must not
+> stop owner jobs. Explain whether the dashboard needs republishing.
+
+The fictitious `examples/lifecycle/` profile contains a pending Broca, paused Mind
+and connected Heart with explicit independent names. Its `example.invalid` URLs
+are placeholders; it makes no claim of live deployment. Use the normal renderer
+with `--instance` and `--repos` for a local preview. Optional-organ provisioning,
+real independent deployment and framework upgrades remain separate tasks.
diff --git a/examples/lifecycle/instance.json b/examples/lifecycle/instance.json
new file mode 100644
index 0000000..b4ccd9b
--- /dev/null
+++ b/examples/lifecycle/instance.json
@@ -0,0 +1,14 @@
+{
+ "schema_version": 1,
+ "name": "Mira Scientist",
+ "repo": "scientist",
+ "pages_url": "https://example.invalid/mira/",
+ "projects": "projects.json",
+ "organs": {
+ "mind": {"repo": "plans", "pages_url": "https://example.invalid/plans/", "paused": true},
+ "heart": {"repo": "checks", "pages_url": "https://example.invalid/checks/"}
+ },
+ "organ_setup": {
+ "broca": {"repo": "guides", "pages_url": "https://example.invalid/guides/"}
+ }
+}
diff --git a/examples/lifecycle/projects.json b/examples/lifecycle/projects.json
new file mode 100644
index 0000000..ea3849d
--- /dev/null
+++ b/examples/lifecycle/projects.json
@@ -0,0 +1,5 @@
+{
+ "schema_version": 1,
+ "resources": {},
+ "projects": {}
+}
diff --git a/examples/lifecycle/repos.yaml b/examples/lifecycle/repos.yaml
new file mode 100644
index 0000000..43d8e7f
--- /dev/null
+++ b/examples/lifecycle/repos.yaml
@@ -0,0 +1,9 @@
+repos:
+ scientist:
+ github: Research/Mira
+ plans:
+ github: Research/ResearchPlans
+ checks:
+ github: Research/Checks
+ guides:
+ github: Research/Communication
diff --git a/scripts/connect_organ.py b/scripts/connect_organ.py
index 9afae45..a4e1eb8 100644
--- a/scripts/connect_organ.py
+++ b/scripts/connect_organ.py
@@ -15,7 +15,7 @@
import project_resources
-def plan(instance, repos, organ, repo, pages_url):
+def plan(instance, repos, organ, repo, pages_url, *, resume=False):
"""Return validated replacement bytes and input snapshots, without writing.
A connection is a location/contract check, never installation or a health
@@ -37,8 +37,12 @@ def plan(instance, repos, organ, repo, pages_url):
github = instance_profile._identity(repo, yaml.safe_load(body_bytes)["repos"])
connection = {"repo": repo, "pages_url": instance_profile._pages(pages_url)}
existing = config["organs"].get(organ)
- if existing is not None:
- normalized = dict(existing, pages_url=instance_profile._pages(existing["pages_url"]))
+ pending = config.get("organ_setup", {}).get(organ)
+ if existing is not None and existing.get("paused", False) and not resume:
+ raise ValueError("organ observation is paused; use explicit lifecycle resume")
+ if existing is not None or pending is not None:
+ prior = existing if existing is not None else pending
+ normalized = {"repo": prior["repo"], "pages_url": instance_profile._pages(prior["pages_url"])}
if normalized != connection:
raise ValueError("organ already has a different connection; review it manually")
@@ -54,12 +58,14 @@ def plan(instance, repos, organ, repo, pages_url):
raise ValueError("organ feed pages_url does not match the selected location")
config["organs"][organ] = connection
+ if pending is not None:
+ del config["organ_setup"][organ]
return {
"instance": instance,
"inputs": {instance: original, repos: body_bytes, projects: project_bytes},
"mode": stat.S_IMODE(instance.stat().st_mode),
"replacement": (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode(),
- "changed": existing is None,
+ "changed": existing is None or bool(existing.get("paused", False)),
"organ": organ,
"connection": connection,
"status": feed["status"],
diff --git a/scripts/instance_profile.py b/scripts/instance_profile.py
index a0a291d..7d26779 100644
--- a/scripts/instance_profile.py
+++ b/scripts/instance_profile.py
@@ -41,7 +41,7 @@ def load(path, repos_path, theme):
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:
+ if not isinstance(config, dict) or not required <= set(config) or set(config) - required - {"organ_setup"}:
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")
@@ -64,18 +64,34 @@ def load(path, repos_path, theme):
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")
+ pending = config.get("organ_setup", {})
+ if not isinstance(pending, dict) or set(pending) - set(family) or set(pending) & set(selected):
+ raise ValueError("organ_setup must contain known, unconnected organ keys")
+ setup = []
+ for key, connection in pending.items():
+ if not isinstance(connection, dict) or set(connection) != {"repo", "pages_url"}:
+ raise ValueError(f"organ setup {key}: declare only repo and pages_url")
+ github_setup = _identity(connection["repo"], repos)
+ setup.append({"key": key, "name": theme.organ(key)["organ"],
+ "role": theme.organ(key)["function"], "repo": github_setup.split("/")[-1],
+ "url": _pages(connection["pages_url"])})
+ available = [{"key": key, "name": theme.organ(key)["organ"],
+ "role": theme.organ(key)["function"]} for key in family
+ if key not in selected and key not in pending]
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")
+ if (not isinstance(connection, dict) or not {"repo", "pages_url"} <= set(connection)
+ or set(connection) - {"repo", "pages_url", "paused"}
+ or type(connection.get("paused", False)) is not bool):
+ raise ValueError(f"organ {key}: declare repo, pages_url and optional boolean paused")
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"])})
+ "url": _pages(connection["pages_url"]), "paused": connection.get("paused", False)})
return {"schema_version": 1, "name": name, "repo": github.split("/")[1],
"source_url": "https://github.com/" + github, "pages_url": pages_url,
- "boards": boards}, projects
+ "boards": boards, "setup": setup, "available": available}, projects
diff --git a/scripts/organ_lifecycle.py b/scripts/organ_lifecycle.py
new file mode 100644
index 0000000..17e14d1
--- /dev/null
+++ b/scripts/organ_lifecycle.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Preview/apply setup or pause Scientist observation; never stop owner jobs."""
+import argparse
+import json
+from pathlib import Path
+import stat
+
+import yaml
+
+import connect_organ
+import instance_profile
+import organism_board
+import project_resources
+
+
+def plan(instance, repos, organ, action, repo=None, pages_url=None):
+ instance, repos = Path(instance), Path(repos)
+ if instance.is_symlink():
+ raise ValueError("instance profile must not be a symlink")
+ original, body = instance.read_bytes(), repos.read_bytes()
+ config = json.loads(original)
+ theme = organism_board.theme()
+ _, projects = instance_profile.load(instance, repos, theme)
+ project_bytes = projects.read_bytes()
+ project_resources.load(projects, repos)
+ if organ not in theme.board_links(config["pages_url"], current="organism"):
+ raise ValueError("unknown organ key")
+ existing = config["organs"].get(organ)
+ if action == "resume":
+ if existing is None:
+ raise ValueError("resume requires a connected organ")
+ prepared = connect_organ.plan(instance, repos, organ, existing["repo"], existing["pages_url"], resume=True)
+ # Preserve the initial snapshot too; a concurrent identity edit must not
+ # silently change which connection this resume was meant to validate.
+ if prepared["inputs"] != {instance: original, repos: body, projects: project_bytes}:
+ raise ValueError("input changed during resume; rerun preview")
+ return prepared
+ if action == "setup":
+ if existing is not None:
+ raise ValueError("organ is already connected")
+ instance_profile._identity(repo, yaml.safe_load(body)["repos"])
+ connection = {"repo": repo, "pages_url": instance_profile._pages(pages_url)}
+ pending = config.setdefault("organ_setup", {})
+ if organ in pending and pending[organ] != connection:
+ raise ValueError("conflicting setup; review existing identity manually")
+ changed = organ not in pending
+ pending[organ] = connection
+ elif action == "pause":
+ if existing is None:
+ raise ValueError("pause requires a connected organ")
+ changed = not existing.get("paused", False)
+ existing["paused"] = True
+ else:
+ raise ValueError("action must be setup, pause or resume")
+ return {"instance": instance, "inputs": {instance: original, repos: body, projects: project_bytes},
+ "mode": stat.S_IMODE(instance.stat().st_mode), "changed": changed,
+ "replacement": (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()}
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("action", choices=["setup", "pause", "resume"])
+ parser.add_argument("--instance", type=Path, required=True)
+ parser.add_argument("--repos", type=Path, required=True)
+ parser.add_argument("--organ", required=True)
+ parser.add_argument("--repo")
+ parser.add_argument("--pages-url")
+ parser.add_argument("--apply", action="store_true")
+ args = parser.parse_args(argv)
+ if args.action != "setup" and (args.repo is not None or args.pages_url is not None):
+ parser.error("pause/resume use the existing connection identity")
+ try:
+ prepared = plan(args.instance, args.repos, args.organ, args.action, args.repo, args.pages_url)
+ connect_organ._unchanged(prepared)
+ if args.apply:
+ print("Updated" if connect_organ.apply(prepared) else "Unchanged")
+ else:
+ print("Preview only; pass --apply to save" if prepared["changed"] else "Unchanged")
+ print(prepared["replacement"].decode())
+ print("Pause controls Scientist observation only; owner jobs and services are unaffected.")
+ except (OSError, ValueError, RuntimeError, yaml.YAMLError) as exc:
+ parser.exit(2, f"organ-lifecycle: {exc}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/organism_board.py b/scripts/organism_board.py
index 0679690..0b009d7 100644
--- a/scripts/organism_board.py
+++ b/scripts/organism_board.py
@@ -87,7 +87,7 @@ 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. "
+ "Consult only connected, unpaused 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. "
@@ -171,10 +171,14 @@ def collect(owner: str | None = None, instance: dict | None = None) -> dict:
def read(row):
row = dict(row, headline=None, color=None, feed=None, error=None)
+ if row.get("paused"):
+ row.update(headline="Observation paused", color="grey")
+ return row
try:
feed = json.loads(_get(row["url"] + "state.json"))
errors = validate_state(feed)
- if errors or feed["organ"] != row["key"] or feed["repo"] != row["repo"]:
+ if (errors or feed["organ"] != row["key"] or feed["repo"] != row["repo"]
+ or (instance is not None and feed["pages_url"].rstrip("/") != row["url"].rstrip("/"))):
raise ValueError("Invalid organ feed")
row.update(feed=feed, headline=feed["headline"], color=feed["status"])
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
@@ -200,7 +204,7 @@ def _heart(snapshot: dict) -> dict | None:
def heart_word(snapshot: dict) -> str:
h = _heart(snapshot)
- if not h or not h.get("headline"):
+ if not h or h.get("paused") or not h.get("headline"):
return "UNKNOWN"
if h.get("feed"):
return h["feed"]["status"].upper()
@@ -235,6 +239,8 @@ def _render_md(snapshot: dict) -> str:
head = b.get("headline") or "unavailable"
link = f"[{b['name']}]({b['url']})" if b.get("url") else b["name"]
lines.append(f"| {link} | {head} | {b['role']} |")
+ for pending in snapshot.get("instance", {}).get("setup", []):
+ lines.append(f"| {pending['name']} | Setup incomplete; not connected | {pending['role']} |")
if "project_resources" in snapshot:
from project_resources import render_markdown
lines.extend(["", render_markdown(snapshot["project_resources"])])
@@ -283,6 +289,9 @@ def _row_id(name: str) -> str:
.available-resource-body{padding:0 .75rem .75rem 1.25rem}
.available-resource-body label{display:block;margin-bottom:.35rem}
.available-resource-body textarea{font:inherit;width:100%;box-sizing:border-box;resize:vertical;background:var(--bg);color:var(--fg);border:1px solid var(--line);padding:.65rem}
+.organ-catalogue textarea,.organ-content textarea{width:100%;box-sizing:border-box;min-height:7rem;font:inherit}
+.organ-catalogue{margin-block:1rem}
+.organ-catalogue details>summary{color:var(--muted,#666)}
.organ-grid :focus-visible{outline:3px solid var(--accent);outline-offset:3px}
@media(max-width:64rem){.organ-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:40rem){.organ-grid{grid-template-columns:1fr}.organ-content .summary-row{grid-template-columns:1fr;gap:.15rem}}
@@ -300,7 +309,7 @@ def _render_html(snapshot: dict) -> str:
"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),
+ all(row.get("headline") is not None and not row.get("paused") for row in snapshot["boards"]) 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 ""
@@ -315,10 +324,37 @@ def _render_html(snapshot: dict) -> str:
# 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)
+ lifecycle = "Paused" if row.get("paused") else "Connected" if instance is not None else "Snapshot"
+ note = ("Scientist observation paused; owner jobs are unaffected." if row.get("paused")
+ else "Published " + str(snapshot.get("generated") or "unknown"))
rows.append(f"""{organ_name}Snapshot
+{organ_name}{lifecycle}
-
Not connected. No feed requested.
' + f'{_html.escape(pending["role"])}
{_html.escape(available["role"])}
No organs connected. Add a connection when your project needs it.
' if instance is not None and not rows else ''} +{'No organs connected. Add a connection when your project needs it.
' if instance is not None and not snapshot.get("boards") else ''}