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
+17 -1
View File
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
import { canWriteRecord } from "@/utils/permission";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -747,6 +749,10 @@ async function confirmDelete() {
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
<!-- What was said AT inception; the live answers are on the Family tab. -->
<template v-if="project.inception.choices.platforms?.length">
· platforms {{ project.inception.choices.platforms.join(", ") }}
</template>
</p>
<!-- Summary stat chips -->
@@ -945,6 +951,9 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
Family
</button>
</div>
<!-- Tasks tab — milestone-grouped kanban -->
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
<!-- Family tab (milestone 463): which platforms this project is. -->
<ProjectFamilyTab
v-if="activeTab === 'family'"
:project-id="projectId"
:can-write="canWriteRecord(project.permission)"
/>
</div>
</div>
</template>
+168 -2
View File
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { usePlatformsStore } from "@/stores/platforms";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -71,6 +72,75 @@ async function saveArea() {
savingArea.value = false;
}
}
// ── Platforms (milestone 463) ───────────────────────────────────────────
// The global list of what a project can be built on or ship as. Same split
// as the areas: admin-only to write, readable by everyone. Markers are the
// repo-relative globs that let a coverage refresh DETECT a platform; they
// are edited one per line, since a glob may itself contain a comma.
const platformsStore = usePlatformsStore();
const newPlatformName = ref("");
const newPlatformDescription = ref("");
const newPlatformMarkers = ref("");
const creatingPlatform = ref(false);
const editingPlatformId = ref<number | null>(null);
const editPlatformName = ref("");
const editPlatformDescription = ref("");
const editPlatformMarkers = ref("");
const savingPlatform = ref(false);
function markerLines(text: string): string[] {
return text.split("\n").map((m) => m.trim()).filter(Boolean);
}
async function createPlatform() {
const name = newPlatformName.value.trim();
if (!name || creatingPlatform.value) return;
creatingPlatform.value = true;
try {
await platformsStore.createEntry({
name,
description: newPlatformDescription.value.trim() || undefined,
markers: markerLines(newPlatformMarkers.value),
});
newPlatformName.value = "";
newPlatformDescription.value = "";
newPlatformMarkers.value = "";
toastStore.show("Platform added");
} catch (e) {
// A 409 names the existing platform the new name reduces to.
toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
} finally {
creatingPlatform.value = false;
}
}
function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
editingPlatformId.value = id;
editPlatformName.value = name;
editPlatformDescription.value = description ?? "";
editPlatformMarkers.value = markers.join("\n");
}
async function savePlatform() {
const id = editingPlatformId.value;
const name = editPlatformName.value.trim();
if (id == null || !name || savingPlatform.value) return;
savingPlatform.value = true;
try {
await platformsStore.updateEntry(id, {
name,
description: editPlatformDescription.value.trim(),
markers: markerLines(editPlatformMarkers.value),
});
editingPlatformId.value = null;
toastStore.show("Platform updated");
} catch (e) {
toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
} finally {
savingPlatform.value = false;
}
}
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -479,7 +549,7 @@ async function copyCommit() {
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
else if (tab === "platforms") platformsStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
@@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) {
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
</section>
</div>
<!-- ── Platforms ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
<section class="settings-section full-width">
<h2>Platforms</h2>
<p class="field-hint">
What a project can be built on or ship as. A project's platforms decide which family
ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
markers is never detected — projects declare it themselves.
</p>
<ul class="area-admin-list">
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
<template v-if="editingPlatformId === entry.id">
<form class="area-admin-form" @submit.prevent="savePlatform">
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
<textarea
v-model="editPlatformDescription"
class="fs-input"
rows="2"
aria-label="Platform description"
></textarea>
<textarea
v-model="editPlatformMarkers"
class="fs-input area-admin-markers"
rows="3"
aria-label="Detection markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
{{ savingPlatform ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
</div>
</form>
</template>
<template v-else>
<div class="area-admin-body">
<div class="area-admin-name-row">
<span class="area-admin-name">{{ entry.name }}</span>
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
</div>
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
<p class="area-admin-desc">
<template v-if="entry.markers.length">
Detected by
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
</template>
<template v-else>Declare-only — never detected.</template>
</p>
</div>
<button
class="btn-ghost btn-compact"
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
>Edit</button>
</template>
</li>
</ul>
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
No platforms yet.
</p>
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
<input
v-model="newPlatformName"
class="fs-input"
placeholder="New platform name (e.g. Flutter app)"
aria-label="New platform name"
/>
<textarea
v-model="newPlatformDescription"
class="fs-input"
rows="2"
placeholder="What makes a project this platform? One sentence."
aria-label="New platform description"
></textarea>
<textarea
v-model="newPlatformMarkers"
class="fs-input area-admin-markers"
rows="2"
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
aria-label="New platform markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
{{ creatingPlatform ? "Adding…" : "Add platform" }}
</button>
</div>
</form>
</section>
</div>
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
the operator is reviewing what was done, and the reason is the part worth