feat(systems): a System names its files — path patterns stored, validated and matched (milestone 444 step 3, #4756)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 43s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:55:26 -04:00
co-authored by Claude Opus 5.5
parent 01d8a0b9f1
commit a113c72b4f
14 changed files with 435 additions and 17 deletions
+14 -1
View File
@@ -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<CreatedSystem> {
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<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
+70 -2
View File
@@ -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<number | null>(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<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const editCanonicalId = ref<number | null>(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.
</span>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="newPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="e.g. src/billing — one pattern per line"
aria-label="System files"
spellcheck="false"
></textarea>
<span class="field-hint">
One pattern per line, from the repo root. <code>*</code> stays in one folder, <code>**</code> reaches any depth, and a folder covers everything in it.
</span>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
@@ -397,6 +429,17 @@ async function confirmDelete() {
</option>
</select>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="editPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="One pattern per line, from the repo root"
aria-label="System files"
spellcheck="false"
></textarea>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
@@ -430,6 +473,9 @@ async function confirmDelete() {
>{{ areaName(system) }}</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
<ul v-if="system.path_patterns.length" class="system-paths" aria-label="Files">
<li v-for="pattern in system.path_patterns" :key="pattern" class="system-path">{{ pattern }}</li>
</ul>
</div>
<div class="system-actions">
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
@@ -627,6 +673,28 @@ async function confirmDelete() {
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
.system-paths-input { font-family: var(--fs-font-mono); font-size: 0.8rem; }
/* The area's files (milestone 444). Mono because they are paths to be read
character for character; small because they qualify the card, not lead it. */
.system-paths {
list-style: none;
margin: var(--fs-space-2) 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-1);
}
.system-path {
font-family: var(--fs-font-mono);
font-size: 0.7rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.4rem;
word-break: break-all;
}
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
+2 -2
View File
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string; canonical_id?: number },
data: Parameters<typeof api.createSystem>[1],
) {
const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
@@ -33,7 +33,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function updateSystem(
projectId: number,
systemId: number,
data: Partial<Pick<System, "name" | "description" | "color" | "status" | "order_index">>,
data: Parameters<typeof api.updateSystem>[2],
) {
const system = await api.updateSystem(projectId, systemId, data);
const list = systemsByProject.value[projectId];