rename(547): what people read says Grimoire
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s

Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 10:46:37 -04:00
co-authored by Claude Opus 5.5
parent 06be537a18
commit e99f1169f7
142 changed files with 460 additions and 460 deletions
+2 -2
View File
@@ -33,12 +33,12 @@ class Config:
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Scribe")
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Grimoire")
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
# Git URL of the repo that ships this Scribe plugin. There is one correct
# Git URL of the repo that ships this Grimoire plugin. There is one correct
# value per deployment (the repo serving the plugin), so it's a config
# default — not something each user types. Surfaced in Settings → MCP Access
# as the default install-command marketplace; an admin can still override it
+1 -1
View File
@@ -1,4 +1,4 @@
"""In-app MCP server. Exposes Fabled Scribe data via the MCP streamable-HTTP transport.
"""In-app MCP server. Exposes Fabled Grimoire data via the MCP streamable-HTTP transport.
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
header resolves to a user, and every tool call acts on that user's data only.
+5 -5
View File
@@ -10,7 +10,7 @@ from quart import Quart
## What this block is — read before editing it
#
# ONE OWNER PER PIECE OF GUIDANCE. Scribe's guidance to agents lives where it
# ONE OWNER PER PIECE OF GUIDANCE. Grimoire's guidance to agents lives where it
# can be delivered, and each topic is stated in full exactly once:
# - Tool docstrings: each tool's contract, delivered with its schema.
# - In-band tool responses: behaviour prose cannot be trusted to trigger —
@@ -38,9 +38,9 @@ from quart import Quart
# sit past the fold. A block pressed against the cap is being used as a
# rulebook.
_INSTRUCTIONS = """
Scribe is the operator's system of record, and yours: recall before acting,
Grimoire is the operator's system of record, and yours: recall before acting,
record as you go, keep one copy here, not in local memory. Each
practice is stated in full in a skill or a tool's description.
practice is stated in full in a skill or a tool description.
- Start with enter_project(id): the project, open work, Systems, design
system. `inception`: ask what it inherits, then decide_project_inception.
@@ -63,7 +63,7 @@ practice is stated in full in a skill or a tool's description.
- In UI work the design system binds: resolve_design_system before
writing a value.
Creates are duplicate-gated: a near-match returns the existing id to update.
Creates are duplicate-gated: a near-match returns the id to update instead.
shared:true records are another user's suggestion, not settled practice.
"""
@@ -337,7 +337,7 @@ class StrictArgsMCPServer(MCPServer):
try:
return await super().call_tool(name, arguments, context)
except UnexpectedToolError as exc:
# ValueError is how every Scribe tool refuses: what was refused,
# ValueError is how every Grimoire tool refuses: what was refused,
# why, and what to do instead, written for the agent. SDK 2.x
# masks it like a crash, which would leave the agent retrying
# blind. Other exceptions ARE crashes; their text can carry
+1 -1
View File
@@ -408,7 +408,7 @@ async def judge_lesson_link(
"""Say whether a lesson is an instance of a rule: `verdict` is "confirm"
or "reject".
Reach for this when Scribe proposes a pair — a lesson and a rule that keep
Reach for this when Grimoire proposes a pair — a lesson and a rule that keep
arriving together in different situations — or whenever you are reading a
lesson and recognise the rule it falls under. Confirming makes the rule
reachable through the situation the lesson describes; rejecting records
+3 -3
View File
@@ -24,7 +24,7 @@ from scribe.services import moment_delivery
async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index.
"""List milestones for a Grimoire project, ordered by order_index.
Returns every milestone, done ones included: id, title, description,
status (active/done), order_index, progress (total, completed, pct,
@@ -98,7 +98,7 @@ async def create_milestone(
status: str = "active",
force: bool = False,
) -> dict:
"""Create a milestone within a Scribe project.
"""Create a milestone within a Grimoire project.
A milestone can serve as a plan container — put the design/intent in `body`
and track each step as a child task (create_task(milestone_id=...)). For a
@@ -148,7 +148,7 @@ async def update_milestone(
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Scribe milestone. Only explicitly provided fields are changed.
"""Update a Grimoire milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to (preserved for API parity;
+5 -5
View File
@@ -34,7 +34,7 @@ async def list_notes(
search_text: str = "",
project_id: int = 0,
) -> dict:
"""List notes (non-task documents) stored in Scribe.
"""List notes (non-task documents) stored in Grimoire.
Optionally filter by a single tag (plain string, no # prefix) or by
`search_text`, which matches on MEANING — the same semantic match every
@@ -70,7 +70,7 @@ async def list_notes(
async def get_note(note_id: int, project_id: int = 0) -> dict:
"""Fetch the full content of a single Scribe note by its ID.
"""Fetch the full content of a single Grimoire note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at,
updated_at — plus `systems` (the areas this note is filed under) or, for
@@ -120,7 +120,7 @@ async def create_note(
expires_when: str = "",
force: bool = False,
) -> dict:
"""Create a new note in Scribe.
"""Create a new note in Grimoire.
WHAT ELSE COULD HOLD THIS? A note is the right home when the answer is
"nothing": it records what you know, nobody owes anything on it, and
@@ -259,7 +259,7 @@ async def update_note(
expires_when: str = "",
clear: list[str] | None = None,
) -> dict:
"""Update an existing Scribe note. Only explicitly provided fields are changed.
"""Update an existing Grimoire note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
@@ -376,7 +376,7 @@ async def find_duplicate_records(kind: str = "note", threshold: float = 0.0) ->
async def delete_note(note_id: int) -> dict:
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
"""Move a Grimoire note to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
# confirmation could only echo the number back. A deletion the operator
+4 -4
View File
@@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
async def list_projects() -> dict:
"""List all Scribe projects for the current user.
"""List all Grimoire projects for the current user.
Returns id, title, description, goal, status (active/paused/completed/archived), color,
and a short auto-generated summary for each project.
@@ -329,7 +329,7 @@ async def enter_project(project_id: int) -> dict:
async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID.
"""Fetch a Grimoire project by ID.
Returns full project fields, a milestone_summary list (every milestone,
with description, progress and `next_step` — the earliest still-open step,
@@ -382,7 +382,7 @@ async def create_project(
seed_systems: bool | None = None,
platforms: list[str] | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
"""Create a new project in Grimoire — and decide what it inherits.
A project's inheritance is a decision, not a default:
before calling, ask the operator the three inception questions and pass
@@ -463,7 +463,7 @@ async def update_project(
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Scribe project. Only explicitly provided fields are changed.
"""Update an existing Grimoire project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
+3 -3
View File
@@ -1,6 +1,6 @@
"""Repo -> project binding MCP tools.
Let the operator map the working git repository to a Scribe project so the
Let the operator map the working git repository to a Grimoire project so the
SessionStart hook auto-loads that project's context — without pinning a project
id in plugin config (which would force a single project for every repo). Run
`bind_repo` once per repo; the binding is keyed on the normalized git remote, so
@@ -14,7 +14,7 @@ from scribe.services import repo_bindings as repo_bindings_svc
async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
"""Bind a git repository to a Scribe project for session-start context.
"""Bind a git repository to a Grimoire project for session-start context.
After this, any session started in that repo auto-loads the project's
context (the SessionStart hook sends the repo's remote; the server resolves
@@ -30,7 +30,7 @@ async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
Args:
repo_url: the repo's git remote (e.g. the output of
`git remote get-url origin` — ssh or https form, both work).
project_id: the Scribe project this repo represents.
project_id: the Grimoire project this repo represents.
ref: branch the ledger follows ("" = leave as is / default branch on
a new binding; "-" = clear to the default branch).
"""
+2 -2
View File
@@ -10,7 +10,7 @@ from scribe.services import retrieval_tuning as tuning_svc
async def retrieval_surfaces() -> dict:
"""Every retrieval surface Scribe pushes on, with its floor and its budget.
"""Every retrieval surface Grimoire pushes on, with its floor and its budget.
Read this before changing either, and read it beside
`retrieval_telemetry(days=…, near_miss_samples=5)` — this tool says what is
@@ -55,7 +55,7 @@ async def retrieval_surfaces() -> dict:
- `stale: true` with `shape_changed` — the documents are cut differently.
The scale still holds, but what a record embeds has changed, so which
records clear a bar has.
- `stale: null`, `source: "unstamped"` — the dial was moved before Scribe
- `stale: null`, `source: "unstamped"` — the dial was moved before Grimoire
recorded this. It was measured under SOMETHING and there is no way to say
what, which is a different answer from "it is fine". Treat it as worth
re-measuring, and the next tuning call stamps it.
+1 -1
View File
@@ -1,4 +1,4 @@
"""MCP tools for the Scribe Rulebook system.
"""MCP tools for the Grimoire Rulebook system.
Rulebook / topic / rule CRUD and the rule-to-rule
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
+2 -2
View File
@@ -213,13 +213,13 @@ async def search(
project_id: int = 0,
system_id: int = 0,
) -> dict:
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
"""Semantic search over the user's existing notes and tasks — Grimoire's recall.
Reach for this BEFORE answering a question about the user's work or starting
a task: the user's second-brain almost always already holds related prior
art. Check for an existing ticket before opening a new one (search with
content_type='task'), and for prior notes/decisions before re-deriving them.
Treating Scribe as the first place to look — not a place to only write — is
Treating Grimoire as the first place to look — not a place to only write — is
the difference between it being a trustworthy record and a write-only log.
Args:
+2 -2
View File
@@ -51,7 +51,7 @@ async def list_snippets(
usually what you want, since a helper you need here may well have
been written somewhere else.
repo: Narrow to snippets recorded in this repo, matched exactly — the
same repo string used when recording, e.g. "Scribe".
same repo string used when recording, e.g. "Grimoire".
path: Narrow to snippets recorded at this path. Matches the exact file
OR anything beneath it, so "frontend/src" finds
"frontend/src/lib/x.ts" as well as itself.
@@ -386,7 +386,7 @@ async def verify_snippet(
) -> dict:
"""Record whether a snippet's recorded location and code still match source.
YOU do the checking — Scribe has no copy of the repo and deliberately never
YOU do the checking — Grimoire has no copy of the repo and deliberately never
gets one. This tool only remembers your verdict so it becomes queryable and
so the operator can see what has rotted.
+7 -7
View File
@@ -112,7 +112,7 @@ async def list_tasks(
project_id: int = 0,
kind: str = "",
) -> dict:
"""List tasks in Scribe.
"""List tasks in Grimoire.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
@@ -160,7 +160,7 @@ async def get_task(
log_limit: int = _WORK_LOG_ENTRIES,
log_chars: int = _WORK_LOG_CHARS,
) -> dict:
"""Fetch a single Scribe task by ID.
"""Fetch a single Grimoire task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at — plus `systems`
@@ -260,7 +260,7 @@ async def create_task(
arose_from_id: int = 0,
force: bool = False,
) -> dict:
"""Create a new task in Scribe.
"""Create a new task in Grimoire.
IS ANYTHING ACTUALLY OWED? A task carries a status and someone is on the
hook to move it. If nothing is owed — you are recording what you learned,
@@ -375,7 +375,7 @@ async def update_task(
arose_from_id: int = 0,
kind: str = "",
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
"""Update an existing Grimoire task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
@@ -469,7 +469,7 @@ async def update_task(
async def add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Scribe task.
"""Append a timestamped progress log entry to a Grimoire task.
Use this to record work sessions, decisions, or status updates over time
without overwriting the task's main body. Each entry is stored separately
@@ -650,7 +650,7 @@ async def start_planning(
steps: list[dict] | None = None,
force: bool = False,
) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
"""Begin a plan in Grimoire (the preferred home for plans — not a local .md file).
Reach for this when the work has an ARC — several steps toward one goal,
worth tracking as a unit. Work without one (a fix, a one-file change, a
@@ -732,7 +732,7 @@ async def start_planning(
async def delete_task(task_id: int) -> dict:
"""Move a Scribe task (or plan) to the trash (recoverable). Sub-tasks go with it.
"""Move a Grimoire task (or plan) to the trash (recoverable). Sub-tasks go with it.
Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
+1 -1
View File
@@ -6,7 +6,7 @@ from scribe.models.base import TimestampMixin, iso
class RepoBinding(Base, TimestampMixin):
"""Maps a git repository to the Scribe project it represents.
"""Maps a git repository to the Grimoire project it represents.
The SessionStart hook sends the working repo's normalized remote
(`repo_key`, e.g. ``git.fabledsword.com/bvandeusen/fabledscribe``) and the
+7 -7
View File
@@ -1,9 +1,9 @@
"""Restore a Scribe backup into an empty instance, from the command line.
"""Restore a Grimoire backup into an empty instance, from the command line.
python -m scribe.restore /backups/scribe-20261015T020000Z.tar
This is how a fresh instance is brought back after losing the old one. Run it
inside the Scribe container, which already has the database settings and has
inside the Grimoire container, which already has the database settings and has
already migrated the schema on start. It reads either the scheduled backup's
tar (services/backup_file.py) or the JSON the admin download produces.
@@ -16,7 +16,7 @@ purpose and with its own warning.)
Unlike the upload, nothing limits the file's size here, so this is also the
way in for a backup too big for the web upload.
Afterwards, restart Scribe: search indexes are not part of a backup and are
Afterwards, restart Grimoire: search indexes are not part of a backup and are
rebuilt from the restored records at startup.
"""
from __future__ import annotations
@@ -60,7 +60,7 @@ async def run(path: Path) -> int:
print(f"There is no file at {path}.", file=sys.stderr)
return 2
except (ArchiveError, ValueError) as exc:
print(f"{path} isn't a backup this Scribe can read: {exc}", file=sys.stderr)
print(f"{path} isn't a backup this Grimoire can read: {exc}", file=sys.stderr)
return 2
accounts = await _account_count()
@@ -68,14 +68,14 @@ async def run(path: Path) -> int:
print(
f"This database already holds {accounts} account(s). A restore adds a second "
"copy of everything rather than replacing it, so it only runs into an empty "
"instance. Start Scribe on an empty database and run this again.",
"instance. Start Grimoire on an empty database and run this again.",
file=sys.stderr,
)
return 3
stats = await restore_full_backup(payload)
print(json.dumps({"restored": stats}, indent=2, default=str))
print("Restored. Restart Scribe so the search indexes are rebuilt.", file=sys.stderr)
print("Restored. Restart Grimoire so the search indexes are rebuilt.", file=sys.stderr)
return 0
finally:
await engine.dispose()
@@ -84,7 +84,7 @@ async def run(path: Path) -> int:
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m scribe.restore",
description="Restore a Scribe backup (.tar or .json) into an empty instance.",
description="Restore a Grimoire backup (.tar or .json) into an empty instance.",
)
parser.add_argument("file", type=Path, help="the backup file")
args = parser.parse_args(argv)
+1 -1
View File
@@ -73,7 +73,7 @@ async def restore():
try:
data = read_archive(io.BytesIO(await request.get_data()))
except ArchiveError as exc:
return jsonify({"error": f"That file isn't a Scribe backup: {exc}"}), 400
return jsonify({"error": f"That file isn't a Grimoire backup: {exc}"}), 400
if not data:
return jsonify({"error": "No backup data provided"}), 400
+4 -4
View File
@@ -1,8 +1,8 @@
"""Scribe-plugin support endpoints.
"""Grimoire-plugin support endpoints.
Consumed by the Scribe Claude Code plugin (not the web UI). `GET /api/plugin/
Consumed by the Grimoire Claude Code plugin (not the web UI). `GET /api/plugin/
context` is curled by the plugin's SessionStart hook to push the operator's
always-on rules + active-project context into the session — Scribe's own push
always-on rules + active-project context into the session — Grimoire's own push
channel. Auth is the standard `@login_required` path, which accepts a `Bearer
fmcp_<key>` API key (a read-scoped key suffices for this GET).
"""
@@ -27,7 +27,7 @@ from scribe.services.settings import get_admin_setting, set_setting
plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
# Setting key for the git URL of the marketplace that serves this plugin (the
# Scribe app's own repo). Instance-global (stored on an admin account) so every
# Grimoire app's own repo). Instance-global (stored on an admin account) so every
# user's Settings page shows the real, copyable install command.
_MARKETPLACE_KEY = "plugin_marketplace_url"
+1 -1
View File
@@ -1,6 +1,6 @@
"""Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: get_current_user_id() is the
Wraps services/rulebooks.py. Standard Grimoire auth: get_current_user_id() is the
authenticated owner; the service enforces ownership scoping.
"""
from __future__ import annotations
+1 -1
View File
@@ -275,7 +275,7 @@ async def verify_snippet_route(snippet_id: int):
"""Record a drift-check verdict. Body: {"status": ..., "detail", "path"}.
The CHECK itself runs wherever the code is — an agent with the working tree
— because Scribe has no checkout and shouldn't have one. This endpoint just
— because Grimoire has no checkout and shouldn't have one. This endpoint just
stores what was found. It's here for parity with the MCP tool and so the UI
can clear a stale marker after the operator fixes a record by hand."""
uid = get_current_user_id()
+6 -6
View File
@@ -4,10 +4,10 @@ Two kinds of destination, both instance-wide and set by an admin:
- **folder**: a directory inside the container, usually a mounted share. A
backup service that watches the share (or a person) takes it from there.
- **s3**: any S3-compatible bucket, addressed by path. Scribe only puts and
- **s3**: any S3-compatible bucket, addressed by path. Grimoire only puts and
lists, so an append-only key is enough.
Either way Scribe ADDS one dated file per run and never removes one: keeping
Either way Grimoire ADDS one dated file per run and never removes one: keeping
and thinning the history is the destination's job (services/backup_file.py
says why the file is shaped the way it is for that).
@@ -201,7 +201,7 @@ async def deliver(
backup_file.write_archive, payload, folder, at, app_version=app_version,
)
except PermissionError:
raise DestinationError(f"Scribe isn't allowed to write in {folder}.") from None
raise DestinationError(f"Grimoire isn't allowed to write in {folder}.") from None
return Delivered(path.name, path.stat().st_size, str(folder))
target = s3_target(config, secret or "")
@@ -240,18 +240,18 @@ async def check_destination(
await asyncio.to_thread(probe.write_bytes, b"")
await asyncio.to_thread(probe.unlink)
except PermissionError:
raise DestinationError(f"Scribe isn't allowed to write in {folder}.") from None
raise DestinationError(f"Grimoire isn't allowed to write in {folder}.") from None
except OSError as exc:
raise DestinationError(f"Writing in {folder} failed: {exc.strerror or exc}.") from None
count = await asyncio.to_thread(_count_backups, folder)
return f"{folder} is writable and holds {count} Scribe backup(s)."
return f"{folder} is writable and holds {count} Grimoire backup(s)."
target = s3_target(config, secret or "")
try:
objects = await list_objects(target, backup_file.NAME_PREFIX, transport=transport)
except S3Error as exc:
raise DestinationError(str(exc)) from None
return f"Bucket {config.s3_bucket} answered and holds {len(objects)} Scribe backup(s)."
return f"Bucket {config.s3_bucket} answered and holds {len(objects)} Grimoire backup(s)."
def _count_backups(folder: Path) -> int:
+2 -2
View File
@@ -184,11 +184,11 @@ def read_archive(fileobj: IO[bytes]) -> dict:
with tar:
manifest = json.loads(_read_member(tar, MANIFEST_NAME))
if manifest.get("format") != ARCHIVE_FORMAT:
raise ArchiveError("not a Scribe backup archive (no Scribe manifest)")
raise ArchiveError("not a Grimoire backup archive (no Grimoire manifest)")
if manifest.get("format_version", 0) > ARCHIVE_FORMAT_VERSION:
raise ArchiveError(
f"archive format {manifest['format_version']} is newer than "
f"this Scribe reads ({ARCHIVE_FORMAT_VERSION}); upgrade first"
f"this Grimoire reads ({ARCHIVE_FORMAT_VERSION}); upgrade first"
)
payload = dict(manifest.get("header") or {})
for name, count in (manifest.get("sections") or {}).items():
+1 -1
View File
@@ -4,7 +4,7 @@ path-style addressing (endpoint/bucket/key), and the two calls a backup needs.
The signing is checked against AWS's published SigV4 examples in
tests/test_services_backup_s3.py.
Scribe puts and lists; it never deletes. The bucket keeps the history and
Grimoire puts and lists; it never deletes. The bucket keeps the history and
thins it on its own policy, so a key that may only add objects is enough —
and is the safer key to hand a service, because a stolen one cannot wipe the
history it writes into.
+1 -1
View File
@@ -1,6 +1,6 @@
"""Lightweight diagnostic instrumentation for crash investigation.
The Scribe app + its Postgres have been crashing recurrently with no
The Grimoire app + its Postgres have been crashing recurrently with no
clear cause in the logs. This module adds three things designed to make
the crash class identifiable from logs alone:
+6 -6
View File
@@ -38,7 +38,7 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Scribe template."""
"""Wrap email body content in the standard Fabled Grimoire template."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -56,7 +56,7 @@ def _email_html(title: str, body: str) -> str:
<!-- Header -->
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Scribe</span>
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Grimoire</span>
</div>
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div>
@@ -68,7 +68,7 @@ def _email_html(title: str, body: str) -> str:
<!-- Footer -->
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Scribe instance.</p>
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Grimoire instance.</p>
</div>
</div>
@@ -146,7 +146,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
username = config.get("smtp_username", "")
password = config.get("smtp_password", "")
from_address = config.get("smtp_from_address", "")
from_name = config.get("smtp_from_name", "Fabled Scribe")
from_name = config.get("smtp_from_name", "Fabled Grimoire")
use_tls = config.get("smtp_use_tls", "true") == "true"
msg = EmailMessage()
@@ -176,6 +176,6 @@ async def send_test_email(to: str) -> None:
"""Send a branded test email."""
body = """
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Scribe instance can send email notifications.</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Grimoire instance can send email notifications.</p>
"""
await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body))
await send_email(to, "Fabled Grimoire - Test Email", _email_html("Test Email", body))
+2 -2
View File
@@ -1,4 +1,4 @@
"""Knowledge service — one query across every record kind Scribe holds.
"""Knowledge service — one query across every record kind Grimoire holds.
ACL (services/access.py): these queries honour shares, so a record shared with
you can be *found* as well as opened by id — at two different widths:
@@ -77,7 +77,7 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool:
if all(
_path_matches((loc.get(key) or "").strip(), want)
if key == "path"
# Repo names are recorded free-form ("Scribe" / "FabledScribe" /
# Repo names are recorded free-form ("Grimoire" / "FabledScribe" /
# "fabledscribe") — case is never the distinguishing thing.
else (loc.get(key) or "").strip().lower() == want.lower()
if key == "repo"
+2 -2
View File
@@ -23,7 +23,7 @@ install called its server is not something a mapping should depend on.
WHY THE DEFAULTS ARE CODE AND THE CORRECTIONS ARE ROWS
The defaults cover the actions every install shares — the harness's own tools,
Scribe's own tools, the commonest command shapes. They ship, and improve, with
Grimoire's own tools, the commonest command shapes. They ship, and improve, with
the product. What no default can know is how one operator works, so an install
ADDS mappings and REMOVES defaults that misfire for it, in-session, through
`map_action` / `unmap_action`. A removal is stored rather than applied to the
@@ -124,7 +124,7 @@ def _defaults() -> tuple[Action, ...]:
for moment in reached:
on(moment, "Skill", f"{SKILL_FIELD}={BUNDLED_SKILL_PLUGIN}:{skill}")
# Scribe's own tools — every install has these, so their moments ship.
# Grimoire's own tools — every install has these, so their moments ship.
on("work.start", "update_task", "status=in_progress")
on("work.finish", "update_task", "status=done", "status=cancelled")
on("work.finish", "update_milestone", "status=done")
+1 -1
View File
@@ -5,7 +5,7 @@ differ only in how they carry the answer:
- the plugin's catch-all PreToolUse hook → `/api/plugin/moment`, with the
session ledger, so a rule already named this session is cited not repeated;
- Scribe's own MCP tools → `attach_moment_rules`, in the tool's response, so
- Grimoire's own MCP tools → `attach_moment_rules`, in the tool's response, so
a client without the plugin still gets `work.finish` when a task closes;
- the Stop hook → `deliver_moments`, for the reply moments no tool call marks.
+1 -1
View File
@@ -24,7 +24,7 @@ moment is generic; the action that reaches it is local.
HOW TO WRITE ONE
Domain-neutral: Scribe's work may be software, configuration,
Domain-neutral: Grimoire's work may be software, configuration,
infrastructure or anything else a person drives through an agent, so a
definition names the moment in words every one of those recognises. The test
beside this module refuses software-only vocabulary in both fields. Concrete
+6 -6
View File
@@ -88,7 +88,7 @@ async def notify_security_event(
</table>
<p style="margin:16px 0 0;color:#9ca3af;font-size:12px;">If this wasn't you, change your password immediately.</p>
"""
await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body))
await send_email(email, f"Fabled Grimoire - {label}", _email_html("Security Alert", body))
except Exception:
logger.exception("Failed to send security notification for user %d", user_id)
@@ -110,7 +110,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
"""
await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body))
await send_email(email, "Fabled Grimoire - Password Reset", _email_html("Password Reset", body))
async def send_password_reset_success_email(email: str) -> None:
@@ -122,14 +122,14 @@ async def send_password_reset_success_email(email: str) -> None:
</p>
<p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
"""
await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body))
await send_email(email, "Fabled Grimoire - Password Changed", _email_html("Password Changed", body))
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
"""Send a branded invitation email with a registration link."""
body = f"""
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
<strong>{invited_by_username}</strong> has invited you to join Fabled Scribe.
<strong>{invited_by_username}</strong> has invited you to join Fabled Grimoire.
Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
@@ -143,7 +143,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
"""
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
await send_email(email, "Fabled Grimoire - You're Invited!", _email_html("You're Invited!", body))
def utc_day_start(day: date) -> datetime:
@@ -239,7 +239,7 @@ async def check_due_tasks() -> None:
{task_rows}
</table>
"""
await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await send_email(email, f"Fabled Grimoire - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await log_audit(
"task_reminder",
user_id=user_id,
+16 -16
View File
@@ -1,7 +1,7 @@
"""Session-context rendering for the Scribe plugin's SessionStart hook.
"""Session-context rendering for the Grimoire plugin's SessionStart hook.
The plugin's hook curls `GET /api/plugin/context` at session start and injects
the returned text as `additionalContext`, giving Scribe a push channel into
the returned text as `additionalContext`, giving Grimoire a push channel into
the session. This module renders that text.
Design note — altitude: we inject rule *titles* grouped by topic (a compact
@@ -166,7 +166,7 @@ WRITEPATH_DEFAULT_THRESHOLD = SURFACES["write_path"].floor_default
# install writes its rules — so the default errs deliberately toward SILENCE
# rather than toward recall, on an asymmetry that is itself structural: this
# hint fires on EVERY write. A missed rule is recoverable, because the rule is
# still in Scribe and the agent can search it. A hint that cries wolf is not:
# still in Grimoire and the agent can search it. A hint that cries wolf is not:
# it teaches the reader to skip the whole block, and the surface is lost along
# with the true positives it would have carried.
#
@@ -515,7 +515,7 @@ async def build_process_manifest(user_id: int) -> dict:
(note_type='process'). Instance-agnostic: derived from whatever Processes the
calling install owns, no operator-specific coupling.
SCOPE: this is the most consequential passive surface Scribe has — every
SCOPE: this is the most consequential passive surface Grimoire has — every
entry becomes a skill file on the operator's machine that auto-surfaces and
is followed as written. It therefore uses the BROWSE scope (via the
no-query knowledge list): a Process shared directly with the operator is
@@ -548,7 +548,7 @@ async def build_process_manifest(user_id: int) -> dict:
if it.get("shared"):
owner = it.get("owner") or "another user"
description = (
f'A shared Scribe process "{title}", authored by {owner} — NOT the'
f'A shared Grimoire process "{title}", authored by {owner} — NOT the'
f" operator's own."
+ (f" {preview}" if preview else "")
+ f' Use only when the operator asks to run the "{title}" process'
@@ -559,7 +559,7 @@ async def build_process_manifest(user_id: int) -> dict:
)
else:
description = (
f'Run the operator\'s saved Scribe process "{title}".'
f'Run the operator\'s saved Grimoire process "{title}".'
+ (f" {preview}" if preview else "")
+ f' Use when the operator asks to run the "{title}" process by'
f" name. If a request merely RESEMBLES this process, the live"
@@ -582,7 +582,7 @@ async def build_process_manifest(user_id: int) -> dict:
entry["owner"] = it.get("owner")
procs.append(entry)
# The most consequential passive surface Scribe has (see SCOPE above): a
# The most consequential passive surface Grimoire has (see SCOPE above): a
# Process installed as a skill and never once opened must be
# distinguishable from one never installed. The honest event is "installed on the operator's
# machine", which is a surfacing in effect: the skill description is in
@@ -738,7 +738,7 @@ async def build_autoinject_hint(
# to explain itself.
if kept:
lines.append(
"> Possibly relevant from your Scribe records — open any in full with "
"> Possibly relevant from your Grimoire records — open any in full with "
"`get_note(id)`, or `get_snippet` / `get_process` / `get_lesson` for "
"those kinds. Each line is a record's name, its kind and System, and "
"the passage that matched; a line marked `seen` is a pointer to one "
@@ -1269,7 +1269,7 @@ async def build_write_path_hint(
Note the repo↔project mapping is deliberately one-way: the hook sends a git
remote, which the ROUTE resolves to `project_id` through the repo bindings.
It is never used as the location `repo` filter — a snippet's `repo` is a
free-text label the operator typed ("Scribe"), not a remote URL, and matching
free-text label the operator typed ("Grimoire"), not a remote URL, and matching
one against the other would silently return nothing.
Returns {"context": str, "note_ids": list[int], "sync_note_ids": list[int],
@@ -1559,7 +1559,7 @@ async def build_write_path_hint(
# title being right there, but the load-bearing sentence is the one no
# other surface says: keeping the record true is part of THIS edit.
lines.append(
f"> Recorded in Scribe AT `{path}` — the snippet(s) below record "
f"> Recorded in Grimoire AT `{path}` — the snippet(s) below record "
"the file this edit is changing. Reuse/extend the recorded shape "
"rather than writing a parallel one; and if this edit changes what "
"a record captures, updating it is part of the edit: "
@@ -1573,7 +1573,7 @@ async def build_write_path_hint(
if menu:
lines.append(
f"> Prior art already recorded in Scribe for `{path}` — open one with "
f"> Prior art already recorded in Grimoire for `{path}` — open one with "
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
"`get_lesson(id)` for a lesson, `get_note(id)` otherwise. Reuse a "
"snippet rather than writing a fresh one-off; read an issue before "
@@ -2073,10 +2073,10 @@ async def build_session_context(
LIVE STATE ONLY. It says only what the server alone knows about THIS
session: the
active project, its open work, its design system, or that the working repo
is unbound. How to work with Scribe is the skill's to say, and it says it
is unbound. How to work with Grimoire is the skill's to say, and it says it
once.
"""
lines: list[str] = ["# Scribe — live session state"]
lines: list[str] = ["# Grimoire — live session state"]
project_dict: dict | None = None
if project_id:
@@ -2143,7 +2143,7 @@ async def build_session_context(
"",
f"## Project {project_id} could not be loaded",
f"This session asked for project {project_id}, but this account "
"cannot read it — the id may belong to a different Scribe "
"cannot read it — the id may belong to a different Grimoire "
"instance, or the project may have been deleted. "
"`list_projects` shows what is readable here.",
]
@@ -2151,14 +2151,14 @@ async def build_session_context(
lines += [
"",
"## Repository not yet bound",
f"This repo (`{unbound_repo}`) isn't mapped to a Scribe project, so "
f"This repo (`{unbound_repo}`) isn't mapped to a Grimoire project, so "
"no project context was loaded. Bind it once with "
f'`bind_repo(repo_url="{unbound_repo}", project_id=<id>)` '
"(call `list_projects` to find the id) and future sessions here will "
"auto-load that project's context.",
]
else:
lines += ["", "No Scribe project is bound to this working directory."]
lines += ["", "No Grimoire project is bound to this working directory."]
context = "\n".join(line for line in lines if line is not None)
if len(context) > _MAX_CHARS:
+3 -3
View File
@@ -25,7 +25,7 @@ HOW A GUESS IS RECOGNISED
A guess is always just ABOVE the highest id that exists — that is the only
place "the next id" can be. So a `#N` is refused when it sits in
(max_id, max_id + GUESS_WINDOW]. Numbers below the max are real records (or
were), and numbers far above it are not Scribe ids at all — a PR, a forge
were), and numbers far above it are not Grimoire ids at all — a PR, a forge
issue — so both pass. What this cannot catch, by construction: a guess that
another session has ALREADY used. That reference points at a real record, and
nothing in the text distinguishes it from a deliberate one. The placeholders
@@ -107,7 +107,7 @@ async def refuse_guessed_ids(*texts: str | None) -> None:
"records first and cite the ids they come back with, or create them "
"together with create_records / start_planning(steps=...) and write "
"{{ref:N}} where the Nth record's id belongs. If this number is not a "
"Scribe record (a PR, an issue elsewhere), write it without the '#'."
"Grimoire record (a PR, an issue elsewhere), write it without the '#'."
)
@@ -157,7 +157,7 @@ _NAMED_RE = re.compile(
)
# Words after which `#N` belongs to a different numbering: a forge's (a PR, a
# CI run, a job) or one of Scribe's own id spaces that is not a note's. Rules,
# CI run, a job) or one of Grimoire's own id spaces that is not a note's. Rules,
# milestones, Systems, projects and work-logs each draw from their own
# sequence, so "rule #153" is not note 153 — and note 153 is somebody's record.
_OTHER_SPACE = frozenset({
+2 -2
View File
@@ -143,7 +143,7 @@ async def is_bound(project_id: int, raw_repo: str) -> str:
async def keys_for_project(user_id: int, project_id: int) -> list[str]:
"""Every repo key bound to a project — the snippet→forge join.
Recorded snippet locations carry free-form repo names ("Scribe"), which
Recorded snippet locations carry free-form repo names ("Grimoire"), which
can't address a forge API. The project's binding is the identity that can:
a snippet reaches its forge repo through the project it belongs to.
"""
@@ -160,7 +160,7 @@ async def keys_for_project(user_id: int, project_id: int) -> list[str]:
async def bindings_for_key(raw_repo: str) -> list[RepoBinding]:
"""All bindings (ANY user) for a repo key — the webhook's entry point.
A push webhook carries no Scribe caller, only the repository it happened
A push webhook carries no Grimoire caller, only the repository it happened
to; the flag it writes is about each record's truth, so every user who
bound the repo gets their project's snippets considered — each write still
lands as that record's owner.
+1 -1
View File
@@ -69,7 +69,7 @@ def block_reason(missing: list[str]) -> str:
listed = ", ".join(known_sections(missing)) or "the completion sections"
shape = reply_shapes.SHAPES["completion"]
return (
f"This turn closed a Scribe task, and the reply that ends it is missing: {listed}. "
f"This turn closed a Grimoire task, and the reply that ends it is missing: {listed}. "
"The person the work is for reads this reply to find out where it stands. Rewrite it "
f"as a completion report — {shape.reminder} Take where it sits from `placement`, the "
"task or plan by id and title (`list_reply_shapes` has the shape in full)."
+1 -1
View File
@@ -1192,7 +1192,7 @@ async def _reserve_note_slot(
"""Guarantee `slot.kinds` one line, if one clears the arm's own bar.
WHY A SLOT AT ALL. Ranking by raw cosine is blind to what KIND of record
answers what kind of ask, and the corpus makes that fatal: Scribe's
answers what kind of ask, and the corpus makes that fatal: Grimoire's
project records are about software work, so a task about building a
helper outranks the snippet that IS one. Snippets can be ~0.5% of a
corpus, and no floor fixes 200:1. A
+1 -1
View File
@@ -25,7 +25,7 @@ WHY THE DEFAULTS BELOW ARE STARTING POINTS AND NOT ANSWERS
A cosine score is a distance in BAAI/bge-small-en-v1.5's vector space, measured
against one corpus. It cannot transfer to an install with different records,
and a shipped default cannot be defended from any one install's telemetry.
Scribe ships a starting point and the means to correct it, so the values carry
Grimoire ships a starting point and the means to correct it, so the values carry
the measurement that motivated them without claiming to be right for anybody
else.
+1 -1
View File
@@ -90,7 +90,7 @@ _reported = False
# redaction nobody made; eating a query costs the ability to tune the bar.
_SECRET_PATTERNS: tuple[tuple[str, "re.Pattern[str]"], ...] = (
# Vendor-prefixed credentials. The prefix IS the tell, so no entropy
# guessing is needed — `fmcp_` is Scribe's own API key format.
# guessing is needed — `fmcp_` is Grimoire's own API key format.
("token", re.compile(
r"\b(?:fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|gitlab-ci-token:"
r"|xox[abprs]-|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
+2 -2
View File
@@ -856,7 +856,7 @@ async def get_rule_version(rule_id: int, version_id: int, user_id: int):
)).scalar_one_or_none()
# ── Drift: what Scribe changed about how it works with you ─────────────
# ── Drift: what Grimoire changed about how it works with you ─────────────
#
# A preference is the one record kind the AGENT rewrites in the ordinary
# course of working, which is the property that makes it useful and the
@@ -908,7 +908,7 @@ async def recent_preference_drift(user_id: int, limit: int = 20) -> list[dict]:
"""Preferences that have been rewritten, most recently changed first.
ONE ROW PER PREFERENCE, carrying its LATEST rewrite — not every version of
every preference. The question this answers is "what has Scribe changed
every preference. The question this answers is "what has Grimoire changed
about how it works with me lately", and a preference rewritten eight times
this week is one answer to that, not eight. The full history of any one
preference is still a click away in its editor, which is where "how did
+1 -1
View File
@@ -1,4 +1,4 @@
"""Sealing a credential Scribe stores so the database alone cannot read it.
"""Sealing a credential Grimoire stores so the database alone cannot read it.
Fernet (AES-128-CBC with an HMAC) under a key derived from the instance's
SECRET_KEY. A copy of the database, or of a backup made from it, then holds
+3 -3
View File
@@ -378,7 +378,7 @@ _DATA_FIELDS = (
# wrong" — which is worse than having no record, because it is surfaced with the
# same authority either way.
#
# WHERE THE CHECK RUNS. Not here. Scribe has no checkout of the operator's repos
# WHERE THE CHECK RUNS. Not here. Grimoire has no checkout of the operator's repos
# and must not acquire one (the instance stays agnostic about where code
# lives; giving the server repo access would make every install a credential
# problem). The agent already has the working tree, so IT does the
@@ -989,7 +989,7 @@ async def record_verification(
):
"""Record the result of a drift check against the snippet's source.
The CHECK happens agent-side — Scribe has no checkout and shouldn't want one
The CHECK happens agent-side — Grimoire has no checkout and shouldn't want one
(see the drift-check note above). This just remembers the verdict, stamped
with a hash of the code it was checked against so it expires by itself when
the snippet is edited.
@@ -1173,7 +1173,7 @@ async def attach_live_body(note, data: dict) -> None:
data["body_source"] = "cache"
data["body_freshness"] = "no-recorded-location"
return
# Recorded location repos are free-form names ("Scribe"), which can't
# Recorded location repos are free-form names ("Grimoire"), which can't
# address a forge API — the project's repo BINDING is the identity that
# can. Try the location string first (it may be a real remote),
# then fall back to the bindings of the snippet's project.
+1 -1
View File
@@ -209,7 +209,7 @@ def render_claims(
lines += [
"",
"## In flight — the work this session had claimed",
"Scribe's record of what you were doing before the context was "
"Grimoire's record of what you were doing before the context was "
"lost. Carry on from here; the full log is `get_task(id)`.",
]
for c in ours[:_OWN_CAP]: