feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s

A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
+4
View File
@@ -160,6 +160,9 @@ _READ_ONLY_TOOLS = frozenset({
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
# The platform catalog and a project's answers (milestone 463). A pure
# read; set_project_platforms is the write.
"list_platforms",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -183,6 +186,7 @@ _WRITE_TOOLS = frozenset({
# projects, Systems, repos
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"set_project_platforms",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+2 -1
View File
@@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
moments, retrieval_review, retrieval_tuning,
moments, platforms, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
)
@@ -24,6 +24,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
platforms.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
+78
View File
@@ -0,0 +1,78 @@
"""Platform MCP tools — what a project is built on or ships as (milestone 463).
Thin wrappers over services/platforms.py. The catalog is global; a project's
platforms decide which family ideas reach it.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import platforms as platforms_svc
async def list_platforms(project_id: int = 0) -> dict:
"""The GLOBAL platform catalog — runtimes, delivery channels and
toolchains a project can be built on or ship as (Android app, container
image, Go, …) — and, with a project_id, that project's answer for each.
A project's platforms decide which family ideas reach it: an idea is for
some platforms, and every project that is one of them answers it. Pass
slugs from here to set_project_platforms and decide_project_inception.
Each platform's `markers` are the file patterns that let the coverage
refresh DETECT it in a bound repo. A project's `state` per platform is
`declared` (a person said so), `detected` (a marker said so) or `rejected`
(a person said no — kept so detection cannot add it back). Only declared
and detected are membership.
Args:
project_id: also return this project's answers. 0 = catalog only.
"""
catalog = await platforms_svc.list_platforms()
out: dict = {"platforms": [p.to_dict() for p in catalog]}
if project_id:
rows = await platforms_svc.project_platforms(current_user_id(), project_id)
if rows is None:
raise ValueError(f"project {project_id} not found")
out["project_platforms"] = rows
return out
async def set_project_platforms(
project_id: int,
declared: list[str] | None = None,
rejected: list[str] | None = None,
withdrawn: list[str] | None = None,
) -> dict:
"""Say which platforms a project is — or is not. Only the platforms named
change; everything else is left exactly as it is.
Use it when the operator corrects what detection found, or when a project
starts or stops shipping something (it gains an Android client; it drops
its container image). At project creation, decide_project_inception's
`platforms` is the place instead — it records the answer with the rest of
what the project inherits.
Args:
project_id: the project.
declared: slugs the project IS (list_platforms).
rejected: slugs it is NOT — recorded as a "no", so the coverage
refresh never detects it back.
withdrawn: slugs whose answer to drop entirely, so detection may
decide again on the next refresh.
"""
updates: dict[str, str | None] = {}
for slug in withdrawn or []:
updates[slug] = None
for slug in rejected or []:
updates[slug] = "rejected"
for slug in declared or []:
updates[slug] = "declared"
if not updates:
raise ValueError("name at least one platform to declare, reject or withdraw")
rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates)
return {"project_id": project_id, "project_platforms": rows}
def register(mcp) -> None:
for fn in (list_platforms, set_project_platforms):
mcp.tool(name=fn.__name__)(fn)
+32 -9
View File
@@ -23,6 +23,7 @@ from scribe.services import design_systems as design_systems_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -125,9 +126,15 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
`platforms` (milestone 463) is what the project is built on or ships as
— Android app, container image, Go, … — each with how it is known
(`declared` by a person, `detected` from a bound repo). They decide which
family ideas reach this project. An empty list on a project that plainly
ships something is worth correcting with set_project_platforms.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(design system, Systems), what to ask the
(design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
@@ -188,6 +195,9 @@ async def enter_project(project_id: int) -> dict:
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
platforms = platforms_svc.members(
await platforms_svc.project_platforms(uid, project_id) or []
)
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
@@ -258,6 +268,7 @@ async def enter_project(project_id: int) -> dict:
},
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
"systems": [{"id": s.id, "name": s.name} for s in systems],
"platforms": platforms,
"design_system": design_system,
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(
@@ -302,12 +313,15 @@ async def get_project(project_id: int) -> dict:
rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
`platforms` is every platform the project has an answer for, with its
state — rejected ones included, unlike enter_project's brief list.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or []
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
applicable = await rulebooks_svc.get_applicable_rules(
@@ -317,17 +331,21 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(design_system_id, seed_systems) -> dict | None:
def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if not design_system_id and seed_systems is None:
system. platforms: None = not stated (memberships untouched), a list =
the whole answer."""
if not design_system_id and seed_systems is None and platforms is None:
return None
return {
choices = {
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
if platforms is not None:
choices["platforms"] = list(platforms)
return choices
async def create_project(
@@ -338,11 +356,12 @@ async def create_project(
color: str = "",
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the two inception questions and pass
before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no design system, no Systems. Rules are not
@@ -359,6 +378,9 @@ async def create_project(
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
Auth & Access, …) so records can be tagged from day one.
platforms: slugs of what the project is built on or ships as
(list_platforms) — they decide which family ideas reach it. The
list is the whole answer; omit it to leave the question open.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -370,7 +392,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
choices = _inception_choices(design_system_id, seed_systems)
choices = _inception_choices(design_system_id, seed_systems, platforms)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -388,6 +410,7 @@ async def decide_project_inception(
project_id: int,
design_system_id: int = 0,
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
@@ -400,10 +423,10 @@ async def decide_project_inception(
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no design system, no seed) — a valid answer,
stated.
stated — and leaves the project's platforms as they are.
"""
uid = current_user_id()
choices = _inception_choices(design_system_id, seed_systems) or {}
choices = _inception_choices(design_system_id, seed_systems, platforms) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}