diff --git a/PROJECTS.md b/PROJECTS.md index d8eae03..25c0093 100644 --- a/PROJECTS.md +++ b/PROJECTS.md @@ -78,11 +78,12 @@ Project repositories own software, docs, notebooks, scripts, assistant definitio and runners. Scientist owns these explicit project associations and links. The agreed Broca expansion covers communication/documentation relationships and upkeep evidence, with documentation, tutorials, user-facing workspaces and -assistants as first-class surfaces. Its current published implementation still -covers assistant evidence; the broader contract and evidence migration are -separate Mind tasks. These IDs/categories provide a coordination point, not a -new Broca evidence schema or copied ledger. Links to Broca route communication resources to its dashboard; they do not -imply a project-specific assessment or implemented evidence coverage. +assistants as first-class surfaces. Its dashboard now presents communication +resource inventories alongside assistant evidence; broader upkeep evidence +remains separate Mind work. These IDs/categories provide a coordination point, +not a new Broca evidence schema or copied ledger. Links to Broca route +communication resources to its dashboard; they do not imply a project-specific +assessment or implemented evidence coverage. Ears retains listening and feedback collection; Brain interprets and routes; Mind tracks implementation; Memory holds knowledge and citations; Cortex owns @@ -102,13 +103,10 @@ Mind. Rename displays or change canonical locations without changing stable IDs. Unsupported schema versions fail explicitly; an upgrade must preserve the configuration and user records or supply a reviewable migration. -This phase does **not** yet make the complete Scientist independently named or -organs optional. Existing hero branding, organ membership, check-in prompt, -workflow checkout identities and framework discovery still reflect the current -instance. Starter packaging, configurable capability locations, optional-organ -activation and record-preserving upgrades are recorded follow-ups. No -collaborator project or access is assumed. Shared multi-contributor operation is -a later milestone. +Independent names and explicit optional organ connections are supported by the +local [starter preview](STARTER.md). Resumable activation, independently hosted +deployment and record-preserving upgrades remain separate phases. Shared +multi-contributor operation is a later milestone. ## Dashboard routing @@ -142,3 +140,36 @@ no evidence timestamp: inventory refresh is not evidence collection. Tests and communication resources link to their owning board's `#resource-` disclosure while retaining source/published links. Publish the owner-board changes before publishing the new Scientist links. + +## Available to add + +Each project may opt into `available` opportunities keyed by the stable capability +IDs `workspaces`, `tutorials`, `documentation`, `assistants`, `tests`, `profiling`, +`inference` and `visualization`. Add only what would be useful to that project: + +```json +"available": { + "workspaces": {"name": "myproject_workspace", "public": true}, + "inference": {"name": "myproject_inference", "public": true} +} +``` + +`name` is an instance-owned naming hint. No GitHub URL is inferred, no remote +existence is checked, and suffixes do not establish identity. The grey disclosure +contains a short purpose and selectable setup prompt. It stays usable by keyboard +and without JavaScript. Existing dashboard routes also group these opportunities; +an absent or private route falls back to the category heading. + +As with connected resources, both the project and opportunity need explicit +`public: true` to appear. Opportunities are excluded from the connected resource +catalogue consumed by Heart and Broca. You cannot mark a capability available +while the same project's relationship contains a connected resource, including a +private one. To connect a validated resource, remove that opportunity and add the +normal resource/relationship using its canonical identity in one configuration +change. Projects can always omit assistants, and resources can be shared. + +This is an explicit discovery contract, not a setup-state ledger. Keep incomplete +setup in your existing task records; do not claim it is connected until validated. +Missing or stale evidence on a connected organ never greys its resource into an +available opportunity. Paused/needs-attention states, resumable activation and +organ-specific validation remain separate work. diff --git a/STARTER.md b/STARTER.md index fc923fd..b6359b5 100644 --- a/STARTER.md +++ b/STARTER.md @@ -119,5 +119,22 @@ 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. +an organ. Grey available-to-add project resources are described below. Resumable organ +activation and independent deployment remain subsequent epic phases. + +## Discover what a project can add + +The separate `examples/connected-project/` fixture shows **Mira Scientist** with +one fictional software resource, no connected assistant or organs, and optional +resources such as `spectra_workspace` and `spectra_inference`: + +```bash +python scripts/organism_board.py --instance examples/connected-project/instance.json \ + --repos examples/connected-project/repos.yaml --html > connected-project.html +``` + +Expand Spectra, then a grey **Available to add** row to select its setup prompt. +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. diff --git a/examples/connected-project/instance.json b/examples/connected-project/instance.json new file mode 100644 index 0000000..f2bb494 --- /dev/null +++ b/examples/connected-project/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/connected-project/projects.json b/examples/connected-project/projects.json new file mode 100644 index 0000000..3b85662 --- /dev/null +++ b/examples/connected-project/projects.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "resources": { + "spectra-code": { + "name": "SpectralAnalysis", + "repo": "spectra-code", + "public": true + } + }, + "projects": { + "spectra": { + "name": "Spectra", + "kind": "scientific_project", + "public": true, + "resources": { + "software": [ + "spectra-code" + ] + }, + "available": { + "workspaces": { + "name": "spectra_workspace", + "public": true + }, + "tutorials": { + "name": "HowToSpectra", + "public": true + }, + "documentation": { + "name": "Spectra guide", + "public": true + }, + "assistants": { + "name": "spectra_assistant", + "public": true + }, + "tests": { + "name": "spectra_workspace_test", + "public": true + }, + "profiling": { + "name": "spectra_profiling", + "public": true + }, + "inference": { + "name": "spectra_inference", + "public": true + }, + "visualization": { + "name": "spectra_visualization", + "public": true + } + } + } + } +} diff --git a/examples/connected-project/repos.yaml b/examples/connected-project/repos.yaml new file mode 100644 index 0000000..cc05eff --- /dev/null +++ b/examples/connected-project/repos.yaml @@ -0,0 +1,6 @@ +# Fictitious identities for the offline example only. +repos: + mira: + github: ExampleResearch/MiraScientist + spectra-code: + github: ExampleResearch/SpectralAnalysis diff --git a/scripts/organism_board.py b/scripts/organism_board.py index 6774a8c..34c9982 100644 --- a/scripts/organism_board.py +++ b/scripts/organism_board.py @@ -272,6 +272,12 @@ def _row_id(name: str) -> str: .organ-foot a{min-height:44px;display:inline-flex;align-items:center} .project-card ul{list-style:none;padding:0;margin:0} .project-card li{margin:0 0 .35rem} +.available-resource{border:1px dashed var(--line);border-radius:6px} +.available-resource>summary{color:var(--muted);cursor:pointer;padding:.65rem;min-height:44px;box-sizing:border-box} +.availability-label{font-size:.8rem;margin-left:.4rem} +.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-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}} diff --git a/scripts/project_resources.py b/scripts/project_resources.py index dac2676..8b603d7 100644 --- a/scripts/project_resources.py +++ b/scripts/project_resources.py @@ -29,6 +29,16 @@ "records": "Organ records", } KINDS = {"software_family": "Software family", "scientific_project": "Scientific investigation"} +CAPABILITIES = { + "workspaces": "Examples and working scripts for this project.", + "tutorials": "Step-by-step teaching material.", + "documentation": "Reference and usage guides.", + "assistants": "A project-specific research assistant.", + "tests": "Regression and smoke-test workspaces.", + "profiling": "Measure runtime and resource use.", + "inference": "Compare inference methods and results.", + "visualization": "Review figures and visualization outputs.", +} ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z") @@ -119,13 +129,19 @@ def resolve(config: dict, body_map: dict) -> dict: public_projects = [] for key, item in projects.items(): - if set(item) - {"name", "public", "kind", "description", "resources"}: + if set(item) - {"name", "public", "kind", "description", "resources", "available"}: raise ValueError(f"project {key}: unknown field") if not isinstance(item.get("kind"), str) or item["kind"] not in KINDS: raise ValueError(f"project {key}: unknown project kind") relations = item.get("resources", {}) if not isinstance(relations, dict) or set(relations) - RELATIONS.keys(): raise ValueError(f"project {key}: unknown resource relationship") + available = _entries(item.get("available", {}), "available capability") + for capability, option in available.items(): + if capability not in CAPABILITIES or set(option) - {"name", "public"}: + raise ValueError(f"project {key}: unknown available capability or field") + if relations.get(capability): + raise ValueError(f"project {key}: available capability is already connected") groups = [] for relation, label in RELATIONS.items(): ids = relations.get(relation, []) @@ -135,8 +151,12 @@ def resolve(config: dict, body_map: dict) -> dict: if any(i not in resources for i in ids): raise ValueError(f"project {key}: unknown resource reference") visible = [resolved[i] for i in ids if resources[i].get("public") is True] - if visible: + option = available.get(relation, {}) + if visible or option.get("public") is True: group = {"relation": relation, "label": label, "resources": visible} + if option.get("public") is True: + group["available"] = [{"id": relation, "name": option["name"], + "purpose": CAPABILITIES[relation]}] dashboard = routes.get(relation) if dashboard and resources[dashboard].get("public") is True: group["dashboard"] = {"id": dashboard, "name": resources[dashboard]["name"], @@ -164,8 +184,10 @@ def display_groups(project: dict) -> list[dict]: key = ("dashboard", dashboard["id"]) if dashboard else ("relation", group["relation"]) if key not in groups: groups[key] = {"label": dashboard["name"] if dashboard else group["label"], - "url": dashboard["url"] if dashboard else None, "resources": []} + "url": dashboard["url"] if dashboard else None, + "resources": [], "available": []} row = groups[key] + row["available"].extend(group.get("available", [])) seen = {r["id"] for r in row["resources"]} for resource in group["resources"]: if resource["id"] in seen: @@ -184,10 +206,31 @@ def markdown_text(value: str) -> str: return re.sub(r"([\\\[\]*_`#|])", r"\\\1", html.escape(value)).replace("\n", " ") +def setup_prompt(project: dict, option: dict) -> str: + """A portable request, not an activation command or inferred repository ID.""" + return ( + f"Help me add {RELATIONS[option['id']].lower()} to project " + f"{json.dumps(project['name'])} (stable project ID: {project['id']}). " + f"Suggested name: {json.dumps(option['name'])}; this is only a naming hint.\n\n" + "Read this Scientist's instructions, instance profile, project relationships and " + "canonical Mind repository map. Inspect existing project-owned sources and any " + "partial setup first; reuse them and preserve user records. Ask for missing purpose, " + "location, ownership or visibility choices. Do not infer identity or access from names. " + "Propose the smallest useful setup through the existing development workflow, " + "including validation and how to resume it. Obtain approval before creating " + "repositories, changing access, publishing or running science.\n\n" + f"Keep capability {option['id']} available until setup is validated. Then replace " + "its available entry with an explicit connected resource using the canonical " + "repository identity, and validate the Scientist configuration offline. " + "Configure an owning organ separately if needed; a resource link is not organ " + "activation or evidence of quality." + ) + + def render_markdown(snapshot: dict) -> str: text = markdown_text - lines = ["## Projects", "", "Connected resources; inventory is not a quality or readiness verdict."] + lines = ["## Projects"] for project in snapshot.get("projects", []): lines.extend(["", "### " + text(project["name"]), "", text(project["description"])]) for group in display_groups(project): @@ -198,6 +241,9 @@ def render_markdown(snapshot: dict) -> str: links = " · ".join(f"[{text(resource['name'] if i == 0 else link['label'])}]({quote(link['url'], safe='/:?=&%+#@~;,-_')})" for i, link in enumerate(resource["links"])) lines.append(f"- {label}: {links}") + for option in group["available"]: + lines.extend([f"- {label}: {text(option['name'])} — Available to add", + "", text(setup_prompt(project, option)), ""]) return "\n".join(lines) @@ -212,6 +258,16 @@ def render_html(snapshot: dict) -> str: links = " · ".join(f'{esc(resource["name"] if i == 0 else link["label"])}' for i, link in enumerate(resource["links"])) entries.append(f'
  • {links}
  • ') + for option in group["available"]: + ident = f"setup-{project['id']}-{option['id']}" + entries.append( + f'
  • ' + f'{esc(option["name"])} ' + 'Available to add' + f'

    {esc(option["purpose"])}

    ' + f'' + f'
  • ') label = esc(group["label"]) if group["url"]: label = f'{label}' @@ -222,7 +278,6 @@ def render_html(snapshot: dict) -> str:

    {esc(project['description'])}

    {content}
    ''') empty = '

    No public projects configured.

    ' if not cards else '' return f'''

    Projects

    -

    Connected resources. Follow the source links for current records; listed resources do not establish quality or readiness.

    {empty}
    {''.join(cards)}
    ''' diff --git a/tests/test_project_resources.py b/tests/test_project_resources.py index 5b5dbae..2c71e5b 100644 --- a/tests/test_project_resources.py +++ b/tests/test_project_resources.py @@ -198,3 +198,105 @@ def test_owner_deep_links_preserve_sources_and_snapshot(inputs): "https://docs.example.org/", "https://example.org/broca/#resource-docs", "https://github.com/OtherLab/renamed-code/tree/release%2F1/guide/start%20here.md"} assert snapshot == before + + +def test_available_resources_are_not_connected_or_exported(inputs): + config, body_map = inputs + before = pr.catalogue(pr.resolve(*inputs)) + config['projects']['alpha']['available'] = { + 'inference': {'name': 'renamed_experiments', 'public': True}, + 'assistants': {'name': 'optional_helper', 'public': True}} + snapshot = pr.resolve(config, body_map) + assert pr.catalogue(snapshot) == before + inference = next(g for g in snapshot['projects'][0]['groups'] if g['relation'] == 'inference') + assert inference['resources'] == [] + assert inference['available'][0]['id'] == 'inference' + page = pr.render_html(snapshot) + assert 'Available to add' in page and 'readonly' in page + assert 'renamed_experiments' in page + assert 'href="https://github.com/renamed_experiments' not in page + assert r'renamed\_experiments' in pr.render_markdown(snapshot) + assert 'Do not infer identity or access from names' in page + + +@pytest.mark.parametrize('available', [ + [], {'unknown': {'name': 'x'}}, {'software': {'name': 'x'}}, + {'tests': {'name': 'x', 'repo': 'library'}}, + {'tests': {'name': 'x', 'public': 'true'}}, + {'tests': {'name': ''}}, {'tests': 'x'}, + {'tests': {'name': 'x', 'status': 'active'}}, + {'documentation': {'name': 'already-connected', 'public': True}}, +]) +def test_available_configuration_rejects_ambiguous_states(inputs, available): + inputs[0]['projects']['alpha']['available'] = available + with pytest.raises(ValueError): + pr.resolve(*inputs) + + +def test_private_available_entries_and_routes_never_leak(inputs): + config, body_map = inputs + config['projects']['alpha']['available'] = { + 'tests': {'name': 'private-tests', 'public': False}, + 'inference': {'name': 'unapproved-inference'}, + 'workspaces': {'name': 'public-workspace', 'public': True}} + config['projects']['beta']['public'] = False + config['projects']['beta']['available'] = {'tests': {'name': 'private-project-tests', 'public': True}} + config['resources']['private']['published_url'] = 'https://private.example.org/' + config['routes'] = {'workspaces': 'private'} + result = pr.resolve(config, body_map) + for output in [json.dumps(result), pr.render_html(result), pr.render_markdown(result)]: + assert 'public-workspace' in output + for private in ['private-tests', 'unapproved-inference', 'private-project-tests', 'private.example.org']: + assert private not in output + + +def test_available_uses_dashboard_groups_without_inventing_detail_anchor(inputs): + config, body_map = inputs + config['resources']['broca'] = {'name': 'Broca', 'published_url': 'https://example.org/broca/', 'public': True} + config['routes'] = {'documentation': 'broca', 'workspaces': 'broca'} + config['projects']['alpha']['available'] = {'workspaces': {'name': 'any-name', 'public': True}} + snapshot = pr.resolve(config, body_map) + row = next(r for r in pr.display_groups(snapshot['projects'][0]) if r['label'] == 'Broca') + assert [r['id'] for r in row['resources']] == ['docs'] + assert [r['id'] for r in row['available']] == ['workspaces'] + page = pr.render_html(snapshot) + assert '#resource-docs' in page + assert '#resource-workspaces' not in page + + +def test_available_transition_to_connected_is_explicit_and_name_independent(inputs): + config, body_map = inputs + config['projects']['alpha']['available'] = {'workspaces': {'name': 'hint-only', 'public': True}} + config['projects']['alpha']['resources']['workspaces'] = ['private'] + with pytest.raises(ValueError, match='already connected'): + pr.resolve(*inputs) # private connections must not look unconfigured + del config['projects']['alpha']['available']['workspaces'] + config['projects']['alpha']['resources']['workspaces'] = ['library'] + page = pr.render_html(pr.resolve(*inputs)) + assert 'hint-only' not in page and 'Available to add' not in page + assert 'https://github.com/OtherLab/renamed-code' in page + + +def test_available_labels_are_plain_text_and_rendering_preserves_inputs(inputs): + inputs[0]['projects']['alpha']['available'] = { + 'tests': {'name': '', 'public': True}} + before = copy.deepcopy(inputs) + snapshot = pr.resolve(*inputs) + page = pr.render_html(snapshot) + assert '