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
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user