feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).
- canonical_systems: global, no user_id — a shared project inherits the
vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
mechanically and a real difference ("CI & runners") becomes a proposal a
human confirms. propose_mappings reports; set_system_canonical is the only
writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
target's own rows and only creates entries an admin added on the source; an
unknown slug restores unmapped rather than failing.
Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ Sentinels (match the milestone/task tool conventions):
|
||||
from __future__ import annotations
|
||||
|
||||
from scribe.mcp._context import current_user_id
|
||||
from scribe.services import canonical_systems as canonical_systems_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
@@ -30,10 +31,9 @@ _BOOTSTRAP_TITLES = 6
|
||||
# design (rule #115): archetypes any codebase could have, never one
|
||||
# install's subsystems. Mint freely beyond the list; the duplicate gate
|
||||
# guards sprawl.
|
||||
# The standard vocabulary lives with the service (services/systems.
|
||||
# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
|
||||
# ask names it, one list for both.
|
||||
_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)
|
||||
# The standard vocabulary lives in the GLOBAL canonical catalog since
|
||||
# milestone 307 — the inception seed mints it and this ask names it, one list
|
||||
# for both, now a table so a rule can reference an area by id (note 3026).
|
||||
|
||||
|
||||
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
@@ -67,6 +67,17 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
titles = "; ".join(
|
||||
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
|
||||
)
|
||||
try:
|
||||
standard = ", ".join(name for name, _charter in await systems_svc.standard_systems())
|
||||
except Exception:
|
||||
standard = ""
|
||||
# An install whose catalog is empty still gets a usable ask — the standard
|
||||
# names are an aid, not the point of the question.
|
||||
standard_line = (
|
||||
"Where an area fits a standard name, use it verbatim so it means the "
|
||||
f"same thing in every project: {standard}. Mint freely beyond that "
|
||||
"list — the duplicate gate guards sprawl. "
|
||||
) if standard else "The duplicate gate guards sprawl. "
|
||||
return (
|
||||
f"This project has {total} records and NO Systems modelled — none of "
|
||||
"them can be tagged to an area, so recurring problem-spots stay "
|
||||
@@ -74,10 +85,8 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||
"asking permission — creating Systems is your call, not an approval "
|
||||
f"flow. From the areas the records themselves name (recent: {titles}), "
|
||||
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
||||
"tag this record (system_ids=[...]). Where an area fits a standard "
|
||||
f"name, use it verbatim so it means the same thing in every project: "
|
||||
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
|
||||
"duplicate gate guards sprawl. This ask repeats until the first "
|
||||
f"tag this record (system_ids=[...]). {standard_line}"
|
||||
"This ask repeats until the first "
|
||||
"System exists; answering it once retires it for every future record."
|
||||
)
|
||||
|
||||
@@ -322,6 +331,64 @@ async def delete_system(system_id: int) -> dict:
|
||||
return {"message": f"System {system_id} deleted."}
|
||||
|
||||
|
||||
async def list_canonical_systems() -> dict:
|
||||
"""The GLOBAL vocabulary of area names, shared by every project.
|
||||
|
||||
These are the standard names to prefer when creating a System, so the same
|
||||
word means the same thing in every project on the instance — and, from
|
||||
milestone 307, the ids a cross-project record can point at. A project's own
|
||||
System keeps whatever name the project calls the area; mapping it here is
|
||||
an association, never a rename.
|
||||
|
||||
Reach for it before create_system when the area is an ordinary one (CI,
|
||||
auth, storage, the API, the UI), and pass the matching `canonical_id`.
|
||||
"""
|
||||
entries = await canonical_systems_svc.list_canonical_systems()
|
||||
return {"canonical_systems": [e.to_dict() for e in entries]}
|
||||
|
||||
|
||||
async def propose_canonical_mappings(project_id: int) -> dict:
|
||||
"""Suggest a global area for each of this project's UNMAPPED Systems.
|
||||
|
||||
Returns PROPOSALS ONLY — nothing is written. Confirm the ones that are
|
||||
right with map_system_to_canonical(system_id, canonical_id); ignore the
|
||||
rest. Each carries a `basis`:
|
||||
|
||||
- `exact` — the names reduce to the same match key ("CI and Release" vs
|
||||
"CI & Release"). Safe to confirm without much thought.
|
||||
- `overlap` — they share a meaningful word ("CI & runners" vs "CI &
|
||||
Release"). A judgment call: confirm only if they really are the same
|
||||
area, since a wrong mapping surfaces cross-project records in the wrong
|
||||
place.
|
||||
|
||||
A System with no proposal is not a problem — unmapped is a valid resting
|
||||
state, and a genuinely project-specific area should stay that way.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
return {"proposals": await canonical_systems_svc.propose_mappings(uid, project_id)}
|
||||
|
||||
|
||||
async def map_system_to_canonical(system_id: int, canonical_id: int = 0) -> dict:
|
||||
"""Map one of a project's Systems onto a global area (or clear it).
|
||||
|
||||
Sets `canonical_id` and NOTHING else — the System's name, charter and every
|
||||
record tagged to it are untouched. Pass canonical_id=0 to unmap.
|
||||
|
||||
Args:
|
||||
canonical_id: id from list_canonical_systems; 0 clears the mapping.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
system = await canonical_systems_svc.set_system_canonical(
|
||||
uid, system_id, canonical_id or None,
|
||||
)
|
||||
if system is None:
|
||||
raise ValueError(
|
||||
f"system {system_id} not found, no write access, "
|
||||
f"or canonical_id {canonical_id} is not a live catalog entry"
|
||||
)
|
||||
return system.to_dict()
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
create_system,
|
||||
@@ -330,5 +397,8 @@ def register(mcp) -> None:
|
||||
update_system,
|
||||
list_system_records,
|
||||
delete_system,
|
||||
list_canonical_systems,
|
||||
propose_canonical_mappings,
|
||||
map_system_to_canonical,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
Reference in New Issue
Block a user