Retire the always-on tier — every rule arrives by retrieval (milestone 394) #152
@@ -0,0 +1,96 @@
|
|||||||
|
"""drop the always-on tier: rules.tier, rulebooks.always_on, the exclusions table
|
||||||
|
|
||||||
|
Revision ID: 0100
|
||||||
|
Revises: 0099
|
||||||
|
Create Date: 2026-09-11
|
||||||
|
|
||||||
|
Milestone 394. Every rule now reaches a session by retrieval — because
|
||||||
|
something it is about to do made the rule relevant — and the machinery that
|
||||||
|
delivered rules unconditionally goes with it.
|
||||||
|
|
||||||
|
WHAT GOES, AND WHERE IT CAME FROM
|
||||||
|
|
||||||
|
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
|
||||||
|
the column takes the constraint with it. Rule 36 is about ADDING a value to
|
||||||
|
a live whitelist, which needs DROP + ADD in the same migration; it does not
|
||||||
|
speak to removing the column outright, and saying so here is cheaper than
|
||||||
|
the next reader wondering whether it was forgotten.
|
||||||
|
- ``rule_versions.tier`` (migration 0098). A version records what a rule
|
||||||
|
SAID; with no tier on a rule there is nothing for a snapshot to carry.
|
||||||
|
- ``rulebooks.always_on`` (migration 0058). A rulebook reaches a project by
|
||||||
|
subscription now, and by nothing else.
|
||||||
|
- ``project_rulebook_exclusions`` (migration 0085). It recorded a project's
|
||||||
|
opt-out of an always-on rulebook. Opting out of something that no longer
|
||||||
|
binds you is not a state that can exist — declining a rulebook is
|
||||||
|
expressed by not subscribing to it.
|
||||||
|
|
||||||
|
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO RATHER THAN PRETENDING
|
||||||
|
|
||||||
|
The downgrade recreates the columns and the table with their DEFAULTS. It
|
||||||
|
cannot restore WHICH rules were always-on, which rulebooks bound every project,
|
||||||
|
or which projects had opted out — that information is in what this drops.
|
||||||
|
|
||||||
|
That distinction is the one this repo keeps insisting on: a value invented to
|
||||||
|
fill a hole is not a measurement. So a downgraded database is structurally able
|
||||||
|
to run the old code and is NOT the database the old code was running against —
|
||||||
|
every rule comes back at the ``always_on`` default, which for the tier column
|
||||||
|
happens to mean "binding", the safe direction to be wrong in.
|
||||||
|
|
||||||
|
Anyone who needs the real prior state restores a backup taken before this ran.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0100"
|
||||||
|
down_revision = "0099"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TIERS = ("always_on", "conditional")
|
||||||
|
|
||||||
|
|
||||||
|
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||||
|
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("project_rulebook_exclusions")
|
||||||
|
op.drop_column("rulebooks", "always_on")
|
||||||
|
op.drop_column("rule_versions", "tier")
|
||||||
|
# The CHECK goes with the column it constrains; naming it here would be a
|
||||||
|
# second drop of the same object.
|
||||||
|
op.drop_column("rules", "tier")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Structure only. See the module docstring — the values are gone."""
|
||||||
|
op.add_column(
|
||||||
|
"rules",
|
||||||
|
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
|
||||||
|
)
|
||||||
|
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
|
||||||
|
op.add_column("rule_versions", sa.Column("tier", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"rulebooks",
|
||||||
|
sa.Column(
|
||||||
|
"always_on", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"project_rulebook_exclusions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rulebook_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"), nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
export interface InceptionChoices {
|
export interface InceptionChoices {
|
||||||
exclude_always_on_rulebooks: number[];
|
|
||||||
subscribe_rulebooks: number[];
|
subscribe_rulebooks: number[];
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
seed_systems: boolean;
|
seed_systems: boolean;
|
||||||
@@ -16,9 +15,7 @@ export interface InceptionRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InceptionDefaults {
|
export interface InceptionDefaults {
|
||||||
always_on_rulebooks: { id: number; title: string }[];
|
rulebooks: { id: number; title: string }[];
|
||||||
other_rulebooks: { id: number; title: string }[];
|
|
||||||
excluded_always_on: { id: number; title: string }[];
|
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
design_systems: { id: number; title: string }[];
|
design_systems: { id: number; title: string }[];
|
||||||
@@ -32,7 +29,7 @@ export interface InceptionDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const emptyChoices = (): InceptionChoices => ({
|
export const emptyChoices = (): InceptionChoices => ({
|
||||||
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchInceptionDefaults = (projectId: number) =>
|
export const fetchInceptionDefaults = (projectId: number) =>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { RecordUsage } from "@/types/usage";
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
/** How a rule reaches a session (milestone 307). */
|
/** How a rule reaches a session (milestone 307). */
|
||||||
export type RuleTier = "always_on" | "conditional";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed edge between two rules. Each kind exists because its absence forced
|
* A typed edge between two rules. Each kind exists because its absence forced
|
||||||
@@ -26,7 +25,6 @@ export interface Rulebook {
|
|||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
always_on: boolean;
|
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -49,12 +47,6 @@ export interface Rule {
|
|||||||
statement: string;
|
statement: string;
|
||||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
/**
|
|
||||||
* always_on preloads into every session; conditional is reachable and
|
|
||||||
* surfaced when its trigger fires. A rule with no tier set behaves as
|
|
||||||
* always_on, which is how every rule behaved before this existed.
|
|
||||||
*/
|
|
||||||
tier: RuleTier;
|
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +79,6 @@ export interface RuleHeader {
|
|||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
tier: RuleTier;
|
|
||||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
when_to_apply?: string;
|
when_to_apply?: string;
|
||||||
@@ -134,7 +125,6 @@ export interface ApplicableRules {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||||
excluded_always_on: { id: number; title: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||||
@@ -152,7 +142,7 @@ export async function createRulebook(data: { title: string; description?: string
|
|||||||
return apiPost("/api/rulebooks", data);
|
return apiPost("/api/rulebooks", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
||||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +197,6 @@ export interface RuleWrite {
|
|||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
tier: RuleTier;
|
|
||||||
why: string;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
@@ -258,7 +247,6 @@ export interface RuleVersion {
|
|||||||
why?: string;
|
why?: string;
|
||||||
how_to_apply?: string;
|
how_to_apply?: string;
|
||||||
when_to_apply?: string;
|
when_to_apply?: string;
|
||||||
tier?: string;
|
|
||||||
verify_with?: string;
|
verify_with?: string;
|
||||||
expires_when?: string;
|
expires_when?: string;
|
||||||
}
|
}
|
||||||
@@ -323,16 +311,6 @@ export async function unsuppressTopicForProject(projectId: number, topicId: numb
|
|||||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
|
||||||
|
|
||||||
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
||||||
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
||||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
||||||
@@ -343,7 +321,6 @@ export interface RuleVerificationRow {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
tier: RuleTier;
|
|
||||||
topic_id: number | null;
|
topic_id: number | null;
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
when_to_apply: string;
|
when_to_apply: string;
|
||||||
@@ -366,12 +343,10 @@ export interface RuleVerificationRow {
|
|||||||
*/
|
*/
|
||||||
export async function listRulesDueForVerification(opts: {
|
export async function listRulesDueForVerification(opts: {
|
||||||
olderThanDays?: number;
|
olderThanDays?: number;
|
||||||
tier?: RuleTier;
|
|
||||||
neverOnly?: boolean;
|
neverOnly?: boolean;
|
||||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
||||||
if (opts.tier) q.set("tier", opts.tier);
|
|
||||||
if (opts.neverOnly) q.set("never_only", "true");
|
if (opts.neverOnly) q.set("never_only", "true");
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||||
|
|||||||
@@ -20,6 +20,15 @@
|
|||||||
milestone (tier, then verification) and were byte-identical; a third would
|
milestone (tier, then verification) and were byte-identical; a third would
|
||||||
have drifted. The pane's italic serif title is inherited by anything inside
|
have drifted. The pane's italic serif title is inherited by anything inside
|
||||||
it, so the chip resets family and style explicitly. */
|
it, so the chip resets family and style explicitly. */
|
||||||
|
/* A rule with no trigger cannot be retrieved, and since milestone 394
|
||||||
|
retrieval is the only delivery — so this marks a rule that will never
|
||||||
|
reach a session. Warning rather than error: the rule is not broken, it is
|
||||||
|
unreachable, and the fix is one field away. */
|
||||||
|
.rule-chip-inert {
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
|
||||||
|
}
|
||||||
|
|
||||||
.rule-chip {
|
.rule-chip {
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
font-family: var(--fs-font-body);
|
font-family: var(--fs-font-body);
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||||
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
|
||||||
const others = ref<{ id: number; title: string }[]>([]);
|
const others = ref<{ id: number; title: string }[]>([]);
|
||||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
const systemsCount = ref(0);
|
const systemsCount = ref(0);
|
||||||
@@ -47,21 +46,18 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
if (props.mode === "decide" && props.projectId) {
|
if (props.mode === "decide" && props.projectId) {
|
||||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||||
alwaysOn.value = d.always_on_rulebooks;
|
others.value = d.rulebooks;
|
||||||
others.value = d.other_rulebooks;
|
|
||||||
designSystems.value = d.design_systems;
|
designSystems.value = d.design_systems;
|
||||||
systemsCount.value = d.systems;
|
systemsCount.value = d.systems;
|
||||||
// Start from what stands today so "record" without changes is a true inherit-all.
|
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||||
local.value = {
|
local.value = {
|
||||||
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
|
||||||
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||||
design_system_id: d.design_system_id,
|
design_system_id: d.design_system_id,
|
||||||
seed_systems: false,
|
seed_systems: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
||||||
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
|
||||||
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
|
||||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -71,13 +67,6 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inherits(id: number): boolean {
|
|
||||||
return !local.value.exclude_always_on_rulebooks.includes(id);
|
|
||||||
}
|
|
||||||
function toggleInherit(id: number) {
|
|
||||||
const list = local.value.exclude_always_on_rulebooks;
|
|
||||||
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
|
||||||
}
|
|
||||||
function subscribed(id: number): boolean {
|
function subscribed(id: number): boolean {
|
||||||
return local.value.subscribe_rulebooks.includes(id);
|
return local.value.subscribe_rulebooks.includes(id);
|
||||||
}
|
}
|
||||||
@@ -87,7 +76,7 @@ function toggleSubscribe(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nothingToDecide = computed(
|
const nothingToDecide = computed(
|
||||||
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
() => !others.value.length && !designSystems.value.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function record() {
|
async function record() {
|
||||||
@@ -118,16 +107,11 @@ onMounted(load);
|
|||||||
<p v-if="loading" class="inception-muted">Loading…</p>
|
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="alwaysOn.length" class="inception-group">
|
|
||||||
<h4>Always-on rulebooks</h4>
|
|
||||||
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
|
|
||||||
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
|
|
||||||
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
|
|
||||||
<span>{{ rb.title }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="others.length" class="inception-group">
|
<div v-if="others.length" class="inception-group">
|
||||||
<h4>Subscribe to rulebooks</h4>
|
<h4>Subscribe to rulebooks</h4>
|
||||||
|
<p class="inception-muted">
|
||||||
|
A rulebook binds this project only if it is subscribed — nothing is inherited automatically.
|
||||||
|
</p>
|
||||||
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
||||||
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||||
<span>{{ rb.title }}</span>
|
<span>{{ rb.title }}</span>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
unsuppressRuleForProject,
|
unsuppressRuleForProject,
|
||||||
suppressTopicForProject,
|
suppressTopicForProject,
|
||||||
unsuppressTopicForProject,
|
unsuppressTopicForProject,
|
||||||
includeAlwaysOnRulebook,
|
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ const ruleDetails = ref<Record<number, {
|
|||||||
const showProjectRuleForm = ref(false);
|
const showProjectRuleForm = ref(false);
|
||||||
const newProjectRule = ref({
|
const newProjectRule = ref({
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
when_to_apply: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -49,11 +48,6 @@ async function subscribe(rulebookId: number) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function includeBack(rulebookId: number) {
|
|
||||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unsubscribe(rulebookId: number) {
|
async function unsubscribe(rulebookId: number) {
|
||||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||||
await unsubscribeProject(props.projectId, rulebookId);
|
await unsubscribeProject(props.projectId, rulebookId);
|
||||||
@@ -134,11 +128,10 @@ async function submitProjectRule() {
|
|||||||
why: newProjectRule.value.why.trim() || undefined,
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||||
tier: newProjectRule.value.tier,
|
|
||||||
});
|
});
|
||||||
newProjectRule.value = {
|
newProjectRule.value = {
|
||||||
title: "", statement: "", why: "", how_to_apply: "",
|
title: "", statement: "", why: "", how_to_apply: "",
|
||||||
when_to_apply: "", tier: "always_on",
|
when_to_apply: "",
|
||||||
};
|
};
|
||||||
showProjectRuleForm.value = false;
|
showProjectRuleForm.value = false;
|
||||||
await load();
|
await load();
|
||||||
@@ -210,17 +203,6 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
|
||||||
<h3>Excluded always-on rulebooks</h3>
|
|
||||||
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
|
||||||
<div class="chips">
|
|
||||||
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
|
||||||
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
|
||||||
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="project-rules">
|
<section class="project-rules">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>Project rules</h3>
|
<h3>Project rules</h3>
|
||||||
@@ -246,22 +228,13 @@ watch(() => props.projectId, load);
|
|||||||
></textarea>
|
></textarea>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProjectRule.when_to_apply"
|
v-model="newProjectRule.when_to_apply"
|
||||||
placeholder="When to apply — the trigger, not the instruction"
|
placeholder="When to apply — the moment, in the words a session actually produces"
|
||||||
rows="2"
|
rows="2"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="tier-row">
|
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
|
||||||
<label>
|
Without a trigger the rule will never reach a session — nothing is
|
||||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
preloaded, so a rule arrives only when work matches what it names.
|
||||||
Always on
|
</p>
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
|
||||||
Conditional
|
|
||||||
</label>
|
|
||||||
<span class="tier-hint">
|
|
||||||
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProjectRule.why"
|
v-model="newProjectRule.why"
|
||||||
placeholder="Why (optional) — the rationale"
|
placeholder="Why (optional) — the rationale"
|
||||||
@@ -405,14 +378,9 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
.trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||||
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
||||||
.tier-row input { accent-color: var(--fs-accent); }
|
|
||||||
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
|
||||||
|
|
||||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
|
||||||
.chip-excluded .chip-remove { text-decoration: none; }
|
|
||||||
.rules-tab { padding: 1rem; }
|
.rules-tab { padding: 1rem; }
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, ref, watch, onMounted } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
import type { RuleTier } from "@/api/rulebooks";
|
|
||||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||||
|
|
||||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
@@ -13,7 +12,6 @@ const canon = useCanonicalSystemsStore();
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
const whenToApply = ref("");
|
const whenToApply = ref("");
|
||||||
const tier = ref<RuleTier>("always_on");
|
|
||||||
const systemIds = ref<number[]>([]);
|
const systemIds = ref<number[]>([]);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
@@ -70,7 +68,6 @@ async function load() {
|
|||||||
title.value = r.title;
|
title.value = r.title;
|
||||||
statement.value = r.statement;
|
statement.value = r.statement;
|
||||||
whenToApply.value = r.when_to_apply || "";
|
whenToApply.value = r.when_to_apply || "";
|
||||||
tier.value = r.tier || "always_on";
|
|
||||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
@@ -81,7 +78,6 @@ async function load() {
|
|||||||
title.value = "";
|
title.value = "";
|
||||||
statement.value = "";
|
statement.value = "";
|
||||||
whenToApply.value = "";
|
whenToApply.value = "";
|
||||||
tier.value = "always_on";
|
|
||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
@@ -100,7 +96,6 @@ async function save() {
|
|||||||
title: title.value,
|
title: title.value,
|
||||||
statement: statement.value,
|
statement: statement.value,
|
||||||
when_to_apply: whenToApply.value,
|
when_to_apply: whenToApply.value,
|
||||||
tier: tier.value,
|
|
||||||
// Always sent, so clearing the last area actually clears it — the server
|
// Always sent, so clearing the last area actually clears it — the server
|
||||||
// reads a list as "these ARE the areas now".
|
// reads a list as "these ARE the areas now".
|
||||||
system_ids: systemIds.value,
|
system_ids: systemIds.value,
|
||||||
@@ -147,38 +142,19 @@ watch(() => props.ruleId, load);
|
|||||||
Statement <span class="required">*</span>
|
Statement <span class="required">*</span>
|
||||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||||||
When to apply
|
When to apply <span class="required">*</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="whenToApply"
|
v-model="whenToApply"
|
||||||
rows="2"
|
rows="3"
|
||||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||||||
<fieldset class="tier">
|
<strong>Without this, the rule will never reach a session.</strong>
|
||||||
<legend>How it reaches a session</legend>
|
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||||||
<label class="tier-opt">
|
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||||
<input v-model="tier" type="radio" value="always_on" />
|
</p>
|
||||||
<span>
|
|
||||||
<strong>Always on</strong>
|
|
||||||
— loaded into every session.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="tier-opt">
|
|
||||||
<input v-model="tier" type="radio" value="conditional" />
|
|
||||||
<span>
|
|
||||||
<strong>Conditional</strong>
|
|
||||||
— arrives when its trigger fires.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<p class="tier-test">
|
|
||||||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
|
||||||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
|
||||||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
|
||||||
it needs to be.
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset v-if="canon.catalog.length" class="areas">
|
<fieldset v-if="canon.catalog.length" class="areas">
|
||||||
<legend>Areas this rule is about</legend>
|
<legend>Areas this rule is about</legend>
|
||||||
@@ -190,14 +166,14 @@ watch(() => props.ruleId, load);
|
|||||||
/>
|
/>
|
||||||
<span>{{ entry.name }}</span>
|
<span>{{ entry.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="tier-test">
|
<p class="field-note">
|
||||||
What lets this rule reach a project working in that area.
|
What lets this rule reach a project working in that area.
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="check">
|
<fieldset class="check">
|
||||||
<legend>Can this rule go stale?</legend>
|
<legend>Can this rule go stale?</legend>
|
||||||
<p class="tier-test intro">
|
<p class="field-note intro">
|
||||||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
||||||
change them. Leave this empty for those. Fill it in when the rule asserts a
|
change them. Leave this empty for those. Fill it in when the rule asserts a
|
||||||
<em>fact</em> about something outside your control, because those go false quietly.
|
<em>fact</em> about something outside your control, because those go false quietly.
|
||||||
@@ -226,7 +202,7 @@ watch(() => props.ruleId, load);
|
|||||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="savedCheck" class="tier-test">
|
<p v-if="savedCheck" class="field-note">
|
||||||
Record this after actually running the check, never on the strength of the rule
|
Record this after actually running the check, never on the strength of the rule
|
||||||
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
||||||
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||||||
@@ -243,7 +219,7 @@ watch(() => props.ruleId, load);
|
|||||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="tier-test">
|
<p class="field-note">
|
||||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||||
cited, surfaced or suppressed a clause at a time.
|
cited, surfaced or suppressed a clause at a time.
|
||||||
</p>
|
</p>
|
||||||
@@ -303,9 +279,16 @@ input, textarea {
|
|||||||
}
|
}
|
||||||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
.trigger-missing textarea { border-color: var(--fs-warning); }
|
||||||
|
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||||||
|
between the warning HUE and warning text, and this is text. */
|
||||||
|
.trigger-warning {
|
||||||
|
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
|
||||||
|
color: var(--fs-warning-fg);
|
||||||
|
}
|
||||||
|
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||||
|
|
||||||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
|||||||
@@ -40,14 +40,13 @@ const loadingDetail = ref(false);
|
|||||||
// Labels rather than column names: a reader is deciding whether to open a
|
// Labels rather than column names: a reader is deciding whether to open a
|
||||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||||
type TextField =
|
type TextField =
|
||||||
| "title" | "statement" | "when_to_apply" | "tier"
|
| "title" | "statement" | "when_to_apply"
|
||||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||||
|
|
||||||
const FIELDS: Array<[TextField, string]> = [
|
const FIELDS: Array<[TextField, string]> = [
|
||||||
["title", "Title"],
|
["title", "Title"],
|
||||||
["statement", "Statement"],
|
["statement", "Statement"],
|
||||||
["when_to_apply", "When to apply"],
|
["when_to_apply", "When to apply"],
|
||||||
["tier", "Tier"],
|
|
||||||
["why", "Why"],
|
["why", "Why"],
|
||||||
["how_to_apply", "How to apply"],
|
["how_to_apply", "How to apply"],
|
||||||
["verify_with", "Check"],
|
["verify_with", "Check"],
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ const emit = defineEmits<{
|
|||||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
{{ r.title }}
|
{{ r.title }}
|
||||||
<!-- Only conditional is marked: always-on is the default and
|
<!-- Marked only when something is WRONG: every rule arrives by
|
||||||
badging every row would say nothing. -->
|
retrieval now, so "conditional" stopped distinguishing anything.
|
||||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
A missing trigger does — it means nothing can retrieve this. -->
|
||||||
|
<span
|
||||||
|
v-if="!r.when_to_apply"
|
||||||
|
class="rule-chip rule-chip-inert"
|
||||||
|
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
|
||||||
|
>never surfaces</span>
|
||||||
<!-- Present only on a rule carrying a check, so the chip's very
|
<!-- Present only on a rule carrying a check, so the chip's very
|
||||||
presence says "this one asserts a fact that can go false". -->
|
presence says "this one asserts a fact that can go false". -->
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -10,19 +10,16 @@
|
|||||||
*/
|
*/
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import type { RuleTier } from "@/api/rulebooks";
|
|
||||||
|
|
||||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const neverOnly = ref(false);
|
const neverOnly = ref(false);
|
||||||
const tier = ref<RuleTier | "">("");
|
|
||||||
const busyId = ref<number | null>(null);
|
const busyId = ref<number | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
return store.fetchRulesDue({
|
return store.fetchRulesDue({
|
||||||
neverOnly: neverOnly.value || undefined,
|
neverOnly: neverOnly.value || undefined,
|
||||||
tier: tier.value || undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +50,6 @@ onMounted(reload);
|
|||||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||||
<span>Never checked only</span>
|
<span>Never checked only</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="filter">
|
|
||||||
<span>Tier</span>
|
|
||||||
<select v-model="tier" @change="reload">
|
|
||||||
<option value="">any</option>
|
|
||||||
<option value="always_on">always on</option>
|
|
||||||
<option value="conditional">conditional</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="store.loading" class="state">Loading…</p>
|
<p v-if="store.loading" class="state">Loading…</p>
|
||||||
@@ -68,14 +57,18 @@ onMounted(reload);
|
|||||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||||
Nothing to check.
|
Nothing to check.
|
||||||
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
{{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ol v-else class="rows">
|
<ol v-else class="rows">
|
||||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||||
<div class="row-head">
|
<div class="row-head">
|
||||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||||
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
<span
|
||||||
|
v-if="!r.when_to_apply"
|
||||||
|
class="rule-chip rule-chip-inert"
|
||||||
|
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
|
||||||
|
>never surfaces</span>
|
||||||
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||||
{{ r.days_since_verified === null
|
{{ r.days_since_verified === null
|
||||||
? "never checked"
|
? "never checked"
|
||||||
|
|||||||
@@ -74,14 +74,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
<section class="pane">
|
<section class="pane">
|
||||||
<header>
|
<header>
|
||||||
<h2>Topics</h2>
|
<h2>Topics</h2>
|
||||||
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="currentRulebook.always_on"
|
|
||||||
@change="store.toggleAlwaysOn(currentRulebook.id)"
|
|
||||||
/>
|
|
||||||
<span>Always on</span>
|
|
||||||
</label>
|
|
||||||
</header>
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
@@ -124,12 +116,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
<style src="@/assets/rules-shared.css" />
|
<style src="@/assets/rules-shared.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
.always-on-toggle {
|
|
||||||
display: flex; align-items: center; gap: 0.4rem;
|
|
||||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.always-on-toggle input { cursor: pointer; }
|
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ async function submitNew() {
|
|||||||
@click="emit('select', rb.id)"
|
@click="emit('select', rb.id)"
|
||||||
>
|
>
|
||||||
<span class="title">{{ rb.title }}</span>
|
<span class="title">{{ rb.title }}</span>
|
||||||
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||||
@@ -65,16 +64,6 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
|
|||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.always-on-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--fs-accent);
|
|
||||||
color: var(--fs-text-on-action);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.sweep-entry {
|
.sweep-entry {
|
||||||
display: block; width: 100%; text-align: left;
|
display: block; width: 100%; text-align: left;
|
||||||
margin-top: var(--fs-space-3);
|
margin-top: var(--fs-space-3);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
||||||
// looking at — re-fetching unfiltered would silently widen the list under
|
// looking at — re-fetching unfiltered would silently widen the list under
|
||||||
// them at the moment they acted on it.
|
// them at the moment they acted on it.
|
||||||
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
const lastSweepOpts = ref<{ olderThanDays?: number; neverOnly?: boolean }>({});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function fetchRulebooks() {
|
async function fetchRulebooks() {
|
||||||
@@ -57,19 +57,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
|
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
|
||||||
const rb = await api.updateRulebook(id, data);
|
const rb = await api.updateRulebook(id, data);
|
||||||
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||||
if (idx >= 0) rulebooks.value[idx] = rb;
|
if (idx >= 0) rulebooks.value[idx] = rb;
|
||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleAlwaysOn(id: number) {
|
|
||||||
const current = rulebooks.value.find((r) => r.id === id);
|
|
||||||
if (!current) return;
|
|
||||||
return updateRulebook(id, { always_on: !current.always_on });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRulebook(id: number) {
|
async function deleteRulebook(id: number) {
|
||||||
await api.deleteRulebook(id);
|
await api.deleteRulebook(id);
|
||||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||||
@@ -112,7 +106,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
title: rule.title,
|
title: rule.title,
|
||||||
statement: rule.statement,
|
statement: rule.statement,
|
||||||
topic_id: rule.topic_id,
|
topic_id: rule.topic_id,
|
||||||
tier: rule.tier,
|
|
||||||
updated_at: rule.updated_at,
|
updated_at: rule.updated_at,
|
||||||
when_to_apply: rule.when_to_apply || undefined,
|
when_to_apply: rule.when_to_apply || undefined,
|
||||||
arose_from_id: rule.arose_from_id ?? undefined,
|
arose_from_id: rule.arose_from_id ?? undefined,
|
||||||
@@ -162,7 +155,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
|
|
||||||
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||||
async function fetchRulesDue(opts: {
|
async function fetchRulesDue(opts: {
|
||||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
olderThanDays?: number; neverOnly?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
lastSweepOpts.value = opts;
|
lastSweepOpts.value = opts;
|
||||||
@@ -206,7 +199,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||||
fetchRulesDue, verifyRule,
|
fetchRulesDue, verifyRule,
|
||||||
|
|||||||
@@ -716,9 +716,6 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<p v-else-if="project.inception" class="inception-line">
|
<p v-else-if="project.inception" class="inception-line">
|
||||||
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||||
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
|
|
||||||
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
|
|
||||||
</template>
|
|
||||||
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
||||||
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -156,28 +156,10 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||||
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
# The rules marker is gone with the resident set it described
|
||||||
# where it has to be captured: the model receives one from
|
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||||
# an MCP tool too, but a hook cannot see a tool's result. Stored
|
# drift a later write could be told about — a rule is retrieved at
|
||||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
# the moment it applies, which cannot be stale.
|
||||||
# so "changed since" means since THIS session loaded its rules.
|
|
||||||
#
|
|
||||||
# Written on `compact` as well as `startup`, and that is correct rather than
|
|
||||||
# convenient: a compact tells the session to re-pull its rules, so the marker
|
|
||||||
# should describe the set it is about to hold. It is also why this cannot
|
|
||||||
# cover the compaction case — see the table in services/plugin_context.py.
|
|
||||||
if [ -n "$body" ]; then
|
|
||||||
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
|
|
||||||
if [ -n "$etag" ]; then
|
|
||||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
|
||||||
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
|
||||||
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
|
||||||
# Best-effort throughout: a marker that cannot be stored costs a hint,
|
|
||||||
# never the session.
|
|
||||||
mkdir -p "$etag_dir" 2>/dev/null \
|
|
||||||
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
||||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
||||||
|
|||||||
@@ -256,18 +256,11 @@ project_rule_suppressions = Table(
|
|||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
|
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
|
||||||
# sibling of the two suppression tables below, one level up. Always-on
|
# project's opt-out of a whole always-on rulebook — which only made sense
|
||||||
# rulebooks bind every project implicitly; an inception decision can exclude
|
# while a rulebook could bind a project WITHOUT being asked. Subscription is
|
||||||
# specific ones for this project, and get_applicable_rules /
|
# now the only reach a rulebook has, so declining one is expressed by not
|
||||||
# get_applicable_rules(project_id) skips them. FKs CASCADE like the others.
|
# subscribing, and there is nothing left to opt out of.
|
||||||
project_rulebook_exclusions = Table(
|
|
||||||
"project_rulebook_exclusions",
|
|
||||||
Base.metadata,
|
|
||||||
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
||||||
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
|
||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
||||||
)
|
|
||||||
|
|
||||||
project_topic_suppressions = Table(
|
project_topic_suppressions = Table(
|
||||||
"project_topic_suppressions",
|
"project_topic_suppressions",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from scribe.models.rulebook import (
|
|||||||
Rulebook,
|
Rulebook,
|
||||||
RulebookTopic,
|
RulebookTopic,
|
||||||
project_rule_suppressions,
|
project_rule_suppressions,
|
||||||
project_rulebook_exclusions,
|
|
||||||
project_rulebook_subscriptions,
|
project_rulebook_subscriptions,
|
||||||
project_topic_suppressions,
|
project_topic_suppressions,
|
||||||
)
|
)
|
||||||
@@ -82,7 +81,7 @@ _BACKED_UP = [
|
|||||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||||
"project_topic_suppressions", "project_rulebook_exclusions",
|
"project_topic_suppressions",
|
||||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||||
"systems", "record_systems", "design_systems", "design_tokens",
|
"systems", "record_systems", "design_systems", "design_tokens",
|
||||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||||
@@ -247,8 +246,6 @@ def _topic_suppression_rows(rows) -> list[dict]:
|
|||||||
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _rulebook_exclusion_rows(rows) -> list[dict]:
|
|
||||||
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
||||||
@@ -652,9 +649,6 @@ async def export_full_backup() -> dict:
|
|||||||
topic_suppressions = (await session.execute(
|
topic_suppressions = (await session.execute(
|
||||||
select(project_topic_suppressions)
|
select(project_topic_suppressions)
|
||||||
)).all()
|
)).all()
|
||||||
rulebook_exclusions = (await session.execute(
|
|
||||||
select(project_rulebook_exclusions)
|
|
||||||
)).all()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -680,7 +674,6 @@ async def export_full_backup() -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
||||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
"rule_relations": _rule_relation_rows(rule_relations),
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
@@ -848,13 +841,8 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
project_topic_suppressions.c.project_id.in_(project_ids)
|
project_topic_suppressions.c.project_id.in_(project_ids)
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
rulebook_exclusions = (await session.execute(
|
|
||||||
select(project_rulebook_exclusions).where(
|
|
||||||
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
|
||||||
)
|
|
||||||
)).all()
|
|
||||||
else:
|
else:
|
||||||
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
subscriptions = rule_suppressions = topic_suppressions = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -882,7 +870,6 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
|
||||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
"rule_relations": _rule_relation_rows(rule_relations),
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
@@ -1028,7 +1015,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
||||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
"topic_suppressions": 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,
|
||||||
"repo_bindings": 0,
|
"repo_bindings": 0,
|
||||||
@@ -1347,15 +1334,11 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
stats["topic_suppressions"] += 1
|
stats["topic_suppressions"] += 1
|
||||||
|
|
||||||
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
||||||
for exc in data.get("rulebook_exclusions", []):
|
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
|
||||||
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
# milestone 394 removed the table. An archive carrying it still
|
||||||
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
# imports — the key is simply not looked at — because refusing a
|
||||||
if mapped_pid is None or mapped_rbid is None:
|
# backup for remembering something we deleted would make every v10-v13
|
||||||
continue
|
# archive unrestorable.
|
||||||
await session.execute(project_rulebook_exclusions.insert().values(
|
|
||||||
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
|
||||||
))
|
|
||||||
stats["rulebook_exclusions"] += 1
|
|
||||||
|
|
||||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||||
# payload restores without them rather than failing on an absent key.
|
# payload restores without them rather than failing on an absent key.
|
||||||
@@ -1680,10 +1663,12 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
inception = p_data.get("inception")
|
inception = p_data.get("inception")
|
||||||
if isinstance(inception, dict):
|
if isinstance(inception, dict):
|
||||||
choices = dict(inception.get("choices") or {})
|
choices = dict(inception.get("choices") or {})
|
||||||
choices["exclude_always_on_rulebooks"] = [
|
# DROPPED, not remapped (milestone 394). A pre-394 archive
|
||||||
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
# carries the retired exclusion choice; restoring it would put
|
||||||
if i in rulebook_id_map
|
# a key back that `validate_inception` now rejects as unknown,
|
||||||
]
|
# so the next edit to that project would fail on data this
|
||||||
|
# importer wrote.
|
||||||
|
choices.pop("exclude_always_on_rulebooks", None)
|
||||||
choices["subscribe_rulebooks"] = [
|
choices["subscribe_rulebooks"] = [
|
||||||
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
||||||
if i in rulebook_id_map
|
if i in rulebook_id_map
|
||||||
|
|||||||
@@ -2142,18 +2142,6 @@ def _stamp_line(path: str, stamped: list[dict]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
|
||||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
|
||||||
if not topic_ids:
|
|
||||||
return {}
|
|
||||||
async with async_session() as session:
|
|
||||||
rows = await session.execute(
|
|
||||||
select(RulebookTopic.id, RulebookTopic.title).where(
|
|
||||||
RulebookTopic.id.in_(topic_ids),
|
|
||||||
RulebookTopic.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return {tid: title for tid, title in rows.all()}
|
|
||||||
|
|
||||||
|
|
||||||
async def build_session_context(
|
async def build_session_context(
|
||||||
|
|||||||
+1
-1
@@ -225,7 +225,7 @@ def fake_rule(**attrs) -> MagicMock:
|
|||||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||||
# rule_brief would attach both keys on every stand-in.
|
# rule_brief would attach both keys on every stand-in.
|
||||||
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
"when_to_apply": None, "arose_from_id": None,
|
||||||
# Named for the same reason one line up, and it bites harder here.
|
# Named for the same reason one line up, and it bites harder here.
|
||||||
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
||||||
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
||||||
|
|||||||
+11
-8
@@ -32,28 +32,31 @@ def test_exclusions_table_is_the_suppressions_sibling():
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_inception_pins_the_choice_vocabulary():
|
def test_validate_inception_pins_the_choice_vocabulary():
|
||||||
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||||
assert validate_inception({}) is None
|
assert validate_inception({}) is None
|
||||||
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
assert validate_inception({"subscribe_rulebooks": [2],
|
||||||
"design_system_id": 3, "seed_systems": True}) is None
|
"design_system_id": 3, "seed_systems": True}) is None
|
||||||
assert validate_inception({"design_system_id": None}) is None
|
assert validate_inception({"design_system_id": None}) is None
|
||||||
assert "must be an object" in validate_inception([])
|
assert "must be an object" in validate_inception([])
|
||||||
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
||||||
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
|
# The retired exclusion key is now an UNKNOWN key rather than a typed one,
|
||||||
|
# which is the right error: a caller still passing it is asking for a
|
||||||
|
# choice that no longer exists, and silently ignoring it would let them
|
||||||
|
# believe a rulebook had been declined.
|
||||||
|
assert "unknown inception choice" in validate_inception(
|
||||||
|
{"exclude_always_on_rulebooks": [1]})
|
||||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
||||||
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
||||||
assert "both excluded and subscribed" in validate_inception(
|
|
||||||
{"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]})
|
|
||||||
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
||||||
assert "positive id or null" in validate_inception({"design_system_id": True})
|
assert "positive id or null" in validate_inception({"design_system_id": True})
|
||||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_choices_is_canonical_and_complete():
|
def test_normalize_choices_is_canonical_and_complete():
|
||||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
|
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
|
||||||
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
assert out == {"subscribe_rulebooks": [1, 3],
|
||||||
"design_system_id": None, "seed_systems": False}
|
"design_system_id": None, "seed_systems": False}
|
||||||
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
|
assert normalize_choices(None) == {"subscribe_rulebooks": [],
|
||||||
"design_system_id": None, "seed_systems": False}
|
"design_system_id": None, "seed_systems": False}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
|
|
||||||
|
|
||||||
The SQL is the integration lane's; here the contracts: rules_payload carries
|
|
||||||
the seventh key, list_always_on_rules takes project_id, the session-start
|
|
||||||
block names the excluded rulebooks, and the MCP tools mount.
|
|
||||||
"""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scribe.services.rulebooks import rules_payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
|
|
||||||
out = rules_payload({
|
|
||||||
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
||||||
"excluded_always_on": [{"id": 1, "title": "Family"}],
|
|
||||||
}, user_id=1, source="enter_project")
|
|
||||||
assert set(out) == {
|
|
||||||
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
|
||||||
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
|
|
||||||
}
|
|
||||||
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
|
|
||||||
# An older applicable dict without the key still renders (empty list).
|
|
||||||
assert rules_payload(
|
|
||||||
{"rules": [], "truncated": False, "subscribed_rulebooks": []},
|
|
||||||
user_id=1, source="enter_project",
|
|
||||||
)["excluded_always_on"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_always_on_rules_service_and_tool_take_a_project_id():
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from scribe.mcp.tools import rulebooks as tools
|
|
||||||
from scribe.services import rulebooks as svc
|
|
||||||
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
|
|
||||||
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_context_names_the_excluded_always_on_rulebooks():
|
|
||||||
from types import SimpleNamespace as NS
|
|
||||||
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
|
|
||||||
project = NS(id=9, title="Widget", goal="", design_system_id=None)
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)) as lao, \
|
|
||||||
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
|
||||||
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
|
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
|
|
||||||
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
|
|
||||||
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
|
||||||
"project_rules": [], "suppressed_rules": [],
|
|
||||||
"suppressed_topics": [], "excluded_always_on": []})):
|
|
||||||
out = await build_session_context(user_id=7, project_id=9)
|
|
||||||
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
|
|
||||||
assert lao.await_args.kwargs.get("project_id") == 9
|
|
||||||
assert "Excluded for this project by its inception decision" in out["context"]
|
|
||||||
assert "Design standards (#5)" in out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_exclusion_routes_are_registered():
|
|
||||||
from scribe.app import create_app
|
|
||||||
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
|
||||||
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The instruction surfaces must agree that the agent pulls the rules itself.
|
"""The instruction surfaces must agree on how a rule reaches a session.
|
||||||
|
|
||||||
WHY THIS EXISTS
|
WHY THIS EXISTS
|
||||||
|
|
||||||
@@ -17,16 +17,23 @@ an extended period, and nothing announced it. An agent trusting the push would
|
|||||||
have run with no binding rules and no signal — while those rules govern branch,
|
have run with no binding rules and no signal — while those rules govern branch,
|
||||||
commit, push and other hard-to-reverse actions.
|
commit, push and other hard-to-reverse actions.
|
||||||
|
|
||||||
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
The asymmetry is the whole argument, and it is what these tests pin: asking
|
||||||
when a push also arrived costs one redundant call; not pulling when the push
|
when a rule had already arrived costs one redundant call; not asking when
|
||||||
never came costs the operator's rules entirely.
|
nothing arrived costs the operator's rules entirely.
|
||||||
|
|
||||||
|
MILESTONE 394 SHARPENED IT RATHER THAN RETIRING IT. There is no longer a
|
||||||
|
resident set to pull, so "no rule in front of me" went from a rare and
|
||||||
|
suspicious state to the ordinary state of most turns. The instruction that
|
||||||
|
used to be supplementary — go and ask — is now the only route a rule has, and
|
||||||
|
the surfaces must additionally say what an EMPTY session means, or a session
|
||||||
|
reads silence as permission on nearly every turn.
|
||||||
|
|
||||||
WHAT THIS DOES NOT DO
|
WHAT THIS DOES NOT DO
|
||||||
|
|
||||||
It cannot tell whether two surfaces contradict each other in prose generally —
|
It cannot tell whether two surfaces contradict each other in prose generally —
|
||||||
that needs a reader. It pins the one instruction whose absence is known to be
|
that needs a reader. It pins the instructions whose absence is known to be
|
||||||
load-bearing, and the specific shape the #2497 defect took: naming the push
|
load-bearing, and the specific shape the #2497 defect took: naming the push
|
||||||
without also stating the pull.
|
without also stating how to ask.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -34,8 +41,33 @@ import pathlib
|
|||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
# The pull instruction, however a surface phrases the surrounding prose.
|
# THE PULL IS NOW THE ASK (milestone 394). This was `list_always_on_rules`,
|
||||||
PULL = "list_always_on_rules"
|
# the call that fetched the resident set. There is no resident set and no such
|
||||||
|
# call: a rule reaches a session by retrieval, and the only thing a session can
|
||||||
|
# DO about a rule it has not been handed is go looking for one.
|
||||||
|
#
|
||||||
|
# So the two halves this file used to pin separately — "pull the resident set"
|
||||||
|
# and "and retrieve the conditional ones too" — have collapsed into one
|
||||||
|
# instruction, and it is the load-bearing one rather than the supplementary
|
||||||
|
# one it used to be.
|
||||||
|
ASK = 'content_type="rule"'
|
||||||
|
|
||||||
|
# A surface must also say what an EMPTY session means, which is the half that
|
||||||
|
# is newly dangerous. Under residency, "no rule in front of me" was rare and
|
||||||
|
# suspicious. Under retrieval it is the ordinary state of most turns, so a
|
||||||
|
# session that reads it as "there is no rule" is wrong on nearly every turn
|
||||||
|
# rather than occasionally — the #3720 defect at session scale.
|
||||||
|
#
|
||||||
|
# Claim phrases, not a single word, for the reason BINDING_CLAIMS gives below:
|
||||||
|
# a bare "matched" or "silence" appears in prose that is not making this claim
|
||||||
|
# at all. A surface passes by asserting the distinction however it words it.
|
||||||
|
ABSENCE_CLAIMS = (
|
||||||
|
"nothing matched",
|
||||||
|
"is not the same as \"there is no rule",
|
||||||
|
"never \"there is no rule",
|
||||||
|
"silence is not absence",
|
||||||
|
"not evidence there is none",
|
||||||
|
)
|
||||||
|
|
||||||
# Surfaces a session loads before substantive work. Hand-written because
|
# Surfaces a session loads before substantive work. Hand-written because
|
||||||
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
# "is this a session-start surface?" is an editorial fact, not a derivable one —
|
||||||
@@ -67,21 +99,51 @@ def _all_surfaces() -> list[tuple[str, str]]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_states_the_pull():
|
def test_every_session_start_surface_states_the_ask():
|
||||||
|
"""Retrieval is the only delivery, so asking is the only recourse."""
|
||||||
missing = []
|
missing = []
|
||||||
for path in SESSION_START_SURFACES:
|
for path in SESSION_START_SURFACES:
|
||||||
assert path.exists(), (
|
assert path.exists(), (
|
||||||
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
f"{path.relative_to(ROOT)} is gone — it was one of the surfaces "
|
||||||
f"carrying the load-the-rules instruction. If it moved, update "
|
f"carrying the rules instruction. If it moved, update "
|
||||||
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
f"SESSION_START_SURFACES; if it was retired, check the instruction "
|
||||||
f"still lives somewhere a fresh session reads."
|
f"still lives somewhere a fresh session reads."
|
||||||
)
|
)
|
||||||
if PULL not in path.read_text():
|
if ASK not in path.read_text():
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
missing.append(str(path.relative_to(ROOT)))
|
||||||
assert not missing, (
|
assert not missing, (
|
||||||
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
f"these surfaces never tell the agent how to ask for a rule "
|
||||||
f"The rules are pull-only and the push is best-effort, so a surface "
|
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
||||||
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
f"surface that omits this leaves a session with no way to reach a rule "
|
||||||
|
f"it was not handed — bound by nothing (#2198, #2497, milestone 394)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_session_start_surface_says_an_empty_session_is_not_an_empty_rulebook():
|
||||||
|
"""The half that got dangerous when residency went away.
|
||||||
|
|
||||||
|
Under the old model a session opened holding every applicable rule, so
|
||||||
|
"nothing is in front of me" was a rare state and a suspicious one. Under
|
||||||
|
retrieval it is the NORMAL state of most turns. A surface that describes
|
||||||
|
where rules come from, without also saying what their absence means, leaves
|
||||||
|
a session reading silence as permission — on nearly every turn rather than
|
||||||
|
occasionally.
|
||||||
|
|
||||||
|
That is #3720's defect ("absence reads as non-existence") moved from a
|
||||||
|
readout to the session itself, and this milestone is what makes every
|
||||||
|
session start in the absent state.
|
||||||
|
"""
|
||||||
|
missing = []
|
||||||
|
for path in SESSION_START_SURFACES:
|
||||||
|
text = path.read_text().lower()
|
||||||
|
if not any(c.lower() in text for c in ABSENCE_CLAIMS):
|
||||||
|
missing.append(str(path.relative_to(ROOT)))
|
||||||
|
assert not missing, (
|
||||||
|
f"these surfaces say how a rule arrives but never what it means when "
|
||||||
|
f"none does: {missing}. 'No rule arrived' means 'nothing matched', "
|
||||||
|
f"never 'there is no rule' — and only one of those has been checked. "
|
||||||
|
f"Say it however you like; one of {ABSENCE_CLAIMS} is what this looks "
|
||||||
|
f"for."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,47 +257,7 @@ def test_displaced_topics_live_on_a_delivered_surface():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# The SECOND pull (milestone 333 step 3). `list_always_on_rules()` fetches the
|
def test_no_surface_names_the_push_without_stating_the_ask():
|
||||||
# resident tier; this one says that tier is not all of them, and that a
|
|
||||||
# conditional rule has to be gone looking for. However a surface words the
|
|
||||||
# surrounding prose, it names the call.
|
|
||||||
RETRIEVE = 'content_type="rule"'
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_session_start_surface_states_the_conditional_retrieval():
|
|
||||||
"""The push/pull asymmetry, one level in.
|
|
||||||
|
|
||||||
The tests above pin that a session PULLS the resident rules rather than
|
|
||||||
trusting the SessionStart push. This pins the same shape between the two
|
|
||||||
TIERS: an always-on rule is delivered, a conditional one is retrieved, and
|
|
||||||
a surface that states only the first leaves a session reading its loaded
|
|
||||||
set as the whole rulebook.
|
|
||||||
|
|
||||||
That reading is wrong in the direction that costs something. "Nothing was
|
|
||||||
pushed" and "no rule applies" are different claims, and only one of them
|
|
||||||
has been checked — the same asymmetry as #2198, now between tiers instead
|
|
||||||
of between channels.
|
|
||||||
|
|
||||||
It is also what made the always-on tier the only one that worked, on any
|
|
||||||
install rather than this one (rule 115). A rule nothing retrieves has to be
|
|
||||||
resident to bind at all, so every rule worth keeping becomes resident; and
|
|
||||||
a resident rule costs tokens in every session forever, so a rulebook that
|
|
||||||
only delivers cannot grow past what one session can hold. Retrieval is what
|
|
||||||
lifts that ceiling — and it only fires if something asks.
|
|
||||||
"""
|
|
||||||
missing = []
|
|
||||||
for path in SESSION_START_SURFACES:
|
|
||||||
if RETRIEVE not in path.read_text():
|
|
||||||
missing.append(str(path.relative_to(ROOT)))
|
|
||||||
assert not missing, (
|
|
||||||
f"these surfaces state the always-on pull but never tell the agent to "
|
|
||||||
f"retrieve a conditional rule ({RETRIEVE}): {missing}. A session that "
|
|
||||||
f"reads its loaded set as the whole rulebook will act on \"I was not "
|
|
||||||
f"told\" as if it meant \"there is no rule\" (milestone 333 step 3)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_surface_names_the_push_without_stating_the_pull():
|
|
||||||
"""The exact shape #2497 took.
|
"""The exact shape #2497 took.
|
||||||
|
|
||||||
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
Mentioning the SessionStart hook is fine and often useful. Mentioning it
|
||||||
@@ -244,13 +266,14 @@ def test_no_surface_names_the_push_without_stating_the_pull():
|
|||||||
"""
|
"""
|
||||||
offenders = [
|
offenders = [
|
||||||
label for label, text in _all_surfaces()
|
label for label, text in _all_surfaces()
|
||||||
if "SessionStart" in text and PULL not in text
|
if "SessionStart" in text and ASK not in text
|
||||||
]
|
]
|
||||||
assert not offenders, (
|
assert not offenders, (
|
||||||
f"these surfaces describe the SessionStart push but never state the "
|
f"these surfaces describe the SessionStart push but never state how to "
|
||||||
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
||||||
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
f"bridge — it can be absent without saying so, and since milestone 394 "
|
||||||
f"but say to call {PULL}() regardless."
|
f"it carries no rules at all. Name it if it helps, but say how to ask "
|
||||||
|
f"({ASK}) regardless."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,14 +117,12 @@ async def source():
|
|||||||
statement="Use sh.",
|
statement="Use sh.",
|
||||||
why="the image ships no bash",
|
why="the image ships no bash",
|
||||||
verify_with="read the workflow's shell setting",
|
verify_with="read the workflow's shell setting",
|
||||||
tier="always_on",
|
|
||||||
),
|
),
|
||||||
# The actor is already gone — what SET NULL leaves behind.
|
# The actor is already gone — what SET NULL leaves behind.
|
||||||
RuleVersion(
|
RuleVersion(
|
||||||
rule_id=rule.id, user_id=None,
|
rule_id=rule.id, user_id=None,
|
||||||
title="The runner has no bash",
|
title="The runner has no bash",
|
||||||
statement="Use POSIX sh in run steps.",
|
statement="Use POSIX sh in run steps.",
|
||||||
tier="always_on",
|
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
await s.commit()
|
await s.commit()
|
||||||
@@ -263,4 +261,3 @@ async def test_the_text_survives(restored):
|
|||||||
assert by_statement["Use sh."].verify_with == (
|
assert by_statement["Use sh."].verify_with == (
|
||||||
"read the workflow's shell setting"
|
"read the workflow's shell setting"
|
||||||
)
|
)
|
||||||
assert by_statement["Use sh."].tier == "always_on"
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import pytest_asyncio
|
|||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
from scribe.services import inception as inception_svc
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import canonical_systems as canonical_svc
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
@@ -31,12 +30,12 @@ async def seeded():
|
|||||||
await s.flush()
|
await s.flush()
|
||||||
ids = {"owner": owner.id, "pid": project.id}
|
ids = {"owner": owner.id, "pid": project.id}
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
# Two ordinary rulebooks. One was flagged always-on until milestone 394
|
||||||
|
# removed the tier; a rulebook now reaches a project only by subscription,
|
||||||
|
# so what used to be "binds automatically" and "binds if you opt in" are
|
||||||
|
# the same kind of thing.
|
||||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
||||||
async with async_session() as s:
|
|
||||||
rb = await s.get(Rulebook, always.id)
|
|
||||||
rb.always_on = True
|
|
||||||
await s.commit()
|
|
||||||
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
||||||
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
||||||
@@ -48,20 +47,20 @@ async def seeded():
|
|||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||||
owner, pid = seeded["owner"], seeded["pid"]
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
# Undecided: nothing binds, because nothing has been subscribed. Before
|
||||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
# milestone 394 the always-on rulebook bound here without being asked for,
|
||||||
|
# and this assertion read the other way round.
|
||||||
defaults = await inception_svc.current_defaults(owner, pid)
|
defaults = await inception_svc.current_defaults(owner, pid)
|
||||||
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||||
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
[seeded["always"], seeded["other"]])
|
||||||
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
||||||
|
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
|
||||||
|
|
||||||
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
"exclude_always_on_rulebooks": [seeded["always"]],
|
|
||||||
"subscribe_rulebooks": [seeded["other"]],
|
"subscribe_rulebooks": [seeded["other"]],
|
||||||
"design_system_id": None,
|
"design_system_id": None,
|
||||||
"seed_systems": True,
|
"seed_systems": True,
|
||||||
})
|
})
|
||||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
|
||||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||||
catalog = await canonical_svc.list_canonical_systems()
|
catalog = await canonical_svc.list_canonical_systems()
|
||||||
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||||
@@ -69,35 +68,32 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
|
|||||||
seeded_systems = await systems_svc.list_systems(owner, pid)
|
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||||
assert all(s.canonical_id is not None for s in seeded_systems)
|
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||||
|
|
||||||
# The exclusion is total: the project's always-on set is empty, the
|
# The subscription is what binds, and it is the ONLY thing that does —
|
||||||
# departure is named, the subscription binds.
|
# the unsubscribed rulebook contributes nothing even though it used to
|
||||||
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
# bind every project by default.
|
||||||
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
|
||||||
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
||||||
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
|
|
||||||
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
|
|
||||||
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
||||||
|
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
|
||||||
# The record, written last, says why.
|
# The record, written last, says why.
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
project = await s.get(Project, pid)
|
project = await s.get(Project, pid)
|
||||||
assert inception_svc.is_decided(project)
|
assert inception_svc.is_decided(project)
|
||||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||||
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
# Re-deciding with seed again mints nothing twice.
|
||||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||||
assert again["effects"]["systems_seeded"] == []
|
assert again["effects"]["systems_seeded"] == []
|
||||||
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||||
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
|
||||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_a_bad_decision_applies_nothing(seeded):
|
async def test_a_bad_decision_applies_nothing(seeded):
|
||||||
owner, pid = seeded["owner"], seeded["pid"]
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
# A subscription to a rulebook that is not yours is refused BEFORE any
|
||||||
with pytest.raises(ValueError, match="not always-on"):
|
# effect lands — the seed must not happen on a decision that fails.
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
await inception_svc.decide(owner, pid, via="mcp", choices={
|
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||||
})
|
})
|
||||||
assert await systems_svc.list_systems(owner, pid) == []
|
assert await systems_svc.list_systems(owner, pid) == []
|
||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5).
|
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
|
||||||
|
narrowed by 394).
|
||||||
|
|
||||||
What mocks can't prove, and what this milestone must not get wrong:
|
What mocks can't prove, and what this design must not get wrong:
|
||||||
|
|
||||||
1. **Nothing stops binding.** A rule with no tier, no areas and no edges
|
1. A rule is invisible to a project that doesn't work in its area, and
|
||||||
behaves exactly as it did before tiers existed. That is the one failure this
|
arrives — binding, not suggested — to one that does. Area matching is
|
||||||
whole design must not produce, and it is asserted first.
|
DETERMINISTIC: a tag comparison, never a similarity score.
|
||||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
2. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||||
and arrives — binding, not suggested — to one that does.
|
|
||||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
|
||||||
that made merging rule 144 into rule 46 look like the only fix.
|
that made merging rule 144 into rule 46 look like the only fix.
|
||||||
4. An explicit suppression outranks an edge.
|
3. An explicit suppression outranks an edge.
|
||||||
|
|
||||||
|
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which
|
||||||
|
rather than leaving a shorter list. "A rule with no tier binds exactly as
|
||||||
|
before" and "a conditional rule is reachable, not resident" were both about
|
||||||
|
the always-on tier. There is no tier and no resident payload, so neither
|
||||||
|
states anything that can now be true or false — they were not failing, they
|
||||||
|
had stopped being claims.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
from scribe.services import canonical_systems as canonical_svc
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
@@ -27,12 +32,13 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def world():
|
async def world():
|
||||||
"""A project with TWO rulebooks, because the two payloads are different sets.
|
"""A project with two rulebooks — one subscribed, one not.
|
||||||
|
|
||||||
`list_always_on_rules` covers always-on rulebooks; `get_applicable_rules`
|
Both are ordinary rulebooks since milestone 394; the fixture used to flag
|
||||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
one always-on because that was a second, separate way to reach a project.
|
||||||
assert nothing, so the fixture carries one of each and every test says
|
Keeping two is still worth it: a rulebook nobody subscribed to must
|
||||||
which payload it is about.
|
contribute nothing, and a fixture with only the subscribed one could not
|
||||||
|
tell "correctly scoped" from "returns everything".
|
||||||
"""
|
"""
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
owner = await ensure_user(s, "surfacing_owner")
|
owner = await ensure_user(s, "surfacing_owner")
|
||||||
@@ -43,10 +49,6 @@ async def world():
|
|||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
async with async_session() as s:
|
|
||||||
rb = await s.get(Rulebook, always.id)
|
|
||||||
rb.always_on = True
|
|
||||||
await s.commit()
|
|
||||||
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
await rulebooks_svc.create_rule(
|
await rulebooks_svc.create_rule(
|
||||||
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||||
@@ -72,39 +74,8 @@ async def _titles(ids) -> set[str]:
|
|||||||
return {r["title"] for r in applicable["rules"]}
|
return {r["title"] for r in applicable["rules"]}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_a_rule_with_no_tier_no_areas_and_no_edges_binds_exactly_as_before(world):
|
|
||||||
"""THE compatibility guarantee. An install upgrades and every rule it
|
|
||||||
already had keeps arriving — no tier set, no areas, no edges, still bound.
|
|
||||||
Getting this wrong would silently stop enforcing rules people rely on,
|
|
||||||
which is worse than any amount of payload bloat."""
|
|
||||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
|
||||||
assert "dev is home" in {r.title for r in always_on}
|
|
||||||
assert "Between batches, keep stacking" in await _titles(world)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_a_conditional_rule_is_reachable_not_resident(world):
|
|
||||||
"""It leaves the session-start payload entirely — that is the point of the
|
|
||||||
tier — and it does NOT reach a project with no matching area."""
|
|
||||||
# In the ALWAYS-ON book: the tier alone keeps it out of the session-start
|
|
||||||
# payload, which is the whole point of the tier.
|
|
||||||
resident = await rulebooks_svc.create_rule(
|
|
||||||
world["always_topic"], world["owner"], "Release tagging", "Derive the tag.",
|
|
||||||
when_to_apply="when cutting a release", tier="conditional",
|
|
||||||
)
|
|
||||||
assert resident.tier == "conditional"
|
|
||||||
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
|
||||||
assert "Release tagging" not in {r.title for r in always_on}
|
|
||||||
|
|
||||||
# In the SUBSCRIBED book, untagged: the project has no area to reach it by,
|
|
||||||
# so it stays out of the project payload too. Absent for a DIFFERENT reason
|
|
||||||
# than above, which is why both are asserted.
|
|
||||||
await rulebooks_svc.create_rule(
|
|
||||||
world["topic"], world["owner"], "Untagged conditional", "No area yet.",
|
|
||||||
when_to_apply="sometime", tier="conditional",
|
|
||||||
)
|
|
||||||
assert "Untagged conditional" not in await _titles(world)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -117,7 +88,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
|||||||
|
|
||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||||
when_to_apply="when cutting a release", tier="conditional",
|
when_to_apply="when cutting a release",
|
||||||
)
|
)
|
||||||
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
||||||
|
|
||||||
@@ -145,7 +116,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
|||||||
partner = await rulebooks_svc.create_rule(
|
partner = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Version names are labels",
|
world["topic"], world["owner"], "Version names are labels",
|
||||||
"A name decides nothing.",
|
"A name decides nothing.",
|
||||||
when_to_apply="when naming a build", tier="conditional",
|
when_to_apply="when naming a build",
|
||||||
)
|
)
|
||||||
await rulebooks_svc.add_rule_relation(
|
await rulebooks_svc.add_rule_relation(
|
||||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
@@ -163,7 +134,6 @@ async def test_a_suppression_outranks_an_edge(world):
|
|||||||
does not want that one. An explicit decision beats an inferred one."""
|
does not want that one. An explicit decision beats an inferred one."""
|
||||||
partner = await rulebooks_svc.create_rule(
|
partner = await rulebooks_svc.create_rule(
|
||||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||||
tier="conditional",
|
|
||||||
)
|
)
|
||||||
await rulebooks_svc.add_rule_relation(
|
await rulebooks_svc.add_rule_relation(
|
||||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async def test_rewording_the_check_drops_the_stamp(constraint):
|
|||||||
"""A stamp certifies a check, not a rule.
|
"""A stamp certifies a check, not a rule.
|
||||||
|
|
||||||
The safe direction, for the same reason _valid_tier falls back to
|
The safe direction, for the same reason _valid_tier falls back to
|
||||||
always_on: a rule wrongly listed as due costs one look, a rule wrongly
|
the safe direction: a rule wrongly listed as due costs one look, a rule wrongly
|
||||||
vouched for costs exactly what the sweep exists to catch.
|
vouched for costs exactly what the sweep exists to catch.
|
||||||
"""
|
"""
|
||||||
await rulebooks_svc.update_rule(
|
await rulebooks_svc.update_rule(
|
||||||
@@ -162,7 +162,6 @@ async def rulebook_of_three():
|
|||||||
stale = await rulebooks_svc.create_rule(
|
stale = await rulebooks_svc.create_rule(
|
||||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||||
verify_with="cat CI-runner/renovate/config.js",
|
verify_with="cat CI-runner/renovate/config.js",
|
||||||
tier="conditional",
|
|
||||||
)
|
)
|
||||||
async with async_session() as s:
|
async with async_session() as s:
|
||||||
row = await s.get(Rule, stale.id)
|
row = await s.get(Rule, stale.id)
|
||||||
@@ -256,12 +255,6 @@ async def test_never_only_and_the_age_filter_narrow_to_what_they_say(rulebook_of
|
|||||||
assert rulebook_of_three["never"] in aged
|
assert rulebook_of_three["never"] in aged
|
||||||
|
|
||||||
|
|
||||||
async def test_the_tier_filter_narrows_to_one_tier(rulebook_of_three):
|
|
||||||
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
|
||||||
rulebook_of_three["uid"], tier="conditional",
|
|
||||||
)]
|
|
||||||
assert rulebook_of_three["stale"] in ids
|
|
||||||
assert rulebook_of_three["never"] not in ids
|
|
||||||
|
|
||||||
|
|
||||||
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
||||||
|
|||||||
@@ -412,10 +412,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
|
|||||||
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
||||||
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
|
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
|
||||||
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
|
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
|
||||||
out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True)
|
||||||
kw = decide.await_args.kwargs
|
kw = decide.await_args.kwargs
|
||||||
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
|
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
|
||||||
assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [],
|
assert kw["choices"] == {"subscribe_rulebooks": [1],
|
||||||
"design_system_id": None, "seed_systems": True}
|
"design_system_id": None, "seed_systems": True}
|
||||||
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
||||||
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
||||||
"subscribed_rulebooks": [], "excluded_always_on": []}
|
"subscribed_rulebooks": []}
|
||||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||||
|
|
||||||
async def run(project):
|
async def run(project):
|
||||||
@@ -466,6 +466,6 @@ def test_inception_routes_and_tool_are_registered():
|
|||||||
mcp = build_mcp_server()
|
mcp = build_mcp_server()
|
||||||
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
||||||
tool = mcp._tool_manager.get_tool("create_project")
|
tool = mcp._tool_manager.get_tool("create_project")
|
||||||
for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"):
|
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"):
|
||||||
assert name in tool.parameters.get("properties", {}), name
|
assert name in tool.parameters.get("properties", {}), name
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,9 @@ def test_register_attaches_every_tool():
|
|||||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
||||||
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
||||||
# (milestone 399).
|
# (milestone 399).
|
||||||
assert len(mcp.names) == 31
|
# 28 since milestone 394 took list_always_on_rules and the two
|
||||||
|
# always-on exclusion tools with the tier they served.
|
||||||
|
assert len(mcp.names) == 28
|
||||||
# spot-check a few names
|
# spot-check a few names
|
||||||
assert "list_rulebooks" in mcp.names
|
assert "list_rulebooks" in mcp.names
|
||||||
assert "create_rule" in mcp.names
|
assert "create_rule" in mcp.names
|
||||||
@@ -236,10 +238,6 @@ def test_register_attaches_every_tool():
|
|||||||
assert "create_preference" in mcp.names
|
assert "create_preference" in mcp.names
|
||||||
assert "update_preference" in mcp.names
|
assert "update_preference" in mcp.names
|
||||||
assert "subscribe_project_to_rulebook" in mcp.names
|
assert "subscribe_project_to_rulebook" in mcp.names
|
||||||
assert "list_always_on_rules" in mcp.names
|
|
||||||
# milestone 297: a project's opt-out of a whole always-on rulebook
|
|
||||||
assert "exclude_always_on_rulebook" in mcp.names
|
|
||||||
assert "include_always_on_rulebook" in mcp.names
|
|
||||||
assert "create_project_rule" in mcp.names
|
assert "create_project_rule" in mcp.names
|
||||||
assert "suppress_rule_for_project" in mcp.names
|
assert "suppress_rule_for_project" in mcp.names
|
||||||
# milestone 312: the sweep, and the stamp that answers it
|
# milestone 312: the sweep, and the stamp that answers it
|
||||||
@@ -252,57 +250,12 @@ def test_register_attaches_every_tool():
|
|||||||
assert "unsuppress_topic_for_project" in mcp.names
|
assert "unsuppress_topic_for_project" in mcp.names
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
|
|
||||||
with patch(
|
|
||||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=[]),
|
|
||||||
):
|
|
||||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
|
||||||
out = await list_always_on_rules()
|
|
||||||
# An install with no always-on rulebooks still gets a marker (milestone
|
|
||||||
# 323): "no rules" is a STATE, and a payload that omitted the key would
|
|
||||||
# make the write path read every session on a fresh install as a change.
|
|
||||||
assert out == {"rules": [], "total": 0, "rules_etag": "empty|0"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_always_on_rules_projects_each_rule():
|
|
||||||
rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
|
|
||||||
with patch(
|
|
||||||
"scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules),
|
|
||||||
):
|
|
||||||
from scribe.mcp.tools.rulebooks import list_always_on_rules
|
|
||||||
out = await list_always_on_rules()
|
|
||||||
assert out["total"] == 2
|
|
||||||
assert {r["id"] for r in out["rules"]} == {100, 101}
|
|
||||||
assert all("topic_id" in r for r in out["rules"])
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_rulebook_forwards_always_on_when_set():
|
|
||||||
rb = fake_rulebook(id=1, title="t")
|
|
||||||
mock = AsyncMock(return_value=rb)
|
|
||||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
|
||||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
|
||||||
await update_rulebook(rulebook_id=1, always_on=True)
|
|
||||||
kwargs = mock.call_args.kwargs
|
|
||||||
assert kwargs.get("always_on") is True
|
|
||||||
assert "title" not in kwargs
|
|
||||||
assert "description" not in kwargs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_rulebook_omits_always_on_when_none():
|
|
||||||
rb = fake_rulebook(id=1, title="t")
|
|
||||||
mock = AsyncMock(return_value=rb)
|
|
||||||
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
|
||||||
from scribe.mcp.tools.rulebooks import update_rulebook
|
|
||||||
await update_rulebook(rulebook_id=1, title="new title")
|
|
||||||
kwargs = mock.call_args.kwargs
|
|
||||||
assert "always_on" not in kwargs
|
|
||||||
assert kwargs["title"] == "new title"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -454,7 +407,7 @@ def _fake_version(**over):
|
|||||||
"id": 5, "rule_id": 100, "user_id": 1,
|
"id": 5, "rule_id": 100, "user_id": 1,
|
||||||
"title": "The runner has no bash", "statement": "Use sh.",
|
"title": "The runner has no bash", "statement": "Use sh.",
|
||||||
"why": "the image ships no bash", "how_to_apply": None,
|
"why": "the image ships no bash", "how_to_apply": None,
|
||||||
"when_to_apply": None, "tier": "always_on",
|
"when_to_apply": None,
|
||||||
"verify_with": "read the workflow's shell setting",
|
"verify_with": "read the workflow's shell setting",
|
||||||
"expires_when": None,
|
"expires_when": None,
|
||||||
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def test_service_signatures_require_user_id():
|
|||||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||||
"create_rule", "create_project_rule", "rule_detail",
|
"create_rule", "create_project_rule", "rule_detail",
|
||||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||||
"list_rules", "list_always_on_rules",
|
"list_rules",
|
||||||
"get_rule", "update_rule", "delete_rule",
|
"get_rule", "update_rule", "delete_rule",
|
||||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||||
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
"suppress_rule_for_project", "unsuppress_rule_for_project",
|
||||||
@@ -93,24 +93,8 @@ def test_suppression_association_tables_declared():
|
|||||||
assert "rule_id" in cols or "topic_id" in cols
|
assert "rule_id" in cols or "topic_id" in cols
|
||||||
|
|
||||||
|
|
||||||
def test_rulebook_model_carries_always_on():
|
|
||||||
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
|
||||||
from scribe.models.rulebook import Rulebook
|
|
||||||
assert "always_on" in Rulebook.__table__.columns
|
|
||||||
col = Rulebook.__table__.columns["always_on"]
|
|
||||||
assert col.nullable is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_rulebook_route_accepts_always_on():
|
|
||||||
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
|
||||||
|
|
||||||
The handler filters body keys against a whitelist; that whitelist needs to
|
|
||||||
include always_on or toggling from the UI silently drops the field.
|
|
||||||
"""
|
|
||||||
import inspect as _inspect
|
|
||||||
from scribe.routes import rulebooks as rb_routes
|
|
||||||
src = _inspect.getsource(rb_routes.update_rulebook)
|
|
||||||
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rule_and_subscription_handlers_callable():
|
def test_rule_and_subscription_handlers_callable():
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ WHY THIS EXISTS
|
|||||||
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
#3749 clears the ledger when an EVENT destroys context — a compaction, a
|
||||||
/clear. This covers the case with no event at all: a long session where a rule
|
/clear. This covers the case with no event at all: a long session where a rule
|
||||||
was named two hundred turns ago and has simply fallen out of attention. Same
|
was named two hundred turns ago and has simply fallen out of attention. Same
|
||||||
argument #3702 made at the tier level (present in context and salient at the
|
argument #3702 made about tiers (present in context and salient at the
|
||||||
moment are different properties), applied to time instead of to tier.
|
moment are different properties), applied to time instead. The tier itself is
|
||||||
|
gone since milestone 394; the distinction it taught is what survives.
|
||||||
|
|
||||||
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
|
||||||
|
|
||||||
|
|||||||
@@ -167,14 +167,21 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
|||||||
kw = search.await_args.kwargs
|
kw = search.await_args.kwargs
|
||||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
# NO CATEGORY FILTER (#3702, repointed by 394). This asserted that the arm
|
||||||
# because "already in the session" is not the same as "in front of the
|
# passed no `tier`. That parameter no longer exists, so the assertion had
|
||||||
# reader at the moment it applies" — and relevance is the threshold's
|
# become one that could not fail — which rule 167 rates below having none.
|
||||||
# job, not a category's. If this assertion is failing because a tier
|
#
|
||||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
# The claim it was making is still live, on the axis that DOES still exist:
|
||||||
# filter may legitimately return, but only carrying a measured reason.
|
# the act arms narrow by nothing, so a preference reaches a write exactly
|
||||||
assert "tier" not in kw or kw["tier"] is None, (
|
# as a rule does. "Already in the session" was never the same as "in front
|
||||||
"the arm is filtering the rule corpus by tier again"
|
# of the reader at the moment it applies", and relevance is the
|
||||||
|
# threshold's job rather than a category's. The one place a kind filter
|
||||||
|
# belongs is the reserved preference slot, which asks for a kind BECAUSE
|
||||||
|
# it is guaranteeing that kind a place.
|
||||||
|
assert "kind" not in kw or kw["kind"] is None, (
|
||||||
|
"the act arm is narrowing the rule corpus by kind — a preference and "
|
||||||
|
"a rule both apply to a write, and filtering here is how one of them "
|
||||||
|
"silently stops arriving"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
"""The staleness marker on the rules payload (milestone 323 step 5).
|
|
||||||
|
|
||||||
WHAT THE MARKER IS FOR: telling a session that the rules it is holding have
|
|
||||||
MOVED since it loaded them. Not general staleness — the limitation is stated
|
|
||||||
in services/rulebooks.py and in the write-path arm, and two tests here pin the
|
|
||||||
cases that would otherwise be quietly lost.
|
|
||||||
|
|
||||||
The two that matter most are both about NOT crying wolf. A marker that reports
|
|
||||||
a change when nothing changed gets ignored within a day, and an ignored
|
|
||||||
staleness signal is worse than none: it trains a reader to skip the line that
|
|
||||||
will one day be true.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scribe.services import rulebooks as svc
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 30, 12, 0, tzinfo=timezone.utc)
|
|
||||||
LATER = NOW + timedelta(hours=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule(updated_at=NOW, rid=1, title="a rule"):
|
|
||||||
"""A Rule-shaped stand-in. The marker only ever reads three attributes,
|
|
||||||
and a real model would need a session to build."""
|
|
||||||
return SimpleNamespace(id=rid, title=title, updated_at=updated_at)
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_same_set_produces_the_same_marker():
|
|
||||||
"""The whole mechanism rests on this. If the marker moved on its own, every
|
|
||||||
write would report a change and the line would be noise by lunchtime."""
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2, updated_at=LATER)]
|
|
||||||
assert svc.rules_etag(rules) == svc.rules_etag(list(reversed(rules))), (
|
|
||||||
"the marker depends on the ORDER rules come back in, so any query "
|
|
||||||
"whose sort changes would look like an edit"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_edit_moves_the_marker():
|
|
||||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
after = svc.rules_etag([_rule(rid=1), _rule(rid=2, updated_at=LATER)])
|
|
||||||
assert before != after
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_DELETED_rule_moves_the_marker():
|
|
||||||
"""THE CASE max(updated_at) ALONE CANNOT SEE, and the reason the count is
|
|
||||||
in there. Deleting a rule moves no timestamp — and it is the single change
|
|
||||||
that takes an instruction OUT of force, which is the one a session most
|
|
||||||
needs to hear about."""
|
|
||||||
before = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
after = svc.rules_etag([_rule(rid=1)])
|
|
||||||
assert before != after, (
|
|
||||||
"a deleted rule left the marker unchanged — the count is missing, and "
|
|
||||||
"the session would keep obeying an instruction that no longer exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_rules_is_a_state_not_a_change():
|
|
||||||
"""Rule 115: this has to behave on an install with no rules at all. `max()`
|
|
||||||
over an empty set raises; a marker that raised would take the whole write
|
|
||||||
path's hint down, and one that varied would tell every session on a fresh
|
|
||||||
install that its rules had changed."""
|
|
||||||
assert svc.rules_etag([]) == svc.rules_etag([])
|
|
||||||
assert svc.rules_etag([]) != svc.rules_etag([_rule()])
|
|
||||||
|
|
||||||
|
|
||||||
def test_moved_since_names_only_what_actually_moved():
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, updated_at=LATER, title="reworded")]
|
|
||||||
moved = svc.rules_moved_since(current, held)
|
|
||||||
assert [r.id for r in moved] == [2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_matching_marker_names_nothing():
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2)]
|
|
||||||
assert svc.rules_moved_since(rules, svc.rules_etag(rules)) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_an_unreadable_marker_reports_no_change():
|
|
||||||
"""A caller cannot act on "something differs but I cannot say what", and a
|
|
||||||
garbled marker must never be rendered as a change — that is the shape of a
|
|
||||||
signal that gets ignored."""
|
|
||||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "not-an-etag") == []
|
|
||||||
assert svc.rules_moved_since([_rule(updated_at=LATER)], "") == []
|
|
||||||
assert svc.etag_count("garbled") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_empty_marker_reports_no_change():
|
|
||||||
"""An install that had no rules and now has some: the count says so, and
|
|
||||||
this function has no timestamp to reason from. Silence here, not a claim."""
|
|
||||||
assert svc.rules_moved_since([_rule()], svc.rules_etag([])) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_count_survives_the_round_trip():
|
|
||||||
assert svc.etag_count(svc.rules_etag([_rule(rid=1), _rule(rid=2)])) == 2
|
|
||||||
assert svc.etag_count(svc.rules_etag([])) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_limitation_is_stated_where_a_reader_will_be():
|
|
||||||
"""A future reader who finds an etag will assume it covers staleness
|
|
||||||
generally. It does not — it is blind to compaction, which is the most
|
|
||||||
common case — and the SessionStart nudge is that case's only mechanism.
|
|
||||||
|
|
||||||
Pinned because the plausible mistake is retiring a nudge that works on the
|
|
||||||
strength of a signal that does not cover it, and the comment is the only
|
|
||||||
thing standing in the way.
|
|
||||||
"""
|
|
||||||
import inspect
|
|
||||||
|
|
||||||
from scribe.services import plugin_context
|
|
||||||
|
|
||||||
for module in (svc, plugin_context):
|
|
||||||
src = inspect.getsource(module).lower()
|
|
||||||
assert "compaction" in src and "etag" in src, (
|
|
||||||
f"{module.__name__} no longer explains what the rules marker "
|
|
||||||
f"cannot see. Without it the next reader will treat an etag as a "
|
|
||||||
f"general staleness check and soften the SessionStart nudge."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── The arm that delivers the message (milestone 323 step 5) ───────────
|
|
||||||
#
|
|
||||||
# The marker is worth nothing until a session is actually TOLD. These drive
|
|
||||||
# the real `build_write_path_hint`, because the feature IS a line arriving in
|
|
||||||
# a hook's output — a test of the helper alone would prove the arithmetic and
|
|
||||||
# nothing about the delivery.
|
|
||||||
|
|
||||||
|
|
||||||
def _quiet_write_path(pc, rules):
|
|
||||||
"""Every other arm stubbed to silent, so the only line that can appear is
|
|
||||||
the one under test."""
|
|
||||||
return (
|
|
||||||
patch.object(pc, "get_writepath_config",
|
|
||||||
AsyncMock(return_value={"enabled": True, "threshold": 0.6,
|
|
||||||
"top_k": 3})),
|
|
||||||
patch.object(pc.snippets_svc, "list_snippets",
|
|
||||||
AsyncMock(return_value=([], 0))),
|
|
||||||
patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])),
|
|
||||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=[])),
|
|
||||||
patch.object(pc, "record_retrieval", MagicMock()),
|
|
||||||
patch.object(pc, "record_surfaced", MagicMock()),
|
|
||||||
patch.object(pc, "owner_names_for", AsyncMock(return_value={})),
|
|
||||||
patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _hint(rules, held_etag):
|
|
||||||
from scribe.services import plugin_context as pc
|
|
||||||
|
|
||||||
patches = _quiet_write_path(pc, rules)
|
|
||||||
for p in patches:
|
|
||||||
p.start()
|
|
||||||
try:
|
|
||||||
out = await pc.build_write_path_hint(
|
|
||||||
1, "src/scribe/services/rulebooks.py", code="x" * 400,
|
|
||||||
rules_etag=held_etag,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
for p in patches:
|
|
||||||
p.stop()
|
|
||||||
return out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_is_told_which_rule_moved():
|
|
||||||
"""The delivery, end to end through the real hint builder. Naming the rule
|
|
||||||
is the point — "something changed" sends the reader to re-read everything,
|
|
||||||
which is the cost the marker was meant to avoid."""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
|
||||||
|
|
||||||
ctx = await _hint(current, held)
|
|
||||||
assert "changed since this session started" in ctx
|
|
||||||
assert "#2" in ctx and "dev is home" in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_holding_the_current_rules_is_told_nothing():
|
|
||||||
"""The one that keeps the signal worth reading. A line on every write is a
|
|
||||||
line nobody reads."""
|
|
||||||
rules = [_rule(rid=1), _rule(rid=2)]
|
|
||||||
ctx = await _hint(rules, svc.rules_etag(rules))
|
|
||||||
assert "changed since this session started" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_session_that_sent_no_marker_is_told_nothing():
|
|
||||||
"""An install whose hook never reached /api/plugin/context has nothing
|
|
||||||
stored. Absent must read as silence, not as a mismatch — otherwise the
|
|
||||||
first thing a new install hears is that its rules changed."""
|
|
||||||
ctx = await _hint([_rule(updated_at=LATER)], "")
|
|
||||||
assert "changed since this session started" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_a_DELETED_rule_is_reported_even_though_it_has_no_row():
|
|
||||||
"""The count arm. A deleted rule leaves nothing to name, and it is the
|
|
||||||
change that takes an instruction OUT of force — so "no longer in force"
|
|
||||||
has to be sayable without a row to say it about."""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2)])
|
|
||||||
ctx = await _hint([_rule(rid=1)], held)
|
|
||||||
assert "no longer in force" in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_the_arm_fails_open():
|
|
||||||
"""A staleness hint must never break a write. Every other arm here fails
|
|
||||||
open for the same reason, and this one runs a query that can fail."""
|
|
||||||
from scribe.services import plugin_context as pc
|
|
||||||
|
|
||||||
patches = _quiet_write_path(pc, [])
|
|
||||||
for p in patches:
|
|
||||||
p.start()
|
|
||||||
try:
|
|
||||||
with patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
|
||||||
AsyncMock(side_effect=RuntimeError("database down"))):
|
|
||||||
out = await pc.build_write_path_hint(
|
|
||||||
1, "src/x.py", code="x" * 400, rules_etag="2026-01-01T00:00:00+00:00|3",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
for p in patches:
|
|
||||||
p.stop()
|
|
||||||
assert "changed since this session started" not in out["context"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_marker_cannot_break_the_payload_it_decorates():
|
|
||||||
"""It is computed on the SessionStart path. Raising there would cost the
|
|
||||||
whole context payload — every rule title, the project, the lot — to save
|
|
||||||
a hint, which is the wrong trade in every case.
|
|
||||||
|
|
||||||
A row with no usable timestamp is skipped; a set with none degrades to a
|
|
||||||
count-only marker. Count-only still catches a rule ADDED or DELETED and
|
|
||||||
only loses edits, which is the right way round to lose information.
|
|
||||||
|
|
||||||
Found by CI: `build_session_context` tests hand it MagicMock rules, and
|
|
||||||
`max()` over those raises TypeError rather than returning anything.
|
|
||||||
"""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
assert svc.rules_etag([MagicMock(), MagicMock()]) == "unknown|2"
|
|
||||||
assert svc.rules_etag([SimpleNamespace()]) == "unknown|1"
|
|
||||||
# A count-only marker still moves when the set does.
|
|
||||||
assert svc.rules_etag([MagicMock()]) != svc.rules_etag([MagicMock(), MagicMock()])
|
|
||||||
# One usable stamp is enough to keep the real thing.
|
|
||||||
assert svc.rules_etag([_rule(), MagicMock()]).startswith("2026-")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_the_signal_arrives_even_when_nothing_else_matched():
|
|
||||||
"""THE BUG CI CAUGHT, and the one the task's acceptance criterion was
|
|
||||||
written to catch.
|
|
||||||
|
|
||||||
`build_write_path_hint` returns early when no prior art, stamp, divergence
|
|
||||||
or derive matched — which sat ABOVE this arm, so a session whose rules had
|
|
||||||
changed was told only if the file it happened to be editing also matched
|
|
||||||
something else. A staleness signal that fires on that coincidence is not a
|
|
||||||
staleness signal.
|
|
||||||
"""
|
|
||||||
held = svc.rules_etag([_rule(rid=1), _rule(rid=2, title="dev is home")])
|
|
||||||
current = [_rule(rid=1), _rule(rid=2, title="dev is home", updated_at=LATER)]
|
|
||||||
|
|
||||||
# Every other arm silent — which is exactly the case that used to return "".
|
|
||||||
ctx = await _hint(current, held)
|
|
||||||
assert "changed since this session started" in ctx
|
|
||||||
@@ -382,13 +382,13 @@ def test_rule_rows_carry_the_verification_fields():
|
|||||||
can rot — the exact blindness the fields were added to end.
|
can rot — the exact blindness the fields were added to end.
|
||||||
|
|
||||||
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
||||||
call made for when_to_apply/tier/arose_from_id in 0088 (commit 6ddb8bf).
|
call made for when_to_apply/arose_from_id in 0088 (commit 6ddb8bf).
|
||||||
"""
|
"""
|
||||||
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why="w", how_to_apply="h", order_index=0,
|
why="w", how_to_apply="h", order_index=0,
|
||||||
when_to_apply="when", tier="conditional", kind="rule",
|
when_to_apply="when", kind="rule",
|
||||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||||
verified_at=checked, arose_from_id=99,
|
verified_at=checked, arose_from_id=99,
|
||||||
created_at=checked, updated_at=checked,
|
created_at=checked, updated_at=checked,
|
||||||
@@ -415,7 +415,7 @@ def test_rule_rows_keep_an_unverified_rule_unverified():
|
|||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why=None, how_to_apply=None, order_index=0,
|
why=None, how_to_apply=None, order_index=0,
|
||||||
when_to_apply=None, tier="always_on", kind="rule",
|
when_to_apply=None, kind="rule",
|
||||||
verify_with=None, expires_when=None, verified_at=None,
|
verify_with=None, expires_when=None, verified_at=None,
|
||||||
arose_from_id=None,
|
arose_from_id=None,
|
||||||
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||||
@@ -445,7 +445,7 @@ def test_rule_rows_carry_the_kind_so_a_preference_does_not_restore_as_a_rule():
|
|||||||
row = SimpleNamespace(
|
row = SimpleNamespace(
|
||||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||||
why=None, how_to_apply=None, order_index=0,
|
why=None, how_to_apply=None, order_index=0,
|
||||||
when_to_apply="when", tier="conditional", kind="preference",
|
when_to_apply="when", kind="preference",
|
||||||
verify_with=None, expires_when=None, verified_at=None,
|
verify_with=None, expires_when=None, verified_at=None,
|
||||||
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,23 +4,9 @@ import pytest
|
|||||||
from tests.helpers import fake_note
|
from tests.helpers import fake_note
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _no_exclusions():
|
|
||||||
"""build_session_context asks for the bound project's always-on
|
|
||||||
exclusions (milestone 297); these tests script the rules only."""
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
|
||||||
AsyncMock(return_value=[])):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("_no_supersession")
|
pytestmark = pytest.mark.usefixtures("_no_supersession")
|
||||||
|
|
||||||
|
|
||||||
def _rule(rid, title, topic_id):
|
|
||||||
r = MagicMock()
|
|
||||||
r.id, r.title, r.topic_id = rid, title, topic_id
|
|
||||||
r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED"
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
# ─── knowledge auto-inject (Path A) ──────────────────────────────────────────
|
||||||
@@ -106,30 +92,6 @@ async def test_build_autoinject_hint_blank_query_returns_empty():
|
|||||||
search.assert_not_called()
|
search.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_build_session_context_renders_titles_grouped_by_topic():
|
|
||||||
rules = [
|
|
||||||
_rule(1, "`dev` is home", 1),
|
|
||||||
_rule(2, "Release — never without explicit request", 1),
|
|
||||||
_rule(3, "No GitHub — Fabled-Git only", 2),
|
|
||||||
]
|
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=rules)), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})):
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
out = await build_session_context(user_id=7, project_id=0)
|
|
||||||
|
|
||||||
ctx = out["context"]
|
|
||||||
assert out["rule_count"] == 3
|
|
||||||
assert out["project"] is None
|
|
||||||
# Titles present, grouped under topic headings
|
|
||||||
assert "### git-workflow" in ctx
|
|
||||||
assert "### fabled-git" in ctx
|
|
||||||
assert "- [1] `dev` is home" in ctx
|
|
||||||
assert "- [3] No GitHub — Fabled-Git only" in ctx
|
|
||||||
# Full statements must NOT be dumped (push channel injects titles only)
|
|
||||||
assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -139,11 +101,7 @@ async def test_build_session_context_includes_project_when_scoped():
|
|||||||
# opposite of what this test is about.
|
# opposite of what this test is about.
|
||||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
||||||
design_system_id=None)
|
design_system_id=None)
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 4))):
|
AsyncMock(return_value=([], 4))):
|
||||||
@@ -170,11 +128,7 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
|||||||
"guidance": [], "token_count": 95,
|
"guidance": [], "token_count": 95,
|
||||||
"token_groups": ["accent", "surface", "type"],
|
"token_groups": ["accent", "surface", "type"],
|
||||||
}
|
}
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 0))), \
|
AsyncMock(return_value=([], 0))), \
|
||||||
@@ -200,11 +154,7 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
|||||||
That must degrade to "no design block", not to a crash that costs the
|
That must degrade to "no design block", not to a crash that costs the
|
||||||
session its rules too."""
|
session its rules too."""
|
||||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
with patch("scribe.services.plugin_context.projects_svc.get_project",
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})), \
|
|
||||||
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
||||||
AsyncMock(return_value=project)), \
|
AsyncMock(return_value=project)), \
|
||||||
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
||||||
AsyncMock(return_value=([], 0))), \
|
AsyncMock(return_value=([], 0))), \
|
||||||
@@ -219,14 +169,10 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
from scribe.services.plugin_context import build_session_context
|
||||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
out = await build_session_context(
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
||||||
AsyncMock(return_value={1: "git-workflow"})):
|
)
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
out = await build_session_context(
|
|
||||||
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx = out["context"]
|
ctx = out["context"]
|
||||||
assert out["project"] is None
|
assert out["project"] is None
|
||||||
@@ -289,16 +235,29 @@ async def test_build_process_manifest_truncates_long_preview():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_caps_length():
|
async def test_build_session_context_caps_length():
|
||||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
"""The cap still binds, and now has to be provoked rather than tripped.
|
||||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
||||||
AsyncMock(return_value=many)), \
|
|
||||||
patch("scribe.services.plugin_context._topic_titles",
|
|
||||||
AsyncMock(return_value={1: "git-workflow"})):
|
|
||||||
from scribe.services.plugin_context import build_session_context
|
|
||||||
out = await build_session_context(user_id=7)
|
|
||||||
|
|
||||||
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
This used to hand the block 200 long rule titles, because the preload made
|
||||||
assert "truncated" in out["context"]
|
overflow the easy case. Milestone 394 removed that block, so nothing this
|
||||||
|
function assembles on its own is big enough any more — which is a reason
|
||||||
|
to drive the cap directly, not a reason to drop it. The project and design
|
||||||
|
blocks are still unbounded in principle, and the hook passes this text
|
||||||
|
through verbatim.
|
||||||
|
|
||||||
|
Patching the cap rather than manufacturing 9,000 characters keeps the test
|
||||||
|
about the TRUNCATION PATH — that it cuts, and that it says it cut — which
|
||||||
|
is the part a reader depends on.
|
||||||
|
"""
|
||||||
|
from scribe.services import plugin_context as pc
|
||||||
|
|
||||||
|
with patch.object(pc, "_MAX_CHARS", 120):
|
||||||
|
out = await pc.build_session_context(user_id=7)
|
||||||
|
|
||||||
|
assert len(out["context"]) <= 120 + 60 # cap + truncation note
|
||||||
|
assert "truncated" in out["context"], (
|
||||||
|
"the block was cut without saying so — a reader cannot tell a "
|
||||||
|
"truncated context from a short one"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- the reuse slot (#2246) --------------------------------------------------
|
# --- the reuse slot (#2246) --------------------------------------------------
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def _no_exclusions():
|
|||||||
"""get_applicable_rules asks for the project's always-on exclusions
|
"""get_applicable_rules asks for the project's always-on exclusions
|
||||||
(milestone 297) through its own session; these mocked-session tests
|
(milestone 297) through its own session; these mocked-session tests
|
||||||
script the rule queries only, so the exclusions lookup is stubbed empty."""
|
script the rule queries only, so the exclusions lookup is stubbed empty."""
|
||||||
with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])):
|
if True:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
|||||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||||
|
|
||||||
|
|
||||||
# ── rule_brief + tier (milestone 307) ───────────────────────────────────
|
# ── rule_brief (milestone 307) ──────────────────────────────────────────
|
||||||
|
|
||||||
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
def test_rule_brief_carries_age_but_not_the_deep_fields():
|
||||||
"""The shape a SURFACED rule takes, and the reason it exists.
|
"""The shape a SURFACED rule takes, and the reason it exists.
|
||||||
@@ -362,7 +362,6 @@ def test_rule_brief_carries_age_but_not_the_deep_fields():
|
|||||||
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
updated_at=datetime(2026, 6, 1, 14, 30, tzinfo=timezone.utc),
|
||||||
))
|
))
|
||||||
assert out["when_to_apply"] == "before any git push"
|
assert out["when_to_apply"] == "before any git push"
|
||||||
assert out["tier"] == "always_on"
|
|
||||||
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
# A DATE, not a stamp: the question is "how old is this", and a full ISO
|
||||||
# string across the always-on set is ~2k characters of payload.
|
# string across the always-on set is ~2k characters of payload.
|
||||||
assert out["updated_at"] == "2026-06-01"
|
assert out["updated_at"] == "2026-06-01"
|
||||||
@@ -381,17 +380,6 @@ def test_rule_brief_omits_keys_a_rule_has_no_value_for():
|
|||||||
assert "arose_from_id" not in out
|
assert "arose_from_id" not in out
|
||||||
|
|
||||||
|
|
||||||
def test_an_unknown_tier_falls_back_to_binding():
|
|
||||||
"""The asymmetry that decides the direction: a rule that preloads when it
|
|
||||||
needn't costs context; a rule that quietly stops preloading costs the
|
|
||||||
behaviour it was written for. So a typo binds."""
|
|
||||||
from scribe.services.rulebooks import _valid_tier
|
|
||||||
|
|
||||||
assert _valid_tier("conditional") == "conditional"
|
|
||||||
assert _valid_tier("always_on") == "always_on"
|
|
||||||
assert _valid_tier("Conditional") == "always_on"
|
|
||||||
assert _valid_tier("") == "always_on"
|
|
||||||
assert _valid_tier("occasionally") == "always_on"
|
|
||||||
|
|
||||||
|
|
||||||
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
# ── verify_with / expires_when (milestone 312) ──────────────────────────
|
||||||
@@ -467,7 +455,6 @@ def test_a_sweep_row_carries_the_check_in_full():
|
|||||||
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
|
||||||
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||||
assert row["when_to_apply"] == "when a dependency bump is in play"
|
assert row["when_to_apply"] == "when a dependency bump is in play"
|
||||||
assert row["tier"] == "always_on"
|
|
||||||
|
|
||||||
|
|
||||||
def test_never_verified_reports_no_day_count_rather_than_zero():
|
def test_never_verified_reports_no_day_count_rather_than_zero():
|
||||||
@@ -495,13 +482,3 @@ def test_a_verified_row_counts_the_days():
|
|||||||
assert row["days_since_verified"] == 74
|
assert row["days_since_verified"] == 74
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_an_unrecognised_tier_filter_raises_rather_than_narrowing():
|
|
||||||
"""_valid_tier's silent always_on fallback is right for a WRITE — a typo
|
|
||||||
should leave a rule binding. It is wrong for a FILTER, where the same
|
|
||||||
fallback would quietly answer a different question than the one asked and
|
|
||||||
return a short list that looks like good news."""
|
|
||||||
from scribe.services.rulebooks import rules_due_for_verification
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="tier must be one of"):
|
|
||||||
await rules_due_for_verification(7, tier="occasionally")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user