Retire the always-on tier — every rule arrives by retrieval (milestone 394) #152

Merged
bvandeusen merged 13 commits from dev into main 2026-09-11 16:43:20 -04:00
64 changed files with 1889 additions and 1822 deletions
@@ -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,
),
)
+1 -1
View File
@@ -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 |
+2 -3
View File
@@ -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
View File
@@ -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 -5
View File
@@ -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) =>
+1 -26
View File
@@ -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}` : ""}`);
+9
View File
@@ -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);
+6 -22
View File
@@ -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);
+4 -11
View File
@@ -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,
-3
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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
+4 -9
View File
@@ -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
+9 -27
View File
@@ -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.
+13 -11
View File
@@ -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
+1 -1
View File
@@ -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
+38 -27
View File
@@ -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
View File
@@ -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.
+9 -11
View File
@@ -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}
+93 -147
View File
@@ -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,
):
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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
-2
View File
@@ -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 "",
+13 -26
View File
@@ -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",
-7
View File
@@ -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)
+1 -1
View File
@@ -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 {}
+3 -32
View File
@@ -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 -36
View File
@@ -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
+7 -13
View File
@@ -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
+32 -45
View File
@@ -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>)"
),
}
+294 -170
View File
@@ -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.0100.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),
}
+84 -2
View File
@@ -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
+3 -3
View File
@@ -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.
"""
+1 -1
View File
@@ -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",
)
+55 -265
View File
@@ -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
View File
@@ -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
View File
@@ -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}
-69
View File
@@ -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
+83 -60
View File
@@ -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"
+20 -24
View File
@@ -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"):
+39 -53
View File
@@ -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",
+1 -8
View File
@@ -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):
+4 -4
View File
@@ -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
+4 -51
View File
@@ -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),
+135
View File
@@ -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"]
+1 -17
View File
@@ -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():
+203
View File
@@ -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()
+3 -2
View File
@@ -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
View File
@@ -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"]
-267
View File
@@ -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
+6 -6
View File
@@ -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,
)
+29 -70
View File
@@ -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) --------------------------------------------------
+2 -25
View File
@@ -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")
+156
View File
@@ -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."
)
+80 -5
View File
@@ -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) ------------------