From a113c72b4f03f866c206b6a995a974d6c86cbcd0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 2 Oct 2026 22:55:26 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(systems):=20a=20System=20names=20its?= =?UTF-8?q?=20files=20=E2=80=94=20path=20patterns=20stored,=20validated=20?= =?UTF-8?q?and=20matched=20(milestone=20444=20step=203,=20#4756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A System gains path_patterns: globs relative to the repo root (* within one directory, ** across any depth, a plain directory covering everything under it). One service validates them for every door, so the web UI and MCP refuse the same bad pattern with the same message. systems_for_paths resolves paths to every active System that covers them, which step 4 (#4757) uses to deliver an area's rulings when its files are touched. - schema: systems.path_patterns JSONB NOT NULL default [] (migration 0113) - service: normalize_path_patterns, path_matches, systems_for_paths - routes + MCP create_system/update_system accept it; [] clears - web UI: a Files field in the create and edit forms, patterns on the card - backup carries it through export and restore - using-scribe reflex 7: tagging work keeps a System's files current Co-Authored-By: Claude Opus 5.5 --- alembic/versions/0113_system_path_patterns.py | 34 +++++ frontend/src/api/systems.ts | 15 ++- frontend/src/components/SystemsSection.vue | 72 +++++++++- frontend/src/stores/systems.ts | 4 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/using-scribe/SKILL.md | 5 + src/scribe/mcp/tools/systems.py | 20 ++- src/scribe/models/system.py | 13 +- src/scribe/routes/systems.py | 23 ++-- src/scribe/services/backup.py | 3 + src/scribe/services/systems.py | 117 ++++++++++++++++- tests/test_guidance_ownership.py | 3 + tests/test_mcp_tool_systems.py | 17 +++ tests/test_services_systems.py | 124 ++++++++++++++++++ 14 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/0113_system_path_patterns.py diff --git a/alembic/versions/0113_system_path_patterns.py b/alembic/versions/0113_system_path_patterns.py new file mode 100644 index 0000000..e63e3c9 --- /dev/null +++ b/alembic/versions/0113_system_path_patterns.py @@ -0,0 +1,34 @@ +"""system_path_patterns — a System names the files that are its area +(milestone 444 step 3, #4756) + +Revision ID: 0113 +Revises: 0112 +Create Date: 2026-10-02 + +A JSONB list of globs relative to the repo root. NOT NULL with a `[]` +default, so an existing System reads as "no paths yet" rather than as NULL. No +backfill: which files are which area is a judgment, and the charters that name +directories in prose are not a mapping anyone confirmed. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0113" +down_revision = "0112" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "systems", + sa.Column( + "path_patterns", postgresql.JSONB(), nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) + + +def downgrade() -> None: + op.drop_column("systems", "path_patterns") diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index 24d3637..7d19ed1 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -14,6 +14,12 @@ export interface System { color: string | null; status: "active" | "archived"; order_index: number; + /** + * The files that are this area, as globs relative to the repo root: `*` + * within one directory, `**` across any depth, a plain directory covering + * everything under it. Empty means the area has not named its files. + */ + path_patterns: string[]; open_issue_count: number; created_at: string | null; updated_at: string | null; @@ -39,7 +45,13 @@ export interface CreatedSystem extends System { export async function createSystem( projectId: number, - data: { name: string; description?: string; color?: string; canonical_id?: number }, + data: { + name: string; + description?: string; + color?: string; + canonical_id?: number; + path_patterns?: string[]; + }, ): Promise { return apiPost(`/api/projects/${projectId}/systems`, data); } @@ -53,6 +65,7 @@ export async function updateSystem( color: string | null; status: "active" | "archived"; order_index: number; + path_patterns: string[]; }>, ): Promise { return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data); diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index d3411f7..f69fffd 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -28,6 +28,7 @@ const newDescription = ref(""); // feature two matchers to keep in step, which is the exact drift the catalog // exists to end. The server still applies an exact hit on submit. const newCanonicalId = ref(null); +const newPaths = ref(""); const creating = ref(false); // An `overlap` the server offered after a create — an offer, never applied. const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null); @@ -37,6 +38,7 @@ const editingId = ref(null); const editName = ref(""); const editDescription = ref(""); const editCanonicalId = ref(null); +const editPaths = ref(""); const savingEdit = ref(false); // Mapping review @@ -55,6 +57,16 @@ const visibleSystems = computed(() => const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []); +// The files field is one pattern per line. The server is the one that +// validates and tidies them, so this only splits — a second validator here +// would be a second answer to "is this pattern acceptable". +function parsePaths(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + function areaName(system: System): string | null { return canon.byId(system.canonical_id)?.name ?? null; } @@ -89,6 +101,7 @@ function openCreate() { newName.value = ""; newDescription.value = ""; newCanonicalId.value = null; + newPaths.value = ""; } function cancelCreate() { @@ -96,6 +109,7 @@ function cancelCreate() { newName.value = ""; newDescription.value = ""; newCanonicalId.value = null; + newPaths.value = ""; } async function submitCreate() { @@ -107,6 +121,7 @@ async function submitCreate() { name, description: newDescription.value.trim() || undefined, canonical_id: newCanonicalId.value ?? undefined, + path_patterns: parsePaths(newPaths.value), }); cancelCreate(); if (created.canonical_suggestion) { @@ -157,6 +172,7 @@ function startEdit(system: System) { editName.value = system.name; editDescription.value = system.description; editCanonicalId.value = system.canonical_id; + editPaths.value = system.path_patterns.join("\n"); } function cancelEdit() { @@ -171,6 +187,7 @@ async function submitEdit(system: System) { await store.updateSystem(props.projectId, system.id, { name, description: editDescription.value.trim(), + path_patterns: parsePaths(editPaths.value), }); // The mapping is a separate write with its own validation — one column, // one writer (services/canonical_systems.set_system_canonical). @@ -180,8 +197,9 @@ async function submitEdit(system: System) { } editingId.value = null; toast.show("System updated"); - } catch { - toast.show("Failed to update system", "error"); + } catch (e) { + // A refused pattern comes back as a 400 naming it — say which. + toast.show(apiErrorMessage(e, "Failed to update system"), "error"); } finally { savingEdit.value = false; } @@ -337,6 +355,20 @@ async function confirmDelete() { Files this system under an area shared by every project. Your name stays as you typed it. +

{{ system.description }}

+
    +
  • {{ pattern }}
  • +