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
+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}