Retire the always-on tier — every rule arrives by retrieval (milestone 394) #152
@@ -0,0 +1,104 @@
|
||||
"""scrub credential-shaped spans out of retrieval_logs.query
|
||||
|
||||
Revision ID: 0099
|
||||
Revises: 0098
|
||||
Create Date: 2026-09-11
|
||||
|
||||
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
|
||||
against the code being written, so whatever was on the command line or in the
|
||||
buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command
|
||||
that exported a token stored the token (#3925).
|
||||
|
||||
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
|
||||
value never reaches the column. It cannot reach BACKWARDS, and this does: it
|
||||
rewrites the rows already written.
|
||||
|
||||
REDACTED IN PLACE, NOT DELETED. The rest of the row — score, threshold,
|
||||
result count, duration, the near-miss record id — is legitimate evidence, and
|
||||
it is what a threshold is tuned from. Deleting the row would throw that away
|
||||
to remove a secret that lives in one column, so the column is what gets
|
||||
rewritten. Rows with no credential in them are not touched at all.
|
||||
|
||||
THE PATTERNS ARE INLINED RATHER THAN IMPORTED, deliberately, against the DRY
|
||||
instinct. A migration is a frozen record of a change that already happened on
|
||||
every install that ran it; importing the live patterns would mean this
|
||||
migration quietly does something different next year than it did when it ran,
|
||||
and two installs at the same revision would no longer be in the same state.
|
||||
The Python twin in `services/retrieval_telemetry.py` is free to grow — this is
|
||||
what ran here, once. The one thing that must not drift is coverage, and the
|
||||
guard for that is `test_retrieval_query_scrubbing.py`, which tests the live
|
||||
function rather than this copy.
|
||||
|
||||
POSIX regex, not Python's. Postgres ARE supports the non-greedy `*?` the PEM
|
||||
pattern needs, and `\\s`/`\\S`, so the shapes port directly. The `'gi'` flags
|
||||
are global + case-insensitive, matching `re.sub` with `(?i)`.
|
||||
|
||||
NO BARE `auth` IN THE ASSIGNED PATTERN. It matches `--author=`, so a commit
|
||||
naming an address would have had the address redacted — evidence eaten for a
|
||||
word that only looks credential-shaped. `AUTH_TOKEN` is still caught, by
|
||||
`token`.
|
||||
|
||||
Downgrade is a no-op, and honestly so: the original text is gone and a
|
||||
migration cannot invent it back. Saying that plainly is better than a
|
||||
downgrade that appears to restore something and does not.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0099"
|
||||
down_revision = "0098"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Vendor-prefixed credentials — the prefix IS the tell, so no entropy guessing.
|
||||
_TOKEN = (
|
||||
r"(fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[abprs]-"
|
||||
r"|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
|
||||
)
|
||||
# A value handed to a secret-NAMED variable, in shell, env, YAML, JSON or a
|
||||
# query string. The NAME identifies it, so the value can be anything.
|
||||
_ASSIGNED = (
|
||||
r"([A-Za-z0-9_]*(token|secret|password|passwd|api[_-]?key|access[_-]?key)"
|
||||
r"[A-Za-z0-9_]*)(\s*[:=]\s*[\"']?)([^\s\"'&]{8,})"
|
||||
)
|
||||
_AUTH_HEADER = r"(authorization\s*:\s*(bearer|basic|token)\s+)(\S+)"
|
||||
_PEM = (
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----(.|\n)*?-----END [A-Z ]*PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _lit(pattern: str) -> str:
|
||||
"""A regex as a SQL string literal.
|
||||
|
||||
A single quote inside a single-quoted SQL literal has to be DOUBLED, and
|
||||
the assigned-value pattern contains two of them (it allows an optional
|
||||
quote around the value). Left unescaped they close the literal early and
|
||||
the migration dies on a syntax error — which is the whole reason this
|
||||
helper exists rather than the patterns being pasted in inline.
|
||||
"""
|
||||
return pattern.replace("'", "''")
|
||||
|
||||
|
||||
_SCRUB_SQL = f"""
|
||||
UPDATE retrieval_logs
|
||||
SET query = regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(query, '{_lit(_TOKEN)}', '[redacted:token]', 'gi'),
|
||||
'{_lit(_ASSIGNED)}', '\\1\\3[redacted:assigned]', 'gi'),
|
||||
'{_lit(_AUTH_HEADER)}', '\\1[redacted:auth-header]', 'gi'),
|
||||
'{_lit(_PEM)}', '[redacted:private-key]', 'gi')
|
||||
WHERE query IS NOT NULL
|
||||
AND (query ~* '{_lit(_TOKEN)}'
|
||||
OR query ~* '{_lit(_ASSIGNED)}'
|
||||
OR query ~* '{_lit(_AUTH_HEADER)}'
|
||||
OR query ~* '{_lit(_PEM)}')
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(_SCRUB_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Deliberately empty — the original text no longer exists to restore."""
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -89,7 +89,7 @@ table here. The tools are grouped by family:
|
||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
|
||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
||||
|
||||
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|
||||
|--------|------|-------------|
|
||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
||||
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
|
||||
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
|
||||
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||
@@ -120,7 +120,6 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
|
||||
|
||||
## Sharing
|
||||
|
||||
@@ -206,6 +205,6 @@ endpoint at `/mcp`, not these REST routes.
|
||||
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
|
||||
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
|
||||
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
|
||||
`start_planning`, `search`, `enter_project`, `list_always_on_rules`, …) with
|
||||
`start_planning`, `search`, `enter_project`, …) with
|
||||
server-level usage guidance delivered in the MCP `instructions` block. See
|
||||
[API Keys & MCP](api-keys-and-mcp.md).
|
||||
|
||||
+5
-3
@@ -60,8 +60,10 @@ Scribe stores the operator's engineering and workflow **rules** so Claude follow
|
||||
across sessions.
|
||||
|
||||
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
|
||||
- **Always-on rules** — A rulebook can be flagged always-on; its rules load at the
|
||||
start of every session through the plugin's push channel.
|
||||
- **Rules arrive by retrieval** — Nothing is preloaded. A rule reaches a
|
||||
session when what the agent is about to do matches its trigger: a command,
|
||||
a file being written, or the operator's own message. `when_to_apply` is
|
||||
therefore the field that decides whether a rule is ever seen.
|
||||
- **Per-project scope** — A project subscribes to rulebooks, and can add
|
||||
project-scoped rules or suppress individual inherited rules/topics.
|
||||
|
||||
@@ -94,7 +96,7 @@ The whole store is reachable by Claude through a built-in **MCP endpoint at `/mc
|
||||
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
|
||||
repo) wires it up:
|
||||
|
||||
- a `SessionStart` hook that injects the operator's always-on rules + active-project
|
||||
- a `SessionStart` hook that injects active-project
|
||||
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
|
||||
- universal process-skills — writing-plans, systematic-debugging, verification,
|
||||
brainstorming — that route their output into Scribe;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
|
||||
export interface InceptionChoices {
|
||||
exclude_always_on_rulebooks: number[];
|
||||
subscribe_rulebooks: number[];
|
||||
design_system_id: number | null;
|
||||
seed_systems: boolean;
|
||||
@@ -16,9 +15,7 @@ export interface InceptionRecord {
|
||||
}
|
||||
|
||||
export interface InceptionDefaults {
|
||||
always_on_rulebooks: { id: number; title: string }[];
|
||||
other_rulebooks: { id: number; title: string }[];
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
rulebooks: { id: number; title: string }[];
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
design_system_id: number | null;
|
||||
design_systems: { id: number; title: string }[];
|
||||
@@ -32,7 +29,7 @@ export interface InceptionDecision {
|
||||
}
|
||||
|
||||
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) =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RecordUsage } from "@/types/usage";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** 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
|
||||
@@ -26,7 +25,6 @@ export interface Rulebook {
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
always_on: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -49,12 +47,6 @@ export interface Rule {
|
||||
statement: string;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
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;
|
||||
how_to_apply: string;
|
||||
/**
|
||||
@@ -87,7 +79,6 @@ export interface RuleHeader {
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
tier: RuleTier;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
@@ -134,7 +125,6 @@ export interface ApplicableRules {
|
||||
truncated: boolean;
|
||||
subscribed_rulebooks: { id: number; title: string }[];
|
||||
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||
excluded_always_on: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||
@@ -152,7 +142,7 @@ export async function createRulebook(data: { title: string; description?: string
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -207,7 +197,6 @@ export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
@@ -258,7 +247,6 @@ export interface RuleVersion {
|
||||
why?: string;
|
||||
how_to_apply?: string;
|
||||
when_to_apply?: string;
|
||||
tier?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}
|
||||
@@ -323,16 +311,6 @@ export async function unsuppressTopicForProject(projectId: number, topicId: numb
|
||||
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
|
||||
@@ -343,7 +321,6 @@ export interface RuleVerificationRow {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
tier: RuleTier;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
when_to_apply: string;
|
||||
@@ -366,12 +343,10 @@ export interface RuleVerificationRow {
|
||||
*/
|
||||
export async function listRulesDueForVerification(opts: {
|
||||
olderThanDays?: number;
|
||||
tier?: RuleTier;
|
||||
neverOnly?: boolean;
|
||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||
const q = new URLSearchParams();
|
||||
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");
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
milestone (tier, then verification) and were byte-identical; a third would
|
||||
have drifted. The pane's italic serif title is inherited by anything inside
|
||||
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 {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
|
||||
@@ -28,7 +28,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
||||
const others = ref<{ id: number; title: string }[]>([]);
|
||||
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||
const systemsCount = ref(0);
|
||||
@@ -47,21 +46,18 @@ async function load() {
|
||||
try {
|
||||
if (props.mode === "decide" && props.projectId) {
|
||||
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||
alwaysOn.value = d.always_on_rulebooks;
|
||||
others.value = d.other_rulebooks;
|
||||
others.value = d.rulebooks;
|
||||
designSystems.value = d.design_systems;
|
||||
systemsCount.value = d.systems;
|
||||
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||
local.value = {
|
||||
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
||||
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||
design_system_id: d.design_system_id,
|
||||
seed_systems: false,
|
||||
};
|
||||
} else {
|
||||
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.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
|
||||
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||
}
|
||||
} 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 {
|
||||
return local.value.subscribe_rulebooks.includes(id);
|
||||
}
|
||||
@@ -87,7 +76,7 @@ function toggleSubscribe(id: number) {
|
||||
}
|
||||
|
||||
const nothingToDecide = computed(
|
||||
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
||||
() => !others.value.length && !designSystems.value.length,
|
||||
);
|
||||
|
||||
async function record() {
|
||||
@@ -118,16 +107,11 @@ onMounted(load);
|
||||
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
<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">
|
||||
<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">
|
||||
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||
<span>{{ rb.title }}</span>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
unsuppressRuleForProject,
|
||||
suppressTopicForProject,
|
||||
unsuppressTopicForProject,
|
||||
includeAlwaysOnRulebook,
|
||||
} from "@/api/rulebooks";
|
||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||
|
||||
@@ -32,7 +31,7 @@ const ruleDetails = ref<Record<number, {
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||
when_to_apply: "",
|
||||
});
|
||||
|
||||
async function load() {
|
||||
@@ -49,11 +48,6 @@ async function subscribe(rulebookId: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function includeBack(rulebookId: number) {
|
||||
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function unsubscribe(rulebookId: number) {
|
||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||
await unsubscribeProject(props.projectId, rulebookId);
|
||||
@@ -134,11 +128,10 @@ async function submitProjectRule() {
|
||||
why: newProjectRule.value.why.trim() || undefined,
|
||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||
tier: newProjectRule.value.tier,
|
||||
});
|
||||
newProjectRule.value = {
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on",
|
||||
when_to_apply: "",
|
||||
};
|
||||
showProjectRuleForm.value = false;
|
||||
await load();
|
||||
@@ -210,17 +203,6 @@ watch(() => props.projectId, load);
|
||||
</div>
|
||||
</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">
|
||||
<div class="section-head">
|
||||
<h3>Project rules</h3>
|
||||
@@ -246,22 +228,13 @@ watch(() => props.projectId, load);
|
||||
></textarea>
|
||||
<textarea
|
||||
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"
|
||||
></textarea>
|
||||
<div class="tier-row">
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||
Always on
|
||||
</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>
|
||||
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
|
||||
Without a trigger the rule will never reach a session — nothing is
|
||||
preloaded, so a rule arrives only when work matches what it names.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
@@ -405,14 +378,9 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.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); }
|
||||
.trigger-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; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
.rules-tab { padding: 1rem; }
|
||||
h3 {
|
||||
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 { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
@@ -13,7 +12,6 @@ const canon = useCanonicalSystemsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
@@ -70,7 +68,6 @@ async function load() {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
whenToApply.value = r.when_to_apply || "";
|
||||
tier.value = r.tier || "always_on";
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
@@ -81,7 +78,6 @@ async function load() {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
tier.value = "always_on";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
@@ -100,7 +96,6 @@ async function save() {
|
||||
title: title.value,
|
||||
statement: statement.value,
|
||||
when_to_apply: whenToApply.value,
|
||||
tier: tier.value,
|
||||
// Always sent, so clearing the last area actually clears it — the server
|
||||
// reads a list as "these ARE the areas now".
|
||||
system_ids: systemIds.value,
|
||||
@@ -147,38 +142,19 @@ watch(() => props.ruleId, load);
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
When to apply
|
||||
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||||
When to apply <span class="required">*</span>
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
rows="2"
|
||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||
rows="3"
|
||||
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>
|
||||
|
||||
<fieldset class="tier">
|
||||
<legend>How it reaches a session</legend>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="always_on" />
|
||||
<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>
|
||||
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||||
<strong>Without this, the rule will never reach a session.</strong>
|
||||
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||||
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||
</p>
|
||||
|
||||
<fieldset v-if="canon.catalog.length" class="areas">
|
||||
<legend>Areas this rule is about</legend>
|
||||
@@ -190,14 +166,14 @@ watch(() => props.ruleId, load);
|
||||
/>
|
||||
<span>{{ entry.name }}</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
<p class="field-note">
|
||||
What lets this rule reach a project working in that area.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<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
|
||||
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.
|
||||
@@ -226,7 +202,7 @@ watch(() => props.ruleId, load);
|
||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||
</span>
|
||||
</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
|
||||
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
|
||||
@@ -243,7 +219,7 @@ watch(() => props.ruleId, load);
|
||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="tier-test">
|
||||
<p class="field-note">
|
||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time.
|
||||
</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; }
|
||||
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; }
|
||||
.tier-opt input, .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; }
|
||||
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.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 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
|
||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||
type TextField =
|
||||
| "title" | "statement" | "when_to_apply" | "tier"
|
||||
| "title" | "statement" | "when_to_apply"
|
||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||
|
||||
const FIELDS: Array<[TextField, string]> = [
|
||||
["title", "Title"],
|
||||
["statement", "Statement"],
|
||||
["when_to_apply", "When to apply"],
|
||||
["tier", "Tier"],
|
||||
["why", "Why"],
|
||||
["how_to_apply", "How to apply"],
|
||||
["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)">
|
||||
<div class="title">
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<!-- Marked only when something is WRONG: every rule arrives by
|
||||
retrieval now, so "conditional" stopped distinguishing anything.
|
||||
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
|
||||
presence says "this one asserts a fact that can go false". -->
|
||||
<span
|
||||
|
||||
@@ -10,19 +10,16 @@
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const neverOnly = ref(false);
|
||||
const tier = ref<RuleTier | "">("");
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
return store.fetchRulesDue({
|
||||
neverOnly: neverOnly.value || undefined,
|
||||
tier: tier.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,14 +50,6 @@ onMounted(reload);
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</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>
|
||||
|
||||
<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. -->
|
||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||
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>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||
<div class="row-head">
|
||||
<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 }">
|
||||
{{ r.days_since_verified === null
|
||||
? "never checked"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { apiGet } from "@/api/client";
|
||||
import {
|
||||
@@ -18,9 +18,6 @@ const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
const newTitle = ref("");
|
||||
|
||||
const currentRulebook = computed(() =>
|
||||
store.rulebooks.find((rb) => rb.id === props.rulebookId),
|
||||
);
|
||||
|
||||
interface ProjectLite { id: number; title: string }
|
||||
const projects = ref<ProjectLite[]>([]);
|
||||
@@ -74,14 +71,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
<section class="pane">
|
||||
<header>
|
||||
<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>
|
||||
<ul>
|
||||
<li
|
||||
@@ -124,12 +113,6 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
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; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
|
||||
@@ -31,7 +31,6 @@ async function submitNew() {
|
||||
@click="emit('select', rb.id)"
|
||||
>
|
||||
<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>
|
||||
</ul>
|
||||
<!-- 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.active { background: var(--fs-accent-soft); }
|
||||
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 {
|
||||
display: block; width: 100%; text-align: left;
|
||||
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
|
||||
// looking at — re-fetching unfiltered would silently widen the list under
|
||||
// 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);
|
||||
|
||||
async function fetchRulebooks() {
|
||||
@@ -57,19 +57,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
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 idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) rulebooks.value[idx] = 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) {
|
||||
await api.deleteRulebook(id);
|
||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||
@@ -112,7 +106,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
tier: rule.tier,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || 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. */
|
||||
async function fetchRulesDue(opts: {
|
||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||
olderThanDays?: number; neverOnly?: boolean;
|
||||
} = {}) {
|
||||
loading.value = true;
|
||||
lastSweepOpts.value = opts;
|
||||
@@ -206,7 +199,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createRulebook, updateRulebook, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
|
||||
@@ -716,9 +716,6 @@ async function confirmDelete() {
|
||||
/>
|
||||
<p v-else-if="project.inception" class="inception-line">
|
||||
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">
|
||||
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "2026.09.11.1154",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "2026.09.11.2026",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ instance into a first-class Claude Code extension:
|
||||
|
||||
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||
rulebook (the `scribe` server).
|
||||
- **Session-start push channel** — a `SessionStart` hook injects your always-on
|
||||
- **Session-start push channel** — a `SessionStart` hook injects your
|
||||
rules + active-project context so Scribe surfaces *without being asked*.
|
||||
- **Prior-art recall on writes** — a `PreToolUse` hook on Write/Edit checks the
|
||||
file about to be written against your recorded snippets (what's kept at that
|
||||
|
||||
@@ -175,16 +175,11 @@ while IFS= read -r rel_path; do
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
# The rules marker the SessionStart hook stored, handed back so the server
|
||||
# can say whether those rules moved since (milestone 323). Nothing stored
|
||||
# means nothing sent, which the server reads as silence rather than as a
|
||||
# mismatch — an install that never reached /api/plugin/context must not
|
||||
# start claiming its rules changed.
|
||||
# The rules marker is gone with the resident set it aged (milestone 394).
|
||||
# A session no longer holds a fixed set of rules from turn zero, so there
|
||||
# is nothing that can have drifted since it loaded them — each rule is
|
||||
# retrieved at the moment it applies.
|
||||
etag_q=""
|
||||
if [ -f "$state_dir/${safe_sid}.rules_etag" ]; then
|
||||
held=$(jq -sRr '@uri' < "$state_dir/${safe_sid}.rules_etag" 2>/dev/null) || held=""
|
||||
[ -n "$held" ] && etag_q="&rules_etag=${held}"
|
||||
fi
|
||||
if [ -n "$path_enc" ]; then
|
||||
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
||||
# gates nothing the session is waiting on, and the first prior-art call
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# does not depend on the key or the network.
|
||||
#
|
||||
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
||||
# for always-on rules + active-project context and appends it. Config comes from
|
||||
# for active-project context and appends it. Config comes from
|
||||
# the plugin's userConfig, exported to hooks as:
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
@@ -156,31 +156,13 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||
[ -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
|
||||
# where it has to be captured: the model receives one from
|
||||
# list_always_on_rules too, but a hook cannot see an MCP tool's result. Stored
|
||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
||||
# 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 rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||
# The rules marker is gone with the resident set it described
|
||||
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||
# drift a later write could be told about — a rule is retrieved at
|
||||
# the moment it applies, which cannot be stale.
|
||||
[ -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
|
||||
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; pull rules with \`list_always_on_rules()\` 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()\`."
|
||||
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
||||
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
||||
@@ -189,7 +171,7 @@ elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||
# silently disabled auto-inject and the write-path trigger too. It is not a
|
||||
# benign state — the plugin prompts for both values at enable time, so if
|
||||
# neither reached the hook, something is wrong. Say so.
|
||||
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
|
||||
fi
|
||||
|
||||
[ -n "$dyn" ] && append "$dyn"
|
||||
@@ -197,7 +179,7 @@ fi
|
||||
|
||||
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
||||
if [ "$source" = "compact" ]; then
|
||||
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
||||
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you are mid-way through against what Scribe records. Scribe is the record."
|
||||
fi
|
||||
|
||||
# Nothing at all to inject → stay silent.
|
||||
|
||||
@@ -6,7 +6,8 @@ of record (notes, tasks, projects, milestones, rules) reachable through the
|
||||
for the operator's work, and as your own working memory across sessions.
|
||||
|
||||
**At the start of this session:**
|
||||
- Call `list_always_on_rules()` to load the operator's standing rules.
|
||||
- You hold none of the operator's rules, and there is no call that loads them
|
||||
all. Rules arrive when something you are about to do matches one.
|
||||
- If the working repo maps to a Scribe project (check `list_repo_bindings`),
|
||||
call `enter_project(<id>)` to load that project's rules, open tasks, and
|
||||
recent notes in one shot.
|
||||
@@ -17,22 +18,23 @@ for the operator's work, and as your own working memory across sessions.
|
||||
operator's Scribe rules decide what to do — NOT generic conventions baked
|
||||
into the harness or your defaults (e.g. "branch before committing," "open a
|
||||
feature branch per task," "push to a fork"). If you have not loaded the
|
||||
operator's rules this session — or earlier turns were summarized away by a
|
||||
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
||||
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
||||
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
||||
no rule has arrived for the act in front of you, `search(content_type=
|
||||
"rule")` BEFORE acting rather than falling back on a default habit. When a
|
||||
retrieved rule and a default habit disagree, the rule wins; if no rule
|
||||
speaks to it, ask rather than assume.
|
||||
- **Rules bind; preferences do not.** A record's `kind` says which. A **rule**
|
||||
must be followed — ignoring it breaks something or crosses a boundary. A
|
||||
**preference** is how the operator wants work done: worth following for
|
||||
consistency, not a defect to miss. Injected lines name the kind in their
|
||||
opening words. A preference is also yours to keep current when they correct
|
||||
you (`update_preference`); a rule waits for them.
|
||||
- **What you loaded is not all of the rules.** Only the always-on tier arrives
|
||||
that way; conditional rules are RETRIEVED, and one you were never handed
|
||||
binds exactly as hard. So before a consequential act, `search` for a rule
|
||||
about it (`content_type="rule"`) rather than concluding from an empty
|
||||
loaded set that nothing applies. "I was not told" is not the same as "there
|
||||
is no rule," and only one of those is checkable.
|
||||
- **Silence is not absence.** Nothing is preloaded: every rule is RETRIEVED,
|
||||
when what you are doing resembles what the rule is about. Most turns
|
||||
retrieve none, and a rule you were never handed binds exactly as hard as one
|
||||
you were. So before a consequential act, `search` for a rule about it
|
||||
(`content_type="rule"`) rather than concluding from an empty session that
|
||||
nothing applies. "I was not told" is not the same as "there is no rule," and
|
||||
only one of those is checkable.
|
||||
This bites hardest on which TOOL to reach for — curling an API that has an
|
||||
MCP client, standing up a local stack, running a suite CI owns. Those feel
|
||||
like mechanics rather than decisions, so they raise no doubt and generate no
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# asks "what is recorded about the file being written". This one asks "does a
|
||||
# standing rule speak to the command about to be run" — the question nothing
|
||||
# could ask before, and the reason every rule about which tool to reach for had
|
||||
# to live in the always-on preload instead.
|
||||
# to live in the preload instead, back when there was one.
|
||||
#
|
||||
# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query (note #3089):
|
||||
# you reach for `curl` confidently, with no moment of doubt, so a surface that
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: using-scribe
|
||||
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. FIRST ACTION of a session: call list_always_on_rules() (and enter_project when a repo/project is in scope) to load the operator's binding rules. Then recall before acting, update over duplicate, plan in Scribe not in files.
|
||||
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and search(content_type="rule") is how you ask before a consequential act. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files.
|
||||
---
|
||||
|
||||
# Using Scribe
|
||||
@@ -13,12 +13,19 @@ asked for.
|
||||
|
||||
## Do this first (every session)
|
||||
|
||||
**Pull the standing rules yourself — do not wait for them to be handed to you.**
|
||||
At the start of a session, before substantive work, call
|
||||
`list_always_on_rules()` to load the operator's always-on rules. If the working
|
||||
repo maps to a Scribe project (you're in a known repo, or `list_repo_bindings`
|
||||
shows a binding), call `enter_project(id)` instead/as-well — it returns the
|
||||
project plus its applicable rules, open tasks, and recent notes in one shot.
|
||||
**You are not holding the operator's rules, and no call loads them all.**
|
||||
There is no standing set to pull. A rule reaches you when what you are about to
|
||||
do matches it — a command, code you are writing, or what the operator just
|
||||
asked for — and on most turns none will. That is the surface working.
|
||||
|
||||
**So the reflex is to ASK, not to load.** Before a consequential act — anything
|
||||
hard to reverse or outward-facing — `search(content_type="rule")` for the thing
|
||||
you are about to do. An empty session is not evidence of an empty rulebook.
|
||||
|
||||
If the working repo maps to a Scribe project (you're in a known repo, or
|
||||
`list_repo_bindings` shows a binding), call `enter_project(id)` — it returns the
|
||||
project plus the rules bound to the areas it works in, open tasks, and recent
|
||||
notes in one shot.
|
||||
|
||||
Do this actively. A SessionStart hook *may* also inject a rule index, but treat
|
||||
that as a bonus, not a precondition: it can be absent (e.g. when the instance is
|
||||
@@ -56,11 +63,12 @@ Two constraints on *how* that's achieved:
|
||||
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||
`project_id` so results stay scoped.
|
||||
|
||||
2. **Standing rules are binding — and the ones you were handed are not all of
|
||||
them.** Load the resident set via `list_always_on_rules()` at session start
|
||||
(see "Do this first"). Pull a record's full statement with `get_rule(id)`
|
||||
when it's about to bite. When a project is in scope, `enter_project(id)`
|
||||
also returns its applicable rules.
|
||||
2. **Rules are binding, and silence does not mean there are none.** Nothing
|
||||
is preloaded, so "no rule arrived" means "nothing matched" — never "no rule
|
||||
exists". Ask with `search(content_type="rule")` before a consequential act,
|
||||
and pull a record's full statement with `get_rule(id)` when it is about to
|
||||
bite. When a project is in scope, `enter_project(id)` also returns the rules
|
||||
bound to its areas.
|
||||
|
||||
**`kind` says how much force a record carries, and it is never something to
|
||||
infer.** A **rule** must be followed: ignoring it breaks something or
|
||||
@@ -222,13 +230,12 @@ bound — confine the session to it:
|
||||
## Starting a project: decide what it inherits
|
||||
|
||||
A project's inheritance is a **decision, not a default**. Before
|
||||
`create_project`, ask the operator the four inception questions and pass the
|
||||
`create_project`, ask the operator the three inception questions and pass the
|
||||
answers — never create a project bare by default:
|
||||
|
||||
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
|
||||
which are always_on; default: inherit them all) →
|
||||
`exclude_always_on_rulebooks=[...]`
|
||||
- which other rulebooks to **subscribe** → `subscribe_rulebooks=[...]`
|
||||
- which rulebooks to **subscribe** (`list_rulebooks` shows them; default: none
|
||||
— a rulebook binds a project only when it opts in) →
|
||||
`subscribe_rulebooks=[...]`
|
||||
- which **design system** its UI is built from (`list_design_systems`; or
|
||||
none) → `design_system_id=<id | -1>`
|
||||
- whether to **seed the standard starter Systems** so records can be tagged
|
||||
@@ -246,19 +253,23 @@ inception is the moment they are decided together, and the record of why.
|
||||
When codifying a rule, pick its home by **who it should bind** — and keep
|
||||
shared homes general:
|
||||
|
||||
- **Always-on rulebook** (`create_rule` in an `always_on` rulebook) — universal
|
||||
norms that bind *every* project. Cross-project standards only.
|
||||
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
||||
reusable, *themed* module of general rules that binds only projects that opt
|
||||
in (e.g. a review checklist → every service). Themed, but project-agnostic.
|
||||
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable,
|
||||
*themed* module of general rules that binds the projects which opt in (e.g. a
|
||||
review checklist → every service). Themed, but project-agnostic.
|
||||
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||
(its files, paths, quirks).
|
||||
|
||||
Both rulebook tiers are shared, so their rules stay general; they differ in
|
||||
**reach** (all vs opt-in), not generality. Names one project's specifics →
|
||||
project rule; a standard a category shares → subscribed rulebook; a universal
|
||||
norm → always-on rulebook. Never put project-specific detail in a shared
|
||||
rulebook — it leaks to every other project that gets it.
|
||||
There used to be a third home — an `always_on` rulebook that bound every
|
||||
project automatically. It is gone: subscription is the only reach a rulebook
|
||||
has. Names one project's specifics → project rule; anything a category of
|
||||
projects shares → rulebook. Never put project-specific detail in a rulebook —
|
||||
it leaks to every other project that subscribes.
|
||||
|
||||
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
|
||||
that decides whether the rule is ever seen: nothing is preloaded, so a rule
|
||||
with no trigger is not a quiet rule, it is an unreachable one. Write the moment
|
||||
in the words a session actually produces — the command, the error, the
|
||||
half-formed ask — not the category it belongs to.
|
||||
|
||||
**First ask whether it's a rule at all.** A rule is prose you have to remember
|
||||
and apply; Scribe's other entities are structure a tool can resolve and check.
|
||||
|
||||
+17
-17
@@ -38,8 +38,8 @@ from quart import Quart
|
||||
# them) was DECLINED a line, deliberately, by the operator — not overlooked.
|
||||
# The reasoning, so it is not re-litigated blind: this is a map, and its own
|
||||
# closing line says each tool's description carries the full contract. The
|
||||
# sweep is a curation act, not a session-start reflex like enter_project or
|
||||
# list_always_on_rules. Spending the last of the budget on it would leave the
|
||||
# sweep is a curation act, not a session-start reflex like enter_project.
|
||||
# Spending the last of the budget on it would leave the
|
||||
# map unable to grow for something more central later.
|
||||
#
|
||||
# The accepted cost: an agent that never opens create_note's docstring never
|
||||
@@ -59,15 +59,15 @@ from quart import Quart
|
||||
# - What it bought is not per-tool guidance and has nowhere else to live at
|
||||
# session-start altitude. Rules were retrievable only by RESIDENCY: the
|
||||
# always-on preload put them in front of the agent, and nothing told a
|
||||
# session to go looking for one it had not been handed. The tier split is
|
||||
# therefore load-bearing on ANY install (rule 115): a delivered rule costs
|
||||
# tokens in every session forever, so a rulebook that only delivers cannot
|
||||
# grow past what one session can hold, and every rule worth keeping has to
|
||||
# become resident to bind at all. Retrieval is what lets it keep growing —
|
||||
# and retrieval fires only if something asks, which nothing told a session
|
||||
# to do. A tool-choice reflex asks least of all (#3476, #161).
|
||||
# - This states the PULL for conditional rules, exactly as the surrounding
|
||||
# line states it for always-on ones. Rule 119 makes these surfaces the
|
||||
# session to go looking for one it had not been handed. That preload is
|
||||
# gone (milestone 394), which makes this line LOAD-BEARING rather than
|
||||
# supplementary: retrieval is now the only delivery, and retrieval fires
|
||||
# only if something asks. A session that waits to be handed a rule is
|
||||
# handed nothing. A tool-choice reflex asks least of all (#3476, #161).
|
||||
# - It also has to carry what absence MEANS. "No rule arrived" is now the
|
||||
# ordinary state rather than the exceptional one, and reading it as
|
||||
# "there is no rule" is the #3720 defect at session scale. Rule 119 makes
|
||||
# these surfaces the
|
||||
# specification, so the same sentence lands on all three session-start
|
||||
# surfaces, and test_instruction_surfaces_agree pins it.
|
||||
_INSTRUCTIONS = """
|
||||
@@ -77,8 +77,8 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
|
||||
|
||||
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
||||
notes, Systems, design system. `inception` key: ask what the project
|
||||
inherits, decide_project_inception (create_project takes the same).
|
||||
notes, Systems, design system. `inception`: ask what the project
|
||||
inherits, then decide_project_inception.
|
||||
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
|
||||
fix), never a work-log line on an unrelated task. Log with add_task_log;
|
||||
keep status honest — in_progress on start, done on finish.
|
||||
@@ -88,9 +88,9 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||
active project_id to stay in scope.
|
||||
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||
create_system when the area is unmodelled.
|
||||
- HOW: rules bind; preferences guide. list_always_on_rules() at start;
|
||||
before a consequential act, search(content_type="rule") — the resident
|
||||
set is not all of them.
|
||||
- HOW: rules bind; preferences guide. Nothing preloads — a rule arrives
|
||||
when your work matches it. Before a consequential act,
|
||||
search(content_type="rule"); silence means nothing matched, not none.
|
||||
- UI: the project's design system is binding — resolve_design_system /
|
||||
get_design_system_stylesheet before hand-writing a value.
|
||||
- REUSE: search snippets before writing a helper; record what you build with
|
||||
@@ -130,7 +130,7 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"get_task", "get_milestone", "get_recent", "enter_project",
|
||||
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
"search",
|
||||
"get_system", "list_systems", "list_system_records",
|
||||
# The global area catalog and its mapping REPORT — propose writes nothing;
|
||||
# map_system_to_canonical is the separate, explicitly-called write.
|
||||
|
||||
@@ -256,17 +256,16 @@ async def get_project(project_id: int) -> dict:
|
||||
|
||||
|
||||
def _inception_choices(
|
||||
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||
subscribe_rulebooks, design_system_id, seed_systems,
|
||||
) -> dict | None:
|
||||
"""The tool args → an inception choices object, or None when no inception
|
||||
arg was given at all (a bare create stays undecided and enter_project
|
||||
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
||||
system."""
|
||||
if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
|
||||
if (subscribe_rulebooks is None
|
||||
and not design_system_id and seed_systems is None):
|
||||
return None
|
||||
return {
|
||||
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
|
||||
"subscribe_rulebooks": list(subscribe_rulebooks or []),
|
||||
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
||||
"seed_systems": bool(seed_systems),
|
||||
@@ -279,7 +278,6 @@ async def create_project(
|
||||
goal: str = "",
|
||||
status: str = "active",
|
||||
color: str = "",
|
||||
exclude_always_on_rulebooks: list[int] | None = None,
|
||||
subscribe_rulebooks: list[int] | None = None,
|
||||
design_system_id: int = 0,
|
||||
seed_systems: bool | None = None,
|
||||
@@ -299,9 +297,10 @@ async def create_project(
|
||||
goal: The desired outcome or definition of done for the project.
|
||||
status: one of active (default), paused, completed, archived.
|
||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||
exclude_always_on_rulebooks: always-on rulebook ids this project does
|
||||
subscribe_rulebooks: rulebook ids this project opts into. Since
|
||||
milestone 394 subscription is the only way a rulebook binds a
|
||||
project, so there is no automatic tier left to decline. Was
|
||||
NOT inherit ([] = inherit them all). list_rulebooks shows which are
|
||||
always_on.
|
||||
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
|
||||
design_system_id: the design system this project's UI is built from
|
||||
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
||||
@@ -319,7 +318,7 @@ async def create_project(
|
||||
)
|
||||
data = project.to_dict()
|
||||
choices = _inception_choices(
|
||||
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||
subscribe_rulebooks, design_system_id, seed_systems,
|
||||
)
|
||||
if choices is not None:
|
||||
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
||||
@@ -336,7 +335,6 @@ async def create_project(
|
||||
|
||||
async def decide_project_inception(
|
||||
project_id: int,
|
||||
exclude_always_on_rulebooks: list[int] | None = None,
|
||||
subscribe_rulebooks: list[int] | None = None,
|
||||
design_system_id: int = 0,
|
||||
seed_systems: bool | None = None,
|
||||
@@ -345,11 +343,11 @@ async def decide_project_inception(
|
||||
or re-decide later (milestone 297).
|
||||
|
||||
Owner-only. Applies the effects through the ordinary tools' paths —
|
||||
exclude_always_on_rulebook, subscribe_project_to_rulebook,
|
||||
subscribe_project_to_rulebook,
|
||||
set_project_design_system, the standard Systems seed — and writes the
|
||||
decision on the project last, so get_project/enter_project can say why
|
||||
the project has the rules, design and Systems it has. Re-deciding is
|
||||
additive for exclusions/subscriptions (use include_always_on_rulebook /
|
||||
additive for subscriptions (use
|
||||
unsubscribe_project_from_rulebook to undo one), replaces the design
|
||||
system, and never re-seeds Systems a project already has.
|
||||
|
||||
@@ -359,7 +357,7 @@ async def decide_project_inception(
|
||||
"""
|
||||
uid = current_user_id()
|
||||
choices = _inception_choices(
|
||||
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||
subscribe_rulebooks, design_system_id, seed_systems,
|
||||
) or {}
|
||||
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
||||
return {"project_id": project_id, **decided}
|
||||
|
||||
@@ -18,7 +18,7 @@ from scribe.mcp._context import current_user_id
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import trash as trash_svc
|
||||
from scribe.services.rule_usage import record_rule_pulled, record_rule_surfaced
|
||||
from scribe.services.rule_usage import record_rule_pulled
|
||||
|
||||
|
||||
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
||||
@@ -48,16 +48,13 @@ async def get_rulebook(rulebook_id: int) -> dict:
|
||||
async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
"""Create a new rulebook (a shared, reusable module of general rules).
|
||||
|
||||
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
|
||||
update_rulebook):
|
||||
- always_on = true -> binds EVERY one of your projects automatically.
|
||||
Use for universal cross-project norms that apply across every
|
||||
project, not just one.
|
||||
- always_on = false -> binds only projects that subscribe
|
||||
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
|
||||
category of projects shares (e.g. a design system that visual apps
|
||||
opt into).
|
||||
Either way a rulebook is SHARED, so its rules must stay general — agnostic
|
||||
A rulebook reaches a project ONE way: the project subscribes to it
|
||||
(subscribe_project_to_rulebook). There was a second until milestone 394 —
|
||||
an `always_on` flag that bound every project automatically — and it is
|
||||
gone with the tier it belonged to. Opt-in is now the whole model, so a
|
||||
rulebook binds what asked for it and nothing else.
|
||||
|
||||
A rulebook is SHARED, so its rules must stay general — agnostic
|
||||
to any single project. Project-specific rules go in create_project_rule.
|
||||
|
||||
Args:
|
||||
@@ -73,7 +70,6 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
|
||||
async def update_rulebook(
|
||||
rulebook_id: int, title: str = "", description: str = "",
|
||||
always_on: bool | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing rulebook. Only non-empty fields are changed.
|
||||
|
||||
@@ -81,9 +77,6 @@ async def update_rulebook(
|
||||
rulebook_id: Rulebook to update.
|
||||
title: New title. Empty string leaves unchanged.
|
||||
description: New description. Empty string leaves unchanged.
|
||||
always_on: When True, rules in this rulebook are loaded at session
|
||||
start by list_always_on_rules regardless of project context.
|
||||
Pass None to leave unchanged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -91,8 +84,6 @@ async def update_rulebook(
|
||||
fields["title"] = title
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if always_on is not None:
|
||||
fields["always_on"] = always_on
|
||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
||||
if rb is None:
|
||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
||||
@@ -234,58 +225,6 @@ async def list_rules(
|
||||
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
|
||||
|
||||
|
||||
async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
"""Return all rules from rulebooks flagged always_on for the current user.
|
||||
|
||||
Call this at session start. Treat the returned rules as binding for the
|
||||
session — they apply regardless of which project (if any) is in scope.
|
||||
|
||||
Returns the ALWAYS-ON tier only (milestone 307). A `conditional` rule is
|
||||
still binding when it applies; it just is not resident — it reaches a
|
||||
session through enter_project (when the project works in an area the rule
|
||||
is tagged to) or through search(content_type="rule"). Nothing here is a
|
||||
behaviour change until rules are actually re-tiered: `tier` defaults to
|
||||
always_on, so an existing rulebook returns exactly what it always did.
|
||||
Pair with get_project(id).applicable_rules when working on a specific
|
||||
project to also load that project's subscription-derived rules.
|
||||
|
||||
A rule carrying `last_verified` asserts a FACT about something outside the
|
||||
operator's control — a runner's shell, a tool's existence, a setting
|
||||
somewhere. It is still binding; the field says how long ago anyone
|
||||
confirmed it, and "never" means nobody has. Follow the rule, and if you
|
||||
are already standing where the check could be made, make it: get_rule
|
||||
gives you its `verify_with`. Most rules have no such field, which means
|
||||
they are decisions and there is nothing to check.
|
||||
|
||||
Args:
|
||||
project_id: 0 (default) = the user-wide set. Inside a project, pass
|
||||
its id: an always-on rulebook the project EXCLUDED at inception
|
||||
(see enter_project's `excluded_always_on`) is left out — the
|
||||
project decided not to inherit it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
|
||||
# AMBIENT source: the resident set, handed over whole. No ranker chose
|
||||
# these, so they must not land in the pull-through numerator's denominator
|
||||
# — but they must land SOMEWHERE, or the largest rule surface in the
|
||||
# product stays the one surface its own scoreboard cannot see (#3473).
|
||||
record_rule_surfaced(
|
||||
user_id=uid,
|
||||
rule_ids=[r.id for r in rules],
|
||||
source="list_always_on_rules",
|
||||
)
|
||||
return {
|
||||
"rules": [_rule_summary(r) for r in rules],
|
||||
"total": len(rules),
|
||||
# A marker for the set you are now holding. It is not for you to read:
|
||||
# the write-path hook carries it back and is told if these rules have
|
||||
# moved since. Deliberately NOT on rules_payload's applicable_rules —
|
||||
# that is a DIFFERENT set (subscription-derived), and one key name
|
||||
# over two sets is how a comparison starts reporting phantom changes.
|
||||
"rules_etag": rulebooks_svc.rules_etag(rules),
|
||||
}
|
||||
|
||||
|
||||
async def get_rule(rule_id: int) -> dict:
|
||||
"""Fetch a rule by id — full statement + why + how_to_apply.
|
||||
|
||||
@@ -309,9 +248,8 @@ async def get_rule(rule_id: int) -> dict:
|
||||
async def create_rule(
|
||||
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
force: bool = False,
|
||||
system_ids: list[int] | None = None, force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||
|
||||
@@ -357,7 +295,7 @@ async def create_rule(
|
||||
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
||||
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
||||
so that text is what gets stored, verbatim.
|
||||
* "LET'S TALK ABOUT IT" — the wording, the scope, the tier, whether it
|
||||
* "LET'S TALK ABOUT IT" — the wording, the scope, whether it
|
||||
wants to be a rule at all. Most good rules arrive this way, so treat
|
||||
this answer as the expected one rather than a setback.
|
||||
* "NO" — let it go. If the observation is still worth keeping, it is a
|
||||
@@ -371,7 +309,7 @@ async def create_rule(
|
||||
into existence, which is the thing this whole loop exists to prevent.
|
||||
|
||||
A rulebook rule is shared by every project that gets the rulebook: an
|
||||
always_on rulebook binds ALL your projects; a subscribed rulebook binds the
|
||||
A subscribed rulebook binds the
|
||||
projects that opt in. So a rulebook rule must read as a general standard —
|
||||
never pin it to one project's files, paths, or quirks. For a rule that
|
||||
applies to a single project only, use create_project_rule instead (no
|
||||
@@ -408,7 +346,7 @@ async def create_rule(
|
||||
instruction. State the moment or the material: "before any git
|
||||
push", "when adding a value to a CHECK-gated column", "when a
|
||||
release is being cut". Write it even though the parameter is
|
||||
optional: it decides the tier below, it is how the rule is found
|
||||
optional: it is how the rule is found
|
||||
when it matters, and a rule nobody can place is a rule nobody
|
||||
applies.
|
||||
This field is also the rule's RETRIEVAL SURFACE — it and the
|
||||
@@ -420,12 +358,14 @@ async def create_rule(
|
||||
brought it back as the top hit. Where a rule prevents a specific
|
||||
failure, put that failure's vocabulary here — the error text,
|
||||
the wrong behaviour, the dead end.
|
||||
tier: "always_on" (default) or "conditional".
|
||||
The test: can you name the trigger WITHOUT naming a system, an
|
||||
artifact type or a moment? If the honest answer is "whenever you
|
||||
are working", it is always_on. If you had to name something, it is
|
||||
conditional — and conditional costs nothing when it is irrelevant,
|
||||
which is what lets it be as long as it needs to be.
|
||||
The two spellings, side by side:
|
||||
RETRIEVES: "the migration failed with a check violation on a
|
||||
column we just extended"
|
||||
COLLAPSES: "when working on migrations"
|
||||
The second names a CATEGORY. No session ever produces a
|
||||
category — it produces the command, the error, the half-formed
|
||||
ask — so a trigger written that way leaves the embedded
|
||||
document to be carried by the title alone.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
rule is about. This is what lets a rule reach a project that is
|
||||
working in that area, so a CI rule surfaces on a CI change.
|
||||
@@ -464,7 +404,7 @@ async def create_rule(
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic_id=topic_id, user_id=uid,
|
||||
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
verify_with=verify_with, expires_when=expires_when,
|
||||
)
|
||||
@@ -474,9 +414,8 @@ async def create_rule(
|
||||
async def create_project_rule(
|
||||
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
force: bool = False,
|
||||
system_ids: list[int] | None = None, force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a rule scoped to a single project (no rulebook needed).
|
||||
|
||||
@@ -518,25 +457,12 @@ async def create_project_rule(
|
||||
characters of statement.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
||||
the words someone would type while stuck. See create_rule for the
|
||||
full argument. It informs the tier below rather than deciding it,
|
||||
since a project rule's tier turns on area-scope, not on whether
|
||||
the trigger can be named.
|
||||
tier: "always_on" (default) or "conditional". The SAME two values as
|
||||
create_rule, judged against a different cost — do not import that
|
||||
tool's test wholesale. There, always_on means every session in
|
||||
every project, so the bar is high: the trigger must be nameless
|
||||
("whenever you are working"). Here the rule is already scoped to
|
||||
one project by construction, so always_on costs only that
|
||||
project's sessions and the bar is correspondingly lower. A
|
||||
project rule that names something specific is still ordinarily
|
||||
always_on — being specific is what project rules are FOR.
|
||||
Reach for conditional when the rule is about one AREA of a large
|
||||
project — a CI quirk, a migration gotcha, one subsystem's
|
||||
convention — so it arrives with that area instead of resident in
|
||||
every session. The failure to avoid is local: forty always-on
|
||||
rules on one project reproduces, inside that project, exactly the
|
||||
preload bloat that made every rule compete for the same budget.
|
||||
the words someone would type while stuck. Show the moment rather
|
||||
than classifying it:
|
||||
RETRIEVES: "the CI job passed locally and fails on the runner
|
||||
with a permission error"
|
||||
COLLAPSES: "when touching CI config"
|
||||
See create_rule for the full argument.
|
||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||
rule is about. Worth setting even on a project rule: it is what
|
||||
lets a conditional one surface when the project is working in
|
||||
@@ -570,7 +496,7 @@ async def create_project_rule(
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id, user_id=uid,
|
||||
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||
tier=tier, arose_from_id=arose_from_id,
|
||||
arose_from_id=arose_from_id,
|
||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||
verify_with=verify_with, expires_when=expires_when,
|
||||
)
|
||||
@@ -580,7 +506,7 @@ async def create_project_rule(
|
||||
async def update_rule(
|
||||
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
|
||||
system_ids: list[int] | None = None, arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "",
|
||||
clear_fields: list[str] | None = None,
|
||||
) -> dict:
|
||||
@@ -593,9 +519,35 @@ async def update_rule(
|
||||
correct. Ordinary edits to an existing preference belong in
|
||||
update_preference, which asks for what taught the change.
|
||||
|
||||
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
|
||||
a rule stops being preloaded into every session and starts arriving when it
|
||||
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
||||
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
|
||||
milestone 394, so a rule with no trigger is not a quiet rule — it is one
|
||||
no session will ever be shown. `system_ids` REPLACES the rule's areas
|
||||
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
|
||||
|
||||
RETROFITTING A TRIGGER HAS ITS OWN TRAP, and it is not the one create_rule
|
||||
warns about. There the field is empty and the instruction is "write one".
|
||||
Here a trigger usually already EXISTS and reads perfectly well as English —
|
||||
"during hard debugging", "when reading any request from the operator",
|
||||
"before starting an action while a previous one is still settling" — so the
|
||||
honest-looking verdict is that it is fine. It is not. Those three named a
|
||||
CATEGORY rather than a moment, and a category is not a thing any session
|
||||
ever types. `rule_document()` puts this field in twice, as the title's
|
||||
other half and again above the body, so it dominates the vector: a trigger
|
||||
describing the abstraction collapses the record toward its title and the
|
||||
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
|
||||
on six preferences written before this was understood.
|
||||
|
||||
So when you touch a rule with an old trigger, re-read it against the query
|
||||
that would have to match it — the command about to run, the code being
|
||||
written, the operator's actual message — and rewrite it in that vocabulary
|
||||
if it does not. Prefer the words someone produces while the rule applies,
|
||||
including the rationalisation they would be drafting to talk themselves out
|
||||
of it — that rationalisation is often the only text in existence at the
|
||||
moment the rule should fire:
|
||||
RETRIEVES: "catching yourself drafting 'this is small enough to not
|
||||
count' about a rule you have already read"
|
||||
COLLAPSES: "when the next action would conflict with a standing rule"
|
||||
See create_rule for the full argument and the measurement behind it.
|
||||
|
||||
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
|
||||
do it — "" means "leave this alone" here, which is what lets you update
|
||||
@@ -624,8 +576,6 @@ async def update_rule(
|
||||
fields["statement"] = statement
|
||||
if when_to_apply:
|
||||
fields["when_to_apply"] = when_to_apply
|
||||
if tier:
|
||||
fields["tier"] = tier
|
||||
if kind:
|
||||
fields["kind"] = kind
|
||||
if arose_from_id:
|
||||
@@ -692,6 +642,16 @@ async def create_preference(
|
||||
session would actually be producing then: the command it is about to run,
|
||||
the code it is writing, the thing the operator just asked for.
|
||||
|
||||
Show the moment rather than classifying it:
|
||||
RETRIEVES: "the operator pasted a stack trace and said it is still
|
||||
broken"
|
||||
COLLAPSES: "during hard debugging"
|
||||
The second is a category, and no session ever produces a category — it
|
||||
produces the command, the error text, the half-formed ask. A trigger
|
||||
naming the abstraction collapses the record toward its title and it
|
||||
never arrives. Both of those describe the same preference; only one of
|
||||
them can be found at the moment it applies.
|
||||
|
||||
`arose_from_id` IS REQUIRED for the same kind of reason. A preference is
|
||||
expected to change as the work teaches it, and a corpus that drifts with
|
||||
no record of what taught each change is one nobody can audit. Point it at
|
||||
@@ -781,12 +741,32 @@ async def update_preference(
|
||||
create_rule rather than hardening a preference in place. Softening in the
|
||||
other direction is equally an edit worth flagging out loud.
|
||||
|
||||
EDITING `when_to_apply` IS THE HIGHEST-LEVERAGE EDIT HERE, and the easiest
|
||||
to skip, because a preference's trigger is load-bearing in a way a rule's
|
||||
is not. Preferences get a RESERVED slot at the prompt boundary, filled by a
|
||||
kind-filtered query at limit=1 — so the corpus does not merely rank against
|
||||
rules, it ranks against ITSELF, and the trigger is almost all of what
|
||||
separates one preference from the next. Six preferences whose triggers all
|
||||
named a category ("during hard debugging", "when reading any request from
|
||||
the operator") made that slot pick close to arbitrarily on every prompt.
|
||||
|
||||
So whenever you are here for any reason, read the trigger against the
|
||||
operator's message that should have summoned it. If it describes a
|
||||
situation rather than quoting the moment, rewrite it in the words they
|
||||
actually type — and in the words YOU would be producing while about to get
|
||||
this wrong:
|
||||
RETRIEVES: "the operator said 'clean this up' or 'make it work like',
|
||||
naming an outcome rather than a change"
|
||||
COLLAPSES: "when reading any request from the operator"
|
||||
update_rule carries the full argument.
|
||||
|
||||
Empty strings leave fields unchanged; clear_fields empties them by name,
|
||||
exactly as update_rule does.
|
||||
|
||||
Args:
|
||||
rule_id: The preference to update.
|
||||
arose_from_id: What taught this change. Required; see above.
|
||||
when_to_apply: The moment it applies, in session vocabulary. See above.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not arose_from_id:
|
||||
@@ -910,7 +890,7 @@ async def subscribe_project_to_rulebook(
|
||||
) -> dict:
|
||||
"""Subscribe a project to a rulebook — its rules then bind that project.
|
||||
|
||||
Subscription is the opt-in path for a non-always_on rulebook: a reusable,
|
||||
Subscription is the ONLY path for a rulebook (milestone 394): a reusable,
|
||||
themed module of GENERAL rules shared across the projects that subscribe.
|
||||
Subscribe a project because it fits the rulebook's theme (e.g. a visual app
|
||||
-> the design-system rulebook), not to host rules about this one project —
|
||||
@@ -936,34 +916,6 @@ async def unsubscribe_project_from_rulebook(
|
||||
|
||||
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||
|
||||
async def exclude_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
|
||||
"""Opt a project OUT of a whole always-on rulebook (milestone 297).
|
||||
|
||||
Always-on rulebooks bind every project implicitly; an inception decision
|
||||
can say "not this one, not here". The exclusion is total for that project
|
||||
— list_always_on_rules(project_id), enter_project/get_project rules and
|
||||
the session-start context all leave it out and name it under
|
||||
`excluded_always_on`. Owner-only; the rulebook must be always_on (a
|
||||
subscribed rulebook is left with unsubscribe_project_from_rulebook).
|
||||
Idempotent; include_always_on_rulebook reverses it. Normally reached via
|
||||
decide_project_inception, not by hand.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
||||
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": True}
|
||||
|
||||
|
||||
async def include_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
|
||||
"""Reverse exclude_always_on_rulebook: the always-on rulebook binds this
|
||||
project again. Idempotent."""
|
||||
uid = current_user_id()
|
||||
await rulebooks_svc.include_always_on_rulebook_for_project(
|
||||
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
|
||||
)
|
||||
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": False}
|
||||
|
||||
|
||||
async def suppress_rule_for_project(
|
||||
project_id: int, rule_id: int,
|
||||
@@ -1019,8 +971,6 @@ async def unsuppress_topic_for_project(
|
||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||
|
||||
|
||||
|
||||
|
||||
async def relate_rules(
|
||||
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||
) -> dict:
|
||||
@@ -1068,7 +1018,7 @@ async def unrelate_rules(relation_id: int) -> dict:
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
older_than_days: int = 0, tier: str = "", never_only: bool = False,
|
||||
older_than_days: int = 0, never_only: bool = False,
|
||||
) -> dict:
|
||||
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
||||
|
||||
@@ -1095,9 +1045,6 @@ async def rules_due_for_verification(
|
||||
Args:
|
||||
older_than_days: only rules last verified longer ago than this.
|
||||
Never-checked rules always qualify. 0 = no age filter.
|
||||
tier: "always_on" or "conditional" to narrow. An always-on constraint
|
||||
that has gone false is the expensive kind — it is preloaded into
|
||||
every session, so a wrong one is wrong everywhere at once.
|
||||
never_only: only rules nobody has ever verified.
|
||||
|
||||
NOT filterable by project, deliberately: a project reaches rules through
|
||||
@@ -1107,7 +1054,7 @@ async def rules_due_for_verification(
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.rules_due_for_verification(
|
||||
uid, older_than_days=older_than_days, tier=tier, never_only=never_only,
|
||||
uid, older_than_days=older_than_days, never_only=never_only,
|
||||
)
|
||||
return {
|
||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||
@@ -1160,14 +1107,13 @@ def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
list_topics, create_topic, update_topic, delete_topic,
|
||||
list_rules, list_always_on_rules, get_rule,
|
||||
list_rules, get_rule,
|
||||
create_rule, create_project_rule, update_rule, delete_rule,
|
||||
create_preference, update_preference,
|
||||
relate_rules, unrelate_rules,
|
||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||
exclude_always_on_rulebook, include_always_on_rulebook,
|
||||
rules_due_for_verification, mark_rule_verified,
|
||||
rule_history,
|
||||
):
|
||||
|
||||
@@ -40,7 +40,6 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
"tier": rule.tier,
|
||||
"why": rule.why or "",
|
||||
"how_to_apply": rule.how_to_apply or "",
|
||||
"verify_with": rule.verify_with or "",
|
||||
@@ -282,7 +281,7 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
||||
`surfaced` VS `ambient` IS THE READING THAT MATTERS HERE. `surfaced` counts
|
||||
rules a ranker chose — today only the write-path arm — and those are claims
|
||||
a pull can settle. `ambient` counts BULK DELIVERIES: the SessionStart
|
||||
preload, `list_always_on_rules`, and the `rules_payload` surfaces
|
||||
preload and the `rules_payload` surfaces
|
||||
(`enter_project`, `get_project`, `get_milestone`, `start_planning`,
|
||||
`get_task`), which hand over the whole applicable set at once with nobody
|
||||
choosing anything. A large `ambient` says the resident set is big and
|
||||
|
||||
@@ -39,7 +39,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
# The inception record (milestone 297): what this project was decided to
|
||||
# inherit, when, and through which door — {decided_at, decided_by, via,
|
||||
# choices: {exclude_always_on_rulebooks, subscribe_rulebooks,
|
||||
# choices: {subscribe_rulebooks,
|
||||
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
|
||||
# and enter_project asks; the effects themselves live in the subscription
|
||||
# / exclusion tables, design_system_id and the project's Systems — this is
|
||||
|
||||
@@ -58,7 +58,6 @@ class RuleVersion(Base, CreatedAtMixin):
|
||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tier: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Carried so that a change of FORCE leaves a trace. `record_if_changed`
|
||||
# snapshots only the fields a version holds, so a kind omitted here would
|
||||
# make "this stopped binding" the one edit with no history behind it.
|
||||
@@ -90,7 +89,6 @@ class RuleVersion(Base, CreatedAtMixin):
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"when_to_apply": self.when_to_apply or "",
|
||||
"tier": self.tier or "",
|
||||
"kind": self.kind or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
"expires_when": self.expires_when or "",
|
||||
|
||||
@@ -19,9 +19,6 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
always_on: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, server_default="false"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -29,7 +26,6 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"owner_user_id": self.owner_user_id,
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"always_on": self.always_on,
|
||||
"created_at": iso(self.created_at),
|
||||
"updated_at": iso(self.updated_at),
|
||||
}
|
||||
@@ -96,16 +92,15 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# WHEN this rule applies — the trigger, not the instruction. Required of
|
||||
# new rules at the service layer and nullable here, because rules written
|
||||
# before migration 0088 have none and a migration cannot invent one.
|
||||
# It carries three jobs at once (note 3026): it is the tier test made
|
||||
# concrete, the readable form of the canon tag, and the half of the
|
||||
# document that makes a rule findable by meaning.
|
||||
# It carries three jobs at once (note 3026): it is the readable form of
|
||||
# the canon tag, the half of the document that makes a rule findable by
|
||||
# meaning, and — since milestone 394 removed the always-on tier — the ONLY
|
||||
# thing that decides whether a rule ever reaches a session at all. A rule
|
||||
# with no trigger is not a quiet rule, it is an unreachable one.
|
||||
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# always_on = preloaded into every session, as every rule is today.
|
||||
# conditional = reachable, and surfaced when its trigger fires. The
|
||||
# default preserves existing behaviour exactly: nothing stops binding
|
||||
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
|
||||
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||
# WHAT KIND of instruction this is — force, where `tier` is delivery.
|
||||
# WHAT KIND of instruction this is. `tier` used to sit beside this and
|
||||
# carry delivery; milestone 394 removed it, so kind is now the only axis
|
||||
# on a rule and delivery belongs entirely to retrieval.
|
||||
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
|
||||
# boundary. `preference` is how this person wants work DONE: ignoring it
|
||||
# costs consistency, not correctness.
|
||||
@@ -161,7 +156,6 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"title": self.title,
|
||||
"statement": self.statement,
|
||||
"when_to_apply": self.when_to_apply or "",
|
||||
"tier": self.tier,
|
||||
# Unconditional, unlike the `if present` keys below. A reader
|
||||
# deciding how much force a record carries must never infer it
|
||||
# from an ABSENT key: "no kind field" and "kind is rule" would be
|
||||
@@ -262,18 +256,11 @@ project_rule_suppressions = Table(
|
||||
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
|
||||
# sibling of the two suppression tables below, one level up. Always-on
|
||||
# rulebooks bind every project implicitly; an inception decision can exclude
|
||||
# specific ones for this project, and get_applicable_rules /
|
||||
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
|
||||
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_rulebook_exclusions` lived here until milestone 394. It recorded a
|
||||
# project's opt-out of a whole always-on rulebook — which only made sense
|
||||
# while a rulebook could bind a project WITHOUT being asked. Subscription is
|
||||
# now the only reach a rulebook has, so declining one is expressed by not
|
||||
# subscribing, and there is nothing left to opt out of.
|
||||
|
||||
project_topic_suppressions = Table(
|
||||
"project_topic_suppressions",
|
||||
|
||||
@@ -202,11 +202,6 @@ async def write_path_prior_art():
|
||||
or `canon:<snippet_id>`) already named this
|
||||
session by the ledger arm (#2900); its own
|
||||
channel, like the two above.
|
||||
rules_etag (opt) — the marker the session was given when it loaded
|
||||
its always-on rules (milestone 323). Sent back
|
||||
so the server can say whether those rules have
|
||||
MOVED since. Absent means the hook has nothing
|
||||
stored, which is silence, not a mismatch.
|
||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||
found in (or enclosing) the payload, kind being
|
||||
css|sym. The shape ledger's write-path feed
|
||||
@@ -226,7 +221,6 @@ async def write_path_prior_art():
|
||||
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
||||
]
|
||||
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||
rules_etag = (request.args.get("rules_etag") or "").strip()
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
@@ -238,7 +232,6 @@ async def write_path_prior_art():
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
exclude_derive=exclude_derive,
|
||||
exclude_rule_ids=exclude_rule_ids,
|
||||
rules_etag=rules_etag,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ async def create_project_route():
|
||||
@login_required
|
||||
async def decide_inception_route(project_id: int):
|
||||
"""Record (or re-record) what a project inherits — milestone 297.
|
||||
Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
|
||||
Body: the choices object {subscribe_rulebooks,
|
||||
design_system_id, seed_systems}; owner-only."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
|
||||
@@ -55,7 +55,7 @@ async def get_rulebook(rulebook_id: int):
|
||||
@login_required
|
||||
async def update_rulebook(rulebook_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description")}
|
||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
|
||||
if rb is None:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
@@ -177,7 +177,6 @@ async def create_rule(topic_id: int):
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
# The human door carries `kind` too, and without the MCP door's
|
||||
# required provenance: an operator editing their own preference
|
||||
# owes nobody an explanation. That requirement is about auditing
|
||||
@@ -217,7 +216,7 @@ async def update_rule(rule_id: int):
|
||||
fields = {
|
||||
k: v for k, v in data.items()
|
||||
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "kind", "arose_from_id",
|
||||
"when_to_apply", "kind", "arose_from_id",
|
||||
"verify_with", "expires_when")
|
||||
}
|
||||
# No clear_fields here: a form sends "" for an emptied input, and the
|
||||
@@ -395,32 +394,6 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
|
||||
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
|
||||
try:
|
||||
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
||||
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||
@login_required
|
||||
async def include_project_rulebook(project_id: int, rulebook_id: int):
|
||||
try:
|
||||
await rulebooks_svc.include_always_on_rulebook_for_project(
|
||||
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return "", 204
|
||||
|
||||
|
||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||
@login_required
|
||||
async def create_project_rule(project_id: int):
|
||||
@@ -440,7 +413,6 @@ async def create_project_rule(project_id: int):
|
||||
how_to_apply=data.get("how_to_apply", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
tier=data.get("tier", "always_on"),
|
||||
# The human door carries `kind` too, and without the MCP door's
|
||||
# required provenance: an operator editing their own preference
|
||||
# owes nobody an explanation. That requirement is about auditing
|
||||
@@ -464,7 +436,7 @@ async def create_project_rule(project_id: int):
|
||||
async def rules_due_for_verification():
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
Query params: older_than_days, tier, never_only. A rule with no
|
||||
Query params: older_than_days, never_only. A rule with no
|
||||
`verify_with` never appears — it is a decision, not a fact.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
@@ -477,7 +449,6 @@ async def rules_due_for_verification():
|
||||
rules = await rulebooks_svc.rules_due_for_verification(
|
||||
uid,
|
||||
older_than_days=older,
|
||||
tier=args.get("tier", ""),
|
||||
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -23,7 +23,6 @@ from scribe.models.rulebook import (
|
||||
Rulebook,
|
||||
RulebookTopic,
|
||||
project_rule_suppressions,
|
||||
project_rulebook_exclusions,
|
||||
project_rulebook_subscriptions,
|
||||
project_topic_suppressions,
|
||||
)
|
||||
@@ -82,7 +81,7 @@ _BACKED_UP = [
|
||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||
"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.
|
||||
"systems", "record_systems", "design_systems", "design_tokens",
|
||||
"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]
|
||||
|
||||
|
||||
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
|
||||
@@ -513,7 +510,7 @@ def _rule_version_rows(rows) -> list[dict]:
|
||||
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
||||
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
||||
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
||||
"tier": rv.tier, "kind": rv.kind, "verify_with": rv.verify_with,
|
||||
"kind": rv.kind, "verify_with": rv.verify_with,
|
||||
"expires_when": rv.expires_when,
|
||||
"created_at": rv.created_at.isoformat(),
|
||||
}
|
||||
@@ -529,7 +526,7 @@ def _rulebook_rows(rows) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title,
|
||||
"description": rb.description, "always_on": rb.always_on,
|
||||
"description": rb.description,
|
||||
"created_at": rb.created_at.isoformat(),
|
||||
"updated_at": rb.updated_at.isoformat(),
|
||||
}
|
||||
@@ -575,7 +572,7 @@ def _rule_rows(rows) -> list[dict]:
|
||||
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
||||
"title": r.title, "statement": r.statement, "why": r.why,
|
||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
||||
"when_to_apply": r.when_to_apply, "tier": r.tier, "kind": r.kind,
|
||||
"when_to_apply": r.when_to_apply, "kind": r.kind,
|
||||
"verify_with": r.verify_with, "expires_when": r.expires_when,
|
||||
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
||||
"arose_from_id": r.arose_from_id,
|
||||
@@ -652,9 +649,6 @@ async def export_full_backup() -> dict:
|
||||
topic_suppressions = (await session.execute(
|
||||
select(project_topic_suppressions)
|
||||
)).all()
|
||||
rulebook_exclusions = (await session.execute(
|
||||
select(project_rulebook_exclusions)
|
||||
)).all()
|
||||
|
||||
return {
|
||||
"version": BACKUP_VERSION,
|
||||
@@ -680,7 +674,6 @@ async def export_full_backup() -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"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)
|
||||
)
|
||||
)).all()
|
||||
rulebook_exclusions = (await session.execute(
|
||||
select(project_rulebook_exclusions).where(
|
||||
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
||||
subscriptions = rule_suppressions = topic_suppressions = []
|
||||
|
||||
return {
|
||||
"version": BACKUP_VERSION,
|
||||
@@ -882,7 +870,6 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||
"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,
|
||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
||||
"topic_suppressions": 0,
|
||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||
"repo_bindings": 0,
|
||||
@@ -1238,7 +1225,6 @@ async def _restore_v2(data: dict) -> dict:
|
||||
owner_user_id=mapped_uid,
|
||||
title=rb_data.get("title", ""),
|
||||
description=rb_data.get("description", ""),
|
||||
always_on=rb_data.get("always_on", False),
|
||||
created_at=_dt(rb_data.get("created_at")),
|
||||
updated_at=_dt(rb_data.get("updated_at")),
|
||||
)
|
||||
@@ -1279,10 +1265,14 @@ async def _restore_v2(data: dict) -> dict:
|
||||
why=r_data.get("why") or None,
|
||||
how_to_apply=r_data.get("how_to_apply") or None,
|
||||
when_to_apply=r_data.get("when_to_apply") or None,
|
||||
# A file written before migration 0088 has no tier. always_on
|
||||
# A file written before milestone 394 carries `tier` and
|
||||
# `always_on`; neither is read. Dropping a field the schema
|
||||
# no longer has is the tolerant direction — an archive
|
||||
# records what WAS, and refusing it because it remembers a
|
||||
# deleted column would make every pre-394 backup
|
||||
# unrestorable. Previously: always_on
|
||||
# is the pre-0088 behaviour, so an old backup restores rules
|
||||
# that bind exactly as they did when it was taken.
|
||||
tier=r_data.get("tier") or "always_on",
|
||||
# Same shape, same reason: a file written before 0098 has no
|
||||
# kind, and every rule in it was a rule. Defaulting the other
|
||||
# way would restore an old backup with things that had always
|
||||
@@ -1344,15 +1334,11 @@ async def _restore_v2(data: dict) -> dict:
|
||||
stats["topic_suppressions"] += 1
|
||||
|
||||
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
||||
for exc in data.get("rulebook_exclusions", []):
|
||||
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
||||
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
||||
if mapped_pid is None or mapped_rbid is None:
|
||||
continue
|
||||
await session.execute(project_rulebook_exclusions.insert().values(
|
||||
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
||||
))
|
||||
stats["rulebook_exclusions"] += 1
|
||||
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
|
||||
# milestone 394 removed the table. An archive carrying it still
|
||||
# imports — the key is simply not looked at — because refusing a
|
||||
# backup for remembering something we deleted would make every v10-v13
|
||||
# archive unrestorable.
|
||||
|
||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||
# payload restores without them rather than failing on an absent key.
|
||||
@@ -1427,7 +1413,6 @@ async def _restore_v2(data: dict) -> dict:
|
||||
why=rv.get("why"),
|
||||
how_to_apply=rv.get("how_to_apply"),
|
||||
when_to_apply=rv.get("when_to_apply"),
|
||||
tier=rv.get("tier"),
|
||||
# NOT defaulted, unlike the rule above. A version records what
|
||||
# was; absent means nobody wrote it down, and inventing "rule"
|
||||
# here would put an artifact where a measurement belongs.
|
||||
@@ -1678,10 +1663,12 @@ async def _restore_v2(data: dict) -> dict:
|
||||
inception = p_data.get("inception")
|
||||
if isinstance(inception, dict):
|
||||
choices = dict(inception.get("choices") or {})
|
||||
choices["exclude_always_on_rulebooks"] = [
|
||||
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
||||
if i in rulebook_id_map
|
||||
]
|
||||
# DROPPED, not remapped (milestone 394). A pre-394 archive
|
||||
# carries the retired exclusion choice; restoring it would put
|
||||
# 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"] = [
|
||||
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
||||
if i in rulebook_id_map
|
||||
|
||||
@@ -817,7 +817,6 @@ async def semantic_search_rules(
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
tier: str | None = None,
|
||||
kind: str | None = None,
|
||||
report: dict | None = None,
|
||||
) -> list[tuple[float, "Rule"]]:
|
||||
@@ -844,17 +843,13 @@ async def semantic_search_rules(
|
||||
is the surfacing question, and it has its own machinery
|
||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||
|
||||
`tier` narrows to one tier, and NONE is the ordinary case. The write-path
|
||||
and pre-tool hints deliberately pass nothing: an always-on rule is already
|
||||
in the session, but being in a list from turn zero is not the same as being
|
||||
in front of the reader when the action it governs is taken, and filtering
|
||||
on tier made a whole class of rules permanently ineligible for the one
|
||||
mechanism that surfaces a rule AT the moment. Relevance is the threshold's
|
||||
job; see the block above RULEHINT_LIMIT in services/plugin_context.py for
|
||||
the argument and for what the resulting scores are being read against.
|
||||
|
||||
Pass a tier when a caller genuinely wants one class — a listing, an audit,
|
||||
a UI that renders the tiers apart. Not to approximate relevance.
|
||||
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a
|
||||
`tier` parameter, and the arms deliberately passed nothing: filtering on it
|
||||
made a whole class of rules permanently ineligible for the one mechanism
|
||||
that surfaces a rule AT the moment it applies. The tier is now gone
|
||||
entirely, so every rule is eligible for every arm and relevance is the
|
||||
threshold's job alone — see the block above RULEHINT_LIMIT in
|
||||
services/plugin_context.py for what those scores are read against.
|
||||
|
||||
`kind` narrows to `rule` or `preference`, and NONE is likewise the ordinary
|
||||
case: a caller asking "what governs this" wants both, because the reader
|
||||
@@ -905,7 +900,6 @@ async def semantic_search_rules(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Project.user_id == user_id,
|
||||
),
|
||||
*( [Rule.tier == tier] if tier else [] ),
|
||||
*( [Rule.kind == kind] if kind else [] ),
|
||||
)
|
||||
# Overfetch so collapsing chunks to their best row still fills
|
||||
|
||||
@@ -7,7 +7,6 @@ A project's inheritance is a decision, not a default. The record lives on
|
||||
"decided_at": "<iso>", "decided_by": <user id> | null,
|
||||
"via": "mcp" | "ui" | "legacy",
|
||||
"choices": {
|
||||
"exclude_always_on_rulebooks": [rulebook ids],
|
||||
"subscribe_rulebooks": [rulebook ids],
|
||||
"design_system_id": <id> | null,
|
||||
"seed_systems": bool
|
||||
@@ -18,9 +17,14 @@ NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
|
||||
projects that existed before the step did (inherit-all / no design system /
|
||||
no seed), so the ask fires only for projects created after this shipped.
|
||||
|
||||
``exclude_always_on_rulebooks`` was a fourth choice until milestone 394. It
|
||||
let a project decline to inherit an always-on rulebook, and with no always-on
|
||||
tier there is nothing to decline — a rulebook now reaches a project by
|
||||
subscription, which is opt-IN, so declining is expressed by not subscribing.
|
||||
|
||||
The shape and its validator are pure; ``decide`` composes the existing
|
||||
services — always-on exclusions, subscriptions, set_project_design_system,
|
||||
the standard Systems seed — checks every target BEFORE touching anything,
|
||||
services — subscriptions, set_project_design_system, the standard Systems
|
||||
seed — checks every target BEFORE touching anything,
|
||||
applies the effects (each idempotent), and writes the record LAST, so a
|
||||
half-applied decision is re-runnable rather than recorded as done.
|
||||
``current_defaults`` is what the enter_project ask shows: what binds today
|
||||
@@ -37,7 +41,7 @@ from scribe.models.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
|
||||
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
||||
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||
CHOICE_KEYS = ("subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||
|
||||
|
||||
def _is_id_list(value) -> bool:
|
||||
@@ -60,15 +64,9 @@ def validate_inception(choices) -> str | None:
|
||||
unknown = sorted(set(choices) - set(CHOICE_KEYS))
|
||||
if unknown:
|
||||
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
|
||||
excl = choices.get("exclude_always_on_rulebooks") or []
|
||||
subs = choices.get("subscribe_rulebooks") or []
|
||||
if not _is_id_list(excl):
|
||||
return "exclude_always_on_rulebooks must be a list of rulebook ids"
|
||||
if not _is_id_list(subs):
|
||||
return "subscribe_rulebooks must be a list of rulebook ids"
|
||||
both = sorted(set(excl) & set(subs))
|
||||
if both:
|
||||
return f"rulebook(s) {both} cannot be both excluded and subscribed"
|
||||
ds = choices.get("design_system_id")
|
||||
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
|
||||
return "design_system_id must be a positive id or null"
|
||||
@@ -79,11 +77,10 @@ def validate_inception(choices) -> str | None:
|
||||
|
||||
|
||||
def normalize_choices(choices: dict | None) -> dict:
|
||||
"""The four keys, always present, in canonical form — what gets stored
|
||||
"""The three keys, always present, in canonical form — what gets stored
|
||||
and what the UI/agent reads back. Call after validate_inception."""
|
||||
choices = choices or {}
|
||||
return {
|
||||
"exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
|
||||
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
|
||||
"design_system_id": choices.get("design_system_id"),
|
||||
"seed_systems": bool(choices.get("seed_systems", False)),
|
||||
@@ -98,8 +95,7 @@ def is_decided(project) -> bool:
|
||||
async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||
"""What the project inherits if nobody decides — the ask's payload.
|
||||
|
||||
{always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
|
||||
excluded_always_on: [...], subscribed_rulebooks: [...],
|
||||
{rulebooks: [{id,title}], subscribed_rulebooks: [...],
|
||||
design_system_id, design_systems: [{id,title}], systems: <count>}.
|
||||
Instance-agnostic: an install with no rulebooks / design systems shows
|
||||
empty lists, and the ask says so rather than inventing a default.
|
||||
@@ -115,7 +111,7 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Rulebook.id, Rulebook.title, Rulebook.always_on)
|
||||
select(Rulebook.id, Rulebook.title)
|
||||
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
|
||||
.order_by(Rulebook.title)
|
||||
)
|
||||
@@ -124,9 +120,11 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||
designs = await design_systems_svc.list_design_systems(user_id)
|
||||
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
||||
return {
|
||||
"always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
|
||||
"other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
|
||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||
# ONE list since milestone 394. This was split into always-on and
|
||||
# "other" because the first bound the project whether it asked or not;
|
||||
# with the tier gone every rulebook is opt-in, so the split named a
|
||||
# difference that no longer exists.
|
||||
"rulebooks": [{"id": i, "title": t} for i, t in rows],
|
||||
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
|
||||
"design_system_id": project.design_system_id,
|
||||
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
||||
@@ -139,28 +137,22 @@ async def _check_targets(user_id: int, choices: dict) -> None:
|
||||
effect lands — a decision applies whole or errors whole."""
|
||||
from scribe.services import access
|
||||
|
||||
wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
|
||||
wanted = set(choices["subscribe_rulebooks"])
|
||||
if wanted:
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Rulebook.id, Rulebook.always_on).where(
|
||||
select(Rulebook.id).where(
|
||||
Rulebook.id.in_(wanted),
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
found = {rid: on for rid, on in rows}
|
||||
missing = sorted(wanted - set(found))
|
||||
found = {rid for (rid,) in rows}
|
||||
missing = sorted(wanted - found)
|
||||
if missing:
|
||||
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
|
||||
not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r])
|
||||
if not_always:
|
||||
raise ValueError(
|
||||
f"rulebook(s) {not_always} are not always-on — only always-on rulebooks "
|
||||
"can be excluded; a subscribed rulebook is simply not subscribed"
|
||||
)
|
||||
ds = choices["design_system_id"]
|
||||
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
||||
raise ValueError(f"design system {ds} not found (or not readable)")
|
||||
@@ -176,13 +168,12 @@ async def decide(
|
||||
"""Record a project's inception decision and apply it (milestone 297).
|
||||
|
||||
Owner-only. Validates the choices (pure) and every target (owned /
|
||||
readable) first; then, each idempotent: exclude the named always-on
|
||||
rulebooks, subscribe the named rulebooks, point the project at the design
|
||||
system (None = explicitly none), seed the standard Systems if asked and
|
||||
the project has none; then write ``projects.inception`` LAST. Re-deciding
|
||||
is additive for exclusions/subscriptions (nothing is silently dropped —
|
||||
include/unsubscribe are explicit calls), replaces the design system, and
|
||||
re-seeds nothing a project already has.
|
||||
readable) first; then, each idempotent: subscribe the named rulebooks,
|
||||
point the project at the design system (None = explicitly none), seed the
|
||||
standard Systems if asked and the project has none; then write
|
||||
``projects.inception`` LAST. Re-deciding is additive for subscriptions
|
||||
(nothing is silently dropped — unsubscribe is an explicit call), replaces
|
||||
the design system, and re-seeds nothing a project already has.
|
||||
|
||||
Returns {"inception": <record>, "effects": {excluded, subscribed,
|
||||
design_system_id, systems_seeded}}.
|
||||
@@ -203,8 +194,6 @@ async def decide(
|
||||
raise ValueError(f"project {project_id} not found (or not yours)")
|
||||
await _check_targets(user_id, choices)
|
||||
|
||||
for rb in choices["exclude_always_on_rulebooks"]:
|
||||
await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id)
|
||||
for rb in choices["subscribe_rulebooks"]:
|
||||
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
|
||||
if not await design_systems_svc.set_project_design_system(
|
||||
@@ -230,7 +219,6 @@ async def decide(
|
||||
return {
|
||||
"inception": record,
|
||||
"effects": {
|
||||
"excluded": choices["exclude_always_on_rulebooks"],
|
||||
"subscribed": choices["subscribe_rulebooks"],
|
||||
"design_system_id": choices["design_system_id"],
|
||||
"systems_seeded": [sy.name for sy in seeded],
|
||||
@@ -247,25 +235,24 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
|
||||
defaults = await current_defaults(user_id, project_id)
|
||||
except Exception:
|
||||
return {}
|
||||
always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
|
||||
others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
|
||||
books = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["rulebooks"]) or "none"
|
||||
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
|
||||
return {
|
||||
"defaults": defaults,
|
||||
"ask": (
|
||||
"This project has no inception decision: nobody has said what it "
|
||||
f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
|
||||
f"rulebooks it could subscribe to — {others}; design system — "
|
||||
f"inherits. Rulebooks it could subscribe to — {books}; design system — "
|
||||
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
||||
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
||||
"once: which always-on rulebooks to EXCLUDE here (default: none), which "
|
||||
"rulebooks to subscribe, which design system (or none), and whether to seed "
|
||||
"once: which rulebooks to subscribe (default: none — a rulebook binds "
|
||||
"a project only when it opts in), which design system (or none), and "
|
||||
"whether to seed "
|
||||
"the standard starter Systems — then record the answers. This ask repeats on "
|
||||
"every enter_project until a decision is recorded."
|
||||
),
|
||||
"call": (
|
||||
f"decide_project_inception(project_id={project_id}, "
|
||||
"exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], "
|
||||
"subscribe_rulebooks=[...], "
|
||||
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Design note — altitude: we inject rule *titles* grouped by topic (a compact
|
||||
index), NOT every rule's full statement. The 48 always-on statements run well
|
||||
past the 10k-char `additionalContext` cap, and the push channel's job is to make
|
||||
Claude *aware* the rules exist and *reach* for them — not to dump them. Full
|
||||
text stays one `get_rule(id)` / `list_always_on_rules()` call away. Titles are
|
||||
text stays one `get_rule(id)` / `search(content_type="rule")` call away. Titles are
|
||||
mostly self-describing ("`dev` is home", "No GitHub — Fabled-Git only"), so the
|
||||
index alone already steers behavior.
|
||||
"""
|
||||
@@ -18,15 +18,11 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.rulebook import RulebookTopic
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import knowledge as knowledge_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import shape_ledger as shape_ledger_svc
|
||||
from scribe.services import snippets as snippets_svc
|
||||
from scribe.services.access import label_shared_items, owner_names_for
|
||||
@@ -126,37 +122,145 @@ WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
||||
# arrive unread; lower it if rules you needed never arrived. What would RETIRE
|
||||
# it: a cross-encoder rerank (#1038), which would make a similarity bar the
|
||||
# wrong control entirely.
|
||||
# SCOPED TO THE WRITE-PATH ARM SINCE #3853. The command arm has its own bar
|
||||
# below, and the measurement that separated them is recorded there. Everything
|
||||
# above still holds for THIS arm: a code payload is long and rich, which is the
|
||||
# case 0.72 was calibrated on, and the telemetry says it is working — the
|
||||
# write-path rule arm speaks on 37% of its calls and its refused mass sits at
|
||||
# p50 0.6989, comfortably under the bar rather than piled against it.
|
||||
RULEHINT_THRESHOLD_KEY = "kb_rulehint_threshold"
|
||||
RULEHINT_DEFAULT_THRESHOLD = 0.72
|
||||
|
||||
# ONE rule per write, not two — and this is deliberately NOT a knob.
|
||||
# THE COMMAND ARM'S OWN BAR, AND WHY IT IS NOT THE WRITE PATH'S (#3853).
|
||||
#
|
||||
# With a corpus this small, top-k does as much damage as the threshold: k=2
|
||||
# over a few dozen candidates means the second line is almost always the
|
||||
# second-best noise, arriving with the same confident framing as the first.
|
||||
# Halving k halves that regardless of where the bar sits.
|
||||
# One bar served both act arms until this. They are not the same problem: a
|
||||
# write-path query is a code payload, long and rich, while a pre-tool query is
|
||||
# a shell command — often under a dozen words. Less text, less signal, lower
|
||||
# scores for the same relevance. At a shared 0.72 the two arms measured like
|
||||
# different subsystems:
|
||||
#
|
||||
# It also BOUNDS the blast radius of widening the pool (below): with k=1 a
|
||||
# wider corpus can change WHICH rule surfaces and how often one does, but it
|
||||
# can never make a single hint longer. The loudness of one hint and the
|
||||
# eligibility of a rule are separate controls, and only one of them moved.
|
||||
# write_path_rule 2,325 calls, speaks on 37%, near-miss p50 0.6989
|
||||
# pre_tool_rule 11,768 calls, speaks on 2%, near-miss p50 0.6794
|
||||
#
|
||||
# It stays a constant because it is a decision about how LOUD one hint may be,
|
||||
# not a per-install tuning question. The hint already carries prior art, shape
|
||||
# signals and staleness; rules are the fourth voice in it, and a fourth voice
|
||||
# that speaks twice is where a reader stops reading. Nothing suggests an
|
||||
# operator wants this different, and a knob nobody turns is a knob that only
|
||||
# adds a way to misconfigure the surface (rule 25 cuts both ways).
|
||||
RULEHINT_LIMIT = 1
|
||||
# The second is not a quiet surface, it is a mute one: 11,530 of 11,768 calls
|
||||
# said nothing, with near-miss p90 at 0.7097 — refused mass piled one
|
||||
# hundredth under the line, which is the shape a bar set too high leaves. The
|
||||
# note arms are the control and look nothing like it (auto_inject refuses at
|
||||
# p90 0.5463, write_path at 0.6738, both far below their bars).
|
||||
#
|
||||
# WHAT 0.68 IS MEASURED AGAINST. Eight replayed queries, consequential acts
|
||||
# against innocuous ones, scored on the post-#3855 corpus:
|
||||
#
|
||||
# 0.7571 git push origin dev consequential
|
||||
# 0.7245 cd ...; git fetch; git add -A consequential
|
||||
# 0.7193 git pull --rebase origin dev consequential
|
||||
# 0.6850 docker compose up -d consequential
|
||||
# ---------------------------------------- 0.68
|
||||
# 0.6735 wc -l src/*.py && date innocuous
|
||||
# 0.6544 grep -rn useState src/ innocuous
|
||||
# 0.6099 sed -n '120,160p' package.json innocuous
|
||||
# 0.6056 ls -la && cat README.md innocuous
|
||||
#
|
||||
# At 0.72 three of the four consequential acts retrieved NOTHING, including
|
||||
# `git pull --rebase origin dev`, where rules 153, 1 and 2 all ranked
|
||||
# correctly and all sat between 0.7126 and 0.7193.
|
||||
#
|
||||
# THE SEPARATION IS 0.0115 WIDE, and that is a caveat, not a result. Eight
|
||||
# probes set a direction; they do not settle a number. `near_miss_samples` on
|
||||
# a few days of post-#3855 traffic is what settles it, and this is the bar to
|
||||
# re-read first.
|
||||
#
|
||||
# This also CORRECTS an assumption stated above. That comment argued 0.68 was
|
||||
# "below where this corpus's noise sits", inferring a higher floor from the
|
||||
# corpus being homogeneous. Measured, the command arm's noise ceiling is
|
||||
# 0.6735 — so 0.68 clears it, barely, rather than sitting under it. The
|
||||
# inference was reasonable and the measurement disagrees.
|
||||
#
|
||||
# WHY LOWERING IS SAFER NOW THAN IT WOULD HAVE BEEN. Until #3851 this arm had
|
||||
# a single slot, so its one line had to be right and a high bar was the only
|
||||
# control. The band now does noise control downstream: a marginal hit that
|
||||
# clears the bar still has to score within `_RULEHINT_BAND` of the top to be
|
||||
# rendered. The bar's job shrank, so the bar can.
|
||||
#
|
||||
# The noise floor above is set by CROSS-PROJECT BLEED rather than bad ranking
|
||||
# — 0.6735 is another project's shell-command rule matching a shell command in
|
||||
# this one, which is a correct match to a rule that should never have been
|
||||
# eligible. Retrieval is ownership-scoped, not project-scoped. Scoping it
|
||||
# would drop that ceiling and widen the 0.0115, which is the larger fix and
|
||||
# the reason to settle project scoping before tuning this number twice.
|
||||
TOOLRULE_THRESHOLD_KEY = "kb_toolrule_threshold"
|
||||
TOOLRULE_DEFAULT_THRESHOLD = 0.68
|
||||
|
||||
# AND A REPEAT COMPETES FOR THAT ONE SLOT ON RANK ALONE (#3750).
|
||||
# A SET OF RULES PER ACT, NOT THE SINGLE BEST ONE (#3851).
|
||||
#
|
||||
# This was 1, and the reasoning for that is kept below rather than deleted
|
||||
# because it was correct for the world it was written in and the half of it
|
||||
# that still holds is what shapes the replacement.
|
||||
#
|
||||
# THE OLD ARGUMENT. "With a corpus this small, top-k does as much damage as
|
||||
# the threshold: k=2 over a few dozen candidates means the second line is
|
||||
# almost always the second-best noise, arriving with the same confident
|
||||
# framing as the first." True — and note the premise. Retrieval was then a
|
||||
# SUPPLEMENT to a 33-rule resident set, so the arm's job was to add one
|
||||
# salient rule beside everything the session already held. One was the right
|
||||
# number for an accent.
|
||||
#
|
||||
# WHAT CHANGED. Milestone 394 removes residency, and then this arm is not the
|
||||
# accent, it is the whole delivery. Moments genuinely need several rules at
|
||||
# once: `git push origin dev` is governed by 1 (`dev` is home), 2 (never
|
||||
# `main` unasked), 9 (poll CI) and 140 (let each action land) simultaneously,
|
||||
# and each alone permits the mistake the others catch. One slot cannot serve
|
||||
# that, and "the single best" is not a coherent answer when four rules bind.
|
||||
#
|
||||
# WHY A CAP PLUS A BAND, RATHER THAN A BIGGER CAP. The old argument's real
|
||||
# content is that a fixed k invents lines — it fills slots whether or not
|
||||
# anything deserves them. A band does not: it keeps what is close to the top
|
||||
# and nothing else, so a moment with one clearly-relevant rule still shows
|
||||
# one, and a moment with four shows four. The corpus decides, not a constant.
|
||||
# The cap survives as a ceiling on the worst case, not as the usual answer.
|
||||
RULEHINT_LIMIT = 5
|
||||
|
||||
# MEASURED, NOT REASONED — and the reasoning it replaced was wrong (#3851).
|
||||
#
|
||||
# The prediction was that rules would rank SHARPLY, because `rule_document()`
|
||||
# shapes them the way snippets are shaped — trigger in the embedded title and
|
||||
# again above the body — and note 2485 measured snippets separating their top
|
||||
# hit by 0.153 while every other kind managed 0.010–0.023.
|
||||
#
|
||||
# They do not. Three probes against real act queries, scored by the same
|
||||
# embedding the arms use:
|
||||
#
|
||||
# `git push origin dev` top 0.757, gap to second 0.022
|
||||
# `docker compose up -d` top 0.685, gap to second 0.016
|
||||
# a bare-owner-filter query top 0.656, gap to second 0.020
|
||||
#
|
||||
# That is dev-log territory, not snippet territory: rules arrive as a
|
||||
# tightly-packed block. Shaping alone did not buy separation, which is worth
|
||||
# recording because the opposite was the natural inference from 2485.
|
||||
#
|
||||
# So the band is narrow BECAUSE the corpus is flat. At 0.10 — the notes
|
||||
# menu's value — every one of the top eight on the push probe falls inside,
|
||||
# including a CI-registry rule and another project's branch policy. At 0.05
|
||||
# it admits roughly three ranks, which is the span where the scores are still
|
||||
# saying something. 2485's Finding 3 is the standing caveat: no band value
|
||||
# fixes a tie, and if rules ever rank as flat as dev-logs did this control
|
||||
# stops working and the answer is a reranker (#1038), not a smaller number.
|
||||
_RULEHINT_BAND = 0.05
|
||||
|
||||
# AND A REPEAT COMPETES ON RANK ALONE (#3750), WHICH SURVIVES THE BAND.
|
||||
#
|
||||
# Since #3750 a hit already on the session's exclusion ledger is RENDERED
|
||||
# rather than dropped, which raises a question the old behaviour never had to
|
||||
# answer: when the top-ranked hit is one the session has already seen, does it
|
||||
# take the slot, or step aside for a fresh rule behind it?
|
||||
# take its place, or step aside for a fresh rule behind it?
|
||||
#
|
||||
# It takes the slot, and nothing is fetched behind it. Two reasons.
|
||||
# It keeps its place, and nothing is promoted past it. #3851 widened the arm
|
||||
# from one slot to a banded set and did NOT reopen this: a repeat still ranks
|
||||
# where it ranks, and the band is applied to scores with no regard for what
|
||||
# the session has seen. The two are independent, exactly as `kind` and `seen`
|
||||
# are independent in the renderer — rank answers "what is relevant now" and
|
||||
# the ledger answers "have you been told", and neither is evidence about the
|
||||
# other. Two reasons, both unchanged by the widening.
|
||||
#
|
||||
# RANK IS THE ANSWER TO "WHAT IS RELEVANT NOW". If the repeat scores 0.85 and
|
||||
# the best fresh candidate 0.73, the repeat is the better match for the action
|
||||
@@ -166,13 +270,25 @@ RULEHINT_LIMIT = 1
|
||||
# match, and "you have seen this" is not the same claim as "you are holding
|
||||
# this".
|
||||
#
|
||||
# AND A SECOND LINE IS THE ONE THING THE LIMIT ABOVE FORBIDS. Letting a repeat
|
||||
# ride alongside a fresh rule means two hint lines, and the paragraph above is
|
||||
# entirely about why a fourth voice that speaks twice is where a reader stops
|
||||
# reading. A reference costs the same ~40 tokens as a first surfacing, so
|
||||
# "it is only a short extra line" is not available as an argument: the budget
|
||||
# is one line because of what a second line does to the whole hint, not
|
||||
# because of what it costs.
|
||||
# AND THE COST OF A SECOND LINE IS NOW PAID DIFFERENTLY, NOT WISHED AWAY.
|
||||
# This paragraph used to read "a second line is the one thing the limit above
|
||||
# forbids", on the strength of "a reference costs the same ~40 tokens as a
|
||||
# first surfacing". Both halves are now wrong and the second was already
|
||||
# wrong when written: a full line is ~143 tokens once the trigger is rendered,
|
||||
# and #3855's rewrite of the corpus roughly tripled trigger length, so five
|
||||
# full lines on a push probe measure ~646 tokens before EVERY Bash call.
|
||||
#
|
||||
# That number is why the widening pairs with a compact rendering rather than
|
||||
# arriving alone (see `_rule_hint_line`). The old paragraph's instinct — that
|
||||
# a fourth voice which speaks at full volume twice is where a reader stops
|
||||
# reading — is the half worth keeping, and it is answered by making the
|
||||
# later lines quieter rather than by refusing to have them. Top hit full,
|
||||
# the rest as references: ~299 tokens against ~568 for five full lines, so
|
||||
# roughly 2x the old single line for four more rules.
|
||||
#
|
||||
# The reference keeps its TAIL and loses only its trigger, which is both the
|
||||
# cheaper and the safer cut — see `_rule_hint_line`, where the first attempt
|
||||
# dropped the tail as well and #3750's guard caught it within one commit.
|
||||
#
|
||||
# The consequence is deliberate and worth naming: a rule that keeps ranking
|
||||
# first for a recurring situation keeps being referenced, every time the
|
||||
@@ -1115,17 +1231,71 @@ async def get_writepath_config(user_id: int) -> dict:
|
||||
rule_threshold = RULEHINT_DEFAULT_THRESHOLD
|
||||
rule_threshold = min(1.0, max(0.0, rule_threshold))
|
||||
|
||||
try:
|
||||
tool_rule_threshold = float(await get_setting(
|
||||
user_id, TOOLRULE_THRESHOLD_KEY, str(TOOLRULE_DEFAULT_THRESHOLD)))
|
||||
except (TypeError, ValueError):
|
||||
tool_rule_threshold = TOOLRULE_DEFAULT_THRESHOLD
|
||||
tool_rule_threshold = min(1.0, max(0.0, tool_rule_threshold))
|
||||
|
||||
return {
|
||||
**cfg,
|
||||
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
||||
"threshold": threshold,
|
||||
# Its own bar, for a third corpus — see RULEHINT_DEFAULT_THRESHOLD.
|
||||
"rule_threshold": rule_threshold,
|
||||
# And the COMMAND arm's own bar again, for the same reason one level
|
||||
# down: a shell command is a different query shape from a code payload
|
||||
# and scores lower for the same relevance (#3853). Separate keys, so an
|
||||
# install can move one without the other — which is the whole finding.
|
||||
"tool_rule_threshold": tool_rule_threshold,
|
||||
}
|
||||
|
||||
def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
|
||||
def _rule_band(hits: list) -> list:
|
||||
"""The top hit, plus every hit within `_RULEHINT_BAND` of it (#3851).
|
||||
|
||||
The instrument that lets an act surface a SET without inventing one: a
|
||||
fixed k fills its slots whether or not anything deserves them, while this
|
||||
keeps only what the scores say is close, so one clearly-relevant rule
|
||||
still shows one and four competing rules show four.
|
||||
|
||||
Sync and pure, and deliberately its own function rather than a comparison
|
||||
written twice — the two act arms are the pair #3497 records drifting apart
|
||||
by being modelled on each other instead of sharing.
|
||||
|
||||
Takes `(score, rule)` pairs already ordered best-first, as both
|
||||
`semantic_search_*` helpers return them.
|
||||
"""
|
||||
if not hits:
|
||||
return []
|
||||
top = hits[0][0]
|
||||
return [(s, r) for s, r in hits if s >= top - _RULEHINT_BAND]
|
||||
|
||||
|
||||
def _rule_hint_line(rule, *, where: str, seen: bool, compact: bool = False) -> str:
|
||||
"""One rule hint line — both arms, both tails, both kinds (#3750, #3849).
|
||||
|
||||
THREE INDEPENDENT AXES SINCE #3851. `compact` joins `kind` and `seen`, and
|
||||
like them it reads none of the others: it says how much ROOM this line
|
||||
gets, which is a fact about its rank among today's hits rather than about
|
||||
the rule. A compact line is still a full claim that the rule may apply —
|
||||
it simply cites the rule instead of quoting its trigger. Crucially it
|
||||
still carries the `seen` tail, so the three axes stay genuinely
|
||||
independent: shortening a line must not decide what it says about whether
|
||||
the session is holding the rule.
|
||||
|
||||
WHY THE LATER LINES ARE QUIETER. Measured at #3851: a full line runs ~143
|
||||
tokens once the trigger is rendered, and #3855 tripled trigger lengths
|
||||
across the corpus, so five full lines cost ~568 tokens before every Bash
|
||||
call. Top-full-plus-references costs ~299 — about 2x the old single line,
|
||||
for four more rules. The budget argument that once justified a single slot
|
||||
was real; what it actually forbids is four voices at full volume, not four
|
||||
voices.
|
||||
|
||||
The top hit keeps the full rendering because it is the one the ranker is
|
||||
most confident about, and a reader who acts on exactly one line should
|
||||
have acted on that one.
|
||||
|
||||
TWO INDEPENDENT AXES. `kind` decides the head, `seen` decides the tail,
|
||||
and neither reads the other. A preference and a rule differ in force; a
|
||||
repeat and a first surfacing differ in whether the session already holds
|
||||
@@ -1192,6 +1362,22 @@ def _rule_hint_line(rule, *, where: str, seen: bool) -> str:
|
||||
f"Read it with get_rule({rule.id}) {reason}; it is not in this "
|
||||
"session's loaded set."
|
||||
)
|
||||
if compact:
|
||||
# THE TRIGGER GOES; THE TAIL STAYS. Only one of the two is expensive \u2014
|
||||
# a trigger runs 300-400 characters after #3855, the tail about 100 \u2014
|
||||
# so dropping the trigger is nearly the whole saving and dropping the
|
||||
# tail would be mostly sacrifice.
|
||||
#
|
||||
# It would also destroy the one thing #3750 exists to say. The tail is
|
||||
# what tells a reader whether they were already told this and may no
|
||||
# longer be holding it, and that is the entire difference they can act
|
||||
# on; a reference with no tail reads as a first surfacing whether it is
|
||||
# one or not. This branch shipped without it for one commit and
|
||||
# test_a_rule_the_session_already_holds_is_referenced_not_re_offered
|
||||
# caught it, which is the guard working exactly as #3750 intended.
|
||||
return (
|
||||
f"Also \u2014 {noun.lower()} \u201c{rule.title}\u201d. {tail}"
|
||||
)
|
||||
return (
|
||||
f"{noun} that may apply {where} \u2014 \u201c{rule.title}\u201d"
|
||||
+ (f" ({trigger})" if trigger else "")
|
||||
@@ -1210,7 +1396,6 @@ async def build_write_path_hint(
|
||||
repo_key: str = "",
|
||||
exclude_derive: list[str] | None = None,
|
||||
exclude_rule_ids: list[int] | None = None,
|
||||
rules_etag: str = "",
|
||||
) -> dict:
|
||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||
|
||||
@@ -1496,60 +1681,22 @@ async def build_write_path_hint(
|
||||
staleness: list[str] = []
|
||||
# ── Have the rules moved under this session? (milestone 323) ───────
|
||||
#
|
||||
# THE CARRIER IS THE POINT. This hook already fires before a write — the
|
||||
# moment acting on a stale rule actually costs something — and the check
|
||||
# is one comparison against a marker the session already holds. No
|
||||
# payload, no extra round trip, and nothing said when nothing moved.
|
||||
# THE RULES-ETAG STALENESS ARM IS GONE (milestone 394).
|
||||
#
|
||||
# WHAT THIS CANNOT SEE, and a reader who finds an etag here will assume
|
||||
# otherwise:
|
||||
# It took a marker the session had been given at SessionStart, compared it
|
||||
# against the resident set as it stood now, and said which rules had moved
|
||||
# or fallen out of force. That was worth doing while a session held a
|
||||
# fixed set of rules from turn zero and could be holding a stale copy of
|
||||
# it hours later.
|
||||
#
|
||||
# what goes wrong | caught?
|
||||
# ---------------------------------------------------|--------
|
||||
# another session edits a rule mid-flight | yes
|
||||
# the session is misremembering a rule read hours ago | yes
|
||||
# compaction summarised the rules out of context | NO
|
||||
# Nothing is resident now. A rule is retrieved at the moment it applies,
|
||||
# so a session cannot be holding an out-of-date one — the next act that
|
||||
# needs it fetches it again. The staleness this arm reported was an
|
||||
# artifact of the delivery model rather than a fact about the corpus, and
|
||||
# it goes with the model.
|
||||
#
|
||||
# The third is the most common and this is blind to it: the etag was in
|
||||
# context too and went with the rules. The SessionStart nudge is that
|
||||
# case's only mechanism and must not be softened because this shipped.
|
||||
#
|
||||
# Fails open, like every other arm here: a staleness hint must never
|
||||
# break a write.
|
||||
if rules_etag:
|
||||
try:
|
||||
current = await rulebooks_svc.list_always_on_rules(
|
||||
user_id, project_id=project_id or 0,
|
||||
)
|
||||
if rulebooks_svc.rules_etag(current) != rules_etag:
|
||||
moved = rulebooks_svc.rules_moved_since(current, rules_etag)
|
||||
held = rulebooks_svc.etag_count(rules_etag)
|
||||
bits = []
|
||||
if moved:
|
||||
named = ", ".join(
|
||||
f"#{r.id} \u201c{r.title}\u201d" for r in moved[:3]
|
||||
)
|
||||
more = len(moved) - 3
|
||||
bits.append(
|
||||
f"{named}" + (f", and {more} more" if more > 0 else "")
|
||||
)
|
||||
# A DELETED rule moves no timestamp and leaves no row to name,
|
||||
# so the count is the only thing that can report the one change
|
||||
# that takes an instruction OUT of force.
|
||||
if held is not None and held != len(current):
|
||||
delta = len(current) - held
|
||||
bits.append(
|
||||
f"{abs(delta)} rule(s) {'added' if delta > 0 else 'no longer in force'}"
|
||||
)
|
||||
if bits:
|
||||
staleness.append(
|
||||
"Your loaded rules have changed since this session "
|
||||
"started — " + "; ".join(bits) + ". Re-read them with "
|
||||
"list_always_on_rules() before relying on the set you "
|
||||
"are holding."
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("write-path rules-etag arm failed", exc_info=True)
|
||||
# `staleness` survives as the list the arms below still append to.
|
||||
|
||||
|
||||
# The guard sits BELOW the staleness arm on purpose. A rules change is
|
||||
# unconditional news — it does not become less true because this
|
||||
@@ -1682,11 +1829,21 @@ async def build_write_path_hint(
|
||||
report=_rep_wpr,
|
||||
)
|
||||
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||
# EVERY hit gets a line; `already` only changes the tail (#3750).
|
||||
for _score, rule in hits:
|
||||
# BAND FIRST, dedup second, and the order is the whole point (#3851).
|
||||
# The band is a statement about the SCORES — what the ranker thinks is
|
||||
# close to the best match — so letting the ledger reorder it would let
|
||||
# "you were told this already" change what counts as relevant. Those
|
||||
# are the independent axes the renderer keeps apart.
|
||||
kept = _rule_band(hits)
|
||||
fresh = [(score, rule) for score, rule in kept if rule.id not in already]
|
||||
# EVERY kept hit gets a line; `already` only changes the tail (#3750),
|
||||
# and rank only changes how much room it gets (#3851).
|
||||
for idx, (_score, rule) in enumerate(kept):
|
||||
lines.append(
|
||||
_rule_hint_line(rule, where="here", seen=rule.id in already)
|
||||
_rule_hint_line(
|
||||
rule, where="here", seen=rule.id in already,
|
||||
compact=idx > 0,
|
||||
)
|
||||
)
|
||||
# `rule_ids` stays FRESH-ONLY, and that is the whole telemetry story of
|
||||
# this change (#3752). It is what the hook writes to the exclusion
|
||||
@@ -1733,10 +1890,15 @@ async def build_write_path_hint(
|
||||
best_available=_rep_wpr.get("best_available_score"),
|
||||
best_available_id=_rep_wpr.get("best_available_id"),
|
||||
searched=bool(_rep_wpr.get("searched", True)),
|
||||
# What the ranker found and this session had already been told.
|
||||
# Without it a zero row cannot say whether the bar was too high or
|
||||
# the reader was simply ahead of it — and only the first is a
|
||||
# reason to move the threshold.
|
||||
# FOUND BUT NOT SHOWN, which since #3851 has TWO causes: the
|
||||
# session had already been told (the ledger), or the score fell
|
||||
# outside `_RULEHINT_BAND` of the top hit. Both are counted here
|
||||
# because the question this answers is unchanged — a zero row must
|
||||
# be able to say whether the bar was too high or whether the arm
|
||||
# simply chose not to speak, and only the first is a reason to
|
||||
# move the threshold. Splitting the two causes needs its own
|
||||
# column and is worth doing only if the band turns out to be
|
||||
# dropping rules anyone wanted.
|
||||
suppressed=len(hits) - len(fresh),
|
||||
)
|
||||
if fresh:
|
||||
@@ -1818,13 +1980,16 @@ async def build_tool_rule_hint(
|
||||
_rep_ptr: dict = {}
|
||||
hits = await semantic_search_rules(
|
||||
user_id, query, limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["rule_threshold"],
|
||||
threshold=cfg["tool_rule_threshold"],
|
||||
report=_rep_ptr,
|
||||
)
|
||||
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
already = set(exclude_rule_ids or [])
|
||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||
# Band first, dedup second — see the sibling arm for why that order is
|
||||
# load-bearing rather than incidental.
|
||||
kept = _rule_band(hits)
|
||||
fresh = [(score, rule) for score, rule in kept if rule.id not in already]
|
||||
|
||||
# Logged BEFORE the early return, for the reason spelled out at length
|
||||
# on the write-path arm above: a call that found nothing is the only
|
||||
@@ -1836,28 +2001,33 @@ async def build_tool_rule_hint(
|
||||
# failure the arm was built to stop.
|
||||
record_retrieval(
|
||||
user_id=user_id, source="pre_tool_rule", query=query,
|
||||
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["tool_rule_threshold"], limit=RULEHINT_LIMIT,
|
||||
project_id=project_id,
|
||||
is_task=None, results=fresh, duration_ms=duration_ms,
|
||||
best_available=_rep_ptr.get("best_available_score"),
|
||||
best_available_id=_rep_ptr.get("best_available_id"),
|
||||
searched=bool(_rep_ptr.get("searched", True)),
|
||||
# See the sibling arm. It matters more here: this arm fires on every
|
||||
# Bash call, so a long session excludes its way to an all-zero row
|
||||
# and the threshold looks wrong when nothing about it is.
|
||||
# See the sibling arm, including why this now counts BOTH the
|
||||
# ledger and the band. It matters more here: this arm fires on
|
||||
# every Bash call, so a long session excludes its way to an
|
||||
# all-zero row and the threshold looks wrong when nothing about it
|
||||
# is.
|
||||
suppressed=len(hits) - len(fresh),
|
||||
)
|
||||
# `hits`, not `fresh` (#3750). A call whose only hit is a repeat still
|
||||
# `kept`, not `fresh` (#3750). A call whose only hit is a repeat still
|
||||
# has something to say — the arm just says it differently.
|
||||
if not hits:
|
||||
if not kept:
|
||||
return out
|
||||
|
||||
lines = [
|
||||
_rule_hint_line(
|
||||
rule, where=f"to this {tool_name} call",
|
||||
seen=rule.id in already,
|
||||
# Rank decides volume (#3851): the ranker's best guess gets the
|
||||
# trigger, the rest get cited.
|
||||
compact=idx > 0,
|
||||
)
|
||||
for _score, rule in hits
|
||||
for idx, (_score, rule) in enumerate(kept)
|
||||
]
|
||||
# FRESH-ONLY, for the reason given on the sibling arm: a reference is a
|
||||
# rendering decision, not a retrieval outcome, and counting it here
|
||||
@@ -1968,18 +2138,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(
|
||||
@@ -1995,67 +2153,37 @@ async def build_session_context(
|
||||
its normalized key — triggers a one-line "bind this repo" hint so
|
||||
the binding is self-healing.
|
||||
|
||||
Returns {"context": str, "rule_count": int, "project": dict | None,
|
||||
"rules_etag": str}. The etag is for the HOOK, not for the model — the
|
||||
hook stores it and hands it back on each write so the server can say
|
||||
whether these rules have moved since the session loaded them.
|
||||
Returns {"context": str, "project": dict | None}.
|
||||
|
||||
It carried `rule_count` and `rules_etag` until milestone 394, when the
|
||||
preload it described was removed. The etag let the hook hand a marker back
|
||||
on each write so the server could say whether the resident rules had
|
||||
moved; nothing is resident now, so nothing can have moved, and a rule is
|
||||
re-retrieved at the moment it applies rather than held and aged.
|
||||
`context` is markdown ready to drop into `additionalContext`; it is capped
|
||||
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
||||
through verbatim.
|
||||
"""
|
||||
# Inside a project, the always-on set is the project's: an inception
|
||||
# exclusion (milestone 297) takes a rulebook out of this block, and is
|
||||
# named below so the departure is visible rather than silent.
|
||||
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
|
||||
# AMBIENT source, and the one that matters most: this is the preload — the
|
||||
# block every session opens with, chosen by nobody, paid for every turn.
|
||||
#
|
||||
# It emitted nothing until 2026-09-03, which made the resident set's cost
|
||||
# certain and its usefulness unfalsifiable at the same time (#3473). Note
|
||||
# #3089 is the argument this measurement finally lets someone test: that a
|
||||
# rule arriving with thirty others, none of them relevant, is read as
|
||||
# preamble rather than as a claim — so presence is not surfacing, and a
|
||||
# tier-1 set can grow without anybody noticing it stopped working.
|
||||
#
|
||||
# Recorded even when the hook truncates the block below: the rules WERE
|
||||
# delivered, and counting only the untruncated ones would quietly shrink
|
||||
# the denominator exactly where the set is too big to read.
|
||||
record_rule_surfaced(
|
||||
user_id=user_id,
|
||||
rule_ids=[r.id for r in rules],
|
||||
source="session_start",
|
||||
)
|
||||
excluded = (
|
||||
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
|
||||
if project_id else []
|
||||
)
|
||||
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
|
||||
|
||||
lines: list[str] = [
|
||||
"# Scribe — standing session context (auto-injected by the Scribe plugin)",
|
||||
"",
|
||||
"You are working with Scribe, the operator's self-hosted second brain. "
|
||||
"The always-on rules below are BINDING this session. Titles only — full "
|
||||
"text via `list_always_on_rules()` or `get_rule(id)`.",
|
||||
"You are working with Scribe, the operator's self-hosted second brain.",
|
||||
"",
|
||||
"## Always-on rules (by topic)",
|
||||
"## You are not holding the operator's rules",
|
||||
"",
|
||||
"No rule has been loaded into this session, and that is deliberate. "
|
||||
"Rules arrive when something you are about to do makes one relevant — "
|
||||
"a command you are about to run, code you are writing, or what the "
|
||||
"operator just asked for. On most turns none will, and that is the "
|
||||
"surface working rather than failing.",
|
||||
"",
|
||||
"**\"No rule arrived\" means \"nothing matched\" — never \"there is no "
|
||||
"rule.\"** Before a consequential act, one that is hard to reverse or "
|
||||
"outward-facing, `search(content_type=\"rule\")` is how you ask. "
|
||||
"Retrieval runs on its own and is a convenience; asking is what you do "
|
||||
"when it matters and nothing has spoken.",
|
||||
]
|
||||
|
||||
# rules already arrive ordered by rulebook/topic/order, so grouping by
|
||||
# consecutive topic_id preserves the intended sequence.
|
||||
current_topic: int | None = object() # sentinel distinct from any id/None
|
||||
for r in rules:
|
||||
if r.topic_id != current_topic:
|
||||
current_topic = r.topic_id
|
||||
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
|
||||
lines.append(f"### {heading}")
|
||||
lines.append(f"- [{r.id}] {r.title}")
|
||||
if excluded:
|
||||
names = ", ".join(f"{e['title']} (#{e['id']})" for e in excluded)
|
||||
lines += [
|
||||
"",
|
||||
f"Excluded for this project by its inception decision (not binding here): {names}.",
|
||||
]
|
||||
|
||||
project_dict: dict | None = None
|
||||
if project_id:
|
||||
@@ -2123,14 +2251,10 @@ async def build_session_context(
|
||||
|
||||
context = "\n".join(line for line in lines if line is not None)
|
||||
if len(context) > _MAX_CHARS:
|
||||
context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated — call list_always_on_rules())"
|
||||
context = context[:_MAX_CHARS].rstrip() + \
|
||||
"\n\n…(truncated — ask with search(content_type=\"rule\"))"
|
||||
|
||||
return {
|
||||
"context": context,
|
||||
"rule_count": len(rules),
|
||||
"project": project_dict,
|
||||
# Computed from the rules THIS payload was built from, not re-queried:
|
||||
# the marker has to describe the set the session is actually holding,
|
||||
# and a second query could disagree with the first.
|
||||
"rules_etag": rulebooks_svc.rules_etag(rules),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -44,6 +45,84 @@ _pending: set[asyncio.Task] = set()
|
||||
_reported = False
|
||||
|
||||
|
||||
|
||||
# ── secrets never reach the query column (#3925) ───────────────────────
|
||||
#
|
||||
# `pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
|
||||
# against the code being written, so whatever was on the command line or in the
|
||||
# buffer is what gets logged. A command that exports a token therefore stored
|
||||
# the token — and worse than stored it: `near_miss_samples` is the readout the
|
||||
# threshold docs tell you to open before moving a bar, so the value came back
|
||||
# out into an agent's context on the next tuning pass. That is how this was
|
||||
# found.
|
||||
#
|
||||
# SCRUBBED ON WRITE, NOT ON READ. A read-side filter leaves the secret in the
|
||||
# table, where a backup, a debug query or a future readout still reaches it.
|
||||
# The value must never land.
|
||||
#
|
||||
# REDACTED VISIBLY, AND THIS IS THE PART THAT KEEPS THE READOUT HONEST. The
|
||||
# whole worth of a near-miss sample is reading the query that was actually
|
||||
# refused; a scrubber that silently deleted spans would turn the one instrument
|
||||
# for tuning a bar into unreadable stubs — the #2663 shape, where a surface
|
||||
# looks fine and has quietly stopped saying anything. A `[redacted:<kind>]`
|
||||
# marker keeps the sentence readable, keeps its shape and length roughly
|
||||
# intact for the ranker's reader, and says plainly that something was removed.
|
||||
#
|
||||
# DELIBERATELY CONSERVATIVE. These patterns match things that are secrets by
|
||||
# CONSTRUCTION — a vendor-prefixed credential, a value assigned to a
|
||||
# secret-named variable, an auth header, a PEM header. Anything cleverer
|
||||
# (entropy heuristics, long-opaque-string detection) starts eating real
|
||||
# queries, and a query is evidence. Missing an exotic secret costs one
|
||||
# redaction nobody made; eating a query costs the ability to tune the bar.
|
||||
_SECRET_PATTERNS: tuple[tuple[str, "re.Pattern[str]"], ...] = (
|
||||
# Vendor-prefixed credentials. The prefix IS the tell, so no entropy
|
||||
# guessing is needed — `fmcp_` is Scribe's own API key format.
|
||||
("token", re.compile(
|
||||
r"\b(?:fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|gitlab-ci-token:"
|
||||
r"|xox[abprs]-|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
|
||||
)),
|
||||
# A value handed to a secret-NAMED variable, in shell, env files, YAML,
|
||||
# JSON or a query string. The name is what identifies it, so the value can
|
||||
# be anything.
|
||||
("assigned", re.compile(
|
||||
r"(?i)\b([A-Za-z0-9_]*"
|
||||
# NO BARE "auth" HERE. It matched `--author=`, so a commit naming an
|
||||
# address redacted the address — evidence eaten for a word that only
|
||||
# LOOKS credential-shaped. `AUTH_TOKEN` is still caught, by `token`.
|
||||
r"(?:token|secret|password|passwd|api[_-]?key|access[_-]?key)"
|
||||
r"[A-Za-z0-9_]*)"
|
||||
r"(\s*[:=]\s*[\"']?)"
|
||||
r"([^\s\"'&]{8,})"
|
||||
)),
|
||||
("auth-header", re.compile(
|
||||
r"(?i)(authorization\s*:\s*(?:bearer|basic|token)\s+)(\S+)"
|
||||
)),
|
||||
("private-key", re.compile(
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def scrub_secrets(text: str | None) -> str | None:
|
||||
"""Redact credential-shaped spans from a query before it is stored.
|
||||
|
||||
Pure and synchronous, so it is unit-testable and safe to run inline on the
|
||||
write path. Returns the input unchanged when nothing matches, which is the
|
||||
overwhelmingly common case and the one the patterns are tuned to protect.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for kind, pattern in _SECRET_PATTERNS:
|
||||
if kind == "assigned":
|
||||
text = pattern.sub(
|
||||
lambda m: f"{m.group(1)}{m.group(2)}[redacted:{kind}]", text)
|
||||
elif kind == "auth-header":
|
||||
text = pattern.sub(lambda m: f"{m.group(1)}[redacted:{kind}]", text)
|
||||
else:
|
||||
text = pattern.sub(f"[redacted:{kind}]", text)
|
||||
return text
|
||||
|
||||
|
||||
def _build_payload(
|
||||
*,
|
||||
user_id: int | None,
|
||||
@@ -85,7 +164,10 @@ def _build_payload(
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"source": source,
|
||||
"query": query,
|
||||
# Scrubbed HERE rather than at each caller: this is the only path to
|
||||
# the column, and a per-caller scrub is three places for one of them
|
||||
# to be forgotten by whoever adds the fourth arm.
|
||||
"query": scrub_secrets(query),
|
||||
"threshold": threshold,
|
||||
"limit_n": limit,
|
||||
"project_id": project_id,
|
||||
@@ -852,7 +934,7 @@ async def retrieval_summary(
|
||||
# something else.
|
||||
#
|
||||
# `ambient` now carries the bulk deliveries — the SessionStart preload,
|
||||
# `list_always_on_rules`, and every `rules_payload` surface (#3473). Before
|
||||
# and every `rules_payload` surface (#3473). Before
|
||||
# they emitted, this block had no ambient key and said the absence was a
|
||||
# fact about the data. It was, and it was also the thing that made the
|
||||
# always-on set impossible to judge: the largest rule surface in the
|
||||
|
||||
@@ -46,7 +46,7 @@ AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
|
||||
because `enter_project` and the skill sync put records in front of the agent
|
||||
without choosing them, and counting those as surfacings makes recency read as
|
||||
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
|
||||
`list_always_on_rules`, and every `rules_payload` surface hand over the whole
|
||||
and every `rules_payload` surface hand over the whole
|
||||
applicable set at once, chosen by nobody.
|
||||
|
||||
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
|
||||
@@ -184,8 +184,8 @@ def record_rule_surfaced(
|
||||
def record_rule_pulled(*, user_id: int | None, rule_id: int, source: str) -> None:
|
||||
"""Fire-and-forget: record that a rule was opened in full.
|
||||
|
||||
A PULL is somebody choosing to open one record. `list_always_on_rules` and
|
||||
`enter_project` are NOT pulls — they are bulk resident loads that hand over
|
||||
A PULL is somebody choosing to open one record. `enter_project` is NOT a
|
||||
pull — they are bulk resident loads that hand over
|
||||
every applicable rule at once, and counting them would swamp the signal
|
||||
with the very ambient delivery the ratio exists to distinguish from.
|
||||
"""
|
||||
|
||||
@@ -36,7 +36,7 @@ from scribe.models.rule_version import RuleVersion
|
||||
# snapshots would bury the edits somebody is actually looking for.
|
||||
SNAPSHOT_FIELDS = (
|
||||
"title", "statement", "why", "how_to_apply", "when_to_apply",
|
||||
"tier", "kind", "verify_with", "expires_when",
|
||||
"kind", "verify_with", "expires_when",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
|
||||
@@ -82,7 +81,7 @@ async def update_rulebook(
|
||||
rb = result.scalar_one_or_none()
|
||||
if rb is None:
|
||||
return None
|
||||
allowed = {"title", "description", "always_on"}
|
||||
allowed = {"title", "description"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(rb, key, value)
|
||||
@@ -293,7 +292,6 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No
|
||||
# The vocabularies migration 0088's CHECK constraints enforce. Named here so
|
||||
# a caller can be corrected before the database refuses it (rule 36 keeps the
|
||||
# two in step; this keeps the error readable).
|
||||
TIERS = ("always_on", "conditional")
|
||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
# Migration 0098's CHECK. `rule` binds; `preference` is how the operator
|
||||
# wants work done — see the model comment for why both live on one table.
|
||||
@@ -311,21 +309,11 @@ NULLABLE_RULE_TEXT = (
|
||||
)
|
||||
|
||||
|
||||
def _valid_tier(tier: str) -> str:
|
||||
"""An unrecognised tier falls back to always_on — the SAFE direction.
|
||||
|
||||
Getting this wrong the other way would silently stop a rule binding, which
|
||||
is the one failure this whole milestone exists to prevent. A rule that
|
||||
preloads when it did not need to costs context; a rule that quietly stops
|
||||
preloading costs the behaviour it was written for.
|
||||
"""
|
||||
return tier if tier in TIERS else "always_on"
|
||||
|
||||
|
||||
def _valid_kind(kind: str) -> str:
|
||||
"""An unrecognised kind falls back to `rule` — the SAFE direction.
|
||||
|
||||
Same shape as _valid_tier and the same argument, pointed at force instead
|
||||
The unrecognised value falls back to the binding one — the SAFE
|
||||
direction, pointed at force instead
|
||||
of delivery. A preference wrongly treated as binding costs a little
|
||||
friction: the reader is told something is required that was only
|
||||
preferred. A rule wrongly treated as a preference costs the thing the rule
|
||||
@@ -369,7 +357,6 @@ def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"topic_id": rule.topic_id,
|
||||
"tier": rule.tier,
|
||||
# Unconditional, and the payload cost is accepted deliberately. Every
|
||||
# other optional key below is attached only when present, because an
|
||||
# absent key should never read as a capability the record lacks. Force
|
||||
@@ -503,7 +490,7 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N
|
||||
async def create_rule(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
when_to_apply: str = "", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||
) -> Rule:
|
||||
async with async_session() as session:
|
||||
@@ -513,7 +500,6 @@ async def create_rule(
|
||||
title=title,
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
kind=_valid_kind(kind),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
@@ -532,7 +518,7 @@ async def create_rule(
|
||||
async def create_project_rule(
|
||||
project_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
when_to_apply: str = "", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||
) -> Rule:
|
||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||
@@ -548,7 +534,6 @@ async def create_project_rule(
|
||||
title=title,
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
kind=_valid_kind(kind),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
@@ -632,89 +617,6 @@ async def list_rules(
|
||||
return rulebook_rules + list(proj_result.scalars().all())
|
||||
|
||||
|
||||
def _excluded_rulebook_ids_q(project_id: int):
|
||||
"""Subquery: the always-on rulebooks this project opted out of at
|
||||
inception (milestone 297) — used by every rule-resolution path so an
|
||||
exclusion is total, not just cosmetic."""
|
||||
from scribe.models.rulebook import project_rulebook_exclusions
|
||||
|
||||
return select(project_rulebook_exclusions.c.rulebook_id).where(
|
||||
project_rulebook_exclusions.c.project_id == project_id
|
||||
)
|
||||
|
||||
|
||||
async def excluded_always_on_rulebooks(user_id: int, project_id: int) -> list[dict]:
|
||||
"""[{id, title}] of the always-on rulebooks excluded for ``project_id``
|
||||
(owner-scoped). Empty for an undecided or inherit-all project."""
|
||||
from scribe.models.rulebook import project_rulebook_exclusions
|
||||
|
||||
if not project_id:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Rulebook.id, Rulebook.title)
|
||||
.join(project_rulebook_exclusions,
|
||||
project_rulebook_exclusions.c.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
project_rulebook_exclusions.c.project_id == project_id,
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Rulebook.title)
|
||||
)
|
||||
).all()
|
||||
return [{"id": rid, "title": title} for rid, title in rows]
|
||||
|
||||
|
||||
async def list_always_on_rules(
|
||||
user_id: int, limit: int = 100, project_id: int = 0,
|
||||
) -> list[Rule]:
|
||||
"""Return all rules from rulebooks flagged always_on for the user.
|
||||
|
||||
Called by the MCP tool of the same name at session start to load the
|
||||
standing rules that apply regardless of which project (if any) is in
|
||||
scope. Ordering matches list_rules so results are stable across calls.
|
||||
|
||||
``project_id`` (milestone 297): inside a project that excluded specific
|
||||
always-on rulebooks at inception, those rulebooks' rules are NOT
|
||||
returned — the project decided not to inherit them. 0 = the user-wide
|
||||
set, which is what a session sees before a project is in scope.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Rule)
|
||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.always_on.is_(True),
|
||||
Rule.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
Rulebook.deleted_at.is_(None),
|
||||
# TIER (milestone 307). This is the SESSION-START call, made
|
||||
# before any project is in scope — there is no area vocabulary
|
||||
# to match a conditional rule against yet, so only the
|
||||
# unconditional tier belongs here. A conditional rule reaches a
|
||||
# session through enter_project (by area) or search (by
|
||||
# meaning), not by being resident.
|
||||
#
|
||||
# Behaviour is unchanged until rules are actually re-tiered:
|
||||
# `tier` defaults to always_on, so every existing rule still
|
||||
# arrives exactly as it did.
|
||||
Rule.tier == "always_on",
|
||||
)
|
||||
)
|
||||
if project_id:
|
||||
q = q.where(Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)))
|
||||
result = await session.execute(
|
||||
q.order_by(
|
||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||
).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
|
||||
"""Fetch a rule by id, scoped to user owning either its rulebook
|
||||
(via topic) or its project (via project_id). Honors soft-delete.
|
||||
@@ -780,7 +682,7 @@ async def update_rule(
|
||||
return None
|
||||
allowed = {
|
||||
"title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "kind", "arose_from_id",
|
||||
"when_to_apply", "kind", "arose_from_id",
|
||||
"verify_with", "expires_when",
|
||||
}
|
||||
check_before = rule.verify_with
|
||||
@@ -796,9 +698,7 @@ async def update_rule(
|
||||
for key, value in fields.items():
|
||||
if key not in allowed or value is None:
|
||||
continue
|
||||
if key == "tier":
|
||||
value = _valid_tier(value)
|
||||
elif key == "kind":
|
||||
if key == "kind":
|
||||
value = _valid_kind(value)
|
||||
elif key in NULLABLE_RULE_TEXT:
|
||||
value = value or None
|
||||
@@ -808,9 +708,8 @@ async def update_rule(
|
||||
# A verification stamp certifies A CHECK, not a rule. Rewrite or
|
||||
# remove the check and the old stamp certifies something that no
|
||||
# longer exists — so it is dropped, and the rule re-enters the sweep.
|
||||
# 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 vouched for costs the thing the sweep exists to catch.
|
||||
# The safe direction: a rule wrongly listed as due costs one look, a
|
||||
# rule wrongly vouched for costs the thing the sweep exists to catch.
|
||||
if rule.verify_with != check_before:
|
||||
rule.verified_at = None
|
||||
# Same session as the edit, so the two commit together. The snapshot
|
||||
@@ -1033,9 +932,6 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
|
||||
|
||||
# ── Subscriptions + get_applicable_rules ───────────────────────────────
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
async def subscribe_project(
|
||||
project_id: int, rulebook_id: int, user_id: int,
|
||||
) -> None:
|
||||
@@ -1113,51 +1009,6 @@ async def unsuppress_rule_for_project(
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def exclude_always_on_rulebook_for_project(
|
||||
project_id: int, rulebook_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Opt one project out of a whole ALWAYS-ON rulebook (milestone 297).
|
||||
Owner-only on both sides; the rulebook must be always_on — a subscribed
|
||||
rulebook is left by unsubscribing, not excluding. Idempotent."""
|
||||
from scribe.models.rulebook import project_rulebook_exclusions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||
rb = await session.get(Rulebook, rulebook_id)
|
||||
if rb is None or not rb.always_on:
|
||||
raise ValueError(
|
||||
f"rulebook {rulebook_id} is not always-on — it binds only by "
|
||||
"subscription; unsubscribe_project_from_rulebook instead"
|
||||
)
|
||||
try:
|
||||
await session.execute(
|
||||
insert(project_rulebook_exclusions).values(
|
||||
project_id=project_id, rulebook_id=rulebook_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback() # already excluded — idempotent
|
||||
|
||||
|
||||
async def include_always_on_rulebook_for_project(
|
||||
project_id: int, rulebook_id: int, user_id: int,
|
||||
) -> None:
|
||||
"""Undo exclude_always_on_rulebook_for_project. Idempotent."""
|
||||
from scribe.models.rulebook import project_rulebook_exclusions
|
||||
|
||||
async with async_session() as session:
|
||||
await _assert_project_owned(session, project_id, user_id)
|
||||
await session.execute(
|
||||
sql_delete(project_rulebook_exclusions).where(
|
||||
project_rulebook_exclusions.c.project_id == project_id,
|
||||
project_rulebook_exclusions.c.rulebook_id == rulebook_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def suppress_topic_for_project(
|
||||
project_id: int, topic_id: int, user_id: int,
|
||||
) -> None:
|
||||
@@ -1195,6 +1046,17 @@ async def unsuppress_topic_for_project(
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _tagged_rule_ids():
|
||||
"""Rules carrying at least one canonical area tag (milestone 394).
|
||||
|
||||
The complement is what matters: a rule NOT in this set was never narrowed
|
||||
by its author, so it is general to its rulebook and applies wherever that
|
||||
rulebook is subscribed. Expressed as a subquery rather than a fetched list
|
||||
so the area test stays inside the one statement `limit` is counted on.
|
||||
"""
|
||||
return select(rule_systems.c.rule_id)
|
||||
|
||||
|
||||
async def get_applicable_rules(
|
||||
project_id: int, user_id: int, limit: int = 50,
|
||||
) -> dict:
|
||||
@@ -1326,7 +1188,6 @@ async def get_applicable_rules(
|
||||
Rulebook.deleted_at.is_(None),
|
||||
# An inception exclusion is total (milestone 297): a rulebook the
|
||||
# project opted out of contributes nothing, subscribed or not.
|
||||
Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
|
||||
)
|
||||
.order_by(
|
||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||
@@ -1337,11 +1198,10 @@ async def get_applicable_rules(
|
||||
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
||||
if suppressed_topic_ids:
|
||||
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
||||
# TIER (milestone 307). always_on rules are resident, as every rule was
|
||||
# before tiers existed. A conditional rule is REACHABLE, and reaches
|
||||
# this project only when it is tagged to an area this project actually
|
||||
# works in — a deterministic tag match, never a similarity score, so
|
||||
# bindingness never depends on a ranking (D7).
|
||||
# AREA BINDING (milestone 307, narrowed by 394). A rule reaches this
|
||||
# project when it is tagged to an area the project actually works in —
|
||||
# a deterministic tag match, never a similarity score, so bindingness
|
||||
# never depends on a ranking (D7).
|
||||
#
|
||||
# Applied in SQL rather than by filtering afterwards, so `limit` counts
|
||||
# the rules that will actually be surfaced instead of counting rules
|
||||
@@ -1357,10 +1217,32 @@ async def get_applicable_rules(
|
||||
reachable = select(rule_systems.c.rule_id).where(
|
||||
rule_systems.c.canonical_id.in_(project_area_ids)
|
||||
) if project_area_ids else None
|
||||
tier_clause = (Rule.tier == "always_on")
|
||||
# SUBSCRIPTION IS THE SCOPE; AREAS NARROW ONLY WHERE AN AUTHOR ASKED.
|
||||
#
|
||||
# This read `always_on OR reachable` (milestone 307). The tier arm is
|
||||
# gone, and the first attempt at 394 kept only the reachable arm — so
|
||||
# a subscribed rulebook's untagged rules stopped arriving at all. That
|
||||
# was wrong twice over: the query above is ALREADY scoped to rulebooks
|
||||
# this project subscribed to, so the project opted in and was then
|
||||
# handed a subset of what it asked for; and the milestone is explicit
|
||||
# that subscription-derived rules are not what it removes. The
|
||||
# integration suite caught it through a co_surfaces partner that never
|
||||
# arrived because the rule it travels with had been filtered out.
|
||||
#
|
||||
# So: every rule in a subscribed rulebook applies, EXCEPT that a rule
|
||||
# tagged to specific areas applies only to a project working in one of
|
||||
# them. An untagged rule is general to its rulebook by construction —
|
||||
# nobody narrowed it — while tagging is an author saying "this is
|
||||
# about CI" and meaning it. That keeps D7's deterministic narrowing
|
||||
# where it was asked for without inventing it where it was not.
|
||||
if reachable is not None:
|
||||
tier_clause = or_(tier_clause, Rule.id.in_(reachable))
|
||||
rules_q = rules_q.where(tier_clause)
|
||||
rules_q = rules_q.where(
|
||||
or_(Rule.id.in_(reachable), Rule.id.notin_(_tagged_rule_ids())),
|
||||
)
|
||||
else:
|
||||
# No canonical areas on this project: nothing can match by area,
|
||||
# so only the untagged (general) rules apply.
|
||||
rules_q = rules_q.where(Rule.id.notin_(_tagged_rule_ids()))
|
||||
rule_rows = (await session.execute(rules_q)).all()
|
||||
truncated = len(rule_rows) > limit
|
||||
rules = [
|
||||
@@ -1381,12 +1263,11 @@ async def get_applicable_rules(
|
||||
)
|
||||
.order_by(Rule.order_index, Rule.title)
|
||||
)
|
||||
if reachable is not None:
|
||||
proj_rules_q = proj_rules_q.where(
|
||||
or_(Rule.tier == "always_on", Rule.id.in_(reachable))
|
||||
)
|
||||
else:
|
||||
proj_rules_q = proj_rules_q.where(Rule.tier == "always_on")
|
||||
# A PROJECT'S OWN RULES ARE NOT FILTERED BY AREA, and the asymmetry
|
||||
# with the family query above is the point. A family rule has to earn
|
||||
# its way into this project; a rule written ON this project is scoped
|
||||
# to it by construction, and filtering it again would drop rules whose
|
||||
# only fault is that nobody tagged them to a System.
|
||||
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
||||
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
|
||||
|
||||
@@ -1422,7 +1303,6 @@ async def get_applicable_rules(
|
||||
"suppressed_topics": suppressed_topics,
|
||||
"truncated": truncated,
|
||||
"subscribed_rulebooks": subscribed_rulebooks,
|
||||
"excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -1434,9 +1314,6 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
||||
same seven keys under the same names — so a reader learns them once. One
|
||||
place renames `rules` → `applicable_rules` and `truncated` →
|
||||
`applicable_rules_truncated`; the tools merge this into their payloads.
|
||||
`excluded_always_on` (milestone 297) names the always-on rulebooks this
|
||||
project decided NOT to inherit, so the departure is visible wherever the
|
||||
rules are.
|
||||
|
||||
IT ALSO RECORDS THE SURFACING, which is why it now takes a caller and a
|
||||
source. Every one of those surfaces is a bulk delivery — the applicable set
|
||||
@@ -1453,7 +1330,7 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
||||
Emitting from here is safe in a way emitting from `get_applicable_rules`
|
||||
would not be: this function is only ever called to BUILD A REPLY. The two
|
||||
other callers of the rules machinery — the write-path etag arm
|
||||
(`plugin_context`) and `rules_etag_for` — compute a marker and show nobody
|
||||
(`plugin_context`) — computed a marker and showed nobody
|
||||
anything, and counting those would put rules in the denominator that no
|
||||
agent ever saw.
|
||||
"""
|
||||
@@ -1472,7 +1349,6 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||
}
|
||||
|
||||
|
||||
@@ -1497,86 +1373,11 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
||||
_ETAG_EMPTY = "empty|0"
|
||||
|
||||
|
||||
def rules_etag(rules: list) -> str:
|
||||
"""A marker for "is the set you are holding still the current one?".
|
||||
|
||||
`max(updated_at)` alone is not enough: DELETING a rule moves no timestamp,
|
||||
and that is the single change that takes an instruction OUT of force —
|
||||
the one a session most needs to hear about. The count catches it.
|
||||
|
||||
Instance-agnostic (rule 115): it knows nothing about any particular
|
||||
rulebook, and an install with one rule or none produces a stable marker
|
||||
rather than an error. "No rules" must read as a state, not as a change,
|
||||
or every session on a fresh install would be told its rules had moved.
|
||||
"""
|
||||
if not rules:
|
||||
return _ETAG_EMPTY
|
||||
# A decoration must not be able to break what it decorates. This is
|
||||
# computed on the SessionStart path, where raising would cost the whole
|
||||
# context payload to save a hint — so a row with no usable timestamp is
|
||||
# skipped rather than compared, and a set with none degrades to a
|
||||
# count-only marker instead of failing. Count-only still catches a rule
|
||||
# added or deleted; it just cannot see an edit, which is the right way
|
||||
# round to lose information.
|
||||
stamps = [
|
||||
r.updated_at for r in rules
|
||||
if isinstance(getattr(r, "updated_at", None), datetime)
|
||||
]
|
||||
if not stamps:
|
||||
return f"unknown|{len(rules)}"
|
||||
return f"{max(stamps).isoformat()}|{len(rules)}"
|
||||
|
||||
|
||||
async def rules_etag_for(user_id: int, project_id: int = 0) -> str:
|
||||
"""The current marker for the set a session at this scope would hold.
|
||||
|
||||
Deliberately built from `list_always_on_rules` rather than from a
|
||||
`max()/count()` aggregate. An aggregate would be cheaper, and would have
|
||||
to restate that function's definition of the set — the always_on flag,
|
||||
the project's inception exclusions, the tier filter. Two definitions of
|
||||
"the session's rules" is how the marker starts disagreeing with the
|
||||
rules, which is worse than materialising a few dozen rows.
|
||||
"""
|
||||
rules = await list_always_on_rules(user_id, project_id=project_id)
|
||||
return rules_etag(rules)
|
||||
|
||||
|
||||
def rules_moved_since(rules: list, held_etag: str) -> list:
|
||||
"""The rules whose text changed after `held_etag` was issued.
|
||||
|
||||
Returns [] when the marker matches, is unparseable, or is absent — a
|
||||
caller cannot act on "something is different but I cannot say what", and
|
||||
a garbled marker must not be reported as a change.
|
||||
|
||||
A count difference is real news that this list cannot show: a rule
|
||||
DELETED since the marker was issued has no row left to return. Callers
|
||||
compare counts separately.
|
||||
"""
|
||||
if not held_etag or held_etag == _ETAG_EMPTY:
|
||||
return []
|
||||
stamp, _, _count = held_etag.partition("|")
|
||||
try:
|
||||
held_at = datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
return []
|
||||
return [r for r in rules if r.updated_at and r.updated_at > held_at]
|
||||
|
||||
|
||||
def etag_count(held_etag: str) -> int | None:
|
||||
"""How many rules the holder had. None when the marker cannot be read."""
|
||||
_stamp, _, count = (held_etag or "").partition("|")
|
||||
try:
|
||||
return int(count)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
user_id: int,
|
||||
older_than_days: int = 0,
|
||||
tier: str = "",
|
||||
never_only: bool = False,
|
||||
) -> list[Rule]:
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
@@ -1605,20 +1406,12 @@ async def rules_due_for_verification(
|
||||
older_than_days: only rules last verified longer ago than this.
|
||||
Never-checked rules always qualify — they are the most overdue
|
||||
thing there is. 0 = no age filter.
|
||||
tier: "always_on" or "conditional" to narrow. Raises on anything else
|
||||
rather than falling back: _valid_tier's silent always_on default
|
||||
is right for a WRITE (the safe direction is to keep binding), and
|
||||
wrong for a FILTER, where it would quietly answer a different
|
||||
question than the one asked.
|
||||
never_only: only rules that have never been verified.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.models.project import Project
|
||||
|
||||
if tier and tier not in TIERS:
|
||||
raise ValueError(f"tier must be one of {TIERS}, got {tier!r}")
|
||||
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Rule)
|
||||
@@ -1641,8 +1434,6 @@ async def rules_due_for_verification(
|
||||
),
|
||||
)
|
||||
)
|
||||
if tier:
|
||||
stmt = stmt.where(Rule.tier == tier)
|
||||
if never_only:
|
||||
stmt = stmt.where(Rule.verified_at.is_(None))
|
||||
elif older_than_days > 0:
|
||||
@@ -1668,7 +1459,6 @@ def verification_row(rule: Rule) -> dict:
|
||||
"id": rule.id,
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"tier": rule.tier,
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
|
||||
+1
-1
@@ -225,7 +225,7 @@ def fake_rule(**attrs) -> MagicMock:
|
||||
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||
# 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.
|
||||
# `rule_brief` and `to_dict` both emit `kind or "rule"`, and a
|
||||
# MagicMock is truthy — so an unnamed `kind` would put a MagicMock
|
||||
|
||||
+17
-18
@@ -1,12 +1,14 @@
|
||||
"""Project inception (milestone 297) — step 1: the record's shape.
|
||||
|
||||
The WHY a project inherits what it does lives on projects.inception; the
|
||||
opt-out of an always-on rulebook is its own association table. Pure
|
||||
The WHY a project inherits what it does lives on projects.inception. Pure
|
||||
validation is pinned here; the effects are step 3's integration tests.
|
||||
|
||||
The opt-out of an always-on rulebook had its own association table until
|
||||
milestone 394. With no always-on tier there is nothing to opt out OF — a
|
||||
rulebook binds a project only by subscription — so the table and the test
|
||||
that pinned its shape both went with it.
|
||||
"""
|
||||
from scribe.models import Base
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.rulebook import project_rulebook_exclusions
|
||||
from scribe.services.inception import (
|
||||
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
|
||||
)
|
||||
@@ -23,37 +25,34 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
|
||||
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
|
||||
|
||||
|
||||
def test_exclusions_table_is_the_suppressions_sibling():
|
||||
t = Base.metadata.tables["project_rulebook_exclusions"]
|
||||
assert project_rulebook_exclusions is t
|
||||
assert {c.name for c in t.primary_key.columns} == {"project_id", "rulebook_id"}
|
||||
fks = {fk.column.table.name: fk.ondelete for c in t.columns for fk in c.foreign_keys}
|
||||
assert fks == {"projects": "CASCADE", "rulebooks": "CASCADE"}
|
||||
|
||||
|
||||
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({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
||||
assert validate_inception({"subscribe_rulebooks": [2],
|
||||
"design_system_id": 3, "seed_systems": True}) is None
|
||||
assert validate_inception({"design_system_id": None}) is None
|
||||
assert "must be an object" in validate_inception([])
|
||||
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": [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": True})
|
||||
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||
|
||||
|
||||
def test_normalize_choices_is_canonical_and_complete():
|
||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
|
||||
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
||||
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
|
||||
assert out == {"subscribe_rulebooks": [1, 3],
|
||||
"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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
commit, push and other hard-to-reverse actions.
|
||||
|
||||
The asymmetry is the whole argument, and it is what these tests pin: pulling
|
||||
when a push also arrived costs one redundant call; not pulling when the push
|
||||
never came costs the operator's rules entirely.
|
||||
The asymmetry is the whole argument, and it is what these tests pin: asking
|
||||
when a rule had already arrived costs one redundant call; not asking when
|
||||
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
|
||||
|
||||
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
|
||||
without also stating the pull.
|
||||
without also stating how to ask.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,8 +41,33 @@ import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
# The pull instruction, however a surface phrases the surrounding prose.
|
||||
PULL = "list_always_on_rules"
|
||||
# THE PULL IS NOW THE ASK (milestone 394). This was `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
|
||||
# "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
|
||||
|
||||
|
||||
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 = []
|
||||
for path in SESSION_START_SURFACES:
|
||||
assert path.exists(), (
|
||||
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"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)))
|
||||
assert not missing, (
|
||||
f"these surfaces no longer tell the agent to call {PULL}(): {missing}. "
|
||||
f"The rules are pull-only and the push is best-effort, so a surface "
|
||||
f"that omits this leaves a session bound by nothing (#2198, #2497)."
|
||||
f"these surfaces never tell the agent how to ask for a rule "
|
||||
f"({ASK}): {missing}. Nothing is pushed and nothing is resident, so a "
|
||||
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
|
||||
# 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():
|
||||
def test_no_surface_names_the_push_without_stating_the_ask():
|
||||
"""The exact shape #2497 took.
|
||||
|
||||
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 = [
|
||||
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, (
|
||||
f"these surfaces describe the SessionStart push but never state the "
|
||||
f"explicit pull: {offenders}. The push is a delivery optimisation, not "
|
||||
f"the bridge — it can be absent without saying so. Name it if it helps, "
|
||||
f"but say to call {PULL}() regardless."
|
||||
f"these surfaces describe the SessionStart push but never state how to "
|
||||
f"ask: {offenders}. The push is a delivery optimisation, not the "
|
||||
f"bridge — it can be absent without saying so, and since milestone 394 "
|
||||
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.",
|
||||
why="the image ships no bash",
|
||||
verify_with="read the workflow's shell setting",
|
||||
tier="always_on",
|
||||
),
|
||||
# The actor is already gone — what SET NULL leaves behind.
|
||||
RuleVersion(
|
||||
rule_id=rule.id, user_id=None,
|
||||
title="The runner has no bash",
|
||||
statement="Use POSIX sh in run steps.",
|
||||
tier="always_on",
|
||||
),
|
||||
])
|
||||
await s.commit()
|
||||
@@ -263,4 +261,3 @@ async def test_the_text_survives(restored):
|
||||
assert by_statement["Use sh."].verify_with == (
|
||||
"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.project import Project
|
||||
from scribe.models.rulebook import Rulebook
|
||||
from scribe.services import inception as inception_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import canonical_systems as canonical_svc
|
||||
@@ -31,12 +30,12 @@ async def seeded():
|
||||
await s.flush()
|
||||
ids = {"owner": owner.id, "pid": project.id}
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
@@ -48,20 +47,20 @@ async def seeded():
|
||||
@pytest.mark.integration
|
||||
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
||||
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||
# Undecided: nothing binds, because nothing has been subscribed. Before
|
||||
# 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)
|
||||
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
||||
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
||||
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
|
||||
[seeded["always"], seeded["other"]])
|
||||
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={
|
||||
"exclude_always_on_rulebooks": [seeded["always"]],
|
||||
"subscribe_rulebooks": [seeded["other"]],
|
||||
"design_system_id": None,
|
||||
"seed_systems": True,
|
||||
})
|
||||
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||
catalog = await canonical_svc.list_canonical_systems()
|
||||
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)
|
||||
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
|
||||
# departure is named, the subscription binds.
|
||||
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
||||
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
||||
# The subscription is what binds, and it is the ONLY thing that does —
|
||||
# the unsubscribed rulebook contributes nothing even though it used to
|
||||
# bind every project by default.
|
||||
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 "dev is home" not in [r["title"] for r in applicable["rules"]]
|
||||
# The record, written last, says why.
|
||||
async with async_session() as s:
|
||||
project = await s.get(Project, pid)
|
||||
assert inception_svc.is_decided(project)
|
||||
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
||||
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
|
||||
# Re-deciding with seed again mints nothing twice.
|
||||
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||
assert again["effects"]["systems_seeded"] == []
|
||||
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
|
||||
async def test_a_bad_decision_applies_nothing(seeded):
|
||||
owner, pid = seeded["owner"], seeded["pid"]
|
||||
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
||||
with pytest.raises(ValueError, match="not always-on"):
|
||||
# A subscription to a rulebook that is not yours is refused BEFORE any
|
||||
# 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={
|
||||
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
||||
"subscribe_rulebooks": [999999], "seed_systems": True,
|
||||
})
|
||||
assert await systems_svc.list_systems(owner, pid) == []
|
||||
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
|
||||
behaves exactly as it did before tiers existed. That is the one failure this
|
||||
whole design must not produce, and it is asserted first.
|
||||
2. A conditional rule is invisible to a project that doesn't work in its area,
|
||||
and arrives — binding, not suggested — to one that does.
|
||||
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||
1. A rule is invisible to a project that doesn't work in its area, and
|
||||
arrives — binding, not suggested — to one that does. Area matching is
|
||||
DETERMINISTIC: a tag comparison, never a similarity score.
|
||||
2. 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.
|
||||
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_asyncio
|
||||
|
||||
from scribe.models import async_session
|
||||
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 rulebooks as rulebooks_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
|
||||
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`
|
||||
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
||||
assert nothing, so the fixture carries one of each and every test says
|
||||
which payload it is about.
|
||||
Both are ordinary rulebooks since milestone 394; the fixture used to flag
|
||||
one always-on because that was a second, separate way to reach a project.
|
||||
Keeping two is still worth it: a rulebook nobody subscribed to must
|
||||
contribute nothing, and a fixture with only the subscribed one could not
|
||||
tell "correctly scoped" from "returns everything".
|
||||
"""
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "surfacing_owner")
|
||||
@@ -43,10 +49,6 @@ async def world():
|
||||
await s.commit()
|
||||
|
||||
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")
|
||||
await rulebooks_svc.create_rule(
|
||||
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"]}
|
||||
|
||||
|
||||
@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
|
||||
@@ -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(
|
||||
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])
|
||||
|
||||
@@ -145,8 +116,21 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Version names are labels",
|
||||
"A name decides nothing.",
|
||||
when_to_apply="when naming a build", tier="conditional",
|
||||
when_to_apply="when naming a build",
|
||||
)
|
||||
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the
|
||||
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a
|
||||
# subscribed rulebook applies on its own, so an untagged partner arrives
|
||||
# through the ordinary query and the edge is never exercised — the
|
||||
# assertion below passed while proving nothing, which is how this was
|
||||
# noticed. Tagging it puts it out of reach of everything except the edge.
|
||||
area = await canonical_svc.find_by_name("CI & Release")
|
||||
assert area is not None, "migration 0087 seeds the standard vocabulary"
|
||||
await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id])
|
||||
assert "Version names are labels" not in await _titles(world), (
|
||||
"the partner must be unreachable on its own, or this test cannot fail"
|
||||
)
|
||||
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||
note="they fail together",
|
||||
@@ -161,9 +145,11 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||
async def test_a_suppression_outranks_an_edge(world):
|
||||
"""The edge says these belong together; the suppression says this project
|
||||
does not want that one. An explicit decision beats an inferred one."""
|
||||
# Untagged on purpose, unlike the partner above: this test is about the
|
||||
# SUPPRESSION winning, so the partner should be one that would otherwise
|
||||
# arrive by every available route — the ordinary query AND the edge.
|
||||
partner = await rulebooks_svc.create_rule(
|
||||
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||
tier="conditional",
|
||||
)
|
||||
await rulebooks_svc.add_rule_relation(
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
await rulebooks_svc.update_rule(
|
||||
@@ -162,7 +162,6 @@ async def rulebook_of_three():
|
||||
stale = await rulebooks_svc.create_rule(
|
||||
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
||||
verify_with="cat CI-runner/renovate/config.js",
|
||||
tier="conditional",
|
||||
)
|
||||
async with async_session() as s:
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -412,10 +412,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
|
||||
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
|
||||
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:
|
||||
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
|
||||
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}
|
||||
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
|
||||
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
|
||||
applicable = {"rules": [], "project_rules": [], "truncated": False,
|
||||
"subscribed_rulebooks": [], "excluded_always_on": []}
|
||||
"subscribed_rulebooks": []}
|
||||
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
|
||||
|
||||
async def run(project):
|
||||
@@ -466,6 +466,6 @@ def test_inception_routes_and_tool_are_registered():
|
||||
mcp = build_mcp_server()
|
||||
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
|
||||
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
|
||||
|
||||
|
||||
@@ -225,7 +225,9 @@ def test_register_attaches_every_tool():
|
||||
# 26 through milestone 307, +2 for the staleness sweep (milestone 312),
|
||||
# +1 for a rule's edit history (milestone 323), +2 for preferences
|
||||
# (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
|
||||
assert "list_rulebooks" 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 "update_preference" 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 "suppress_rule_for_project" in mcp.names
|
||||
# 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
|
||||
|
||||
|
||||
@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
|
||||
@@ -454,7 +407,7 @@ def _fake_version(**over):
|
||||
"id": 5, "rule_id": 100, "user_id": 1,
|
||||
"title": "The runner has no bash", "statement": "Use sh.",
|
||||
"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",
|
||||
"expires_when": None,
|
||||
"created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""A logged query never carries a credential (#3925).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
|
||||
against the code being written, so whatever was on the command line or in the
|
||||
buffer is what `record_retrieval` stores in `retrieval_logs.query`. A command
|
||||
that exported a token therefore stored the token.
|
||||
|
||||
And storing it was not the worst of it. `near_miss_samples` is the readout the
|
||||
threshold documentation tells you to open before moving a bar, so the value
|
||||
came back OUT into an agent's context on the next tuning pass — which is
|
||||
exactly how this was found, during #3853's threshold spike.
|
||||
|
||||
WHAT THIS PINS, IN BOTH DIRECTIONS, AND WHY THE SECOND HALF IS THE HARD ONE
|
||||
|
||||
A scrubber has two ways to fail and only one of them is obvious.
|
||||
|
||||
1. It misses a secret. Caught by the redaction cases below.
|
||||
2. It eats the EVIDENCE. This is the failure that would do more damage,
|
||||
because it is silent: the whole worth of a near-miss sample is reading the
|
||||
query that was actually refused, and a scrubber that chewed up ordinary
|
||||
commands would turn the one instrument for tuning a bar into unreadable
|
||||
stubs while still looking like it worked. That is the #2663 shape — a
|
||||
surface that reads fine and has quietly stopped saying anything.
|
||||
|
||||
So the second block is not padding. Its cases are REAL queries taken from this
|
||||
install's `near_miss_samples` during #3853, and they must survive byte for
|
||||
byte. If a future pattern is added and one of them changes, the pattern is too
|
||||
greedy — tighten it rather than editing the expectation.
|
||||
|
||||
The secret cases use FABRICATED values in the real formats. Nothing here is or
|
||||
was a live credential.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.retrieval_telemetry import scrub_secrets
|
||||
|
||||
# Fabricated, in the shapes that actually occur. The first is the shape that
|
||||
# was found stored: a shell assignment of a vendor-prefixed token.
|
||||
_SECRETS = [
|
||||
("vendor-prefixed token in a shell assignment",
|
||||
"TOK=flt_AAAABBBBCCCCDDDDEEEEFFFF\npython3 - <<'PY'",
|
||||
"flt_AAAABBBBCCCCDDDDEEEEFFFF"),
|
||||
("a Scribe fmcp_ key in an auth header",
|
||||
"curl -H 'Authorization: Bearer fmcp_ZZZZYYYYXXXXWWWWVVVV' https://x",
|
||||
"fmcp_ZZZZYYYYXXXXWWWWVVVV"),
|
||||
("a forge token in an export",
|
||||
"export GITHUB_TOKEN=ghp_1234567890abcdefghijABCDEF",
|
||||
"ghp_1234567890abcdefghijABCDEF"),
|
||||
("a value assigned to a secret-named variable",
|
||||
'REGISTRY_PASSWORD="hunter2-correct-horse"',
|
||||
"hunter2-correct-horse"),
|
||||
("an api_key in a query string",
|
||||
"curl 'https://api.example/v1/things?api_key=abcdef1234567890'",
|
||||
"abcdef1234567890"),
|
||||
("a private key block",
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKC\n-----END RSA PRIVATE KEY-----",
|
||||
"MIIEowIBAAKC"),
|
||||
]
|
||||
|
||||
# Real queries, from this install's near-miss samples during #3853.
|
||||
_EVIDENCE = [
|
||||
"git push origin dev",
|
||||
'git pull --rebase origin dev 2>&1 | tail -3; echo "=== HEAD ==="; git log --oneline -2',
|
||||
"python3 - <<'PY'\nimport pathlib\np = pathlib.Path(\"web/src/routes/admin/tuning/tuning.test.ts\")",
|
||||
"docker compose up -d",
|
||||
'package library\n\nimport (\n\t"context"\n\t"fmt"\n)',
|
||||
"import {\n fetchTransfers,\n retryTransfer,\n} from './api'",
|
||||
'grep -rn "useState" src/components/ | head -20',
|
||||
# The word "token" in ordinary prose is not a token.
|
||||
"explain how the token bucket rate limiter works",
|
||||
"wc -l src/*.py && date",
|
||||
# `--author=` contains "auth". A bare `auth` keyword in the assigned
|
||||
# pattern redacted the address here, which is the evidence-eating failure
|
||||
# this block exists to catch — and it shipped for one commit because the
|
||||
# set did not contain a case with it. `AUTH_TOKEN=` is still caught, via
|
||||
# `token`.
|
||||
"git commit --author=bvandeusen@example.com -m 'x'",
|
||||
"git log --author=\"Bryan Van Deusen\" --oneline",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("label", "text", "secret"), _SECRETS,
|
||||
ids=[c[0] for c in _SECRETS])
|
||||
def test_a_credential_never_survives_into_the_query_column(label, text, secret):
|
||||
"""The value goes; something visible stays in its place."""
|
||||
out = scrub_secrets(text)
|
||||
assert secret not in out, (
|
||||
f"{label}: the credential is still in the text that would be stored"
|
||||
)
|
||||
assert "[redacted" in out, (
|
||||
f"{label}: the span was removed without saying so. A silent deletion "
|
||||
"leaves a reader unable to tell a scrubbed query from a short one, "
|
||||
"which is the readout lying about itself rather than protecting you."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", _EVIDENCE)
|
||||
def test_an_ordinary_query_is_stored_exactly_as_it_was(query):
|
||||
"""Evidence survives byte for byte.
|
||||
|
||||
These came out of real `near_miss_samples`. A threshold is tuned by reading
|
||||
them, so a pattern greedy enough to touch one has destroyed the instrument
|
||||
it was meant to make safe — tighten the pattern, never this expectation.
|
||||
"""
|
||||
assert scrub_secrets(query) == query
|
||||
|
||||
|
||||
def test_empty_and_missing_queries_pass_through():
|
||||
"""Some sources log no query at all; scrubbing must not invent one."""
|
||||
assert scrub_secrets(None) is None
|
||||
assert scrub_secrets("") == ""
|
||||
|
||||
|
||||
def test_the_write_path_scrubs_rather_than_the_read_path():
|
||||
"""The payload built for storage carries the redacted text (#3925).
|
||||
|
||||
Pinned on `_build_payload` because that is the single seam every source
|
||||
reaches the column through. A per-caller scrub would be three places for
|
||||
one of them to be forgotten by whoever adds the fourth arm — and the one
|
||||
forgotten would be the one that stored a secret.
|
||||
"""
|
||||
from scribe.services.retrieval_telemetry import _build_payload
|
||||
|
||||
payload = _build_payload(
|
||||
user_id=1, source="pre_tool_rule",
|
||||
query="export API_TOKEN=ghp_1234567890abcdefghijABCDEF && git push",
|
||||
threshold=0.68, limit=5, project_id=0, is_task=None,
|
||||
results=[], duration_ms=1.0,
|
||||
)
|
||||
assert "ghp_1234567890abcdefghijABCDEF" not in payload["query"]
|
||||
assert "[redacted" in payload["query"]
|
||||
# The rest of the command survives, or the row stops being evidence.
|
||||
assert "git push" in payload["query"]
|
||||
@@ -46,7 +46,7 @@ def test_service_signatures_require_user_id():
|
||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||
"create_rule", "create_project_rule", "rule_detail",
|
||||
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
|
||||
"list_rules", "list_always_on_rules",
|
||||
"list_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
"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
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""An act surfaces a SET of rules, and rank decides how loudly (#3851).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
`RULEHINT_LIMIT` was 1. That was right while retrieval merely SUPPLEMENTED a
|
||||
33-rule resident set — one salient rule beside everything already loaded. It
|
||||
stops being right the moment milestone 394 removes residency, because then
|
||||
this arm is the whole delivery, and `git push origin dev` is governed by
|
||||
rules 1, 2, 9 and 140 at once, each of which alone permits the mistake the
|
||||
others catch.
|
||||
|
||||
Two instruments, and they answer different questions:
|
||||
|
||||
- `_rule_band` decides HOW MANY. A fixed k fills its slots whether or not
|
||||
anything deserves them; a band keeps only what scored close to the top,
|
||||
so one clearly-relevant rule still shows one.
|
||||
- `compact` decides HOW LOUD. Measured at #3851: a full line is ~143 tokens
|
||||
once the trigger is rendered, so five of them cost ~646 before every Bash
|
||||
call. Top-full-plus-references costs ~198.
|
||||
|
||||
WHAT THIS PINS
|
||||
|
||||
Structure, never wording — the lines are prose and will be rewritten:
|
||||
|
||||
1. The band keeps the top hit and everything within `_RULEHINT_BAND`, and
|
||||
drops what falls outside. Falsified below from both sides: a hit just
|
||||
inside survives, a hit just outside does not.
|
||||
2. Rank decides volume — the first line carries the trigger, later lines do
|
||||
not, and every line names its rule's id so any of them can be pulled.
|
||||
3. The band reads SCORES ONLY. A top hit the session has already seen still
|
||||
anchors the band, and its score still sets the cutoff. This is the axis
|
||||
independence the renderer already keeps between `kind` and `seen`, and
|
||||
the regression it prevents is subtle: letting the ledger reorder the
|
||||
band would make "you were told this" change what counts as relevant.
|
||||
|
||||
The band width itself is deliberately NOT pinned. It is a tuning value with
|
||||
a comment recording the measurement behind it, and a test asserting 0.05
|
||||
would fail on every future retune while proving nothing about behaviour —
|
||||
so the cases below express their scores as offsets from the constant.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.plugin_context import (
|
||||
_RULEHINT_BAND,
|
||||
_rule_band,
|
||||
_rule_hint_line,
|
||||
)
|
||||
from tests.helpers import fake_rule
|
||||
|
||||
_TRIGGER = "about to run git push with an earlier CI run still unread"
|
||||
|
||||
|
||||
def _hit(score: float, rule_id: int):
|
||||
return (score, fake_rule(id=rule_id, title=f"rule {rule_id}",
|
||||
when_to_apply=_TRIGGER))
|
||||
|
||||
|
||||
def test_an_empty_result_stays_empty():
|
||||
"""No hits is not a crash and not a phantom line."""
|
||||
assert _rule_band([]) == []
|
||||
|
||||
|
||||
def test_the_band_keeps_a_hit_just_inside_it():
|
||||
"""The whole point: a close second rule reaches the agent."""
|
||||
top = 0.75
|
||||
hits = [_hit(top, 1), _hit(top - _RULEHINT_BAND + 0.01, 2)]
|
||||
assert [r.id for _s, r in _rule_band(hits)] == [1, 2]
|
||||
|
||||
|
||||
def test_the_band_drops_a_hit_just_outside_it():
|
||||
"""And the band must actually BIND, or it is a fixed k wearing a hat."""
|
||||
top = 0.75
|
||||
hits = [_hit(top, 1), _hit(top - _RULEHINT_BAND - 0.01, 2)]
|
||||
assert [r.id for _s, r in _rule_band(hits)] == [1]
|
||||
|
||||
|
||||
def test_one_clearly_better_rule_still_surfaces_alone():
|
||||
"""The behaviour the old limit of 1 got right, which must not regress.
|
||||
|
||||
A moment with a single relevant rule shows one line, because the corpus
|
||||
said so — not because a constant capped it.
|
||||
"""
|
||||
hits = [_hit(0.80, 1), _hit(0.55, 2), _hit(0.54, 3)]
|
||||
assert [r.id for _s, r in _rule_band(hits)] == [1]
|
||||
|
||||
|
||||
def test_a_flat_cluster_surfaces_together():
|
||||
"""Measured shape of this corpus: adjacent rules sit ~0.02 apart.
|
||||
|
||||
Four rules governing one act is the `git push` case the step exists for,
|
||||
and at the measured spacing they must arrive together rather than the
|
||||
ranker picking one of four near-ties.
|
||||
"""
|
||||
hits = [_hit(0.757, 2), _hit(0.735, 7), _hit(0.726, 1), _hit(0.711, 9)]
|
||||
assert [r.id for _s, r in _rule_band(hits)] == [2, 7, 1, 9]
|
||||
|
||||
|
||||
def test_the_band_is_computed_from_scores_not_from_the_ledger():
|
||||
"""A seen top hit still anchors the band (#3750 x #3851).
|
||||
|
||||
`_rule_band` never learns what the session has seen — dedup happens after
|
||||
it, in the arms. Pinned here because the tempting "reorder so a fresh rule
|
||||
leads" would silently change the cutoff, and the failure is invisible: the
|
||||
arm would still emit lines, just the wrong set.
|
||||
"""
|
||||
hits = [_hit(0.80, 1), _hit(0.78, 2), _hit(0.60, 3)]
|
||||
kept = _rule_band(hits)
|
||||
# Independent of any `already` set, because it is not consulted.
|
||||
assert [r.id for _s, r in kept] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seen", [False, True])
|
||||
def test_the_leading_line_carries_the_trigger(seen):
|
||||
"""Rank 0 gets the full rendering, on either tail."""
|
||||
line = _rule_hint_line(
|
||||
fake_rule(id=4, title="dev is home", when_to_apply=_TRIGGER),
|
||||
where="to this Bash call", seen=seen, compact=False,
|
||||
)
|
||||
assert _TRIGGER in line
|
||||
assert "get_rule(4)" in line
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seen", [False, True])
|
||||
def test_a_later_line_cites_its_rule_without_quoting_the_trigger(seen):
|
||||
"""Rank > 0 is a reference: identity and pointer, no trigger.
|
||||
|
||||
The trigger is the expensive half — 300-400 characters after #3855 — and
|
||||
the leading line has already demonstrated the shape. Both assertions
|
||||
matter: dropping the trigger is the saving, and keeping `get_rule(id)` is
|
||||
what makes the saving safe, because a cited rule the reader cannot pull is
|
||||
just noise.
|
||||
"""
|
||||
line = _rule_hint_line(
|
||||
fake_rule(id=4, title="dev is home", when_to_apply=_TRIGGER),
|
||||
where="to this Bash call", seen=seen, compact=True,
|
||||
)
|
||||
assert _TRIGGER not in line
|
||||
assert "dev is home" in line
|
||||
assert "get_rule(4)" in line
|
||||
|
||||
|
||||
def test_shortening_a_line_does_not_decide_what_it_says_about_holding():
|
||||
"""A reference still tells a repeat from a first surfacing (#3750 x #3851).
|
||||
|
||||
This is the regression the first cut of #3851 actually shipped: the
|
||||
compact branch dropped the tail along with the trigger, so a rule the
|
||||
session had already been told read exactly like one it had not. #3750's
|
||||
whole argument is that the two are different claims — a repeat is rendered
|
||||
precisely because the session may no longer HOLD what it was told — and
|
||||
the tail is the entire difference a reader can act on.
|
||||
|
||||
`compact` and `seen` are independent axes. How much room a line gets is a
|
||||
fact about its rank; whether the session holds it is a fact about the
|
||||
ledger; and neither may be allowed to answer the other's question.
|
||||
"""
|
||||
rule = fake_rule(id=4, title="dev is home", when_to_apply=_TRIGGER)
|
||||
seen = _rule_hint_line(rule, where="here", seen=True, compact=True)
|
||||
fresh = _rule_hint_line(rule, where="here", seen=False, compact=True)
|
||||
assert seen != fresh
|
||||
assert "no longer hold it" in seen
|
||||
assert "not in this session's loaded set" in fresh
|
||||
|
||||
|
||||
def test_a_compact_line_is_materially_shorter_than_a_full_one():
|
||||
"""The cost claim, asserted rather than left in a comment.
|
||||
|
||||
Not a token count — that would pin the tokenizer. Half the characters is
|
||||
the property that makes widening the arm affordable, and it is what fails
|
||||
if a later edit puts the trigger back into the compact branch.
|
||||
|
||||
Measured against a REALISTIC trigger, because that is where the saving
|
||||
lives: the rules this arm carries run 300-400 characters of trigger after
|
||||
#3855, and a toy one-line trigger would make this pass on a compact branch
|
||||
that had stopped saving anything.
|
||||
"""
|
||||
long_trigger = (
|
||||
"Opening or merging a `dev`->`main` pull request, running "
|
||||
"`git push origin main`, `git tag`, or minting a release, image tag "
|
||||
"or other public artifact. Also whenever CI has just gone green and "
|
||||
"the next step feels like shipping it, and whenever an earlier merge "
|
||||
"this session reads like standing permission for the next one."
|
||||
)
|
||||
rule = fake_rule(id=4, title="dev is home", when_to_apply=long_trigger)
|
||||
full = _rule_hint_line(rule, where="here", seen=False, compact=False)
|
||||
compact = _rule_hint_line(rule, where="here", seen=False, compact=True)
|
||||
assert len(compact) * 2 < len(full)
|
||||
|
||||
|
||||
def test_a_preference_keeps_its_noun_when_compact():
|
||||
"""Force survives the shortening (#3849).
|
||||
|
||||
`kind` and `compact` are independent axes. A preference rendered as a
|
||||
reference must still not read as a rule — the noun is the whole of the
|
||||
visual difference, so losing it in the compact branch would make every
|
||||
cited preference bind.
|
||||
"""
|
||||
line = _rule_hint_line(
|
||||
fake_rule(id=5, title="pace debugging", kind="preference",
|
||||
when_to_apply=_TRIGGER),
|
||||
where="here", seen=False, compact=True,
|
||||
)
|
||||
assert "preference" in line.lower()
|
||||
assert "standing rule" not in line.lower()
|
||||
@@ -5,8 +5,9 @@ WHY THIS EXISTS
|
||||
#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
|
||||
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
|
||||
moment are different properties), applied to time instead of to tier.
|
||||
argument #3702 made about tiers (present in context and salient at the
|
||||
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
|
||||
|
||||
|
||||
+123
-79
@@ -62,6 +62,12 @@ def _arm_patches(pc, hits, recorder, prior_art=None, cfg=None, rule_search=None,
|
||||
AsyncMock(return_value=cfg or {
|
||||
"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3, "rule_threshold": 0.6,
|
||||
# The command arm reads its OWN bar since #3853, and
|
||||
# a stub missing this key does not fail where a
|
||||
# reader would see it: the arm fails open, so the
|
||||
# KeyError becomes an empty hint and every case in
|
||||
# _ARMS reports the arm went silent instead.
|
||||
"tool_rule_threshold": 0.6,
|
||||
})),
|
||||
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))),
|
||||
patch.object(pc, "semantic_search_notes",
|
||||
@@ -161,14 +167,21 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
||||
kw = search.await_args.kwargs
|
||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||
# NO tier filter (#3702). The arms search every rule the caller owns,
|
||||
# because "already in the session" is not the same as "in front of the
|
||||
# reader at the moment it applies" — and relevance is the threshold's
|
||||
# job, not a category's. If this assertion is failing because a tier
|
||||
# argument came back, read the block above RULEHINT_LIMIT first: the
|
||||
# filter may legitimately return, but only carrying a measured reason.
|
||||
assert "tier" not in kw or kw["tier"] is None, (
|
||||
"the arm is filtering the rule corpus by tier again"
|
||||
# NO CATEGORY FILTER (#3702, repointed by 394). This asserted that the arm
|
||||
# passed no `tier`. That parameter no longer exists, so the assertion had
|
||||
# become one that could not fail — which rule 167 rates below having none.
|
||||
#
|
||||
# The claim it was making is still live, on the axis that DOES still exist:
|
||||
# the act arms narrow by nothing, so a preference reaches a write exactly
|
||||
# as a rule does. "Already in the session" was never the same as "in front
|
||||
# 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"
|
||||
)
|
||||
|
||||
|
||||
@@ -296,57 +309,8 @@ def test_the_bulk_loaders_are_not_counted_as_pulls():
|
||||
# lookalike call sites which show nobody anything do NOT.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_session_start_preload_records_what_it_delivered():
|
||||
"""The block every session opens with. Chosen by nobody, paid for every
|
||||
turn — and until it emitted, invisible to the scoreboard that judges every
|
||||
other surface."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
rec = MagicMock()
|
||||
rules = [fake_rule(id=1, title="`dev` is home"),
|
||||
fake_rule(id=2, title="`main` — never without explicit request")]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(pc.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(return_value=rules))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(pc.rulebooks_svc, "excluded_always_on_rulebooks",
|
||||
AsyncMock(return_value=[]))
|
||||
)
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", rec))
|
||||
stack.enter_context(
|
||||
patch.object(pc, "_topic_titles", AsyncMock(return_value={}))
|
||||
)
|
||||
await pc.build_session_context(1, project_id=0)
|
||||
|
||||
assert rec.call_count == 1, "the preload recorded nothing"
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["rule_ids"] == [1, 2]
|
||||
assert kw["source"] == "session_start"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_always_on_tool_records_what_it_handed_over():
|
||||
from scribe.mcp.tools import rulebooks as tools
|
||||
|
||||
rec = MagicMock()
|
||||
rules = [fake_rule(id=3, title="No GitHub — Fabled-Git only")]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch.object(tools.rulebooks_svc, "list_always_on_rules",
|
||||
AsyncMock(return_value=rules))
|
||||
)
|
||||
stack.enter_context(
|
||||
patch.object(tools.rulebooks_svc, "rules_etag",
|
||||
MagicMock(return_value="etag"))
|
||||
)
|
||||
stack.enter_context(patch.object(tools, "record_rule_surfaced", rec))
|
||||
await tools.list_always_on_rules()
|
||||
|
||||
assert rec.call_args.kwargs["rule_ids"] == [3]
|
||||
assert rec.call_args.kwargs["source"] == "list_always_on_rules"
|
||||
|
||||
|
||||
def test_rules_payload_records_both_the_family_and_project_halves():
|
||||
@@ -393,26 +357,6 @@ def test_every_rules_payload_caller_names_itself():
|
||||
}, f"a rules_payload caller is missing or misnamed: {sorted(seen)}"
|
||||
|
||||
|
||||
def test_the_marker_paths_stay_silent():
|
||||
"""The two call sites that read the rules and show NOBODY anything.
|
||||
|
||||
`rules_etag_for` and the write-path staleness arm both call
|
||||
`list_always_on_rules` to build or compare a marker. Emitting there would
|
||||
put rules in the denominator that no agent ever saw — the exact inflation
|
||||
`record_rule_surfaced`'s docstring forbids, arriving from the one direction
|
||||
nothing else guards.
|
||||
"""
|
||||
svc_src = Path("src/scribe/services/rulebooks.py").read_text()
|
||||
etag_fn = svc_src.split("async def rules_etag_for")[1].split("\ndef ")[0]
|
||||
assert "record_rule_surfaced" not in etag_fn, (
|
||||
"rules_etag_for emits a surfacing — it builds a marker, it shows nothing"
|
||||
)
|
||||
|
||||
pc_src = Path("src/scribe/services/plugin_context.py").read_text()
|
||||
staleness = pc_src.split("if rules_etag:")[1].split("# The guard sits BELOW")[0]
|
||||
assert "record_rule_surfaced" not in staleness, (
|
||||
"the staleness arm emits a surfacing — it compares a marker, it shows nothing"
|
||||
)
|
||||
|
||||
|
||||
# ── The PRE-TOOL arm: rules keyed on the action (#3476) ────────────────
|
||||
@@ -428,6 +372,12 @@ def _tool_patches(pc, hits, recorder, cfg=None, retrieval_log=None):
|
||||
AsyncMock(return_value=cfg or {
|
||||
"enabled": True, "threshold": 0.6,
|
||||
"top_k": 3, "rule_threshold": 0.6,
|
||||
# The command arm reads its OWN bar since #3853, and
|
||||
# a stub missing this key does not fail where a
|
||||
# reader would see it: the arm fails open, so the
|
||||
# KeyError becomes an empty hint and every case in
|
||||
# _ARMS reports the arm went silent instead.
|
||||
"tool_rule_threshold": 0.6,
|
||||
})),
|
||||
patch.object(pc, "semantic_search_rules", AsyncMock(return_value=hits)),
|
||||
patch.object(pc, "record_retrieval", retrieval_log or MagicMock()),
|
||||
@@ -1026,10 +976,37 @@ async def test_a_shown_hit_is_not_counted_as_suppressed():
|
||||
|
||||
_THREE_HITS = [
|
||||
(0.81, fake_rule(id=156, title="A wait with no deadline is a bug")),
|
||||
(0.77, fake_rule(id=157, title="A loop re-arms in a finally")),
|
||||
(0.74, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
(0.80, fake_rule(id=157, title="A loop re-arms in a finally")),
|
||||
(0.79, fake_rule(id=161, title="Reach the forge through its MCP tools")),
|
||||
]
|
||||
|
||||
def test_the_three_hit_fixture_sits_inside_the_rule_band():
|
||||
"""The fixture's own precondition, asserted rather than commented (#3851).
|
||||
|
||||
The act arms band before they dedup, so a fixture whose spread straddles
|
||||
`_RULEHINT_BAND` loses its lowest hit to the BAND and then reports a count
|
||||
mismatch — under a message blaming the exclusion filter. That is the
|
||||
failure this file is least able to survive: a guard pointing confidently
|
||||
at the wrong subsystem costs more than no guard, because it is believed.
|
||||
|
||||
Not hypothetical. The spread was 0.07 against a 0.05 band, and four cases
|
||||
of `test_both_recorders_report_the_same_rules_for_one_call` failed that
|
||||
way the moment the band shipped.
|
||||
|
||||
Widening the band leaves this alone; narrowing it past the spread must
|
||||
retighten these scores, and says so here rather than through four
|
||||
confusing failures elsewhere.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
spread = _THREE_HITS[0][0] - _THREE_HITS[-1][0]
|
||||
assert spread < pc._RULEHINT_BAND, (
|
||||
f"the rule-arm fixture spans {spread:.3f} against a band of "
|
||||
f"{pc._RULEHINT_BAND}: the act arms will drop its lowest hit as "
|
||||
"out-of-band, and every count assertion below will blame the "
|
||||
"exclusion filter for it"
|
||||
)
|
||||
|
||||
_ARMS = [
|
||||
("write_path_rule", _run_arm),
|
||||
("pre_tool_rule", _run_tool_arm),
|
||||
@@ -1554,3 +1531,70 @@ async def test_a_preference_on_the_ledger_keeps_the_slot_and_is_not_recounted():
|
||||
"a repeat was written back to the hook's ledger, which would keep "
|
||||
"pushing its stamp forward so it never aged out (#3751)"
|
||||
)
|
||||
|
||||
|
||||
# ── each act arm uses its OWN bar, end to end (#3853) ───────────────────
|
||||
#
|
||||
# The two act arms shared one threshold until the telemetry showed them
|
||||
# behaving like different subsystems at the same number: write_path_rule
|
||||
# speaking on 37% of 2,325 calls, pre_tool_rule on 2% of 11,768, because a
|
||||
# code payload is long and rich where a shell command is short and carries
|
||||
# less signal for the same relevance.
|
||||
#
|
||||
# Splitting the bar creates a failure the old single-bar code could not have:
|
||||
# an arm can now search at one threshold and REPORT another. That row is what
|
||||
# near-miss analysis is read against, so a mismatch does not look like a bug —
|
||||
# it looks like a corpus whose scores sit somewhere they do not, and it would
|
||||
# be acted on by moving the very bar it is misreporting.
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_act_arm_searches_at_its_own_bar():
|
||||
"""The split, where it actually takes effect."""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
cfg = {"enabled": True, "threshold": 0.6, "top_k": 3,
|
||||
"rule_threshold": 0.77, "tool_rule_threshold": 0.61}
|
||||
|
||||
search = AsyncMock(return_value=list(_THREE_HITS))
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(
|
||||
pc, "get_writepath_config", AsyncMock(return_value=cfg)))
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", search))
|
||||
stack.enter_context(patch.object(pc, "record_retrieval", MagicMock()))
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
|
||||
await pc.build_tool_rule_hint(1, "Bash", "git push origin dev")
|
||||
|
||||
assert search.await_args.kwargs["threshold"] == 0.61, (
|
||||
"the command arm searched at the write-path arm's bar; the two were "
|
||||
"split at #3853 precisely because one number cannot serve both"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_act_arm_reports_the_bar_it_actually_searched_at():
|
||||
"""Search and log must agree, or the telemetry lies about the refusal.
|
||||
|
||||
`retrieval_logs.threshold` is what `near_miss_samples` is read against.
|
||||
An arm searching at 0.61 and logging 0.72 reports every hit between them
|
||||
as having cleared a bar it never faced — and the reader's conclusion would
|
||||
be to move the bar that was already right.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
cfg = {"enabled": True, "threshold": 0.6, "top_k": 3,
|
||||
"rule_threshold": 0.77, "tool_rule_threshold": 0.61}
|
||||
|
||||
search = AsyncMock(return_value=list(_THREE_HITS))
|
||||
log = MagicMock()
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(
|
||||
pc, "get_writepath_config", AsyncMock(return_value=cfg)))
|
||||
stack.enter_context(patch.object(pc, "semantic_search_rules", search))
|
||||
stack.enter_context(patch.object(pc, "record_retrieval", log))
|
||||
stack.enter_context(patch.object(pc, "record_rule_surfaced", MagicMock()))
|
||||
await pc.build_tool_rule_hint(1, "Bash", "git push origin dev")
|
||||
|
||||
rows = [c for c in log.call_args_list
|
||||
if c.kwargs.get("source") == "pre_tool_rule"]
|
||||
assert len(rows) == 1
|
||||
assert rows[0].kwargs["threshold"] == search.await_args.kwargs["threshold"]
|
||||
|
||||
@@ -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
|
||||
@@ -251,7 +251,7 @@ def test_the_column_guard_covers_every_table_with_a_row_helper():
|
||||
# caught on its own first run.
|
||||
join_tables = {
|
||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||
"project_topic_suppressions", "project_rulebook_exclusions",
|
||||
"project_topic_suppressions",
|
||||
"rule_systems",
|
||||
}
|
||||
covered = set(_column_guard_targets()) | join_tables
|
||||
@@ -355,7 +355,7 @@ async def test_export_full_backup_contains_every_declared_section():
|
||||
"systems", "record_systems", "design_systems",
|
||||
"design_tokens", "note_usage_events", "repo_bindings",
|
||||
"note_supersessions", "code_shapes", "code_shape_events",
|
||||
"code_shape_uses", "rulebook_exclusions"):
|
||||
"code_shape_uses"):
|
||||
assert key in out, f"missing export section: {key}"
|
||||
assert out[key] == []
|
||||
|
||||
@@ -382,13 +382,13 @@ def test_rule_rows_carry_the_verification_fields():
|
||||
can rot — the exact blindness the fields were added to end.
|
||||
|
||||
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)
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
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",
|
||||
verified_at=checked, arose_from_id=99,
|
||||
created_at=checked, updated_at=checked,
|
||||
@@ -415,7 +415,7 @@ def test_rule_rows_keep_an_unverified_rule_unverified():
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
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,
|
||||
arose_from_id=None,
|
||||
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(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
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,
|
||||
arose_from_id=None, created_at=stamp, updated_at=stamp,
|
||||
)
|
||||
|
||||
@@ -4,23 +4,9 @@ import pytest
|
||||
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")
|
||||
|
||||
|
||||
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) ──────────────────────────────────────────
|
||||
@@ -106,30 +92,6 @@ async def test_build_autoinject_hint_blank_query_returns_empty():
|
||||
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
|
||||
@@ -139,11 +101,7 @@ async def test_build_session_context_includes_project_when_scoped():
|
||||
# opposite of what this test is about.
|
||||
project = MagicMock(id=2, title="FabledScribe", goal="ship it",
|
||||
design_system_id=None)
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
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",
|
||||
with 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=([], 4))):
|
||||
@@ -170,11 +128,7 @@ async def test_build_session_context_pushes_the_projects_design_system():
|
||||
"guidance": [], "token_count": 95,
|
||||
"token_groups": ["accent", "surface", "type"],
|
||||
}
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
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",
|
||||
with 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))), \
|
||||
@@ -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
|
||||
session its rules too."""
|
||||
project = MagicMock(id=2, title="App", goal="", design_system_id=9)
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
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",
|
||||
with 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))), \
|
||||
@@ -219,14 +169,10 @@ async def test_build_session_context_survives_an_unreadable_design_system():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
||||
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
||||
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, project_id=0, unbound_repo="host/owner/repo",
|
||||
)
|
||||
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"]
|
||||
assert out["project"] is None
|
||||
@@ -289,16 +235,29 @@ async def test_build_process_manifest_truncates_long_preview():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_session_context_caps_length():
|
||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
||||
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)
|
||||
"""The cap still binds, and now has to be provoked rather than tripped.
|
||||
|
||||
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
||||
assert "truncated" in out["context"]
|
||||
This used to hand the block 200 long rule titles, because the preload made
|
||||
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) --------------------------------------------------
|
||||
|
||||
@@ -14,7 +14,7 @@ def _no_exclusions():
|
||||
"""get_applicable_rules asks for the project's always-on exclusions
|
||||
(milestone 297) through its own session; these mocked-session tests
|
||||
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
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
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():
|
||||
"""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),
|
||||
))
|
||||
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
|
||||
# string across the always-on set is ~2k characters of payload.
|
||||
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
|
||||
|
||||
|
||||
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) ──────────────────────────
|
||||
@@ -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["expires_when"] == "dependencyDashboardApproval is turned off"
|
||||
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():
|
||||
@@ -495,13 +482,3 @@ def test_a_verified_row_counts_the_days():
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Every rule-write surface SHOWS what a trigger looks like (#3855).
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
`when_to_apply` is not documentation. `rule_document()` uses it twice — as
|
||||
the embedded title's second half and again above the body — so it dominates
|
||||
the vector, and a rule whose trigger names a CATEGORY rather than a moment
|
||||
collapses toward its title and never arrives. Measured in #3835 across 113
|
||||
rules, then again in #3855 on eight preferences.
|
||||
|
||||
The corpus damage traced to an uneven contract rather than to careless
|
||||
authors. `create_rule` had carried the full argument since 2026-08-27, and
|
||||
the two preferences written that day got good triggers; the six written
|
||||
before it — "during hard debugging", "when reading any request from the
|
||||
operator" — got categories. The guidance worked wherever it existed. It
|
||||
simply did not exist on either `update_*` surface, which is where every
|
||||
RETROFITTED trigger is written, and retrofitting is most of the work: a
|
||||
trigger that already reads fine as English is the one nobody rewrites.
|
||||
|
||||
WHAT THIS PINS
|
||||
|
||||
Two properties, neither of them wording:
|
||||
|
||||
1. A tool accepting `when_to_apply` mentions it. Exactly what
|
||||
`update_preference` failed before #3855 — the parameter existed and
|
||||
the docstring never said so.
|
||||
2. It carries a worked CONTRAST: one `RETRIEVES:` example and one
|
||||
`COLLAPSES:` example, quoted, and different from each other.
|
||||
|
||||
WHY A CONVENTION RATHER THAN A PROSE HEURISTIC
|
||||
|
||||
The second property guards the softer regression — guidance kept but
|
||||
abstracted back to "name the moment in session vocabulary", which is advice
|
||||
about being concrete that is not itself concrete, and is the shape that was
|
||||
already on file while the corpus filled with categories.
|
||||
|
||||
Two attempts to detect that in free prose were written and discarded, and
|
||||
the reason is worth keeping because it generalises:
|
||||
|
||||
- Counting quoted multi-word phrases anywhere in the docstring measured
|
||||
ambient quotation, not demonstrated triggers. It PASSED the abstracted
|
||||
version by scoring unrelated prose, and text with an odd number of
|
||||
quote characters produced matches spanning the gap BETWEEN two
|
||||
unrelated phrases.
|
||||
- Scoping that count to a window after each "trigger"/"when_to_apply"
|
||||
mention then FAILED `create_preference` in its correct state, because
|
||||
its examples sit further from the first mention than any defensible
|
||||
window reaches.
|
||||
|
||||
Both were proxies trying to infer demonstration from prose. The fix was to
|
||||
stop inferring: a two-line labelled contrast is unambiguous to parse, free
|
||||
in its wording, and a better teaching form than the sentences it replaced —
|
||||
`RETRIEVES:`/`COLLAPSES:` names the mechanism, so the label does work for
|
||||
the reader instead of only for the test. Where a property cannot be
|
||||
measured, changing the shape of the thing is cheaper than a cleverer
|
||||
measurement.
|
||||
|
||||
What it still cannot do is judge whether a GOOD example is good. It catches
|
||||
what actually happened: a write surface shipping with the contrast absent,
|
||||
half-present, or tidied back into abstract advice.
|
||||
|
||||
THE SURFACE LIST IS DERIVED, NEVER HAND-KEPT. It comes from what
|
||||
`rulebooks.register()` actually hands the server, filtered to signatures
|
||||
taking `when_to_apply`, so a rule-write tool added later is in scope on the
|
||||
day it is added. A hand-kept list has to be remembered by the person least
|
||||
likely to know it exists — the argument `RANKED_SOURCES` makes in
|
||||
services/rule_usage.py, and the reason #3855 happened at all.
|
||||
"""
|
||||
import inspect
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers import tool_doc as _doc
|
||||
|
||||
_MODULE = "scribe.mcp.tools.rulebooks"
|
||||
|
||||
# The worked contrast. Quoted so the example's own punctuation stays inside
|
||||
# it, labelled so no proximity guess is needed to find it.
|
||||
_RETRIEVES = re.compile(r'RETRIEVES:\s*"([^"]+)"')
|
||||
_COLLAPSES = re.compile(r'COLLAPSES:\s*"([^"]+)"')
|
||||
|
||||
|
||||
def _registered_tools() -> list:
|
||||
"""Every function `rulebooks.register()` actually hands to the server.
|
||||
|
||||
Collected by handing `register` a stand-in that records what it is given,
|
||||
rather than by reading the source or repeating the tuple here. The point
|
||||
is that this cannot drift from what ships.
|
||||
"""
|
||||
from scribe.mcp.tools import rulebooks
|
||||
|
||||
collected: list = []
|
||||
|
||||
class _Collector:
|
||||
def tool(self, name=None):
|
||||
def register_one(fn):
|
||||
collected.append(fn)
|
||||
return fn
|
||||
return register_one
|
||||
|
||||
rulebooks.register(_Collector())
|
||||
return collected
|
||||
|
||||
|
||||
def _trigger_writers() -> list[str]:
|
||||
"""The registered tools that can write a rule's trigger."""
|
||||
names = [
|
||||
fn.__name__
|
||||
for fn in _registered_tools()
|
||||
if "when_to_apply" in inspect.signature(fn).parameters
|
||||
]
|
||||
# An empty list would make every case below vacuous and the guard would
|
||||
# pass by having nothing to check — absence read as non-existence, the
|
||||
# #3720 shape. Fail loudly instead.
|
||||
assert names, "no rule-write surface takes when_to_apply; the guard is blind"
|
||||
return sorted(names)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _trigger_writers())
|
||||
def test_a_trigger_writing_surface_documents_the_field(name):
|
||||
"""A tool that can write a trigger must say what the field is for."""
|
||||
doc = _doc(_MODULE, name)
|
||||
assert "when_to_apply" in doc, (
|
||||
f"{name} accepts when_to_apply but never mentions it in its "
|
||||
"docstring. The tool docstring is the agent-facing contract "
|
||||
"(rule 119), and a trigger written against no contract is the #3855 "
|
||||
"defect: six preferences named a category instead of a moment, and "
|
||||
"a category is not a thing any session ever types."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _trigger_writers())
|
||||
def test_a_trigger_writing_surface_shows_the_contrast(name):
|
||||
"""Advice about being concrete has to be concrete itself."""
|
||||
doc = _doc(_MODULE, name)
|
||||
retrieves, collapses = _RETRIEVES.findall(doc), _COLLAPSES.findall(doc)
|
||||
|
||||
assert retrieves and collapses, (
|
||||
f"{name} is missing its worked contrast — found "
|
||||
f"{len(retrieves)} RETRIEVES and {len(collapses)} COLLAPSES example(s), "
|
||||
"and one of each is required. Telling an author to 'name the moment "
|
||||
"in session vocabulary' without showing one is the guidance that was "
|
||||
"already on file while the corpus filled with categories. Add two "
|
||||
'quoted lines: RETRIEVES: "<a moment a session actually produces — a '
|
||||
'command, an error, a half-formed ask>" and COLLAPSES: "<the same '
|
||||
'thing named as a category>".'
|
||||
)
|
||||
|
||||
same = set(retrieves) & set(collapses)
|
||||
assert not same, (
|
||||
f"{name} shows the same text as both RETRIEVES and COLLAPSES "
|
||||
f"({next(iter(same))!r}). The pair teaches by DIFFERING — one moment "
|
||||
"written the way a session would produce it, and the same moment "
|
||||
"written as a category. Identical halves demonstrate nothing."
|
||||
)
|
||||
@@ -481,6 +481,63 @@ async def test_the_two_write_path_bars_are_independent():
|
||||
assert cfg["rule_threshold"] == 0.61
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_two_act_arms_read_independent_rule_bars():
|
||||
"""#3853's split: the command arm's bar moves without the write path's.
|
||||
|
||||
The two act arms shared one key until the telemetry showed them behaving
|
||||
like different subsystems at the same number — the write-path arm speaking
|
||||
on 37% of calls against the command arm's 2%, because a code payload is
|
||||
long and rich where a shell command is short. A config assembler that
|
||||
reads one key into both fields would silently undo that, and the symptom
|
||||
would be invisible: both arms would simply agree again.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
stored = {pc.RULEHINT_THRESHOLD_KEY: "0.75", pc.TOOLRULE_THRESHOLD_KEY: "0.61"}
|
||||
with patch.object(pc, "get_setting",
|
||||
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
|
||||
cfg = await pc.get_writepath_config(1)
|
||||
|
||||
assert cfg["rule_threshold"] == 0.75
|
||||
assert cfg["tool_rule_threshold"] == 0.61
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_garbage_command_bar_falls_back_to_its_own_default():
|
||||
"""Not to 0.0, and not to the write path's default.
|
||||
|
||||
Falling back to 0.0 would attach a rule to every Bash call in the session;
|
||||
falling back to the sibling's default would quietly re-merge the two bars
|
||||
that #3853 separated, which is the harder failure to see because the arm
|
||||
keeps working.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
stored = {pc.TOOLRULE_THRESHOLD_KEY: "banana"}
|
||||
with patch.object(pc, "get_setting",
|
||||
AsyncMock(side_effect=lambda uid, k, d: stored.get(k, d))):
|
||||
cfg = await pc.get_writepath_config(1)
|
||||
|
||||
assert cfg["tool_rule_threshold"] == pc.TOOLRULE_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def test_the_command_bar_defaults_below_the_write_path_bar():
|
||||
"""A DIRECTION check, like its sibling above, and for the same rule-115
|
||||
reason: the value is measured against one corpus, the relationship is not.
|
||||
|
||||
A shell command carries less text than a code payload and therefore scores
|
||||
lower for the same relevance — measured at #3853, where three of four
|
||||
consequential commands retrieved nothing at the shared bar while the
|
||||
write-path arm was healthy at it. Tuning either value stays free; inverting
|
||||
the relationship would reinstate the mute arm that spoke on 2% of 11,768
|
||||
calls.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
assert pc.TOOLRULE_DEFAULT_THRESHOLD < pc.RULEHINT_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def test_the_rule_bar_defaults_above_the_code_bar():
|
||||
"""Not a number check — a DIRECTION check, and the only part of the default
|
||||
that is defensible without one instance's histogram (rule 115).
|
||||
@@ -499,13 +556,31 @@ def test_the_rule_bar_defaults_above_the_code_bar():
|
||||
assert pc.RULEHINT_DEFAULT_THRESHOLD > pc.WRITEPATH_DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def test_the_rule_arm_asks_for_one_rule_not_two():
|
||||
"""With a corpus this small, top-k does as much damage as the threshold:
|
||||
k=2 over a few dozen candidates means the second line is almost always the
|
||||
second-best noise, carrying the same confident framing as the first."""
|
||||
def test_the_rule_arm_asks_for_a_set_and_lets_the_band_narrow_it():
|
||||
"""WAS `..._asks_for_one_rule_not_two`, pinning `RULEHINT_LIMIT == 1`.
|
||||
|
||||
That guarded a real decision: with retrieval SUPPLEMENTING a 33-rule
|
||||
resident set, k=2 over a few dozen candidates made the second line the
|
||||
second-best noise wearing the first line's confident framing. Milestone
|
||||
394 removes residency, so this arm becomes the whole delivery and a
|
||||
`git push` governed by four rules cannot be served by one slot (#3851).
|
||||
|
||||
What replaces it is not simply a bigger k — that is the thing the old
|
||||
test was right to fear, because a fixed k fills its slots whether or not
|
||||
anything deserves them. The cap is a ceiling and the BAND is the control,
|
||||
so both must exist for the arm to be shaped as intended.
|
||||
|
||||
Pinned as relationships rather than values, like the threshold test above
|
||||
and for the same rule-115 reason: the band is a tuning number measured
|
||||
against one corpus, and a test asserting 0.05 would fail on every retune
|
||||
while proving nothing. What must not silently invert is that the arm can
|
||||
return several, and that rules — which measured FLATTER than notes, not
|
||||
sharper — are narrowed harder than the notes menu is.
|
||||
"""
|
||||
from scribe.services import plugin_context as pc
|
||||
|
||||
assert pc.RULEHINT_LIMIT == 1
|
||||
assert pc.RULEHINT_LIMIT > 1
|
||||
assert 0 < pc._RULEHINT_BAND < pc._AUTOINJECT_BAND
|
||||
|
||||
|
||||
# --- the minimum-substance floor on the semantic arm (#2223) ------------------
|
||||
|
||||
Reference in New Issue
Block a user