Merge pull request 'Rulings reach the work they govern — milestone 444 steps 3-4' (#194) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 20s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 20s
This commit was merged in pull request #194.
This commit is contained in:
@@ -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")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""system_usage_events — were an area's rulings read once its files were
|
||||||
|
touched? (milestone 444 step 4, #4757)
|
||||||
|
|
||||||
|
Revision ID: 0114
|
||||||
|
Revises: 0113
|
||||||
|
Create Date: 2026-10-03
|
||||||
|
|
||||||
|
The usage twin for Systems, beside note_usage_events and rule_usage_events and
|
||||||
|
separate from both for the same reason they are separate from each other: a
|
||||||
|
System id restores through its own map. FK-free like its siblings; no CHECK on
|
||||||
|
`event`, like its siblings.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0114"
|
||||||
|
down_revision = "0113"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"system_usage_events",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("system_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("event", sa.Text(), nullable=False),
|
||||||
|
sa.Column("source", sa.Text(), nullable=False),
|
||||||
|
sa.Column("project_id", sa.BigInteger(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_system_usage_system_event", "system_usage_events", ["system_id", "event"]
|
||||||
|
)
|
||||||
|
op.create_index("ix_system_usage_created_at", "system_usage_events", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_system_usage_created_at", table_name="system_usage_events")
|
||||||
|
op.drop_index("ix_system_usage_system_event", table_name="system_usage_events")
|
||||||
|
op.drop_table("system_usage_events")
|
||||||
@@ -14,6 +14,12 @@ export interface System {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
status: "active" | "archived";
|
status: "active" | "archived";
|
||||||
order_index: number;
|
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;
|
open_issue_count: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -39,7 +45,13 @@ export interface CreatedSystem extends System {
|
|||||||
|
|
||||||
export async function createSystem(
|
export async function createSystem(
|
||||||
projectId: number,
|
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> {
|
): Promise<CreatedSystem> {
|
||||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||||
}
|
}
|
||||||
@@ -53,6 +65,7 @@ export async function updateSystem(
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
status: "active" | "archived";
|
status: "active" | "archived";
|
||||||
order_index: number;
|
order_index: number;
|
||||||
|
path_patterns: string[];
|
||||||
}>,
|
}>,
|
||||||
): Promise<System> {
|
): Promise<System> {
|
||||||
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
|
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const newDescription = ref("");
|
|||||||
// feature two matchers to keep in step, which is the exact drift the catalog
|
// 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.
|
// exists to end. The server still applies an exact hit on submit.
|
||||||
const newCanonicalId = ref<number | null>(null);
|
const newCanonicalId = ref<number | null>(null);
|
||||||
|
const newPaths = ref("");
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
// An `overlap` the server offered after a create — an offer, never applied.
|
// An `overlap` the server offered after a create — an offer, never applied.
|
||||||
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
||||||
@@ -37,6 +38,7 @@ const editingId = ref<number | null>(null);
|
|||||||
const editName = ref("");
|
const editName = ref("");
|
||||||
const editDescription = ref("");
|
const editDescription = ref("");
|
||||||
const editCanonicalId = ref<number | null>(null);
|
const editCanonicalId = ref<number | null>(null);
|
||||||
|
const editPaths = ref("");
|
||||||
const savingEdit = ref(false);
|
const savingEdit = ref(false);
|
||||||
|
|
||||||
// Mapping review
|
// Mapping review
|
||||||
@@ -55,6 +57,16 @@ const visibleSystems = computed(() =>
|
|||||||
|
|
||||||
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
|
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 {
|
function areaName(system: System): string | null {
|
||||||
return canon.byId(system.canonical_id)?.name ?? null;
|
return canon.byId(system.canonical_id)?.name ?? null;
|
||||||
}
|
}
|
||||||
@@ -89,6 +101,7 @@ function openCreate() {
|
|||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newCanonicalId.value = null;
|
newCanonicalId.value = null;
|
||||||
|
newPaths.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreate() {
|
function cancelCreate() {
|
||||||
@@ -96,6 +109,7 @@ function cancelCreate() {
|
|||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newCanonicalId.value = null;
|
newCanonicalId.value = null;
|
||||||
|
newPaths.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
@@ -107,6 +121,7 @@ async function submitCreate() {
|
|||||||
name,
|
name,
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
canonical_id: newCanonicalId.value ?? undefined,
|
canonical_id: newCanonicalId.value ?? undefined,
|
||||||
|
path_patterns: parsePaths(newPaths.value),
|
||||||
});
|
});
|
||||||
cancelCreate();
|
cancelCreate();
|
||||||
if (created.canonical_suggestion) {
|
if (created.canonical_suggestion) {
|
||||||
@@ -157,6 +172,7 @@ function startEdit(system: System) {
|
|||||||
editName.value = system.name;
|
editName.value = system.name;
|
||||||
editDescription.value = system.description;
|
editDescription.value = system.description;
|
||||||
editCanonicalId.value = system.canonical_id;
|
editCanonicalId.value = system.canonical_id;
|
||||||
|
editPaths.value = system.path_patterns.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -171,6 +187,7 @@ async function submitEdit(system: System) {
|
|||||||
await store.updateSystem(props.projectId, system.id, {
|
await store.updateSystem(props.projectId, system.id, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.value.trim(),
|
description: editDescription.value.trim(),
|
||||||
|
path_patterns: parsePaths(editPaths.value),
|
||||||
});
|
});
|
||||||
// The mapping is a separate write with its own validation — one column,
|
// The mapping is a separate write with its own validation — one column,
|
||||||
// one writer (services/canonical_systems.set_system_canonical).
|
// one writer (services/canonical_systems.set_system_canonical).
|
||||||
@@ -180,8 +197,9 @@ async function submitEdit(system: System) {
|
|||||||
}
|
}
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
toast.show("System updated");
|
toast.show("System updated");
|
||||||
} catch {
|
} catch (e) {
|
||||||
toast.show("Failed to update system", "error");
|
// A refused pattern comes back as a 400 naming it — say which.
|
||||||
|
toast.show(apiErrorMessage(e, "Failed to update system"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
savingEdit.value = false;
|
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.
|
Files this system under an area shared by every project. Your name stays as you typed it.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
@@ -397,6 +429,17 @@ async function confirmDelete() {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||||
{{ savingEdit ? "Saving…" : "Save" }}
|
{{ savingEdit ? "Saving…" : "Save" }}
|
||||||
@@ -430,6 +473,9 @@ async function confirmDelete() {
|
|||||||
>{{ areaName(system) }}</span>
|
>{{ areaName(system) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
<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>
|
||||||
<div class="system-actions">
|
<div class="system-actions">
|
||||||
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
|
<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-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.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
|
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||||
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
|||||||
|
|
||||||
async function createSystem(
|
async function createSystem(
|
||||||
projectId: number,
|
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);
|
const system = await api.createSystem(projectId, data);
|
||||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||||
@@ -33,7 +33,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
|||||||
async function updateSystem(
|
async function updateSystem(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
systemId: 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 system = await api.updateSystem(projectId, systemId, data);
|
||||||
const list = systemsByProject.value[projectId];
|
const list = systemsByProject.value[projectId];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.10.02.2259",
|
"version": "2026.10.03.0308",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1123,6 +1123,20 @@ scribe_held_query() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The Systems whose rulings this session was already shown in full (milestone
|
||||||
|
# 444), as the `seen_ruling_systems` query both PreToolUse hooks send. A flat
|
||||||
|
# read, not aged like the rule file: a ruling binds for the whole session, and
|
||||||
|
# the server already answers a repeat with one short reference line. The file
|
||||||
|
# follows the ledger name convention, so a compaction clears it and the
|
||||||
|
# rulings are shown in full again to the context that lost them.
|
||||||
|
scribe_rulings_query() {
|
||||||
|
local ids
|
||||||
|
[ -f "$1" ] || return 0
|
||||||
|
ids=$(grep -E '^[0-9]+$' "$1" 2>/dev/null | sort -un | tr '\n' ',' | sed 's/,$//')
|
||||||
|
[ -n "$ids" ] && printf '&seen_ruling_systems=%s' "$ids"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Drop EVERY per-session ledger, matched by convention rather than listed (#4101).
|
# Drop EVERY per-session ledger, matched by convention rather than listed (#4101).
|
||||||
#
|
#
|
||||||
# A LIST IS THE BUG. Until now the compact/clear branch named its files one at
|
# A LIST IS THE BUG. Until now the compact/clear branch named its files one at
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ mkdir -p "$state_dir" 2>/dev/null || true
|
|||||||
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
|
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
|
||||||
# file for the same reason as the others — a rule named once should not be
|
# file for the same reason as the others — a rule named once should not be
|
||||||
# re-offered on every subsequent write in the session.
|
# re-offered on every subsequent write in the session.
|
||||||
|
#
|
||||||
|
# A FIFTH (milestone 444): the Systems whose RULINGS were shown in full. SHARED
|
||||||
|
# with scribe_tool_rules.sh, like the rule file: an area's rulings shown before
|
||||||
|
# a command must not be repeated in full before the next edit there.
|
||||||
idfile=""
|
idfile=""
|
||||||
syncfile=""
|
syncfile=""
|
||||||
derivefile=""
|
derivefile=""
|
||||||
@@ -200,6 +204,8 @@ exclude_q=""
|
|||||||
sync_exclude_q=""
|
sync_exclude_q=""
|
||||||
derive_exclude_q=""
|
derive_exclude_q=""
|
||||||
rule_exclude_q=""
|
rule_exclude_q=""
|
||||||
|
rulingsfile=""
|
||||||
|
rulings_q=""
|
||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
# What this write defined, for the end-of-turn question (milestone 439). A
|
# What this write defined, for the end-of-turn question (milestone 439). A
|
||||||
@@ -214,6 +220,8 @@ if [ -n "$session_id" ]; then
|
|||||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||||
rulefile="$state_dir/${safe_sid}.rules.ids"
|
rulefile="$state_dir/${safe_sid}.rules.ids"
|
||||||
|
rulingsfile="$state_dir/${safe_sid}.rulings.ids"
|
||||||
|
rulings_q=$(scribe_rulings_query "$rulingsfile")
|
||||||
if [ -f "$idfile" ]; then
|
if [ -f "$idfile" ]; then
|
||||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||||
@@ -242,7 +250,7 @@ fi
|
|||||||
reached=1
|
reached=1
|
||||||
body=$(curl -fsS --max-time 5 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${rulings_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||||
unreached_context=""
|
unreached_context=""
|
||||||
if [ "$reached" = 1 ]; then
|
if [ "$reached" = 1 ]; then
|
||||||
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
||||||
@@ -271,6 +279,9 @@ if [ -n "$body" ]; then
|
|||||||
if [ -n "$derivefile" ]; then
|
if [ -n "$derivefile" ]; then
|
||||||
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
|
scribe_json_list "$body_flat" '.derive_keys' >> "$derivefile" || true
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$rulingsfile" ]; then
|
||||||
|
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
|||||||
scope=$(scribe_scope_query "$lookup_dir")
|
scope=$(scribe_scope_query "$lookup_dir")
|
||||||
[ -n "$scope" ] && repo_q="&${scope}"
|
[ -n "$scope" ] && repo_q="&${scope}"
|
||||||
|
|
||||||
|
# Where the command runs, against the repo's root (milestone 444): the paths a
|
||||||
|
# command names are relative to its cwd or absolute, and a System's patterns
|
||||||
|
# are relative to the root. The server does the arithmetic; this sends both.
|
||||||
|
where_q=""
|
||||||
|
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||||
|
if [ -n "$repo_root" ]; then
|
||||||
|
where_q="&root=$(printf '%s' "$repo_root" | scribe_urlenc)&cwd=$(printf '%s' "$lookup_dir" | scribe_urlenc)"
|
||||||
|
fi
|
||||||
|
|
||||||
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
|
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
|
||||||
#
|
#
|
||||||
# scribe_prior_art.sh keeps the rules it has already named in
|
# scribe_prior_art.sh keeps the rules it has already named in
|
||||||
@@ -82,6 +91,8 @@ state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|||||||
mkdir -p "$state_dir" 2>/dev/null || true
|
mkdir -p "$state_dir" 2>/dev/null || true
|
||||||
rulefile=""
|
rulefile=""
|
||||||
rule_exclude_q=""
|
rule_exclude_q=""
|
||||||
|
rulingsfile=""
|
||||||
|
rulings_q=""
|
||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
rulefile="$state_dir/${safe_sid}.rules.ids"
|
rulefile="$state_dir/${safe_sid}.rules.ids"
|
||||||
@@ -91,6 +102,10 @@ if [ -n "$session_id" ]; then
|
|||||||
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
|
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
|
||||||
# What the session actually OPENED, as against what it was shown (#4100).
|
# What the session actually OPENED, as against what it was shown (#4100).
|
||||||
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
rule_exclude_q="${rule_exclude_q}$(scribe_held_query "$state_dir/${safe_sid}.opened.ids")"
|
||||||
|
# The Systems whose rulings were shown in full — shared with the write-path
|
||||||
|
# hook, for the reason the rule file is.
|
||||||
|
rulingsfile="$state_dir/${safe_sid}.rulings.ids"
|
||||||
|
rulings_q=$(scribe_rulings_query "$rulingsfile")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
|
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
|
||||||
@@ -98,7 +113,7 @@ fi
|
|||||||
# than silence. See the header.
|
# than silence. See the header.
|
||||||
body=$(curl -fsS --max-time 5 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${rule_exclude_q}" 2>/dev/null) || exit 0
|
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${where_q}${rule_exclude_q}${rulings_q}" 2>/dev/null) || exit 0
|
||||||
|
|
||||||
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||||
context=$(scribe_json_pick "$body_flat" '.context')
|
context=$(scribe_json_pick "$body_flat" '.context')
|
||||||
@@ -111,6 +126,9 @@ context=$(scribe_json_pick "$body_flat" '.context')
|
|||||||
if [ -n "$rulefile" ]; then
|
if [ -n "$rulefile" ]; then
|
||||||
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
scribe_json_list "$body_flat" '.rule_ids' | scribe_rules_append "$rulefile"
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$rulingsfile" ]; then
|
||||||
|
scribe_json_list "$body_flat" '.ruling_system_ids' >> "$rulingsfile" || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── The pre-act checkpoint (#4214, milestone 419) ────────────────────────
|
# ── The pre-act checkpoint (#4214, milestone 419) ────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ Two constraints on *how* that's achieved:
|
|||||||
work-logs alike. Treat it as the tagging question asked at the moment of
|
work-logs alike. Treat it as the tagging question asked at the moment of
|
||||||
work: tag the record, create the missing System, or deliberately leave it.
|
work: tag the record, create the missing System, or deliberately leave it.
|
||||||
|
|
||||||
|
A System also names its files: `path_patterns`, globs from the repo root.
|
||||||
|
When the work you are tagging touched files its System's patterns don't
|
||||||
|
cover, and they plainly belong to that area, add them with `update_system`
|
||||||
|
— which files are which area is your call, as naming the area was.
|
||||||
|
|
||||||
8. **Name the record, never just its number.** Whenever you refer to a Scribe
|
8. **Name the record, never just its number.** Whenever you refer to a Scribe
|
||||||
record — in a message to the operator, a commit message, a task body, a
|
record — in a message to the operator, a commit message, a task body, a
|
||||||
work-log — write the id *and* its title: `#3244 "the staleness signal"`,
|
work-log — write the id *and* its title: `#3244 "the staleness signal"`,
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ one line each — the statement, who decided, when, and the record it came from:
|
|||||||
|
|
||||||
The description arrives with every record filed under that System, so a ruling
|
The description arrives with every record filed under that System, so a ruling
|
||||||
there reaches each session working in the area without having to win a search.
|
there reaches each session working in the area without having to win a search.
|
||||||
|
Once the System names its files (`path_patterns`), its rulings also arrive with
|
||||||
|
the first command or edit in a session that touches them.
|
||||||
A quote inside a work log does not: it surfaces only when a query happens to
|
A quote inside a work log does not: it surfaces only when a query happens to
|
||||||
match it, and the passage that matches is usually the prose around it — often a
|
match it, and the passage that matches is usually the prose around it — often a
|
||||||
session's *reading* of the ruling rather than the ruling.
|
session's *reading* of the ruling rather than the ruling.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ tools are thin wrappers.
|
|||||||
Sentinels (match the milestone/task tool conventions):
|
Sentinels (match the milestone/task tool conventions):
|
||||||
- name="" / description="" / color="" / status="" → "leave unchanged" on update
|
- name="" / description="" / color="" / status="" → "leave unchanged" on update
|
||||||
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
|
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
|
||||||
|
- path_patterns=None → "leave unchanged" on update ([] clears them)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ from scribe.services import canonical_systems as canonical_systems_svc
|
|||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
|
from scribe.services.system_usage import record_system_pulled
|
||||||
|
|
||||||
# Below this, a project is young enough that the mild "which area is this
|
# Below this, a project is young enough that the mild "which area is this
|
||||||
# about?" question stays proportionate; at or above it, a zero-Systems project
|
# about?" question stays proportionate; at or above it, a zero-Systems project
|
||||||
@@ -167,6 +169,7 @@ async def create_system(
|
|||||||
name: str,
|
name: str,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
path_patterns: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a System (a reusable, self-describing subsystem/area) in a project.
|
"""Create a System (a reusable, self-describing subsystem/area) in a project.
|
||||||
|
|
||||||
@@ -195,11 +198,20 @@ async def create_system(
|
|||||||
under the System, so a ruling there reaches each session working in the
|
under the System, so a ruling there reaches each session working in the
|
||||||
area; a quote in a work log reaches one only if a search matches it.
|
area; a quote in a work log reaches one only if a search matches it.
|
||||||
|
|
||||||
|
`path_patterns` name the files that ARE the area: globs relative to the
|
||||||
|
repo root, `*` within one directory, `**` across any depth, and a plain
|
||||||
|
directory covering everything under it (`src/billing`,
|
||||||
|
`frontend/src/components/Billing*.vue`). The description says what the
|
||||||
|
area is for; the patterns say where it lives, so work touching those files
|
||||||
|
can be traced to the System without a search. Give them when the area's
|
||||||
|
files are known; a System without them still works for tagging.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: The project this system belongs to (required).
|
project_id: The project this system belongs to (required).
|
||||||
name: Short label (required).
|
name: Short label (required).
|
||||||
description: What the system is and how it's used — a name is rarely enough.
|
description: What the system is and how it's used — a name is rarely enough.
|
||||||
color: Optional UI accent (hex), or empty.
|
color: Optional UI accent (hex), or empty.
|
||||||
|
path_patterns: The area's files as repo-relative globs, or omit.
|
||||||
|
|
||||||
Duplicate-gated like the other creates: if a System with the same
|
Duplicate-gated like the other creates: if a System with the same
|
||||||
normalized name already exists in this project (archived included), the
|
normalized name already exists in this project (archived included), the
|
||||||
@@ -237,7 +249,7 @@ async def create_system(
|
|||||||
system = await systems_svc.create_system(
|
system = await systems_svc.create_system(
|
||||||
uid, project_id=project_id, name=name,
|
uid, project_id=project_id, name=name,
|
||||||
description=description or None, color=color or None,
|
description=description or None, color=color or None,
|
||||||
canonical_id=applied,
|
canonical_id=applied, path_patterns=path_patterns,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
||||||
@@ -282,6 +294,9 @@ async def get_system(system_id: int) -> dict:
|
|||||||
system = await systems_svc.get_system(uid, system_id)
|
system = await systems_svc.get_system(uid, system_id)
|
||||||
if system is None:
|
if system is None:
|
||||||
raise ValueError(f"system {system_id} not found")
|
raise ValueError(f"system {system_id} not found")
|
||||||
|
# The pull half of the rulings arm's measurement (milestone 444): a
|
||||||
|
# System whose rulings were shown, then opened.
|
||||||
|
record_system_pulled(user_id=uid, system_id=system_id, source="mcp_get_system")
|
||||||
records = await systems_svc.list_records_for_system(uid, system_id)
|
records = await systems_svc.list_records_for_system(uid, system_id)
|
||||||
titles = await milestones_svc.titles_for({r.milestone_id for r in records})
|
titles = await milestones_svc.titles_for({r.milestone_id for r in records})
|
||||||
issues, tasks, notes = [], [], []
|
issues, tasks, notes = [], [], []
|
||||||
@@ -307,6 +322,7 @@ async def update_system(
|
|||||||
color: str = "",
|
color: str = "",
|
||||||
status: str = "",
|
status: str = "",
|
||||||
order_index: int = -1,
|
order_index: int = -1,
|
||||||
|
path_patterns: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a System. Only explicitly provided fields change.
|
"""Update a System. Only explicitly provided fields change.
|
||||||
|
|
||||||
@@ -319,10 +335,14 @@ async def update_system(
|
|||||||
history. If the charter above it contradicts a ruling, fix that sentence
|
history. If the charter above it contradicts a ruling, fix that sentence
|
||||||
in the same edit.
|
in the same edit.
|
||||||
|
|
||||||
|
`path_patterns` also REPLACES the whole list, and `[]` clears it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
status: 'active' or 'archived'. Archive a system to retire it without
|
status: 'active' or 'archived'. Archive a system to retire it without
|
||||||
losing history; archived systems hide from default lists.
|
losing history; archived systems hide from default lists.
|
||||||
order_index: display position (0-based); -1 = leave unchanged.
|
order_index: display position (0-based); -1 = leave unchanged.
|
||||||
|
path_patterns: The area's files as repo-relative globs (see
|
||||||
|
create_system). Omit to leave unchanged; [] clears them.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -336,6 +356,8 @@ async def update_system(
|
|||||||
fields["status"] = status
|
fields["status"] = status
|
||||||
if order_index >= 0:
|
if order_index >= 0:
|
||||||
fields["order_index"] = order_index
|
fields["order_index"] = order_index
|
||||||
|
if path_patterns is not None:
|
||||||
|
fields["path_patterns"] = path_patterns
|
||||||
system = await systems_svc.update_system(uid, system_id, **fields)
|
system = await systems_svc.update_system(uid, system_id, **fields)
|
||||||
if system is None:
|
if system is None:
|
||||||
raise ValueError(f"system {system_id} not found or no write access")
|
raise ValueError(f"system {system_id} not found or no write access")
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
|||||||
from scribe.models.retrieval_tuning import RetrievalTuningEvent # noqa: E402, F401
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent # noqa: E402, F401
|
||||||
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
||||||
from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401
|
from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401
|
||||||
|
from scribe.models.system_usage import SystemUsageEvent # noqa: E402, F401
|
||||||
from scribe.models.project import Project # noqa: E402, F401
|
from scribe.models.project import Project # noqa: E402, F401
|
||||||
from scribe.models.milestone import Milestone # noqa: E402, F401
|
from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||||
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from scribe.models import Base
|
from scribe.models import Base
|
||||||
@@ -38,6 +39,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
# active | archived — systems accumulate; archive rather than delete.
|
# active | archived — systems accumulate; archive rather than delete.
|
||||||
status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
|
status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
# The files that ARE this area, as globs relative to the repo root
|
||||||
|
# (milestone 444). The description says what the area is FOR; these say
|
||||||
|
# where it lives, so a command or edit touching those files can be
|
||||||
|
# resolved to the System — and its rulings — without a similarity search.
|
||||||
|
# NOT NULL with a `[]` default for the reason DesignToken.supersedes gives:
|
||||||
|
# a nullable list has two empties, and every reader must handle both.
|
||||||
|
path_patterns: Mapped[list] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_systems_project_id", "project_id"),
|
Index("ix_systems_project_id", "project_id"),
|
||||||
@@ -54,6 +64,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"color": self.color,
|
"color": self.color,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
|
"path_patterns": list(self.path_patterns or []),
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_at),
|
"updated_at": iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from sqlalchemy import BigInteger, Index, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from scribe.models import Base
|
||||||
|
from scribe.models.base import CreatedAtMixin, iso
|
||||||
|
|
||||||
|
SURFACED = "surfaced"
|
||||||
|
PULLED = "pulled"
|
||||||
|
|
||||||
|
|
||||||
|
class SystemUsageEvent(Base, CreatedAtMixin):
|
||||||
|
"""One row per time a System's rulings were SHOWN to the agent because its
|
||||||
|
files were touched, or the System was PULLED in full (milestone 444).
|
||||||
|
|
||||||
|
The third of the usage tables, after `note_usage_events` and
|
||||||
|
`rule_usage_events`, and separate from both for the reason the rule twin
|
||||||
|
gives: identity at restore. A System id is its own namespace, mapped
|
||||||
|
through the restore's system map; parked in either sibling's id column it
|
||||||
|
would come back attached to whatever note or rule took that number.
|
||||||
|
|
||||||
|
What it answers: whether an area's rulings, delivered by path rather than
|
||||||
|
by a ranker, are then read (`get_system`) — and so whether delivery by
|
||||||
|
path earns its line.
|
||||||
|
|
||||||
|
FK-free on `system_id`, `user_id` and `project_id`, like its siblings:
|
||||||
|
telemetry outlives the row it describes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "system_usage_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
system_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
# 'surfaced' | 'pulled'. Plain Text, no CHECK, like the siblings.
|
||||||
|
event: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Which surface produced it — a convention, not a vocabulary (see the
|
||||||
|
# note twin). `grep -rn record_system_ src/` is the authoritative list.
|
||||||
|
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# The project the READER was in, not the System's own.
|
||||||
|
project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_system_usage_system_event", "system_id", "event"),
|
||||||
|
Index("ix_system_usage_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"system_id": self.system_id,
|
||||||
|
"event": self.event,
|
||||||
|
"source": self.source,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
}
|
||||||
@@ -194,8 +194,18 @@ async def pre_tool_rules():
|
|||||||
different claims about the reader's
|
different claims about the reader's
|
||||||
context, so they get different lines
|
context, so they get different lines
|
||||||
(#4100).
|
(#4100).
|
||||||
|
root, cwd (opt) — the repo's absolute root and the command's
|
||||||
|
working directory. They turn the paths the
|
||||||
|
command names into repo-relative ones, for
|
||||||
|
the rulings arm (milestone 444).
|
||||||
|
seen_ruling_systems (opt) — comma-separated System ids whose rulings
|
||||||
|
were already shown this session, by either
|
||||||
|
arm; those get a one-line reference. SHARED
|
||||||
|
with /prior-art, like exclude_rule_ids.
|
||||||
|
|
||||||
Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419).
|
Returns `context`, `rule_ids`, and `checkpoint` (#4214, milestone 419),
|
||||||
|
plus `ruling_system_ids` — the Systems whose rulings were shown in full on
|
||||||
|
this call — when there were any.
|
||||||
|
|
||||||
`checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is
|
`checkpoint` IS THE ONE PART OF THIS RESPONSE THAT IS NOT A HINT. It is
|
||||||
empty on almost every call. When present it carries `rule_id`, `title`,
|
empty on almost every call. When present it carries `rule_id`, `title`,
|
||||||
@@ -221,6 +231,9 @@ async def pre_tool_rules():
|
|||||||
result = await plugin_ctx_svc.build_tool_rule_hint(
|
result = await plugin_ctx_svc.build_tool_rule_hint(
|
||||||
g.user.id, tool, command,
|
g.user.id, tool, command,
|
||||||
project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
project_id=project_id, exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
||||||
|
root=(request.args.get("root") or "").strip(),
|
||||||
|
cwd=(request.args.get("cwd") or "").strip(),
|
||||||
|
seen_ruling_systems=_int_list(request.args.get("seen_ruling_systems")),
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@@ -265,6 +278,10 @@ async def write_path_prior_art():
|
|||||||
or `canon:<snippet_id>`) already named this
|
or `canon:<snippet_id>`) already named this
|
||||||
session by the ledger arm (#2900); its own
|
session by the ledger arm (#2900); its own
|
||||||
channel, like the two above.
|
channel, like the two above.
|
||||||
|
seen_ruling_systems (opt) — System ids whose rulings were already shown
|
||||||
|
this session (milestone 444); shared with
|
||||||
|
/tool-rules. The response's `ruling_system_ids`
|
||||||
|
are the ones shown in full on this call.
|
||||||
(Returns a `checkpoint` block on the same contract as /tool-rules —
|
(Returns a `checkpoint` block on the same contract as /tool-rules —
|
||||||
see that endpoint. The write-path HOOK deliberately does not act on
|
see that endpoint. The write-path HOOK deliberately does not act on
|
||||||
it: scribe_prior_art.sh carries a tested property that it never
|
it: scribe_prior_art.sh carries a tested property that it never
|
||||||
@@ -306,6 +323,7 @@ async def write_path_prior_art():
|
|||||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||||
exclude_derive=exclude_derive,
|
exclude_derive=exclude_derive,
|
||||||
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
||||||
|
seen_ruling_systems=_int_list(request.args.get("seen_ruling_systems")),
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,16 @@ async def create_system_route(project_id: int):
|
|||||||
# Exact is mechanical and applied; overlap is a judgment call and is only
|
# Exact is mechanical and applied; overlap is a judgment call and is only
|
||||||
# offered back for the form to present.
|
# offered back for the form to present.
|
||||||
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||||
system = await systems_svc.create_system(
|
try:
|
||||||
uid, project_id=project_id, name=data["name"],
|
system = await systems_svc.create_system(
|
||||||
description=data.get("description"), color=data.get("color"),
|
uid, project_id=project_id, name=data["name"],
|
||||||
order_index=data.get("order_index", 0),
|
description=data.get("description"), color=data.get("color"),
|
||||||
canonical_id=data.get("canonical_id") or applied,
|
order_index=data.get("order_index", 0),
|
||||||
)
|
canonical_id=data.get("canonical_id") or applied,
|
||||||
|
path_patterns=data.get("path_patterns"),
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
if system is None:
|
if system is None:
|
||||||
return jsonify({"error": "Permission denied"}), 403
|
return jsonify({"error": "Permission denied"}), 403
|
||||||
out = system.to_dict()
|
out = system.to_dict()
|
||||||
@@ -125,11 +129,14 @@ async def update_system_route(project_id: int, system_id: int):
|
|||||||
if system is None or system.project_id != project_id:
|
if system is None or system.project_id != project_id:
|
||||||
return not_found("System")
|
return not_found("System")
|
||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
allowed = {"name", "description", "color", "status", "order_index"}
|
allowed = {"name", "description", "color", "status", "order_index", "path_patterns"}
|
||||||
fields = {k: v for k, v in data.items() if k in allowed}
|
fields = {k: v for k, v in data.items() if k in allowed}
|
||||||
if "status" in fields and fields["status"] not in ("active", "archived"):
|
if "status" in fields and fields["status"] not in ("active", "archived"):
|
||||||
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
|
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
|
||||||
updated = await systems_svc.update_system(uid, system_id, **fields)
|
try:
|
||||||
|
updated = await systems_svc.update_system(uid, system_id, **fields)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
if updated is None:
|
if updated is None:
|
||||||
return not_found("System")
|
return not_found("System")
|
||||||
return jsonify(updated.to_dict())
|
return jsonify(updated.to_dict())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from scribe.models.rule_version import RuleVersion
|
|||||||
from scribe.models.design_system import DesignSystem, DesignToken
|
from scribe.models.design_system import DesignSystem, DesignToken
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
from scribe.models.rule_usage import RuleUsageEvent
|
from scribe.models.rule_usage import RuleUsageEvent
|
||||||
|
from scribe.models.system_usage import SystemUsageEvent
|
||||||
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
||||||
from scribe.models.canonical_system import CanonicalSystem
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||||
@@ -88,8 +89,12 @@ logger = logging.getLogger(__name__)
|
|||||||
# v19 (2026-10) added lesson_no_rule (#4631): the "no rule fits" answer and its
|
# v19 (2026-10) added lesson_no_rule (#4631): the "no rule fits" answer and its
|
||||||
# reason. Without it a restored lesson that was judged to stand alone reads as
|
# reason. Without it a restored lesson that was judged to stand alone reads as
|
||||||
# never judged, and lands back on the unjudged list.
|
# never judged, and lands back on the unjudged list.
|
||||||
|
# v20 (2026-10) added systems.path_patterns and system_usage_events (milestone
|
||||||
|
# 444): the files that are each area, and whether an area's rulings were read
|
||||||
|
# once shown. The usage rows restore through the SYSTEM map, for the reason
|
||||||
|
# the rule twin restores through the rule map.
|
||||||
# Bump when the serialized schema changes.
|
# Bump when the serialized schema changes.
|
||||||
BACKUP_VERSION = 19
|
BACKUP_VERSION = 20
|
||||||
|
|
||||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||||
# below, these two lists must together account for the entire schema — which is
|
# below, these two lists must together account for the entire schema — which is
|
||||||
@@ -131,6 +136,9 @@ _BACKED_UP = [
|
|||||||
"lesson_rule_links",
|
"lesson_rule_links",
|
||||||
# v19 (2026-10): "no rule fits" answers (#4631).
|
# v19 (2026-10): "no rule fits" answers (#4631).
|
||||||
"lesson_no_rule",
|
"lesson_no_rule",
|
||||||
|
# v20 (2026-10): System usage telemetry (milestone 444), for the reason
|
||||||
|
# its note and rule twins travel.
|
||||||
|
"system_usage_events",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||||
@@ -231,6 +239,7 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|||||||
"note_usage_events": {"id"},
|
"note_usage_events": {"id"},
|
||||||
# Same as the note twin: the surrogate key is re-issued on insert.
|
# Same as the note twin: the surrogate key is re-issued on insert.
|
||||||
"rule_usage_events": {"id"},
|
"rule_usage_events": {"id"},
|
||||||
|
"system_usage_events": {"id"},
|
||||||
# Same again — and everything else travels, because each remaining column
|
# Same again — and everything else travels, because each remaining column
|
||||||
# is part of the argument: what moved, from what, to what, by whom, why.
|
# is part of the argument: what moved, from what, to what, by whom, why.
|
||||||
"retrieval_tuning_events": {"id"},
|
"retrieval_tuning_events": {"id"},
|
||||||
@@ -319,6 +328,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|||||||
"lesson_no_rule": set(),
|
"lesson_no_rule": set(),
|
||||||
"note_usage_events": {"id"},
|
"note_usage_events": {"id"},
|
||||||
"rule_usage_events": {"id"},
|
"rule_usage_events": {"id"},
|
||||||
|
"system_usage_events": {"id"},
|
||||||
"retrieval_tuning_events": {"id"},
|
"retrieval_tuning_events": {"id"},
|
||||||
"design_systems": {
|
"design_systems": {
|
||||||
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at",
|
||||||
@@ -387,6 +397,7 @@ def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
|
|||||||
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
||||||
"name": r.name, "description": r.description, "color": r.color,
|
"name": r.name, "description": r.description, "color": r.color,
|
||||||
"status": r.status, "order_index": r.order_index,
|
"status": r.status, "order_index": r.order_index,
|
||||||
|
"path_patterns": list(r.path_patterns or []),
|
||||||
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
|
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
@@ -443,6 +454,17 @@ def _usage_event_rows(rows) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_usage_event_rows(rows) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": r.user_id, "system_id": r.system_id, "event": r.event,
|
||||||
|
"source": r.source, "project_id": r.project_id,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_usage_event_rows(rows) -> list[dict]:
|
def _rule_usage_event_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -801,6 +823,9 @@ async def export_full_backup() -> dict:
|
|||||||
rule_usage_events = (
|
rule_usage_events = (
|
||||||
await session.execute(select(RuleUsageEvent))
|
await session.execute(select(RuleUsageEvent))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
system_usage_events = (
|
||||||
|
await session.execute(select(SystemUsageEvent))
|
||||||
|
).scalars().all()
|
||||||
# Oldest first, so a restored history reads in the order the dials
|
# Oldest first, so a restored history reads in the order the dials
|
||||||
# actually moved — the sequence IS the argument when a surface has been
|
# actually moved — the sequence IS the argument when a surface has been
|
||||||
# walked up and down.
|
# walked up and down.
|
||||||
@@ -853,6 +878,7 @@ async def export_full_backup() -> dict:
|
|||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||||
|
"system_usage_events": _system_usage_event_rows(system_usage_events),
|
||||||
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
||||||
retrieval_tuning_events
|
retrieval_tuning_events
|
||||||
),
|
),
|
||||||
@@ -990,6 +1016,12 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
rule_usage_events = (await session.execute(
|
rule_usage_events = (await session.execute(
|
||||||
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
||||||
)).scalars().all() if _rule_ids else []
|
)).scalars().all() if _rule_ids else []
|
||||||
|
# Scoped through the SYSTEM, for the reason the rule usage events
|
||||||
|
# above are scoped through the rule: `user_id` is who it fired for.
|
||||||
|
_system_ids = [sy.id for sy in systems]
|
||||||
|
system_usage_events = (await session.execute(
|
||||||
|
select(SystemUsageEvent).where(SystemUsageEvent.system_id.in_(_system_ids))
|
||||||
|
)).scalars().all() if _system_ids else []
|
||||||
# Scoped on user_id, and here that IS the right column — unlike the
|
# Scoped on user_id, and here that IS the right column — unlike the
|
||||||
# rule usage events directly above. These record changes to this user's
|
# rule usage events directly above. These record changes to this user's
|
||||||
# OWN retrieval settings, which is what `user_id` means on this table;
|
# OWN retrieval settings, which is what `user_id` means on this table;
|
||||||
@@ -1053,6 +1085,7 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||||
|
"system_usage_events": _system_usage_event_rows(system_usage_events),
|
||||||
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
"retrieval_tuning_events": _retrieval_tuning_event_rows(
|
||||||
retrieval_tuning_events
|
retrieval_tuning_events
|
||||||
),
|
),
|
||||||
@@ -1460,6 +1493,8 @@ def _build_system(row: dict, maps: _Maps) -> System | None:
|
|||||||
color=row.get("color"),
|
color=row.get("color"),
|
||||||
status=row.get("status", "active"),
|
status=row.get("status", "active"),
|
||||||
order_index=row.get("order_index", 0),
|
order_index=row.get("order_index", 0),
|
||||||
|
# Absent in a backup taken before milestone 444: no paths, not an error.
|
||||||
|
path_patterns=list(row.get("path_patterns") or []),
|
||||||
# An unknown slug restores UNMAPPED rather than failing: the System and
|
# An unknown slug restores UNMAPPED rather than failing: the System and
|
||||||
# its records are the payload, the mapping is an aid.
|
# its records are the payload, the mapping is an aid.
|
||||||
canonical_id=maps.canonical_by_slug.get(row.get("canonical_slug") or ""),
|
canonical_id=maps.canonical_by_slug.get(row.get("canonical_slug") or ""),
|
||||||
@@ -1563,6 +1598,22 @@ def _build_rule_usage_event(row: dict, maps: _Maps) -> RuleUsageEvent | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_usage_event(row: dict, maps: _Maps) -> SystemUsageEvent | None:
|
||||||
|
"""Resolved through the SYSTEM map — see `_build_rule_usage_event`."""
|
||||||
|
sid = maps.systems.get(row.get("system_id", 0))
|
||||||
|
if sid is None:
|
||||||
|
return None
|
||||||
|
return SystemUsageEvent(
|
||||||
|
user_id=maps.users.get(row.get("user_id") or 0),
|
||||||
|
system_id=sid,
|
||||||
|
event=row.get("event", ""),
|
||||||
|
source=row.get("source", ""),
|
||||||
|
project_id=(maps.projects.get(row["project_id"])
|
||||||
|
if row.get("project_id") else None),
|
||||||
|
created_at=_dt(row.get("created_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_repo_binding(row: dict, maps: _Maps) -> RepoBinding | None:
|
def _build_repo_binding(row: dict, maps: _Maps) -> RepoBinding | None:
|
||||||
"""Small, but losing these means every bound repo quietly stops loading
|
"""Small, but losing these means every bound repo quietly stops loading
|
||||||
its project at session start."""
|
its project at session start."""
|
||||||
@@ -1813,6 +1864,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||||
|
"system_usage_events": 0,
|
||||||
"repo_bindings": 0,
|
"repo_bindings": 0,
|
||||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||||
"code_shape_uses": 0, "canonical_systems": 0,
|
"code_shape_uses": 0, "canonical_systems": 0,
|
||||||
@@ -2102,6 +2154,14 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
session.add(event)
|
session.add(event)
|
||||||
stats["rule_usage_events"] += 1
|
stats["rule_usage_events"] += 1
|
||||||
|
|
||||||
|
# And the System twin, after the Systems (step 16 fills their map).
|
||||||
|
for ev in data.get("system_usage_events", []):
|
||||||
|
event = _build_system_usage_event(ev, maps)
|
||||||
|
if event is None:
|
||||||
|
continue
|
||||||
|
session.add(event)
|
||||||
|
stats["system_usage_events"] += 1
|
||||||
|
|
||||||
# 20. Repo bindings
|
# 20. Repo bindings
|
||||||
for rb_data in data.get("repo_bindings", []):
|
for rb_data in data.get("repo_bindings", []):
|
||||||
binding = _build_repo_binding(rb_data, maps)
|
binding = _build_repo_binding(rb_data, maps)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from scribe.services.retrieval_telemetry import record_retrieval
|
|||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
from scribe.services.systems import system_names_for
|
from scribe.services.systems import system_names_for
|
||||||
from scribe.services.text import elide
|
from scribe.services.text import elide
|
||||||
|
from scribe.services import system_rulings as system_rulings_svc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -2187,6 +2188,7 @@ async def build_write_path_hint(
|
|||||||
exclude_derive: list[str] | None = None,
|
exclude_derive: list[str] | None = None,
|
||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
held_rule_ids: list[int] | None = None,
|
held_rule_ids: list[int] | None = None,
|
||||||
|
seen_ruling_systems: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||||
|
|
||||||
@@ -2253,7 +2255,7 @@ async def build_write_path_hint(
|
|||||||
cfg = await get_writepath_config(user_id)
|
cfg = await get_writepath_config(user_id)
|
||||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||||
"suggested": [], "divergence": [], "derive": [], "derive_keys": [],
|
"suggested": [], "divergence": [], "derive": [], "derive_keys": [],
|
||||||
"rule_ids": []}
|
"rule_ids": [], "ruling_system_ids": []}
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
if not cfg["enabled"] or not path:
|
if not cfg["enabled"] or not path:
|
||||||
return empty
|
return empty
|
||||||
@@ -2590,9 +2592,21 @@ async def build_write_path_hint(
|
|||||||
design_text, design_dedup = await _design_arm(
|
design_text, design_dedup = await _design_arm(
|
||||||
user_id, project_id, path, set(exclude_derive or []),
|
user_id, project_id, path, set(exclude_derive or []),
|
||||||
)
|
)
|
||||||
|
# The rulings arm (milestone 444), decided here for the design arm's
|
||||||
|
# reasons: a lookup by path, so a write that matched no prior art still
|
||||||
|
# carries its area's rulings, and it never switches the ranked arms on.
|
||||||
|
rulings = await system_rulings_svc.rulings_for_paths(
|
||||||
|
user_id, project_id, [path], seen=seen_ruling_systems,
|
||||||
|
source="rulings_write_path",
|
||||||
|
)
|
||||||
if not staleness and not synced and not menu and not suggested and not divergence and not derive:
|
if not staleness and not synced and not menu and not suggested and not divergence and not derive:
|
||||||
if design_text:
|
if design_text or rulings["lines"]:
|
||||||
return {**empty, "context": design_text, "derive_keys": [design_dedup]}
|
return {
|
||||||
|
**empty,
|
||||||
|
"context": "\n".join(rulings["lines"] + ([design_text] if design_text else [])),
|
||||||
|
"derive_keys": [design_dedup] if design_dedup else [],
|
||||||
|
"ruling_system_ids": rulings["system_ids"],
|
||||||
|
}
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
owners = await owner_names_for({
|
||||||
@@ -2614,7 +2628,9 @@ async def build_write_path_hint(
|
|||||||
# Seeded with the staleness line, which is decided above the early
|
# Seeded with the staleness line, which is decided above the early
|
||||||
# return and so cannot wait for this list to exist.
|
# return and so cannot wait for this list to exist.
|
||||||
lines: list[str] = list(staleness)
|
lines: list[str] = list(staleness)
|
||||||
# First after staleness: it BINDS, where everything below is prior art.
|
# First after staleness: rulings and the design system BIND, where
|
||||||
|
# everything below is prior art.
|
||||||
|
lines.extend(rulings["lines"])
|
||||||
if design_text:
|
if design_text:
|
||||||
lines.append(design_text)
|
lines.append(design_text)
|
||||||
sync_note_ids: list[int] = []
|
sync_note_ids: list[int] = []
|
||||||
@@ -2890,6 +2906,7 @@ async def build_write_path_hint(
|
|||||||
),
|
),
|
||||||
"rule_ids": rule_ids,
|
"rule_ids": rule_ids,
|
||||||
"checkpoint": checkpoint,
|
"checkpoint": checkpoint,
|
||||||
|
"ruling_system_ids": rulings["system_ids"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2901,18 +2918,46 @@ async def build_tool_rule_hint(
|
|||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
held_rule_ids: list[int] | None = None,
|
held_rule_ids: list[int] | None = None,
|
||||||
|
root: str = "",
|
||||||
|
cwd: str = "",
|
||||||
|
seen_ruling_systems: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Standing rules that may apply to the action about to be taken — matched
|
"""Standing rules that may apply to the action about to be taken — matched
|
||||||
directly (`_tool_rule_hint`, where the design is written), then reached
|
directly (`_tool_rule_hint`, where the design is written), then reached
|
||||||
through a linked lesson (`_rules_via_lessons`, #4633)."""
|
through a linked lesson (`_rules_via_lessons`, #4633) — and the rulings of
|
||||||
|
any area whose files the command names (milestone 444).
|
||||||
|
|
||||||
|
`root` and `cwd` are the repo's absolute root and the command's working
|
||||||
|
directory, from the hook; they are what turn the paths a command names
|
||||||
|
into the repo-relative paths a System's patterns are written in."""
|
||||||
out = await _tool_rule_hint(
|
out = await _tool_rule_hint(
|
||||||
user_id, tool_name, command, project_id=project_id,
|
user_id, tool_name, command, project_id=project_id,
|
||||||
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
|
||||||
)
|
)
|
||||||
return await _add_rules_via_lessons(
|
out = await _add_rules_via_lessons(
|
||||||
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
|
||||||
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
|
held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
|
||||||
)
|
)
|
||||||
|
# Its own arm, not part of the rule search: a lookup by path, with no
|
||||||
|
# floor and no budget, so it adds to the rule lines rather than competing
|
||||||
|
# with them. First, because a ruling is the operator's own decision.
|
||||||
|
# `ruling_system_ids` is set only when a ruling was shown: this arm fires
|
||||||
|
# on every command, and the hook reads an absent list as an empty one.
|
||||||
|
try:
|
||||||
|
paths = system_rulings_svc.command_paths(command, root=root, cwd=cwd) if project_id else []
|
||||||
|
if paths and (await get_writepath_config(user_id)).get("enabled"):
|
||||||
|
rulings = await system_rulings_svc.rulings_for_paths(
|
||||||
|
user_id, project_id, paths,
|
||||||
|
seen=seen_ruling_systems, source="rulings_pre_tool",
|
||||||
|
)
|
||||||
|
if rulings["lines"]:
|
||||||
|
out["context"] = "\n".join(
|
||||||
|
rulings["lines"] + ([out["context"]] if out.get("context") else [])
|
||||||
|
)
|
||||||
|
out["ruling_system_ids"] = rulings["system_ids"]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("pre-tool rulings arm failed", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _tool_rule_hint(
|
async def _tool_rule_hint(
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""An area's rulings, delivered when its files are touched (milestone 444).
|
||||||
|
|
||||||
|
A RULING is the operator's decision about how one area of the work must behave.
|
||||||
|
It lives in a `Rulings` section at the end of the area's System description
|
||||||
|
(writing-records.md says how one is written). The description already rides
|
||||||
|
every record filed under the System; this module is the other half: the first
|
||||||
|
command or edit in a session that touches the System's files
|
||||||
|
(`System.path_patterns`) shows its rulings, one line per System.
|
||||||
|
|
||||||
|
A LOOKUP, NOT A SEARCH. A shell command scores against prose decisions at noise
|
||||||
|
level, so nothing here is ranked: a path either falls under a System's
|
||||||
|
patterns or it does not. It takes no budget and no floor from the retrieval
|
||||||
|
arms beside it, and writes no retrieval_logs row — there is no score
|
||||||
|
distribution for it to join. Its surfacings go to `system_usage_events`.
|
||||||
|
|
||||||
|
ONCE IN FULL, THEN A REFERENCE (the #3750 shape). The hook keeps the Systems
|
||||||
|
already shown this session and passes them back; a repeat renders as one short
|
||||||
|
line rather than the rulings again, and is not counted as a surfacing.
|
||||||
|
|
||||||
|
Fails open everywhere: a delivery aid must never break the act it rides on.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
from scribe.services.system_usage import record_system_surfaced
|
||||||
|
from scribe.services.text import elide
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The section heading: `Rulings`, optionally as a markdown heading, bold, or
|
||||||
|
# with a trailing colon. The LAST one wins — the section sits at the end.
|
||||||
|
_HEADING = re.compile(r"^[ \t]*(?:#{1,6}[ \t]*)?\**Rulings\**[ \t]*:?[ \t]*$", re.I | re.M)
|
||||||
|
_BULLET = re.compile(r"^[ \t]*[-*][ \t]+(.*)$")
|
||||||
|
|
||||||
|
# Per line, so one System with a long list cannot crowd the others out.
|
||||||
|
RULING_CHARS = 300
|
||||||
|
RULINGS_PER_SYSTEM = 8
|
||||||
|
|
||||||
|
# A command is not a file list: these bound how much of it is read as paths.
|
||||||
|
_MAX_PATHS = 40
|
||||||
|
_LINE_SUFFIX = re.compile(r":\d+(?::\d+)?$")
|
||||||
|
_HAS_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,10}$")
|
||||||
|
_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rulings(description: str | None) -> list[str]:
|
||||||
|
"""The rulings in a System description, one string per ruling.
|
||||||
|
|
||||||
|
A bullet under the last `Rulings` heading is a ruling; an indented line
|
||||||
|
after one continues it. The section ends at the first line that is
|
||||||
|
neither — a heading or paragraph written after it is not a ruling.
|
||||||
|
"""
|
||||||
|
text = description or ""
|
||||||
|
found = list(_HEADING.finditer(text))
|
||||||
|
if not found:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for line in text[found[-1].end():].splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
bullet = _BULLET.match(line)
|
||||||
|
if bullet:
|
||||||
|
if bullet.group(1).strip():
|
||||||
|
out.append(bullet.group(1).strip())
|
||||||
|
elif out and line[:1] in (" ", "\t"):
|
||||||
|
out[-1] = f"{out[-1]} {line.strip()}"
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(token: str, root: str, cwd_rel: str) -> str:
|
||||||
|
"""`token` as a repo-relative path, or "" when it is not one."""
|
||||||
|
if token.startswith("/"):
|
||||||
|
if not root or not token.startswith(root.rstrip("/") + "/"):
|
||||||
|
return ""
|
||||||
|
token = token[len(root.rstrip("/")) + 1:]
|
||||||
|
elif cwd_rel:
|
||||||
|
token = f"{cwd_rel}/{token}"
|
||||||
|
path = posixpath.normpath(token)
|
||||||
|
if path in (".", "") or path == ".." or path.startswith("../"):
|
||||||
|
return ""
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def command_paths(command: str, *, root: str = "", cwd: str = "") -> list[str]:
|
||||||
|
"""The repo-relative paths a shell command names, in order, deduplicated.
|
||||||
|
|
||||||
|
Reads and writes alike: a session forms its picture of an area by reading
|
||||||
|
it, which is exactly when that area's rulings should reach it. A token is
|
||||||
|
taken as a path when it has a `/` or a file extension; flags, URLs and
|
||||||
|
globs are not paths. `root` is the repo's absolute root and `cwd` the
|
||||||
|
command's working directory, both from the hook — an absolute path
|
||||||
|
outside the root, or a relative one that climbs out of it, is dropped.
|
||||||
|
"""
|
||||||
|
command = command or ""
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command, comments=False, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
tokens = command.split()
|
||||||
|
root = (root or "").rstrip("/")
|
||||||
|
cwd_rel = ""
|
||||||
|
if root and cwd:
|
||||||
|
cwd = cwd.rstrip("/")
|
||||||
|
if cwd.startswith(root + "/"):
|
||||||
|
cwd_rel = cwd[len(root) + 1:]
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in tokens:
|
||||||
|
token = raw.strip().strip("'\"").rstrip(";,)|&")
|
||||||
|
if token.startswith("-"):
|
||||||
|
if "=" not in token:
|
||||||
|
continue
|
||||||
|
token = token.split("=", 1)[1]
|
||||||
|
elif _ASSIGNMENT.match(token):
|
||||||
|
token = token.split("=", 1)[1]
|
||||||
|
if not token or "://" in token or any(c in token for c in "*?[]{}$`<>"):
|
||||||
|
continue
|
||||||
|
# `file.py:120` and a test id `file.py::test_x` name the file.
|
||||||
|
token = _LINE_SUFFIX.sub("", token.split("::", 1)[0])
|
||||||
|
if "/" not in token and not _HAS_EXTENSION.search(token):
|
||||||
|
continue
|
||||||
|
path = _relative(token, root, cwd_rel)
|
||||||
|
if path and path not in out:
|
||||||
|
out.append(path)
|
||||||
|
if len(out) >= _MAX_PATHS:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _full_line(system, path: str, rulings: list[str]) -> str:
|
||||||
|
shown = [elide(r, RULING_CHARS)[0] for r in rulings[:RULINGS_PER_SYSTEM]]
|
||||||
|
more = len(rulings) - len(shown)
|
||||||
|
listed = " ".join(f"({i}) {r}" for i, r in enumerate(shown, 1))
|
||||||
|
if more > 0:
|
||||||
|
listed += f" (+{more} more in `get_system({system.id})`)"
|
||||||
|
return (
|
||||||
|
f"> Rulings for `{path}` — the operator's decisions about "
|
||||||
|
f"{system.name} (System {system.id}): {listed} "
|
||||||
|
"Work here keeps to them; something that would depart from one is a "
|
||||||
|
"question for the operator, not an option to choose. "
|
||||||
|
"(Shown once per session.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_line(system, path: str) -> str:
|
||||||
|
return (
|
||||||
|
f"> `{path}` is in {system.name} (System {system.id}) — its rulings "
|
||||||
|
"were shown earlier this session and still apply."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def rulings_for_paths(
|
||||||
|
user_id: int,
|
||||||
|
project_id: int,
|
||||||
|
paths: list[str],
|
||||||
|
*,
|
||||||
|
seen: list[int] | set[int] | None = None,
|
||||||
|
source: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Rulings lines for the Systems whose files `paths` touch.
|
||||||
|
|
||||||
|
Returns {"lines": [...], "system_ids": [...]} — `system_ids` are the
|
||||||
|
Systems shown IN FULL on this call, for the hook to add to the session's
|
||||||
|
seen list and for the usage ledger; a System in `seen` gets a reference
|
||||||
|
line and is in neither. A System with patterns but no rulings says
|
||||||
|
nothing: there is no decision to deliver, and the charter already rides
|
||||||
|
its records.
|
||||||
|
"""
|
||||||
|
out: dict = {"lines": [], "system_ids": []}
|
||||||
|
if not project_id or not paths:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
already = {int(s) for s in (seen or [])}
|
||||||
|
for system, hit in await systems_svc.systems_for_paths(user_id, project_id, paths):
|
||||||
|
rulings = parse_rulings(system.description)
|
||||||
|
if not rulings:
|
||||||
|
continue
|
||||||
|
if system.id in already:
|
||||||
|
out["lines"].append(_reference_line(system, hit[0]))
|
||||||
|
else:
|
||||||
|
out["lines"].append(_full_line(system, hit[0], rulings))
|
||||||
|
out["system_ids"].append(system.id)
|
||||||
|
if out["system_ids"]:
|
||||||
|
record_system_surfaced(
|
||||||
|
user_id=user_id, system_ids=out["system_ids"], source=source,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("rulings arm failed", exc_info=True)
|
||||||
|
return {"lines": [], "system_ids": []}
|
||||||
|
return out
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""System usage telemetry — were an area's rulings read once shown?
|
||||||
|
|
||||||
|
The twin of `note_usage` and `rule_usage` for Systems (milestone 444). The
|
||||||
|
rulings arm shows a System's rulings when a command or edit touches the
|
||||||
|
System's files; a pull is somebody then opening the System (`get_system`). The
|
||||||
|
ratio says whether delivering rulings by path earns its line.
|
||||||
|
|
||||||
|
Fire-and-forget like its siblings: telemetry never adds latency to, or
|
||||||
|
breaks, the surface it observes, and failures report through the shared
|
||||||
|
canary rather than vanishing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.system_usage import PULLED, SURFACED, SystemUsageEvent
|
||||||
|
from scribe.services.background import report_telemetry_failure, spawn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_events(rows: list[dict]) -> None:
|
||||||
|
"""Persist usage rows. Best-effort: failures degrade, visibly."""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
session.add_all([SystemUsageEvent(**row) for row in rows])
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await report_telemetry_failure("system_usage", "write")
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(user_id, system_ids, event: str, source: str, project_id) -> list[dict]:
|
||||||
|
pid = int(project_id or 0) or None
|
||||||
|
return [
|
||||||
|
{"user_id": user_id, "system_id": int(sid), "event": event,
|
||||||
|
"source": source, "project_id": pid}
|
||||||
|
for sid in system_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def record_system_surfaced(
|
||||||
|
*, user_id: int | None, system_ids: list[int], source: str,
|
||||||
|
project_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fire-and-forget: these Systems' rulings were shown in full. A repeat
|
||||||
|
rendered as a short reference is not a surfacing and is not recorded."""
|
||||||
|
try:
|
||||||
|
rows = _rows(user_id, system_ids, SURFACED, source, project_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("system usage payload build failed", exc_info=True)
|
||||||
|
return
|
||||||
|
if rows:
|
||||||
|
spawn(_insert_events(rows), site="system_usage_write")
|
||||||
|
|
||||||
|
|
||||||
|
def record_system_pulled(
|
||||||
|
*, user_id: int | None, system_id: int, source: str,
|
||||||
|
project_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fire-and-forget: a System was opened in full."""
|
||||||
|
try:
|
||||||
|
rows = _rows(user_id, [system_id], PULLED, source, project_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("system usage payload build failed", exc_info=True)
|
||||||
|
return
|
||||||
|
spawn(_insert_events(rows), site="system_usage_write")
|
||||||
@@ -7,6 +7,7 @@ many-to-many through record_systems, mutable over time.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
@@ -31,6 +32,87 @@ def local_name_key(name: str) -> str:
|
|||||||
return " ".join(name.split()).lower()
|
return " ".join(name.split()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# Bounds on what one System may claim. Generous for a real area — a handful of
|
||||||
|
# directories and the odd stray file — and small enough that a pasted file
|
||||||
|
# listing is refused rather than stored as the area's definition.
|
||||||
|
MAX_PATH_PATTERNS = 50
|
||||||
|
MAX_PATH_PATTERN_LENGTH = 300
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_repo_path(path: str) -> str:
|
||||||
|
"""A path as the patterns see it: relative to the repo root, `/`-separated.
|
||||||
|
|
||||||
|
Shared by the patterns and the paths matched against them, so the two can
|
||||||
|
never disagree about whether `./src/x.py` and `src/x.py` are one file.
|
||||||
|
"""
|
||||||
|
out = (path or "").strip().replace("\\", "/")
|
||||||
|
while out.startswith("./"):
|
||||||
|
out = out[2:]
|
||||||
|
out = out.lstrip("/")
|
||||||
|
while len(out) > 1 and out.endswith("/"):
|
||||||
|
out = out[:-1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_patterns(patterns) -> list[str]:
|
||||||
|
"""Validate and tidy a System's path patterns; ValueError says what is wrong.
|
||||||
|
|
||||||
|
Blank entries are dropped and repeats collapse, keeping the first-written
|
||||||
|
order. A pattern that climbs out of the repo (`..`) is refused rather than
|
||||||
|
dropped: it can never match, and storing it would read as coverage.
|
||||||
|
"""
|
||||||
|
if patterns is None:
|
||||||
|
return []
|
||||||
|
if isinstance(patterns, str) or not isinstance(patterns, (list, tuple)):
|
||||||
|
raise ValueError("path_patterns must be a list of glob strings")
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in patterns:
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise ValueError("path_patterns must be a list of glob strings")
|
||||||
|
pattern = normalize_repo_path(raw)
|
||||||
|
if not pattern:
|
||||||
|
continue
|
||||||
|
if len(pattern) > MAX_PATH_PATTERN_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"path pattern longer than {MAX_PATH_PATTERN_LENGTH} characters: "
|
||||||
|
f"{pattern[:60]}…"
|
||||||
|
)
|
||||||
|
if ".." in pattern.split("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"path pattern {pattern!r} leaves the repo — patterns are "
|
||||||
|
"relative to the repo root"
|
||||||
|
)
|
||||||
|
if pattern not in out:
|
||||||
|
out.append(pattern)
|
||||||
|
if len(out) > MAX_PATH_PATTERNS:
|
||||||
|
raise ValueError(
|
||||||
|
f"{len(out)} path patterns; a System takes at most "
|
||||||
|
f"{MAX_PATH_PATTERNS} — name directories with `**` rather than "
|
||||||
|
"listing their files"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def path_matches(pattern: str, path: str) -> bool:
|
||||||
|
"""Whether a repo-relative path falls under one pattern.
|
||||||
|
|
||||||
|
Glob semantics are `PurePosixPath.full_match`: `*` stays inside one path
|
||||||
|
segment, `**` spans any number of them, and case counts. A pattern with
|
||||||
|
no wildcard that names a directory covers everything under it, so
|
||||||
|
`src/billing` means the directory the way a person writing it means it.
|
||||||
|
"""
|
||||||
|
path = normalize_repo_path(path)
|
||||||
|
if not path or not pattern:
|
||||||
|
return False
|
||||||
|
candidate = PurePosixPath(path)
|
||||||
|
return candidate.full_match(pattern) or candidate.full_match(f"{pattern}/**")
|
||||||
|
|
||||||
|
|
||||||
|
def matching_patterns(patterns, path: str) -> list[str]:
|
||||||
|
"""The patterns among `patterns` that `path` falls under, in their order."""
|
||||||
|
return [p for p in (patterns or []) if path_matches(p, path)]
|
||||||
|
|
||||||
|
|
||||||
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
||||||
"""What BOTH doors must know before minting a System name (milestone 307).
|
"""What BOTH doors must know before minting a System name (milestone 307).
|
||||||
|
|
||||||
@@ -154,13 +236,18 @@ async def create_system(
|
|||||||
color: str | None = None,
|
color: str | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
canonical_id: int | None = None,
|
canonical_id: int | None = None,
|
||||||
|
path_patterns: list[str] | None = None,
|
||||||
) -> System | None:
|
) -> System | None:
|
||||||
"""Create a System. None if the user can't write the project.
|
"""Create a System. None if the user can't write the project.
|
||||||
|
|
||||||
`canonical_id` maps the new System onto the global catalog; leaving it None
|
`canonical_id` maps the new System onto the global catalog; leaving it None
|
||||||
is fine — an unmapped System is fully usable, and the mapping can be
|
is fine — an unmapped System is fully usable, and the mapping can be
|
||||||
proposed later (services/canonical_systems.propose_mappings).
|
proposed later (services/canonical_systems.propose_mappings).
|
||||||
|
|
||||||
|
`path_patterns` are validated here, so every door refuses the same bad
|
||||||
|
pattern with the same message (ValueError).
|
||||||
"""
|
"""
|
||||||
|
patterns = normalize_path_patterns(path_patterns)
|
||||||
if not await access.can_write_project(user_id, project_id):
|
if not await access.can_write_project(user_id, project_id):
|
||||||
return None
|
return None
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -172,6 +259,7 @@ async def create_system(
|
|||||||
color=color,
|
color=color,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
canonical_id=canonical_id,
|
canonical_id=canonical_id,
|
||||||
|
path_patterns=patterns,
|
||||||
)
|
)
|
||||||
session.add(system)
|
session.add(system)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -209,13 +297,40 @@ async def list_systems(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def systems_for_paths(
|
||||||
|
user_id: int, project_id: int, paths: list[str],
|
||||||
|
) -> list[tuple[System, list[str]]]:
|
||||||
|
"""The project's active Systems whose patterns cover any of `paths`.
|
||||||
|
|
||||||
|
Each comes with the paths it matched, in the order given. A path can match
|
||||||
|
several Systems — areas overlap, and every one it belongs to answers for
|
||||||
|
it — so nothing here picks a winner. Systems with no patterns never match:
|
||||||
|
an area that has not named its files is not claiming all of them.
|
||||||
|
"""
|
||||||
|
wanted = [p for p in (normalize_repo_path(x) for x in paths or []) if p]
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
out: list[tuple[System, list[str]]] = []
|
||||||
|
for system in await list_systems(user_id, project_id):
|
||||||
|
patterns = system.path_patterns or []
|
||||||
|
if not patterns:
|
||||||
|
continue
|
||||||
|
hit = [p for p in wanted if matching_patterns(patterns, p)]
|
||||||
|
if hit:
|
||||||
|
out.append((system, hit))
|
||||||
|
return out
|
||||||
|
|
||||||
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
||||||
"""Update a System if the user can write its project."""
|
"""Update a System if the user can write its project."""
|
||||||
# canonical_id is deliberately NOT settable here: canonical_systems.
|
# canonical_id is deliberately NOT settable here: canonical_systems.
|
||||||
# set_system_canonical is its single writer, because it also validates the
|
# set_system_canonical is its single writer, because it also validates the
|
||||||
# catalog entry is live. Two entry points onto one column is the drift this
|
# catalog entry is live. Two entry points onto one column is the drift this
|
||||||
# table exists to end.
|
# table exists to end.
|
||||||
allowed = {"name", "description", "color", "status", "order_index"}
|
allowed = {"name", "description", "color", "status", "order_index", "path_patterns"}
|
||||||
|
# Validated before anything is read, so a refused pattern changes nothing.
|
||||||
|
# `[]` is a value here, not "leave unchanged": it clears the paths.
|
||||||
|
if fields.get("path_patterns") is not None:
|
||||||
|
fields["path_patterns"] = normalize_path_patterns(fields["path_patterns"])
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
system = await session.get(System, system_id)
|
system = await session.get(System, system_id)
|
||||||
if system is None or system.deleted_at is not None:
|
if system is None or system.deleted_at is not None:
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ def _no_system_labels():
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_rulings_arm():
|
||||||
|
"""Stub the rulings arm both PreToolUse builders run (milestone 444).
|
||||||
|
|
||||||
|
Autouse for _no_system_labels' reason: every write-path and tool-rule
|
||||||
|
test with a project in scope reaches it, and its first act is reading the
|
||||||
|
project's Systems from Postgres. Stubbed to "no area's files touched".
|
||||||
|
tests/test_system_rulings.py binds the real function at import time,
|
||||||
|
before this patch runs.
|
||||||
|
"""
|
||||||
|
with patch("scribe.services.system_rulings.rulings_for_paths",
|
||||||
|
AsyncMock(return_value={"lines": [], "system_ids": []})):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _no_task_log_arm():
|
def _no_task_log_arm():
|
||||||
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
|
"""Stub the task-log read arm that get_task / list_tasks / get_milestone
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ TOPICS: tuple[Topic, ...] = (
|
|||||||
Topic("code says what a thing does, not what was wanted", U,
|
Topic("code says what a thing does, not what was wanted", U,
|
||||||
("rulings", "unconfirmed"),
|
("rulings", "unconfirmed"),
|
||||||
"code tells you what a thing does, not what was wanted"),
|
"code tells you what a thing does, not what was wanted"),
|
||||||
|
Topic("a System names its files, and tagging work keeps them current", U,
|
||||||
|
("path_patterns", "update_system"),
|
||||||
|
"which files are which area is your call"),
|
||||||
# ── process arcs — owned by their skills ──
|
# ── process arcs — owned by their skills ──
|
||||||
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"),
|
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"),
|
||||||
"a milestone earns its place when the work has an arc", index=("start_planning",)),
|
"a milestone earns its place when the work has an arc", index=("start_planning",)),
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ async def test_get_system_splits_records_by_kind():
|
|||||||
assert [r["id"] for r in result["notes"]] == [12]
|
assert [r["id"] for r in result["notes"]] == [12]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_path_patterns_reach_the_service_from_both_tools():
|
||||||
|
"""Omitted means unchanged; an empty list is a value — it clears them."""
|
||||||
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||||
|
patch("scribe.mcp.tools.systems.systems_svc") as svc:
|
||||||
|
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
|
||||||
|
svc.create_system = AsyncMock(return_value=fake_system(name="Billing"))
|
||||||
|
svc.update_system = AsyncMock(return_value=fake_system(name="Billing"))
|
||||||
|
from scribe.mcp.tools.systems import create_system, update_system
|
||||||
|
await create_system(project_id=5, name="Billing", path_patterns=["src/billing"])
|
||||||
|
assert svc.create_system.await_args.kwargs["path_patterns"] == ["src/billing"]
|
||||||
|
await update_system(system_id=1, name="Billing")
|
||||||
|
assert "path_patterns" not in svc.update_system.await_args.kwargs
|
||||||
|
await update_system(system_id=1, path_patterns=[])
|
||||||
|
assert svc.update_system.await_args.kwargs["path_patterns"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_system_not_found_raises():
|
async def test_update_system_not_found_raises():
|
||||||
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
|
||||||
|
|||||||
@@ -744,7 +744,9 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
|
|||||||
handler = route.split("async def pre_tool_rules")[1].split("\n@plugin_bp")[0]
|
handler = route.split("async def pre_tool_rules")[1].split("\n@plugin_bp")[0]
|
||||||
|
|
||||||
sent = set(re.findall(r"[?&]([a-z_]+)=", hook))
|
sent = set(re.findall(r"[?&]([a-z_]+)=", hook))
|
||||||
assert sent == {"tool", "command", "exclude_rule_ids"}, sent
|
# `root` and `cwd` (milestone 444): where the command runs, so the paths it
|
||||||
|
# names can be made repo-relative for the rulings arm.
|
||||||
|
assert sent == {"tool", "command", "exclude_rule_ids", "root", "cwd"}, sent
|
||||||
|
|
||||||
# The project-scope key is the shared helper's to choose (#4085): the hook
|
# The project-scope key is the shared helper's to choose (#4085): the hook
|
||||||
# splices in `scribe_scope_query`'s output, which is `repo=` inside a git
|
# splices in `scribe_scope_query`'s output, which is `repo=` inside a git
|
||||||
@@ -763,6 +765,12 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
|
|||||||
assert "printf '&held_rule_ids=" in defs, (
|
assert "printf '&held_rule_ids=" in defs, (
|
||||||
"the opened ledger's query key is no longer spelled in the helper"
|
"the opened ledger's query key is no longer spelled in the helper"
|
||||||
)
|
)
|
||||||
|
# The rulings ledger, shared with the write-path hook (milestone 444) —
|
||||||
|
# spelled once in its helper for the same reason.
|
||||||
|
assert "scribe_rulings_query" in hook, "hook no longer sends the rulings ledger"
|
||||||
|
assert "printf '&seen_ruling_systems=" in defs, (
|
||||||
|
"the rulings ledger's query key is no longer spelled in the helper"
|
||||||
|
)
|
||||||
|
|
||||||
# Both scope keys are read by the shared _project_scope() helper, not inline.
|
# Both scope keys are read by the shared _project_scope() helper, not inline.
|
||||||
assert "_project_scope()" in handler
|
assert "_project_scope()" in handler
|
||||||
@@ -776,7 +784,7 @@ def test_the_hook_and_the_route_agree_on_every_parameter_name():
|
|||||||
assert 'request.args.get("held_rule_ids")' in handler, (
|
assert 'request.args.get("held_rule_ids")' in handler, (
|
||||||
"the hook sends held_rule_ids and the route never reads it"
|
"the hook sends held_rule_ids and the route never reads it"
|
||||||
)
|
)
|
||||||
for arg in ("tool", "command", "exclude_rule_ids"):
|
for arg in ("tool", "command", "exclude_rule_ids", "root", "cwd", "seen_ruling_systems"):
|
||||||
assert f'request.args.get("{arg}")' in handler, (
|
assert f'request.args.get("{arg}")' in handler, (
|
||||||
f"the hook sends {arg!r} and the route never reads it"
|
f"the hook sends {arg!r} and the route never reads it"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
|
|||||||
|
|
||||||
(Named for the number it asserted until v10, which is exactly the drift a
|
(Named for the number it asserted until v10, which is exactly the drift a
|
||||||
name-carrying-a-value invites; it now says what it checks.)"""
|
name-carrying-a-value invites; it now says what it checks.)"""
|
||||||
assert backup.BACKUP_VERSION == 19
|
assert backup.BACKUP_VERSION == 20
|
||||||
|
|
||||||
|
|
||||||
def _exportable_note(**over):
|
def _exportable_note(**over):
|
||||||
@@ -139,6 +139,7 @@ def _column_guard_targets():
|
|||||||
from scribe.models.note_supersession import NoteSupersession
|
from scribe.models.note_supersession import NoteSupersession
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
from scribe.models.rule_usage import RuleUsageEvent
|
from scribe.models.rule_usage import RuleUsageEvent
|
||||||
|
from scribe.models.system_usage import SystemUsageEvent
|
||||||
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
||||||
from scribe.models.note_version import NoteVersion
|
from scribe.models.note_version import NoteVersion
|
||||||
from scribe.models.rule_version import RuleVersion
|
from scribe.models.rule_version import RuleVersion
|
||||||
@@ -172,6 +173,7 @@ def _column_guard_targets():
|
|||||||
"lesson_no_rule": (LessonNoRule, backup._lesson_no_rule_rows),
|
"lesson_no_rule": (LessonNoRule, backup._lesson_no_rule_rows),
|
||||||
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
|
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
|
||||||
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
|
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
|
||||||
|
"system_usage_events": (SystemUsageEvent, backup._system_usage_event_rows),
|
||||||
"retrieval_tuning_events": (
|
"retrieval_tuning_events": (
|
||||||
RetrievalTuningEvent, backup._retrieval_tuning_event_rows,
|
RetrievalTuningEvent, backup._retrieval_tuning_event_rows,
|
||||||
),
|
),
|
||||||
@@ -290,6 +292,7 @@ def _import_guard_targets():
|
|||||||
"lesson_no_rule": backup._build_lesson_no_rule,
|
"lesson_no_rule": backup._build_lesson_no_rule,
|
||||||
"note_usage_events": backup._build_usage_event,
|
"note_usage_events": backup._build_usage_event,
|
||||||
"rule_usage_events": backup._build_rule_usage_event,
|
"rule_usage_events": backup._build_rule_usage_event,
|
||||||
|
"system_usage_events": backup._build_system_usage_event,
|
||||||
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
|
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
|
||||||
"design_systems": backup._build_design_system,
|
"design_systems": backup._build_design_system,
|
||||||
"design_tokens": backup._build_design_token,
|
"design_tokens": backup._build_design_token,
|
||||||
@@ -543,7 +546,9 @@ async def test_export_full_backup_contains_every_declared_section():
|
|||||||
# v18: which rule each lesson is an instance of.
|
# v18: which rule each lesson is an instance of.
|
||||||
"lesson_rule_links",
|
"lesson_rule_links",
|
||||||
# v19: the lessons judged to fall under no rule.
|
# v19: the lessons judged to fall under no rule.
|
||||||
"lesson_no_rule"):
|
"lesson_no_rule",
|
||||||
|
# v20: whether an area's rulings were read once shown.
|
||||||
|
"system_usage_events"):
|
||||||
assert key in out, f"missing export section: {key}"
|
assert key in out, f"missing export section: {key}"
|
||||||
assert out[key] == []
|
assert out[key] == []
|
||||||
|
|
||||||
|
|||||||
@@ -126,3 +126,127 @@ async def test_assess_fails_open_so_a_naming_aid_cannot_block_a_create():
|
|||||||
async def test_assess_says_nothing_about_a_nameless_system():
|
async def test_assess_says_nothing_about_a_nameless_system():
|
||||||
from scribe.services.systems import assess_system_name
|
from scribe.services.systems import assess_system_name
|
||||||
assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None}
|
assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ── path patterns — the files that ARE the area (milestone 444, #4756) ──
|
||||||
|
|
||||||
|
def test_patterns_are_tidied_to_repo_relative_and_deduplicated():
|
||||||
|
from scribe.services.systems import normalize_path_patterns
|
||||||
|
out = normalize_path_patterns([
|
||||||
|
" ./src/billing/ ", "/src/billing", "", "src\\api\\*.py", " ",
|
||||||
|
"frontend/src/**/Billing*.vue",
|
||||||
|
])
|
||||||
|
assert out == ["src/billing", "src/api/*.py", "frontend/src/**/Billing*.vue"]
|
||||||
|
assert normalize_path_patterns(None) == []
|
||||||
|
assert normalize_path_patterns([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
"src/billing", # a bare string, not a list
|
||||||
|
["../other-repo/src"], # leaves the repo
|
||||||
|
["src/../../etc"],
|
||||||
|
[42],
|
||||||
|
["x" * 301],
|
||||||
|
[f"src/f{i}.py" for i in range(51)],
|
||||||
|
])
|
||||||
|
def test_patterns_that_cannot_mean_an_area_are_refused(bad):
|
||||||
|
from scribe.services.systems import normalize_path_patterns
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
normalize_path_patterns(bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pattern,path,expected", [
|
||||||
|
# A plain directory covers everything under it, at any depth.
|
||||||
|
("src/billing", "src/billing/invoice.py", True),
|
||||||
|
("src/billing", "src/billing/deep/er/x.py", True),
|
||||||
|
("src/billing", "src/billing", True),
|
||||||
|
("src/billing", "src/billingual/x.py", False),
|
||||||
|
# `*` stays inside one segment; `**` spans any number, including none.
|
||||||
|
("src/*.py", "src/app.py", True),
|
||||||
|
("src/*.py", "src/pkg/app.py", False),
|
||||||
|
("src/**/*.py", "src/pkg/sub/app.py", True),
|
||||||
|
("src/**/*.py", "src/app.py", True),
|
||||||
|
("**/Billing*.vue", "frontend/src/components/BillingCard.vue", True),
|
||||||
|
# Case counts, and the path is read the way the pattern was written.
|
||||||
|
("src/billing", "Src/billing/x.py", False),
|
||||||
|
("src/billing", "./src/billing/x.py", True),
|
||||||
|
("src/billing", "", False),
|
||||||
|
])
|
||||||
|
def test_path_matches(pattern, path, expected):
|
||||||
|
from scribe.services.systems import path_matches
|
||||||
|
assert path_matches(pattern, path) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_systems_for_paths_returns_every_area_a_path_belongs_to():
|
||||||
|
"""Areas overlap, and each one a path belongs to answers for it — the
|
||||||
|
lookup never picks a winner. A System that named no files claims none."""
|
||||||
|
api = MagicMock(id=1, path_patterns=["src/scribe/routes", "src/scribe/mcp/tools"])
|
||||||
|
data = MagicMock(id=2, path_patterns=["src/scribe/models", "src/scribe/**/systems.py"])
|
||||||
|
unnamed = MagicMock(id=3, path_patterns=[])
|
||||||
|
with patch("scribe.services.systems.list_systems",
|
||||||
|
AsyncMock(return_value=[api, data, unnamed])):
|
||||||
|
from scribe.services.systems import systems_for_paths
|
||||||
|
out = await systems_for_paths(1, 5, [
|
||||||
|
"src/scribe/routes/systems.py", "./README.md", "",
|
||||||
|
])
|
||||||
|
assert [(s.id, paths) for s, paths in out] == [
|
||||||
|
(1, ["src/scribe/routes/systems.py"]),
|
||||||
|
(2, ["src/scribe/routes/systems.py"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_systems_for_paths_with_no_paths_reads_nothing():
|
||||||
|
lister = AsyncMock(return_value=[])
|
||||||
|
with patch("scribe.services.systems.list_systems", lister):
|
||||||
|
from scribe.services.systems import systems_for_paths
|
||||||
|
assert await systems_for_paths(1, 5, ["", " "]) == []
|
||||||
|
lister.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_refuses_a_bad_pattern_before_touching_the_row():
|
||||||
|
with patch("scribe.services.systems.async_session") as mock_cls:
|
||||||
|
from scribe.services.systems import update_system
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await update_system(1, 9, path_patterns=["../elsewhere"])
|
||||||
|
mock_cls.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_with_an_empty_list_clears_the_patterns():
|
||||||
|
"""`[]` is a value, not "leave unchanged" — the service skips only None."""
|
||||||
|
system = MagicMock(deleted_at=None, project_id=5, path_patterns=["src/old"])
|
||||||
|
session = make_mock_session()
|
||||||
|
session.get = AsyncMock(return_value=system)
|
||||||
|
with patch("scribe.services.systems.async_session", return_value=session), \
|
||||||
|
patch("scribe.services.systems.access") as acc, \
|
||||||
|
patch("scribe.services.systems.embed_system"):
|
||||||
|
acc.can_write_project = AsyncMock(return_value=True)
|
||||||
|
from scribe.services.systems import update_system
|
||||||
|
await update_system(1, 9, path_patterns=[])
|
||||||
|
assert system.path_patterns == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_stores_tidied_patterns():
|
||||||
|
mock_session = make_mock_session()
|
||||||
|
captured = {}
|
||||||
|
mock_session.add = MagicMock(side_effect=lambda obj: captured.update(
|
||||||
|
patterns=getattr(obj, "path_patterns", "MISSING")))
|
||||||
|
with patch("scribe.services.systems.async_session", return_value=mock_session), \
|
||||||
|
patch("scribe.services.systems.access") as acc, \
|
||||||
|
patch("scribe.services.systems.embed_system"):
|
||||||
|
acc.can_write_project = AsyncMock(return_value=True)
|
||||||
|
from scribe.services.systems import create_system
|
||||||
|
await create_system(1, 5, "Billing", path_patterns=["./src/billing/"])
|
||||||
|
assert captured["patterns"] == ["src/billing"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_to_dict_carries_its_patterns():
|
||||||
|
from scribe.models.system import System
|
||||||
|
system = System(id=1, user_id=1, project_id=5, name="Billing",
|
||||||
|
path_patterns=["src/billing"])
|
||||||
|
assert system.to_dict()["path_patterns"] == ["src/billing"]
|
||||||
|
assert System(id=2, user_id=1, project_id=5, name="X").to_dict()["path_patterns"] == []
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""An area's rulings reach the work that touches its files (milestone 444, #4757).
|
||||||
|
|
||||||
|
These pin:
|
||||||
|
|
||||||
|
- WHAT A RULING IS, as read: a bullet under the last `Rulings` heading of a
|
||||||
|
System's description, wrapped lines joined, the section ending at the first
|
||||||
|
line that is neither.
|
||||||
|
- WHICH PATHS A COMMAND NAMES: reads and writes alike, relative to the repo
|
||||||
|
root whatever the command's cwd; flags, URLs and globs are not paths, and a
|
||||||
|
path outside the repo is nobody's area.
|
||||||
|
- THE ARM: a lookup, not a search. No rulings or no patterns says nothing; a
|
||||||
|
path in two areas hears both; a repeat is one short reference line and is
|
||||||
|
neither counted as a surfacing nor handed back as newly shown.
|
||||||
|
- THE WIRING: both PreToolUse builders carry it, ahead of what they rank.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
from scribe.services import system_rulings as sr
|
||||||
|
# Bound at import, before conftest's autouse stub replaces the module attribute.
|
||||||
|
from scribe.services.system_rulings import rulings_for_paths as real_rulings_for_paths
|
||||||
|
from tests.helpers import writepath_cfg
|
||||||
|
|
||||||
|
DOWNLOADS = """Fetching and keeping downloads healthy: stalls, retries, the blocklist.
|
||||||
|
|
||||||
|
Rulings
|
||||||
|
- Failed work is retried until it succeeds; no attempt limit. (Operator, 2026-09-19, #1234)
|
||||||
|
- A blocklisted release falls off the list after a while
|
||||||
|
and may be tried again. (Operator, 2026-09-20, #1240)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _system(sid, name, description, patterns):
|
||||||
|
return SimpleNamespace(id=sid, name=name, description=description, path_patterns=patterns)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reading the section ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_rulings_are_the_bullets_under_the_heading_with_wrapped_lines_joined():
|
||||||
|
assert sr.parse_rulings(DOWNLOADS) == [
|
||||||
|
"Failed work is retried until it succeeds; no attempt limit. (Operator, 2026-09-19, #1234)",
|
||||||
|
"A blocklisted release falls off the list after a while and may be tried again. "
|
||||||
|
"(Operator, 2026-09-20, #1240)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("heading", ["Rulings", "## Rulings", "**Rulings**", "Rulings:", "### rulings"])
|
||||||
|
def test_the_heading_may_be_written_several_ways(heading):
|
||||||
|
assert sr.parse_rulings(f"Charter.\n\n{heading}\n- One. (Operator)\n") == ["One. (Operator)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_section_and_an_empty_description_have_no_rulings():
|
||||||
|
assert sr.parse_rulings("A charter that mentions rulings in passing.") == []
|
||||||
|
assert sr.parse_rulings("") == []
|
||||||
|
assert sr.parse_rulings(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_section_ends_where_a_paragraph_starts():
|
||||||
|
text = "Rulings\n- Kept. (Operator)\n\nNotes written after the section.\n- Not a ruling.\n"
|
||||||
|
assert sr.parse_rulings(text) == ["Kept. (Operator)"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the paths a command names ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = "/home/me/repo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command,cwd,expected", [
|
||||||
|
("sed -n 1,80p src/app/downloads.py", ROOT, ["src/app/downloads.py"]),
|
||||||
|
# Absolute inside the root, and outside it.
|
||||||
|
(f"cat {ROOT}/src/app/x.py /etc/hosts", ROOT, ["src/app/x.py"]),
|
||||||
|
# A command run from a subdirectory names paths relative to it.
|
||||||
|
("grep -n retry downloads.py", f"{ROOT}/src/app", ["src/app/downloads.py"]),
|
||||||
|
("cat ../../../elsewhere/x.py", f"{ROOT}/src", []),
|
||||||
|
# Flags, URLs and globs are not paths; a `--flag=path` and a `path:line` are.
|
||||||
|
("pytest -q --rootdir=tests/unit tests/test_x.py::test_y", ROOT,
|
||||||
|
["tests/unit", "tests/test_x.py"]),
|
||||||
|
("curl https://example.com/a/b.json", ROOT, []),
|
||||||
|
("ls src/*.py", ROOT, []),
|
||||||
|
("vim src/app/x.py:120", ROOT, ["src/app/x.py"]),
|
||||||
|
# Repeats collapse; a bare word with no slash or extension is not a path.
|
||||||
|
("git add src/a.py src/a.py && git commit -m done", ROOT, ["src/a.py"]),
|
||||||
|
])
|
||||||
|
def test_command_paths(command, cwd, expected):
|
||||||
|
assert sr.command_paths(command, root=ROOT, cwd=cwd) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_root_relative_paths_are_taken_as_given_and_absolute_ones_dropped():
|
||||||
|
assert sr.command_paths("cat src/a.py /abs/b.py") == ["src/a.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unbalanced_quote_still_yields_paths():
|
||||||
|
assert sr.command_paths("echo 'oops src/a.py") == ["src/a.py"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the arm ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _arm(systems, paths, seen=None):
|
||||||
|
lister = AsyncMock(return_value=systems)
|
||||||
|
recorder = MagicMock()
|
||||||
|
with patch.object(sr.systems_svc, "list_systems", lister), \
|
||||||
|
patch.object(sr, "record_system_surfaced", recorder):
|
||||||
|
out = await real_rulings_for_paths(1, 2, paths, seen=seen, source="rulings_test")
|
||||||
|
return out, recorder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_touched_area_with_rulings_shows_them_once_in_full_and_records_it():
|
||||||
|
downloads = _system(104, "Download lifecycle", DOWNLOADS, ["src/app/downloads"])
|
||||||
|
out, recorder = await _arm([downloads], ["src/app/downloads/retry.py"])
|
||||||
|
[line] = out["lines"]
|
||||||
|
assert line.startswith("> Rulings for `src/app/downloads/retry.py`")
|
||||||
|
assert "Download lifecycle (System 104)" in line
|
||||||
|
assert "(1) Failed work is retried until it succeeds" in line
|
||||||
|
assert "(2) A blocklisted release falls off" in line
|
||||||
|
assert out["system_ids"] == [104]
|
||||||
|
recorder.assert_called_once()
|
||||||
|
assert recorder.call_args.kwargs["system_ids"] == [104]
|
||||||
|
assert recorder.call_args.kwargs["source"] == "rulings_test"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_area_without_rulings_says_nothing():
|
||||||
|
plain = _system(5, "Billing", "What billing is for, and nothing decided.", ["src/billing"])
|
||||||
|
out, recorder = await _arm([plain], ["src/billing/invoice.py"])
|
||||||
|
assert out == {"lines": [], "system_ids": []}
|
||||||
|
recorder.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_area_without_patterns_claims_no_files():
|
||||||
|
unnamed = _system(104, "Download lifecycle", DOWNLOADS, [])
|
||||||
|
out, _ = await _arm([unnamed], ["src/app/downloads/retry.py"])
|
||||||
|
assert out["lines"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_path_in_two_areas_hears_both():
|
||||||
|
downloads = _system(104, "Download lifecycle", DOWNLOADS, ["src/app/downloads"])
|
||||||
|
storage = _system(7, "Storage", "Where files go.\n\nRulings\n- Never delete a user file. (Operator)\n",
|
||||||
|
["src/app/**/*.py"])
|
||||||
|
out, _ = await _arm([downloads, storage], ["src/app/downloads/retry.py"])
|
||||||
|
assert len(out["lines"]) == 2
|
||||||
|
assert out["system_ids"] == [104, 7]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_repeat_is_a_reference_and_is_not_counted_again():
|
||||||
|
downloads = _system(104, "Download lifecycle", DOWNLOADS, ["src/app/downloads"])
|
||||||
|
out, recorder = await _arm([downloads], ["src/app/downloads/retry.py"], seen=[104])
|
||||||
|
[line] = out["lines"]
|
||||||
|
assert "shown earlier this session" in line
|
||||||
|
assert "Failed work is retried" not in line
|
||||||
|
assert out["system_ids"] == []
|
||||||
|
recorder.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_project_or_no_paths_reads_nothing():
|
||||||
|
lister = AsyncMock(return_value=[])
|
||||||
|
with patch.object(sr.systems_svc, "list_systems", lister):
|
||||||
|
assert await real_rulings_for_paths(1, 0, ["src/a.py"], source="t") == {"lines": [], "system_ids": []}
|
||||||
|
assert await real_rulings_for_paths(1, 2, [], source="t") == {"lines": [], "system_ids": []}
|
||||||
|
lister.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_failing_lookup_fails_open():
|
||||||
|
with patch.object(sr.systems_svc, "list_systems", AsyncMock(side_effect=RuntimeError("db"))):
|
||||||
|
out = await real_rulings_for_paths(1, 2, ["src/a.py"], source="t")
|
||||||
|
assert out == {"lines": [], "system_ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_long_list_is_bounded_per_area():
|
||||||
|
many = "Rulings\n" + "".join(f"- Ruling {i}. (Operator)\n" for i in range(12))
|
||||||
|
area = _system(9, "Busy", many, ["src"])
|
||||||
|
out, _ = await _arm([area], ["src/x.py"])
|
||||||
|
assert "(8) Ruling 7." in out["lines"][0]
|
||||||
|
assert "Ruling 8." not in out["lines"][0]
|
||||||
|
assert "+4 more in `get_system(9)`" in out["lines"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the wiring ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
RULING_LINE = "> Rulings for `src/a.py` — the operator's decisions about A (System 3): (1) x"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_tool_arm_leads_with_rulings_and_hands_back_what_it_showed():
|
||||||
|
rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]})
|
||||||
|
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={
|
||||||
|
"context": "> a rule line", "rule_ids": [8], "checkpoint": {}})), \
|
||||||
|
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \
|
||||||
|
patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings):
|
||||||
|
out = await pc.build_tool_rule_hint(
|
||||||
|
1, "Bash", f"cat {ROOT}/src/a.py", project_id=2,
|
||||||
|
root=ROOT, cwd=ROOT, seen_ruling_systems=[5],
|
||||||
|
)
|
||||||
|
assert out["context"].splitlines() == [RULING_LINE, "> a rule line"]
|
||||||
|
assert out["ruling_system_ids"] == [3]
|
||||||
|
args, kwargs = rulings.call_args
|
||||||
|
assert args[2] == ["src/a.py"]
|
||||||
|
assert kwargs["seen"] == [5] and kwargs["source"] == "rulings_pre_tool"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_tool_arm_adds_no_key_when_no_ruling_was_shown():
|
||||||
|
with patch.object(pc, "_tool_rule_hint", AsyncMock(return_value={
|
||||||
|
"context": "", "rule_ids": [], "checkpoint": {}})), \
|
||||||
|
patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())):
|
||||||
|
out = await pc.build_tool_rule_hint(1, "Bash", "ls", project_id=2)
|
||||||
|
assert "ruling_system_ids" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_write_with_no_prior_art_still_carries_its_areas_rulings():
|
||||||
|
rulings = AsyncMock(return_value={"lines": [RULING_LINE], "system_ids": [3]})
|
||||||
|
with patch.object(pc, "get_writepath_config", AsyncMock(return_value=writepath_cfg())), \
|
||||||
|
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
|
||||||
|
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(pc, "record_retrieval", MagicMock()), \
|
||||||
|
patch.object(pc.projects_svc, "get_project", AsyncMock(return_value=None)), \
|
||||||
|
patch.object(pc.system_rulings_svc, "rulings_for_paths", rulings):
|
||||||
|
out = await pc.build_write_path_hint(
|
||||||
|
1, "src/a.py", code="x = 1", project_id=2, seen_ruling_systems=[9],
|
||||||
|
)
|
||||||
|
assert out["context"] == RULING_LINE
|
||||||
|
assert out["ruling_system_ids"] == [3]
|
||||||
|
args, kwargs = rulings.call_args
|
||||||
|
assert args[2] == ["src/a.py"]
|
||||||
|
assert kwargs["seen"] == [9] and kwargs["source"] == "rulings_write_path"
|
||||||
@@ -970,7 +970,7 @@ def test_route_reads_every_arg_the_hook_sends():
|
|||||||
routes._project_scope
|
routes._project_scope
|
||||||
)
|
)
|
||||||
for arg in ("path", "code", "repo", "project_id", "exclude_ids",
|
for arg in ("path", "code", "repo", "project_id", "exclude_ids",
|
||||||
"exclude_sync_ids", "shapes"):
|
"exclude_sync_ids", "shapes", "seen_ruling_systems"):
|
||||||
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
|
assert f'request.args.get("{arg}"' in src, f"route ignores {arg}"
|
||||||
|
|
||||||
hook = HOOK.read_text()
|
hook = HOOK.read_text()
|
||||||
@@ -991,6 +991,9 @@ def test_route_reads_every_arg_the_hook_sends():
|
|||||||
assert "printf '&held_rule_ids=" in defs, (
|
assert "printf '&held_rule_ids=" in defs, (
|
||||||
"the opened ledger's query key is no longer spelled in the helper"
|
"the opened ledger's query key is no longer spelled in the helper"
|
||||||
)
|
)
|
||||||
|
# The rulings ledger (milestone 444), shared with the tool-rules hook.
|
||||||
|
assert "scribe_rulings_query" in hook, "hook no longer sends the rulings ledger"
|
||||||
|
assert "printf '&seen_ruling_systems=" in defs
|
||||||
|
|
||||||
|
|
||||||
def test_route_resolves_repo_to_a_project_not_to_a_location_filter():
|
def test_route_resolves_repo_to_a_project_not_to_a_location_filter():
|
||||||
|
|||||||
Reference in New Issue
Block a user