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

This commit was merged in pull request #194.
This commit is contained in:
2026-10-03 08:29:25 -04:00
30 changed files with 1256 additions and 32 deletions
@@ -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 -1
View File
@@ -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);
+70 -2
View File
@@ -28,6 +28,7 @@ const newDescription = ref("");
// feature two matchers to keep in step, which is the exact drift the catalog // 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.
+2 -2
View File
@@ -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 -1
View File
@@ -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"
}, },
+14
View File
@@ -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
+12 -1
View File
@@ -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
+19 -1
View File
@@ -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) ────────────────────────
# #
+5
View File
@@ -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.
+23 -1
View File
@@ -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")
+1
View File
@@ -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
+12 -1
View File
@@ -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),
} }
+56
View File
@@ -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,
}
+19 -1
View File
@@ -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)
+8 -1
View File
@@ -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
try:
system = await systems_svc.create_system( system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"], uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"), description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0), order_index=data.get("order_index", 0),
canonical_id=data.get("canonical_id") or applied, 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
try:
updated = await systems_svc.update_system(uid, system_id, **fields) 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())
+61 -1
View File
@@ -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)
+51 -6
View File
@@ -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(
+196
View File
@@ -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
+67
View File
@@ -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")
+116 -1
View File
@@ -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:
+15
View File
@@ -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
+3
View File
@@ -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",)),
+17
View File
@@ -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), \
+10 -2
View File
@@ -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"
) )
+7 -2
View File
@@ -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] == []
+124
View File
@@ -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"] == []
+241
View File
@@ -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"
+4 -1
View File
@@ -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():