wip(394): steps 6+7 — backend path and instruction surfaces
This commit is contained in:
@@ -89,7 +89,7 @@ table here. The tools are grouped by family:
|
|||||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||||
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
| 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/inception/defaults` | What binds if nobody decides — the inception card's payload |
|
||||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
| 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 | `/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/rules/:rid` | Suppress / unsuppress a rule |
|
||||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
| 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
|
## 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,
|
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
|
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`,
|
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
|
server-level usage guidance delivered in the MCP `instructions` block. See
|
||||||
[API Keys & MCP](api-keys-and-mcp.md).
|
[API Keys & MCP](api-keys-and-mcp.md).
|
||||||
|
|||||||
+5
-3
@@ -60,8 +60,10 @@ Scribe stores the operator's engineering and workflow **rules** so Claude follow
|
|||||||
across sessions.
|
across sessions.
|
||||||
|
|
||||||
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
|
- **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
|
- **Rules arrive by retrieval** — Nothing is preloaded. A rule reaches a
|
||||||
start of every session through the plugin's push channel.
|
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
|
- **Per-project scope** — A project subscribes to rulebooks, and can add
|
||||||
project-scoped rules or suppress individual inherited rules/topics.
|
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
|
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
|
||||||
repo) wires it up:
|
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);
|
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
|
||||||
- universal process-skills — writing-plans, systematic-debugging, verification,
|
- universal process-skills — writing-plans, systematic-debugging, verification,
|
||||||
brainstorming — that route their output into Scribe;
|
brainstorming — that route their output into Scribe;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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.1154",
|
"version": "2026.09.11.1154",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ instance into a first-class Claude Code extension:
|
|||||||
|
|
||||||
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||||
rulebook (the `scribe` server).
|
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*.
|
rules + active-project context so Scribe surfaces *without being asked*.
|
||||||
- **Prior-art recall on writes** — a `PreToolUse` hook on Write/Edit checks the
|
- **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
|
file about to be written against your recorded snippets (what's kept at that
|
||||||
|
|||||||
@@ -175,16 +175,11 @@ while IFS= read -r rel_path; do
|
|||||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
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}"
|
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||||
fi
|
fi
|
||||||
# The rules marker the SessionStart hook stored, handed back so the server
|
# The rules marker is gone with the resident set it aged (milestone 394).
|
||||||
# can say whether those rules moved since (milestone 323). Nothing stored
|
# A session no longer holds a fixed set of rules from turn zero, so there
|
||||||
# means nothing sent, which the server reads as silence rather than as a
|
# is nothing that can have drifted since it loaded them — each rule is
|
||||||
# mismatch — an install that never reached /api/plugin/context must not
|
# retrieved at the moment it applies.
|
||||||
# start claiming its rules changed.
|
|
||||||
etag_q=""
|
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
|
if [ -n "$path_enc" ]; then
|
||||||
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
# 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
|
# gates nothing the session is waiting on, and the first prior-art call
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# does not depend on the key or the network.
|
# does not depend on the key or the network.
|
||||||
#
|
#
|
||||||
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
# 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:
|
# the plugin's userConfig, exported to hooks as:
|
||||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||||
@@ -158,7 +158,7 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||||
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
||||||
# where it has to be captured: the model receives one from
|
# 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
|
# an MCP tool too, but a hook cannot see a tool's result. Stored
|
||||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
# under the same state dir the prior-art hook already uses, keyed by session,
|
||||||
# so "changed since" means since THIS session loaded its rules.
|
# so "changed since" means since THIS session loaded its rules.
|
||||||
#
|
#
|
||||||
@@ -178,9 +178,9 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
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."
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
|
||||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; 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
|
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||||
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
||||||
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
||||||
@@ -189,7 +189,7 @@ elif [ -z "$url" ] && [ -z "$token" ]; then
|
|||||||
# silently disabled auto-inject and the write-path trigger too. It is not a
|
# 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
|
# benign state — the plugin prompts for both values at enable time, so if
|
||||||
# neither reached the hook, something is wrong. Say so.
|
# 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
|
fi
|
||||||
|
|
||||||
[ -n "$dyn" ] && append "$dyn"
|
[ -n "$dyn" ] && append "$dyn"
|
||||||
@@ -197,7 +197,7 @@ fi
|
|||||||
|
|
||||||
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
||||||
if [ "$source" = "compact" ]; then
|
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
|
fi
|
||||||
|
|
||||||
# Nothing at all to inject → stay silent.
|
# Nothing at all to inject → stay silent.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ of record (notes, tasks, projects, milestones, rules) reachable through the
|
|||||||
for the operator's work, and as your own working memory across sessions.
|
for the operator's work, and as your own working memory across sessions.
|
||||||
|
|
||||||
**At the start of this session:**
|
**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`),
|
- 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
|
call `enter_project(<id>)` to load that project's rules, open tasks, and
|
||||||
recent notes in one shot.
|
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
|
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
|
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
|
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
|
no rule has arrived for the act in front of you, `search(content_type=
|
||||||
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
"rule")` BEFORE acting rather than falling back on a default habit. When a
|
||||||
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
retrieved rule and a default habit disagree, the rule wins; if no rule
|
||||||
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
speaks to it, ask rather than assume.
|
||||||
- **Rules bind; preferences do not.** A record's `kind` says which. A **rule**
|
- **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
|
must be followed — ignoring it breaks something or crosses a boundary. A
|
||||||
**preference** is how the operator wants work done: worth following for
|
**preference** is how the operator wants work done: worth following for
|
||||||
consistency, not a defect to miss. Injected lines name the kind in their
|
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
|
opening words. A preference is also yours to keep current when they correct
|
||||||
you (`update_preference`); a rule waits for them.
|
you (`update_preference`); a rule waits for them.
|
||||||
- **What you loaded is not all of the rules.** Only the always-on tier arrives
|
- **Silence is not absence.** Nothing is preloaded: every rule is RETRIEVED,
|
||||||
that way; conditional rules are RETRIEVED, and one you were never handed
|
when what you are doing resembles what the rule is about. Most turns
|
||||||
binds exactly as hard. So before a consequential act, `search` for a rule
|
retrieve none, and a rule you were never handed binds exactly as hard as one
|
||||||
about it (`content_type="rule"`) rather than concluding from an empty
|
you were. So before a consequential act, `search` for a rule about it
|
||||||
loaded set that nothing applies. "I was not told" is not the same as "there
|
(`content_type="rule"`) rather than concluding from an empty session that
|
||||||
is no rule," and only one of those is checkable.
|
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
|
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
|
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
|
like mechanics rather than decisions, so they raise no doubt and generate no
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# asks "what is recorded about the file being written". This one asks "does a
|
# 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
|
# 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
|
# 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):
|
# 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
|
# you reach for `curl` confidently, with no moment of doubt, so a surface that
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: using-scribe
|
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
|
# Using Scribe
|
||||||
@@ -13,12 +13,19 @@ asked for.
|
|||||||
|
|
||||||
## Do this first (every session)
|
## Do this first (every session)
|
||||||
|
|
||||||
**Pull the standing rules yourself — do not wait for them to be handed to you.**
|
**You are not holding the operator's rules, and no call loads them all.**
|
||||||
At the start of a session, before substantive work, call
|
There is no standing set to pull. A rule reaches you when what you are about to
|
||||||
`list_always_on_rules()` to load the operator's always-on rules. If the working
|
do matches it — a command, code you are writing, or what the operator just
|
||||||
repo maps to a Scribe project (you're in a known repo, or `list_repo_bindings`
|
asked for — and on most turns none will. That is the surface working.
|
||||||
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.
|
**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
|
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
|
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
|
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||||
`project_id` so results stay scoped.
|
`project_id` so results stay scoped.
|
||||||
|
|
||||||
2. **Standing rules are binding — and the ones you were handed are not all of
|
2. **Rules are binding, and silence does not mean there are none.** Nothing
|
||||||
them.** Load the resident set via `list_always_on_rules()` at session start
|
is preloaded, so "no rule arrived" means "nothing matched" — never "no rule
|
||||||
(see "Do this first"). Pull a record's full statement with `get_rule(id)`
|
exists". Ask with `search(content_type="rule")` before a consequential act,
|
||||||
when it's about to bite. When a project is in scope, `enter_project(id)`
|
and pull a record's full statement with `get_rule(id)` when it is about to
|
||||||
also returns its applicable rules.
|
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
|
**`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
|
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
|
## Starting a project: decide what it inherits
|
||||||
|
|
||||||
A project's inheritance is a **decision, not a default**. Before
|
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:
|
answers — never create a project bare by default:
|
||||||
|
|
||||||
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
|
- which rulebooks to **subscribe** (`list_rulebooks` shows them; default: none
|
||||||
which are always_on; default: inherit them all) →
|
— a rulebook binds a project only when it opts in) →
|
||||||
`exclude_always_on_rulebooks=[...]`
|
`subscribe_rulebooks=[...]`
|
||||||
- which other rulebooks to **subscribe** → `subscribe_rulebooks=[...]`
|
|
||||||
- which **design system** its UI is built from (`list_design_systems`; or
|
- which **design system** its UI is built from (`list_design_systems`; or
|
||||||
none) → `design_system_id=<id | -1>`
|
none) → `design_system_id=<id | -1>`
|
||||||
- whether to **seed the standard starter Systems** so records can be tagged
|
- 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
|
When codifying a rule, pick its home by **who it should bind** — and keep
|
||||||
shared homes general:
|
shared homes general:
|
||||||
|
|
||||||
- **Always-on rulebook** (`create_rule` in an `always_on` rulebook) — universal
|
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable,
|
||||||
norms that bind *every* project. Cross-project standards only.
|
*themed* module of general rules that binds the projects which opt in (e.g. a
|
||||||
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
review checklist → every service). Themed, but project-agnostic.
|
||||||
reusable, *themed* module of general rules that binds only projects that opt
|
|
||||||
in (e.g. a review checklist → every service). Themed, but project-agnostic.
|
|
||||||
- **Project rule** (`create_project_rule`) — anything specific to one project
|
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||||
(its files, paths, quirks).
|
(its files, paths, quirks).
|
||||||
|
|
||||||
Both rulebook tiers are shared, so their rules stay general; they differ in
|
There used to be a third home — an `always_on` rulebook that bound every
|
||||||
**reach** (all vs opt-in), not generality. Names one project's specifics →
|
project automatically. It is gone: subscription is the only reach a rulebook
|
||||||
project rule; a standard a category shares → subscribed rulebook; a universal
|
has. Names one project's specifics → project rule; anything a category of
|
||||||
norm → always-on rulebook. Never put project-specific detail in a shared
|
projects shares → rulebook. Never put project-specific detail in a rulebook —
|
||||||
rulebook — it leaks to every other project that gets it.
|
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
|
**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.
|
and apply; Scribe's other entities are structure a tool can resolve and check.
|
||||||
|
|||||||
+17
-17
@@ -38,8 +38,8 @@ from quart import Quart
|
|||||||
# them) was DECLINED a line, deliberately, by the operator — not overlooked.
|
# 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
|
# 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
|
# 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
|
# sweep is a curation act, not a session-start reflex like enter_project.
|
||||||
# list_always_on_rules. Spending the last of the budget on it would leave the
|
# Spending the last of the budget on it would leave the
|
||||||
# map unable to grow for something more central later.
|
# map unable to grow for something more central later.
|
||||||
#
|
#
|
||||||
# The accepted cost: an agent that never opens create_note's docstring never
|
# 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
|
# - 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
|
# session-start altitude. Rules were retrievable only by RESIDENCY: the
|
||||||
# always-on preload put them in front of the agent, and nothing told a
|
# 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
|
# session to go looking for one it had not been handed. That preload is
|
||||||
# therefore load-bearing on ANY install (rule 115): a delivered rule costs
|
# gone (milestone 394), which makes this line LOAD-BEARING rather than
|
||||||
# tokens in every session forever, so a rulebook that only delivers cannot
|
# supplementary: retrieval is now the only delivery, and retrieval fires
|
||||||
# grow past what one session can hold, and every rule worth keeping has to
|
# only if something asks. A session that waits to be handed a rule is
|
||||||
# become resident to bind at all. Retrieval is what lets it keep growing —
|
# handed nothing. A tool-choice reflex asks least of all (#3476, #161).
|
||||||
# and retrieval fires only if something asks, which nothing told a session
|
# - It also has to carry what absence MEANS. "No rule arrived" is now the
|
||||||
# to do. A tool-choice reflex asks least of all (#3476, #161).
|
# ordinary state rather than the exceptional one, and reading it as
|
||||||
# - This states the PULL for conditional rules, exactly as the surrounding
|
# "there is no rule" is the #3720 defect at session scale. Rule 119 makes
|
||||||
# line states it for always-on ones. Rule 119 makes these surfaces the
|
# these surfaces the
|
||||||
# specification, so the same sentence lands on all three session-start
|
# specification, so the same sentence lands on all three session-start
|
||||||
# surfaces, and test_instruction_surfaces_agree pins it.
|
# surfaces, and test_instruction_surfaces_agree pins it.
|
||||||
_INSTRUCTIONS = """
|
_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:
|
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||||
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
||||||
notes, Systems, design system. `inception` key: ask what the project
|
notes, Systems, design system. `inception`: ask what the project
|
||||||
inherits, decide_project_inception (create_project takes the same).
|
inherits, then decide_project_inception.
|
||||||
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
|
- 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;
|
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.
|
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.
|
active project_id to stay in scope.
|
||||||
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||||
create_system when the area is unmodelled.
|
create_system when the area is unmodelled.
|
||||||
- HOW: rules bind; preferences guide. list_always_on_rules() at start;
|
- HOW: rules bind; preferences guide. Nothing preloads — a rule arrives
|
||||||
before a consequential act, search(content_type="rule") — the resident
|
when your work matches it. Before a consequential act,
|
||||||
set is not all of them.
|
search(content_type="rule"); silence means nothing matched, not none.
|
||||||
- UI: the project's design system is binding — resolve_design_system /
|
- UI: the project's design system is binding — resolve_design_system /
|
||||||
get_design_system_stylesheet before hand-writing a value.
|
get_design_system_stylesheet before hand-writing a value.
|
||||||
- REUSE: search snippets before writing a helper; record what you build with
|
- 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",
|
"get_task", "get_milestone", "get_recent", "enter_project",
|
||||||
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
|
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
|
||||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||||
"list_always_on_rules", "search",
|
"search",
|
||||||
"get_system", "list_systems", "list_system_records",
|
"get_system", "list_systems", "list_system_records",
|
||||||
# The global area catalog and its mapping REPORT — propose writes nothing;
|
# The global area catalog and its mapping REPORT — propose writes nothing;
|
||||||
# map_system_to_canonical is the separate, explicitly-called write.
|
# map_system_to_canonical is the separate, explicitly-called write.
|
||||||
|
|||||||
@@ -256,17 +256,16 @@ async def get_project(project_id: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _inception_choices(
|
def _inception_choices(
|
||||||
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
subscribe_rulebooks, design_system_id, seed_systems,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""The tool args → an inception choices object, or None when no inception
|
"""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
|
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
|
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
||||||
system."""
|
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):
|
and not design_system_id and seed_systems is None):
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
|
|
||||||
"subscribe_rulebooks": list(subscribe_rulebooks or []),
|
"subscribe_rulebooks": list(subscribe_rulebooks or []),
|
||||||
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
||||||
"seed_systems": bool(seed_systems),
|
"seed_systems": bool(seed_systems),
|
||||||
@@ -279,7 +278,6 @@ async def create_project(
|
|||||||
goal: str = "",
|
goal: str = "",
|
||||||
status: str = "active",
|
status: str = "active",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
exclude_always_on_rulebooks: list[int] | None = None,
|
|
||||||
subscribe_rulebooks: list[int] | None = None,
|
subscribe_rulebooks: list[int] | None = None,
|
||||||
design_system_id: int = 0,
|
design_system_id: int = 0,
|
||||||
seed_systems: bool | None = None,
|
seed_systems: bool | None = None,
|
||||||
@@ -299,9 +297,10 @@ async def create_project(
|
|||||||
goal: The desired outcome or definition of done for the project.
|
goal: The desired outcome or definition of done for the project.
|
||||||
status: one of active (default), paused, completed, archived.
|
status: one of active (default), paused, completed, archived.
|
||||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
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
|
NOT inherit ([] = inherit them all). list_rulebooks shows which are
|
||||||
always_on.
|
|
||||||
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
|
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
|
||||||
design_system_id: the design system this project's UI is built from
|
design_system_id: the design system this project's UI is built from
|
||||||
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
||||||
@@ -319,7 +318,7 @@ async def create_project(
|
|||||||
)
|
)
|
||||||
data = project.to_dict()
|
data = project.to_dict()
|
||||||
choices = _inception_choices(
|
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:
|
if choices is not None:
|
||||||
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
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(
|
async def decide_project_inception(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
exclude_always_on_rulebooks: list[int] | None = None,
|
|
||||||
subscribe_rulebooks: list[int] | None = None,
|
subscribe_rulebooks: list[int] | None = None,
|
||||||
design_system_id: int = 0,
|
design_system_id: int = 0,
|
||||||
seed_systems: bool | None = None,
|
seed_systems: bool | None = None,
|
||||||
@@ -345,11 +343,11 @@ async def decide_project_inception(
|
|||||||
or re-decide later (milestone 297).
|
or re-decide later (milestone 297).
|
||||||
|
|
||||||
Owner-only. Applies the effects through the ordinary tools' paths —
|
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
|
set_project_design_system, the standard Systems seed — and writes the
|
||||||
decision on the project last, so get_project/enter_project can say why
|
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
|
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
|
unsubscribe_project_from_rulebook to undo one), replaces the design
|
||||||
system, and never re-seeds Systems a project already has.
|
system, and never re-seeds Systems a project already has.
|
||||||
|
|
||||||
@@ -359,7 +357,7 @@ async def decide_project_inception(
|
|||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
choices = _inception_choices(
|
choices = _inception_choices(
|
||||||
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
subscribe_rulebooks, design_system_id, seed_systems,
|
||||||
) or {}
|
) or {}
|
||||||
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
||||||
return {"project_id": project_id, **decided}
|
return {"project_id": project_id, **decided}
|
||||||
|
|||||||
@@ -48,16 +48,13 @@ async def get_rulebook(rulebook_id: int) -> dict:
|
|||||||
async def create_rulebook(title: str, description: str = "") -> dict:
|
async def create_rulebook(title: str, description: str = "") -> dict:
|
||||||
"""Create a new rulebook (a shared, reusable module of general rules).
|
"""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
|
A rulebook reaches a project ONE way: the project subscribes to it
|
||||||
update_rulebook):
|
(subscribe_project_to_rulebook). There was a second until milestone 394 —
|
||||||
- always_on = true -> binds EVERY one of your projects automatically.
|
an `always_on` flag that bound every project automatically — and it is
|
||||||
Use for universal cross-project norms that apply across every
|
gone with the tier it belonged to. Opt-in is now the whole model, so a
|
||||||
project, not just one.
|
rulebook binds what asked for it and nothing else.
|
||||||
- always_on = false -> binds only projects that subscribe
|
|
||||||
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
|
A rulebook is SHARED, so its rules must stay general — agnostic
|
||||||
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
|
|
||||||
to any single project. Project-specific rules go in create_project_rule.
|
to any single project. Project-specific rules go in create_project_rule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -73,7 +70,6 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
|||||||
|
|
||||||
async def update_rulebook(
|
async def update_rulebook(
|
||||||
rulebook_id: int, title: str = "", description: str = "",
|
rulebook_id: int, title: str = "", description: str = "",
|
||||||
always_on: bool | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing rulebook. Only non-empty fields are changed.
|
"""Update an existing rulebook. Only non-empty fields are changed.
|
||||||
|
|
||||||
@@ -81,9 +77,6 @@ async def update_rulebook(
|
|||||||
rulebook_id: Rulebook to update.
|
rulebook_id: Rulebook to update.
|
||||||
title: New title. Empty string leaves unchanged.
|
title: New title. Empty string leaves unchanged.
|
||||||
description: New description. 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()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -91,8 +84,6 @@ async def update_rulebook(
|
|||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if description:
|
if description:
|
||||||
fields["description"] = 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)
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
||||||
if rb is None:
|
if rb is None:
|
||||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
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)}
|
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:
|
async def get_rule(rule_id: int) -> dict:
|
||||||
"""Fetch a rule by id — full statement + why + how_to_apply.
|
"""Fetch a rule by id — full statement + why + how_to_apply.
|
||||||
|
|
||||||
@@ -309,7 +248,6 @@ async def get_rule(rule_id: int) -> dict:
|
|||||||
async def create_rule(
|
async def create_rule(
|
||||||
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
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 = "",
|
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -357,7 +295,7 @@ async def create_rule(
|
|||||||
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
||||||
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
||||||
so that text is what gets stored, verbatim.
|
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
|
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.
|
this answer as the expected one rather than a setback.
|
||||||
* "NO" — let it go. If the observation is still worth keeping, it is a
|
* "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.
|
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
|
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 —
|
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
|
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
|
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
|
instruction. State the moment or the material: "before any git
|
||||||
push", "when adding a value to a CHECK-gated column", "when a
|
push", "when adding a value to a CHECK-gated column", "when a
|
||||||
release is being cut". Write it even though the parameter is
|
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
|
when it matters, and a rule nobody can place is a rule nobody
|
||||||
applies.
|
applies.
|
||||||
This field is also the rule's RETRIEVAL SURFACE — it and the
|
This field is also the rule's RETRIEVAL SURFACE — it and the
|
||||||
@@ -428,12 +366,6 @@ async def create_rule(
|
|||||||
category — it produces the command, the error, the half-formed
|
category — it produces the command, the error, the half-formed
|
||||||
ask — so a trigger written that way leaves the embedded
|
ask — so a trigger written that way leaves the embedded
|
||||||
document to be carried by the title alone.
|
document to be carried by the title alone.
|
||||||
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.
|
|
||||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
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
|
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.
|
working in that area, so a CI rule surfaces on a CI change.
|
||||||
@@ -472,7 +404,7 @@ async def create_rule(
|
|||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
topic_id=topic_id, user_id=uid,
|
topic_id=topic_id, user_id=uid,
|
||||||
title=title, statement=statement, when_to_apply=when_to_apply,
|
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,
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
verify_with=verify_with, expires_when=expires_when,
|
verify_with=verify_with, expires_when=expires_when,
|
||||||
)
|
)
|
||||||
@@ -482,7 +414,6 @@ async def create_rule(
|
|||||||
async def create_project_rule(
|
async def create_project_rule(
|
||||||
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
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 = "",
|
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -531,25 +462,7 @@ async def create_project_rule(
|
|||||||
RETRIEVES: "the CI job passed locally and fails on the runner
|
RETRIEVES: "the CI job passed locally and fails on the runner
|
||||||
with a permission error"
|
with a permission error"
|
||||||
COLLAPSES: "when touching CI config"
|
COLLAPSES: "when touching CI config"
|
||||||
See create_rule for the full argument. It informs the
|
See create_rule for the full argument.
|
||||||
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.
|
|
||||||
system_ids: Ids from list_canonical_systems — the global AREAS this
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||||
rule is about. Worth setting even on a project rule: it is what
|
rule is about. Worth setting even on a project rule: it is what
|
||||||
lets a conditional one surface when the project is working in
|
lets a conditional one surface when the project is working in
|
||||||
@@ -583,7 +496,7 @@ async def create_project_rule(
|
|||||||
rule = await rulebooks_svc.create_project_rule(
|
rule = await rulebooks_svc.create_project_rule(
|
||||||
project_id=project_id, user_id=uid,
|
project_id=project_id, user_id=uid,
|
||||||
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
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,
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
verify_with=verify_with, expires_when=expires_when,
|
verify_with=verify_with, expires_when=expires_when,
|
||||||
)
|
)
|
||||||
@@ -593,7 +506,7 @@ async def create_project_rule(
|
|||||||
async def update_rule(
|
async def update_rule(
|
||||||
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
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 = "",
|
verify_with: str = "", expires_when: str = "", kind: str = "",
|
||||||
clear_fields: list[str] | None = None,
|
clear_fields: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -606,9 +519,10 @@ async def update_rule(
|
|||||||
correct. Ordinary edits to an existing preference belong in
|
correct. Ordinary edits to an existing preference belong in
|
||||||
update_preference, which asks for what taught the change.
|
update_preference, which asks for what taught the change.
|
||||||
|
|
||||||
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
|
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
|
||||||
a rule stops being preloaded into every session and starts arriving when it
|
milestone 394, so a rule with no trigger is not a quiet rule — it is one
|
||||||
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
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
|
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".
|
warns about. There the field is empty and the instruction is "write one".
|
||||||
@@ -662,8 +576,6 @@ async def update_rule(
|
|||||||
fields["statement"] = statement
|
fields["statement"] = statement
|
||||||
if when_to_apply:
|
if when_to_apply:
|
||||||
fields["when_to_apply"] = when_to_apply
|
fields["when_to_apply"] = when_to_apply
|
||||||
if tier:
|
|
||||||
fields["tier"] = tier
|
|
||||||
if kind:
|
if kind:
|
||||||
fields["kind"] = kind
|
fields["kind"] = kind
|
||||||
if arose_from_id:
|
if arose_from_id:
|
||||||
@@ -978,7 +890,7 @@ async def subscribe_project_to_rulebook(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Subscribe a project to a rulebook — its rules then bind that project.
|
"""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.
|
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
|
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 —
|
-> the design-system rulebook), not to host rules about this one project —
|
||||||
@@ -1004,34 +916,6 @@ async def unsubscribe_project_from_rulebook(
|
|||||||
|
|
||||||
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
# ── 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(
|
async def suppress_rule_for_project(
|
||||||
project_id: int, rule_id: int,
|
project_id: int, rule_id: int,
|
||||||
@@ -1087,8 +971,6 @@ async def unsuppress_topic_for_project(
|
|||||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def relate_rules(
|
async def relate_rules(
|
||||||
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -1136,7 +1018,7 @@ async def unrelate_rules(relation_id: int) -> dict:
|
|||||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||||
|
|
||||||
async def rules_due_for_verification(
|
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:
|
) -> dict:
|
||||||
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
||||||
|
|
||||||
@@ -1163,9 +1045,6 @@ async def rules_due_for_verification(
|
|||||||
Args:
|
Args:
|
||||||
older_than_days: only rules last verified longer ago than this.
|
older_than_days: only rules last verified longer ago than this.
|
||||||
Never-checked rules always qualify. 0 = no age filter.
|
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.
|
never_only: only rules nobody has ever verified.
|
||||||
|
|
||||||
NOT filterable by project, deliberately: a project reaches rules through
|
NOT filterable by project, deliberately: a project reaches rules through
|
||||||
@@ -1175,7 +1054,7 @@ async def rules_due_for_verification(
|
|||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rules = await rulebooks_svc.rules_due_for_verification(
|
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 {
|
return {
|
||||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||||
@@ -1228,14 +1107,13 @@ def register(mcp) -> None:
|
|||||||
for fn in (
|
for fn in (
|
||||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||||
list_topics, create_topic, update_topic, delete_topic,
|
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_rule, create_project_rule, update_rule, delete_rule,
|
||||||
create_preference, update_preference,
|
create_preference, update_preference,
|
||||||
relate_rules, unrelate_rules,
|
relate_rules, unrelate_rules,
|
||||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||||
suppress_topic_for_project, unsuppress_topic_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,
|
rules_due_for_verification, mark_rule_verified,
|
||||||
rule_history,
|
rule_history,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
|||||||
"title": rule.title,
|
"title": rule.title,
|
||||||
"statement": rule.statement,
|
"statement": rule.statement,
|
||||||
"when_to_apply": rule.when_to_apply or "",
|
"when_to_apply": rule.when_to_apply or "",
|
||||||
"tier": rule.tier,
|
|
||||||
"why": rule.why or "",
|
"why": rule.why or "",
|
||||||
"how_to_apply": rule.how_to_apply or "",
|
"how_to_apply": rule.how_to_apply or "",
|
||||||
"verify_with": rule.verify_with 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
|
`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
|
rules a ranker chose — today only the write-path arm — and those are claims
|
||||||
a pull can settle. `ambient` counts BULK DELIVERIES: the SessionStart
|
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`,
|
(`enter_project`, `get_project`, `get_milestone`, `start_planning`,
|
||||||
`get_task`), which hand over the whole applicable set at once with nobody
|
`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
|
choosing anything. A large `ambient` says the resident set is big and
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
# The inception record (milestone 297): what this project was decided to
|
# The inception record (milestone 297): what this project was decided to
|
||||||
# inherit, when, and through which door — {decided_at, decided_by, via,
|
# 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,
|
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
|
||||||
# and enter_project asks; the effects themselves live in the subscription
|
# and enter_project asks; the effects themselves live in the subscription
|
||||||
# / exclusion tables, design_system_id and the project's Systems — this is
|
# / exclusion tables, design_system_id and the project's Systems — this is
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ class RuleVersion(Base, CreatedAtMixin):
|
|||||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
how_to_apply: 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)
|
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`
|
# 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
|
# 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.
|
# make "this stopped binding" the one edit with no history behind it.
|
||||||
@@ -90,7 +89,6 @@ class RuleVersion(Base, CreatedAtMixin):
|
|||||||
"why": self.why or "",
|
"why": self.why or "",
|
||||||
"how_to_apply": self.how_to_apply or "",
|
"how_to_apply": self.how_to_apply or "",
|
||||||
"when_to_apply": self.when_to_apply or "",
|
"when_to_apply": self.when_to_apply or "",
|
||||||
"tier": self.tier or "",
|
|
||||||
"kind": self.kind or "",
|
"kind": self.kind or "",
|
||||||
"verify_with": self.verify_with or "",
|
"verify_with": self.verify_with or "",
|
||||||
"expires_when": self.expires_when or "",
|
"expires_when": self.expires_when or "",
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -29,7 +26,6 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"owner_user_id": self.owner_user_id,
|
"owner_user_id": self.owner_user_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description or "",
|
"description": self.description or "",
|
||||||
"always_on": self.always_on,
|
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_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
|
# WHEN this rule applies — the trigger, not the instruction. Required of
|
||||||
# new rules at the service layer and nullable here, because rules written
|
# new rules at the service layer and nullable here, because rules written
|
||||||
# before migration 0088 have none and a migration cannot invent one.
|
# 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
|
# It carries three jobs at once (note 3026): it is the readable form of
|
||||||
# concrete, the readable form of the canon tag, and the half of the
|
# the canon tag, the half of the document that makes a rule findable by
|
||||||
# document that makes a rule findable by meaning.
|
# 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)
|
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# always_on = preloaded into every session, as every rule is today.
|
# WHAT KIND of instruction this is. `tier` used to sit beside this and
|
||||||
# conditional = reachable, and surfaced when its trigger fires. The
|
# carry delivery; milestone 394 removed it, so kind is now the only axis
|
||||||
# default preserves existing behaviour exactly: nothing stops binding
|
# on a rule and delivery belongs entirely to retrieval.
|
||||||
# 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.
|
|
||||||
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
|
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
|
||||||
# boundary. `preference` is how this person wants work DONE: ignoring it
|
# boundary. `preference` is how this person wants work DONE: ignoring it
|
||||||
# costs consistency, not correctness.
|
# costs consistency, not correctness.
|
||||||
@@ -161,7 +156,6 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"statement": self.statement,
|
"statement": self.statement,
|
||||||
"when_to_apply": self.when_to_apply or "",
|
"when_to_apply": self.when_to_apply or "",
|
||||||
"tier": self.tier,
|
|
||||||
# Unconditional, unlike the `if present` keys below. A reader
|
# Unconditional, unlike the `if present` keys below. A reader
|
||||||
# deciding how much force a record carries must never infer it
|
# deciding how much force a record carries must never infer it
|
||||||
# from an ABSENT key: "no kind field" and "kind is rule" would be
|
# from an ABSENT key: "no kind field" and "kind is rule" would be
|
||||||
@@ -266,7 +260,7 @@ project_rule_suppressions = Table(
|
|||||||
# sibling of the two suppression tables below, one level up. Always-on
|
# sibling of the two suppression tables below, one level up. Always-on
|
||||||
# rulebooks bind every project implicitly; an inception decision can exclude
|
# rulebooks bind every project implicitly; an inception decision can exclude
|
||||||
# specific ones for this project, and get_applicable_rules /
|
# specific ones for this project, and get_applicable_rules /
|
||||||
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
|
# get_applicable_rules(project_id) skips them. FKs CASCADE like the others.
|
||||||
project_rulebook_exclusions = Table(
|
project_rulebook_exclusions = Table(
|
||||||
"project_rulebook_exclusions",
|
"project_rulebook_exclusions",
|
||||||
Base.metadata,
|
Base.metadata,
|
||||||
|
|||||||
@@ -202,11 +202,6 @@ async def write_path_prior_art():
|
|||||||
or `canon:<snippet_id>`) already named this
|
or `canon:<snippet_id>`) already named this
|
||||||
session by the ledger arm (#2900); its own
|
session by the ledger arm (#2900); its own
|
||||||
channel, like the two above.
|
channel, like the two above.
|
||||||
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
|
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||||
found in (or enclosing) the payload, kind being
|
found in (or enclosing) the payload, kind being
|
||||||
css|sym. The shape ledger's write-path feed
|
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()
|
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"))
|
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 "")
|
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||||
api_key = getattr(g, "api_key", None)
|
api_key = getattr(g, "api_key", None)
|
||||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
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 "",
|
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||||
exclude_derive=exclude_derive,
|
exclude_derive=exclude_derive,
|
||||||
exclude_rule_ids=exclude_rule_ids,
|
exclude_rule_ids=exclude_rule_ids,
|
||||||
rules_etag=rules_etag,
|
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async def create_project_route():
|
|||||||
@login_required
|
@login_required
|
||||||
async def decide_inception_route(project_id: int):
|
async def decide_inception_route(project_id: int):
|
||||||
"""Record (or re-record) what a project inherits — milestone 297.
|
"""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."""
|
design_system_id, seed_systems}; owner-only."""
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async def get_rulebook(rulebook_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
async def update_rulebook(rulebook_id: int):
|
async def update_rulebook(rulebook_id: int):
|
||||||
data = await request.get_json() or {}
|
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)
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
|
||||||
if rb is None:
|
if rb is None:
|
||||||
return jsonify({"error": "rulebook not found"}), 404
|
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", ""),
|
how_to_apply=data.get("how_to_apply", ""),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
when_to_apply=data.get("when_to_apply", ""),
|
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
|
# The human door carries `kind` too, and without the MCP door's
|
||||||
# required provenance: an operator editing their own preference
|
# required provenance: an operator editing their own preference
|
||||||
# owes nobody an explanation. That requirement is about auditing
|
# owes nobody an explanation. That requirement is about auditing
|
||||||
@@ -217,7 +216,7 @@ async def update_rule(rule_id: int):
|
|||||||
fields = {
|
fields = {
|
||||||
k: v for k, v in data.items()
|
k: v for k, v in data.items()
|
||||||
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
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")
|
"verify_with", "expires_when")
|
||||||
}
|
}
|
||||||
# No clear_fields here: a form sends "" for an emptied input, and the
|
# 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
|
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")
|
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||||
@login_required
|
@login_required
|
||||||
async def create_project_rule(project_id: int):
|
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", ""),
|
how_to_apply=data.get("how_to_apply", ""),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
when_to_apply=data.get("when_to_apply", ""),
|
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
|
# The human door carries `kind` too, and without the MCP door's
|
||||||
# required provenance: an operator editing their own preference
|
# required provenance: an operator editing their own preference
|
||||||
# owes nobody an explanation. That requirement is about auditing
|
# 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():
|
async def rules_due_for_verification():
|
||||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
"""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.
|
`verify_with` never appears — it is a decision, not a fact.
|
||||||
"""
|
"""
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
@@ -477,7 +449,6 @@ async def rules_due_for_verification():
|
|||||||
rules = await rulebooks_svc.rules_due_for_verification(
|
rules = await rulebooks_svc.rules_due_for_verification(
|
||||||
uid,
|
uid,
|
||||||
older_than_days=older,
|
older_than_days=older,
|
||||||
tier=args.get("tier", ""),
|
|
||||||
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ def _rule_version_rows(rows) -> list[dict]:
|
|||||||
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
||||||
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
||||||
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
"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,
|
"expires_when": rv.expires_when,
|
||||||
"created_at": rv.created_at.isoformat(),
|
"created_at": rv.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -529,7 +529,7 @@ def _rulebook_rows(rows) -> list[dict]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title,
|
"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(),
|
"created_at": rb.created_at.isoformat(),
|
||||||
"updated_at": rb.updated_at.isoformat(),
|
"updated_at": rb.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ def _rule_rows(rows) -> list[dict]:
|
|||||||
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
||||||
"title": r.title, "statement": r.statement, "why": r.why,
|
"title": r.title, "statement": r.statement, "why": r.why,
|
||||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
"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,
|
"verify_with": r.verify_with, "expires_when": r.expires_when,
|
||||||
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
||||||
"arose_from_id": r.arose_from_id,
|
"arose_from_id": r.arose_from_id,
|
||||||
@@ -1238,7 +1238,6 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
owner_user_id=mapped_uid,
|
owner_user_id=mapped_uid,
|
||||||
title=rb_data.get("title", ""),
|
title=rb_data.get("title", ""),
|
||||||
description=rb_data.get("description", ""),
|
description=rb_data.get("description", ""),
|
||||||
always_on=rb_data.get("always_on", False),
|
|
||||||
created_at=_dt(rb_data.get("created_at")),
|
created_at=_dt(rb_data.get("created_at")),
|
||||||
updated_at=_dt(rb_data.get("updated_at")),
|
updated_at=_dt(rb_data.get("updated_at")),
|
||||||
)
|
)
|
||||||
@@ -1279,10 +1278,14 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
why=r_data.get("why") or None,
|
why=r_data.get("why") or None,
|
||||||
how_to_apply=r_data.get("how_to_apply") or None,
|
how_to_apply=r_data.get("how_to_apply") or None,
|
||||||
when_to_apply=r_data.get("when_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
|
# is the pre-0088 behaviour, so an old backup restores rules
|
||||||
# that bind exactly as they did when it was taken.
|
# 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
|
# Same shape, same reason: a file written before 0098 has no
|
||||||
# kind, and every rule in it was a rule. Defaulting the other
|
# kind, and every rule in it was a rule. Defaulting the other
|
||||||
# way would restore an old backup with things that had always
|
# way would restore an old backup with things that had always
|
||||||
@@ -1427,7 +1430,6 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
why=rv.get("why"),
|
why=rv.get("why"),
|
||||||
how_to_apply=rv.get("how_to_apply"),
|
how_to_apply=rv.get("how_to_apply"),
|
||||||
when_to_apply=rv.get("when_to_apply"),
|
when_to_apply=rv.get("when_to_apply"),
|
||||||
tier=rv.get("tier"),
|
|
||||||
# NOT defaulted, unlike the rule above. A version records what
|
# NOT defaulted, unlike the rule above. A version records what
|
||||||
# was; absent means nobody wrote it down, and inventing "rule"
|
# was; absent means nobody wrote it down, and inventing "rule"
|
||||||
# here would put an artifact where a measurement belongs.
|
# here would put an artifact where a measurement belongs.
|
||||||
|
|||||||
@@ -817,7 +817,6 @@ async def semantic_search_rules(
|
|||||||
query: str,
|
query: str,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
threshold: float = _SIMILARITY_THRESHOLD,
|
threshold: float = _SIMILARITY_THRESHOLD,
|
||||||
tier: str | None = None,
|
|
||||||
kind: str | None = None,
|
kind: str | None = None,
|
||||||
report: dict | None = None,
|
report: dict | None = None,
|
||||||
) -> list[tuple[float, "Rule"]]:
|
) -> list[tuple[float, "Rule"]]:
|
||||||
@@ -844,17 +843,13 @@ async def semantic_search_rules(
|
|||||||
is the surfacing question, and it has its own machinery
|
is the surfacing question, and it has its own machinery
|
||||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
(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
|
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a
|
||||||
and pre-tool hints deliberately pass nothing: an always-on rule is already
|
`tier` parameter, and the arms deliberately passed nothing: filtering on it
|
||||||
in the session, but being in a list from turn zero is not the same as being
|
made a whole class of rules permanently ineligible for the one mechanism
|
||||||
in front of the reader when the action it governs is taken, and filtering
|
that surfaces a rule AT the moment it applies. The tier is now gone
|
||||||
on tier made a whole class of rules permanently ineligible for the one
|
entirely, so every rule is eligible for every arm and relevance is the
|
||||||
mechanism that surfaces a rule AT the moment. Relevance is the threshold's
|
threshold's job alone — see the block above RULEHINT_LIMIT in
|
||||||
job; see the block above RULEHINT_LIMIT in services/plugin_context.py for
|
services/plugin_context.py for what those scores are read against.
|
||||||
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.
|
|
||||||
|
|
||||||
`kind` narrows to `rule` or `preference`, and NONE is likewise the ordinary
|
`kind` narrows to `rule` or `preference`, and NONE is likewise the ordinary
|
||||||
case: a caller asking "what governs this" wants both, because the reader
|
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,
|
Rulebook.owner_user_id == user_id,
|
||||||
Project.user_id == user_id,
|
Project.user_id == user_id,
|
||||||
),
|
),
|
||||||
*( [Rule.tier == tier] if tier else [] ),
|
|
||||||
*( [Rule.kind == kind] if kind else [] ),
|
*( [Rule.kind == kind] if kind else [] ),
|
||||||
)
|
)
|
||||||
# Overfetch so collapsing chunks to their best row still fills
|
# Overfetch so collapsing chunks to their best row still fills
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ A project's inheritance is a decision, not a default. The record lives on
|
|||||||
"decided_at": "<iso>", "decided_by": <user id> | null,
|
"decided_at": "<iso>", "decided_by": <user id> | null,
|
||||||
"via": "mcp" | "ui" | "legacy",
|
"via": "mcp" | "ui" | "legacy",
|
||||||
"choices": {
|
"choices": {
|
||||||
"exclude_always_on_rulebooks": [rulebook ids],
|
|
||||||
"subscribe_rulebooks": [rulebook ids],
|
"subscribe_rulebooks": [rulebook ids],
|
||||||
"design_system_id": <id> | null,
|
"design_system_id": <id> | null,
|
||||||
"seed_systems": bool
|
"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 /
|
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.
|
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
|
The shape and its validator are pure; ``decide`` composes the existing
|
||||||
services — always-on exclusions, subscriptions, set_project_design_system,
|
services — subscriptions, set_project_design_system, the standard Systems
|
||||||
the standard Systems seed — checks every target BEFORE touching anything,
|
seed — checks every target BEFORE touching anything,
|
||||||
applies the effects (each idempotent), and writes the record LAST, so a
|
applies the effects (each idempotent), and writes the record LAST, so a
|
||||||
half-applied decision is re-runnable rather than recorded as done.
|
half-applied decision is re-runnable rather than recorded as done.
|
||||||
``current_defaults`` is what the enter_project ask shows: what binds today
|
``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
|
from scribe.models.rulebook import Rulebook
|
||||||
|
|
||||||
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
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:
|
def _is_id_list(value) -> bool:
|
||||||
@@ -60,15 +64,9 @@ def validate_inception(choices) -> str | None:
|
|||||||
unknown = sorted(set(choices) - set(CHOICE_KEYS))
|
unknown = sorted(set(choices) - set(CHOICE_KEYS))
|
||||||
if unknown:
|
if unknown:
|
||||||
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
|
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 []
|
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):
|
if not _is_id_list(subs):
|
||||||
return "subscribe_rulebooks must be a list of rulebook ids"
|
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")
|
ds = choices.get("design_system_id")
|
||||||
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
|
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"
|
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:
|
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."""
|
and what the UI/agent reads back. Call after validate_inception."""
|
||||||
choices = choices or {}
|
choices = choices or {}
|
||||||
return {
|
return {
|
||||||
"exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
|
|
||||||
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
|
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
|
||||||
"design_system_id": choices.get("design_system_id"),
|
"design_system_id": choices.get("design_system_id"),
|
||||||
"seed_systems": bool(choices.get("seed_systems", False)),
|
"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:
|
async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||||
"""What the project inherits if nobody decides — the ask's payload.
|
"""What the project inherits if nobody decides — the ask's payload.
|
||||||
|
|
||||||
{always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
|
{rulebooks: [{id,title}], subscribed_rulebooks: [...],
|
||||||
excluded_always_on: [...], subscribed_rulebooks: [...],
|
|
||||||
design_system_id, design_systems: [{id,title}], systems: <count>}.
|
design_system_id, design_systems: [{id,title}], systems: <count>}.
|
||||||
Instance-agnostic: an install with no rulebooks / design systems shows
|
Instance-agnostic: an install with no rulebooks / design systems shows
|
||||||
empty lists, and the ask says so rather than inventing a default.
|
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:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
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))
|
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
|
||||||
.order_by(Rulebook.title)
|
.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)
|
designs = await design_systems_svc.list_design_systems(user_id)
|
||||||
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
||||||
return {
|
return {
|
||||||
"always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
|
# ONE list since milestone 394. This was split into always-on and
|
||||||
"other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
|
# "other" because the first bound the project whether it asked or not;
|
||||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
# 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", []),
|
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
|
||||||
"design_system_id": project.design_system_id,
|
"design_system_id": project.design_system_id,
|
||||||
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
"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."""
|
effect lands — a decision applies whole or errors whole."""
|
||||||
from scribe.services import access
|
from scribe.services import access
|
||||||
|
|
||||||
wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
|
wanted = set(choices["subscribe_rulebooks"])
|
||||||
if wanted:
|
if wanted:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Rulebook.id, Rulebook.always_on).where(
|
select(Rulebook.id).where(
|
||||||
Rulebook.id.in_(wanted),
|
Rulebook.id.in_(wanted),
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
Rulebook.deleted_at.is_(None),
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
found = {rid: on for rid, on in rows}
|
found = {rid for (rid,) in rows}
|
||||||
missing = sorted(wanted - set(found))
|
missing = sorted(wanted - found)
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
|
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"]
|
ds = choices["design_system_id"]
|
||||||
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
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)")
|
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).
|
"""Record a project's inception decision and apply it (milestone 297).
|
||||||
|
|
||||||
Owner-only. Validates the choices (pure) and every target (owned /
|
Owner-only. Validates the choices (pure) and every target (owned /
|
||||||
readable) first; then, each idempotent: exclude the named always-on
|
readable) first; then, each idempotent: subscribe the named rulebooks,
|
||||||
rulebooks, subscribe the named rulebooks, point the project at the design
|
point the project at the design system (None = explicitly none), seed the
|
||||||
system (None = explicitly none), seed the standard Systems if asked and
|
standard Systems if asked and the project has none; then write
|
||||||
the project has none; then write ``projects.inception`` LAST. Re-deciding
|
``projects.inception`` LAST. Re-deciding is additive for subscriptions
|
||||||
is additive for exclusions/subscriptions (nothing is silently dropped —
|
(nothing is silently dropped — unsubscribe is an explicit call), replaces
|
||||||
include/unsubscribe are explicit calls), replaces the design system, and
|
the design system, and re-seeds nothing a project already has.
|
||||||
re-seeds nothing a project already has.
|
|
||||||
|
|
||||||
Returns {"inception": <record>, "effects": {excluded, subscribed,
|
Returns {"inception": <record>, "effects": {excluded, subscribed,
|
||||||
design_system_id, systems_seeded}}.
|
design_system_id, systems_seeded}}.
|
||||||
@@ -203,8 +194,6 @@ async def decide(
|
|||||||
raise ValueError(f"project {project_id} not found (or not yours)")
|
raise ValueError(f"project {project_id} not found (or not yours)")
|
||||||
await _check_targets(user_id, choices)
|
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"]:
|
for rb in choices["subscribe_rulebooks"]:
|
||||||
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
|
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
|
||||||
if not await design_systems_svc.set_project_design_system(
|
if not await design_systems_svc.set_project_design_system(
|
||||||
@@ -230,7 +219,6 @@ async def decide(
|
|||||||
return {
|
return {
|
||||||
"inception": record,
|
"inception": record,
|
||||||
"effects": {
|
"effects": {
|
||||||
"excluded": choices["exclude_always_on_rulebooks"],
|
|
||||||
"subscribed": choices["subscribe_rulebooks"],
|
"subscribed": choices["subscribe_rulebooks"],
|
||||||
"design_system_id": choices["design_system_id"],
|
"design_system_id": choices["design_system_id"],
|
||||||
"systems_seeded": [sy.name for sy in seeded],
|
"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)
|
defaults = await current_defaults(user_id, project_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
|
books = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["rulebooks"]) or "none"
|
||||||
others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
|
|
||||||
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
|
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
|
||||||
return {
|
return {
|
||||||
"defaults": defaults,
|
"defaults": defaults,
|
||||||
"ask": (
|
"ask": (
|
||||||
"This project has no inception decision: nobody has said what it "
|
"This project has no inception decision: nobody has said what it "
|
||||||
f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
|
f"inherits. Rulebooks it could subscribe to — {books}; design system — "
|
||||||
f"rulebooks it could subscribe to — {others}; design system — "
|
|
||||||
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
||||||
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
||||||
"once: which always-on rulebooks to EXCLUDE here (default: none), which "
|
"once: which rulebooks to subscribe (default: none — a rulebook binds "
|
||||||
"rulebooks to subscribe, which design system (or none), and whether to seed "
|
"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 "
|
"the standard starter Systems — then record the answers. This ask repeats on "
|
||||||
"every enter_project until a decision is recorded."
|
"every enter_project until a decision is recorded."
|
||||||
),
|
),
|
||||||
"call": (
|
"call": (
|
||||||
f"decide_project_inception(project_id={project_id}, "
|
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>)"
|
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Design note — altitude: we inject rule *titles* grouped by topic (a compact
|
|||||||
index), NOT every rule's full statement. The 48 always-on statements run well
|
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
|
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
|
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
|
mostly self-describing ("`dev` is home", "No GitHub — Fabled-Git only"), so the
|
||||||
index alone already steers behavior.
|
index alone already steers behavior.
|
||||||
"""
|
"""
|
||||||
@@ -1400,7 +1400,6 @@ async def build_write_path_hint(
|
|||||||
repo_key: str = "",
|
repo_key: str = "",
|
||||||
exclude_derive: list[str] | None = None,
|
exclude_derive: list[str] | None = None,
|
||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
rules_etag: str = "",
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||||
|
|
||||||
@@ -1686,60 +1685,22 @@ async def build_write_path_hint(
|
|||||||
staleness: list[str] = []
|
staleness: list[str] = []
|
||||||
# ── Have the rules moved under this session? (milestone 323) ───────
|
# ── Have the rules moved under this session? (milestone 323) ───────
|
||||||
#
|
#
|
||||||
# THE CARRIER IS THE POINT. This hook already fires before a write — the
|
# THE RULES-ETAG STALENESS ARM IS GONE (milestone 394).
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# WHAT THIS CANNOT SEE, and a reader who finds an etag here will assume
|
# It took a marker the session had been given at SessionStart, compared it
|
||||||
# otherwise:
|
# 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?
|
# 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
|
||||||
# another session edits a rule mid-flight | yes
|
# needs it fetches it again. The staleness this arm reported was an
|
||||||
# the session is misremembering a rule read hours ago | yes
|
# artifact of the delivery model rather than a fact about the corpus, and
|
||||||
# compaction summarised the rules out of context | NO
|
# it goes with the model.
|
||||||
#
|
#
|
||||||
# The third is the most common and this is blind to it: the etag was in
|
# `staleness` survives as the list the arms below still append to.
|
||||||
# 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)
|
|
||||||
|
|
||||||
# The guard sits BELOW the staleness arm on purpose. A rules change is
|
# The guard sits BELOW the staleness arm on purpose. A rules change is
|
||||||
# unconditional news — it does not become less true because this
|
# unconditional news — it does not become less true because this
|
||||||
@@ -2208,67 +2169,37 @@ async def build_session_context(
|
|||||||
its normalized key — triggers a one-line "bind this repo" hint so
|
its normalized key — triggers a one-line "bind this repo" hint so
|
||||||
the binding is self-healing.
|
the binding is self-healing.
|
||||||
|
|
||||||
Returns {"context": str, "rule_count": int, "project": dict | None,
|
Returns {"context": str, "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
|
It carried `rule_count` and `rules_etag` until milestone 394, when the
|
||||||
whether these rules have moved since the session loaded them.
|
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
|
`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
|
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
||||||
through verbatim.
|
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] = [
|
lines: list[str] = [
|
||||||
"# Scribe — standing session context (auto-injected by the Scribe plugin)",
|
"# Scribe — standing session context (auto-injected by the Scribe plugin)",
|
||||||
"",
|
"",
|
||||||
"You are working with Scribe, the operator's self-hosted second brain. "
|
"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)`.",
|
|
||||||
"",
|
"",
|
||||||
"## 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
|
project_dict: dict | None = None
|
||||||
if project_id:
|
if project_id:
|
||||||
@@ -2336,14 +2267,10 @@ async def build_session_context(
|
|||||||
|
|
||||||
context = "\n".join(line for line in lines if line is not None)
|
context = "\n".join(line for line in lines if line is not None)
|
||||||
if len(context) > _MAX_CHARS:
|
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 {
|
return {
|
||||||
"context": context,
|
"context": context,
|
||||||
"rule_count": len(rules),
|
|
||||||
"project": project_dict,
|
"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),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -852,7 +852,7 @@ async def retrieval_summary(
|
|||||||
# something else.
|
# something else.
|
||||||
#
|
#
|
||||||
# `ambient` now carries the bulk deliveries — the SessionStart preload,
|
# `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
|
# 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
|
# 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
|
# always-on set impossible to judge: the largest rule surface in the
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
|
|||||||
because `enter_project` and the skill sync put records in front of the agent
|
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
|
without choosing them, and counting those as surfacings makes recency read as
|
||||||
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
|
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.
|
applicable set at once, chosen by nobody.
|
||||||
|
|
||||||
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
|
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:
|
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.
|
"""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
|
A PULL is somebody choosing to open one record. `enter_project` is NOT a
|
||||||
`enter_project` are NOT pulls — they are bulk resident loads that hand over
|
pull — they are bulk resident loads that hand over
|
||||||
every applicable rule at once, and counting them would swamp the signal
|
every applicable rule at once, and counting them would swamp the signal
|
||||||
with the very ambient delivery the ratio exists to distinguish from.
|
with the very ambient delivery the ratio exists to distinguish from.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from scribe.models.rule_version import RuleVersion
|
|||||||
# snapshots would bury the edits somebody is actually looking for.
|
# snapshots would bury the edits somebody is actually looking for.
|
||||||
SNAPSHOT_FIELDS = (
|
SNAPSHOT_FIELDS = (
|
||||||
"title", "statement", "why", "how_to_apply", "when_to_apply",
|
"title", "statement", "why", "how_to_apply", "when_to_apply",
|
||||||
"tier", "kind", "verify_with", "expires_when",
|
"kind", "verify_with", "expires_when",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from collections.abc import Iterable
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
|
from sqlalchemy import and_, delete as sql_delete, false as sa_false, insert, or_, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.system import System
|
from scribe.models.system import System
|
||||||
@@ -82,7 +82,7 @@ async def update_rulebook(
|
|||||||
rb = result.scalar_one_or_none()
|
rb = result.scalar_one_or_none()
|
||||||
if rb is None:
|
if rb is None:
|
||||||
return None
|
return None
|
||||||
allowed = {"title", "description", "always_on"}
|
allowed = {"title", "description"}
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if key in allowed and value is not None:
|
if key in allowed and value is not None:
|
||||||
setattr(rb, key, value)
|
setattr(rb, key, value)
|
||||||
@@ -293,7 +293,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
|
# 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
|
# a caller can be corrected before the database refuses it (rule 36 keeps the
|
||||||
# two in step; this keeps the error readable).
|
# two in step; this keeps the error readable).
|
||||||
TIERS = ("always_on", "conditional")
|
|
||||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||||
# Migration 0098's CHECK. `rule` binds; `preference` is how the operator
|
# 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.
|
# wants work done — see the model comment for why both live on one table.
|
||||||
@@ -311,21 +310,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:
|
def _valid_kind(kind: str) -> str:
|
||||||
"""An unrecognised kind falls back to `rule` — the SAFE direction.
|
"""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
|
of delivery. A preference wrongly treated as binding costs a little
|
||||||
friction: the reader is told something is required that was only
|
friction: the reader is told something is required that was only
|
||||||
preferred. A rule wrongly treated as a preference costs the thing the rule
|
preferred. A rule wrongly treated as a preference costs the thing the rule
|
||||||
@@ -369,7 +358,6 @@ def rule_brief(rule: Rule, **extra) -> dict:
|
|||||||
"title": rule.title,
|
"title": rule.title,
|
||||||
"statement": rule.statement,
|
"statement": rule.statement,
|
||||||
"topic_id": rule.topic_id,
|
"topic_id": rule.topic_id,
|
||||||
"tier": rule.tier,
|
|
||||||
# Unconditional, and the payload cost is accepted deliberately. Every
|
# Unconditional, and the payload cost is accepted deliberately. Every
|
||||||
# other optional key below is attached only when present, because an
|
# other optional key below is attached only when present, because an
|
||||||
# absent key should never read as a capability the record lacks. Force
|
# absent key should never read as a capability the record lacks. Force
|
||||||
@@ -503,7 +491,7 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N
|
|||||||
async def create_rule(
|
async def create_rule(
|
||||||
topic_id: int, user_id: int, title: str, statement: str,
|
topic_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
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",
|
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -513,7 +501,6 @@ async def create_rule(
|
|||||||
title=title,
|
title=title,
|
||||||
statement=statement,
|
statement=statement,
|
||||||
when_to_apply=when_to_apply or None,
|
when_to_apply=when_to_apply or None,
|
||||||
tier=_valid_tier(tier),
|
|
||||||
kind=_valid_kind(kind),
|
kind=_valid_kind(kind),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply or None,
|
how_to_apply=how_to_apply or None,
|
||||||
@@ -532,7 +519,7 @@ async def create_rule(
|
|||||||
async def create_project_rule(
|
async def create_project_rule(
|
||||||
project_id: int, user_id: int, title: str, statement: str,
|
project_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
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",
|
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||||
@@ -548,7 +535,6 @@ async def create_project_rule(
|
|||||||
title=title,
|
title=title,
|
||||||
statement=statement,
|
statement=statement,
|
||||||
when_to_apply=when_to_apply or None,
|
when_to_apply=when_to_apply or None,
|
||||||
tier=_valid_tier(tier),
|
|
||||||
kind=_valid_kind(kind),
|
kind=_valid_kind(kind),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply or None,
|
how_to_apply=how_to_apply or None,
|
||||||
@@ -632,89 +618,6 @@ async def list_rules(
|
|||||||
return rulebook_rules + list(proj_result.scalars().all())
|
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]:
|
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
|
"""Fetch a rule by id, scoped to user owning either its rulebook
|
||||||
(via topic) or its project (via project_id). Honors soft-delete.
|
(via topic) or its project (via project_id). Honors soft-delete.
|
||||||
@@ -780,7 +683,7 @@ async def update_rule(
|
|||||||
return None
|
return None
|
||||||
allowed = {
|
allowed = {
|
||||||
"title", "statement", "why", "how_to_apply", "order_index",
|
"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",
|
"verify_with", "expires_when",
|
||||||
}
|
}
|
||||||
check_before = rule.verify_with
|
check_before = rule.verify_with
|
||||||
@@ -796,9 +699,7 @@ async def update_rule(
|
|||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if key not in allowed or value is None:
|
if key not in allowed or value is None:
|
||||||
continue
|
continue
|
||||||
if key == "tier":
|
if key == "kind":
|
||||||
value = _valid_tier(value)
|
|
||||||
elif key == "kind":
|
|
||||||
value = _valid_kind(value)
|
value = _valid_kind(value)
|
||||||
elif key in NULLABLE_RULE_TEXT:
|
elif key in NULLABLE_RULE_TEXT:
|
||||||
value = value or None
|
value = value or None
|
||||||
@@ -808,9 +709,8 @@ async def update_rule(
|
|||||||
# A verification stamp certifies A CHECK, not a rule. Rewrite or
|
# A verification stamp certifies A CHECK, not a rule. Rewrite or
|
||||||
# remove the check and the old stamp certifies something that no
|
# remove the check and the old stamp certifies something that no
|
||||||
# longer exists — so it is dropped, and the rule re-enters the sweep.
|
# 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
|
# The safe direction: a rule wrongly listed as due costs one look, a
|
||||||
# always_on: a rule wrongly listed as due costs one look, a rule
|
# rule wrongly vouched for costs the thing the sweep exists to catch.
|
||||||
# wrongly vouched for costs the thing the sweep exists to catch.
|
|
||||||
if rule.verify_with != check_before:
|
if rule.verify_with != check_before:
|
||||||
rule.verified_at = None
|
rule.verified_at = None
|
||||||
# Same session as the edit, so the two commit together. The snapshot
|
# Same session as the edit, so the two commit together. The snapshot
|
||||||
@@ -1113,51 +1013,6 @@ async def unsuppress_rule_for_project(
|
|||||||
await session.commit()
|
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(
|
async def suppress_topic_for_project(
|
||||||
project_id: int, topic_id: int, user_id: int,
|
project_id: int, topic_id: int, user_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1326,7 +1181,6 @@ async def get_applicable_rules(
|
|||||||
Rulebook.deleted_at.is_(None),
|
Rulebook.deleted_at.is_(None),
|
||||||
# An inception exclusion is total (milestone 297): a rulebook the
|
# An inception exclusion is total (milestone 297): a rulebook the
|
||||||
# project opted out of contributes nothing, subscribed or not.
|
# project opted out of contributes nothing, subscribed or not.
|
||||||
Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
|
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
@@ -1337,11 +1191,10 @@ async def get_applicable_rules(
|
|||||||
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
||||||
if suppressed_topic_ids:
|
if suppressed_topic_ids:
|
||||||
rules_q = rules_q.where(Rule.topic_id.notin_(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
|
# AREA BINDING (milestone 307, narrowed by 394). A rule reaches this
|
||||||
# before tiers existed. A conditional rule is REACHABLE, and reaches
|
# project when it is tagged to an area the project actually works in —
|
||||||
# this project only when it is tagged to an area this project actually
|
# a deterministic tag match, never a similarity score, so bindingness
|
||||||
# works in — a deterministic tag match, never a similarity score, so
|
# never depends on a ranking (D7).
|
||||||
# bindingness never depends on a ranking (D7).
|
|
||||||
#
|
#
|
||||||
# Applied in SQL rather than by filtering afterwards, so `limit` counts
|
# Applied in SQL rather than by filtering afterwards, so `limit` counts
|
||||||
# the rules that will actually be surfaced instead of counting rules
|
# the rules that will actually be surfaced instead of counting rules
|
||||||
@@ -1357,10 +1210,21 @@ async def get_applicable_rules(
|
|||||||
reachable = select(rule_systems.c.rule_id).where(
|
reachable = select(rule_systems.c.rule_id).where(
|
||||||
rule_systems.c.canonical_id.in_(project_area_ids)
|
rule_systems.c.canonical_id.in_(project_area_ids)
|
||||||
) if project_area_ids else None
|
) if project_area_ids else None
|
||||||
tier_clause = (Rule.tier == "always_on")
|
# AREA REACHABILITY IS NOW THE WHOLE TEST (milestone 394). This read
|
||||||
if reachable is not None:
|
# `always_on OR reachable`, so a subscribed rulebook's resident rules
|
||||||
tier_clause = or_(tier_clause, Rule.id.in_(reachable))
|
# arrived here whatever the project did. The tier is gone, and
|
||||||
rules_q = rules_q.where(tier_clause)
|
# dropping its arm rather than the whole clause is the deliberate
|
||||||
|
# half: what survives is the DETERMINISTIC one — a rule binds this
|
||||||
|
# project because it is tagged to an area the project actually works
|
||||||
|
# in (D7), never because a similarity score cleared a bar.
|
||||||
|
#
|
||||||
|
# A project with no canonical-tagged Systems therefore gets no bulk
|
||||||
|
# rules here, and that is the reading rather than a gap: rules still
|
||||||
|
# reach it by retrieval, when something it is doing makes one
|
||||||
|
# relevant. Handing over every subscribed rule instead would make this
|
||||||
|
# payload BIGGER than the preload this milestone exists to remove.
|
||||||
|
rules_q = (rules_q.where(Rule.id.in_(reachable)) if reachable is not None
|
||||||
|
else rules_q.where(sa_false()))
|
||||||
rule_rows = (await session.execute(rules_q)).all()
|
rule_rows = (await session.execute(rules_q)).all()
|
||||||
truncated = len(rule_rows) > limit
|
truncated = len(rule_rows) > limit
|
||||||
rules = [
|
rules = [
|
||||||
@@ -1381,12 +1245,11 @@ async def get_applicable_rules(
|
|||||||
)
|
)
|
||||||
.order_by(Rule.order_index, Rule.title)
|
.order_by(Rule.order_index, Rule.title)
|
||||||
)
|
)
|
||||||
if reachable is not None:
|
# A PROJECT'S OWN RULES ARE NOT FILTERED BY AREA, and the asymmetry
|
||||||
proj_rules_q = proj_rules_q.where(
|
# with the family query above is the point. A family rule has to earn
|
||||||
or_(Rule.tier == "always_on", Rule.id.in_(reachable))
|
# 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
|
||||||
else:
|
# only fault is that nobody tagged them to a System.
|
||||||
proj_rules_q = proj_rules_q.where(Rule.tier == "always_on")
|
|
||||||
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
||||||
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
|
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
|
||||||
|
|
||||||
@@ -1422,7 +1285,6 @@ async def get_applicable_rules(
|
|||||||
"suppressed_topics": suppressed_topics,
|
"suppressed_topics": suppressed_topics,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"subscribed_rulebooks": subscribed_rulebooks,
|
"subscribed_rulebooks": subscribed_rulebooks,
|
||||||
"excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1434,9 +1296,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
|
same seven keys under the same names — so a reader learns them once. One
|
||||||
place renames `rules` → `applicable_rules` and `truncated` →
|
place renames `rules` → `applicable_rules` and `truncated` →
|
||||||
`applicable_rules_truncated`; the tools merge this into their payloads.
|
`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
|
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
|
source. Every one of those surfaces is a bulk delivery — the applicable set
|
||||||
@@ -1453,7 +1312,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`
|
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
|
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
|
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
|
anything, and counting those would put rules in the denominator that no
|
||||||
agent ever saw.
|
agent ever saw.
|
||||||
"""
|
"""
|
||||||
@@ -1472,7 +1331,6 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
|||||||
"project_rules": applicable.get("project_rules", []),
|
"project_rules": applicable.get("project_rules", []),
|
||||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1497,86 +1355,11 @@ def rules_payload(applicable: dict, *, user_id: int | None, source: str) -> dict
|
|||||||
_ETAG_EMPTY = "empty|0"
|
_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) ────────────────────────────────
|
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||||
|
|
||||||
async def rules_due_for_verification(
|
async def rules_due_for_verification(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
older_than_days: int = 0,
|
older_than_days: int = 0,
|
||||||
tier: str = "",
|
|
||||||
never_only: bool = False,
|
never_only: bool = False,
|
||||||
) -> list[Rule]:
|
) -> list[Rule]:
|
||||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||||
@@ -1605,20 +1388,12 @@ async def rules_due_for_verification(
|
|||||||
older_than_days: only rules last verified longer ago than this.
|
older_than_days: only rules last verified longer ago than this.
|
||||||
Never-checked rules always qualify — they are the most overdue
|
Never-checked rules always qualify — they are the most overdue
|
||||||
thing there is. 0 = no age filter.
|
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.
|
never_only: only rules that have never been verified.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from scribe.models.project import Project
|
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:
|
async with async_session() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Rule)
|
select(Rule)
|
||||||
@@ -1641,8 +1416,6 @@ async def rules_due_for_verification(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if tier:
|
|
||||||
stmt = stmt.where(Rule.tier == tier)
|
|
||||||
if never_only:
|
if never_only:
|
||||||
stmt = stmt.where(Rule.verified_at.is_(None))
|
stmt = stmt.where(Rule.verified_at.is_(None))
|
||||||
elif older_than_days > 0:
|
elif older_than_days > 0:
|
||||||
@@ -1668,7 +1441,6 @@ def verification_row(rule: Rule) -> dict:
|
|||||||
"id": rule.id,
|
"id": rule.id,
|
||||||
"title": rule.title,
|
"title": rule.title,
|
||||||
"statement": rule.statement,
|
"statement": rule.statement,
|
||||||
"tier": rule.tier,
|
|
||||||
"topic_id": rule.topic_id,
|
"topic_id": rule.topic_id,
|
||||||
"project_id": rule.project_id,
|
"project_id": rule.project_id,
|
||||||
"when_to_apply": rule.when_to_apply or "",
|
"when_to_apply": rule.when_to_apply or "",
|
||||||
|
|||||||
Reference in New Issue
Block a user